From f71de56dd2b1aba4a0efd10f8758c0bbda451960 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 30 Aug 2026 08:05:59 -0400 Subject: [PATCH 1/4] =?UTF-8?q?ci(amico-run):=20bundle-build=20gate=20?= =?UTF-8?q?=E2=80=94=20CI=20builds=20every=20declared=20bundle=20+=20smoke?= =?UTF-8?q?-runs=20the=20verb=20router=20(#643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI bundles (packages/amico-run/dist/*.js) are gitignored build artifacts; the deployed verb-router bundle sat 46 days stale because nothing gated the build — a broken entry or unresolvable import only failed on whichever machine last tried to build. New bundle-build-gate job (mirrors app-bundle-gate's shape): install + the package's own build + scripts/assert_built_bundles.mjs, which re-reads the package's bin map (single source of truth, same map the extension staging and assert_packaged_cli.mjs re-read) and asserts every declared bundle is built non-empty, plus a verb-router smoke (amico --help exits 0 with the usage surface) — the vitest suite transpiles and never executes the shipped artifact, so a builds-but-dies bundle reds here. Both directions pinned in test/bundle_gate.test.ts (real build green; fabricated missing-bundle and dead-router red). --- .github/workflows/ci.yml | 20 +++ .../scripts/assert_built_bundles.mjs | 134 ++++++++++++++++++ packages/amico-run/test/bundle_gate.test.ts | 95 +++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 packages/amico-run/scripts/assert_built_bundles.mjs create mode 100644 packages/amico-run/test/bundle_gate.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccfbd15d..7f2dd32f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,26 @@ jobs: 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 + bundle-build-gate: + # #643: the CLI bundles (packages/amico-run/dist/*.js) are gitignored build + # artifacts — the deployed verb-router bundle sat 46 days stale because + # nothing gated the build. This lane builds every declared bundle from + # current source on every push/PR: a broken entry or unresolvable import + # fails the build step (esbuild exits non-zero), and a build that silently + # drops or truncates a bundle reds the assert step, which also smoke-runs + # the built verb router (--help must exit 0) — the vitest suite transpiles + # and never executes the shipped artifact, so this is where a + # builds-but-dies bundle is caught. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 # version pinned by packageManager in package.json + - uses: actions/setup-node@v7 + with: { node-version: 20, cache: pnpm } + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @amicode/amico-run build + - name: bundle-gate — every declared bundle built + the verb-router smoke answers (#643) + run: node packages/amico-run/scripts/assert_built_bundles.mjs fast: runs-on: ubuntu-latest steps: diff --git a/packages/amico-run/scripts/assert_built_bundles.mjs b/packages/amico-run/scripts/assert_built_bundles.mjs new file mode 100644 index 00000000..956c443b --- /dev/null +++ b/packages/amico-run/scripts/assert_built_bundles.mjs @@ -0,0 +1,134 @@ +// The #643 bundle-build gate: the CLI package's dist bundles are gitignored +// build artifacts, and the deployed verb-router bundle went 46 days stale +// because nothing gated the build — a broken entry or unresolvable import +// only failed on whichever machine last tried to build (the incident: the +// checkout's node_modules had drifted, the local build died, the stale dist +// kept shipping). CI calls this after `pnpm --filter @amicode/amico-run build` +// (see .github/workflows/ci.yml bundle-build-gate), and the vitest suite +// exercises both directions (test/bundle_gate.test.ts). +// +// What it asserts, per DECLARED bin of the CLI package (packages/amico-run +// package.json `bin` map + `amicode.shadowBins` — the single source of truth, +// the same map the extension staging and scripts/assert_packaged_cli.mjs +// re-read): +// 1. the build produced dist/.js, non-empty (a declared-but- +// unbuilt bin reds — half-built sets are the stale-bundle signature); +// 2. the built verb router still ANSWERS: `amico --help` exits 0 printing +// the usage surface. The unit suite never proves this — vitest transpiles +// instead of bundling, so the shipped artifact is only executed here and +// by the packaging gates (the createRequire/yaml seam shipped exactly +// this way: every test green, the binary dead on first import). +// +// Usage: node scripts/assert_built_bundles.mjs [--pkg-dir ] +// --pkg-dir the amico-run package dir to gate (default: this script's ../) +import { execFile } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); + +/** Declared bins → dist file layout. Staging convention (extension + * esbuild.config.mjs): bin key K ships from launcher basename B as + * dist/.js — for both the npm `bin` map and the `amicode.shadowBins` + * map (whose key and launcher basename coincide, e.g. `gh`). */ +export function declaredDistBundles(pkgDir = PKG_ROOT) { + const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8")); + const bin = pkg.bin; + if (!bin || typeof bin !== "object" || Object.keys(bin).length === 0) + throw new Error(`${pkgDir}/package.json: no \`bin\` map — nothing to gate is a failure, not a pass`); + const fromMap = Object.entries(bin).map(([name, launcherPath]) => ({ + name, + dist: `${basename(String(launcherPath))}.js`, + })); + const shadow = Object.entries(pkg.amicode?.shadowBins ?? {}).map(([name, launcherPath]) => ({ + name, + dist: `${basename(String(launcherPath))}.js`, + })); + // dedup by dist path (a shadow key colliding with a declared basename) + const seen = new Set(); + return [...fromMap, ...shadow].filter((b) => (seen.has(b.dist) ? false : (seen.add(b.dist), true))); +} + +/** The verb-router smoke: `amico --help` is the side-effect-free invocation — + * pure usage print before any env or surface read (src/amico.ts main). */ +const ROUTER_BIN = "amico"; + +function execCapture(file, args) { + return new Promise((resolveP) => { + execFile(file, args, { timeout: 30_000, encoding: "utf8" }, (err, stdout, stderr) => { + const code = err ? (typeof err.code === "number" ? err.code : -1) : 0; + resolveP({ code, stdout: stdout ?? "", stderr: stderr ?? "" }); + }); + }); +} + +/** Run the gate. Returns { ok, results } with one row per check — { bin, + * check, ok, detail } — never throwing on a failing bundle (the caller gets + * the full picture, mirroring assert_packaged_cli.mjs). */ +export async function runBundleGate({ pkgDir = PKG_ROOT } = {}) { + const results = []; + const push = (bin, check, detail) => + results.push({ bin, check, ok: detail === null, detail: detail ?? "ok" }); + const distDir = join(pkgDir, "dist"); + const bundles = declaredDistBundles(pkgDir); + + // 1. every declared bundle built + non-empty + for (const b of bundles) { + const p = join(distDir, b.dist); + if (!existsSync(p)) { + push(b.name, "dist bundle built", `missing from the build output: ${p}`); + continue; + } + if (statSync(p).size === 0) { + push(b.name, "dist bundle built", `empty bundle (zero bytes): ${p}`); + continue; + } + push(b.name, "dist bundle built", null); + } + + // 2. the verb-router smoke — only meaningful when the router built + const router = bundles.find((b) => b.name === ROUTER_BIN); + if (!router) { + push(ROUTER_BIN, "verb-router smoke (--help)", `no "${ROUTER_BIN}" bin declared — the verb router is the gate's subject`); + } else if (existsSync(join(distDir, router.dist))) { + const r = await execCapture(process.execPath, [join(distDir, router.dist), "--help"]); + if (r.code !== 0) push(ROUTER_BIN, "verb-router smoke (--help)", `amico --help exited ${r.code}${r.stderr.trim() ? `: ${r.stderr.trim().split("\n")[0]}` : ""}`); + else if (!/usage:/.test(r.stdout)) push(ROUTER_BIN, "verb-router smoke (--help)", "--help exited 0 but printed no usage surface — wrong bundle?"); + else push(ROUTER_BIN, "verb-router smoke (--help)", null); + } + return { ok: results.every((r) => r.ok), results }; +} + +async function main(argv) { + let pkgDir = PKG_ROOT; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--pkg-dir") pkgDir = resolve(argv[++i]); + else { + console.error(`assert_built_bundles: unknown arg ${argv[i]} (usage: [--pkg-dir ])`); + return 2; + } + } + console.log(`[bundle-gate] package dir: ${pkgDir}`); + const { ok, results } = await runBundleGate({ pkgDir }); + for (const r of results) + console.log(`[bundle-gate] ${r.ok ? "PASS" : "FAIL"} ${r.bin.padEnd(20)} ${r.check}${r.ok ? "" : ` — ${r.detail}`}`); + console.log( + ok + ? `[bundle-gate] OK — every declared bundle is built and the verb router answers` + : `[bundle-gate] FAILED — the bundle build is stale or incomplete (see FAIL lines)`, + ); + return ok ? 0 : 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).then( + (c) => { + process.exitCode = c; + }, + (e) => { + console.error(`[bundle-gate] ${e.message}`); + process.exitCode = 1; + }, + ); +} diff --git a/packages/amico-run/test/bundle_gate.test.ts b/packages/amico-run/test/bundle_gate.test.ts new file mode 100644 index 00000000..fea882ce --- /dev/null +++ b/packages/amico-run/test/bundle_gate.test.ts @@ -0,0 +1,95 @@ +// bundle_gate.test.ts — the #643 bundle-build gate (scripts/assert_built_bundles.mjs). +// +// The deployed verb-router bundle went 46 days stale because nothing gated the +// build: the dists are gitignored artifacts, and a broken entry or unresolvable +// import only failed on whichever machine last tried to build. The gate asserts, +// against the package's DECLARED bin map (the single source of truth — the same +// map the extension staging and assert_packaged_cli.mjs re-read): +// (a) every declared bundle exists in dist/ and is non-empty (absence or a +// half-built set reds); +// (b) the built verb router still ANSWERS — `amico --help` exits 0 with the +// usage surface (a bundle that builds-but-dies reds; vitest transpiles, +// so the unit suite never executes the shipped artifact — this gate does). +// The mutation direction is proven with fabricated dist dirs: a missing bundle +// and a dead router both red the gate. +import { describe, test, expect, beforeAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { declaredDistBundles, runBundleGate } from "../scripts/assert_built_bundles.mjs"; + +const ROOT = join(__dirname, ".."); + +// the suite's build convention (amico.test.ts et al.): build the real bundles +// before probing them — the esbuild config is atomic + idempotent by design. +beforeAll(() => { + execFileSync("node", [join(ROOT, "esbuild.config.mjs")], { cwd: ROOT }); +}); + +describe("bundle-build gate (#643)", () => { + test("the real build satisfies the gate: every declared bundle present, verb-router smoke answers", async () => { + const { ok, results } = await runBundleGate({ pkgDir: ROOT }); + expect(ok).toBe(true); + expect(results.filter((r) => !r.ok)).toEqual([]); // no failing rows — detail says why if not + + // fail-closed coverage: every DECLARED bin (bin map + shadowBins) is gated + const bins = declaredDistBundles(ROOT).map((b) => b.name).sort(); + expect(bins).toContain("amico"); // the verb router is the incident's subject + const gated = new Set(results.map((r) => r.bin)); + for (const b of bins) expect(gated.has(b)).toBe(true); + }); +}); + +// ── fabricated dist dirs: the mutation direction ───────────────────────────── + +/** A fabricated package dir whose dist/ the gate runs against: a package.json + * carrying the REAL bin map (the gate's input contract) + `files` mapping a + * dist basename to its bytes. */ +function fabricatedPkg(files: Record): string { + const pkg = mkdtempSync(join(tmpdir(), "amico-bundle-gate-")); + writeFileSync( + join(pkg, "package.json"), + JSON.stringify({ + name: "@amicode/amico-run-fixture", + bin: { + "amico-run": "./launcher/amico-run", + amico: "./launcher/amico", + "amico-pasqal": "./launcher/amico-pasqal", + "amico-git-credential": "./launcher/amico-git-credential", + }, + amicode: { shadowBins: { gh: "./launcher/gh" } }, + }), + ); + mkdirSync(join(pkg, "dist"), { recursive: true }); + for (const [name, body] of Object.entries(files)) { + writeFileSync(join(pkg, "dist", name), body); + } + return pkg; +} + +const ALL_BUNDLES = ["amico-run.js", "amico.js", "amico-pasqal.js", "amico-git-credential.js", "gh.js"]; +/** A router stub that answers --help like the real one (exit 0, usage text). */ +const LIVE_ROUTER = 'console.log("usage:\\n amico run [--spec ]");\n'; + +describe("bundle-build gate — fabricated dists (the red direction)", () => { + test("a missing declared bundle reds the gate, naming the bundle", async () => { + const files: Record = {}; + for (const b of ALL_BUNDLES) files[b] = b === "amico.js" ? LIVE_ROUTER : "export {};\n"; + delete files["amico-pasqal.js"]; // one declared bundle absent + const { ok, results } = await runBundleGate({ pkgDir: fabricatedPkg(files) }); + expect(ok).toBe(false); + const row = results.find((r) => r.bin === "amico-pasqal" && /built/.test(r.check)); + expect(row?.ok).toBe(false); + expect(row?.detail).toMatch(/amico-pasqal\.js/); + }); + + test("a router that no longer answers reds the gate (builds-but-dies)", async () => { + const files: Record = {}; + for (const b of ALL_BUNDLES) files[b] = b === "amico.js" ? "process.exit(3);\n" : "export {};\n"; + const { ok, results } = await runBundleGate({ pkgDir: fabricatedPkg(files) }); + expect(ok).toBe(false); + const row = results.find((r) => r.bin === "amico" && /smoke/.test(r.check)); + expect(row?.ok).toBe(false); + }); +}); From 3d1b45afdcfebd79b62c5260712ae6c11dd7e966 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 30 Aug 2026 08:10:00 -0400 Subject: [PATCH 2/4] feat(upgrade): server-binary upgrades rebuild + deploy the verb-router dists, with receipt evidence (#643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The incident: the deployed amico bundle sat 46 days stale while server-binary upgrades ran — the ledger verbs existed in source, were absent from the deployed binary, and every ledger call silently degraded for weeks. Deployed router freshness must not depend on whoever last ran a local build. The server-binary verb (the deployed-code lane) now rebuilds the CLI bundles from the amicode checkout's current source and refreshes BOTH copies — the build output (packages/amico-run/dist) and the extension-side byte-copy the PATH-first launcher execs (packages/extension/bin/dist) — between the artifact smoke (5) and the freeze (6). Fail-closed by placement: a failed or incomplete rebuild aborts BEFORE any server surface is touched, so the receipt never lies about a half-deployed state. - the declared bundle set comes from the amico-run package's bin map + shadowBins (the single source of truth the extension staging and CI's bundle-build-gate re-read); a build that drops a declared bundle is the stale-bundle signature and aborts - receipt evidence: amicode_head (the commit the dists were built from), verb_router_sha256 + verb_router_staged_sha256 (the router's bytes on both sides) - --dist-build-command is the hermetic stub seam (mirrors --package-command); the default is 'pnpm run build' in the package dir with a pnpm environment check - fixture worlds carry the real amico-run bin map; the server-binary suite stubs the dist build through the shared verbArgs helper --- .../scripts/assert_built_bundles.d.mts | 21 +++ packages/amico-run/src/upgrade.ts | 155 +++++++++++++++++- packages/amico-run/test/helpers.ts | 21 +++ .../test/upgrade-server-binary.test.ts | 90 ++++++++++ 4 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 packages/amico-run/scripts/assert_built_bundles.d.mts diff --git a/packages/amico-run/scripts/assert_built_bundles.d.mts b/packages/amico-run/scripts/assert_built_bundles.d.mts new file mode 100644 index 00000000..f265f569 --- /dev/null +++ b/packages/amico-run/scripts/assert_built_bundles.d.mts @@ -0,0 +1,21 @@ +// Typed surface of scripts/assert_built_bundles.mjs for the test suite (the +// amico-run tsconfig includes test/, unlike the extension package's — hence +// this declaration rather than an untyped import). + +export interface DeclaredDistBundle { + /** bin key (npm `bin` map) or shadow-bin name (`amicode.shadowBins`) */ + name: string; + /** dist file basename, e.g. "amico.js" (launcher basename + .js) */ + dist: string; +} + +export interface BundleGateRow { + bin: string; + check: string; + ok: boolean; + detail: string; +} + +export function declaredDistBundles(pkgDir?: string): DeclaredDistBundle[]; + +export function runBundleGate(opts?: { pkgDir?: string }): Promise<{ ok: boolean; results: BundleGateRow[] }>; diff --git a/packages/amico-run/src/upgrade.ts b/packages/amico-run/src/upgrade.ts index 579ec911..2327fcba 100644 --- a/packages/amico-run/src/upgrade.ts +++ b/packages/amico-run/src/upgrade.ts @@ -28,9 +28,10 @@ // and matches it field-for-field. // // STUB COMMAND CONTRACT (the hermetic seams; tokens + env, both available): -// {frozen} {running} {prev} {server} {version} {vsix} {repo} (path tokens) +// {frozen} {running} {prev} {server} {version} {vsix} {vsix} {repo} {amicoRun} // AMICO_UPGRADE_FROZEN_BIN / _RUNNING_BIN / _PREV_BIN / _ROOT_SERVER / // AMICO_UPGRADE_ROOT_VSCEXT / _TARGET_VERSION / _REPO_AMICODE / _VSIX / +// AMICO_UPGRADE_AMICO_RUN_DIR / // AMICO_UPGRADE_PHASE ∈ kick | verify | verify-retry | restore-kick | restore // // The KICK STUB's contract (spec D2): make the health command succeed AND @@ -45,7 +46,7 @@ import { execFile } from "node:child_process"; import { mkdir, open, readFile, rm, copyFile, readdir, stat, writeFile, chmod } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { surfaceInventory, newestExtensionDir, @@ -88,7 +89,7 @@ export interface UpgradeReceipt { const USAGE = "amico upgrade [--root-…] " + "[--skip-build

] [--ref ] [--kick-command ] [--health-command ] [--no-kick] " + - "[--package-command ] [--install-command ] [--root-receipts

]"; + "[--package-command ] [--install-command ] [--dist-build-command ] [--root-receipts ]"; const SURFACES: readonly UpgradeSurface[] = ["server-binary", "extension", "agents", "skills"]; @@ -175,6 +176,7 @@ const TOKEN_ENV: Record = { vscext: "AMICO_UPGRADE_ROOT_VSCEXT", vsix: "AMICO_UPGRADE_VSIX", repo: "AMICO_UPGRADE_REPO_AMICODE", + amicoRun: "AMICO_UPGRADE_AMICO_RUN_DIR", }; async function runShell(cmd: string, opts: ShellOpts = {}): Promise<{ code: number; stdout: string; stderr: string }> { @@ -243,6 +245,7 @@ export function parseUpgradeArgs(argv: string[]): { ok: true; args: ParsedVerbAr "--verify-timeout-ms", "--package-command", "--install-command", + "--dist-build-command", "--root-receipts", ...Object.keys(ROOT_FLAGS), "--running-binary", @@ -740,7 +743,7 @@ const extensionVerb = (argv: string[]): Promise => }); /** The stub/live command env contract (see the module header). */ -function stubEnv(vars: { repo?: string; version?: string; vscext?: string; vsix?: string; frozen?: string; running?: string; prev?: string; server?: string; phase?: string }): Record { +function stubEnv(vars: { repo?: string; version?: string; vscext?: string; vsix?: string; frozen?: string; running?: string; prev?: string; server?: string; phase?: string; amicoRun?: string }): Record { const env: Record = {}; if (vars.repo !== undefined) env.AMICO_UPGRADE_REPO_AMICODE = vars.repo; if (vars.version !== undefined) env.AMICO_UPGRADE_TARGET_VERSION = vars.version; @@ -751,9 +754,142 @@ function stubEnv(vars: { repo?: string; version?: string; vscext?: string; vsix? if (vars.prev !== undefined) env.AMICO_UPGRADE_PREV_BIN = vars.prev; if (vars.server !== undefined) env.AMICO_UPGRADE_ROOT_SERVER = vars.server; if (vars.phase !== undefined) env.AMICO_UPGRADE_PHASE = vars.phase; + if (vars.amicoRun !== undefined) env.AMICO_UPGRADE_AMICO_RUN_DIR = vars.amicoRun; return env; } +// ── the verb-router dist rebuild (#643): a deployed upgrade never leaves a +// stale verb router behind ─────────────────────────────────────────────────── +// +// The incident: the deployed amico bundle sat 46 days stale while server- +// binary upgrades ran — the ledger verbs existed in source, were absent from +// the deployed binary, and every ledger call silently degraded for weeks. +// The router's freshness must not depend on whoever last ran a local build, +// so the server-binary upgrade (the deployed-code lane) rebuilds the CLI +// bundles from the amicode checkout's current source and refreshes BOTH +// copies: the build output (packages/amico-run/dist) and the extension-side +// byte-copy the PATH-first launcher execs (packages/extension/bin/dist). +// +// Fail-closed by placement: the rebuild runs BEFORE the freeze, so a failed +// or incomplete rebuild aborts the upgrade with NO server surface touched — +// the receipt never has to lie about a half-deployed state. The bin map of +// packages/amico-run/package.json is the single source of truth for the +// declared bundle set (the extension staging and CI's bundle-build-gate +// re-read the same map). + +interface DistRebuildFailure { + ok: false; + outcome: "aborted-environment" | "aborted-build"; + reason: string; +} + +async function rebuildVerbRouterDists( + ctx: VerbCtx, + digests: Record, +): Promise<{ ok: true } | DistRebuildFailure> { + const rootRepoAmicode = ctx.roots.rootRepoAmicode!; + const pkgDir = join(rootRepoAmicode, "packages", "amico-run"); + const buildCommand = ctx.args.flags["--dist-build-command"]; + const usingBuildStub = buildCommand !== undefined; + + // the declared bundle set — the package's bin map (+ shadowBins), by + // launcher basename: the staging convention everywhere in this repo + let declared: string[]; + try { + const pkg = JSON.parse(await readFile(join(pkgDir, "package.json"), "utf8")) as { + bin?: Record; + amicode?: { shadowBins?: Record }; + }; + const basenames = [ + ...Object.values(pkg.bin ?? {}), + ...Object.values(pkg.amicode?.shadowBins ?? {}), + ].map((p) => basename(String(p))); + declared = [...new Set(basenames)]; + if (declared.length === 0) throw new Error("no `bin` map"); + } catch (e) { + return { + ok: false, + outcome: "aborted-environment", + reason: `no readable amico-run package (bin map) at ${join(pkgDir, "package.json")}: ${e instanceof Error ? e.message : String(e)}`, + }; + } + + // environment: the live build path needs pnpm; the stub seam does not + if (!usingBuildStub) { + const pnpm = await runShell("pnpm --version"); + if (pnpm.code !== 0) { + return { + ok: false, + outcome: "aborted-environment", + reason: "pnpm not available on PATH (the verb-router dist rebuild needs it — or pass --dist-build-command )", + }; + } + } + + // the rebuild itself — from the checkout's CURRENT source (the commit is + // recorded below, so the receipt shows exactly what was built) + digests.amicode_head = (await runGit(rootRepoAmicode, ["rev-parse", "HEAD"])).stdout.trim() || null; + const cmd = buildCommand ?? "pnpm run build"; + ctx.log(`verb-router dist rebuild: ${cmd} (cwd ${pkgDir}, amicode@${String(digests.amicode_head).slice(0, 12)})`); + const build = await runShell(cmd, { cwd: pkgDir, env: stubEnv({ amicoRun: pkgDir }), timeoutMs: 600_000 }); + if (build.code !== 0) { + return { + ok: false, + outcome: "aborted-build", + reason: `verb-router dist rebuild failed (exit ${build.code}): ${firstLine(build.stderr || build.stdout)}`, + }; + } + + // a "successful" build that drops a declared bundle is the stale-bundle + // signature — verify the complete set before deploying anything + const distDir = join(pkgDir, "dist"); + const missing: string[] = []; + for (const b of declared) { + try { + const st = await stat(join(distDir, `${b}.js`)); + if (st.size === 0) missing.push(`${b}.js (empty)`); + } catch { + missing.push(`${b}.js`); + } + } + if (missing.length > 0) { + return { + ok: false, + outcome: "aborted-build", + reason: `dist rebuild produced an incomplete set — missing ${missing.join(", ")}`, + }; + } + + // refresh the extension-side byte-copy (the PATH-first launcher's target) + const extBinDist = join(rootRepoAmicode, "packages", "extension", "bin", "dist"); + await mkdir(extBinDist, { recursive: true }); + for (const b of declared) { + await copyFile(join(distDir, `${b}.js`), join(extBinDist, `${b}.js`)); + } + + // verify the refresh: every staged copy byte-matches the build output; + // the router's pair becomes the receipt's evidence + const mismatched: string[] = []; + for (const b of declared) { + const builtSha = await fileSha(join(distDir, `${b}.js`)); + const stagedSha = await fileSha(join(extBinDist, `${b}.js`)); + if (builtSha === null || stagedSha !== builtSha) mismatched.push(b); + } + if (mismatched.length > 0) { + return { + ok: false, + outcome: "aborted-build", + reason: `staged verb-router copies do not byte-match the build output: ${mismatched.join(", ")}`, + }; + } + if (declared.includes("amico")) { + digests.verb_router_sha256 = await fileSha(join(distDir, "amico.js")); + digests.verb_router_staged_sha256 = await fileSha(join(extBinDist, "amico.js")); + } + ctx.log(`refreshed ${declared.length} CLI bundles → both copies (router sha ${String(digests.verb_router_sha256).slice(0, 12)})`); + return { ok: true }; +} + // ── server-binary: the 9-step chain (build → freeze → sidecar → kick) ─────── const serverBinaryVerb = (argv: string[]): Promise => @@ -886,6 +1022,17 @@ const serverBinaryVerb = (argv: string[]): Promise => digests.artifact_version = artifactVersion; ctx.log(`smoke ok: ${artifact} --version → ${artifactVersion}`); + // (5b) the verb-router dist rebuild (#643) — BEFORE the freeze, so a + // failed or incomplete rebuild aborts with no server surface touched: + // a deployed upgrade must never proceed with a stale verb router behind + // it. Runs on every path (--skip-build, --no-kick): the router's source + // is the amicode checkout, independent of the server-binary artifact. + const dist = await rebuildVerbRouterDists(ctx, digests); + if (!dist.ok) { + ctx.log(dist.reason); + return { outcome: dist.outcome, verification: null, post: null, sourceDigests: digests }; + } + // (6) freeze: preserve the current binary as opencode.prev, copy the new // one, write the sidecar — never a live-path swap under a running server await mkdir(binDir, { recursive: true }); diff --git a/packages/amico-run/test/helpers.ts b/packages/amico-run/test/helpers.ts index 6f16ff9c..0c2714f4 100644 --- a/packages/amico-run/test/helpers.ts +++ b/packages/amico-run/test/helpers.ts @@ -193,6 +193,27 @@ export function buildDoctorWorld(opts: DoctorWorldOpts = {}): DoctorWorld { "opencode", "1.18.10", ); + // the amico-run package with its REAL bin map (#643): the server-binary + // upgrade's dist-rebuild reads this map to know which bundles to verify + + // refresh, exactly as the extension staging and assert_packaged_cli.mjs do. + mkdirSync(join(repoAmicode, "packages", "amico-run"), { recursive: true }); + writeFileSync( + join(repoAmicode, "packages", "amico-run", "package.json"), + JSON.stringify( + { + name: "@amicode/amico-run", + bin: { + "amico-run": "./launcher/amico-run", + amico: "./launcher/amico", + "amico-pasqal": "./launcher/amico-pasqal", + "amico-git-credential": "./launcher/amico-git-credential", + }, + amicode: { shadowBins: { gh: "./launcher/gh" } }, + }, + null, + 2, + ) + "\n", + ); fixtureGit(repoAmicode, ["init", "-b", "main"]); fixtureGit(repoAmicode, ["add", "-A"]); fixtureGit(repoAmicode, ["commit", "-m", "amicode fixture"]); diff --git a/packages/amico-run/test/upgrade-server-binary.test.ts b/packages/amico-run/test/upgrade-server-binary.test.ts index 0e651334..f5ccc94c 100644 --- a/packages/amico-run/test/upgrade-server-binary.test.ts +++ b/packages/amico-run/test/upgrade-server-binary.test.ts @@ -9,6 +9,7 @@ // --running-binary path; the health stub shapes the verify phases. import { describe, test, expect } from "vitest"; import { readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { execFileSync } from "node:child_process"; import { join } from "node:path"; import { surfaceInventory, fileSha, type SurfaceRecord } from "../src/surfaces.js"; import { upgradeVerb } from "../src/upgrade.js"; @@ -20,6 +21,7 @@ import { fakeBin, fixtureGit, bumpForkHead, + sha256File, FUTURE_BUILD, PAST_BUILD, type DoctorWorld, @@ -41,6 +43,15 @@ const HEALTH_FAIL_VERIFY_ONLY = 'case "$AMICO_UPGRADE_PHASE" in verify*) exit 1;; *) exit 0;; esac'; const HEALTH_FAIL_ALWAYS = "exit 1"; +/** THE DIST-BUILD STUB (#643): fabricates every declared bundle in the + * fixture checkout's packages/amico-run/dist — the hermetic seam for the + * verb-router dist rebuild the server-binary upgrade now performs (the real + * build is `pnpm run build` in that dir; CI's bundle-build-gate lane proves + * the real one on every push). Each stub bundle is ESM (`export {};`) so the + * module-type warning test discriminates. */ +const DIST_BUILD_STUB = + 'mkdir -p dist && for n in amico-run amico amico-pasqal amico-git-credential gh; do printf "export {};\\n" > "dist/$n.js"; done'; + function verbArgs(w: DoctorWorld, extra: string[]): string[] { return [ "server-binary", @@ -51,6 +62,7 @@ function verbArgs(w: DoctorWorld, extra: string[]): string[] { "--root-repo-fork", w.repoFork, "--root-staging", w.staging, "--running-binary", w.running, + "--dist-build-command", DIST_BUILD_STUB, ...extra, ]; } @@ -251,6 +263,84 @@ describe("upgrade server-binary — --no-kick (freeze only)", () => { }); }); +// ── the verb-router dist rebuild (#643) ────────────────────────────────────── + +describe("upgrade server-binary — the verb-router dist rebuild (#643)", () => { + test("a deployed upgrade rebuilds the dists and refreshes BOTH copies, with receipt evidence", async () => { + const w = stageVersionStale(); + const artifact = freshArtifact(); + + const r = await upgradeVerb(successArgs(w, artifact)); + expect(r.code).toBe(0); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("upgraded"); + + // BOTH copies refreshed: the build output AND the extension-side byte-copy + // the PATH-first launcher execs — byte-identical, for every declared bundle + const arDist = join(w.repoAmicode, "packages", "amico-run", "dist"); + const extBin = join(w.repoAmicode, "packages", "extension", "bin"); + for (const n of ["amico-run", "amico", "amico-pasqal", "amico-git-credential", "gh"]) { + const built = readFileSync(join(arDist, `${n}.js`)); + const staged = readFileSync(join(extBin, "dist", `${n}.js`)); + expect(staged.equals(built)).toBe(true); + } + + // receipt evidence: the router's sha on BOTH sides + the source commit + const routerSha = sha256File(join(arDist, "amico.js")); + expect(receipt.source_digests.verb_router_sha256).toBe(routerSha); + expect(receipt.source_digests.verb_router_staged_sha256).toBe(routerSha); + expect(receipt.source_digests.amicode_head).toBe( + execFileSync("git", ["-C", w.repoAmicode, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(), + ); + // the human story names the refresh + expect(receipt.detail.join(" ")).toMatch(/verb-router|dist/i); + cleanup(); + }); + + test("dist build failure → aborted-build BEFORE the freeze: server surface untouched, no receipt lie", async () => { + const w = stageVersionStale(); + const frozenShaBefore = await fileSha(join(w.server, "bin", "opencode")); + const args = verbArgs(w, [ + "--root-receipts", receiptsDir(w), + "--skip-build", freshArtifact(), + "--kick-command", KICK_STUB, + "--health-command", HEALTH_OK, + "--verify-timeout-ms", "2000", + "--dist-build-command", "exit 3", + ]); + + const r = await upgradeVerb(args); + expect(r.code).toBe(1); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("aborted-build"); + // nothing frozen: the old binary + sidecar still agree, no prev, no staged copy + expect(await fileSha(join(w.server, "bin", "opencode"))).toBe(frozenShaBefore); + expect(readFileSync(join(w.server, "bin", "opencode.sha256"), "utf8")).toContain(frozenShaBefore!); + expect(existsSync(join(w.server, "bin", "opencode.prev"))).toBe(false); + expect(existsSync(join(w.repoAmicode, "packages", "extension", "bin", "dist", "amico.js"))).toBe(false); + expect(lastReceipt(w).outcome).toBe("aborted-build"); + cleanup(); + }); + + test("a half-built dist set (declared bundle missing) → aborted-build", async () => { + const w = stageVersionStale(); + const args = verbArgs(w, [ + "--root-receipts", receiptsDir(w), + "--skip-build", freshArtifact(), + "--kick-command", KICK_STUB, + "--health-command", HEALTH_OK, + "--verify-timeout-ms", "2000", + "--dist-build-command", 'mkdir -p dist && printf "export {};\\n" > dist/amico.js', + ]); + + const r = await upgradeVerb(args); + expect(r.code).toBe(1); + expect((r.json as Record).outcome).toBe("aborted-build"); + expect((r.json as Record).detail.join(" ")).toMatch(/amico-pasqal/); + cleanup(); + }); +}); + // ── the restore paths ──────────────────────────────────────────────────────── describe("upgrade server-binary — restore (verify fails → rollback to prev)", () => { From abb92b437b4e46f1d2105bbf604626b62681b92f Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 30 Aug 2026 08:12:00 -0400 Subject: [PATCH 3/4] =?UTF-8?q?fix(upgrade):=20module-type=20marker=20on?= =?UTF-8?q?=20the=20refreshed=20extension-side=20bundle=20=E2=80=94=20no?= =?UTF-8?q?=20MODULE=5FTYPELESS=20reparse=20(#643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ESM CLI bundles staged under packages/extension/bin sit inside the VS Code extension manifest's package.json — which MUST stay typeless (adding "type": "module" would flip the CJS extension-host entry dist/extension.js and break vsce packaging). Without a scoped marker, node reparse-warns (MODULE_TYPELESS_PACKAGE_JSON) on every invocation — the deployed live machine paid exactly this, because its bin/ was staged before the marker existed and refreshed by hand. The dist-rebuild hook now writes the bin/-scoped {"type":"module"} marker alongside the refreshed bundles — byte-identical to the one the extension build's staging writes (esbuild.config.mjs), so both refresh paths converge. Blast radius: zero changes to the extension manifest, no .mjs rename (which would churn the staging convention, the launcher exec path, and the packaging gates for no additional benefit). Pinned from both sides: the upgrade test spawns the refreshed extension-side bundle and asserts no MODULE_TYPELESS warning on stderr; the VSIX packaging list pins extension/bin/package.json as a required runtime asset (the packaged side of the contract). --- packages/amico-run/src/upgrade.ts | 10 +++++++++- .../test/upgrade-server-binary.test.ts | 18 +++++++++++++++++- packages/extension/test/packaging.test.ts | 6 ++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/amico-run/src/upgrade.ts b/packages/amico-run/src/upgrade.ts index 2327fcba..25734330 100644 --- a/packages/amico-run/src/upgrade.ts +++ b/packages/amico-run/src/upgrade.ts @@ -861,11 +861,19 @@ async function rebuildVerbRouterDists( } // refresh the extension-side byte-copy (the PATH-first launcher's target) - const extBinDist = join(rootRepoAmicode, "packages", "extension", "bin", "dist"); + const extBin = join(rootRepoAmicode, "packages", "extension", "bin"); + const extBinDist = join(extBin, "dist"); await mkdir(extBinDist, { recursive: true }); for (const b of declared) { await copyFile(join(distDir, `${b}.js`), join(extBinDist, `${b}.js`)); } + // the module-type marker: the ESM bundle under a typeless package.json (the + // VS Code extension manifest — never add "type" there; it would flip the + // CJS extension-host entry) reparse-warns on EVERY invocation. The + // bin/-scoped {"type":"module"} marker is the same convention the extension + // build's staging writes (esbuild.config.mjs) — identical bytes, so a + // refresh by this hook and a refresh by the extension build converge. + await writeFile(join(extBin, "package.json"), `${JSON.stringify({ type: "module" })}\n`); // verify the refresh: every staged copy byte-matches the build output; // the router's pair becomes the receipt's evidence diff --git a/packages/amico-run/test/upgrade-server-binary.test.ts b/packages/amico-run/test/upgrade-server-binary.test.ts index f5ccc94c..97ad3367 100644 --- a/packages/amico-run/test/upgrade-server-binary.test.ts +++ b/packages/amico-run/test/upgrade-server-binary.test.ts @@ -9,7 +9,7 @@ // --running-binary path; the health stub shapes the verify phases. import { describe, test, expect } from "vitest"; import { readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { join } from "node:path"; import { surfaceInventory, fileSha, type SurfaceRecord } from "../src/surfaces.js"; import { upgradeVerb } from "../src/upgrade.js"; @@ -339,6 +339,22 @@ describe("upgrade server-binary — the verb-router dist rebuild (#643)", () => expect((r.json as Record).detail.join(" ")).toMatch(/amico-pasqal/); cleanup(); }); + + test("invoking the refreshed extension-side bundle emits no module-type reparse warning (#643)", async () => { + const w = stageVersionStale(); + const r = await upgradeVerb(successArgs(w, freshArtifact())); + expect(r.code).toBe(0); + + // the staged stub bundle is ESM-only (`export {};`); the fixture's + // packages/extension/package.json (like the real VS Code manifest) has no + // "type" field — without a scoped marker, node reparse-warns on EVERY + // invocation (the deployed live machine paid exactly this). + const stagedRouter = join(w.repoAmicode, "packages", "extension", "bin", "dist", "amico.js"); + const run = spawnSync(process.execPath, [stagedRouter, "--help"], { encoding: "utf8" }); + expect(run.status).toBe(0); + expect(run.stderr).not.toMatch(/MODULE_TYPELESS_PACKAGE_JSON/); + cleanup(); + }); }); // ── the restore paths ──────────────────────────────────────────────────────── diff --git a/packages/extension/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index ac066354..9aa40064 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -21,6 +21,12 @@ const REQUIRED = [ "extension/bin/launcher/amico-git-credential", "extension/bin/dist/gh.js", "extension/bin/launcher/gh", + // #643 — the bin/-scoped module-type marker: the ESM CLI bundles sit under + // the typeless VS Code extension manifest; without this file every packaged + // invocation pays a MODULE_TYPELESS_PACKAGE_JSON reparse. Both refresh + // paths write it (the extension build's staging AND the server-binary + // upgrade's dist rebuild) — this pin is the packaged side of that contract. + "extension/bin/package.json", // Pasqal connector assets — staged to /scripts/pasqal-connector at // activation (the Connections panel's default validator path, #161). Kept in // the vsix by explicit .vscodeignore negations against scripts/**. From e934e33b17ba1769021b207e534afb4618b89b64 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 30 Aug 2026 08:12:26 -0400 Subject: [PATCH 4/4] docs(upgrade): carry --dist-build-command in the module-header usage line (#643) --- packages/amico-run/src/upgrade.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/amico-run/src/upgrade.ts b/packages/amico-run/src/upgrade.ts index 25734330..fd5d9bc1 100644 --- a/packages/amico-run/src/upgrade.ts +++ b/packages/amico-run/src/upgrade.ts @@ -2,7 +2,8 @@ // D2): the four upgrade chains as receipt-emitting, idempotent runbooks. // // amico upgrade server-binary [--skip-build ] [--ref ] [--kick-command ] -// [--health-command ] [--no-kick] [--root-server ] +// [--health-command ] [--no-kick] [--dist-build-command ] +// [--root-server ] // amico upgrade extension [--package-command ] [--install-command ] // [--root-vscext ] [--root-repo-amicode ] // amico upgrade agents [--root-config ] [--root-staging ] [--root-repo-amicode ] @@ -28,7 +29,7 @@ // and matches it field-for-field. // // STUB COMMAND CONTRACT (the hermetic seams; tokens + env, both available): -// {frozen} {running} {prev} {server} {version} {vsix} {vsix} {repo} {amicoRun} +// {frozen} {running} {prev} {server} {version} {vscext} {vsix} {repo} {amicoRun} // AMICO_UPGRADE_FROZEN_BIN / _RUNNING_BIN / _PREV_BIN / _ROOT_SERVER / // AMICO_UPGRADE_ROOT_VSCEXT / _TARGET_VERSION / _REPO_AMICODE / _VSIX / // AMICO_UPGRADE_AMICO_RUN_DIR /