mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-10-23 03:47:00 +09:00
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:
1 parent
9c61c86f06
commit
f02759cd6d
3 files changed
+257
-1
No files matched your search
@@ -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
|
||||
|
||||
Executable
+234
@@ -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);
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user