agentleFS
sirkirbysirkirby/unifi-network-mcp/.agents/skills/monorepo-release-pipeline/SKILL.md

myco:monorepo-release-pipeline

Skill979 starsChanged 19 days agoMITView on GitHub

Covers the full release pipeline for the unifi-mcp monorepo: determining release scope by analyzing changed packages, scoping hatch-vcs version tag globs per Python package to prevent sibling-tag contamination, batching independent release tags while preserving real dependency edges, releasing plugins (independent versions, client update paths, writebacks), configuring scripts/generate_release_notes.py path scoping per package, wiring per-package publish workflows for OIDC trusted publishing, coordinating cross-package version bumps in pyproject.toml, understanding app vs. library versioning and writeback behavior, validating releases post-tag, and verifying shared package architecture constraints (DI-only rule, scope gate, relay sync) before releasing shared packages. The single authoritative procedure for releasing anything in this repo, plugins included. Apply when cutting any release, changing shipped plugin files, bumping unifi-core, or debugging a versioning failure. CRITICAL: All PRs require pin-alignment CI gate before merge (automated, cannot be skipped).

  • Deletes or force-pushes
  • Installs packages
  • Commits and pushes

We split this skill into the rules it gives an agent

42 rules. About 0 are common, 20 are distinctive, and 22 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈20

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 22

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    All feature PRs for the release are merged to main.

    line 35
  2. Very specific:

    When adding a new package, extend this table, update the release-notes path configuration, and add a new release-<package>.yml workflow …

    line 64
  3. Distinctive:

    Before pushing any tag, identify exactly which packages changed.

    line 71
  4. Distinctive:

    After a PR is merged but before tagging, confirm which files actually landed:

    line 87
  5. Distinctive:

    Decision rule: Default to Minimal. Escalate to Standard when a shared library change requires every dependent to ship a new wheel.

    line 118
  6. Distinctive:

    When releasing multiple packages in one coordination, avoid bumping major versions for multiple packages simultaneously.

    line 146
  7. Distinctive:

    When to break the rule: Only when breaking API changes are required in both packages with no feasible bridge, all downstream maintainers …

    line 152
  8. Very specific:

    Use dynamic = ["version"] with hatch-vcs.

    line 160
  9. Distinctive:

    Rule: Never expect writeback from a library package tag (core, shared, relay, api).

    line 173
  10. Distinctive:

    Use patch for bug fixes and backwards-compatible internal changes.

    line 177
  11. Very specific:

    Every built wheel declares the advisory-safe dependency floors, the Network and API wheels exactly pin aiounifi / aiohttp consistently, and …

    line 211
  12. Distinctive:

    Transitive chains: Only each package's own declared bounds are validated.

    line 222
  13. Very specific:

    Before creating release tags, inspect every downstream pyproject.toml for packages being released together.

    line 228
  14. Very specific:

    Update downstream dependency ranges in apps/*/pyproject.toml and packages/unifi-mcp-relay/pyproject.toml.

    line 233
  15. Very specific:

    Verify with wheel-metadata check (see below).

    line 234
  16. Distinctive:

    Commit dependency-bound changes before creating local tags.

    line 235
  17. Very specific:

    Keep version out of marketplace entries.

    line 331
  18. Very specific:

    Keep the server pin identical in the inline Claude mcpServers of .claude-plugin/plugin.json and in .mcp.codex.json.

    line 342
  19. Very specific:

    Never ship a root .mcp.json: OpenClaw would merge it into the server it runs.

    line 344
  20. Very specific:

    Run make pre-commit (both plugin checks included).

    line 365
  21. Very specific:

    Add a writeback exclusion between Worker and any tag namespace configured in bump-plugin-versions.yml.

    line 387
  22. Distinctive:

    Put every tag whose incoming edges are already satisfied into the same batch.

    line 393
  23. Very specific:

    After a batch containing at least one namespace configured under bump-plugin-versions.yml's on.push.tags:

    line 432
  24. Very specific:

    Wait for the last bump-plugin-versions.yml run to complete.

    line 435
  25. Distinctive:

    Start another batch from a writeback commit only when one was created and a later tag actually consumes those committed manifests or a new …

    line 443
  26. Distinctive:

    All must exit 0 with zero failed/exception records before the first tag in the release is pushed — this is a gate on Procedure F, not just …

    line 478
  27. Distinctive:

    After pushing a release batch:

    line 482
  28. Very specific:

    Verify the final manifest sync when triggered: If the batch contains a tag namespace listed in bump-plugin-versions.yml, confirm its last …

    line 486
  29. Distinctive:

    All three must exit 0 with zero failed/exception records.

    line 505
  30. Distinctive:

    Post-publish installed-wheel verification: After PyPI confirms the new versions, use the clean environment from step 5 to verify every …

    line 507
  31. Very specific:

    Strategy B (Proactive): Don't wait for Dependabot PRs to accumulate.

    line 532
  32. Distinctive:

    pyproject floor vs. lockfile drift: After batching, verify each updated package's lower bound still permits the resolved version.

    line 536
  33. Very specific:

    Before releasing packages/unifi-mcp-shared/ or packages/unifi-core/, verify these three structural invariants were maintained during …

    line 544
  34. Very specific:

    Every entrypoint in packages/unifi-mcp-shared/ must accept app-specific behavior as injected parameters.

    line 548
  35. Distinctive:

    Verify before releasing any shared-package change:

    line 550
  36. Very specific:

    Import direction must remain strictly left-to-right: app servers → unifi-mcp-shared → unifi-core.

    line 564
  37. Distinctive:

    Before any shared-package protocol release:

    line 575
  38. Very specific:

    Sibling tag contamination: If git_describe_command --match is too broad (e.g., v*), hatch-vcs picks up a sibling package's tag and reports …

    line 586
  39. Very specific:

    Silent version freeze: If a tag is missing, hatch-vcs falls back to fallback_version = "0.0.0".

    line 590
  40. Very specific:

    Missing tag causes broken downstream install: If unifi-core code is merged but the tag is never pushed, downstream packages requesting that …

    line 592
  41. Very specific:

    Backwards-coupling enforcement: Import direction must stay: app servers → unifi-mcp-shared → unifi-core.

    line 612
  42. Distinctive:

    Diagnostics direct-import pattern: When debugging shared-package behavior from an app server, write diagnostic logic in the app server's …

    line 616

≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Monorepo Release Pipeline

The unifi-mcp repo ships seven independently versioned Python packages plus the Node/TypeScript worker app: unifi-core and unifi-mcp-shared live under packages/; unifi-network-mcp, unifi-protect-mcp, unifi-access-mcp, and unifi-api-server live under apps/; unifi-mcp-relay lives under packages/ alongside core and shared; unifi-mcp-worker lives under apps/worker/ and publishes to npm. Each has its own package identity, tag namespace, publish workflow, and release-notes scope. Getting the release sequence wrong leaves downstream packages referencing non-existent PyPI versions or produces contaminated release notes that bleed across package boundaries.

Prerequisites

  • All feature PRs for the release are merged to main.
  • PRs that change any package's pyproject.toml or src/ must pass the pin-alignment CI gate (automated blocker). The gate builds the workspace wheels and installs them the way a user would, so a downstream floor that cannot work on PyPI fails before release. See Procedure D, Step 0 for exactly what it covers and what it cannot see.
  • Working tree is clean: git status shows nothing staged or modified.
  • Remote is current: git fetch origin && git log origin/main..HEAD shows nothing.
  • PyPI credentials are not stored locally — publishing is handled entirely by GitHub Actions OIDC trusted publishing (no TWINE_PASSWORD, no PYPI_TOKEN).
  • Decide which packages are changing and their new versions before pushing any tag.

Package Map

PackageDirectoryPyPI NameTag namespace
unifi-corepackages/unifi-core/unifi-corecore/v*
unifi-mcp-sharedpackages/unifi-mcp-shared/unifi-mcp-sharedshared/v*
unifi-mcp-networkapps/network/unifi-network-mcpnetwork/v*
unifi-mcp-protectapps/protect/unifi-protect-mcpprotect/v*
unifi-mcp-accessapps/access/unifi-access-mcpaccess/v*
unifi-api-serverapps/api/unifi-api-serverapi/v*
unifi-mcp-relaypackages/unifi-mcp-relay/unifi-mcp-relayrelay/v*
unifi-mcp-workerapps/worker/unifi-mcp-worker (npm, unscoped)worker/v*

Critical: PyPI package names differ from directory names. Always reference the PyPI name when installing or checking versions. Example: pip install unifi-network-mcp (not unifi-mcp-network). The worker's npm package name is also unscoped: npm install -g unifi-mcp-worker (not @unifi-mcp/worker) — confirmed by apps/worker/src/commands/upgrade.mjs's npm view unifi-mcp-worker version call.

When adding a new package, extend this table, update the release-notes path configuration, and add a new release-<package>.yml workflow before pushing any tag.


Procedure A: Determine Release Scope

Before pushing any tag, identify exactly which packages changed. This decision gates everything downstream — wrong scope means tagging unnecessary packages, causing spurious releases, or forgetting a tag and leaving a gap in PyPI versions.

List changes per package

For each package candidate, check whether code actually changed since the last tag:

# Check packages/unifi-core/:
git log --oneline core/v$(git tag -l 'core/v*' | sort -V | tail -1)..HEAD -- packages/unifi-core/

# Check apps/network/:
git log --oneline network/v$(git tag -l 'network/v*' | sort -V | tail -1)..HEAD -- apps/network/ packages/unifi-mcp-shared/

Post-merge scope verification

After a PR is merged but before tagging, confirm which files actually landed:

# Replace <pre-merge-sha> with the commit SHA immediately before the merge commit
git diff <pre-merge-sha>..HEAD --name-only

This is the most reliable way to scope which packages need a release — PR descriptions can lag or overstate changes.

Apply scope rules

What changedTags required
packages/unifi-mcp-shared/ onlyAlways shared/v*; add affected downstream tags under the selectivity framework, then use Procedure F for batch boundaries
packages/unifi-core/ onlyScope includes core/v* and affected downstream packages, including api/v*; Procedure F decides batch boundaries
One app only (e.g., apps/protect/)protect/v* only
Multiple appsOne tag per changed app; put independent tags in the same release batch
Plugin-only changes (any shipped file)Independent plugin manifest bump; no package tag (Procedure E)
Worker (apps/worker/)worker/v*

Negative corollary (skip-unchanged rule): Do NOT tag a package simply because an upstream dependency was bumped, if the package's own code did not change AND its existing pyproject.toml bounds already accommodate the new upstream version. Check the declared version range in pyproject.toml first — if the new upstream version already satisfies the existing bounds and the app code is unchanged, no new tag is required.

Tagging Selectivity Framework

Minimal (lowest risk): Tag ONLY packages with code changes in their own directory.

Standard (recommended): Tag all packages with code changes PLUS all downstream dependents to ensure they pick up shared library bumps. Use for any bump to unifi-core or unifi-mcp-shared.

Full (maximum redundancy): Tag all seven packages every release. Use for scheduled releases or recovering from a prior broken release sequence.

Decision rule: Default to Minimal. Escalate to Standard when a shared library change requires every dependent to ship a new wheel. Use Full when recovering from a release failure.

Plugin releases

Plugins release by merge, not by tag. Follow Procedure E.

Shared package rule

Any change to packages/unifi-mcp-shared/ requires a Shared release. Add a downstream app or Relay tag when its own code changed, it needs the new Shared API or metadata floor, or the release plan explicitly selects Standard or Full coverage. Under the default Minimal strategy, unchanged dependents whose existing bounds accept the new Shared version do not need tags. Procedure F decides batch boundaries after scope is settled.

CVE / transitive dependency changes

Trace the dependency chain before tagging everything:

grep -ri "pillow" apps/*/pyproject.toml packages/*/pyproject.toml

Tag only the affected app(s). Unnecessary tags create spurious releases.


Procedure A.5: Patch-Bump Lockstep Avoidance

When releasing multiple packages in one coordination, avoid bumping major versions for multiple packages simultaneously. This prevents accidental lockstep coupling.

Strategy: When releasing shared with core, give core the larger bump (minor/major as needed) while keeping shared's bump narrow (patch-only, staying within its current minor). Defer shared's next minor bump to a future cycle. Downstream apps can adopt the new core version while keeping the prior shared minor pinned, then adopt the new shared minor independently later.

Verify current floor bounds before citing an example — they shift every release. Re-check the live dependency line with grep unifi-core packages/unifi-mcp-shared/pyproject.toml rather than trusting a hardcoded version string from this skill.

When to break the rule: Only when breaking API changes are required in both packages with no feasible bridge, all downstream maintainers are coordinated, and you can support a temporary dual-version install guide.


Procedure B: App vs. Library Versioning and Writeback Behavior

Library packages (unifi-core, unifi-mcp-shared, relay, api)

Use dynamic = ["version"] with hatch-vcs. Version is derived from the git tag at build time — no _version.py is ever committed. When a library tag is pushed, bump-plugin-versions.yml outputs No version changes to commit — this is correct and expected.

unifi-api-server is a library package, not an app package. It publishes to PyPI only and has no plugin manifest assets. Expect no writeback on api/v* tags.

App packages (network, protect, access)

These have writable manifest assets updated on tag:

  • plugins/unifi-network/.claude-plugin/plugin.json
  • plugins/unifi-protect/.claude-plugin/plugin.json
  • Corresponding .codex-plugin/plugin.json and .mcp.codex.json
  • plugins/unifi-access/.claude-plugin/plugin.json
  • apps/*/server.json (server package version)

