diff --git a/docs/2026-07-25-v0.1.0-release-readiness.md b/docs/2026-07-25-v0.1.0-release-readiness.md index cca37c975..03ac971fd 100644 --- a/docs/2026-07-25-v0.1.0-release-readiness.md +++ b/docs/2026-07-25-v0.1.0-release-readiness.md @@ -65,8 +65,8 @@ shippable** until Ropes #1 and #2 are closed. package. Verify with `npm pack --dry-run` that the tarball file list actually contains `dist/`. 2. **Close `health→config`:** add `@refarm.dev/config` to `vault-seed-ready` (and confirm it is itself dep-closed) or drop the runtime dep. (Not needed if shipping only the 4 contracts.) -3. **A REAL install-and-import smoke — SHIPPED (2026-07-25).** `scripts/ci/release-install-smoke.mjs` - (`pnpm run release:install:smoke`) builds each selected package, `npm pack`s a REAL tarball, `npm +3. **A REAL install-and-import smoke — SHIPPED (2026-07-25; hardened 2026-09-16).** `scripts/ci/release-install-smoke.mjs` + (`pnpm run release:install:smoke`) builds each selected package, `pnpm pack`s a REAL tarball, `pnpm install`s them into a throwaway consumer, and `import()`s each entrypoint — failing loudly if a tarball ships no dist (empty import) or a dep can't resolve (install error). This is the gate that catches Ropes #1/#2, which `pnpm publish --dry-run` does not. Proven: the 4 kernel contracts pack diff --git a/package.json b/package.json index de20660e9..7a40ae3fc 100644 --- a/package.json +++ b/package.json @@ -272,7 +272,7 @@ "release:promote:check:test": "node --test scripts/ci/test-promote-check.mjs", "release:readiness": "node scripts/ci/release-readiness.mjs", "release:readiness:plan": "node scripts/ci/release-readiness.mjs --plan", - "release:readiness:test": "node --test scripts/ci/test-release-check.mjs scripts/ci/test-release-readiness.mjs scripts/ci/test-release-boundary-audit.mjs scripts/ci/test-first-publish-selection.mjs scripts/ci/test-check-first-publish-changesets.mjs scripts/ci/test-agent-demo-release-proof.mjs scripts/ci/test-secure-extensibility-demo-proof.mjs scripts/ci/test-local-first-platform-proof.mjs scripts/ci/test-agents-lab-assimilation-audit.mjs scripts/ci/test-agents-lab-skill-import-manifest.mjs scripts/ci/test-agents-lab-skill-source-review.mjs scripts/ci/test-agents-lab-skill-convention-review.mjs scripts/ci/test-native-skill-system-roadmap.mjs scripts/ci/test-reference-driver-smoke.mjs scripts/ci/test-subprocess-utils.mjs scripts/ci/test-requirements-supply-handoff.mjs scripts/ci/test-promote-check.mjs scripts/ci/test-check-no-tracked-artifacts.mjs && node --test scripts/ci/test-vault-seed-ready-handoff.mjs scripts/ci/test-vault-seed-release-consumer.mjs scripts/ci/test-distribution-status-doc.mjs scripts/ci/test-audience-boundary.mjs codemods/registry.test.mjs codemods/ds-token-adoption.test.mjs codemods/package-workspace-adoption.test.mjs codemods/node-test-to-vitest.test.mjs codemods/astro-6-to-7.test.mjs", + "release:readiness:test": "node --test scripts/ci/test-release-check.mjs scripts/ci/test-release-readiness.mjs scripts/ci/test-release-install-smoke.mjs scripts/ci/test-release-boundary-audit.mjs scripts/ci/test-first-publish-selection.mjs scripts/ci/test-check-first-publish-changesets.mjs scripts/ci/test-agent-demo-release-proof.mjs scripts/ci/test-secure-extensibility-demo-proof.mjs scripts/ci/test-local-first-platform-proof.mjs scripts/ci/test-agents-lab-assimilation-audit.mjs scripts/ci/test-agents-lab-skill-import-manifest.mjs scripts/ci/test-agents-lab-skill-source-review.mjs scripts/ci/test-agents-lab-skill-convention-review.mjs scripts/ci/test-native-skill-system-roadmap.mjs scripts/ci/test-reference-driver-smoke.mjs scripts/ci/test-subprocess-utils.mjs scripts/ci/test-requirements-supply-handoff.mjs scripts/ci/test-promote-check.mjs scripts/ci/test-check-no-tracked-artifacts.mjs && node --test scripts/ci/test-vault-seed-ready-handoff.mjs scripts/ci/test-vault-seed-release-consumer.mjs scripts/ci/test-distribution-status-doc.mjs scripts/ci/test-audience-boundary.mjs codemods/registry.test.mjs codemods/ds-token-adoption.test.mjs codemods/package-workspace-adoption.test.mjs codemods/node-test-to-vitest.test.mjs codemods/astro-6-to-7.test.mjs", "release:trusted-publishing:plan": "node scripts/ci/trusted-publishing-plan-cli.mjs", "release:trusted-publishing:test": "node --test scripts/ci/test-trusted-publishing-plan.mjs scripts/ci/test-trusted-publishing-plan-cli.mjs scripts/ci/test-bootstrap-publish-preflight.mjs scripts/ci/bootstrap-publish-preflight-cli.test.mjs", "release:vault-seed:check": "node scripts/release-check.mjs --selection consumer-ready", diff --git a/scripts/ci/release-install-smoke.mjs b/scripts/ci/release-install-smoke.mjs index 3d547da97..0547e4f7d 100644 --- a/scripts/ci/release-install-smoke.mjs +++ b/scripts/ci/release-install-smoke.mjs @@ -1,222 +1,303 @@ #!/usr/bin/env node /** - * release-install-smoke — stretch the rope the release never stretched. + * Prove the tarball a consumer actually receives: build → pack → clean install → import. + * The consumer is disposable. The pnpm content-addressed store is deliberately reused, + * so CI's normal cache accelerates this proof without weakening its clean resolution. * - * The gap (docs/2026-07-25-v0.1.0-release-readiness.md, Rope #1/#2/#4): - * `pnpm publish --dry-run` does NOT fail when a tarball ships no `dist/` or - * depends on an unpublished package — so a release can pass every gate and still - * break on `npm install`. This closes it end-to-end: for each selected package, - * BUILD it, `pnpm pack` a REAL tarball, `pnpm install` those tarballs into a - * throwaway consumer, and `import()` each entrypoint. If the tarball has no dist, - * or a dep can't resolve, or the entrypoint won't load — this fails, loudly, - * BEFORE the publish button. - * - * Usage: - * node scripts/ci/release-install-smoke.mjs # the 4 kernel contracts - * node scripts/ci/release-install-smoke.mjs packages/ds … # explicit dirs - * node scripts/ci/release-install-smoke.mjs --selection consumer-ready + * Usage: release-install-smoke [--selection ID] [--json] [--keep] + * [--store-dir PATH] [--command-timeout-ms MS] [package-dir …] */ import { execFileSync } from "node:child_process"; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { buildReleaseCheckPlan } from "../release-check.mjs"; -// The smallest coherent 0.1.0 — the zero-runtime-dep kernel contracts. const DEFAULT_PACKAGES = [ "packages/storage-contract-v1", "packages/sync-contract-v1", "packages/identity-contract-v1", "packages/channel-policy-v1", ]; +const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60 * 1000; +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const CONSUMER_ALLOW_BUILDS = { esbuild: true }; -const repoRoot = process.cwd(); +function requireValue(argv, index, flag) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`); + return value; +} -function parseArgs(argv) { - const options = { selectionId: null, packageDirs: [] }; +export function parseArgs(argv) { + const options = { + commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS, + json: false, + keep: process.env.REFARM_RELEASE_SMOKE_KEEP === "1", + packageDirs: [], + selectionId: null, + storeDir: process.env.REFARM_RELEASE_SMOKE_STORE_DIR ?? null, + }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--") continue; if (arg === "--selection") { - const value = argv[index + 1]; - if (!value || value.startsWith("--")) { - throw new Error("--selection requires a value"); - } - options.selectionId = value; + options.selectionId = requireValue(argv, index, arg); + index += 1; + continue; + } + if (arg === "--store-dir") { + options.storeDir = requireValue(argv, index, arg); index += 1; continue; } - if (arg.startsWith("--")) { - throw new Error(`Unknown argument: ${arg}`); + if (arg === "--command-timeout-ms") { + const value = Number(requireValue(argv, index, arg)); + if (!Number.isSafeInteger(value) || value <= 0) + throw new Error("--command-timeout-ms requires a positive integer"); + options.commandTimeoutMs = value; + index += 1; + continue; + } + if (arg === "--json") { + options.json = true; + continue; } + if (arg === "--keep") { + options.keep = true; + continue; + } + if (arg.startsWith("--")) throw new Error(`Unknown argument: ${arg}`); options.packageDirs.push(arg); } - if (options.selectionId && options.packageDirs.length > 0) { + if (options.selectionId && options.packageDirs.length > 0) throw new Error("Use either --selection or explicit package directories, not both"); - } return options; } -function resolvePackageDirs(options) { - if (!options.selectionId) { +export function resolvePackageDirs(options) { + if (!options.selectionId) return options.packageDirs.length > 0 ? options.packageDirs : DEFAULT_PACKAGES; - } - const check = buildReleaseCheckPlan({ - cwd: repoRoot, - selectionId: options.selectionId, - }); - if (!check.ok) { - throw new Error(`Release selection ${options.selectionId} is not accepted`); - } + const check = buildReleaseCheckPlan({ cwd: repoRoot, selectionId: options.selectionId }); + if (!check.ok) throw new Error(`Release selection ${options.selectionId} is not accepted`); return check.commands.map((command) => command.packageDir); } -function assertInternalDependencyClosure(packageEntries) { +export function assertInternalDependencyClosure(packageEntries) { const selectedNames = new Set(packageEntries.map((entry) => entry.pkg.name)); const missing = []; for (const entry of packageEntries) { for (const section of ["dependencies", "optionalDependencies", "peerDependencies"]) { for (const [name, spec] of Object.entries(entry.pkg[section] ?? {})) { - if (name.startsWith("@refarm.dev/") && !selectedNames.has(name)) { + if (name.startsWith("@refarm.dev/") && !selectedNames.has(name)) missing.push(`${entry.pkg.name} -> ${name} (${section}: ${spec})`); - } } } } - if (missing.length > 0) { + if (missing.length > 0) throw new Error( `Release install selection is not closed over internal dependencies:\n- ${missing.join("\n- ")}`, ); - } } -const options = parseArgs(process.argv.slice(2)); -const packageDirs = resolvePackageDirs(options); +function readPkg(dir) { + return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); +} +function elapsedMs(startedAt) { + return Math.round(performance.now() - startedAt); +} +function formatDuration(ms) { + return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`; +} +function log(options, message) { + if (!options.json) process.stdout.write(`${message}\n`); +} -function run(cmd, args, cwd) { +function run(command, args, cwd, { commandTimeoutMs }) { + const startedAt = performance.now(); try { - return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + execFileSync(command, args, { + cwd, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + timeout: commandTimeoutMs, + windowsHide: true, + }); + return { elapsedMs: elapsedMs(startedAt) }; } catch (error) { const detail = [error.stdout, error.stderr] .filter((value) => typeof value === "string" && value.trim().length > 0) .join("\n"); - throw new Error(`Command failed: ${cmd} ${args.join(" ")}${detail ? `\n${detail}` : ""}`, { - cause: error, - }); + const timeout = error.code === "ETIMEDOUT" ? ` after ${commandTimeoutMs}ms` : ""; + throw new Error( + `Command failed${timeout}: ${command} ${args.join(" ")}${detail ? `\n${detail}` : ""}`, + { cause: error }, + ); } } -function readPkg(dir) { - return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); +function installArgs(options) { + const args = ["install", "--no-frozen-lockfile"]; + if (options.storeDir) args.unshift("--store-dir", resolve(repoRoot, options.storeDir)); + return args; } -// Build scripts the consumer approves, mirroring `allowBuilds` in the root pnpm-workspace.yaml. -const CONSUMER_ALLOW_BUILDS = { esbuild: true }; - -const stage = mkdtempSync(join(tmpdir(), "refarm-install-smoke-")); -const consumer = join(stage, "consumer"); -mkdirSync(consumer, { recursive: true }); -const packed = []; -try { - const packageEntries = packageDirs.map((dir) => { - const abs = resolve(repoRoot, dir); - return { dir, abs, pkg: readPkg(abs) }; - }); - assertInternalDependencyClosure(packageEntries); +export function serializeReport({ options, packageResults, phaseTimings, results, stage }) { + return { + command: "release-install-smoke", + ok: results.every((result) => result.ok), + packages: results, + packageTimings: packageResults, + phasesMs: phaseTimings, + selectionId: options.selectionId, + stage: options.keep ? stage : null, + storeDir: options.storeDir ? resolve(repoRoot, options.storeDir) : "pnpm-configured-store", + }; +} - // 1) build + pack a real tarball per package - for (const { abs, pkg } of packageEntries) { - process.stdout.write(`\n📦 ${pkg.name}\n`); - if (pkg.scripts?.build) { - process.stdout.write(` building…\n`); - run("pnpm", ["--filter", pkg.name, "run", "build"], repoRoot); +async function main(options) { + const startedAt = performance.now(); + const phaseTimings = {}; + const packageResults = []; + const stage = mkdtempSync(join(tmpdir(), "refarm-install-smoke-")); + const consumer = join(stage, "consumer"); + mkdirSync(consumer, { recursive: true }); + const packed = []; + try { + const packageEntries = resolvePackageDirs(options).map((dir) => ({ + dir, + abs: resolve(repoRoot, dir), + pkg: readPkg(resolve(repoRoot, dir)), + })); + assertInternalDependencyClosure(packageEntries); + const buildAndPackStartedAt = performance.now(); + for (const [index, { abs, pkg }] of packageEntries.entries()) { + const packageStartedAt = performance.now(); + const timing = { name: pkg.name, buildMs: 0, packMs: 0 }; + log(options, `\n📦 [${index + 1}/${packageEntries.length}] ${pkg.name}`); + if (pkg.scripts?.build) { + log(options, " building…"); + timing.buildMs = run( + "pnpm", + ["--filter", pkg.name, "run", "build"], + repoRoot, + options, + ).elapsedMs; + } + const beforePack = new Set(readdirSync(stage)); + timing.packMs = run("pnpm", ["pack", "--pack-destination", stage], abs, options).elapsedMs; + const producedTarballs = readdirSync(stage).filter( + (name) => name.endsWith(".tgz") && !beforePack.has(name), + ); + if (producedTarballs.length !== 1) + throw new Error( + `${pkg.name}: pnpm pack produced ${producedTarballs.length} tarballs instead of one`, + ); + packed.push({ + name: pkg.name, + main: pkg.main ?? "index.js", + tarball: resolve(stage, producedTarballs[0]), + }); + timing.totalMs = elapsedMs(packageStartedAt); + packageResults.push(timing); + log(options, ` packed ${producedTarballs[0]} (${formatDuration(timing.totalMs)})`); } - // Match the real publish lane: pnpm rewrites workspace: ranges to - // publishable versions, while npm pack leaves workspace:* untouched. - const beforePack = new Set(readdirSync(stage)); - run("pnpm", ["pack", "--pack-destination", stage], abs); - const producedTarballs = readdirSync(stage).filter((name) => name.endsWith(".tgz") && !beforePack.has(name)); - if (producedTarballs.length !== 1) { - throw new Error(`${pkg.name}: pnpm pack produced ${producedTarballs.length} tarballs instead of one`); + phaseTimings.buildAndPackMs = elapsedMs(buildAndPackStartedAt); + const fileSpecs = Object.fromEntries( + packed.map((entry) => [entry.name, `file:${entry.tarball.replaceAll("\\", "/")}`]), + ); + writeFileSync( + join(consumer, "package.json"), + `${JSON.stringify({ name: "install-smoke-consumer", private: true, type: "module", dependencies: fileSpecs }, null, 2)}\n`, + ); + writeFileSync( + join(consumer, "pnpm-workspace.yaml"), + [ + "packages:", + ' - "."', + "overrides:", + ...Object.entries(fileSpecs).map(([name, spec]) => ` "${name}": "${spec}"`), + "allowBuilds:", + ...Object.entries(CONSUMER_ALLOW_BUILDS).map(([name, allowed]) => ` ${name}: ${allowed}`), + "", + ].join("\n"), + ); + if (existsSync(join(repoRoot, ".npmrc"))) + copyFileSync(join(repoRoot, ".npmrc"), join(consumer, ".npmrc")); + log(options, `\n⬇️ pnpm install (${packed.length} tarball(s), reusable store)…`); + const installStartedAt = performance.now(); + run("pnpm", installArgs(options), consumer, options); + phaseTimings.installMs = elapsedMs(installStartedAt); + const importStartedAt = performance.now(); + const results = []; + for (const p of packed) { + const entry = join(consumer, "node_modules", ...p.name.split("/"), p.main); + try { + const mod = await import(pathToFileURL(entry).href); + results.push({ + name: p.name, + ok: Object.keys(mod).length > 0, + exports: Object.keys(mod).length, + }); + } catch (error) { + results.push({ + name: p.name, + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } } - packed.push({ name: pkg.name, main: pkg.main ?? "index.js", tarball: resolve(stage, producedTarballs[0]) }); - process.stdout.write(` packed ${producedTarballs[0]}\n`); - } - - // 2) Install all tarballs into a clean pnpm consumer. Direct file specs do not - // redirect transitive semver lookups, so the workspace overrides are part of - // the proof: without them an unpublished support package would hit the registry. - const fileSpecs = Object.fromEntries( - packed.map((entry) => [ - entry.name, - `file:${entry.tarball.replaceAll("\\", "/")}`, - ]), - ); - writeFileSync( - join(consumer, "package.json"), - `${JSON.stringify({ - name: "install-smoke-consumer", - private: true, - type: "module", - dependencies: fileSpecs, - }, null, 2)}\n`, - ); - writeFileSync( - join(consumer, "pnpm-workspace.yaml"), - [ - "packages:", - ' - "."', - "overrides:", - ...Object.entries(fileSpecs).map(([name, spec]) => ` "${name}": "${spec}"`), - // pnpm 11 hard-errors (ERR_PNPM_IGNORED_BUILDS) on an unreviewed build script. - // ds-astro → astro → esbuild carries one; the root pnpm-workspace.yaml approves - // it, and a consumer following the same security line approves it too. - "allowBuilds:", - ...Object.entries(CONSUMER_ALLOW_BUILDS).map(([name, allowed]) => ` ${name}: ${allowed}`), - "", - ].join("\n"), - ); - // The consumer lives in tmpdir, outside the repo, so the repo's .npmrc (public - // registry over any corporate proxy in ~/.npmrc) would not apply — carry it over. - if (existsSync(join(repoRoot, ".npmrc"))) { - copyFileSync(join(repoRoot, ".npmrc"), join(consumer, ".npmrc")); - } - process.stdout.write(`\n⬇️ pnpm install (${packed.length} tarball(s))…\n`); - run("pnpm", ["--store-dir", ".pnpm-store", "install", "--no-frozen-lockfile"], consumer); - - // 3) import each entrypoint from the INSTALLED package (a missing dist fails HERE) - const results = []; - for (const p of packed) { - const entry = join(consumer, "node_modules", ...p.name.split("/"), p.main); - try { - const mod = await import(pathToFileURL(entry).href); - const exports = Object.keys(mod).length; - results.push({ name: p.name, ok: exports > 0, exports }); - } catch (error) { - results.push({ name: p.name, ok: false, error: error instanceof Error ? error.message : String(error) }); + phaseTimings.importMs = elapsedMs(importStartedAt); + phaseTimings.totalMs = elapsedMs(startedAt); + const report = serializeReport({ options, packageResults, phaseTimings, results, stage }); + if (options.json) process.stdout.write(`${JSON.stringify(report)}\n`); + else { + process.stdout.write("\n── install-and-import ──\n"); + for (const result of results) + process.stdout.write( + result.ok + ? ` ✅ ${result.name} — installed + imported (${result.exports} exports)\n` + : ` ❌ ${result.name} — ${result.error ?? "no exports"}\n`, + ); + process.stdout.write( + `\n${report.ok ? "✅" : "❌"} ${results.length} package(s) in ${formatDuration(phaseTimings.totalMs)} (build+pack ${formatDuration(phaseTimings.buildAndPackMs)}, install ${formatDuration(phaseTimings.installMs)}, import ${formatDuration(phaseTimings.importMs)}).\n`, + ); } + if (!report.ok) + throw new Error( + "release-install-smoke FAILED — a tarball would break on consumer install or import", + ); + return report; + } finally { + if (options.keep) + process.stderr.write(`Keeping release install-smoke stage for diagnosis: ${stage}\n`); + else rmSync(stage, { recursive: true, force: true }); } +} - process.stdout.write(`\n── install-and-import ──\n`); - let allOk = true; - for (const r of results) { - if (r.ok) { - process.stdout.write(` ✅ ${r.name} — installed + imported (${r.exports} exports)\n`); - } else { - allOk = false; - process.stdout.write(` ❌ ${r.name} — ${r.error ?? "no exports (empty/missing dist?)"}\n`); - } - } - if (!allOk) { - process.stderr.write(`\n❌ release-install-smoke FAILED — a tarball would break on npm install.\n`); - process.exit(1); - } - process.stdout.write(`\n✅ all ${results.length} package(s) pack → install → import cleanly.\n`); -} finally { - if (process.env.REFARM_RELEASE_SMOKE_KEEP === "1") { - process.stderr.write(`Keeping release install-smoke stage for diagnosis: ${stage}\n`); - } else { - rmSync(stage, { recursive: true, force: true }); - } +function isMain() { + return process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]); +} +if (isMain()) { + const options = parseArgs(process.argv.slice(2)); + main(options).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + if (options.json) + process.stdout.write( + `${JSON.stringify({ command: "release-install-smoke", ok: false, error: message })}\n`, + ); + else process.stderr.write(`\n❌ ${message}\n`); + process.exitCode = 1; + }); } diff --git a/scripts/ci/test-release-install-smoke.mjs b/scripts/ci/test-release-install-smoke.mjs new file mode 100644 index 000000000..45775fe02 --- /dev/null +++ b/scripts/ci/test-release-install-smoke.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assertInternalDependencyClosure, + parseArgs, + serializeReport, +} from "./release-install-smoke.mjs"; + +test("install smoke rejects an internal dependency outside the selected packet", () => { + assert.throws( + () => + assertInternalDependencyClosure([ + { + pkg: { + name: "@refarm.dev/consumer", + dependencies: { "@refarm.dev/missing": "workspace:*" }, + }, + }, + ]), + /consumer -> @refarm\.dev\/missing \(dependencies: workspace:\*\)/, + ); +}); + +test("install smoke accepts a closed internal dependency packet", () => { + assert.doesNotThrow(() => + assertInternalDependencyClosure([ + { pkg: { name: "@refarm.dev/provider" } }, + { + pkg: { + name: "@refarm.dev/consumer", + dependencies: { "@refarm.dev/provider": "workspace:*" }, + }, + }, + ]), + ); +}); + +test("arguments expose durable diagnostics without changing the default cache policy", () => { + const options = parseArgs([ + "--selection", + "ecosystem-ready", + "--json", + "--keep", + "--command-timeout-ms", + "1234", + ]); + assert.equal(options.selectionId, "ecosystem-ready"); + assert.equal(options.json, true); + assert.equal(options.keep, true); + assert.equal(options.commandTimeoutMs, 1234); + assert.equal(options.storeDir, null); + assert.throws(() => parseArgs(["--command-timeout-ms", "0"]), /positive integer/); +}); + +test("structured receipt retains timings and hides a deleted stage", () => { + const report = serializeReport({ + options: { selectionId: "ecosystem-ready", keep: false, storeDir: null }, + packageResults: [{ name: "@refarm.dev/example", buildMs: 1, packMs: 2, totalMs: 3 }], + phaseTimings: { buildAndPackMs: 3, installMs: 4, importMs: 5, totalMs: 12 }, + results: [{ name: "@refarm.dev/example", ok: true, exports: 1 }], + stage: "/tmp/ephemeral-stage", + }); + assert.equal(report.ok, true); + assert.equal(report.stage, null); + assert.equal(report.storeDir, "pnpm-configured-store"); + assert.equal(report.phasesMs.totalMs, 12); +}); diff --git a/scripts/ci/test-release-readiness.mjs b/scripts/ci/test-release-readiness.mjs index 3f49de34a..b8b05e1fc 100644 --- a/scripts/ci/test-release-readiness.mjs +++ b/scripts/ci/test-release-readiness.mjs @@ -1,11 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { - buildPlan, - parseReleaseReadinessArgs, - serializePlan, -} from "./release-readiness.mjs"; +import { buildPlan, parseReleaseReadinessArgs, serializePlan } from "./release-readiness.mjs"; test("prints an ordered release readiness plan", () => { const output = buildPlan() @@ -30,8 +26,14 @@ test("prints an ordered release readiness plan", () => { assert.match(output, /agent-demo-release-proof: .*agent-demo:release-proof/); assert.match(output, /secure-extensibility-proof: .*secure-extensibility:proof/); assert.match(output, /local-first-platform-proof: .*local-first:proof/); - assert.match(output, /first-publish-selection-plan: .*release:first-publish:plan -- --selection ecosystem-ready/); - assert.match(output, /ecosystem-install-smoke: .*release:install:smoke -- --selection ecosystem-ready/); + assert.match( + output, + /first-publish-selection-plan: .*release:first-publish:plan -- --selection ecosystem-ready/, + ); + assert.match( + output, + /ecosystem-install-smoke: .*release:install:smoke -- --selection ecosystem-ready/, + ); assert.match(output, /publish-dry-run: .*release:check/); }); @@ -47,7 +49,7 @@ test("prints structured release readiness metadata", () => { assert.equal(payload.ok, true); assert.equal(payload.command, "release-readiness"); assert.equal(payload.mode, "plan"); -// THE RULE, not the roster. This pinned all ~30 step ids verbatim and went red when + // THE RULE, not the roster. This pinned all ~30 step ids verbatim and went red when // `no-tracked-artifacts` joined the readiness plan — a gate being ADDED broke the test that // guards the plan, which is the wrong way round, and it went unnoticed because no lane ran // this suite (ISS-106). @@ -104,7 +106,10 @@ test("consumer install smoke matches the pnpm publication and handoff semantics" assert.match(smoke, /"pnpm", \["pack", "--pack-destination"/); assert.match(smoke, /"pnpm-workspace\.yaml"/); assert.match(smoke, /overrides:/); - assert.match(smoke, /"pnpm", \["--store-dir", "\.pnpm-store", "install", "--no-frozen-lockfile"\]/); + assert.match(smoke, /const args = \["install", "--no-frozen-lockfile"\]/); + assert.match(smoke, /pnpm content-addressed store is deliberately reused/); + assert.match(smoke, /DEFAULT_COMMAND_TIMEOUT_MS/); + assert.match(smoke, /serializeReport/); assert.doesNotMatch(smoke, /run\("npm", \["pack"/); assert.doesNotMatch(smoke, /run\("npm", \["install"/); });