diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb642d7863..3e99af27cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -609,6 +609,11 @@ jobs: - name: Assert dist/server.js exists run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1) - run: npm run check:pack-artifact + # WS1.2 (#7065 class): pack the real tarball, install it into a clean prefix and + # BOOT it to a healthy /api/monitoring/health — the gate that structure checks + # cannot provide (3 releases shipped boot-crashing tarballs with green lists). + - name: Boot-smoke the packed tarball + run: npm run check:pack-boot electron-package-smoke: name: Electron Package Smoke diff --git a/changelog.d/fixes/pack-boot-smoke-gate.md b/changelog.d/fixes/pack-boot-smoke-gate.md new file mode 100644 index 0000000000..cc44649218 --- /dev/null +++ b/changelog.d/fixes/pack-boot-smoke-gate.md @@ -0,0 +1 @@ +- **Packaging**: new `check:pack-boot` gate packs the real npm tarball, installs it into a clean prefix (postinstall runs for real) and boots the installed CLI until `/api/monitoring/health` returns 200 with the packed version — the runtime gate that structure checks could not provide (3 releases shipped boot-crashing tarballs with green packaging lists: tls-options/3.8.41, #7040, #7065). Wired into the CI `package-artifact` job and `check:release-green --with-build` diff --git a/package.json b/package.json index b2f2af7389..e060c58635 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "i18n:check-ui-coverage": "node scripts/i18n/check-ui-keys-coverage.mjs", "check:node-runtime": "node --import tsx scripts/check/check-supported-node-runtime.ts", "check:pack-artifact": "node --import tsx scripts/build/validate-pack-artifact.ts", + "check:pack-boot": "node scripts/check/check-pack-boot.mjs", "check:pack-policy": "node --import tsx scripts/build/validate-pack-artifact.ts --policy-only", "check:cli-i18n": "node scripts/check/check-cli-i18n.mjs", "check:openapi-coverage": "node scripts/check/check-openapi-coverage.mjs", diff --git a/scripts/check/check-pack-boot.mjs b/scripts/check/check-pack-boot.mjs new file mode 100644 index 0000000000..62a4b78ab5 --- /dev/null +++ b/scripts/check/check-pack-boot.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * check:pack-boot — boot-smoke of the REAL npm tarball (#7065 class killer, WS1.2/T1). + * + * Three releases shipped a tarball that crashed on every boot (tls-options/3.8.41, + * head-response-guard VPS #7040 + npm #7065) because no gate ever EXECUTED the + * artifact: structure checks (check:pack-artifact) validate lists, not runtime. + * This gate packs the tree, installs the tarball into a clean prefix, boots the + * installed CLI and polls /api/monitoring/health until it proves the artifact + * starts — regardless of WHICH packaging list drifted. + * + * Requires a built dist/ (run after `npm run build:cli`, e.g. in the CI + * package-artifact job or `check:release-green --with-build`). Exit codes: + * 0 = boots and reports the right version · 1 = boot failed · 2 = missing build. + */ +import { execFileSync, spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const POLL_INTERVAL_MS = 2_000; +const BOOT_DEADLINE_MS = 240_000; + +/** Parse `npm pack --json` output into the generated tarball filename. */ +export function pickTarball(packJsonOutput) { + const parsed = JSON.parse(packJsonOutput); + const filename = Array.isArray(parsed) ? parsed[0]?.filename : undefined; + if (!filename) throw new Error("npm pack --json returned no filename"); + // npm >=9 may emit scoped names with "/" — normalize to the on-disk file name. + return filename.replace(/\//g, "-"); +} + +/** + * Boot verdict: HTTP 200 + a JSON body reporting the version we just packed. + * `status` is logged but NOT asserted — a clean install with zero providers may + * legitimately report degraded states; the gate targets boot crashes, not health. + */ +export function evaluateBoot(httpStatus, body, expectedVersion) { + const failures = []; + if (httpStatus !== 200) failures.push(`health HTTP ${httpStatus} (expected 200)`); + if (!body || typeof body !== "object") failures.push("health body is not JSON"); + else if (body.version !== expectedVersion) + failures.push(`version "${body.version}" (expected "${expectedVersion}")`); + return { ok: failures.length === 0, failures }; +} + +/** Deterministic-enough free-ish port in a range CI runners don't use. */ +export function pickPort(seed = process.pid) { + return 23000 + (seed % 4000); +} + +function log(msg) { + console.log(`[pack-boot] ${msg}`); +} + +async function main() { + const ROOT = process.cwd(); + if (!fs.existsSync(path.join(ROOT, "dist", "server.js"))) { + console.error("[pack-boot] dist/server.js missing — run `npm run build:cli` first (this is a --with-build gate)"); + process.exit(2); + } + const expectedVersion = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pack-boot-")); + let child = null; + let exitCode = 1; + try { + log(`packing v${expectedVersion}…`); + const packOut = execFileSync("npm", ["pack", "--json", "--pack-destination", tmp], { + cwd: ROOT, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + const tarball = path.join(tmp, pickTarball(packOut)); + log(`installing ${path.basename(tarball)} into a clean prefix (postinstall runs for real)…`); + const prefix = path.join(tmp, "prefix"); + execFileSync("npm", ["install", "-g", "--prefix", prefix, tarball], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + + const port = pickPort(); + const dataDir = path.join(tmp, "data"); + fs.mkdirSync(dataDir, { recursive: true }); + const binPath = path.join(prefix, "bin", "omniroute"); + log(`booting installed CLI on :${port} (DATA_DIR isolated)…`); + child = spawn(binPath, ["serve", "--port", String(port)], { + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + JWT_SECRET: "pack-boot-smoke-secret-with-sufficient-length-000", + API_KEY_SECRET: "pack-boot-smoke-api-key-secret-long", + DISABLE_SQLITE_AUTO_BACKUP: "true", + OMNIROUTE_SKIP_SYSTEM_TRUST: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + const tail = []; + const keepTail = (chunk) => { + tail.push(String(chunk)); + while (tail.length > 80) tail.shift(); + }; + child.stdout.on("data", keepTail); + child.stderr.on("data", keepTail); + let childExit = null; + child.on("exit", (code) => { + childExit = code ?? -1; + }); + + const deadline = Date.now() + BOOT_DEADLINE_MS; + let verdict = { ok: false, failures: ["never polled"] }; + while (Date.now() < deadline) { + if (childExit !== null) { + verdict = { ok: false, failures: [`process exited with code ${childExit} before serving`] }; + break; + } + try { + const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`); + const body = await res.json().catch(() => null); + verdict = evaluateBoot(res.status, body, expectedVersion); + if (verdict.ok) { + log(`healthy: HTTP 200, version ${body.version}, status "${body.status}"`); + break; + } + } catch { + // not listening yet — keep polling + } + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } + + if (verdict.ok) { + log("✅ the packed tarball boots — #7065 class gate green"); + exitCode = 0; + } else { + console.error(`[pack-boot] ❌ boot FAILED: ${verdict.failures.join("; ")}`); + console.error("[pack-boot] last server output:\n" + tail.join("").split("\n").slice(-40).join("\n")); + exitCode = 1; + } + } finally { + if (child?.pid) { + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + /* already gone */ + } + await new Promise((r) => setTimeout(r, 2_000)); + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + /* already gone */ + } + } + fs.rmSync(tmp, { recursive: true, force: true }); + } + process.exit(exitCode); +} + +const isDirectRun = + process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); +if (isDirectRun) { + main().catch((e) => { + console.error("[pack-boot] fatal:", e.message); + process.exit(1); + }); +} diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs index db0933ed67..13ffdb8ff0 100644 --- a/scripts/quality/validate-release-green.mjs +++ b/scripts/quality/validate-release-green.mjs @@ -543,6 +543,14 @@ async function main() { args: ["run", "check:pack-artifact"], timeout: 20 * 60 * 1000, }); + // WS1.2 (#7065 class): boot the REAL packed tarball from a clean install — + // the runtime gate structure checks cannot provide. Reuses the same dist/ build. + slow.push({ + id: "pack-boot", + label: "Tarball boot-smoke (installed CLI serves /health)", + args: ["run", "check:pack-boot"], + timeout: 15 * 60 * 1000, + }); } slow.forEach((g) => announce(`${g.label} [parallel]`)); const slowResults = await Promise.all( diff --git a/tests/unit/check-pack-boot.test.ts b/tests/unit/check-pack-boot.test.ts new file mode 100644 index 0000000000..b22ca557b5 --- /dev/null +++ b/tests/unit/check-pack-boot.test.ts @@ -0,0 +1,57 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { pickTarball, evaluateBoot, pickPort } from "../../scripts/check/check-pack-boot.mjs"; + +// WS1.2 (T1, v3.8.49 quality plan) — pure-function guards for the tarball boot-smoke +// gate that kills the #7065 class (published artifact crashes on every boot because a +// packaging list drifted; 3rd recurrence). The end-to-end path runs in CI's +// package-artifact job; these tests pin the decision logic. + +const SCRIPT_PATH = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../../scripts/check/check-pack-boot.mjs" +); + +test("pickTarball extracts the filename from npm pack --json output", () => { + assert.equal(pickTarball('[{"filename":"omniroute-3.8.49.tgz","size":1}]'), "omniroute-3.8.49.tgz"); +}); + +test("pickTarball normalizes scoped slashes to the on-disk dash form", () => { + assert.equal(pickTarball('[{"filename":"@scope/pkg-1.0.0.tgz"}]'), "@scope-pkg-1.0.0.tgz"); +}); + +test("pickTarball throws on empty/odd npm output instead of booting garbage", () => { + assert.throws(() => pickTarball("[]")); + assert.throws(() => pickTarball("{}")); +}); + +test("evaluateBoot passes on HTTP 200 + matching version, whatever the health status", () => { + const r = evaluateBoot(200, { version: "3.8.49", status: "warning" }, "3.8.49"); + assert.equal(r.ok, true); + assert.deepEqual(r.failures, []); +}); + +test("evaluateBoot fails on non-200, non-JSON body, and version mismatch", () => { + assert.equal(evaluateBoot(503, { version: "3.8.49" }, "3.8.49").ok, false); + assert.equal(evaluateBoot(200, null, "3.8.49").ok, false); + const wrong = evaluateBoot(200, { version: "3.8.48" }, "3.8.49"); + assert.equal(wrong.ok, false); + assert.match(wrong.failures[0], /3\.8\.48/); +}); + +test("pickPort stays inside the reserved smoke range for any pid", () => { + for (const seed of [0, 1, 4000, 65535, 123456]) { + const p = pickPort(seed); + assert.ok(p >= 23000 && p < 27000, `port ${p} out of range for seed ${seed}`); + } +}); + +test("source guard: the gate polls the real health endpoint of the INSTALLED binary", () => { + const src = readFileSync(SCRIPT_PATH, "utf8"); + assert.ok(src.includes('"install", "-g", "--prefix"'), "must install the packed tarball into a clean prefix"); + assert.ok(src.includes("/api/monitoring/health"), "must poll the health endpoint"); + assert.ok(src.indexOf("npm") < src.indexOf("spawn"), "pack+install must precede the boot spawn"); +});