Rule: Never expect writeback from a library package tag (core, shared, relay, api). Never accept a missing writeback from an app package tag (network, protect, access).

Version bump selection

Use patch for bug fixes and backwards-compatible internal changes. Use minor when adding new functionality that is additive but could affect downstream consumers — even if technically backwards-compatible, a minor bump signals to dependents that review is warranted. Use major for breaking API changes. Dependency bound updates for upstream packages must land on main (and pass CI) before pushing the corresponding version tags.


Procedure C: hatch-vcs Tag Glob Scoping

Without a per-package git_describe_command --match glob, any tag reachable in the repo can influence any package's version.

PackageMatch pattern
networknetwork/v*
protectprotect/v*
accessaccess/v*
relayrelay/v*
unifi-corecore/v*
unifi-mcp-sharedshared/v*
apiapi/v*

Each package must declare both tag_regex and git_describe_command scoped to its own prefix. Never share or widen the --match pattern across packages.


Procedure D: Pin-Alignment CI Gate + Align Cross-Package Dependency Bounds Before Tagging

Step 0: Pin-Alignment CI Gate (Automated Blocker)

The pin-alignment CI gate (scripts/check_pin_alignment.py, workflow check-pin-alignment.yml) runs on a PR that touches any of: a package's pyproject.toml, anything under apps/*/src/** or packages/*/src/** (core, shared, relay and every app), the API's force-included alembic files, the gate script, the workflow, or scripts/generate_api_action_catalog.py. If your PR fails: read the CI output for the failing package and module, update the offending bounds in pyproject.toml (or split the change, see the floor-bump sequencing gotcha), commit and push.

