myco:monorepo-release-pipeline — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited myco:monorepo-release-pipeline (Agent Skill) and scored it 96/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 1 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.
main.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.
git status shows nothing staged or modified.git fetch origin && git log origin/main..HEAD shows nothing.GitHub Actions OIDC trusted publishing (no TWINE_PASSWORD, no PYPI_TOKEN).
| Package | Directory | PyPI Name | Tag namespace |
|---|---|---|---|
unifi-core | packages/unifi-core/ | unifi-core | core/v* |
unifi-mcp-shared | packages/unifi-mcp-shared/ | unifi-mcp-shared | shared/v* |
unifi-mcp-network | apps/network/ | unifi-network-mcp | network/v* |
unifi-mcp-protect | apps/protect/ | unifi-protect-mcp | protect/v* |
unifi-mcp-access | apps/access/ | unifi-access-mcp | access/v* |
unifi-api-server | apps/api/ | unifi-api-server | api/v* |
unifi-mcp-relay | packages/unifi-mcp-relay/ | unifi-mcp-relay | relay/v* |
unifi-mcp-worker | apps/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.
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.
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/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-onlyThis is the most reliable way to scope which packages need a release — PR descriptions can lag or overstate changes.
| What changed | Tags required |
|---|---|
packages/unifi-mcp-shared/ only | Always shared/v*; add affected downstream tags under the selectivity framework, then use Procedure F for batch boundaries |
packages/unifi-core/ only | Scope 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 apps | One 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.
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.
Plugins release by merge, not by tag. Follow Procedure E.
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.
Trace the dependency chain before tagging everything:
grep -ri "pillow" apps/*/pyproject.toml packages/*/pyproject.tomlTag only the affected app(s). Unnecessary tags create spurious releases.
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.
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.
These have writable manifest assets updated on tag:
plugins/unifi-network/.claude-plugin/plugin.jsonplugins/unifi-protect/.claude-plugin/plugin.json.codex-plugin/plugin.json and .mcp.codex.jsonplugins/unifi-access/.claude-plugin/plugin.jsonapps/*/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).
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.
Without a per-package git_describe_command --match glob, any tag reachable in the repo can influence any package's version.
| Package | Match pattern |
|---|---|
| network | network/v* |
| protect | protect/v* |
| access | access/v* |
| relay | relay/v* |
| unifi-core | core/v* |
| unifi-mcp-shared | shared/v* |
| api | api/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.
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:
aiounifi / aiohttp consistently, and the prior vulnerable resolutions upgrade to safe versions.__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.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.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.<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):
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.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.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:
unifi-core, unifi-mcp-shared).apps/*/pyproject.toml and packages/unifi-mcp-relay/pyproject.toml.uv lock --check is insufficient — see below.uv lock --check is not a sufficient gateWorkspace [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.
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.
--network-core-floor-only and --api-core-floor-onlyThe 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-onlyEach 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.
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
doneA 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.
main.bump-plugin-versions.yml,which moves the server pin and bumps the plugin in one writeback commit.
| Client | Reads | Detects an update by | Users update with |
|---|---|---|---|
| Claude Code | Marketplace on the default branch unless added as owner/repo#ref | Manifest version; the cache is keyed by it | claude 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@ref | Manifest 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 change | Nothing: 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 |
| OpenClaw | git: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 version | openclaw 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 Agent | Cannot load the current layout (needs a root plugin.json, skills/ and mcp.json) | Commits | Not 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).
main together, never as a string of separate merges. Commit-tracking clients (OpenClaw, Hermes) deliver every commit to their users.
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.
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.
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.
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.
1.0.0. The guardchecks 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.
plugins/<name>/changes, including skills, references, setup scripts, MCP configs and docs. Version-only bumps are allowed.
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).
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.
bump-plugin-versions.yml runspython3 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.
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.
make pre-commit (both plugin checks included).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.
main, after the merge, run the real-client acceptance procedure indocs/plugin-acceptance.md.
release; the version guard requires the higher version and clients keyed by version need the change to reinstall.
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
doneMonitor 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.
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:
bump-plugin-versions.yml run to complete.origin/main, inspect the consolidatedwriteback 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.
succeed but allow No version changes to commit. Inspect a commit only if the run found an outstanding app-version change.
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 downstreampyproject.tomlversion floors, and commit locally. Holdgit pushand 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.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.
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 safeAll 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.
After pushing a release batch:
successfully. Do not serialize independent workflow watches.
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.
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.
npm view unifi-mcp-worker@<version> version for a Worker tag.
possible. Assert each installed distribution version, its import path under site-packages, and any security or cross-package metadata floor that motivated the release.
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 safeAll 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.
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.
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.
Before releasing packages/unifi-mcp-shared/ or packages/unifi-core/, verify these three structural invariants were maintained during development.
unifi-mcp-sharedEvery 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/.
unifi-core vs. unifi-mcp-sharedDecision tree for placing new code:
unifi-coreunifi-mcp-sharedFastMCP, tool decorators)? → unifi-mcp-sharedunifi-coreImport 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.
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:
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.PR checklist trigger: any PR modifying shared-package protocol must include a "relay sync" section confirming both relay files were reviewed.
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.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.