mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
631bccd0b4
commit
405feee806
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
1
changelog.d/fixes/pack-boot-smoke-gate.md
Normal file
1
changelog.d/fixes/pack-boot-smoke-gate.md
Normal 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`
|
||||
@@ -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",
|
||||
|
||||
166
scripts/check/check-pack-boot.mjs
Normal file
166
scripts/check/check-pack-boot.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
57
tests/unit/check-pack-boot.test.ts
Normal file
57
tests/unit/check-pack-boot.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user