ci(guard): fail when a non-bundled @claude-flow/* leaf changes without 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>
This commit is contained in:
ruvnetandClaude Opus 5 committed 2026-09-21 16:49:17 -04:00
1 parent 9c61c86f06
commit f02759cd6d
3 files changed
+257 -1

No files matched your search

+22
View File
@@ -1742,6 +1742,28 @@ jobs:
shell: bash
run: node scripts/audit-umbrella-version-lockstep.mjs
- name: Fetch PR base ref (for the leaf-publish diff check)
# The leaf-publish audit's diff check needs the base commit to compare
# against. actions/checkout is shallow by default, so without this the
# audit reports the diff check as NOT EXERCISED (it says so loudly
# rather than passing silently) and only the range check runs.
if: github.event_name == 'pull_request'
shell: bash
run: git fetch --no-tags --depth=1 origin "$GITHUB_BASE_REF"
- name: Run leaf-package publish audit (#3335, #3390)
# Only four @claude-flow/* components are bundled into the release
# tarballs (INTERNAL_RUNTIME_PACKAGES). cli-core, neural, shared and
# memory resolve from the REGISTRY, so a merged source change in one of
# them does NOT ship with the three-package train — the release carries
# the last published copy instead. #3334 lost a fix that way (#3335),
# and #3390's reasoningBank fix would have shipped only its cli half if
# @claude-flow/memory had not been published alongside v3.42.5. CI goes
# green on such a change because the leaf's own tests run against
# workspace source, which is exactly what makes it easy to miss.
shell: bash
run: node scripts/audit-leaf-package-publish.mjs
- name: Run better-sqlite3 override audit (#2219)
# agentdb pins better-sqlite3 as an OPTIONAL dep at ^11.8.1, which has
# no Node 24/25/26 prebuild → the optional native build fails silently
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env node
/**
* Static guard for ruvnet/ruflo#3335 and #3390 (#3327 Finding A).
*
* The release train is exactly three packages — `@claude-flow/cli`,
* `claude-flow`, `ruflo`. Four internal components ride along INSIDE those
* tarballs (`INTERNAL_RUNTIME_PACKAGES` in stage-internal-runtime-bundles.mjs:
* security, codex, mcp, plugin-agent-federation), so a source change in them
* ships with the train.
*
* Every OTHER `@claude-flow/*` dependency of the CLI — currently cli-core,
* neural, shared, memory — is resolved from the REGISTRY at the version
* `v3/@claude-flow/cli/package.json` declares. A merged source change in one of
* those does NOT ship with the train: the release silently carries the last
* *published* copy of that leaf.
*
* This has now bitten twice:
*
* #3335 (2026-09-16) — PR #3334 fixed RUFLO_INTELLIGENCE_MODE in
* @claude-flow/memory. v3.42.1 shipped cli/claude-flow/ruflo only; memory was
* last published 2026-08-22, so a fresh consumer install had ZERO references
* to the fix.
*
* #3390 (2026-09-21) — the reasoningBank activation fix 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 just as inert as before.
* Caught by hand; memory@3.0.0-alpha.25 was published alongside v3.42.5.
*
* The trap is that CI goes GREEN on such a change: the leaf's own package tests
* run against workspace source, so the fix looks verified while being unshippable.
*
* Asserts:
*
* 1. For every non-bundled `@claude-flow/*` dep of the CLI that exists as a
* workspace package, the CLI's declared range must be satisfied by the
* workspace's own version. A miss means the release resolves to something
* OLDER than the source in this repo.
*
* 2. (diff mode — needs a base ref) If a leaf's publishable source changed
* relative to the base ref but its package.json `version` did NOT, the
* release cannot ship that change. Bump the leaf and publish it.
*
* Diff mode is skipped, loudly, when no base ref is available — "not exercised"
* is reported distinctly from "clean" so a skip can never read as a pass.
*
* Usage: node scripts/audit-leaf-package-publish.mjs [--base <ref>]
* GITHUB_BASE_REF is used automatically in PR CI.
*
* Exit codes: 0 ok / 1 violations (or the guard inspected nothing).
*/
import { readFileSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import semver from 'semver';
import { INTERNAL_RUNTIME_PACKAGES } from './stage-internal-runtime-bundles.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..');
const CLI_PKG = 'v3/@claude-flow/cli/package.json';
// Files that actually end up in a published leaf tarball. A change to tests or
// fixtures cannot alter what consumers receive, so it must not trip the guard.
const PUBLISHABLE = /\/(src|bin|scripts)\/|\/package\.json$/;
const NOT_PUBLISHABLE = /(\.test\.|\.spec\.|__tests__\/|\/tests?\/|\/fixtures?\/|\.md$)/;
function readJson(rel) {
const p = join(REPO_ROOT, rel);
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null;
}
function git(args) {
return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8' }).trim();
}
// Time-boxed accepted findings. NOT a mute button: an expired entry is refused
// and fails the guard, so debt has to be revisited rather than quietly inherited.
const ACCEPTED = [
{
package: '@claude-flow/shared',
check: 'range-coverage',
// Pre-existing when this guard landed (2026-09-21). @claude-flow/shared@3.0.0-alpha.8
// IS published and is its `latest`, so the CLI is pinned one release behind a
// package that shipped — stale, not unshippable, and materially different from
// the #3335/#3390 failure this guard exists to catch. Raising the pin edits
// v3/@claude-flow/cli deps, which forces a pnpm-lock.yaml regen in the same
// change (#2540 -> #2552: frozen-lockfile otherwise fails ~25 jobs), so it wants
// its own PR rather than riding along with a guard.
reason: 'cli pins shared 3.0.0-alpha.7; workspace is 3.0.0-alpha.8 (published). Needs a pin bump + lockfile regen in its own PR.',
expires: '2026-10-21',
},
];
const today = new Date().toISOString().slice(0, 10);
function acceptedFor(pkg, check) {
const hit = ACCEPTED.find((a) => a.package === pkg && a.check === check);
if (!hit) return null;
if (hit.expires < today) return { ...hit, expired: true };
return hit;
}
const violations = [];
const notes = [];
// ── 0. resolve the bundled set from its single source of truth ───────────────
// Imported, never re-typed: a hardcoded copy here would silently go stale the
// moment someone adds a package to the staging script, and this guard would
// then wave through exactly the class of bug it exists to catch.
const bundled = new Set(INTERNAL_RUNTIME_PACKAGES.map((p) => p.name));
if (bundled.size === 0) {
console.error('✗ INTERNAL_RUNTIME_PACKAGES is empty — cannot classify deps. Guard aborted.');
process.exit(1);
}
// ── 1. enumerate the non-bundled leaves ──────────────────────────────────────
const cliPkg = readJson(CLI_PKG);
if (!cliPkg) {
console.error(`✗ ${CLI_PKG} not found — guard aborted.`);
process.exit(1);
}
const declared = { ...(cliPkg.dependencies ?? {}), ...(cliPkg.optionalDependencies ?? {}) };
const leaves = [];
for (const [name, range] of Object.entries(declared)) {
if (!name.startsWith('@claude-flow/')) continue;
if (bundled.has(name)) continue;
const dir = `v3/${name}`;
const pkg = readJson(`${dir}/package.json`);
if (!pkg) continue; // not a workspace package — nothing in this repo to ship
leaves.push({ name, range, dir, version: pkg.version });
}
console.log('leaf-package-publish audit — non-bundled @claude-flow/* deps of the CLI');
console.log(` bundled (ship with the train): ${[...bundled].join(', ')}`);
// A guard that inspects nothing must never report success.
if (leaves.length === 0) {
console.error('✗ inspected 0 non-bundled leaves — the classifier is broken, not the repo.');
console.error(' Refusing to report a clean result. Check CLI deps and INTERNAL_RUNTIME_PACKAGES.');
process.exit(1);
}
// ── 2. range must cover the workspace version ────────────────────────────────
for (const leaf of leaves) {
const covered = semver.satisfies(leaf.version, leaf.range, { includePrerelease: true });
console.log(` ${leaf.name.padEnd(36)} cli pins ${leaf.range.padEnd(22)} source ${leaf.version} ${covered ? '✓' : '✗'}`);
if (!covered) {
const waiver = acceptedFor(leaf.name, 'range-coverage');
if (waiver && !waiver.expired) {
notes.push(`ACCEPTED until ${waiver.expires} — ${leaf.name}: ${waiver.reason}`);
continue;
}
if (waiver?.expired) {
violations.push(
`${leaf.name}: accepted-finding EXPIRED ${waiver.expires} and is no longer honoured. ` +
`Resolve it or renew the entry with a fresh justification. (${waiver.reason})`
);
continue;
}
violations.push(
`${leaf.name}: the CLI pins "${leaf.range}", which does NOT cover the workspace version ` +
`${leaf.version}. The release would resolve to an OLDER published copy than the source ` +
`in this repo. Either publish ${leaf.name}@${leaf.version} and widen/raise the range in ` +
`${CLI_PKG}, or correct the workspace version.`
);
}
}
// ── 3. changed source must come with a version bump ──────────────────────────
const baseArg = process.argv.indexOf('--base');
const base = baseArg !== -1 ? process.argv[baseArg + 1]
: process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}`
: null;
if (!base) {
notes.push(
'diff check NOT EXERCISED — no base ref (pass --base <ref> or set GITHUB_BASE_REF). ' +
'Range coverage above was still checked; this is a skip, not a pass.'
);
} else {
let changed = [];
try {
changed = git(['diff', '--name-only', `${base}...HEAD`]).split('\n').filter(Boolean);
} catch {
notes.push(`diff check NOT EXERCISED — could not diff against '${base}' (shallow clone?).`);
}
if (changed.length > 0) {
for (const leaf of leaves) {
const touched = changed.filter(
(f) => f.startsWith(`${leaf.dir}/`) && PUBLISHABLE.test(f) && !NOT_PUBLISHABLE.test(f),
);
if (touched.length === 0) continue;
let baseVersion = null;
try {
baseVersion = JSON.parse(git(['show', `${base}:${leaf.dir}/package.json`])).version;
} catch {
continue; // new package in this PR — nothing published to go stale
}
if (baseVersion === leaf.version) {
violations.push(
`${leaf.name}: publishable source changed but the version is still ${leaf.version}.\n` +
` changed: ${touched.slice(0, 5).join(', ')}${touched.length > 5 ? `, +${touched.length - 5} more` : ''}\n` +
` ${leaf.name} is NOT bundled into the release tarballs — it resolves from the\n` +
` registry — so publishing the three train packages will ship the OLD copy and\n` +
` this change will reach nobody. Bump ${leaf.dir}/package.json and publish the\n` +
` leaf alongside the release. See CLAUDE.md "Publishing Rules".`
);
} else {
console.log(` ${leaf.name}: source changed AND version bumped ${baseVersion} -> ${leaf.version} ✓`);
}
}
}
}
// ── 4. report ────────────────────────────────────────────────────────────────
console.log(` inspected ${leaves.length} non-bundled leaf package(s)`);
for (const n of notes) console.log(` NOTE: ${n}`);
if (violations.length === 0) {
console.log(' ok: every non-bundled leaf is covered by the CLI range and carries a bump if changed');
process.exit(0);
}
console.error('\nviolations:');
for (const v of violations) console.error(` ✗ ${v}`);
console.error(`\n${violations.length} violation(s) — a release now would ship an incomplete fix.`);
console.error('Reference: ruvnet/ruflo#3335, #3390 (#3327 Finding A).');
process.exit(1);
+1 -1
View File
@@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, '..');
const INTERNAL_RUNTIME_PACKAGES = [
export const INTERNAL_RUNTIME_PACKAGES = [
{
directory: 'security',
name: '@claude-flow/security',