Skip to content

ci(guard): fail when a non-bundled @claude-flow/* leaf changes without a publish - #3391

Merged
ruvnet merged 2 commits into
mainfrom
guard/leaf-package-publish
Sep 26, 2026
Merged

ruvnet merged 2 commits into
mainfrom
guard/leaf-package-publish

Conversation

@ruvnet

@ruvnet ruvnet commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Why

Only four @claude-flow/* components are bundled into the release tarballs — INTERNAL_RUNTIME_PACKAGES in stage-internal-runtime-bundles.mjs: security, codex, mcp, plugin-agent-federation. Everything else the CLI depends on — cli-core, neural, shared, memory — resolves from the registry at whatever v3/@claude-flow/cli/package.json pins.

So a merged source change in one of those does not ship with the three-package train. The release silently carries the last published copy of that leaf.

This has bitten twice:

What makes it easy to miss: CI goes green. The leaf's own package tests run against workspace source, so the change looks verified while being unshippable.

What the guard asserts

  1. Every non-bundled leaf's workspace version is covered by the CLI's declared range. A miss means the release resolves to older code than this repo's source.
  2. (diff mode) If a leaf's publishable source changed vs the base ref but its version did not — fail, with remediation.

Design choices that matter:

  • Imports INTERNAL_RUNTIME_PACKAGES instead of restating it, so the bundled/non-bundled sets cannot drift apart. A hardcoded copy would go stale the moment someone adds a package to the staging script — and then wave through the exact bug this exists to catch.
  • Exits non-zero if it inspects zero leaves, so a broken classifier can't report clean.
  • A skipped diff check reports NOT EXERCISED, never a silent pass.
  • Test/fixture/docs changes in a leaf don't trip it — they can't alter what consumers receive.

Accepted finding

@claude-flow/shared is entered as a time-boxed waiver (expires 2026-10-21): the CLI pins 3.0.0-alpha.7 while the workspace is 3.0.0-alpha.8, which is published — stale, not unshippable. Raising the pin edits v3/@claude-flow/cli deps and therefore forces a pnpm-lock.yaml regen in the same change (#2540 → #2552 — frozen-lockfile otherwise fails ~25 jobs), so it wants its own PR. An expired entry fails the guard rather than muting it.

Negative controls

A guard that cannot fail is worthless, so all four paths were exercised:

scenario expected result
memory source changed, no version bump FAIL ✅ FAIL — replays #3390
same change with a version bump pass ✅ pass
test-only change in a leaf pass ✅ pass
waiver expiry backdated to 2020 FAIL ✅ FAIL — "no longer honoured"

🤖 Generated with Claude Code

ruvnet and others added 2 commits September 21, 2026 16:49
…t a publish

Adds scripts/audit-leaf-package-publish.mjs, wired into v3-ci.yml's existing
audit job (semver is already installed there).

Only four @claude-flow/* components are bundled into the release tarballs —
INTERNAL_RUNTIME_PACKAGES in stage-internal-runtime-bundles.mjs: security,
codex, mcp, plugin-agent-federation. cli-core, neural, shared and memory
resolve from the REGISTRY at whatever v3/@claude-flow/cli/package.json pins, so
a merged source change in one of those does NOT ship with the three-package
train: the release silently carries the last published copy.

This has bitten twice. #3334 fixed RUFLO_INTELLIGENCE_MODE in
@claude-flow/memory; v3.42.1 shipped the train only and a fresh consumer
install had zero references to the fix (#3335). #3390's reasoningBank
activation spanned cli/src/memory/memory-bridge.ts AND
memory/src/controller-registry.ts — publishing only the train would have
shipped the bridge half and left the embedder half unreachable, leaving
reasoningBank exactly as inert as before. It was caught by hand and
memory@3.0.0-alpha.25 went out alongside v3.42.5.

What makes it easy to miss is that CI goes GREEN: the leaf's own package tests
run against workspace source, so the change looks verified while being
unshippable.

The audit asserts:
  1. every non-bundled leaf's workspace version is covered by the CLI's
     declared range (a miss means the release resolves to OLDER code than
     this repo's source);
  2. diff mode — if a leaf's publishable source changed vs the base ref but
     its version did not, fail with remediation.

It imports INTERNAL_RUNTIME_PACKAGES rather than restating it, so the bundled
and non-bundled sets cannot drift apart; exits non-zero if it inspects zero
leaves, so a broken classifier can't report clean; and reports a skipped diff
check as NOT EXERCISED rather than as a pass.

@claude-flow/shared is entered as a time-boxed accepted finding (expires
2026-10-21): the CLI pins 3.0.0-alpha.7 while the workspace is at
3.0.0-alpha.8, which IS published — stale, not unshippable. Raising the pin
edits v3/@claude-flow/cli deps and so forces a pnpm-lock.yaml regen in the same
change (#2540 -> #2552), which wants its own PR. An EXPIRED entry fails the
guard rather than muting it.

Negative-controlled, all four paths:
  - memory source changed, no bump      -> FAIL (replays #3390)
  - same change with a version bump     -> pass
  - test-only change in a leaf          -> pass (not publishable)
  - waiver expiry backdated             -> FAIL, "no longer honoured"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard's first real CI run passed but reported:

  NOTE: diff check NOT EXERCISED — could not diff against 'origin/main' (shallow clone?)

So only the range check ran; check 2 — the half that replays #3335/#3390 by
catching a leaf source change with no version bump — sat out entirely. The
NOT EXERCISED note is what made that visible instead of reading as a pass,
which is precisely why a skipped check must never print like a clean one.

Two causes, both fixed:

  - `git fetch --depth=1 origin $GITHUB_BASE_REF` fetches the base tip with no
    shared history, so `base...HEAD` dies with "fatal: no merge base". Now
    fetches --depth=50 with an explicit
    +refs/heads/<base>:refs/remotes/origin/<base> refspec.
  - The script only attempted three-dot. It now falls back to two-dot, which
    needs no common ancestor, and reports which mode actually ran.

Verified against the exact failing condition — an orphan branch with no merge
base against origin/main, where `git diff origin/main...HEAD` returns
"fatal: no merge base": the guard now reports "diff check ran (two-dot ...)"
rather than skipping. NC1 re-confirmed: a leaf source change without a version
bump still fails, now accompanied by "diff check ran (three-dot ...)" proving
the check executed rather than being skipped into a pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ruvnet
ruvnet merged commit 4a6e063 into main Sep 26, 2026
107 of 108 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant