feat(ci): boot-smoke the packed npm tarball (check:pack-boot, #7065 class killer) (#7086)

Three releases shipped a tarball that crashed on every boot (tls-options/3.8.41,
head-response-guard #7040/#7065) because no gate ever EXECUTED the artifact.
check:pack-boot packs the tree, installs the tarball into a clean prefix
(postinstall runs for real), boots the installed CLI on a reserved port with an
isolated DATA_DIR and polls /api/monitoring/health until it returns 200 with
the packed version — failing loudly with the server's last output otherwise.
Wired into the CI package-artifact job (reuses the dist/ the job already
assembles) and into check:release-green --with-build (parallel slow wave).
Live evidence: packed v3.8.49, installed and booted in 16.6s, health 200.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-14 05:23:34 -03:00
committed by GitHub
parent 631bccd0b4
commit 405feee806
6 changed files with 238 additions and 0 deletions

View File

@@ -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

View File

@@ -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`

View File

@@ -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",

View File

@@ -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);
});
}

View File

@@ -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(

View File

@@ -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");
});