What the gate proves, against PyPI plus the workspace-built upstream wheels:

  1. Every built wheel declares the advisory-safe dependency floors, the Network and API wheels exactly pin aiounifi / aiohttp consistently, and the prior vulnerable resolutions upgrade to safe versions.
  2. Each downstream wheel installs into a clean venv, with the declared ranges resolved from PyPI. Then every module the wheel ships is imported. The module list comes from the installed distribution's file list, so directories without an __init__.py (Network's tools/ and utils/) are included. Any ImportError fails the gate: an undeclared dependency (ModuleNotFoundError) and a symbol the resolved upstream does not provide (cannot import name). Other import-time exceptions are ignored.
  3. Each downstream wheel is installed again against exactly its declared minimum unifi-core (and, for Protect, Access and Relay, its declared minimum unifi-mcp-shared). The same full module import runs against those floors, so a symbol imported from a newer upstream than the floor admits fails even though the latest upstream has it.
  4. An AST scan of the installed downstream source resolves every attribute reached through an unifi_core / unifi_mcp_shared import (import unifi_core.x as x; x.new_helper, from unifi_core.x import Cls; Cls.new_method) against those floor versions. This covers use that only fails when the line runs.
  5. Network and API additionally run manager-call contracts against the Core floor: every direct <name>_manager.<method> call in the Network tool modules, and every API catalog binding, must exist with a compatible signature.

