Files
ruflo/scripts/stage-internal-runtime-bundles.mjs
T
ruvnetandClaude Opus 5 f02759cd6d 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>
2026-09-21 16:49:17 -04:00

263 lines
9.0 KiB
JavaScript

#!/usr/bin/env node
import {
access,
cp,
mkdir,
readFile,
readdir,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, '..');
export const INTERNAL_RUNTIME_PACKAGES = [
{
directory: 'security',
name: '@claude-flow/security',
requiredAssets: ['dist'],
optionalAssets: ['README.md'],
},
{
directory: 'codex',
name: '@claude-flow/codex',
requiredAssets: ['dist', '.agents/skills'],
optionalAssets: ['README.md'],
},
{
directory: 'mcp',
name: '@claude-flow/mcp',
requiredAssets: ['dist'],
optionalAssets: ['README.md'],
},
{
directory: 'plugin-agent-federation',
name: '@claude-flow/plugin-agent-federation',
requiredAssets: ['dist'],
optionalAssets: ['README.md', 'LICENSE'],
},
];
const BUILD_PREREQUISITES = ['shared'];
function runBuild(packageDirectory) {
// CreateProcess cannot launch a .cmd directly, and Node has refused to
// since CVE-2024-27980 — spawnSync('npm.cmd', ...) without shell:true
// throws EINVAL on Windows. `command`/args here are constant literals,
// never hook-derived or user-controlled, so shell:true carries no
// injection risk (contrast plugins/ruflo-core/scripts/ruflo-hook.cjs,
// which resolves a real .js entrypoint instead because its argv IS
// externally derived).
// Node's DEP0190 warns when shell:true is combined with an args array
// (args are concatenated, not escaped) — harmless here since nothing is
// interpolated, but the single-string form below is what Node itself
// recommends to avoid the warning while keeping shell:true.
const win32 = process.platform === 'win32';
const result = win32
? spawnSync('npm.cmd run build', { cwd: packageDirectory, stdio: 'inherit', shell: true })
: spawnSync('npm', ['run', 'build'], { cwd: packageDirectory, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(
`Internal runtime build failed for ${packageDirectory} with exit code ${result.status ?? 'unknown'}`,
);
}
}
async function copyIfPresent(source, target) {
try {
await cp(source, target, { recursive: true });
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
async function copyRequired(source, target) {
await access(source);
await cp(source, target, { recursive: true });
}
function collectEntrypoints(value, entrypoints) {
if (typeof value === 'string') {
if (value.startsWith('./')) entrypoints.add(value.slice(2));
return;
}
if (Array.isArray(value)) {
for (const item of value) collectEntrypoints(item, entrypoints);
return;
}
if (value && typeof value === 'object') {
for (const item of Object.values(value)) collectEntrypoints(item, entrypoints);
}
}
async function validateEntrypoints(packageRoot, packageJson) {
const entrypoints = new Set();
collectEntrypoints(packageJson.main, entrypoints);
collectEntrypoints(packageJson.types, entrypoints);
collectEntrypoints(packageJson.bin, entrypoints);
collectEntrypoints(packageJson.exports, entrypoints);
for (const entrypoint of entrypoints) {
if (entrypoint.includes('*')) {
const entrypointDirectory = dirname(entrypoint);
const expression = new RegExp(
`^${entrypoint
.slice(entrypointDirectory.length + 1)
.split('*')
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('.*')}$`,
);
const matches = (await readdir(join(packageRoot, entrypointDirectory))).filter(
(name) => expression.test(name),
);
if (matches.length === 0) {
throw new Error(`No staged entrypoints match ${entrypoint}`);
}
continue;
}
await access(join(packageRoot, entrypoint));
}
}
export function createBundledRuntimeManifest(packageJson) {
const dependencyMetadata = {};
for (const field of [
'dependencies',
'optionalDependencies',
'peerDependencies',
'peerDependenciesMeta',
]) {
if (packageJson[field] && Object.keys(packageJson[field]).length > 0) {
dependencyMetadata[field] = packageJson[field];
}
}
const bundledManifest = {
...packageJson,
rufloBundledRuntime: {
format: 1,
sourceDependencies: dependencyMetadata,
},
};
delete bundledManifest.dependencies;
delete bundledManifest.optionalDependencies;
delete bundledManifest.peerDependencies;
delete bundledManifest.peerDependenciesMeta;
return bundledManifest;
}
export function alignBundledRuntimeVersion(targetPackageJson, runtimePackageJson) {
const current = targetPackageJson.dependencies?.[runtimePackageJson.name];
if (current === undefined) {
throw new Error(`${runtimePackageJson.name} must be a dependency of ${targetPackageJson.name}`);
}
if (current === runtimePackageJson.version) return false;
targetPackageJson.dependencies[runtimePackageJson.name] = runtimePackageJson.version;
return true;
}
/**
* Build reviewed internal packages and stage only their runtime artifacts under
* a public package's node_modules tree. npm's bundledDependencies mechanism
* then embeds them in the public tarball, so installation never depends on an
* unpublished internal package coordinate.
*/
export async function stageInternalRuntimeBundles(
targetPackageDirectory,
{ build = true } = {},
) {
const targetRoot = resolve(targetPackageDirectory);
const scopeRoot = join(targetRoot, 'node_modules', '@claude-flow');
const targetPackageJson = JSON.parse(
await readFile(join(targetRoot, 'package.json'), 'utf8'),
);
// npm normalizes `bundledDependencies` to `bundleDependencies` when it
// rewrites a manifest. Accept both documented spellings so staging remains
// stable after lockfile/version maintenance.
const declaredBundles = new Set(
targetPackageJson.bundleDependencies ??
targetPackageJson.bundledDependencies ??
[],
);
for (const runtimePackage of INTERNAL_RUNTIME_PACKAGES) {
if (targetPackageJson.dependencies?.[runtimePackage.name] === undefined) {
throw new Error(`${runtimePackage.name} must be a dependency of ${targetPackageJson.name}`);
}
if (!declaredBundles.has(runtimePackage.name)) {
throw new Error(`${runtimePackage.name} must be bundled by ${targetPackageJson.name}`);
}
}
await mkdir(scopeRoot, { recursive: true });
if (build) {
for (const packageDirectory of BUILD_PREREQUISITES) {
runBuild(join(repoRoot, 'v3', '@claude-flow', packageDirectory));
}
}
let targetManifestChanged = false;
for (const runtimePackage of INTERNAL_RUNTIME_PACKAGES) {
const sourceRoot = join(repoRoot, 'v3', '@claude-flow', runtimePackage.directory);
if (build) runBuild(sourceRoot);
const packageJson = JSON.parse(await readFile(join(sourceRoot, 'package.json'), 'utf8'));
if (packageJson.name !== runtimePackage.name) {
throw new Error(
`Internal bundle identity mismatch: expected ${runtimePackage.name}, got ${String(packageJson.name)}`,
);
}
targetManifestChanged =
alignBundledRuntimeVersion(targetPackageJson, packageJson) || targetManifestChanged;
const packageLeaf = runtimePackage.name.slice('@claude-flow/'.length);
const target = join(scopeRoot, packageLeaf);
const temporary = `${target}.${process.pid}.tmp`;
await rm(temporary, { recursive: true, force: true });
try {
await mkdir(temporary, { recursive: true });
await writeFile(
join(temporary, 'package.json'),
`${JSON.stringify(createBundledRuntimeManifest(packageJson), null, 2)}\n`,
);
for (const asset of runtimePackage.requiredAssets) {
await copyRequired(join(sourceRoot, asset), join(temporary, asset));
}
for (const asset of runtimePackage.optionalAssets) {
await copyIfPresent(join(sourceRoot, asset), join(temporary, asset));
}
await validateEntrypoints(temporary, packageJson);
await rm(target, { recursive: true, force: true });
await rename(temporary, target);
} finally {
await rm(temporary, { recursive: true, force: true });
}
}
// The source-built runtime may be newer than the registry bootstrap used by
// `npm ci`. The packed manifest must name the bytes actually embedded in the
// archive or npm correctly reports the bundle as invalid during install.
if (targetManifestChanged) {
await writeFile(
join(targetRoot, 'package.json'),
`${JSON.stringify(targetPackageJson, null, 2)}\n`,
);
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const targetIndex = process.argv.indexOf('--target');
if (targetIndex === -1 || !process.argv[targetIndex + 1]) {
throw new Error('Usage: stage-internal-runtime-bundles.mjs --target <package-directory>');
}
await stageInternalRuntimeBundles(process.argv[targetIndex + 1], {
build: !process.argv.includes('--no-build'),
});
}