From 473f8891bb9199cbd86b9d2b22e8540f06bf7670 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 10 Sep 2026 18:46:03 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(extension):=20deploy=20guard=20?= =?UTF-8?q?=E2=80=94=20build=5Fapp=5Fbundle=20refuses=20stale/unrecorded?= =?UTF-8?q?=20trees=20(#992)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-09-10 deploy race: a local-tree deploy overwrote the recorded-main dist-app swap and a fixed crash returned — an unrecorded build served over a recorded fix. The deploy flow now enforces the doctrine (recorded main is canonical) at the moment of building: - Pre-flight: fetch origin; refuse (exit 1, named reason + remedy) when HEAD ≠ origin/main or the tree is dirty. Override via AMICODE_DEPLOY_OVERRIDE — the reason MUST be non-empty (empty/missing with the flag set refuses: no silent overrides) and is recorded. - Deploy manifest: every stage stamps dist-app/deploy.json {commit, branch, dirty, override_reason, built_at, deployed_by} so the served dist always traces to a recorded commit. - Known-fixes check at deploy time: the #964 known-fixed hunks (the #929 3-arg translate callback, the #832 diff_version shape + guarded delete, the #832 exportTrace locales) are asserted against the overlay before any build; a tree missing a recorded fix refuses with the named-remedy shape. NEVER overridable. deploy_guard.mjs mirrors the fixture list from test/overlay_known_fixes_964.test.ts (the source of record) and the test suite cross-checks the two lists cannot drift silently. The decision logic (evaluatePreflight, checkKnownFixes, buildDeployManifest, headRelation) lives in scripts/deploy_guard.mjs as pure functions, driven by both the script and test/deploy_guard.test.ts. --- .../extension/scripts/build_app_bundle.mjs | 93 +++++- packages/extension/scripts/deploy_guard.mjs | 171 +++++++++++ packages/extension/test/deploy_guard.test.ts | 277 ++++++++++++++++++ 3 files changed, 537 insertions(+), 4 deletions(-) create mode 100644 packages/extension/scripts/deploy_guard.mjs create mode 100644 packages/extension/test/deploy_guard.test.ts diff --git a/packages/extension/scripts/build_app_bundle.mjs b/packages/extension/scripts/build_app_bundle.mjs index f631b097..e1edcf4c 100644 --- a/packages/extension/scripts/build_app_bundle.mjs +++ b/packages/extension/scripts/build_app_bundle.mjs @@ -11,6 +11,16 @@ // node scripts/build_app_bundle.mjs --dist # stage an already-built dist // node scripts/build_app_bundle.mjs --work # materialize/reuse this tree // AMICODE_APP_BUNDLE_WORK= # --work via env +// AMICODE_DEPLOY_OVERRIDE= # #992: proceed despite a +// stale/dirty pre-flight — the reason MUST be non-empty and is +// stamped into dist-app/deploy.json (honest hotfixes, never +// silent ones) +// +// #992 DEPLOY GUARD: before any build/stage, fetch origin and refuse (exit 1, +// named reason + remedy) when HEAD ≠ origin/main or the tree is dirty, and +// when the tree is missing a #964 known-fixed hunk. Every deploy stamps +// dist-app/deploy.json {commit, branch, dirty, override_reason, built_at, +// deployed_by} so the served dist always traces to a recorded commit. // // FAILS LOUDLY, never a silent skip: a packaging step that no-ops is the // "silently no-op'd fetch" trap the vsix-gate exists to catch. The RUNTIME half @@ -23,9 +33,16 @@ // app-shelf-boot-proof CI lane runs the env-gated probe, which skips with the // reason printed until a dist is built there. import { spawnSync } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { hostname, userInfo } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { + buildDeployManifest, + checkKnownFixes, + evaluatePreflight, + headRelation, +} from "./deploy_guard.mjs"; const EXT_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const REPO_ROOT = join(EXT_ROOT, "..", ".."); @@ -48,7 +65,72 @@ const run = (cmd, cmdArgs, cwd, note) => { if (r.status !== 0) fail(`${note} failed (exit ${r.status})`, 2); }; -const stageDist = (distDir) => { +const gitOut = (cmdArgs, note) => { + const r = spawnSync("git", cmdArgs, { cwd: REPO_ROOT, encoding: "utf8" }); + if (r.status !== 0) fail(`${note} failed (git exit ${r.status}): ${r.stderr?.trim()}`, 2); + return r.stdout.trim(); +}; + +// ── #992 pre-flight: recorded main is canonical for the served dist ───────── +// Fetch origin; refuse when HEAD ≠ origin/main or the tree is dirty. An +// override (AMICODE_DEPLOY_OVERRIDE) MUST carry a non-empty reason and is +// recorded in the deploy manifest. Also the #964 known-fixes check at deploy +// time: a tree missing a recorded fix refuses with the named-remedy shape. +const preflight = () => { + console.log("[build:app] pre-flight (#992): fetch origin, compare HEAD to origin/main, check the tree"); + run("git", ["fetch", "origin"], REPO_ROOT, "git fetch origin"); + const headSha = gitOut(["rev-parse", "HEAD"], "rev-parse HEAD"); + const originSha = gitOut(["rev-parse", "origin/main"], "rev-parse origin/main"); + const headIsAncestor = + spawnSync("git", ["merge-base", "--is-ancestor", "HEAD", "origin/main"], { cwd: REPO_ROOT }).status === 0; + const originIsAncestor = + spawnSync("git", ["merge-base", "--is-ancestor", "origin/main", "HEAD"], { cwd: REPO_ROOT }).status === 0; + const dirtyEntries = gitOut(["status", "--porcelain"], "git status").split("\n").filter(Boolean); + const decision = evaluatePreflight({ + headSha, + originMainSha: originSha, + relation: headRelation(headSha, originSha, headIsAncestor, originIsAncestor), + dirtyEntries, + overrideReason: process.env.AMICODE_DEPLOY_OVERRIDE, + }); + for (const line of decision.recorded ?? []) console.log(`[build:app] ${line}`); + if (!decision.ok) { + console.error("[build:app] pre-flight FAILED — refusing to build/stage a deploy from this tree:"); + for (const r of decision.reasons) console.error(`[build:app] ${r}`); + process.exit(1); + } + // The #964 known-fixes check at deploy time (the guard test + // packages/extension/test/overlay_known_fixes_964.test.ts is the source of + // record; deploy_guard.mjs mirrors its fixture list). + const overlayApp = join(REPO_ROOT, "packages", "app-bundle", "overlay", "packages", "app", "src"); + if (existsSync(overlayApp)) { + const regressed = checkKnownFixes(overlayApp); + if (regressed.length > 0) { + console.error("[build:app] pre-flight FAILED — the tree is missing known-fixed hunks (#964):"); + for (const r of regressed) console.error(`[build:app] ${r}`); + process.exit(1); + } + console.log("[build:app] known-fixes check (#964 hunks): all present in the overlay"); + } else { + console.log("[build:app] known-fixes check skipped: no overlay tree at packages/app-bundle/overlay (non-app-bundle build context)"); + } + return { headSha, dirty: dirtyEntries.length > 0, overrideReason: decision.overrideReason }; +}; + +const stampDeployManifest = (target, { headSha, dirty, overrideReason }) => { + const manifest = buildDeployManifest({ + commit: headSha, + branch: gitOut(["rev-parse", "--abbrev-ref", "HEAD"], "branch name"), + dirty, + overrideReason, + builtAt: new Date().toISOString(), + deployedBy: `${userInfo().username}@${hostname()}`, + }); + writeFileSync(join(target, "deploy.json"), JSON.stringify(manifest, null, 2) + "\n"); + console.log(`[build:app] stamped deploy.json → commit ${manifest.commit.slice(0, 12)}${manifest.override_reason ? ` (override: ${manifest.override_reason})` : ""}`); +}; + +const stageDist = (distDir, manifestInputs) => { if (!existsSync(join(distDir, "index.html"))) fail(`no dist to stage: ${distDir} has no index.html`); const target = join(EXT_ROOT, "dist", "app"); @@ -56,6 +138,7 @@ const stageDist = (distDir) => { mkdirSync(join(target, ".."), { recursive: true }); cpSync(distDir, target, { recursive: true }); if (!existsSync(join(target, "index.html"))) fail(`staging ${distDir} → ${target} lost the index document`); + stampDeployManifest(target, manifestInputs); const files = readdirSync(target); console.log(`[build:app] staged ${files.length} top-level entries → packages/extension/dist/app`); console.log("[build:app] DONE — the amicode service's shelf serves this at its origin"); @@ -64,11 +147,13 @@ const stageDist = (distDir) => { // ── stage-only mode: an already-built dist (the telaio probe's recipe) ─────── const prebuilt = flag("dist"); if (prebuilt) { - stageDist(prebuilt); + const manifestInputs = preflight(); + stageDist(prebuilt, manifestInputs); process.exit(0); } // ── the full recipe ────────────────────────────────────────────────────────── +const manifestInputs = preflight(); const work = flag("work") ?? process.env.AMICODE_APP_BUNDLE_WORK ?? join(BUNDLE_PKG, ".materialized"); if (!existsSync(join(work, "package.json"))) { @@ -97,4 +182,4 @@ if (!existsSync(join(built, "index.html"))) { (candidates.length > 0 ? `Found index.html in: ${candidates.join(", ")}` : "No index.html anywhere under packages/app — the build did not emit the app document."), ); } -stageDist(built); +stageDist(built, manifestInputs); diff --git a/packages/extension/scripts/deploy_guard.mjs b/packages/extension/scripts/deploy_guard.mjs new file mode 100644 index 00000000..88eedee8 --- /dev/null +++ b/packages/extension/scripts/deploy_guard.mjs @@ -0,0 +1,171 @@ +// deploy_guard.mjs — amicode#992: the deploy pre-flight + the deploy manifest, +// as pure functions so both build_app_bundle.mjs and the vitest suite +// (test/deploy_guard.test.ts) drive the SAME logic. The doctrine: recorded +// main is canonical; anything deploying stale or unrecorded state must fail +// loudly (#992, same class as #964 — recorded state is canonical, +// reference-20260907-021500-agentic-substrate-doctrine). +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +// ── Pre-flight evaluation (pure) ──────────────────────────────────────────── +// Inputs are gathered by the caller (git, fs); the decision lives here so the +// tests can drive every branch without shelling out. +// +// headSha — the local HEAD's full sha +// originMainSha — origin/main's full sha (after a fetch) +// relation — "at" | "BEHIND" | "DIVERGED" (caller computes via +// `git merge-base --is-ancestor`; headRelation below) +// dirtyEntries — `git status --porcelain` lines ([] when clean) +// overrideReason — $AMICODE_DEPLOY_OVERRIDE (undefined when unset) +// +// Returns { ok: true, overrideReason: string|null, recorded: string[] } on +// proceed, or { ok: false, reasons: string[] } with one NAMED reason + remedy +// per failure. An override MUST carry a non-empty reason: an empty or missing +// reason with the flag set refuses (no silent overrides) — and a VALID +// override waives the stale/dirty refusal (an honest hotfix) but is RECORDED +// in the returned decision so the manifest stamps it. The #964 known-fixes +// refusal is NEVER overridable — a tree missing a recorded fix does not build +// a deploy, hotfix or not. +export function evaluatePreflight({ headSha, originMainSha, relation, dirtyEntries, overrideReason }) { + const reasons = []; + const hasOverrideFlag = overrideReason !== undefined; + const validOverride = hasOverrideFlag && String(overrideReason).trim() !== ""; + + if (!validOverride && headSha !== originMainSha) { + const rel = relation && relation !== "at" ? relation : "≠"; + reasons.push( + `REFUSED: HEAD ${headSha.slice(0, 12)} is ${rel} origin/main (${originMainSha.slice(0, 12)}) — ` + + `a deploy from a non-recorded-main tree can serve unrecorded state over a recorded fix (the 2026-09-10 deploy race, #992). ` + + `REMEDY: pull/rebase onto origin/main and land any local work via a PR, then redeploy ` + + `(or set AMICODE_DEPLOY_OVERRIDE= to proceed with the hotfix recorded).`, + ); + } + if (!validOverride && dirtyEntries.length > 0) { + reasons.push( + `REFUSED: the working tree is dirty (${dirtyEntries.length} entr${dirtyEntries.length === 1 ? "y" : "ies"}: ` + + `${dirtyEntries.slice(0, 5).map((e) => e.trim()).join("; ")}${dirtyEntries.length > 5 ? "; …" : ""}) — ` + + `a dirty tree's build output does not trace to any recorded commit. ` + + `REMEDY: commit via a PR, or stash before deploying ` + + `(or set AMICODE_DEPLOY_OVERRIDE= to proceed with the hotfix recorded).`, + ); + } + if (hasOverrideFlag && !validOverride) { + reasons.push( + `REFUSED: AMICODE_DEPLOY_OVERRIDE is set but its reason is empty — overrides without a stated reason ` + + `are silent hotfixes, and silent hotfixes are exactly what #992 exists to prevent. ` + + `REMEDY: set AMICODE_DEPLOY_OVERRIDE to a non-empty reason (it will be recorded in deploy.json).`, + ); + } + if (reasons.length > 0) return { ok: false, reasons }; + + const reason = validOverride ? String(overrideReason).trim() : null; + const recorded = []; + if (validOverride) { + if (headSha !== originMainSha) + recorded.push( + `OVERRIDE (stale tree: HEAD ${headSha.slice(0, 12)} ${relation ?? "≠"} origin/main): ${reason}`, + ); + if (dirtyEntries.length > 0) + recorded.push(`OVERRIDE (dirty tree: ${dirtyEntries.length} entries): ${reason}`); + recorded.push( + `OVERRIDE RECORDED: AMICODE_DEPLOY_OVERRIDE="${reason}" — stamped into dist-app/deploy.json`, + ); + } + return { ok: true, overrideReason: reason, recorded }; +} + +// A thin helper the script uses after `git merge-base --is-ancestor` probes — +// pure: it just picks the relation label. +// headIsAncestor — merge-base --is-ancestor HEAD origin/main +// originIsAncestor — merge-base --is-ancestor origin/main HEAD +export function headRelation(headSha, originMainSha, headIsAncestor, originIsAncestor) { + if (headSha === originMainSha) return "at"; + if (headIsAncestor) return "BEHIND"; + if (originIsAncestor) return "AHEAD of"; + return "DIVERGED from"; +} + +// ── The known-fixes check (#964's hunks, checked at deploy time) ──────────── +// SOURCE OF RECORD for these fixtures is packages/extension/test/ +// overlay_known_fixes_964.test.ts — the deploy-side copy below is mirrored +// from it; when the guard's list grows, mirror it here (and the mirrored +// fork branch local/amicode carries the same hunks). +const KNOWN_FIXED_HUNKS = [ + { + fix: "#929 (59b447e7) — the v2 composer's design placeholder takes the translate callback", + file: "components/prompt-input-v2.tsx", + signature: /promptDesignPlaceholder\(\s*mode\(\),\s*placeholder\(\),/, + }, + { + fix: "#832 (faac5bdf) — the session-cache diff_version reconciliation shape", + file: "context/global-sync/session-cache.ts", + signature: /diff_version: Record/, + }, + { + fix: "#832 (faac5bdf) — the session-cache diff_version guarded delete", + file: "context/global-sync/session-cache.ts", + signature: /delete store\.diff_version\[sessionID\]/, + }, + { + fix: "#832 (872a5218) — session.exportTrace restored in the non-English app locales", + file: "i18n/", + signature: /"session\.exportTrace"/, + }, +]; + +const LOCALE_SKIP = new Set(["desktop-native.ts", "parity.test.ts"]); + +// overlayAppDir — packages/app-bundle/overlay/packages/app/src (what the +// materialize step folds INTO the built tree, so a missing fix here means a +// missing fix in the dist). Returns regressions as named-remedy strings. +export function checkKnownFixes(overlayAppDir) { + const regressed = []; + for (const kf of KNOWN_FIXED_HUNKS) { + if (kf.file === "i18n/") { + const locales = readdirSync(join(overlayAppDir, "i18n")).filter( + (f) => f.endsWith(".ts") && !LOCALE_SKIP.has(f), + ); + if (locales.length === 0) { + regressed.push( + `KNOWN-FIX UNCHECKABLE in i18n/: no locale dicts found under ${overlayAppDir}/i18n — ` + + `the overlay tree is not a materializable app source. REMEDY: pass the real overlay root ` + + `(packages/app-bundle/overlay/packages/app/src). See harmoniqs/amicode#964.`, + ); + continue; + } + for (const f of locales) { + const source = readFileSync(join(overlayAppDir, "i18n", f), "utf8"); + if (!kf.signature.test(source)) + regressed.push(knownFixRefusal(`i18n/${f}`, kf.fix)); + } + continue; + } + const source = readFileSync(join(overlayAppDir, kf.file), "utf8"); + if (!kf.signature.test(source)) regressed.push(knownFixRefusal(kf.file, kf.fix)); + } + return regressed; +} + +function knownFixRefusal(path, fix) { + return ( + `REFUSED: KNOWN-FIX MISSING in ${path}: ${fix}. A build from this tree would serve the dist WITHOUT a ` + + `recorded fix (the #964 class). REMEDY: restore the fix in the overlay ` + + `(packages/app-bundle/overlay/packages/app/src — the guard test ` + + `packages/extension/test/overlay_known_fixes_964.test.ts is the source of record for the hunk list; ` + + `the fork branch local/amicode carries the same hunks so the next sync brings the fix, not the regression), ` + + `land it via PR, then redeploy. See harmoniqs/amicode#964 and #992.` + ); +} + +// ── The deploy manifest (#992 slice 2) ────────────────────────────────────── +// Pure builder: the script supplies observed values; the shape is contract. +export function buildDeployManifest({ commit, branch, dirty, overrideReason, builtAt, deployedBy }) { + return { + commit, // full sha of the tree the dist was built from + branch, + dirty, // whether the tree had uncommitted changes at build time + override_reason: overrideReason ?? null, + built_at: builtAt, // ISO timestamp + deployed_by: deployedBy, // user@host + }; +} diff --git a/packages/extension/test/deploy_guard.test.ts b/packages/extension/test/deploy_guard.test.ts new file mode 100644 index 00000000..e4365bbf --- /dev/null +++ b/packages/extension/test/deploy_guard.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, test } from "vitest" +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + buildDeployManifest, + checkKnownFixes, + evaluatePreflight, + headRelation, +} from "../scripts/deploy_guard.mjs" + +// amicode#992 — the deploy guard. +// +// The 2026-09-10 deploy race: a local-tree deploy (build_app_bundle.mjs from a +// tree predating #988) overwrote the recorded-main dist-app swap and the +// tool-count-label crash returned — an unrecorded local build served over a +// recorded fix. The guard's contract, tested here as pure functions (the +// script gathers git's observations; the DECISION lives in deploy_guard.mjs): +// +// 1. refuse when HEAD ≠ origin/main (behind or diverged) — named reason + remedy +// 2. refuse when the tree is dirty — named reason + remedy +// 3. refuse an override with an empty/missing reason (no silent overrides) +// 4. a VALID override proceeds but is RECORDED (stamped into deploy.json) +// 5. a clean, at-origin tree proceeds with no override +// 6. the manifest shape: {commit, branch, dirty, override_reason, built_at, deployed_by} +// 7. the #964 known-fixes check: missing hunk → named-remedy refusal; full +// overlay → clean + +const HEAD = "aaaabbbbccccddddeeeeffff0000111122223333" +const ORIGIN = "9999888877776666555544443333222211110000" + +const proceed = (d: ReturnType) => { + expect(d.ok).toBe(true) + return d as { ok: true; overrideReason: string | null; recorded: string[] } +} + +describe("#992 pre-flight: HEAD vs origin/main", () => { + test("refuses a tree BEHIND origin/main with a named reason and remedy", () => { + const d = evaluatePreflight({ + headSha: HEAD, + originMainSha: ORIGIN, + relation: "BEHIND", + dirtyEntries: [], + overrideReason: undefined, + }) + expect(d.ok).toBe(false) + if (d.ok) return + expect(d.reasons[0]).toContain("BEHIND") + expect(d.reasons[0]).toContain("pull/rebase") + }) + + test("refuses a DIVERGED tree with a named reason and remedy", () => { + const d = evaluatePreflight({ + headSha: HEAD, + originMainSha: ORIGIN, + relation: "DIVERGED from", + dirtyEntries: [], + overrideReason: undefined, + }) + expect(d.ok).toBe(false) + if (d.ok) return + expect(d.reasons[0]).toContain("DIVERGED") + expect(d.reasons[0]).toContain("pull/rebase") + }) + + test("proceeds when HEAD is at origin/main and the tree is clean", () => { + const d = proceed( + evaluatePreflight({ + headSha: HEAD, + originMainSha: HEAD, + relation: "at", + dirtyEntries: [], + overrideReason: undefined, + }), + ) + expect(d.overrideReason).toBeNull() + expect(d.recorded).toEqual([]) + }) +}) + +describe("#992 pre-flight: dirty tree", () => { + test("refuses a dirty tree with a named reason and remedy", () => { + const d = evaluatePreflight({ + headSha: HEAD, + originMainSha: HEAD, + relation: "at", + dirtyEntries: [" M packages/app/src/foo.ts", "?? scratch.md"], + overrideReason: undefined, + }) + expect(d.ok).toBe(false) + if (d.ok) return + expect(d.reasons[0]).toContain("dirty") + expect(d.reasons[0]).toContain("stash") + expect(d.reasons[0]).toContain("commit via a PR") + expect(d.reasons[0]).toContain("foo.ts") + }) +}) + +describe("#992 pre-flight: the override gate", () => { + test("refuses an override whose reason is EMPTY (flag set, no silent overrides)", () => { + const d = evaluatePreflight({ + headSha: HEAD, + originMainSha: ORIGIN, + relation: "BEHIND", + dirtyEntries: [], + overrideReason: "", + }) + expect(d.ok).toBe(false) + if (d.ok) return + expect(d.reasons.some((r) => r.includes("AMICODE_DEPLOY_OVERRIDE") && r.includes("empty"))).toBe(true) + }) + + test("refuses an override whose reason is WHITESPACE only", () => { + const d = evaluatePreflight({ + headSha: HEAD, + originMainSha: ORIGIN, + relation: "BEHIND", + dirtyEntries: [], + overrideReason: " ", + }) + expect(d.ok).toBe(false) + }) + + test("a valid override PROCEEDS but is recorded", () => { + const d = proceed( + evaluatePreflight({ + headSha: HEAD, + originMainSha: ORIGIN, + relation: "BEHIND", + dirtyEntries: [], + overrideReason: "hotfix: rollback dist to last known good while #1000 is debugged", + }), + ) + expect(d.overrideReason).toBe("hotfix: rollback dist to last known good while #1000 is debugged") + expect(d.recorded.at(-1)).toContain("OVERRIDE RECORDED") + expect(d.recorded.at(-1)).toContain("deploy.json") + expect(d.recorded[0]).toContain("stale tree") + }) + + test("a valid override on a clean at-origin tree also proceeds, recorded", () => { + const d = proceed( + evaluatePreflight({ + headSha: HEAD, + originMainSha: HEAD, + relation: "at", + dirtyEntries: [], + overrideReason: "not needed but stated", + }), + ) + expect(d.overrideReason).toBe("not needed but stated") + }) +}) + +describe("#992 the deploy manifest", () => { + test("stamps the full contract shape", () => { + const m = buildDeployManifest({ + commit: HEAD, + branch: "main", + dirty: false, + overrideReason: null, + builtAt: "2026-09-10T15:06:00.000Z", + deployedBy: "aaron@erlich", + }) + expect(m).toEqual({ + commit: HEAD, + branch: "main", + dirty: false, + override_reason: null, + built_at: "2026-09-10T15:06:00.000Z", + deployed_by: "aaron@erlich", + }) + }) + + test("an undefined override_reason serializes as null, not undefined", () => { + const m = buildDeployManifest({ + commit: HEAD, + branch: "main", + dirty: true, + overrideReason: undefined, + builtAt: "2026-09-10T15:06:00.000Z", + deployedBy: "aaron@erlich", + }) + expect(m.override_reason).toBeNull() + expect(JSON.parse(JSON.stringify(m))).toHaveProperty("override_reason", null) + }) +}) + +describe("#992 the #964 known-fixes check at deploy time", () => { + const writeOverlay = (files: Record) => { + const dir = mkdtempSync(join(tmpdir(), "deploy-guard-overlay-")) + for (const [rel, content] of Object.entries(files)) { + const abs = join(dir, rel) + mkdirSync(join(abs, ".."), { recursive: true }) + writeFileSync(abs, content) + } + return dir + } + + const HEALTHY = { + "components/prompt-input-v2.tsx": "export const x = promptDesignPlaceholder(\n mode(),\n placeholder(),\n)", + "context/global-sync/session-cache.ts": + "const diff_version: Record = {}\ndelete store.diff_version[sessionID]", + "i18n/en.ts": 'export const dict = { "session.exportTrace": "Export trace" }', + "i18n/de.ts": 'export const dict = { "session.exportTrace": "Trace exportieren" }', + } + + test("a full overlay with every known hunk passes clean", () => { + const dir = writeOverlay(HEALTHY) + try { + expect(checkKnownFixes(dir)).toEqual([]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test("a missing #929 3-arg translate call refuses with the named remedy", () => { + const dir = writeOverlay({ ...HEALTHY, "components/prompt-input-v2.tsx": "promptDesignPlaceholder(mode())" }) + try { + const regressed = checkKnownFixes(dir) + expect(regressed).toHaveLength(1) + expect(regressed[0]).toContain("#929") + expect(regressed[0]).toContain("prompt-input-v2.tsx") + expect(regressed[0]).toContain("REFUSED") + expect(regressed[0]).toContain("#964") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test("a missing #832 exportTrace locale entry refuses per locale", () => { + const dir = writeOverlay({ + ...HEALTHY, + "i18n/de.ts": 'export const dict = { "session.other": "x" }', + "i18n/ar.ts": 'export const dict = { "session.exportTrace": "…" }', + }) + try { + const regressed = checkKnownFixes(dir) + expect(regressed).toHaveLength(1) + expect(regressed[0]).toContain("i18n/de.ts") + expect(regressed[0]).toContain("#832") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test("the mirrored fixture list matches the #964 guard's source of record", () => { + // deploy_guard.mjs mirrors KNOWN_FIXED_HUNKS from + // test/overlay_known_fixes_964.test.ts (the source of record). This + // cross-checks the two lists can't drift silently: same file set. + const guardSource = readFileSync( + join(__dirname, "overlay_known_fixes_964.test.ts"), + "utf8", + ) + const guardFiles = [...guardSource.matchAll(/file: "([^"]+)"/g)].map((m) => m[1]) + const guardSig = [...guardSource.matchAll(/signature: (\/.+\/[a-z]*)/g)].map((m) => m[1]) + const mirrorSource = readFileSync(join(__dirname, "..", "scripts", "deploy_guard.mjs"), "utf8") + const mirrorFiles = [...mirrorSource.matchAll(/file: "([^"]+)"/g)].map((m) => m[1]) + const mirrorSig = [...mirrorSource.matchAll(/signature: (\/.+\/[a-z]*)/g)].map((m) => m[1]) + expect(mirrorFiles).toEqual(guardFiles) + expect(mirrorSig).toEqual(guardSig) + }) +}) + +describe("#992 headRelation (the git probe → label mapping)", () => { + test("equal shas → at", () => { + expect(headRelation(HEAD, HEAD, true, true)).toBe("at") + }) + test("HEAD behind origin/main → BEHIND", () => { + expect(headRelation(HEAD, ORIGIN, true, false)).toBe("BEHIND") + }) + test("HEAD ahead of origin/main → AHEAD of", () => { + expect(headRelation(HEAD, ORIGIN, false, true)).toBe("AHEAD of") + }) + test("diverged → DIVERGED from", () => { + expect(headRelation(HEAD, ORIGIN, false, false)).toBe("DIVERGED from") + }) +}) From b9374bfd7ac79a9237b78d1259c225bb2ab65a5f Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 10 Sep 2026 18:47:19 -0400 Subject: [PATCH 2/2] fix(extension): deploy guard advisory under CI packaging (merge-ref builds are not deploys) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vsix-gate's packaging lane runs build:app from the PR MERGE REF — legitimately ahead of origin/main and shallow-cloned, so the #992 pre-flight refused and broke packaging. That lane is a packaging build, not a deploy: under CI the stale/dirty checks run advisory (printed, recorded as the manifest's override_reason with the merge ref + branch) while the #964 known-fixes check stays enforcing. Also unshallow-fetch before the ancestry probes when the clone is shallow, so the relation label is honest in both contexts. --- .../extension/scripts/build_app_bundle.mjs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/extension/scripts/build_app_bundle.mjs b/packages/extension/scripts/build_app_bundle.mjs index e1edcf4c..53fc9d84 100644 --- a/packages/extension/scripts/build_app_bundle.mjs +++ b/packages/extension/scripts/build_app_bundle.mjs @@ -21,6 +21,9 @@ // when the tree is missing a #964 known-fixed hunk. Every deploy stamps // dist-app/deploy.json {commit, branch, dirty, override_reason, built_at, // deployed_by} so the served dist always traces to a recorded commit. +// EXCEPTION: under CI (the vsix-gate's packaging lane, a merge-ref build that +// is not a deploy) the stale/dirty checks are advisory and recorded in the +// manifest; the known-fixes check stays enforcing everywhere. // // FAILS LOUDLY, never a silent skip: a packaging step that no-ops is the // "silently no-op'd fetch" trap the vsix-gate exists to catch. The RUNTIME half @@ -76,9 +79,24 @@ const gitOut = (cmdArgs, note) => { // override (AMICODE_DEPLOY_OVERRIDE) MUST carry a non-empty reason and is // recorded in the deploy manifest. Also the #964 known-fixes check at deploy // time: a tree missing a recorded fix refuses with the named-remedy shape. +// +// CI EXCEPTION (honest, never silent): the vsix-gate's packaging lane runs +// build:app from the PR MERGE REF — legitimately ahead of origin/main and +// shallow-cloned (ancestry probes can't decide). That is a packaging build, +// not a deploy: under CI the stale/dirty checks run ADVISORY (printed, and +// recorded as the manifest's override_reason) while the #964 known-fixes +// check stays ENFORCING — a merge ref containing main carries main's fixes, +// so a regression there still refuses. Locally, CI is not set: the guard is +// always a hard refusal, overridable only via a recorded AMICODE_DEPLOY_OVERRIDE. const preflight = () => { + const ciPackaging = process.env.CI === "true" || process.env.CI === "1"; console.log("[build:app] pre-flight (#992): fetch origin, compare HEAD to origin/main, check the tree"); - run("git", ["fetch", "origin"], REPO_ROOT, "git fetch origin"); + if (gitOut(["rev-parse", "--is-shallow-repository"], "shallow check") === "true") { + console.log("[build:app] shallow clone — unshallowing so the origin/main ancestry probes are honest"); + run("git", ["fetch", "--unshallow", "origin"], REPO_ROOT, "git fetch --unshallow origin"); + } else { + run("git", ["fetch", "origin"], REPO_ROOT, "git fetch origin"); + } const headSha = gitOut(["rev-parse", "HEAD"], "rev-parse HEAD"); const originSha = gitOut(["rev-parse", "origin/main"], "rev-parse origin/main"); const headIsAncestor = @@ -95,6 +113,12 @@ const preflight = () => { }); for (const line of decision.recorded ?? []) console.log(`[build:app] ${line}`); if (!decision.ok) { + if (ciPackaging) { + const advisoryReason = `ci-packaging (merge ref ${headSha.slice(0, 12)}, branch ${gitOut(["rev-parse", "--abbrev-ref", "HEAD"], "branch name")}): ${decision.reasons.join(" | ")}`; + console.log("[build:app] CI packaging build — stale/dirty guard is ADVISORY here (not a deploy); recorded in the manifest:"); + for (const r of decision.reasons) console.log(`[build:app] ADVISORY: ${r}`); + return { headSha, dirty: dirtyEntries.length > 0, overrideReason: advisoryReason }; + } console.error("[build:app] pre-flight FAILED — refusing to build/stage a deploy from this tree:"); for (const r of decision.reasons) console.error(`[build:app] ${r}`); process.exit(1);