What the gate cannot see (verify these by hand):

  • Calls on objects the scan cannot resolve statically. Attribute access on instances, parameters, return values, or any name that is rebound in the same file (client = Cls(); client.new_method()) is skipped. Network and API are covered for manager calls by their contracts; Protect, Access and Relay have no equivalent, so a new Core method called on a manager instance without a floor bump passes. Review any PR that starts calling new upstream methods against the upstream's changelog.
  • Behavior changes with the same API shape. A floor is checked for symbol existence and signatures, not semantics.
  • The Shared floor of Network and API. Network is installed with the branch-built Shared candidate (the release being cut), and the API wheel declares no direct Shared dependency, so neither is tested against its minimum published Shared.
  • Transitive chains. Only each package's own declared bounds are validated; see the coverage limit below.
  • Imports that do not run at import time. An import inside a function body never runs when the module is imported, and an import under try/except ImportError has its failure swallowed; the attribute scan resolves neither. A symbol absent from the floor that is only imported that way passes.
  • Import-time exceptions other than ImportError (for example a module that needs a live controller at import) are ignored, so that module's imports after the failure point go unchecked.

Align Cross-Package Dependency Bounds Before Tagging

Before creating release tags, inspect every downstream pyproject.toml for packages being released together. Tag order only solves publication timing; the wheel metadata must also allow the newly published upstream version.

