Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 113 additions & 4 deletions packages/extension/scripts/build_app_bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@
// node scripts/build_app_bundle.mjs --dist <dir> # stage an already-built dist
// node scripts/build_app_bundle.mjs --work <dir> # materialize/reuse this tree
// AMICODE_APP_BUNDLE_WORK=<dir> # --work via env
// AMICODE_DEPLOY_OVERRIDE=<reason> # #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.
// 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
Expand All @@ -23,9 +36,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, "..", "..");
Expand All @@ -48,14 +68,101 @@ 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.
//
// 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");
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 =
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) {
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);
}
// 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");
rmSync(target, { recursive: true, force: true });
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");
Expand All @@ -64,11 +171,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"))) {
Expand Down Expand Up @@ -97,4 +206,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);
171 changes: 171 additions & 0 deletions packages/extension/scripts/deploy_guard.mjs
Original file line number Diff line number Diff line change
@@ -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=<reason> 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=<reason> 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<string, number \| undefined>/,
},
{
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/<every locale dict>",
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/<every locale dict>") {
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
};
}
Loading
Loading