From 08e0f276b8f2b8d15855fda0ec4666859f35538d Mon Sep 17 00:00:00 2001 From: aaron Date: Fri, 21 Aug 2026 04:14:43 -0400 Subject: [PATCH] =?UTF-8?q?feat(app-bundle):=20M2=20drift=20gate=20?= =?UTF-8?q?=E2=80=94=20overlay/manifest=20sync=20enforced=20in=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final M2 machinery piece: scripts/drift_gate.mjs + a dedicated CI job. Three checks: (1) manifest pins resolve at the tagged commits; (2) with a fork clone present, a fresh extraction (--out, never touching committed state) must reproduce the committed manifest byte-identically; (3) always, the committed overlay/ matches the manifest — every file's hash, no strays, no missing. Negative-tested by hand-edit (caught, exit 1, clean restore). On public CI the gate protects the committed state; the re-derivation runs wherever the private fork is reachable. Closes the M2 extraction worklist: overlay (a)+(b), materializer, manifest, drift gate, CI. Remaining M2 is consumer-side (deck panes → service origin, CSP/?auth_token=) — the port-inventory decisions are M3. --- .github/workflows/ci.yml | 14 +++ packages/app-bundle/scripts/drift_gate.mjs | 112 ++++++++++++++++++ .../app-bundle/scripts/extract_overlay.mjs | 8 +- 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 packages/app-bundle/scripts/drift_gate.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b28495a..ccfbd15d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,20 @@ on: push: { branches: [main] } pull_request: jobs: + # M2 app-bundle drift gate (#451): the committed overlay + manifest must be + # exactly what the extractor produces at the pinned fork tag. On public CI + # (no fork clone) this protects the committed state — stray files, hand + # edits, manifest/disk divergence. With a fork clone present (dev machines, + # future self-hosted runners; the fork is private) it ALSO re-derives the + # extraction and requires byte-identical reproduction. + app-bundle-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: { node-version: 20 } + - name: drift gate — overlay/manifest sync (committed state; re-derivation when the fork is reachable) + run: node packages/app-bundle/scripts/drift_gate.mjs fast: runs-on: ubuntu-latest steps: diff --git a/packages/app-bundle/scripts/drift_gate.mjs b/packages/app-bundle/scripts/drift_gate.mjs new file mode 100644 index 00000000..8efe43fa --- /dev/null +++ b/packages/app-bundle/scripts/drift_gate.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +// M2 CI drift gate (#451) — verifies the committed overlay + manifest are in +// sync with what the fork at the pinned tag would extract, WITHOUT touching +// them. Runs in CI on PRs that touch packages/app-bundle. +// +// Checks, in order: +// 1. manifest.json's fork_tag/fork_sha and upstream_base/upstream_base_sha +// resolve in the fork at the pinned commits. +// 2. A fresh extraction (into a temp dir, via --out) reproduces the +// committed manifest EXACTLY (same file set, same per-file hashes). +// 3. Every file in the committed overlay/ is in the manifest and exists on +// disk with the manifest's hash (catches hand-edits + stray files). +// +// node scripts/drift_gate.mjs [--fork ] [--tag v1.18.10-amicode.14] +// +// Exit 0 = in sync. Exit 1 = drift (names the first divergence). +// NOTE: the fork clone is a private repo — in CI the gate runs only when the +// checkout is present (AMICODE_OPENCODE_SRC or the sibling layout); otherwise +// it SKIPS with exit 0 and a printed reason (the committed overlay itself is +// the artifact CI protects; the re-derivation needs fork access). +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, rmSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; + +const PKG_ROOT = join(import.meta.dirname, ".."); +const FORK_DEFAULT = join(homedir(), "armonia", "repos", "opencode"); + +const args = process.argv.slice(2); +const flag = (n) => { + const i = args.indexOf(`--${n}`); + return i >= 0 ? args[i + 1] : undefined; +}; + +const manifestPath = join(PKG_ROOT, "manifest.json"); +const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); +const TAG = flag("tag") ?? manifest.fork_tag; +const FORK = flag("fork") ?? process.env.AMICODE_OPENCODE_SRC ?? FORK_DEFAULT; + +const fail = (msg) => { + console.error(`[drift-gate] FAIL: ${msg}`); + process.exit(1); +}; + +if (!existsSync(FORK) || !existsSync(join(FORK, ".git"))) { + console.log(`[drift-gate] SKIP: no fork clone at ${FORK} (set AMICODE_OPENCODE_SRC) — protecting committed state only`); +} + +// ── 1. manifest pins resolve ──────────────────────────────────────────────── +if (existsSync(FORK)) { + const git = (...a) => execFileSync("git", ["-C", FORK, ...a], { encoding: "utf8" }).trim(); + const tagSha = git("rev-parse", `${TAG}^{commit}`); + if (tagSha !== manifest.fork_sha) { + fail(`manifest.fork_sha ${manifest.fork_sha.slice(0, 10)} != ${TAG} (${tagSha.slice(0, 10)}) — re-run the extractor`); + } + const baseSha = git("rev-parse", `${manifest.upstream_base}^{commit}`); + if (baseSha !== manifest.upstream_base_sha) { + fail(`manifest.upstream_base_sha disagrees with ${manifest.upstream_base}`); + } + console.log(`[drift-gate] pins resolve: ${TAG} (fork) on base ${manifest.upstream_base}`); + + // ── 2. fresh extraction reproduces the committed manifest ──────────────── + const work = mkdtempSync(join(tmpdir(), "app-bundle-drift-")); + try { + execFileSync("node", [join(PKG_ROOT, "scripts", "extract_overlay.mjs"), "--fork", FORK, "--tag", TAG, "--out", work], { + cwd: PKG_ROOT, + stdio: ["ignore", "ignore", "inherit"], + }); + const fresh = JSON.parse(readFileSync(join(work, "manifest.json"), "utf8")); + if (Object.keys(fresh.files).length !== Object.keys(manifest.files).length) { + fail(`file-set drift: fresh extraction has ${Object.keys(fresh.files).length} files, committed manifest has ${Object.keys(manifest.files).length} — re-run the extractor`); + } + for (const [rel, want] of Object.entries(manifest.files)) { + if (fresh.files[rel] !== want) { + fail(`hash drift on ${rel}: committed ${want.slice(0, 10)}, fresh ${String(fresh.files[rel] ?? "(missing)").slice(0, 10)} — re-run the extractor`); + } + } + console.log(`[drift-gate] fresh extraction matches the committed manifest (${Object.keys(manifest.files).length} files)`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +// ── 3. committed overlay/ matches the manifest (always runs) ──────────────── +const overlayDir = join(PKG_ROOT, "overlay"); +const onDisk = new Set(); +for (const rel of readdirSync(overlayDir, { recursive: true })) { + const p = join(overlayDir, rel.toString()); + const st = lstatSync(p); + if (st.isSymbolicLink()) { + onDisk.add(rel.toString()); // hashed as the link-target string + continue; + } + if (st.isFile()) onDisk.add(rel.toString()); +} +const inManifest = new Set(Object.keys(manifest.files)); +const stray = [...onDisk].filter((f) => !inManifest.has(f)); +const missing = [...inManifest].filter((f) => !onDisk.has(f)); +if (stray.length > 0) fail(`stray files in overlay/ not in the manifest: ${stray.slice(0, 3).join(", ")}${stray.length > 3 ? " …" : ""}`); +if (missing.length > 0) fail(`manifest files missing from overlay/: ${missing.slice(0, 3).join(", ")}${missing.length > 3 ? " …" : ""}`); +for (const [rel, want] of Object.entries(manifest.files)) { + const p = join(overlayDir, rel); + const st = lstatSync(p, { throwIfNoEntry: false }); + if (!st) fail(`overlay file missing on disk: ${rel}`); + const h = st.isSymbolicLink() + ? createHash("sha256").update(readlinkSync(p)).digest("hex") + : createHash("sha256").update(readFileSync(p)).digest("hex"); + if (h !== want) fail(`overlay file hash mismatch (hand-edit?): ${rel} — re-run the extractor`); +} +console.log(`[drift-gate] committed overlay verified against the manifest (${onDisk.size} files)`); +console.log("[drift-gate] PASS: overlay, manifest, and (when the fork is present) the extraction are in sync"); diff --git a/packages/app-bundle/scripts/extract_overlay.mjs b/packages/app-bundle/scripts/extract_overlay.mjs index 5dfc0053..824ba022 100644 --- a/packages/app-bundle/scripts/extract_overlay.mjs +++ b/packages/app-bundle/scripts/extract_overlay.mjs @@ -56,6 +56,10 @@ const flag = (n) => { return i >= 0 ? args[i + 1] : undefined; }; +// --out: write overlay/ + manifest.json here instead of the package root (the +// drift gate re-derives without touching committed state). +const OUT_ROOT = flag("out") ?? PKG_ROOT; + const UPSTREAM_BASE = "v1.18.12"; const TAG = flag("tag") ?? "v1.18.10-amicode.14"; const SLICE = flag("slice") ?? "full"; @@ -114,7 +118,7 @@ console.log( console.log(`[extract] OVERLAY TOTAL: ${overlayFiles.length} files, ${deletions.length} deletions`); // ── 4. extract at their upstream-relative paths, AT THE TAG ────────────────── -const overlayDir = join(PKG_ROOT, "overlay"); +const overlayDir = join(OUT_ROOT, "overlay"); rmSync(overlayDir, { recursive: true, force: true }); mkdirSync(overlayDir, { recursive: true }); if (overlayFiles.length > 0) { @@ -191,5 +195,5 @@ const manifest = { classification, files: hashes, }; -writeFileSync(join(PKG_ROOT, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); +writeFileSync(join(OUT_ROOT, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); console.log(`[extract] wrote manifest.json (${overlayFiles.length} entries, ${deletions.length} deletions)`);