Dependency-bound checklist:

  1. Identify upstream packages that changed (unifi-core, unifi-mcp-shared).
  2. Identify downstream packages being tagged because they use that upstream code.
  3. Update downstream dependency ranges in apps/*/pyproject.toml and packages/unifi-mcp-relay/pyproject.toml.
  4. Verify with wheel-metadata check (see below). uv lock --check is insufficient — see below.
  5. Commit dependency-bound changes before creating local tags.

Why uv lock --check is not a sufficient gate

Workspace [tool.uv.sources] overrides take precedence over the version range during uv sync/uv lock — every CI job passes cleanly even when the published wheel's requires_dist will reject the just-released upstream package. The pin only fails when pip/uv resolves the published wheel against PyPI on a user's machine. Docker images also bypass the failure. This is a PyPI-only failure mode.

check_pin_alignment.py Coverage Limit

scripts/check_pin_alignment.py validates the direct dependency bounds declared in each package's own pyproject.toml — it does not walk the full transitive/nested dependency chain (e.g., relay → shared → core, multiple hops deep). A downstream package can pass the gate while a deeper transitive package in the chain is still misaligned. When releasing a change that touches a multi-hop dependency chain, manually trace and verify bounds at each hop rather than trusting a single green run of this script.

Running one floor check locally: --network-core-floor-only and --api-core-floor-only

The full gate takes several minutes because it builds every wheel and builds a venv per downstream. When a PR changes only how Network or the API uses upstream code, run just that wheel's floor check:

uv run scripts/check_pin_alignment.py --network-core-floor-only
uv run scripts/check_pin_alignment.py --api-core-floor-only

Each mode builds that one wheel, installs it against exactly its declared unifi-core minimum, and runs the manager-call contract, the full module import, and the upstream attribute scan described in Step 0. A failure means the code was written against an upstream API newer than the floor admits: either raise the floor (after the upstream version is on PyPI) or remove the use. Protect, Access and Relay have no single-package flag; they are checked by the full gate.

Pre-tag wheel-metadata check

rm -rf /tmp/wheelcheck && mkdir -p /tmp/wheelcheck
for app in apps/network apps/protect apps/access packages/unifi-mcp-relay; do
  echo "=== $app ==="
  uv build --wheel "$app" --out-dir /tmp/wheelcheck/$(basename $app) 2>&1 | tail -2
  whl=$(ls /tmp/wheelcheck/$(basename $app)/*.whl | tail -1)
  python -m zipfile -e "$whl" /tmp/wheelcheck/extracted/$(basename $app)/
  grep -h 'Requires-Dist.*\(unifi-mcp-shared\|unifi-core\)' \
    /tmp/wheelcheck/extracted/$(basename $app)/*.dist-info/METADATA
done

Procedure E: Plugin Releases

A plugin release is a plugin version bump landing on main. There is no publish step: every client reads the default branch unless the user pins a ref. Python packages and the Worker still release only by tag.

Triggers

  • Plugin-only release: a PR that bumps the plugin version merges to main.
  • Server release: a Network, Protect or Access tag runs bump-plugin-versions.yml, which moves the server pin and bumps the plugin in one writeback commit.

Clients

ClientReadsDetects an update byUsers update with
Claude CodeMarketplace on the default branch unless added as owner/repo#refManifest version; the cache is keyed by itclaude plugin marketplace update unifi-plugins, then claude plugin update <plugin>@unifi-plugins (or /plugin → Installed → Update now), then a new session: the SessionStart notice does not run on /reload-plugins. Auto-update is off by default for our marketplace; a user can turn it on under /plugin → Marketplaces, and it then installs releases in the background during a session and prompts for /reload-plugins, which also skips the notice.
Codex.agents/plugins/marketplace.json on remote HEAD unless added as owner/repo@refManifest version: Codex re-clones configured Git marketplaces at the start of every session (and on codex plugin marketplace upgrade) and reinstalls a plugin only on a version changeNothing: Codex runs the bundled server, which reads the per-user settings file setup writes (UNIFI_SETTINGS_FILE), so a pin bump starts in the session after the one that installs the release. A [mcp_servers.<plugin>] entry from setup before plugin 1.0.0 shadows the bundled server until the user runs setup with --refresh once
OpenClawgit:owner/repo[@ref] or our Claude-format marketplace; treats the bundle as a Codex bundle and runs the server in .mcp.codex.json, merging in a root .mcp.json if one ships (so none does)Commits, not versionopenclaw plugins update <id> or openclaw plugins update --all; it asks for capability consent, or needs --accept-capabilities without a terminal. The bundled server reads the per-user settings file setup writes, so the pin moves at the next agent turn, without a Gateway restart. An mcp.servers.<plugin> entry from setup before plugin 1.0.0 replaces the bundled server until the user runs setup with --target openclaw --refresh once
Hermes AgentCannot load the current layout (needs a root plugin.json, skills/ and mcp.json)CommitsNot supported

Sources: Claude Code host a marketplace, loading, install, marketplace reference; Codex plugins and openai/codex core-plugins/src/marketplace_upgrade.rs, loader.rs; OpenClaw install, update, bundles; Hermes Agent plugins.md and plugin-catalog.md (NousResearch/hermes-agent).

Rules

  • One release, one merge. Land every plugin change that makes up one release on main together, never as a string of separate merges. Commit-tracking clients (OpenClaw, Hermes) deliver every commit to their users.
  • Never ahead of the server. Land plugin content (skills, references, setup, MCP config) that depends on a server feature only when, or after, that server version is tagged and the pin moves; usually in or after the writeback.
  • Enforcement. make check-plugin-versions admits version bumps and pins; make check-plugin-tool-refs fails when a shipped plugin file names a tool absent from the release tag its product's plugin pins (unifi-intelligence: the product pins plus the latest released cloud worker). Both run in make pre-commit and in the generated-artifacts CI job. Never weaken either check to pass a PR; fix the plugin text or move the pin.
  • Counts and references follow the pin. Plugin skill tool counts and the generated references/*-tools.md come from the manifest of the release tag each plugin pins (git show <tag>:<manifest>), never the workspace: a server PR that adds a tool leaves them unchanged, and the plugin release that moves the pin (the writeback runs generate_skill_references.py) updates them. A new tool category needs its <!-- AUTO:tools:<category> --> marker section added in that release.

Versioning

  • Each plugin has an independent SemVer describing everything it ships, separate from the server pin. Set the same version in every existing Claude and Codex manifest (at least one). The skills-only unifi-intelligence bundle has both manifests, no MCP config and no pin, and names the MCP plugins it needs in the Claude manifest's dependencies; Codex and OpenClaw install no dependencies, so its descriptions say so. Keep version out of marketplace entries.
  • A renamed plugin is a new identity to every client, so it starts at 1.0.0. The guard checks a plugin with no files at the merge base as new and skips a removed one; say in the upgrade guide what users of the old name do.
  • Bump every affected plugin in the same PR whenever any file under plugins/<name>/ changes, including skills, references, setup scripts, MCP configs and docs. Version-only bumps are allowed.
  • Patch: plugin-only fixes or docs, and patch server pin moves. Minor: new skills/capabilities, and minor server pin moves. Major: breaking setup/configuration, and major server pin moves. Before 1.0, breaking changes advance minor and additive changes advance patch (SemVer 0.x).
  • Keep the server pin identical in the inline Claude mcpServers of .claude-plugin/plugin.json and in .mcp.codex.json; it must name a release tag. Never ship a root .mcp.json: OpenClaw would merge it into the server it runs.
  • Writeback: bump-plugin-versions.yml runs python3 scripts/plugin_versions.py sync --releasing <package-tag>. It moves the inline Claude pin and the .mcp.codex.json pin, bumps the plugin by the size of the server move, regenerates apps/<product>/server.json with the server version, and inserts one CHANGELOG.md entry naming every plugin it bumped and linking each server release. Retrying an already-synced pin does not bump again. Core, Shared and Relay triggers only coalesce outstanding product tags, otherwise they write nothing.
  • make check-plugin-versions runs scripts/plugin_versions.py check --base origin/main; PR CI uses the PR base SHA. Fetch base history and tags first; a missing base fails. Changed plugin versions must be valid SemVer, agree across manifests and exceed the merge-base version, and CHANGELOG.md needs an entry heading naming <plugin> <version> for each changed or new plugin. Pins must agree and name release tags; only the workflow's exact --releasing tag may stand in for a tag not yet discovered locally.

Release checklist

  1. Bump the version by the rules above and add a CHANGELOG.md entry below its marker: ## <YYYY-MM-DD UTC> — <plugin> <version>[, <plugin> <version>…], then the user-visible changes in plain language (what users notice, not how it was built). Repeat them in the PR description. The README describes what each plugin is; never put release notes there.
  2. Run make pre-commit (both plugin checks included).
  3. Merge as one squash merge.
  4. Tell users how to update, per the client table: Claude Code users update the marketplace and plugin themselves (unless they turned on auto-update), then start a new session; Codex installs the release and its pinned server at the next session start (users whose config.toml entry predates plugin 1.0.0 run setup with --refresh once); OpenClaw users run openclaw plugins update.
  5. From main, after the merge, run the real-client acceptance procedure in docs/plugin-acceptance.md.
  6. Roll back by reverting the merge, then bumping the version again above the bad release; the version guard requires the higher version and clients keyed by version need the change to reinstall.

Procedure F: Dependency-Aware Release Batching

Build a dependency and writeback graph before pushing tags. A package has a hard dependency edge on an upstream release when its wheel metadata must name the new upstream version, its code requires an API that exists only in that version, or its release workflow installs the new version from PyPI. Existing bounds that already accept the upstream release do not create an edge by themselves.

Add a writeback exclusion between Worker and any tag namespace configured in bump-plugin-versions.yml. The Worker release and plugin-version sync both checkout and push main, but they do not share a concurrency group or retry a non-fast-forward push. Keep Worker out of those batches. Wait for the plugin writeback to finish, fetch main, and then push Worker; an API-only tag may share the Worker batch because it does not start the plugin writeback.

Put every tag whose incoming edges are already satisfied into the same batch. Create the tags from the intended release commit, then push each tag in its own command back-to-back. Separate push commands reliably trigger every tag workflow; one multi-ref git push can omit triggers.

# Include only packages in this batch. Each variable is the reviewed version without the v prefix.
release_tags=(
  "core/v${CORE_VERSION:?Set CORE_VERSION}"
  "shared/v${SHARED_VERSION:?Set SHARED_VERSION}"
  "network/v${NETWORK_VERSION:?Set NETWORK_VERSION}"
  "protect/v${PROTECT_VERSION:?Set PROTECT_VERSION}"
  "access/v${ACCESS_VERSION:?Set ACCESS_VERSION}"
  "api/v${API_VERSION:?Set API_VERSION}"
  "relay/v${RELAY_VERSION:?Set RELAY_VERSION}"
  "worker/v${WORKER_VERSION:?Set WORKER_VERSION}"
)

# Each loop iteration is a separate push. Do not wait for its workflow before the next iteration.
for tag in "${release_tags[@]}"; do
  git tag "$tag" || exit 1
done

for tag in "${release_tags[@]}"; do
  git push origin "$tag" || exit 1
done

Monitor every workflow in the batch concurrently. Wait at a dependency boundary only: confirm the upstream workflow and PyPI artifact before pushing a later batch that consumes that artifact. If one workflow fails, let unrelated workflows finish, but hold every downstream batch that depends on the failure.

Consolidated plugin-manifest writebacks

bump-plugin-versions.yml reads the current release tags and synchronizes all Network, Protect, and Access server pins and independently bumps each changed plugin. Its concurrency policy coalesces a burst of tags: earlier sync runs may be cancelled, and the last run writes one commit containing every app version visible at that point. Cancelled superseded runs are expected; the final run must succeed.

After a batch containing at least one namespace configured under bump-plugin-versions.yml's on.push.tags:

  1. Wait for the last bump-plugin-versions.yml run to complete.
  2. For a batch with Network, Protect, or Access tags, fetch origin/main, inspect the consolidated writeback commit, and verify apps/<app>/server.json and both MCP configs contain each released server version; existing plugin manifests must agree on the independently bumped plugin version. Run make check-plugin-versions against the parent of the writeback commit.
  3. For a library-only trigger batch containing Core, Shared, or Relay, require the sync run to succeed but allow No version changes to commit. Inspect a commit only if the run found an outstanding app-version change.
  4. Start another batch from a writeback commit only when one was created and a later tag actually consumes those committed manifests or a new dependency pin. Independent tags can share the original release commit.

For example, direct security floors added independently to Shared, Network, Protect, Access, API, and Relay form one batch when no cross-package floor changes. A new Core API plus downstream code that requires it forms at least two batches: publish Core first, then publish the downstream wheels after Core is available and their reviewed metadata floors are correct.

Floor-bump sequencing gotcha: Open downstream pyproject.toml floor-bump PRs (raising the minimum version bound on an upstream package) only after the upstream tag is confirmed on PyPI. Committing the floor-bump PR before the upstream version exists on PyPI causes the pin-alignment CI gate on that PR to fail — the gate tries to resolve the declared lower bound but the version does not yet exist.

Floor-bump local staging: While waiting for PyPI to confirm the upstream version, stage the floor-bump work locally — create the branch, update downstream pyproject.toml version floors, and commit locally. Hold git push and PR creation until PyPI confirms the version exists. This decouples preparation from the PyPI propagation gate and eliminates idle waiting between confirmation and branch push.

Post-hoc branch-split at package boundary: If a shared/core change was developed inline inside a larger feature branch that also touches a downstream package (e.g., unifi-api-server), split the reviewed core change into its own clean branch and PR before merging the downstream work. Verify the core PR independently, squash-merge and tag it first, then continue the downstream package's PR on top of the merged core change. This preserves the dependency-ordered tag sequence above even when development didn't originally follow package boundaries.


Procedure G: generate_release_notes.py Path Configuration

Open scripts/generate_release_notes.py and locate APP_CONFIGS. Each entry is a PackageConfig with path_groups — a tuple of PathGroup objects that filter PRs to only those touching paths relevant to each package. Each entry should include the app directory, shared dependency directories (packages/unifi-core/, packages/unifi-mcp-shared/), and its own publish, test, and Docker build workflow paths.

Known limitation: scripts/generate_release_notes.py does not emit PR author information. Contributor credit must be added manually.


Procedure G.5: Pre-Tag Full Release-Wide Smoke Matrix Gate

Even when the merged diff touches only one package (e.g., a Core-only PR), do not tag from that PR's own targeted live validation alone. Run the full release-wide safe-phase smoke matrix across every live-validated surface (network, protect, access, api) before pushing any tag. A single package's isolated pass does not prove the other surfaces still function against the same rebased head — regressions in unrelated surfaces have shipped from Core-only changes that looked locally safe.

uv run python scripts/live_smoke.py --server network --phase safe
uv run python scripts/live_smoke.py --server protect --phase safe
uv run python scripts/live_smoke.py --server access --phase safe

All must exit 0 with zero failed/exception records before the first tag in the release is pushed — this is a gate on Procedure F, not just a post-tag check.

Procedure H: Release Validation

After pushing a release batch:

  1. Check CI concurrently: Capture every release workflow run and confirm all complete successfully. Do not serialize independent workflow watches.
  2. Verify the final manifest sync when triggered: If the batch contains a tag namespace listed in bump-plugin-versions.yml, confirm its last run succeeded. For Network, Protect, or Access tags, inspect the consolidated commit on main; for a Core-, Shared-, or Relay-only trigger, accept a successful No version changes to commit result. API-only and Worker-only batches skip this step because they do not trigger the workflow.
  3. Verify local tag versions: For each Python tag, change to its directory from the Package Map and run hatch version; it must print exactly the tagged version. Worker is not a Hatch project, so verify its version from the exact npm artifact in the next step.
  4. Confirm every registry artifact: Query PyPI for each exact Python version and run npm view unifi-mcp-worker@<version> version for a Worker tag.
  5. Install smoke test: Install compatible releases together in one clean environment when possible. Assert each installed distribution version, its import path under site-packages, and any security or cross-package metadata floor that motivated the release.
  6. Post-Release Live Smoke Verification (must run via uv, not system python3):
    uv run python scripts/live_smoke.py --server network --phase safe
    uv run python scripts/live_smoke.py --server protect --phase safe
    uv run python scripts/live_smoke.py --server access --phase safe
    All three must exit 0 with zero failed/exception records. This is the final release validation gate. Do not invoke with bare python3 scripts/live_smoke.py — the system Python lacks the workspace dependencies and the harness will fail at import time.
  7. Post-publish installed-wheel verification: After PyPI confirms the new versions, use the clean environment from step 5 to verify every released Python distribution. For released server apps, run live_smoke.py --server <server> --phase safe separately with that environment's Python executable, not uv run or --server all. Core, Shared, API, and Relay have no standalone live-smoke target, so their installed version and site-packages import-path assertions from step 5 are their packaging gate. Worker uses the exact npm-artifact check from step 4. This catches packaging defects that local workspace sources can hide.

Procedure I: Dependabot Dependency Update Management

Dependabot updates the lockfile one package at a time. When multiple PRs fail CI with the same class of lockfile error, merge into a single maintainer branch:

git checkout -b deps/batch-dependabot main
# Apply dependency changes, then re-lock from combined state:
uv lock
# Run per-package tests before opening the PR:
cd apps/network && uv run pytest && cd ../..
cd apps/protect && uv run pytest && cd ../..

Strategy A (Reactive): Batch accumulated Dependabot PRs into a single maintainer branch, run uv lock, merge the combined PR.

Strategy B (Proactive): Don't wait for Dependabot PRs to accumulate. Periodically run uv lock yourself and open a single maintainer PR before the Dependabot queue grows. This prevents cascading CI failures from multiple simultaneous lockfile-touching PRs.

Major-version bumps are invisible at the PR level — the CI failure looks identical to a minor version conflict. Always inspect the actual version change in the PR diff.

pyproject floor vs. lockfile drift: After batching, verify each updated package's lower bound still permits the resolved version. Run the wheel-metadata check from Procedure D before merging.

Second-wave cascading PRs gotcha: After a batch Dependabot PR merges, Dependabot may open a fresh wave of new PRs for versions newly unlocked by the combined resolution. This is expected — the first merge freed resolution space for the next tier. Budget time for a second round of batching review.


Procedure J: Shared Package Architecture Constraints

Before releasing packages/unifi-mcp-shared/ or packages/unifi-core/, verify these three structural invariants were maintained during development.

J-1: DI-Only Rule — No Reverse Imports in unifi-mcp-shared

Every entrypoint in packages/unifi-mcp-shared/ must accept app-specific behavior as injected parameters. Direct imports from unifi_network_mcp, unifi_protect_mcp, or unifi_access_mcp inside the shared package create a circular import that breaks all three app servers simultaneously at import time — blast radius is total.

Verify before releasing any shared-package change:

grep -r "from unifi_network_mcp\|from unifi_protect_mcp\|from unifi_access_mcp" packages/unifi-mcp-shared/

Any match is a release blocker. Replace with an injected parameter or a typed Protocol interface defined within packages/unifi-mcp-shared/.

J-2: Scope Gate — unifi-core vs. unifi-mcp-shared

Decision tree for placing new code:

  • Talks directly to UniFi hardware or manages HTTP sessions? → unifi-core
  • Coordinates MCP concerns (permissions, tool registration, confirmations)? → unifi-mcp-shared
  • References MCP types (FastMCP, tool decorators)? → unifi-mcp-shared
  • Would be useful in a non-MCP CLI calling the UniFi API? → unifi-core

Import direction must remain strictly left-to-right: app servers → unifi-mcp-shared → unifi-core. Verify:

grep -r "from unifi_mcp_shared\|import unifi_mcp_shared" packages/unifi-core/
grep -r "from unifi_network_mcp\|from unifi_protect_mcp\|from unifi_access_mcp" packages/unifi-mcp-shared/ packages/unifi-core/

Both commands must return nothing.

J-3: Relay Protocol Sync (Required Before Shared-Package Release)

The two relay files packages/unifi-mcp-relay/src/unifi_mcp_relay/discovery.py and packages/unifi-mcp-relay/src/unifi_mcp_relay/protocol.py implement relay protocol logic that does not import from unifi-mcp-shared. When shared-package protocol changes (new message format, endpoint path, header), these files receive no automatic update — there is no import error, failing test, or CI gate. Drift fails silently at runtime.

Before any shared-package protocol release:

  1. Identify what changed (message schema, endpoint path, header, error envelope shape).
  2. Open packages/unifi-mcp-relay/src/unifi_mcp_relay/discovery.py and packages/unifi-mcp-relay/src/unifi_mcp_relay/protocol.py and manually port the change.
  3. Trace the message path end-to-end through both to confirm they agree.

PR checklist trigger: any PR modifying shared-package protocol must include a "relay sync" section confirming both relay files were reviewed.


Cross-Cutting Gotchas

Sibling tag contamination. If git_describe_command --match is too broad (e.g., v*), hatch-vcs picks up a sibling package's tag and reports the wrong version. Fix: tighten the --match pattern and rebuild.

PR merge is NOT the release trigger — the tag push is. hatch-vcs reads git tags at build time. Merging a PR does NOT trigger a release. Always run Procedure H after tagging.

Silent version freeze. If a tag is missing, hatch-vcs falls back to fallback_version = "0.0.0". There is no error at merge time. Always run Procedure H after tagging.

Missing tag causes broken downstream install. If unifi-core code is merged but the tag is never pushed, downstream packages requesting that version fail to install. Check PyPI before debugging code.

Plugin updates need a version change. Claude Code and Codex install a merged plugin change only when its manifest version changes. Keep the bump in the same PR as the shipped-file change (Procedure E).

Malformed git tag — missing 'v' prefix. A tag like protect/0.4.2 (no 'v') is syntactically wrong and ignored by hatch-vcs. Validate before pushing: git tag -l | grep -E 'network|protect|access|relay|core|shared|api' | sort -V — every line must contain /v.

PyPI pin masked by workspace source — failure mode the entire CI matrix cannot detect. Workspace [tool.uv.sources] overrides mean uv lock --check passes with a stale pin. The pin only fails when pip resolves against PyPI on a user's machine. Run the wheel-metadata check in Procedure D before pushing tags.

One multi-ref push silently skips releases. git push origin tag1 tag2 tag3 can start only the first workflow. Push each ref separately in a tight loop, then monitor the independent workflows concurrently.

Broken published wheels: remediation via PyPI yank (PEP 592). Yank via the PyPI web UI — twine does not support the yank operation. Yanked versions are skipped during resolution but remain installable when explicitly pinned. Always follow a bulk yank with a corrected patch release.

PyPI package names differ from directory names. unifi-network-mcp ≠ unifi-mcp-network. See Package Map.

Cross-package combined pytest run causes test configuration collision. Running uv run pytest packages/ apps/ causes pytest to load conflicting conftest files. Run per-package: cd apps/network && uv run pytest. CI workflows are already scoped this way.

Shared-package import blast radius is total. A single from unifi_network_mcp import inside packages/unifi-mcp-shared/ breaks all three app servers simultaneously at import time. The ImportError traceback names the shared package, masking the root cause. Run the grep from Procedure J-1 before every shared-package release.

Relay protocol drift is silent. The relay implementation files (packages/unifi-mcp-relay/src/unifi_mcp_relay/discovery.py and packages/unifi-mcp-relay/src/unifi_mcp_relay/protocol.py) have no import from unifi-mcp-shared — protocol changes fail silently until a real client exercises the changed path. Manual review on every protocol-touching PR is the only protection (see Procedure J-3).

Backwards-coupling enforcement. Import direction must stay: app servers → unifi-mcp-shared → unifi-core. Direct imports from app packages inside shared or core break at import time but no test catches it until the import is exercised. Grep before committing.

Docker builds must COPY transitive workspace dependencies, not just direct ones. packages/unifi-mcp-relay/Dockerfile once copied only its direct dependency's source, and broke when unifi-core became a transitive dependency of unifi-mcp-shared and was never added to the COPY steps. Any Dockerfile for a package with workspace dependencies must COPY the full transitive dependency source tree, not just direct dependencies — verify after any change to the shared/core dependency graph.

Diagnostics direct-import pattern. When debugging shared-package behavior from an app server, write diagnostic logic in the app server's namespace — do not import shared internals directly for inline inspection. Direct imports of shared internals count for cycle-checking.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.