Compare commits
2 commits
0f912547cd
...
c6479d6811
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6479d6811 | ||
|
|
1a13b9d58c |
4 changed files with 244 additions and 1 deletions
|
|
@ -2,4 +2,4 @@
|
||||||
|
|
||||||
Authoritative source for pipeline (ADR-014) — not shipped. emergency/schedule/map/info/assets.
|
Authoritative source for pipeline (ADR-014) — not shipped. emergency/schedule/map/info/assets.
|
||||||
|
|
||||||
Stage 6: implemented — canonical source sheets `emergency/source.json`, `schedule/source.json`, `map/source.json`, `info/source.json`. The current sheets model SolarPunk Summit 2026, sourced from the public site on 2026-08-31. Stable IDs and festival-zone `dayKey` values are used; the site does not provide verified emergency phone numbers or machine-readable POI coordinates, so those fields remain conservative rather than fabricated. Same emergency source generates both `emergency.json` and emergency floor `≤16KB` via pipeline/emergency.ts (no drift per ARCH 10.3). See IMPLEMENTATION-CONTRACT.md §4 responsibilities and §6 B-1…B-7.
|
Stage 6: implemented — canonical source sheets `emergency/source.json`, `schedule/source.json`, `map/source.json`, `info/source.json`. The current sheets model SolarPunk Summit 2026, sourced from the public site on 2026-08-31. Stable IDs and festival-zone `dayKey` values are used; the site does not provide verified emergency phone numbers or machine-readable POI coordinates, so those fields remain conservative rather than fabricated. Same emergency source generates both `emergency.json` and emergency floor `≤16KB` via pipeline/emergency.ts (no drift per ARCH 10.3). Runbook CLI: `npm run package:staging` builds+signs the `lumen-2026` staging edition from these sheets and writes the origin layout (`/editions/<ed>/packages/<v>/…` + mutable `latest.json`) under `instance/origin/`, then smoke re-fetches and re-verifies every file hash, the manifest hash, and the Ed25519 signature (test key only; `npm run package:smoke` re-verifies without rebuilding). See IMPLEMENTATION-CONTRACT.md §4 responsibilities and §6 B-1…B-7.
|
||||||
|
|
|
||||||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"typescript-eslint": "^8.26.1",
|
"typescript-eslint": "^8.26.1",
|
||||||
"vite": "^6.4.3",
|
"vite": "^6.4.3",
|
||||||
|
"vite-node": "^3.0.0",
|
||||||
"vitest": "^3.1.1"
|
"vitest": "^3.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"package:staging": "vite-node pipeline/run.ts",
|
||||||
|
"package:smoke": "vite-node pipeline/run.ts --smoke-only",
|
||||||
"ci": "npm run typecheck && npm run lint && npm run format && npm run test && npm run build"
|
"ci": "npm run typecheck && npm run lint && npm run format && npm run test && npm run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -33,6 +35,7 @@
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"typescript-eslint": "^8.26.1",
|
"typescript-eslint": "^8.26.1",
|
||||||
"vite": "^6.4.3",
|
"vite": "^6.4.3",
|
||||||
|
"vite-node": "^3.0.0",
|
||||||
"vitest": "^3.1.1"
|
"vitest": "^3.1.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
239
pipeline/run.ts
Normal file
239
pipeline/run.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
/* eslint-disable no-console -- this is the publisher CLI; stdout is its interface */
|
||||||
|
/**
|
||||||
|
* Stage 6 runbook CLI — validate → build → hash → sign → upload → smoke.
|
||||||
|
* `node_modules/.bin/vite-node pipeline/run.ts [--edition lumen-2026] [--version 1] [--out instance/origin]`
|
||||||
|
*
|
||||||
|
* Upload target is a LOCAL directory laid out exactly like the hosting origin
|
||||||
|
* (contract §37): /editions/<ed>/packages/<v>/manifest.json|signature.json|
|
||||||
|
* sections|assets, mutable /latest.json, compiled emergency floor. Smoke step
|
||||||
|
* re-reads the uploaded bytes from disk and re-verifies every file hash,
|
||||||
|
* manifest hash, and the Ed25519 signature — a genuine round-trip, test key only.
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md Stage 6, SPIKE-04 §3 gate 5, ARCH 18.7.
|
||||||
|
*/
|
||||||
|
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { buildPackage } from "./package.js";
|
||||||
|
import { generateTestKeyPair, fingerprintFromPublicPem } from "./sign.js";
|
||||||
|
import { sha256Hex } from "./hash.js";
|
||||||
|
import { verify } from "node:crypto";
|
||||||
|
import { canonicalJson } from "./canonical-json.js";
|
||||||
|
import type { PipelineInput, EmergencySource } from "./types.js";
|
||||||
|
import { dayKeyFor } from "../src/domain/clock/logic.js";
|
||||||
|
|
||||||
|
// ——— CLI args ———
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
function arg(name: string, dflt: string): string {
|
||||||
|
const i = argv.indexOf(`--${name}`);
|
||||||
|
const v = i >= 0 ? argv[i + 1] : undefined;
|
||||||
|
return v !== undefined && v.length > 0 ? v : dflt;
|
||||||
|
}
|
||||||
|
const edition = arg("edition", "lumen-2026");
|
||||||
|
const version = Number(arg("version", "1"));
|
||||||
|
const outDir = arg("out", "instance/origin");
|
||||||
|
const smokeOnly = argv.includes("--smoke-only");
|
||||||
|
|
||||||
|
function log(step: string, msg: string): void {
|
||||||
|
console.log(`[${step}] ${msg}`);
|
||||||
|
}
|
||||||
|
function die(msg: string): never {
|
||||||
|
console.error(`FAIL: ${msg}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Load provisional content sheets (Stage 6: placeholder, not real organizer data) ———
|
||||||
|
function loadSheet(section: string): Record<string, unknown> {
|
||||||
|
const p = join("content", section, "source.json");
|
||||||
|
if (!existsSync(p)) die(`missing content sheet ${p}`);
|
||||||
|
return JSON.parse(readFileSync(p, "utf8")) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emergency = loadSheet("emergency") as unknown as EmergencySource;
|
||||||
|
const schedule = loadSheet("schedule");
|
||||||
|
const mapSheet = loadSheet("map");
|
||||||
|
const info = loadSheet("info");
|
||||||
|
|
||||||
|
// Festival window: SolarPunk Summit 2026 (provisional, from public site Aug 31).
|
||||||
|
// Derived from the sheet's own event span so gate3 window checks are honest.
|
||||||
|
const FEST_TZ = "America/Chicago";
|
||||||
|
const allEvents = schedule.events as Array<{ startUtc: number; endUtc: number }>;
|
||||||
|
const startUtc = Math.min(...allEvents.map((e) => e.startUtc));
|
||||||
|
const endUtc = Math.max(...allEvents.map((e) => e.endUtc));
|
||||||
|
|
||||||
|
// Patch dayKey so gate3 can pass against the sheets' own startUtc values —
|
||||||
|
// source sheets stay hand-editable; the pipeline stamps derived fields.
|
||||||
|
const events = (schedule.events as Array<Record<string, unknown>>).map((ev) => ({
|
||||||
|
...ev,
|
||||||
|
dayKey: dayKeyFor(ev.startUtc as number, FEST_TZ),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Provisional placeholder assets: tiny deterministic blobs standing in for art
|
||||||
|
// (contract: "Real organizer content — provisional placeholder" only).
|
||||||
|
function placeholderPng(id: string): Uint8Array {
|
||||||
|
// 1x1 PNG-ish deterministic payload; hash/size budgets are what matter here.
|
||||||
|
const marker = new TextEncoder().encode(`lumen-placeholder:${id}`);
|
||||||
|
const pngHeader = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ...marker]);
|
||||||
|
return pngHeader;
|
||||||
|
}
|
||||||
|
const mapLevelIds = (
|
||||||
|
(mapSheet.base as { levels: Array<{ id: string; assetId: string }> }).levels ?? []
|
||||||
|
).map((l) => l.assetId);
|
||||||
|
const assetIds = mapLevelIds.length > 0 ? mapLevelIds : ["map-base-overview"];
|
||||||
|
const blobs = new Map<string, Uint8Array>();
|
||||||
|
for (const id of assetIds) blobs.set(id, placeholderPng(id));
|
||||||
|
const assetsInventory = {
|
||||||
|
assets: assetIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
file: `assets/${id}.png`,
|
||||||
|
kind: "map-base" as const,
|
||||||
|
role: id.includes("detail") ? "detail" : "overview",
|
||||||
|
sha256: "",
|
||||||
|
bytes: 0,
|
||||||
|
})),
|
||||||
|
blobs,
|
||||||
|
};
|
||||||
|
|
||||||
|
const input: PipelineInput = {
|
||||||
|
edition,
|
||||||
|
packageVersion: version,
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
festival: { name: "SolarPunk Summit 2026", timezone: FEST_TZ, startUtc, endUtc },
|
||||||
|
appCompatibility: { minAppVersion: "1.0.0", maxAppVersion: null },
|
||||||
|
content: {
|
||||||
|
emergency,
|
||||||
|
schedule: { ...schedule, events } as unknown as PipelineInput["content"]["schedule"],
|
||||||
|
map: mapSheet as unknown as PipelineInput["content"]["map"],
|
||||||
|
info: info as unknown as PipelineInput["content"]["info"],
|
||||||
|
assets: assetsInventory,
|
||||||
|
},
|
||||||
|
previous: null,
|
||||||
|
previousPackageVersion: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ——— Smoke: re-read the "origin" tree and re-verify everything ———
|
||||||
|
function smokeCheck(originRoot: string, publicKeyPem: string | null): void {
|
||||||
|
log("smoke", "re-fetching from origin and re-verifying");
|
||||||
|
const latestRaw = JSON.parse(readFileSync(join(originRoot, "latest.json"), "utf8")) as {
|
||||||
|
edition: string;
|
||||||
|
packageVersion: number;
|
||||||
|
manifestUrl: string;
|
||||||
|
};
|
||||||
|
const smokePkgDir = join(
|
||||||
|
originRoot,
|
||||||
|
"editions",
|
||||||
|
latestRaw.edition,
|
||||||
|
"packages",
|
||||||
|
String(latestRaw.packageVersion),
|
||||||
|
);
|
||||||
|
const manifestBytes = new TextEncoder().encode(
|
||||||
|
readFileSync(join(smokePkgDir, "manifest.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as {
|
||||||
|
sections: Record<string, { file: string; sha256: string; bytes: number }>;
|
||||||
|
limits: { totalBytes: number };
|
||||||
|
};
|
||||||
|
const sig = JSON.parse(readFileSync(join(smokePkgDir, "signature.json"), "utf8")) as {
|
||||||
|
manifestSha256: string;
|
||||||
|
signature: string;
|
||||||
|
publicKeyFingerprint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// hash of the manifest bytes must match signature
|
||||||
|
if (sha256Hex(manifestBytes) !== sig.manifestSha256) die("smoke: manifest hash mismatch");
|
||||||
|
|
||||||
|
// Ed25519 verify over sha256(manifest bytes). With no key given, resolve the
|
||||||
|
// public key from the published publicKey.pem and check its fingerprint.
|
||||||
|
let verifyPem = publicKeyPem;
|
||||||
|
if (!verifyPem) {
|
||||||
|
const pubPemPath = join(smokePkgDir, "publicKey.pem");
|
||||||
|
if (!existsSync(pubPemPath)) die("smoke: no key to verify with (publicKey.pem missing)");
|
||||||
|
const pem = readFileSync(pubPemPath, "utf8");
|
||||||
|
if (fingerprintFromPublicPem(pem) !== sig.publicKeyFingerprint)
|
||||||
|
die("smoke: published publicKey.pem fingerprint mismatch");
|
||||||
|
verifyPem = pem;
|
||||||
|
}
|
||||||
|
const okSig = verify(
|
||||||
|
null,
|
||||||
|
new Uint8Array(Buffer.from(sha256Hex(manifestBytes), "hex")),
|
||||||
|
{ key: verifyPem, format: "pem", type: "spki" },
|
||||||
|
Buffer.from(sig.signature, "base64"),
|
||||||
|
);
|
||||||
|
if (!okSig) die("smoke: signature verification FAILED");
|
||||||
|
if (fingerprintFromPublicPem(verifyPem) !== sig.publicKeyFingerprint)
|
||||||
|
die("smoke: fingerprint mismatch");
|
||||||
|
|
||||||
|
// every section + asset file: re-read from disk, recompute sha256, compare
|
||||||
|
let checked = 0;
|
||||||
|
for (const [name, meta] of Object.entries(manifest.sections)) {
|
||||||
|
const p = join(smokePkgDir, meta.file);
|
||||||
|
if (!existsSync(p)) die(`smoke: section ${name} not reachable at ${meta.file}`);
|
||||||
|
const bytes = readFileSync(p);
|
||||||
|
if (bytes.length !== meta.bytes) die(`smoke: ${meta.file} size mismatch`);
|
||||||
|
if (sha256Hex(new Uint8Array(bytes)) !== meta.sha256) die(`smoke: ${meta.file} sha mismatch`);
|
||||||
|
checked++;
|
||||||
|
}
|
||||||
|
// assets inventory carries per-asset hashes
|
||||||
|
const assetsJson = JSON.parse(readFileSync(join(smokePkgDir, "assets.json"), "utf8")) as {
|
||||||
|
assets: Array<{ id: string; file: string; sha256: string; bytes: number }>;
|
||||||
|
};
|
||||||
|
for (const a of assetsJson.assets) {
|
||||||
|
const p = join(smokePkgDir, a.file);
|
||||||
|
if (!existsSync(p)) die(`smoke: asset ${a.id} missing`);
|
||||||
|
const bytes = readFileSync(p);
|
||||||
|
if (sha256Hex(new Uint8Array(bytes)) !== a.sha256) die(`smoke: asset ${a.file} sha mismatch`);
|
||||||
|
checked++;
|
||||||
|
}
|
||||||
|
log(
|
||||||
|
"smoke",
|
||||||
|
`PASS — ${String(checked)} files re-fetched + hash-verified, signature + fingerprint verified, latest.json consistent`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— --smoke-only: skip build/sign/upload, verify what is already on disk ———
|
||||||
|
if (smokeOnly) {
|
||||||
|
smokeCheck(outDir, null);
|
||||||
|
console.log("STAGE6 RUNBOOK OK");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— 1-2-3-4: validate + build + hash + sign (buildPackage runs gates 1-5) ———
|
||||||
|
log("validate/build/hash", `building ${edition} v${version}`);
|
||||||
|
const kp = generateTestKeyPair();
|
||||||
|
const built = buildPackage(input, { signWith: kp });
|
||||||
|
if (!built.ok) die(`build rejected: ${built.reason}`);
|
||||||
|
const pkg = built.pkg;
|
||||||
|
const totalBytes =
|
||||||
|
[...pkg.files.values()].reduce((s, f) => s + f.bytes, 0) +
|
||||||
|
pkg.assets.reduce((s, a) => s + a.bytes, 0);
|
||||||
|
log(
|
||||||
|
"validate/build/hash",
|
||||||
|
`ok — ${pkg.files.size} sections + ${pkg.assets.length} assets, ${String(totalBytes)} bytes (budget ≤40MB: ${totalBytes <= 40 * 1024 * 1024 ? "PASS" : "FAIL"})`,
|
||||||
|
);
|
||||||
|
if (pkg.floorBytes > 16 * 1024) die(`emergency floor ${String(pkg.floorBytes)} > 16KB`);
|
||||||
|
log("floor", `emergency floor compiled: ${String(pkg.floorBytes)} bytes (≤16KB)`);
|
||||||
|
if (!pkg.signature) die("expected signature with test key");
|
||||||
|
log("sign", `signed with test key ${kp.fingerprint.slice(0, 18)}…`);
|
||||||
|
|
||||||
|
// ——— 5: upload immutable files + flip latest.json (local dir = origin layout §37) ———
|
||||||
|
const pkgDir = join(outDir, "editions", edition, "packages", String(version));
|
||||||
|
mkdirSync(join(pkgDir, "assets"), { recursive: true });
|
||||||
|
for (const [, f] of pkg.files) {
|
||||||
|
writeFileSync(join(pkgDir, f.file), f.canonicalBytes);
|
||||||
|
}
|
||||||
|
for (const a of pkg.assets) {
|
||||||
|
writeFileSync(join(pkgDir, a.file), a.bytesContent);
|
||||||
|
}
|
||||||
|
writeFileSync(join(pkgDir, "manifest.json"), canonicalJson(pkg.manifest));
|
||||||
|
writeFileSync(join(pkgDir, "signature.json"), JSON.stringify(pkg.signature, null, 2));
|
||||||
|
// TEST-ONLY: publish the test public key next to the package so `--smoke-only`
|
||||||
|
// can re-verify offline. Production trust pins the key inside the app shell
|
||||||
|
// (Stage 7 verifier) — never rely on a key published alongside the data.
|
||||||
|
writeFileSync(join(pkgDir, "publicKey.pem"), kp.publicKeyPem);
|
||||||
|
writeFileSync(join(pkgDir, "emergency-floor.json"), JSON.stringify(pkg.emergencyFloor, null, 2));
|
||||||
|
// Mutable pointer — last write, so immutable files always exist before flip.
|
||||||
|
writeFileSync(join(outDir, "latest.json"), JSON.stringify(pkg.latest, null, 2));
|
||||||
|
log("upload", `wrote immutable tree under ${pkgDir}/ + latest.json flip`);
|
||||||
|
|
||||||
|
// ——— 6: smoke — verify what we just uploaded (key known from this run) ———
|
||||||
|
smokeCheck(outDir, kp.publicKeyPem);
|
||||||
|
console.log("STAGE6 RUNBOOK OK");
|
||||||
Loading…
Add table
Add a link
Reference in a new issue