From 261063a59291c8d460b4047623edd51928f54fae Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 06:48:28 +0200 Subject: [PATCH 01/20] fix: preserve CLI table and review policy gates --- docs-site/src/content/docs/reference/cli.md | 20 ++++++++++---------- tests/review-execution-policy.test.ts | 11 +++++++++++ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 074e3b12..04bcd327 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -195,16 +195,16 @@ Operational dashboard features are also available without a browser. These comma identity-checked running proxy (including a fallback runtime port) and reuse the same management routes, validation, live configuration, and catalog refresh side effects as the GUI. -| Resource | Commands | -| --------------- | ---------------------------------------- | -| Routing | `ocx combo ...` or `ocx route combo ...` | -| Agent policy | `ocx agent injection | effort | subagents | fallback | sidecar ...` | -| Observability | `ocx observe logs | usage | storage | memory | cache | debug ...` | -| API admission | `ocx access key | endpoints | models | test ...` | -| Claude Code | `ocx claude config status | set ...` | -| Grok Build | `ocx grok status | exclude | include | set | clear | apply ...` | -| Runtime control | `ocx system status | settings | startup | diagnostics | sync | update ...` | -| Offline config | `ocx config show | get | set | unset | validate | export | import ...` | +| Resource | Commands | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routing | `ocx combo ...` or `ocx route combo ...` | +| Agent policy | `ocx agent injection ...`, `ocx agent effort ...`, `ocx agent subagents ...`, `ocx agent fallback ...`, `ocx agent sidecar ...` | +| Observability | `ocx observe logs ...`, `ocx observe usage ...`, `ocx observe storage ...`, `ocx observe memory ...`, `ocx observe cache ...`, `ocx observe debug ...` | +| API admission | `ocx access key ...`, `ocx access endpoints ...`, `ocx access models ...`, `ocx access test ...` | +| Claude Code | `ocx claude config status`, `ocx claude config set ...` | +| Grok Build | `ocx grok status`, `ocx grok exclude ...`, `ocx grok include ...`, `ocx grok set ...`, `ocx grok clear ...`, `ocx grok apply ...` | +| Runtime control | `ocx system status`, `ocx system settings ...`, `ocx system startup ...`, `ocx system diagnostics ...`, `ocx system sync ...`, `ocx system update ...` | +| Offline config | `ocx config show`, `ocx config get ...`, `ocx config set ...`, `ocx config unset ...`, `ocx config validate`, `ocx config export ...`, `ocx config import ...` | List/status is the default where unambiguous. Use `--json` for structured snapshots and `ocx observe logs --follow --jsonl` for a streaming request-log feed. Destructive removal/import, diff --git a/tests/review-execution-policy.test.ts b/tests/review-execution-policy.test.ts index bc794c15..2d9ce580 100644 --- a/tests/review-execution-policy.test.ts +++ b/tests/review-execution-policy.test.ts @@ -6,6 +6,8 @@ const policy = readFileSync( "utf8", ); const agents = readFileSync(new URL("../AGENTS.md", import.meta.url), "utf8"); +const normalizedPolicy = policy.replace(/\s+/g, " "); +const normalizedAgents = agents.replace(/\s+/g, " "); describe("review execution policy", () => { test("external approval is advisory without weakening technical verification", () => { @@ -21,6 +23,15 @@ describe("review execution policy", () => { expect(agents).toContain("External approval is advisory, never a blocker"); }); + test("technical checks apply to the exact head even without branch protection", () => { + expect(normalizedPolicy).toContain( + "Required technical checks must succeed on the exact head being merged, whether enforced by branch protection or by the operator.", + ); + expect(normalizedAgents).toContain( + "Required technical checks apply even when branch protection is not configured.", + ); + }); + test("findings become verified repairs within authority, not fabricated approval", () => { expect(policy).toContain("fixing it,"); expect(policy).toContain("regression coverage"); From d133d0e3cff2df8bf6cc7206225726e009c22622 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 06:56:07 +0200 Subject: [PATCH 02/20] fix(ci): use supported Bun cache input --- .github/workflows/ci.yml | 3 +- tests/ci-workflows.test.ts | 1750 ++++++++++++++++++++++++++---------- 2 files changed, 1263 insertions(+), 490 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bca3886f..c480eace 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,8 +72,7 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.14 - cache: true - cache-bin: true + no-cache: false - name: Cache GUI node_modules if: ${{ matrix.run_quality || matrix.run_tests }} diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index f87df0a6..fd998841 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -10,14 +10,20 @@ import { /** Final enforcer comment body after pending/draft checkpoints. */ function lastEnforcerCommentBody(result: HarnessResult): string { - const updates = callsTo(result, "issues.updateComment") as Array<{ body: string }>; + const updates = callsTo(result, "issues.updateComment") as Array<{ + body: string; + }>; if (updates.length > 0) return updates[updates.length - 1]!.body; - const creates = callsTo(result, "issues.createComment") as Array<{ body: string }>; + const creates = callsTo(result, "issues.createComment") as Array<{ + body: string; + }>; return creates[creates.length - 1]!.body; } const root = new URL("../", import.meta.url); -const doctorGuiIfChangedScript = fileURLToPath(new URL("../scripts/doctor-gui-if-changed.ts", import.meta.url)); +const doctorGuiIfChangedScript = fileURLToPath( + new URL("../scripts/doctor-gui-if-changed.ts", import.meta.url), +); async function readText(path: string): Promise { return await Bun.file(new URL(path, root)).text(); @@ -40,7 +46,12 @@ describe("GitHub Actions hardening", () => { // EVERY job must stay bounded — an unbounded job can hang a queue for hours. // Derived from the job set rather than a hardcoded count, so adding a job // without a ceiling fails here instead of quietly changing the expectation. - const jobs = (Bun.YAML.parse(workflow) as { jobs?: Record }).jobs ?? {}; + const jobs = + ( + Bun.YAML.parse(workflow) as { + jobs?: Record; + } + ).jobs ?? {}; const unbounded = Object.entries(jobs) .filter(([, job]) => typeof job?.["timeout-minutes"] !== "number") .map(([name]) => name); @@ -58,7 +69,12 @@ describe("GitHub Actions hardening", () => { ".github/workflows/pr-labeler.yml", ]) { const text = await readText(path); - const parsed = (Bun.YAML.parse(text) as { jobs?: Record }).jobs ?? {}; + const parsed = + ( + Bun.YAML.parse(text) as { + jobs?: Record; + } + ).jobs ?? {}; const unboundedAux = Object.entries(parsed) .filter(([, job]) => typeof job?.["timeout-minutes"] !== "number") .map(([name]) => `${path}: ${name}`); @@ -66,18 +82,33 @@ describe("GitHub Actions hardening", () => { expect(count(text, "timeout-minutes:")).toBe(Object.keys(parsed).length); } - const designSystem = await readText(".github/workflows/design-system-contract.yml"); - expect(count(designSystem, "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1")).toBe(2); + const designSystem = await readText( + ".github/workflows/design-system-contract.yml", + ); + expect( + count( + designSystem, + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1", + ), + ).toBe(2); expect(designSystem).not.toMatch(/uses:\s+\S+@(?:v\d+|main|master)\b/); - expect(workflow).toContain("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"); - expect(workflow).toContain("oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6"); - expect(workflow).toContain("actions/setup-node@820762786026740c76f36085b0efc47a31fe5020"); + expect(workflow).toContain( + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + ); + expect(workflow).toContain( + "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + ); + expect(workflow).toContain( + "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", + ); // Both test paths must go through the canonical isolated-environment entry points, never a // bare `bun test` that inherits the runner's real HOME and configuration. expect(workflow).toContain("bun run scripts/test.ts"); expect(workflow).toContain("bun run scripts/ci-test-shard.ts"); expect(workflow).not.toContain("bun test --isolate tests"); - expect(workflow).toContain("raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7"); + expect(workflow).toContain( + "raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7", + ); expect(workflow).toContain("bun audit --audit-level=high"); expect(workflow).not.toMatch(/uses:\s+\S+@(?:v\d+|main|master)\b/); }); @@ -90,10 +121,15 @@ describe("GitHub Actions hardening", () => { const gate = await readText(".github/workflows/enforce-pr-target.yml"); const allowed = gate.match(/const ALLOWED_BASES = \[([^\]]*)\];/); expect(allowed).not.toBeNull(); - const bases = [...(allowed?.[1] ?? "").matchAll(/"([^"]+)"/g)].map(m => m[1]); + const bases = [...(allowed?.[1] ?? "").matchAll(/"([^"]+)"/g)].map( + (m) => m[1], + ); expect(bases).toEqual(["main"]); - for (const path of [".github/workflows/ci.yml", ".github/workflows/service-lifecycle.yml"]) { + for (const path of [ + ".github/workflows/ci.yml", + ".github/workflows/service-lifecycle.yml", + ]) { const workflow = Bun.YAML.parse(await readText(path)) as { on?: { pull_request?: Record }; }; @@ -111,7 +147,11 @@ describe("GitHub Actions hardening", () => { if ("types" in trigger) { // If a future change genuinely needs `types`, it must still cover the // three events the default covers. - expect([...(trigger.types as string[])].sort()).toEqual(["opened", "reopened", "synchronize"]); + expect([...(trigger.types as string[])].sort()).toEqual([ + "opened", + "reopened", + "synchronize", + ]); } } @@ -124,7 +164,11 @@ describe("GitHub Actions hardening", () => { pull_request?: { paths?: string[] }; }; }; - expect([...(ci.on?.push?.branches ?? [])].sort()).toEqual(["dev", "main", "preview"]); + expect([...(ci.on?.push?.branches ?? [])].sort()).toEqual([ + "dev", + "main", + "preview", + ]); // The path filter decides whether the job runs at all. Deleting one entry // deletes nothing visible: the workflow still exists, still lists the right @@ -171,13 +215,16 @@ describe("GitHub Actions hardening", () => { const workflow = Bun.YAML.parse(text) as { on?: Record; permissions?: Record; - jobs?: Record; - }>; - }>; + jobs?: Record< + string, + { + steps?: Array<{ + name?: string; + uses?: string; + with?: Record; + }>; + } + >; }; // Branch-selected workflow_dispatch would run an unreviewed YAML with write tokens. @@ -205,7 +252,9 @@ describe("GitHub Actions hardening", () => { const stale = steps[1]!; expect(stale.name).toBe("Mark and close inactive needs-info issues"); - expect(stale.uses).toBe("actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629"); + expect(stale.uses).toBe( + "actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629", + ); expect(stale.with?.["only-issue-labels"]).toBe("needs-info"); expect(stale.with?.["days-before-pr-stale"]).toBe(-1); expect(stale.with?.["days-before-pr-close"]).toBe(-1); @@ -236,7 +285,9 @@ describe("GitHub Actions hardening", () => { expect(workflow).not.toContain("Get-ScheduledTask"); expect(workflow).not.toContain("runs-on: macos"); expect(workflow).not.toContain("runs-on: windows"); - expect(workflow).toContain("systemd service has no positive MainPID before crash test"); + expect(workflow).toContain( + "systemd service has no positive MainPID before crash test", + ); expect(workflow).toContain("systemd artifact or proxy survived uninstall"); expect(workflow).not.toMatch(/uses:\s+\S+@(?:v\d+|main|master)\b/); }); @@ -267,7 +318,9 @@ describe("GitHub Actions hardening", () => { } expect(workflow).toContain("unset NODE_AUTH_TOKEN"); expect(workflow).toContain("NPM_PUBLISH_BIN"); - expect(workflow).toContain("printf 'registry=https://registry.npmjs.org/\\n'"); + expect(workflow).toContain( + "printf 'registry=https://registry.npmjs.org/\\n'", + ); expect(workflow).toContain("OIDC publish failed"); expect(workflow).toContain("retrying with NPM_TOKEN"); expect(workflow).toContain("--ignore-scripts"); @@ -279,19 +332,31 @@ describe("GitHub Actions hardening", () => { expect(oidcStep).not.toContain("NPM_TOKEN:"); expect(oidcStep).not.toContain("NODE_AUTH_TOKEN:"); expect(workflow).toContain("- name: Publish token fallback"); - const fallbackStep = workflow.slice(workflow.indexOf("- name: Publish token fallback")); - expect(fallbackStep).toContain("//registry.npmjs.org/:_authToken=\\${NODE_AUTH_TOKEN}"); + const fallbackStep = workflow.slice( + workflow.indexOf("- name: Publish token fallback"), + ); + expect(fallbackStep).toContain( + "//registry.npmjs.org/:_authToken=\\${NODE_AUTH_TOKEN}", + ); expect(fallbackStep).toContain("always-auth=true"); // Immutable action references. - expect(workflow).toContain("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"); - expect(workflow).toContain("oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6"); - expect(workflow).toContain("actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"); + expect(workflow).toContain( + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + ); + expect(workflow).toContain( + "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + ); + expect(workflow).toContain( + "actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e", + ); expect(workflow).not.toMatch(/uses:\s+\S+@(?:v\d+|main|master)\b/); // Workflow-dispatch inputs must reach shell code via env, never by direct // interpolation into run: source (script-injection hardening). - const runBlocks = workflow.split(/\n {6,}- name: /).filter(block => block.includes("run: |")); + const runBlocks = workflow + .split(/\n {6,}- name: /) + .filter((block) => block.includes("run: |")); for (const block of runBlocks) { const runSource = block.slice(block.indexOf("run: |")); expect(runSource).not.toContain("${{ inputs."); @@ -307,9 +372,9 @@ describe("GitHub Actions hardening", () => { .split("push:")[1]! .split("workflow_dispatch:")[0]! .split("\n") - .map(line => line.trim()) - .filter(line => line.startsWith('- "')) - .map(line => line.slice(3, -1)); + .map((line) => line.trim()) + .filter((line) => line.startsWith('- "')) + .map((line) => line.slice(3, -1)); expect(pushPaths.length).toBeGreaterThanOrEqual(6); for (const path of pushPaths) { expect(gate.test(path)).toBe(true); @@ -326,9 +391,9 @@ describe("GitHub Actions hardening", () => { .split("pull_request:")[1]! .split("push:")[0]! .split("\n") - .map(line => line.trim()) - .filter(line => line.startsWith('- "')) - .map(line => line.slice(3, -1)); + .map((line) => line.trim()) + .filter((line) => line.startsWith('- "')) + .map((line) => line.slice(3, -1)); expect([...prPaths].sort()).toEqual([...pushPaths].sort()); expect(prPaths).toContain("src/cli.ts"); expect(pushPaths).toContain("src/cli.ts"); @@ -338,8 +403,12 @@ describe("GitHub Actions hardening", () => { // Fork release model: everything publishes from main; prerelease semver // (any `-*` suffix) must use dist-tag preview, stable must use latest. expect(workflow).toContain("Release must run from main;"); - expect(workflow).toContain("Pre-release versions (${RELEASE_VERSION}) must use dist-tag 'preview'"); - expect(workflow).toContain("Stable releases (${RELEASE_VERSION}) must use dist-tag 'latest'"); + expect(workflow).toContain( + "Pre-release versions (${RELEASE_VERSION}) must use dist-tag 'preview'", + ); + expect(workflow).toContain( + "Stable releases (${RELEASE_VERSION}) must use dist-tag 'latest'", + ); expect(workflow).not.toContain("refs/heads/preview)"); // Prereleases are restricted to X.Y.Z-preview.N: the update client only parses // that suffix, so an -alpha/-beta/-rc publish would never notify preview users. @@ -348,7 +417,9 @@ describe("GitHub Actions hardening", () => { // The release helper must dispatch from the only ref the workflow accepts. const releaseHelper = await readText("scripts/release.ts"); expect(releaseHelper).toContain('const releaseBranch = "main"'); - expect(releaseHelper).toContain('const expectedTag = isPrerelease ? "preview" : "latest"'); + expect(releaseHelper).toContain( + 'const expectedTag = isPrerelease ? "preview" : "latest"', + ); expect(releaseHelper).not.toContain('["main", "preview"]'); // Release notes must include PR categories and the full channel commit range @@ -356,58 +427,79 @@ describe("GitHub Actions hardening", () => { // only create (not edit) is wired. Stable releases also carry matching preview notes. expect(workflow).toContain("releases/generate-notes"); expect(workflow).toContain("git log --pretty=format:'- %s (%h)'"); - expect(workflow).toContain('commit_range="${notes_range_start}..${GITHUB_SHA}"'); - expect(workflow).toContain('previous_tag_name=${notes_range_start}'); + expect(workflow).toContain( + 'commit_range="${notes_range_start}..${GITHUB_SHA}"', + ); + expect(workflow).toContain("previous_tag_name=${notes_range_start}"); expect(workflow).toContain("skipping generate-notes (commits-only notes)"); expect(workflow).toContain("bun scripts/release-notes.ts strip-carried"); expect(workflow).toContain("bun scripts/release-notes.ts assemble"); - expect(workflow).toContain("bun scripts/release-notes.ts matching-preview-tags"); - expect(workflow).toContain("bun scripts/release-notes.ts previous-release-tag"); + expect(workflow).toContain( + "bun scripts/release-notes.ts matching-preview-tags", + ); + expect(workflow).toContain( + "bun scripts/release-notes.ts previous-release-tag", + ); expect(workflow).toContain("bun scripts/release-notes.ts has-meaningful"); expect(workflow).toContain("bun scripts/release-notes.ts join-carried"); expect(workflow).toContain("bun scripts/release-notes.ts credit-takeovers"); expect(workflow).toContain('if [ -s "$carried_file" ]; then'); expect(workflow).toContain('if [ -s "$delta_file" ]; then'); // Preview notes must baseline any prior release (stable or preview), not preview-only. - expect(workflow).toContain('bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION"'); + expect(workflow).toContain( + 'bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION"', + ); expect(workflow).not.toMatch( /RELEASE_VERSION" == \*-preview\.\*[\s\S]{0,200}grep -- '-preview\\.'/, ); expect(workflow).toContain("releases/tags/"); - expect(workflow).toContain('gh api "repos/${GITHUB_REPOSITORY}" --jq \'.full_name\''); + expect(workflow).toContain( + "gh api \"repos/${GITHUB_REPOSITORY}\" --jq '.full_name'", + ); expect(workflow).toContain("git merge-base --is-ancestor"); expect(workflow).toContain("operational error, not a missing release"); expect(workflow).toContain("not an ancestor"); expect(workflow).toContain("newest_carried_preview_tag"); expect(workflow).not.toMatch(/newest_preview_tag="\$preview_carry_tag"/); expect(workflow).toContain("--commits"); - expect(workflow).toContain('git tag --list "v${RELEASE_VERSION}-preview.*"'); + expect(workflow).toContain( + 'git tag --list "v${RELEASE_VERSION}-preview.*"', + ); expect(workflow).toContain("Carrying preview release notes from"); // Every subcommand the workflow invokes must be dispatched by the CLI. const releaseNotesHelper = await readText("scripts/release-notes.ts"); - const invoked = [...workflow.matchAll(/bun scripts\/release-notes\.ts ([a-z-]+)/g)] - .map(m => m[1]!); + const invoked = [ + ...workflow.matchAll(/bun scripts\/release-notes\.ts ([a-z-]+)/g), + ].map((m) => m[1]!); expect(invoked.length).toBeGreaterThan(0); for (const cmd of new Set(invoked)) { expect(releaseNotesHelper).toContain(`"${cmd}"`); } - expect(workflow).toMatch(/gh release create[\s\S]*?--notes-file "\$notes_file"/); + expect(workflow).toMatch( + /gh release create[\s\S]*?--notes-file "\$notes_file"/, + ); expect(workflow).not.toContain("gh release edit"); expect(workflow).not.toContain("--generate-notes"); // Notes must be assembled before tagging so a notes API failure does not leave // a remote tag that blocks release retries at preflight. - const createStep = workflow.split("- name: Create GitHub release")[1]!.split(/\n {6}- name:/)[0]!; + const createStep = workflow + .split("- name: Create GitHub release")[1]! + .split(/\n {6}- name:/)[0]!; // Preview carry lookup must use tag-specific API status, not `gh release view` stderr prose. expect(createStep).toContain("releases/tags/"); expect(createStep).not.toContain("gh release view"); // Fail closed: no soft-skip in any spelling around gh api calls in this step. - for (const line of createStep.split("\n").filter(l => l.includes("gh api"))) { + for (const line of createStep + .split("\n") + .filter((l) => l.includes("gh api"))) { expect(line).not.toMatch(/\|\|\s*(true|echo|:)/); } expect(createStep).not.toContain("set +e\n pr_notes"); expect(createStep.indexOf("gh api")).toBeGreaterThan(-1); expect(createStep.indexOf('git tag "$release_tag"')).toBeGreaterThan(-1); - expect(createStep.indexOf("gh api")).toBeLessThan(createStep.indexOf('git tag "$release_tag"')); + expect(createStep.indexOf("gh api")).toBeLessThan( + createStep.indexOf('git tag "$release_tag"'), + ); // First-channel releases must not call generate-notes without an explicit baseline // (GitHub would otherwise pick the newest repo tag, possibly from the other channel). // Scope to the single if-block that owns generate-notes; createStep has two @@ -426,13 +518,18 @@ describe("GitHub Actions hardening", () => { jobs?: Record< string, { - steps?: Array<{ name?: string; uses?: string; run?: string; with?: Record }>; + steps?: Array<{ + name?: string; + uses?: string; + run?: string; + with?: Record; + }>; } >; }; const steps = workflow.jobs?.publish?.steps ?? []; - const stepNames = steps.map(step => step.name); + const stepNames = steps.map((step) => step.name); // The gate has to run immediately after checkout and before version // resolution or any publish step, or a tag pushed on a stray branch could @@ -491,7 +588,10 @@ describe("GitHub Actions hardening", () => { run?: string; with?: Record; }; - type WorkflowJob = Record & { "runs-on"?: unknown; steps?: WorkflowStep[] }; + type WorkflowJob = Record & { + "runs-on"?: unknown; + steps?: WorkflowStep[]; + }; type WorkflowShape = Record & { on?: { pull_request_target?: { types?: string[] } }; permissions?: Record | string; @@ -515,7 +615,9 @@ describe("GitHub Actions hardening", () => { // one. `enforce-target` is not privileged here; it is just the one whose // script body the behavioural tests read. const allSteps = jobs.flatMap(([, job]) => job?.steps ?? []); - const scriptStep = steps!.find(step => typeof step.with?.script === "string"); + const scriptStep = steps!.find( + (step) => typeof step.with?.script === "string", + ); expect(scriptStep).toBeDefined(); const script = stripComments(String(scriptStep!.with!.script)); return { workflow, jobs, steps: steps!, allSteps, script }; @@ -584,20 +686,38 @@ describe("GitHub Actions hardening", () => { const next = source[i + 1]; if (block) { - if (char === "*" && next === "/") { block = false; i += 1; continue; } + if (char === "*" && next === "/") { + block = false; + i += 1; + continue; + } if (char === "\n") out += char; continue; } if (quote) { out += char; - if (char === "\\") { if (next !== undefined) { out += next; i += 1; } continue; } + if (char === "\\") { + if (next !== undefined) { + out += next; + i += 1; + } + continue; + } if (char === quote) quote = null; continue; } - if (char === '"' || char === "'" || char === "`") { quote = char; out += char; continue; } - if (char === "/" && next === "*") { block = true; i += 1; continue; } + if (char === '"' || char === "'" || char === "`") { + quote = char; + out += char; + continue; + } + if (char === "/" && next === "*") { + block = true; + i += 1; + continue; + } if (char === "/" && next === "/") { while (i < source.length && source[i] !== "\n") i += 1; out += "\n"; @@ -652,7 +772,9 @@ describe("GitHub Actions hardening", () => { // particular files change, which on a docs-only PR means never. Both are // additive, both look like ordinary scoping in a diff, and neither failed a // single assertion. - expect(Object.keys(workflow.on?.pull_request_target ?? {})).toEqual(["types"]); + expect(Object.keys(workflow.on?.pull_request_target ?? {})).toEqual([ + "types", + ]); // Exactly the scopes this gate needs. `pull-requests: write` covers title // and comment updates. `contents: write` is required for the draft GraphQL @@ -728,7 +850,9 @@ describe("GitHub Actions hardening", () => { */ test("PR target enforcement's script interpolates nothing from the event", async () => { const { steps } = await readEnforcePrTarget(); - const scriptStep = steps.find(step => typeof step.with?.script === "string"); + const scriptStep = steps.find( + (step) => typeof step.with?.script === "string", + ); const rawScript = String(scriptStep?.with?.script ?? ""); expect(rawScript).not.toContain("${{"); }); @@ -751,7 +875,9 @@ describe("GitHub Actions hardening", () => { // The verdict is a live PR read plus ancestry/description checks. expect(script).toContain("github.rest.pulls.get"); expect(script).toContain("collectPrQualityFailures"); - expect(script).toContain("github.rest.repos.getCollaboratorPermissionLevel"); + expect(script).toContain( + "github.rest.repos.getCollaboratorPermissionLevel", + ); expect(script).toContain("github.rest.repos.compareCommitsWithBasehead"); // The allow-list is the gate's whole policy, so it is pinned by value and // not just by shape: a widened list is the one edit that opens every base @@ -759,7 +885,9 @@ describe("GitHub Actions hardening", () => { expect(script).toMatch(/const ALLOWED_BASES = \["main"\];/); expect(script).toMatch(/const DEFAULT_BASE = "main";/); expect(script).toContain("headRef: pr.head.ref"); - expect(script).toContain("headFromSameRepo: isSameGithubRepo(pr.head.repo, pr.base.repo)"); + expect(script).toContain( + "headFromSameRepo: isSameGithubRepo(pr.head.repo, pr.base.repo)", + ); // Every mutation targets the PR the event fired for. `pull_number` is the // only handle the script has, and an audit round repointed it at @@ -801,7 +929,10 @@ describe("GitHub Actions hardening", () => { // argument names by brace-depth, so nested objects do not leak in. function callArgs(callee: string): string[][] { const found: string[][] = []; - const pattern = new RegExp(`${callee.replaceAll(".", "\\.")}\\(\\s*\\{`, "g"); + const pattern = new RegExp( + `${callee.replaceAll(".", "\\.")}\\(\\s*\\{`, + "g", + ); for (const match of script.matchAll(pattern)) { let depth = 1; let i = match.index! + match[0].length; @@ -853,9 +984,9 @@ describe("GitHub Actions hardening", () => { // These are the only three mutating REST calls. A fourth is a new write // nobody reviewed. `pulls.list` is a stacked-base read, not a write. const restWrites = [...script.matchAll(/github\.rest\.[\w.]+/g)] - .map(match => match[0]) + .map((match) => match[0]) .filter( - name => + (name) => !name.endsWith(".get") && !name.endsWith(".list") && !name.endsWith(".listComments") && @@ -904,7 +1035,9 @@ describe("GitHub Actions hardening", () => { * the check that closes them has to be able to look from the same place. */ async function runProbe(body: string): Promise> { - const result = await runEnforcePrTarget(body, { pr: { base: { ref: "main" } } }); + const result = await runEnforcePrTarget(body, { + pr: { base: { ref: "main" } }, + }); return result.returnValue as Record; } @@ -949,7 +1082,9 @@ describe("GitHub Actions hardening", () => { expect(methodsOf(result)).toEqual(readsAllowedBase()); expect(callsTo(result, "pulls.update")).toEqual([]); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(false); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + false, + ); }); test("maintainers skip ancestry enforcement with the same compares", async () => { @@ -960,14 +1095,20 @@ describe("GitHub Actions hardening", () => { }); expect(methodsOf(result)).toEqual(readsAllowedBase()); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(false); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + false, + ); }); test("empty PR description fails and drafts", async () => { const result = await run({ pr: { base: { ref: "main" }, body: "" } }); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); - expect(lastEnforcerCommentBody(result)).toContain("Pull request description"); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + true, + ); + expect(lastEnforcerCommentBody(result)).toContain( + "Pull request description", + ); expect(lastEnforcerCommentBody(result)).toContain("body is empty"); expect(callsTo(result, "graphql")).toHaveLength(1); }); @@ -980,29 +1121,38 @@ describe("GitHub Actions hardening", () => { }, }); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); - expect(lastEnforcerCommentBody(result)).toContain("literal `\\n` escape sequences"); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + true, + ); + expect(lastEnforcerCommentBody(result)).toContain( + "literal `\\n` escape sequences", + ); }); test("clears prior bot state when every gate passes again", async () => { const result = await run({ pr: { base: { ref: "main" }, draft: true }, - comments: [botComment({ - version: 1, - active: true, - autoDraftedByBot: true, - titlePrefixedByBot: false, - ancestryFailed: true, - descriptionFailed: true, - })], + comments: [ + botComment({ + version: 1, + active: true, + autoDraftedByBot: true, + titlePrefixedByBot: false, + ancestryFailed: true, + descriptionFailed: true, + }), + ], }); - expect(methodsOf(result)).toEqual(readsAllowedBase([ - "graphql", - "issues.updateComment", - ])); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(false); - const [cleared] = callsTo(result, "issues.updateComment") as [{ body: string }]; + expect(methodsOf(result)).toEqual( + readsAllowedBase(["graphql", "issues.updateComment"]), + ); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + false, + ); + const [cleared] = callsTo(result, "issues.updateComment") as [ + { body: string }, + ]; expect(cleared.body).toContain('"active":false'); expect(cleared.body).toContain("PR quality gates passed"); }); @@ -1014,18 +1164,24 @@ describe("GitHub Actions hardening", () => { // native-port line, and a topic branch. None of them is an integration // line any more — `main` is the only one. for (const ref of ["dev", "master", "preview", "dev2-go", "feature/x"]) { - const result = await run({ pr: { base: { ref }, title: "Add a thing", draft: false } }); + const result = await run({ + pr: { base: { ref }, title: "Add a thing", draft: false }, + }); - expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.createComment", - "pulls.update", - "issues.updateComment", - "graphql", - "issues.updateComment", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsWrongBase([ + "issues.createComment", + "pulls.update", + "issues.updateComment", + "graphql", + "issues.updateComment", + "issues.updateComment", + ]), + ); expect(lastEnforcerCommentBody(result)).toContain(`\`${ref}\``); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + true, + ); } }); @@ -1035,19 +1191,35 @@ describe("GitHub Actions hardening", () => { // does not fire, they are left with a permanently renamed, drafted PR // and no state to explain it. const result = await run({ - pr: { base: { ref: "main" }, draft: true, title: "[WRONG BRANCH] Port the runtime entry" }, - comments: [botComment({ version: 1, active: true, autoDraftedByBot: true, titlePrefixedByBot: true })], + pr: { + base: { ref: "main" }, + draft: true, + title: "[WRONG BRANCH] Port the runtime entry", + }, + comments: [ + botComment({ + version: 1, + active: true, + autoDraftedByBot: true, + titlePrefixedByBot: true, + }), + ], }); - expect(methodsOf(result)).toEqual(readsAllowedBase([ - "pulls.update", - "graphql", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsAllowedBase(["pulls.update", "graphql", "issues.updateComment"]), + ); expect(callsTo(result, "pulls.update")).toEqual([ - { owner: "GroepOnline", repo: "opencodex", pull_number: 42, title: "Port the runtime entry" }, + { + owner: "GroepOnline", + repo: "opencodex", + pull_number: 42, + title: "Port the runtime entry", + }, ]); - const [cleared] = callsTo(result, "issues.updateComment") as [{ body: string }]; + const [cleared] = callsTo(result, "issues.updateComment") as [ + { body: string }, + ]; expect(cleared.body).toContain('"active":false'); // The confirmation names where the PR actually went, read from the live // PR rather than assumed. @@ -1059,25 +1231,49 @@ describe("GitHub Actions hardening", () => { // inactive, so a move back out has to build fresh state rather than // reuse the cleared one, and must not stack a second prefix. const result = await run({ - pr: { base: { ref: "dev" }, draft: false, title: "Port the runtime entry" }, - comments: [botComment({ version: 1, active: false, autoDraftedByBot: false, titlePrefixedByBot: false })], + pr: { + base: { ref: "dev" }, + draft: false, + title: "Port the runtime entry", + }, + comments: [ + botComment({ + version: 1, + active: false, + autoDraftedByBot: false, + titlePrefixedByBot: false, + }), + ], }); - expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.updateComment", - "pulls.update", - "issues.updateComment", - "graphql", - "issues.updateComment", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsWrongBase([ + "issues.updateComment", + "pulls.update", + "issues.updateComment", + "graphql", + "issues.updateComment", + "issues.updateComment", + ]), + ); expect(callsTo(result, "pulls.update")).toEqual([ - { owner: "GroepOnline", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Port the runtime entry" }, + { + owner: "GroepOnline", + repo: "opencodex", + pull_number: 42, + title: "[WRONG BRANCH] Port the runtime entry", + }, ]); expect(lastEnforcerCommentBody(result)).toContain('"active":true'); - expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); - expect(lastEnforcerCommentBody(result)).toContain('"titlePrefixedByBot":true'); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); + expect(lastEnforcerCommentBody(result)).toContain( + '"autoDraftedByBot":true', + ); + expect(lastEnforcerCommentBody(result)).toContain( + '"titlePrefixedByBot":true', + ); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + true, + ); }); test("the wrong-target explanation names the one allowed base", async () => { @@ -1115,7 +1311,9 @@ describe("GitHub Actions hardening", () => { expect(callsTo(result, "pulls.update")).toEqual([]); expect(callsTo(result, "graphql")).toEqual([]); expect(result.logs.join(" ")).toContain("All PR quality gates passed"); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(false); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + false, + ); }); test("a fork head named main targeting dev stays wrong-base", async () => { @@ -1128,16 +1326,20 @@ describe("GitHub Actions hardening", () => { }, }); - expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.createComment", - "pulls.update", - "issues.updateComment", - "graphql", - "issues.updateComment", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsWrongBase([ + "issues.createComment", + "pulls.update", + "issues.updateComment", + "graphql", + "issues.updateComment", + "issues.updateComment", + ]), + ); expect(lastEnforcerCommentBody(result)).toContain("Wrong target branch"); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + true, + ); }); test("a feature head targeting dev is wrong-base", async () => { @@ -1150,16 +1352,20 @@ describe("GitHub Actions hardening", () => { }, }); - expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.createComment", - "pulls.update", - "issues.updateComment", - "graphql", - "issues.updateComment", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsWrongBase([ + "issues.createComment", + "pulls.update", + "issues.updateComment", + "graphql", + "issues.updateComment", + "issues.updateComment", + ]), + ); expect(lastEnforcerCommentBody(result)).toContain("Wrong target branch"); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + true, + ); }); test("a PR targeting dev is prefixed, drafted, and explained — and nothing else", async () => { @@ -1170,24 +1376,33 @@ describe("GitHub Actions hardening", () => { // Pending ownership first, then title prefix, then claim autoDraftedByBot and // checkpoint before convertToDraft so a successful convert followed by a // failed comment still restores later. - expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.createComment", - "pulls.update", - "issues.updateComment", - "graphql", - "issues.updateComment", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsWrongBase([ + "issues.createComment", + "pulls.update", + "issues.updateComment", + "graphql", + "issues.updateComment", + "issues.updateComment", + ]), + ); // The title update carries the title and nothing else. `base`, `state` // and `body` are all accepted by this endpoint; an audit round added // `base: "main"` here and no static assertion caught it. expect(callsTo(result, "pulls.update")).toEqual([ - { owner: "GroepOnline", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Add a thing" }, + { + owner: "GroepOnline", + repo: "opencodex", + pull_number: 42, + title: "[WRONG BRANCH] Add a thing", + }, ]); // The first comment create addresses this PR, by its own number. - const [created] = callsTo(result, "issues.createComment") as [{ issue_number: number; body: string }]; + const [created] = callsTo(result, "issues.createComment") as [ + { issue_number: number; body: string }, + ]; expect(created.issue_number).toBe(42); expect(created.body).toContain(MARKER); const commentBody = lastEnforcerCommentBody(result); @@ -1195,13 +1410,17 @@ describe("GitHub Actions hardening", () => { expect(commentBody).toContain('"autoDraftedByBot":true'); // The only GraphQL mutation is the draft conversion — not a retarget. - const [draft] = callsTo(result, "graphql") as [{ query: string; variables: unknown }]; + const [draft] = callsTo(result, "graphql") as [ + { query: string; variables: unknown }, + ]; expect(draft.query).toContain("convertPullRequestToDraft"); expect(draft.query).not.toContain("updatePullRequest"); expect(draft.variables).toEqual({ pullRequestId: "PR_kwDOnode42" }); // Wrong-base runs must fail the required check even when mutations succeed. - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + true, + ); }); test("a stacked PR targeting another open PR head is not wrong-base", async () => { @@ -1232,7 +1451,9 @@ describe("GitHub Actions hardening", () => { expect(callsTo(result, "graphql")).toEqual([]); expect(result.logs.join(" ")).toContain("treating as stacked"); expect(result.logs.join(" ")).toContain("All PR quality gates passed"); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(false); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + false, + ); }); test("a stacked parent found on open-PR page two is still exempt", async () => { @@ -1268,19 +1489,27 @@ describe("GitHub Actions hardening", () => { ], }); - const listPages = callsTo(result, "pulls.list").map( - (args) => Number((args as { page?: number }).page ?? 1), + const listPages = callsTo(result, "pulls.list").map((args) => + Number((args as { page?: number }).page ?? 1), ); expect(listPages).toEqual([1, 2]); - expect(methodsOf(result).filter((m) => m === "pulls.list")).toHaveLength(2); + expect(methodsOf(result).filter((m) => m === "pulls.list")).toHaveLength( + 2, + ); expect(callsTo(result, "pulls.update")).toEqual([]); expect(result.logs.join(" ")).toContain("treating as stacked"); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(false); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + false, + ); }); test("a non-main base with no open parent PR is still wrong-base", async () => { const result = await run({ - pr: { base: { ref: "feature/orphan" }, title: "Orphan stack", draft: false }, + pr: { + base: { ref: "feature/orphan" }, + title: "Orphan stack", + draft: false, + }, openPulls: [ { number: 99, @@ -1292,14 +1521,16 @@ describe("GitHub Actions hardening", () => { ], }); - expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.createComment", - "pulls.update", - "issues.updateComment", - "graphql", - "issues.updateComment", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsWrongBase([ + "issues.createComment", + "pulls.update", + "issues.updateComment", + "graphql", + "issues.updateComment", + "issues.updateComment", + ]), + ); expect(callsTo(result, "pulls.update")).toEqual([ { owner: "GroepOnline", @@ -1308,7 +1539,9 @@ describe("GitHub Actions hardening", () => { title: "[WRONG BRANCH] Orphan stack", }, ]); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + true, + ); }); test("a PR that was already a draft is not un-drafted afterwards", async () => { @@ -1319,50 +1552,85 @@ describe("GitHub Actions hardening", () => { // No draft conversion: it is already a draft. Pending ownership first, // then title prefix, then final explanation. State records that the bot // did not draft — which stops restore from marking it ready. - expect(methodsOf(wrong)).toEqual(readsWrongBase([ - "issues.createComment", - "pulls.update", - "issues.updateComment", - ])); - expect(lastEnforcerCommentBody(wrong)).toContain('"autoDraftedByBot":false'); + expect(methodsOf(wrong)).toEqual( + readsWrongBase([ + "issues.createComment", + "pulls.update", + "issues.updateComment", + ]), + ); + expect(lastEnforcerCommentBody(wrong)).toContain( + '"autoDraftedByBot":false', + ); expect(wrong.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); // Now retarget it correctly, feeding that state back in. const restored = await run({ - pr: { base: { ref: "main" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, - comments: [botComment({ version: 1, active: true, autoDraftedByBot: false, titlePrefixedByBot: true })], + pr: { + base: { ref: "main" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, + comments: [ + botComment({ + version: 1, + active: true, + autoDraftedByBot: false, + titlePrefixedByBot: true, + }), + ], }); // The prefix comes off; the draft stays. No GraphQL at all. - expect(methodsOf(restored)).toEqual(readsAllowedBase([ - "pulls.update", - "issues.updateComment", - ])); + expect(methodsOf(restored)).toEqual( + readsAllowedBase(["pulls.update", "issues.updateComment"]), + ); expect(callsTo(restored, "pulls.update")).toEqual([ - { owner: "GroepOnline", repo: "opencodex", pull_number: 42, title: "Add a thing" }, + { + owner: "GroepOnline", + repo: "opencodex", + pull_number: 42, + title: "Add a thing", + }, ]); }); test("a corrected PR gets its title and ready state back", async () => { const result = await run({ - pr: { base: { ref: "main" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, - comments: [botComment({ version: 1, active: true, autoDraftedByBot: true, titlePrefixedByBot: true })], + pr: { + base: { ref: "main" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, + comments: [ + botComment({ + version: 1, + active: true, + autoDraftedByBot: true, + titlePrefixedByBot: true, + }), + ], }); - expect(methodsOf(result)).toEqual(readsAllowedBase([ - "pulls.update", - "graphql", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsAllowedBase(["pulls.update", "graphql", "issues.updateComment"]), + ); expect(callsTo(result, "pulls.update")).toEqual([ - { owner: "GroepOnline", repo: "opencodex", pull_number: 42, title: "Add a thing" }, + { + owner: "GroepOnline", + repo: "opencodex", + pull_number: 42, + title: "Add a thing", + }, ]); const [ready] = callsTo(result, "graphql") as [{ query: string }]; expect(ready.query).toContain("markPullRequestReadyForReview"); // The comment is edited in place, and the state is cleared so a later // run does not try to restore twice. - const [update] = callsTo(result, "issues.updateComment") as [{ comment_id: number; body: string }]; + const [update] = callsTo(result, "issues.updateComment") as [ + { comment_id: number; body: string }, + ]; expect(update.comment_id).toBe(7); expect(update.body).toContain('"active":false'); expect(update.body).toContain("PR quality gates passed"); @@ -1370,26 +1638,52 @@ describe("GitHub Actions hardening", () => { test("only this workflow's own prefix is removed, not a contributor's edits", async () => { const result = await run({ - pr: { base: { ref: "main" }, draft: false, title: "[WRONG BRANCH] Add a thing (v2)" }, - comments: [botComment({ version: 1, active: true, autoDraftedByBot: false, titlePrefixedByBot: true })], + pr: { + base: { ref: "main" }, + draft: false, + title: "[WRONG BRANCH] Add a thing (v2)", + }, + comments: [ + botComment({ + version: 1, + active: true, + autoDraftedByBot: false, + titlePrefixedByBot: true, + }), + ], }); expect(callsTo(result, "pulls.update")).toEqual([ - { owner: "GroepOnline", repo: "opencodex", pull_number: 42, title: "Add a thing (v2)" }, + { + owner: "GroepOnline", + repo: "opencodex", + pull_number: 42, + title: "Add a thing (v2)", + }, ]); }); test("a rerun on an already-handled PR does not stack prefixes or re-draft", async () => { const result = await run({ - pr: { base: { ref: "dev" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, - comments: [botComment({ version: 1, active: true, autoDraftedByBot: true, titlePrefixedByBot: true })], + pr: { + base: { ref: "dev" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, + comments: [ + botComment({ + version: 1, + active: true, + autoDraftedByBot: true, + titlePrefixedByBot: true, + }), + ], }); // The comment is refreshed (pending + final); title and draft are already right. - expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.updateComment", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsWrongBase(["issues.updateComment", "issues.updateComment"]), + ); }); test("the verdict comes from the fetched PR, not the stale event payload", async () => { @@ -1400,21 +1694,32 @@ describe("GitHub Actions hardening", () => { // harness where the two were the same object. const wentWrong = await run({ pr: { base: { ref: "dev" }, title: "Add a thing", draft: false }, - eventPayload: { base: { ref: "main" }, title: "Add a thing", draft: false }, + eventPayload: { + base: { ref: "main" }, + title: "Add a thing", + draft: false, + }, }); // Exact equality, not `toContain`. An audit round hung an extra // `github.request("POST /repos/attacker/other/issues", …)` off precisely // this path because it was the one scenario asserting loosely. - expect(methodsOf(wentWrong)).toEqual(readsWrongBase([ - "issues.createComment", - "pulls.update", - "issues.updateComment", - "graphql", - "issues.updateComment", - "issues.updateComment", - ])); + expect(methodsOf(wentWrong)).toEqual( + readsWrongBase([ + "issues.createComment", + "pulls.update", + "issues.updateComment", + "graphql", + "issues.updateComment", + "issues.updateComment", + ]), + ); expect(callsTo(wentWrong, "pulls.update")).toEqual([ - { owner: "GroepOnline", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Add a thing" }, + { + owner: "GroepOnline", + repo: "opencodex", + pull_number: 42, + title: "[WRONG BRANCH] Add a thing", + }, ]); // …and the reverse: the event says main, the live PR says dev. No writes. @@ -1434,20 +1739,45 @@ describe("GitHub Actions hardening", () => { // hole in reverse. const wrongTarget = await run({ pr: { base: { ref: "dev" }, title: "Add a thing", draft: false }, - eventPayload: { base: { ref: "main" }, title: "Add a thing", draft: false }, + eventPayload: { + base: { ref: "main" }, + title: "Add a thing", + draft: false, + }, }); - expect(lastEnforcerCommentBody(wrongTarget)).toContain("currently targets `dev`"); - expect(lastEnforcerCommentBody(wrongTarget)).not.toContain("currently targets `main`"); + expect(lastEnforcerCommentBody(wrongTarget)).toContain( + "currently targets `dev`", + ); + expect(lastEnforcerCommentBody(wrongTarget)).not.toContain( + "currently targets `main`", + ); // The corrected-path sentence: the event still carries the old wrong // base, the live PR is on dev. Naming the event's base here tells the // author their retarget did not take. const corrected = await run({ - pr: { base: { ref: "main" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, - eventPayload: { base: { ref: "dev" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, - comments: [botComment({ version: 1, active: true, autoDraftedByBot: false, titlePrefixedByBot: true })], + pr: { + base: { ref: "main" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, + eventPayload: { + base: { ref: "dev" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, + comments: [ + botComment({ + version: 1, + active: true, + autoDraftedByBot: false, + titlePrefixedByBot: true, + }), + ], }); - const [edited] = callsTo(corrected, "issues.updateComment") as [{ body: string }]; + const [edited] = callsTo(corrected, "issues.updateComment") as [ + { body: string }, + ]; expect(edited.body).toContain("now targets `main`"); expect(edited.body).not.toContain("now targets `dev`"); }); @@ -1464,20 +1794,33 @@ describe("GitHub Actions hardening", () => { })); const result = await run({ - pr: { base: { ref: "main" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, + pr: { + base: { ref: "main" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, commentPages: [ filler, - [botComment({ version: 1, active: true, autoDraftedByBot: true, titlePrefixedByBot: true })], + [ + botComment({ + version: 1, + active: true, + autoDraftedByBot: true, + titlePrefixedByBot: true, + }), + ], ], }); // Found it: the prefix comes off, the PR is marked ready, and the // existing comment is edited rather than duplicated. - expect(methodsOf(result)).toEqual(readsAllowedBasePaged([ - "pulls.update", - "graphql", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsAllowedBasePaged([ + "pulls.update", + "graphql", + "issues.updateComment", + ]), + ); expect(callsTo(result, "issues.createComment")).toEqual([]); }); @@ -1488,24 +1831,30 @@ describe("GitHub Actions hardening", () => { // permanent no-op, and every scenario still passed. const result = await run({ pr: { base: { ref: "dev" }, title: "Add a thing", draft: false }, - comments: [{ - id: 7, - user: { login: BOT }, - body: `${MARKER}\n`, - }], + comments: [ + { + id: 7, + user: { login: BOT }, + body: `${MARKER}\n`, + }, + ], }); // Enforcement still happens, and the unreadable comment is repaired in // place rather than duplicated. - expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.updateComment", - "pulls.update", - "issues.updateComment", - "graphql", - "issues.updateComment", - "issues.updateComment", - ])); - expect(result.warnings.join(" ")).toContain("Could not parse stored workflow state"); + expect(methodsOf(result)).toEqual( + readsWrongBase([ + "issues.updateComment", + "pulls.update", + "issues.updateComment", + "graphql", + "issues.updateComment", + "issues.updateComment", + ]), + ); + expect(result.warnings.join(" ")).toContain( + "Could not parse stored workflow state", + ); }); test("an active state that recorded no changes still enforces and still clears", async () => { @@ -1523,22 +1872,33 @@ describe("GitHub Actions hardening", () => { // Still wrong: refresh the explanation. Nothing to re-apply. const stillWrong = await run({ - pr: { base: { ref: "dev" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, + pr: { + base: { ref: "dev" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, comments: [botComment(noRecordedChanges)], }); - expect(methodsOf(stillWrong)).toEqual(readsWrongBase([ - "issues.updateComment", - "issues.updateComment", - ])); + expect(methodsOf(stillWrong)).toEqual( + readsWrongBase(["issues.updateComment", "issues.updateComment"]), + ); // Corrected: nothing to undo, but the state must still be cleared or the // next wrong-target event resumes from a stale record. const corrected = await run({ - pr: { base: { ref: "main" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, + pr: { + base: { ref: "main" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, comments: [botComment(noRecordedChanges)], }); - expect(methodsOf(corrected)).toEqual(readsAllowedBase(["issues.updateComment"])); - const [cleared] = callsTo(corrected, "issues.updateComment") as [{ body: string }]; + expect(methodsOf(corrected)).toEqual( + readsAllowedBase(["issues.updateComment"]), + ); + const [cleared] = callsTo(corrected, "issues.updateComment") as [ + { body: string }, + ]; expect(cleared.body).toContain('"active":false'); expect(cleared.body).toContain("PR quality gates passed"); }); @@ -1550,16 +1910,28 @@ describe("GitHub Actions hardening", () => { // !pr.draft) return;` — the state comment stays active forever and the // next wrong-target event resumes from a record that no longer matches. const result = await run({ - pr: { base: { ref: "main" }, draft: false, title: "[WRONG BRANCH] Add a thing" }, - comments: [botComment({ version: 1, active: true, autoDraftedByBot: true, titlePrefixedByBot: true })], + pr: { + base: { ref: "main" }, + draft: false, + title: "[WRONG BRANCH] Add a thing", + }, + comments: [ + botComment({ + version: 1, + active: true, + autoDraftedByBot: true, + titlePrefixedByBot: true, + }), + ], }); // Nothing to un-draft, the prefix comes off, and the state is cleared. - expect(methodsOf(result)).toEqual(readsAllowedBase([ - "pulls.update", - "issues.updateComment", - ])); - const [cleared] = callsTo(result, "issues.updateComment") as [{ body: string }]; + expect(methodsOf(result)).toEqual( + readsAllowedBase(["pulls.update", "issues.updateComment"]), + ); + const [cleared] = callsTo(result, "issues.updateComment") as [ + { body: string }, + ]; expect(cleared.body).toContain('"active":false'); }); @@ -1569,14 +1941,20 @@ describe("GitHub Actions hardening", () => { // the first 15 characters of their title. const result = await run({ pr: { base: { ref: "main" }, draft: true, title: "Add a thing" }, - comments: [botComment({ version: 1, active: true, autoDraftedByBot: true, titlePrefixedByBot: true })], + comments: [ + botComment({ + version: 1, + active: true, + autoDraftedByBot: true, + titlePrefixedByBot: true, + }), + ], }); expect(callsTo(result, "pulls.update")).toEqual([]); - expect(methodsOf(result)).toEqual(readsAllowedBase([ - "graphql", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsAllowedBase(["graphql", "issues.updateComment"]), + ); }); test("the explanation tells the contributor what to do and where to read", async () => { @@ -1586,7 +1964,12 @@ describe("GitHub Actions hardening", () => { // both left every other assertion intact, and both leave a contributor // staring at a mangled PR with no notification and no next step. const result = await run({ - pr: { base: { ref: "dev" }, title: "Add a thing", draft: false, user: { login: "someone-else" } }, + pr: { + base: { ref: "dev" }, + title: "Add a thing", + draft: false, + user: { login: "someone-else" }, + }, }); const commentBody = lastEnforcerCommentBody(result); @@ -1613,7 +1996,9 @@ describe("GitHub Actions hardening", () => { // duplicate and forgets what it changed. Round ten dropped it to 1 and // nothing failed. const result = await run({ pr: { base: { ref: "main" } } }); - const [listed] = callsTo(result, "issues.listComments") as [{ per_page: number }]; + const [listed] = callsTo(result, "issues.listComments") as [ + { per_page: number }, + ]; expect(listed.per_page).toBe(100); }); @@ -1624,14 +2009,29 @@ describe("GitHub Actions hardening", () => { // nobody honours — the prefix stays on forever. Round ten bumped it to 2 // and every test passed, because nothing asserted the value. const wrong = await run({ pr: { base: { ref: "dev" }, draft: false } }); - const [posted] = callsTo(wrong, "issues.createComment") as [{ body: string }]; + const [posted] = callsTo(wrong, "issues.createComment") as [ + { body: string }, + ]; expect(posted.body).toContain('"version":1'); const cleared = await run({ - pr: { base: { ref: "main" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, - comments: [botComment({ version: 1, active: true, autoDraftedByBot: true, titlePrefixedByBot: true })], + pr: { + base: { ref: "main" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, + comments: [ + botComment({ + version: 1, + active: true, + autoDraftedByBot: true, + titlePrefixedByBot: true, + }), + ], }); - const [done] = callsTo(cleared, "issues.updateComment") as [{ body: string }]; + const [done] = callsTo(cleared, "issues.updateComment") as [ + { body: string }, + ]; expect(done.body).toContain('"version":1'); }); @@ -1646,24 +2046,38 @@ describe("GitHub Actions hardening", () => { // produced only ["pulls.get", "issues.listComments"]. No title // restoration, no ready-for-review, permanently stuck. for (const version of [2, 99]) { - const active = { version, active: true, autoDraftedByBot: true, titlePrefixedByBot: true }; + const active = { + version, + active: true, + autoDraftedByBot: true, + titlePrefixedByBot: true, + }; // Corrected target: the unknown-version state is trusted and both // changes are undone, and the marker is rewritten at the version this // workflow writes. const restored = await run({ - pr: { base: { ref: "main" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, + pr: { + base: { ref: "main" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, comments: [botComment(active)], }); - expect(methodsOf(restored)).toEqual(readsAllowedBase([ - "pulls.update", - "graphql", - "issues.updateComment", - ])); + expect(methodsOf(restored)).toEqual( + readsAllowedBase(["pulls.update", "graphql", "issues.updateComment"]), + ); expect(callsTo(restored, "pulls.update")).toEqual([ - { owner: "GroepOnline", repo: "opencodex", pull_number: 42, title: "Add a thing" }, + { + owner: "GroepOnline", + repo: "opencodex", + pull_number: 42, + title: "Add a thing", + }, ]); - const [cleared] = callsTo(restored, "issues.updateComment") as [{ body: string }]; + const [cleared] = callsTo(restored, "issues.updateComment") as [ + { body: string }, + ]; expect(cleared.body).toContain('"version":1'); expect(cleared.body).toContain('"active":false'); @@ -1674,17 +2088,23 @@ describe("GitHub Actions hardening", () => { pr: { base: { ref: "dev" }, draft: false, title: "Add a thing" }, comments: [botComment(active)], }); - expect(methodsOf(wrong)).toEqual(readsWrongBase([ - "issues.updateComment", - "pulls.update", - "issues.updateComment", - "graphql", - "issues.updateComment", - "issues.updateComment", - ])); - expect(lastEnforcerCommentBody(wrong)).toContain(`"version":${version}`); + expect(methodsOf(wrong)).toEqual( + readsWrongBase([ + "issues.updateComment", + "pulls.update", + "issues.updateComment", + "graphql", + "issues.updateComment", + "issues.updateComment", + ]), + ); + expect(lastEnforcerCommentBody(wrong)).toContain( + `"version":${version}`, + ); expect(lastEnforcerCommentBody(wrong)).toContain('"active":true'); - expect(wrong.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); + expect(wrong.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + true, + ); } }); @@ -1702,26 +2122,55 @@ describe("GitHub Actions hardening", () => { // contributor-reachable. It is reachable across a migration, which is // exactly when the prefix must still come off. const loose = await run({ - pr: { base: { ref: "main" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, - comments: [botComment({ version: 1, active: "true", autoDraftedByBot: 1, titlePrefixedByBot: "yes" })], + pr: { + base: { ref: "main" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, + comments: [ + botComment({ + version: 1, + active: "true", + autoDraftedByBot: 1, + titlePrefixedByBot: "yes", + }), + ], }); - expect(methodsOf(loose)).toEqual(readsAllowedBase([ - "pulls.update", - "graphql", - "issues.updateComment", - ])); + expect(methodsOf(loose)).toEqual( + readsAllowedBase(["pulls.update", "graphql", "issues.updateComment"]), + ); expect(callsTo(loose, "pulls.update")).toEqual([ - { owner: "GroepOnline", repo: "opencodex", pull_number: 42, title: "Add a thing" }, + { + owner: "GroepOnline", + repo: "opencodex", + pull_number: 42, + title: "Add a thing", + }, ]); // And the falsy side is symmetric: `null` and `0` skip their own // restoration without stopping the run or the clearing write. const falsy = await run({ - pr: { base: { ref: "main" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, - comments: [botComment({ version: 1, active: true, autoDraftedByBot: null, titlePrefixedByBot: 0 })], + pr: { + base: { ref: "main" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, + comments: [ + botComment({ + version: 1, + active: true, + autoDraftedByBot: null, + titlePrefixedByBot: 0, + }), + ], }); - expect(methodsOf(falsy)).toEqual(readsAllowedBase(["issues.updateComment"])); - const [cleared] = callsTo(falsy, "issues.updateComment") as [{ body: string }]; + expect(methodsOf(falsy)).toEqual( + readsAllowedBase(["issues.updateComment"]), + ); + const [cleared] = callsTo(falsy, "issues.updateComment") as [ + { body: string }, + ]; expect(cleared.body).toContain('"active":false'); }); @@ -1741,7 +2190,9 @@ describe("GitHub Actions hardening", () => { expect(title).toBeLessThan(draftClaim); expect(draftClaim).toBeLessThan(draft); expect(draft).toBeLessThan(finalUpdate); - expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); + expect(lastEnforcerCommentBody(result)).toContain( + '"autoDraftedByBot":true', + ); }); test("a title that is exactly the prefix is still enforced", async () => { @@ -1756,15 +2207,21 @@ describe("GitHub Actions hardening", () => { // Already prefixed, so no title write — but pending/draft/final still run. expect(callsTo(result, "pulls.update")).toEqual([]); - expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.createComment", - "issues.updateComment", - "graphql", - "issues.updateComment", - "issues.updateComment", - ])); - expect(lastEnforcerCommentBody(result)).toContain('"titlePrefixedByBot":false'); - expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); + expect(methodsOf(result)).toEqual( + readsWrongBase([ + "issues.createComment", + "issues.updateComment", + "graphql", + "issues.updateComment", + "issues.updateComment", + ]), + ); + expect(lastEnforcerCommentBody(result)).toContain( + '"titlePrefixedByBot":false', + ); + expect(lastEnforcerCommentBody(result)).toContain( + '"autoDraftedByBot":true', + ); }); test("an empty title is enforced rather than skipped", async () => { @@ -1776,13 +2233,20 @@ describe("GitHub Actions hardening", () => { }); expect(callsTo(result, "pulls.update")).toEqual([ - { owner: "GroepOnline", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] " }, + { + owner: "GroepOnline", + repo: "opencodex", + pull_number: 42, + title: "[WRONG BRANCH] ", + }, ]); - expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.createComment", - "pulls.update", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsWrongBase([ + "issues.createComment", + "pulls.update", + "issues.updateComment", + ]), + ); }); test("an already-prefixed title is never prefixed twice", async () => { @@ -1792,7 +2256,11 @@ describe("GitHub Actions hardening", () => { // prefix — which only ever matches after the bug already happened — // cannot be introduced as if it were the fix. const result = await run({ - pr: { base: { ref: "dev" }, title: "[WRONG BRANCH] Add a thing", draft: true }, + pr: { + base: { ref: "dev" }, + title: "[WRONG BRANCH] Add a thing", + draft: true, + }, }); // No second prefix — and the run still does everything else it owes: @@ -1800,11 +2268,12 @@ describe("GitHub Actions hardening", () => { // Asserting only the absent write would let an early return keyed on the // doubled prefix pass, since that skips the write too. expect(callsTo(result, "pulls.update")).toEqual([]); - expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.createComment", - "issues.updateComment", - ])); - expect(lastEnforcerCommentBody(result)).toContain('"titlePrefixedByBot":false'); + expect(methodsOf(result)).toEqual( + readsWrongBase(["issues.createComment", "issues.updateComment"]), + ); + expect(lastEnforcerCommentBody(result)).toContain( + '"titlePrefixedByBot":false', + ); expect(lastEnforcerCommentBody(result)).toContain('"active":true'); }); @@ -1816,20 +2285,28 @@ describe("GitHub Actions hardening", () => { // ["pulls.get", "issues.listComments"] — no comment, no draft, complete // exemption. const result = await run({ - pr: { base: { ref: "dev" }, title: "[WRONG BRANCH] [WRONG BRANCH] mine", draft: false }, + pr: { + base: { ref: "dev" }, + title: "[WRONG BRANCH] [WRONG BRANCH] mine", + draft: false, + }, }); - expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.createComment", - "issues.updateComment", - "graphql", - "issues.updateComment", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsWrongBase([ + "issues.createComment", + "issues.updateComment", + "graphql", + "issues.updateComment", + "issues.updateComment", + ]), + ); // Already prefixed by the `startsWith` test, so no third prefix is added. expect(callsTo(result, "pulls.update")).toEqual([]); expect(lastEnforcerCommentBody(result)).toContain('"active":true'); - expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); + expect(lastEnforcerCommentBody(result)).toContain( + '"autoDraftedByBot":true', + ); }); test("with two bot comments, the workflow reads and writes the first", async () => { @@ -1841,27 +2318,37 @@ describe("GitHub Actions hardening", () => { const first = { id: 7, user: { login: BOT }, - body: [MARKER, ``].join("\n"), + body: [ + MARKER, + ``, + ].join("\n"), }; const second = { id: 8, user: { login: BOT }, - body: [MARKER, ``].join("\n"), + body: [ + MARKER, + ``, + ].join("\n"), }; const result = await run({ - pr: { base: { ref: "main" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, + pr: { + base: { ref: "main" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, comments: [first, second], }); // The first comment's state is the one honoured: it says the bot // prefixed and drafted, so both are undone. - expect(methodsOf(result)).toEqual(readsAllowedBase([ - "pulls.update", - "graphql", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsAllowedBase(["pulls.update", "graphql", "issues.updateComment"]), + ); // And the first comment is the one rewritten, not the second. - const [updated] = callsTo(result, "issues.updateComment") as [{ comment_id: number }]; + const [updated] = callsTo(result, "issues.updateComment") as [ + { comment_id: number }, + ]; expect(updated.comment_id).toBe(7); }); @@ -1873,7 +2360,11 @@ describe("GitHub Actions hardening", () => { // GraphQL call and `pulls.update`, never this one. for (const status of [404, 403, 500]) { await expect( - run({ pr: { base: { ref: "main" } }, failOn: ["pulls.get"], failStatus: status }), + run({ + pr: { base: { ref: "main" } }, + failOn: ["pulls.get"], + failStatus: status, + }), ).rejects.toThrow(); } }); @@ -1964,7 +2455,9 @@ describe("GitHub Actions hardening", () => { // Everything the action injects is a function or an object on the runner. // Nothing here may be `undefined`. - expect(Object.values(probe).some(value => value === "undefined")).toBe(false); + expect(Object.values(probe).some((value) => value === "undefined")).toBe( + false, + ); expect(probe["core.getInput"]).toBe("function"); expect(probe["core.setOutput"]).toBe("function"); expect(probe["core.summary.addRaw"]).toBe("function"); @@ -1989,8 +2482,12 @@ describe("GitHub Actions hardening", () => { // Read the major out of the workflow's own pin rather than hardcoding // it: re-pinning the action to a node26 build should fail here and say // so, not silently reopen the gap. - const workflow = await readText(".github/workflows/enforce-pr-target.yml"); - expect(workflow).toContain("actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3"); + const workflow = await readText( + ".github/workflows/enforce-pr-target.yml", + ); + expect(workflow).toContain( + "actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3", + ); const probe = await runProbe(` return { @@ -2021,18 +2518,26 @@ describe("GitHub Actions hardening", () => { failOn: ["graphql"], failStatus: status, }); - expect(methodsOf(result)).toEqual(readsWrongBase([ - "issues.createComment", - "pulls.update", - "issues.updateComment", - "graphql", - "issues.updateComment", - ])); + expect(methodsOf(result)).toEqual( + readsWrongBase([ + "issues.createComment", + "pulls.update", + "issues.updateComment", + "graphql", + "issues.updateComment", + ]), + ); const commentBody = lastEnforcerCommentBody(result); expect(commentBody).toContain('"autoDraftedByBot":false'); expect(commentBody).toContain("Automatic draft conversion failed"); - expect(result.warnings.some((w) => w.includes("Could not convert pull request to draft"))).toBe(true); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); + expect( + result.warnings.some((w) => + w.includes("Could not convert pull request to draft"), + ), + ).toBe(true); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + true, + ); } // Title update failures still propagate — without the prefix the gate @@ -2057,13 +2562,19 @@ describe("GitHub Actions hardening", () => { const commentBody = lastEnforcerCommentBody(result); expect(commentBody).toContain('"autoDraftedByBot":false'); expect(commentBody).toContain('"titlePrefixedByBot":true'); - expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe( + true, + ); }); test("a failed ready-for-review conversion keeps ownership active for retry", async () => { const { script } = await readEnforcePrTarget(); const result = await runEnforcePrTarget(script, { - pr: { base: { ref: "main" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, + pr: { + base: { ref: "main" }, + draft: true, + title: "[WRONG BRANCH] Add a thing", + }, comments: [ { id: 7, @@ -2084,8 +2595,14 @@ describe("GitHub Actions hardening", () => { const commentBody = lastEnforcerCommentBody(result); expect(commentBody).toContain('"active":true'); expect(commentBody).toContain('"autoDraftedByBot":true'); - expect(commentBody).toContain("Automatic ready-for-review conversion failed"); - expect(result.warnings.some((w) => w.includes("Could not mark pull request ready for review"))).toBe(true); + expect(commentBody).toContain( + "Automatic ready-for-review conversion failed", + ); + expect( + result.warnings.some((w) => + w.includes("Could not mark pull request ready for review"), + ), + ).toBe(true); }); }); @@ -2113,11 +2630,17 @@ describe("GitHub Actions hardening", () => { ["convertToDraft", "convertPullRequestToDraft"], ["markReadyForReview", "markPullRequestReadyForReview"], ] as const) { - const declarations = [...script.matchAll(new RegExp(`function\\s+${helper}\\s*\\(`, "g"))]; + const declarations = [ + ...script.matchAll(new RegExp(`function\\s+${helper}\\s*\\(`, "g")), + ]; expect(declarations).toHaveLength(1); const body = script.slice(declarations[0]!.index!); - const nextDeclaration = body.slice(1).search(/\n\s*(?:async\s+)?function\s/); - expect(nextDeclaration === -1 ? body : body.slice(0, nextDeclaration + 1)).toContain(mutation); + const nextDeclaration = body + .slice(1) + .search(/\n\s*(?:async\s+)?function\s/); + expect( + nextDeclaration === -1 ? body : body.slice(0, nextDeclaration + 1), + ).toContain(mutation); } expect(script).toMatch(/const TITLE_PREFIX = "\[WRONG BRANCH\] ";/); @@ -2137,7 +2660,10 @@ describe("GitHub Actions hardening", () => { const branch = script.slice(branchStart); const pendingWriteIndex = branch.indexOf("await upsertComment("); const draftCallIndex = branch.indexOf("await convertToDraft()"); - const afterDraftWriteIndex = branch.indexOf("await upsertComment(", draftCallIndex); + const afterDraftWriteIndex = branch.indexOf( + "await upsertComment(", + draftCallIndex, + ); expect(pendingWriteIndex).toBeGreaterThan(-1); expect(draftCallIndex).toBeGreaterThan(-1); expect(pendingWriteIndex).toBeLessThan(draftCallIndex); @@ -2147,13 +2673,21 @@ describe("GitHub Actions hardening", () => { test("docs deployment is pinned, bounded, and scoped to Pages", async () => { const workflow = await readText(".github/workflows/deploy-docs.yml"); - expect(workflow).toContain("permissions:\n contents: read\n pages: write\n id-token: write"); + expect(workflow).toContain( + "permissions:\n contents: read\n pages: write\n id-token: write", + ); expect(workflow).toContain("cancel-in-progress: false"); expect(workflow).toContain("timeout-minutes: 15"); expect(workflow).toContain("timeout-minutes: 10"); - expect(workflow).toContain("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"); - expect(workflow).toContain("withastro/action@e84f40bd8d2caa9e768ec82ad30dd81f0b280853"); - expect(workflow).toContain("actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128"); + expect(workflow).toContain( + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + ); + expect(workflow).toContain( + "withastro/action@e84f40bd8d2caa9e768ec82ad30dd81f0b280853", + ); + expect(workflow).toContain( + "actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128", + ); expect(workflow).not.toMatch(/uses:\s+\S+@(?:v\d+|main|master)\b/); // The Astro action's own `package-manager` input is a mutable version @@ -2179,7 +2713,9 @@ describe("GitHub Actions hardening", () => { expect(workflow).not.toContain("docker/login-action@"); expect(workflow).toContain("sudo docker login ghcr.io"); expect(workflow).toContain("sudo docker pull"); - expect(workflow).toContain("sudo docker image inspect --format='{{index .RepoDigests 0}}'"); + expect(workflow).toContain( + "sudo docker image inspect --format='{{index .RepoDigests 0}}'", + ); expect(workflow).toContain("/etc/chef/opencodex/service-api-token"); expect(workflow).toContain('sudo sha256sum "$token_file"'); expect(workflow).toContain("OPENCODEX_IMAGE="); @@ -2188,27 +2724,37 @@ describe("GitHub Actions hardening", () => { expect(workflow).not.toContain("bun install --frozen-lockfile"); expect(workflow).not.toContain("git checkout --force"); expect(workflow).not.toContain("git reset --hard"); - expect(workflow).not.toContain("Refuse dirty live checkout or dropped commits"); + expect(workflow).not.toContain( + "Refuse dirty live checkout or dropped commits", + ); expect(workflow).not.toContain("oven-sh/setup-bun@"); expect(workflow).not.toContain("actions/setup-node@"); }); test("actionlint config declares exactly the self-hosted labels the deploy workflow requires", async () => { - const config = Bun.YAML.parse(await readText(".github/actionlint.yaml")) as { + const config = Bun.YAML.parse( + await readText(".github/actionlint.yaml"), + ) as { "self-hosted-runner"?: { labels?: string[] }; }; // actionlint fails closed on unknown `runs-on` labels for self-hosted runners, // so every label deploy.yml's `runs-on: [self-hosted, ...]` uses must be // declared here or CI linting the workflow itself would go red. - expect(config["self-hosted-runner"]?.labels).toEqual(["deploy", "opencodex"]); + expect(config["self-hosted-runner"]?.labels).toEqual([ + "deploy", + "opencodex", + ]); - const deploy = Bun.YAML.parse(await readText(".github/workflows/deploy.yml")) as { + const deploy = Bun.YAML.parse( + await readText(".github/workflows/deploy.yml"), + ) as { jobs?: Record; }; const runsOn = deploy.jobs?.deploy?.["runs-on"]; expect(Array.isArray(runsOn)).toBe(true); for (const label of runsOn as string[]) { - if (label === "self-hosted" || label === "Linux" || label === "X64") continue; + if (label === "self-hosted" || label === "Linux" || label === "X64") + continue; expect(config["self-hosted-runner"]?.labels).toContain(label); } }); @@ -2219,20 +2765,30 @@ describe("GitHub Actions hardening", () => { jobs?: Record< string, { - steps?: Array<{ name?: string; uses?: string; if?: string; with?: Record }>; + steps?: Array<{ + name?: string; + uses?: string; + if?: string; + with?: Record; + }>; } >; }; const steps = workflow.jobs?.test?.steps ?? []; - const names = steps.map(step => step.name); + const names = steps.map((step) => step.name); // Bun's own toolchain cache must be enabled, and the GUI node_modules cache // step must exist and run before dependencies are installed (a cache // restored after `bun install` cannot save any work). - const setupBun = steps.find(step => step.uses?.startsWith("oven-sh/setup-bun@")); - expect(setupBun?.with?.cache).toBe(true); - expect(setupBun?.with?.["cache-bin"]).toBe(true); + const setupBun = steps.find((step) => + step.uses?.startsWith("oven-sh/setup-bun@"), + ); + // The pinned setup-bun action exposes no-cache, not cache/cache-bin. + // Unknown inputs only warn in Actions, so reject them here as well. + expect(setupBun?.with?.["no-cache"]).toBe(false); + expect(setupBun?.with).not.toHaveProperty("cache"); + expect(setupBun?.with).not.toHaveProperty("cache-bin"); const cacheIndex = names.indexOf("Cache GUI node_modules"); const installGuiIndex = names.indexOf("Install GUI dependencies"); @@ -2247,22 +2803,34 @@ describe("GitHub Actions hardening", () => { // step it feeds; a narrower/broader condition would either install into an // uncached tree on some matrix legs or restore a cache nothing populated. expect(cacheStep.if).toBe(steps[installGuiIndex]!.if); - expect(cacheStep.uses).toBe("actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9"); // v4 + expect(cacheStep.uses).toBe( + "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9", + ); // v4 expect(cacheStep.with?.path).toBe("gui/node_modules"); - expect(cacheStep.with?.key).toBe("gui-node-modules-${{ runner.os }}-${{ hashFiles('gui/bun.lock') }}"); + expect(cacheStep.with?.key).toBe( + "gui-node-modules-${{ runner.os }}-${{ hashFiles('gui/bun.lock') }}", + ); // The restore-keys fallback must be a strict prefix of the primary key // (drop the lockfile hash) so a lockfile bump still gets a partial hit. - expect(String(cacheStep.with?.["restore-keys"] ?? "").trim()).toBe("gui-node-modules-${{ runner.os }}-"); + expect(String(cacheStep.with?.["restore-keys"] ?? "").trim()).toBe( + "gui-node-modules-${{ runner.os }}-", + ); const lintJob = workflow.jobs?.["lint-github-actions"]?.steps ?? []; - const yamllint = lintJob.find(step => step.name === "Lint YAML"); + const yamllint = lintJob.find((step) => step.name === "Lint YAML"); expect(yamllint).toBeDefined(); - expect(yamllint?.uses).toBe("ibiqlik/action-yamllint@2576378a8e339169678f9939646ee3ee325e845c"); // v3.1.1 + expect(yamllint?.uses).toBe( + "ibiqlik/action-yamllint@2576378a8e339169678f9939646ee3ee325e845c", + ); // v3.1.1 expect(yamllint?.with?.config_file).toBe(".yamllint.yml"); // actionlint must run first: a YAML syntax error should surface as an // actionlint parse failure with clearer context before yamllint style rules run. - const actionlintIndex = lintJob.findIndex(step => step.name === "Lint workflows"); - const yamllintIndex = lintJob.findIndex(step => step.name === "Lint YAML"); + const actionlintIndex = lintJob.findIndex( + (step) => step.name === "Lint workflows", + ); + const yamllintIndex = lintJob.findIndex( + (step) => step.name === "Lint YAML", + ); expect(actionlintIndex).toBeGreaterThan(-1); expect(actionlintIndex).toBeLessThan(yamllintIndex); }); @@ -2272,11 +2840,16 @@ describe("GitHub Actions hardening", () => { const workflow = Bun.YAML.parse(text) as { on?: { push?: { tags?: string[] }; - workflow_dispatch?: { inputs?: Record }; + workflow_dispatch?: { + inputs?: Record; + }; }; permissions?: Record; concurrency?: { group?: string; "cancel-in-progress"?: boolean }; - jobs?: Record }>; + jobs?: Record< + string, + { "timeout-minutes"?: number; env?: Record } + >; }; expect(workflow.on?.push?.tags).toEqual(["v*.*.*"]); @@ -2285,7 +2858,11 @@ describe("GitHub Actions hardening", () => { // Read-only token plus GHCR pull: the job reads container metadata and pulls // a digest-pinned image; it never pushes packages or mutates GitHub state. - expect(workflow.permissions).toEqual({ contents: "read", packages: "read", actions: "read" }); + expect(workflow.permissions).toEqual({ + contents: "read", + packages: "read", + actions: "read", + }); // A second deploy must queue rather than race the first, and a mid-flight // cancel could leave the live checkout half-updated with no rollback run. @@ -2294,9 +2871,13 @@ describe("GitHub Actions hardening", () => { expect(workflow.jobs?.deploy?.["timeout-minutes"]).toBe(30); expect(workflow.jobs?.deploy?.env?.DEPLOY_PATH).toBeUndefined(); - expect(workflow.jobs?.deploy?.env?.COMPOSE_DIR).toBe("/opt/chef/deploy/opencodex"); + expect(workflow.jobs?.deploy?.env?.COMPOSE_DIR).toBe( + "/opt/chef/deploy/opencodex", + ); - expect(text).toContain("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"); + expect(text).toContain( + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + ); expect(text).toContain("sudo docker login ghcr.io"); expect(text).toContain("sudo docker logout ghcr.io"); expect(text).not.toContain("docker/login-action@"); @@ -2306,10 +2887,18 @@ describe("GitHub Actions hardening", () => { test("az-01 deploy resolves the dispatch ref via env (not inline interpolation) and validates its shape", async () => { const text = await readText(".github/workflows/deploy.yml"); const workflow = Bun.YAML.parse(text) as { - jobs?: { deploy?: { steps?: Array<{ name?: string; env?: Record; run?: string }> } }; + jobs?: { + deploy?: { + steps?: Array<{ + name?: string; + env?: Record; + run?: string; + }>; + }; + }; }; const steps = workflow.jobs?.deploy?.steps ?? []; - const resolve = steps.find(step => step.name === "Resolve deploy tag"); + const resolve = steps.find((step) => step.name === "Resolve deploy tag"); expect(resolve).toBeDefined(); // The raw dispatch input must be delivered as data via env, never spliced @@ -2355,38 +2944,57 @@ describe("GitHub Actions hardening", () => { }; }; const steps = workflow.jobs?.deploy?.steps ?? []; - const verify = steps.find(step => step.id === "verify"); - const deploy = steps.find(step => step.name === "Deploy digest-pinned container (in-place cutover)"); + const verify = steps.find((step) => step.id === "verify"); + const deploy = steps.find( + (step) => + step.name === "Deploy digest-pinned container (in-place cutover)", + ); expect(verify).toBeDefined(); expect(deploy).toBeDefined(); - const checkouts = steps.filter(step => step.uses?.startsWith("actions/checkout@")); + const checkouts = steps.filter((step) => + step.uses?.startsWith("actions/checkout@"), + ); expect(checkouts).toHaveLength(2); for (const checkout of checkouts) { expect(checkout.with?.["persist-credentials"]).toBe(false); } - expect(verify!.run ?? "").toContain('git merge-base --is-ancestor "refs/tags/$tag" "origin/main"'); - expect(verify!.run ?? "").toContain("is not on origin/main"); - const tagShaAssigns = [...(verify!.run ?? "").matchAll(/tag_sha=\$\(git rev-parse [^)]+\)/g)].map( - match => match[0], + expect(verify!.run ?? "").toContain( + 'git merge-base --is-ancestor "refs/tags/$tag" "origin/main"', ); - expect(tagShaAssigns).toEqual(['tag_sha=$(git rev-parse "refs/tags/$tag^{}")']); + expect(verify!.run ?? "").toContain("is not on origin/main"); + const tagShaAssigns = [ + ...(verify!.run ?? "").matchAll(/tag_sha=\$\(git rev-parse [^)]+\)/g), + ].map((match) => match[0]); + expect(tagShaAssigns).toEqual([ + 'tag_sha=$(git rev-parse "refs/tags/$tag^{}")', + ]); - const verifyIndex = steps.findIndex(step => step.id === "verify"); - const peeledCheckout = steps.findIndex(step => step.name === "Checkout peeled tag commit"); + const verifyIndex = steps.findIndex((step) => step.id === "verify"); + const peeledCheckout = steps.findIndex( + (step) => step.name === "Checkout peeled tag commit", + ); expect(verifyIndex).toBeGreaterThanOrEqual(0); expect(peeledCheckout).toBeGreaterThan(verifyIndex); expect(text).toContain("ref: ${{ steps.verify.outputs.tag_sha }}"); expect(text).not.toContain("ref: ${{ steps.ref.outputs.tag }}"); expect(deploy!.run ?? "").toContain("deploy/container/compose.example.yml"); - expect(deploy!.run ?? "").toContain("deploy/container/opencodex-proxy.service"); - expect(deploy!.run ?? "").toContain("systemctl stop opencodex-proxy.service"); - expect(deploy!.run ?? "").toContain("systemctl start opencodex-proxy.service"); + expect(deploy!.run ?? "").toContain( + "deploy/container/opencodex-proxy.service", + ); + expect(deploy!.run ?? "").toContain( + "systemctl stop opencodex-proxy.service", + ); + expect(deploy!.run ?? "").toContain( + "systemctl start opencodex-proxy.service", + ); const unit = await readText("deploy/container/opencodex-proxy.service"); expect(unit).toContain("docker compose up"); - expect(unit).toContain("After=docker.service network-online.target tailscaled.service"); + expect(unit).toContain( + "After=docker.service network-online.target tailscaled.service", + ); expect(unit).toContain("Wants=network-online.target tailscaled.service"); expect(deploy!.run ?? "").not.toContain("git checkout --force"); expect(deploy!.run ?? "").not.toContain("git reset --hard"); @@ -2409,53 +3017,86 @@ describe("GitHub Actions hardening", () => { }; }; const steps = workflow.jobs?.deploy?.steps ?? []; - const health = steps.find(step => step.id === "health"); - const rollback = steps.find(step => step.name === "Rollback on failure"); + const health = steps.find((step) => step.id === "health"); + const rollback = steps.find((step) => step.name === "Rollback on failure"); expect(health).toBeDefined(); expect(rollback).toBeDefined(); expect(health!.run ?? "").toContain("deadline=$((SECONDS + 60))"); - expect(health!.run ?? "").toContain('b.get("gitSha") == os.environ["TAG_SHA"]'); + expect(health!.run ?? "").toContain( + 'b.get("gitSha") == os.environ["TAG_SHA"]', + ); expect(health!.run ?? "").toContain('b.get("service") == "opencodex"'); expect(health!.run ?? "").not.toContain("MainPID"); expect(health!.run ?? "").not.toContain('b.get("pid")'); expect(health!.run ?? "").toContain("--max-time"); expect(health!.run ?? "").toContain("gitSha-verified healthy within 60s"); - expect(health!.run ?? "").toContain("opencodex · proxy dashboard"); - const resolveHealthIndex = steps.findIndex(step => step.name === "Resolve health URLs"); - const deployIndex = steps.findIndex(step => step.name === "Deploy digest-pinned container (in-place cutover)"); + expect(health!.run ?? "").toContain( + "opencodex · proxy dashboard", + ); + const resolveHealthIndex = steps.findIndex( + (step) => step.name === "Resolve health URLs", + ); + const deployIndex = steps.findIndex( + (step) => + step.name === "Deploy digest-pinned container (in-place cutover)", + ); const resolveHealth = steps[resolveHealthIndex]; expect(resolveHealthIndex).toBeGreaterThanOrEqual(0); expect(resolveHealthIndex).toBeLessThan(deployIndex); - expect(resolveHealth?.run ?? "").toContain("http://127.0.0.1:10100/healthz"); + expect(resolveHealth?.run ?? "").toContain( + "http://127.0.0.1:10100/healthz", + ); expect(resolveHealth?.run ?? "").toContain("tailscale ip -4"); - expect(resolveHealth?.run ?? "").toContain("no discoverable Tailscale IPv4"); - expect(resolveHealth?.run ?? "").toContain('urls="$urls http://${ts_ip}:10100/healthz"'); + expect(resolveHealth?.run ?? "").toContain( + "no discoverable Tailscale IPv4", + ); + expect(resolveHealth?.run ?? "").toContain( + 'urls="$urls http://${ts_ip}:10100/healthz"', + ); expect(resolveHealth?.run ?? "").toContain("OPENCODEX_BIND_IP=$ts_ip"); expect(resolveHealth?.run ?? "").not.toContain("tailscale_ipv4="); expect(text).not.toContain("100.109.39.86"); expect(text).not.toContain("OPENCODEX_BIND_IP=127.0.0.1"); - expect(rollback!.if).toBe("(failure() || cancelled()) && steps.prev.outcome == 'success' && steps.deploy.outputs.cutover_started == 'true'"); + expect(rollback!.if).toBe( + "(failure() || cancelled()) && steps.prev.outcome == 'success' && steps.deploy.outputs.cutover_started == 'true'", + ); expect(rollback!.env?.PREV_IMAGE).toBe("${{ steps.prev.outputs.image }}"); - expect(rollback!.env?.BUN_RUNTIME).toBe("${{ steps.prev.outputs.bun_runtime }}"); - expect(rollback!.env?.UNIT_BACKUP).toBe("${{ steps.prev.outputs.unit_backup }}"); - expect(rollback!.env?.PREV_HEALTH_URLS).toBe("${{ steps.prev.outputs.health_urls }}"); + expect(rollback!.env?.BUN_RUNTIME).toBe( + "${{ steps.prev.outputs.bun_runtime }}", + ); + expect(rollback!.env?.UNIT_BACKUP).toBe( + "${{ steps.prev.outputs.unit_backup }}", + ); + expect(rollback!.env?.PREV_HEALTH_URLS).toBe( + "${{ steps.prev.outputs.health_urls }}", + ); expect(rollback!.run ?? "").not.toContain("${{ steps.prev.outputs"); expect(rollback!.run ?? "").toContain("OPENCODEX_IMAGE="); expect(rollback!.run ?? "").not.toContain("bun run build:gui"); expect(rollback!.run ?? "").not.toContain("git checkout --force"); expect(rollback!.run ?? "").not.toContain("git reset --hard"); - expect(rollback!.run ?? "").toContain("sudo systemctl restart opencodex-proxy.service"); + expect(rollback!.run ?? "").toContain( + "sudo systemctl restart opencodex-proxy.service", + ); expect(rollback!.run ?? "").toContain('urls="$PREV_HEALTH_URLS"'); - expect(rollback!.run ?? "").toContain("no pre-deploy healthy endpoint was captured for rollback verification"); - expect(rollback!.run ?? "").not.toContain('${OCX_HEALTH_URLS:-http://127.0.0.1:10100/healthz}'); - expect(rollback!.run ?? "").toContain("printf 'OPENCODEX_BIND_IP=%s\\n' \"$OPENCODEX_BIND_IP\""); + expect(rollback!.run ?? "").toContain( + "no pre-deploy healthy endpoint was captured for rollback verification", + ); + expect(rollback!.run ?? "").not.toContain( + "${OCX_HEALTH_URLS:-http://127.0.0.1:10100/healthz}", + ); + expect(rollback!.run ?? "").toContain( + "printf 'OPENCODEX_BIND_IP=%s\\n' \"$OPENCODEX_BIND_IP\"", + ); expect(rollback!.run ?? "").toContain("deadline=$((SECONDS + 30))"); expect(rollback!.run ?? "").not.toContain("MainPID"); expect(rollback!.run ?? "").toContain('b.get("service") == "opencodex"'); expect(rollback!.run ?? "").toContain("rolled back and healthy"); - expect(rollback!.run ?? "").toContain("opencodex · proxy dashboard"); + expect(rollback!.run ?? "").toContain( + "opencodex · proxy dashboard", + ); }); test("design-system contract only runs when design-system inputs or the GUI change, identically on push and PR", async () => { @@ -2471,7 +3112,9 @@ describe("GitHub Actions hardening", () => { "gui/**", ]; expect([...(workflow.on?.push?.paths ?? [])].sort()).toEqual(expectedPaths); - expect([...(workflow.on?.pull_request?.paths ?? [])].sort()).toEqual(expectedPaths); + expect([...(workflow.on?.pull_request?.paths ?? [])].sort()).toEqual( + expectedPaths, + ); // Push and PR must match exactly, or a change could be checked on one // trigger and merge unchecked on the other. expect([...(workflow.on?.push?.paths ?? [])].sort()).toEqual( @@ -2495,7 +3138,9 @@ describe("GitHub Actions hardening", () => { expect(text).not.toContain("StrictHostKeyChecking=no"); const steps = workflow.jobs?.publish?.steps ?? []; - const createRelease = steps.find(step => step.name === "Create GitHub Release"); + const createRelease = steps.find( + (step) => step.name === "Create GitHub Release", + ); expect(createRelease).toBeDefined(); const releaseScript = createRelease!.run ?? ""; @@ -2542,14 +3187,33 @@ describe("GitHub Actions hardening", () => { expect(config.rules?.["line-length"]).toBe("disable"); expect(config.rules?.["document-start"]).toBe("disable"); expect(config.rules?.["comments-indentation"]).toBe("disable"); - expect((config.rules?.truthy as { "check-keys"?: boolean })?.["check-keys"]).toBe(false); - expect((config.rules?.comments as { "min-spaces-from-content"?: number })?.["min-spaces-from-content"]).toBe(1); - expect((config.rules?.indentation as { spaces?: number; "indent-sequences"?: boolean })).toEqual({ + expect( + (config.rules?.truthy as { "check-keys"?: boolean })?.["check-keys"], + ).toBe(false); + expect( + (config.rules?.comments as { "min-spaces-from-content"?: number })?.[ + "min-spaces-from-content" + ], + ).toBe(1); + expect( + config.rules?.indentation as { + spaces?: number; + "indent-sequences"?: boolean; + }, + ).toEqual({ spaces: 2, "indent-sequences": true, }); - expect((config.rules?.braces as { "max-spaces-inside"?: number })?.["max-spaces-inside"]).toBe(1); - expect((config.rules?.brackets as { "max-spaces-inside"?: number })?.["max-spaces-inside"]).toBe(1); + expect( + (config.rules?.braces as { "max-spaces-inside"?: number })?.[ + "max-spaces-inside" + ], + ).toBe(1); + expect( + (config.rules?.brackets as { "max-spaces-inside"?: number })?.[ + "max-spaces-inside" + ], + ).toBe(1); // The generated/external design-system manifest is not expected to follow // this repo's YAML style, so it must be excluded rather than tightened @@ -2561,15 +3225,21 @@ describe("GitHub Actions hardening", () => { }); test("issue-quality workflow rejects workflow_dispatch pull request numbers before mutation", async () => { - const workflow = await readText(".github/workflows/enforce-issue-quality.yml"); + const workflow = await readText( + ".github/workflows/enforce-issue-quality.yml", + ); expect(workflow).toContain("issue_comment:"); expect(workflow).toContain("Translate non-English issue comments"); expect(workflow).toContain("shouldTranslateComment"); expect(workflow).toContain("buildTranslatedCommentBody"); expect(workflow).toContain("github.rest.issues.updateComment"); - expect(workflow).toContain("group: issue-translation-${{ github.event.issue.number }}"); - expect(workflow).not.toContain("issue-comment-translation-${{ github.event.comment.id }}"); + expect(workflow).toContain( + "group: issue-translation-${{ github.event.issue.number }}", + ); + expect(workflow).not.toContain( + "issue-comment-translation-${{ github.event.comment.id }}", + ); expect(workflow).toContain("if: github.event_name == 'issue_comment'"); expect(workflow).toMatch( /translate:\s*\n\s*name: Translate non-English issues\s*\n\s*if: github\.event_name == 'issues' \|\| github\.event_name == 'workflow_dispatch'/, @@ -2578,14 +3248,20 @@ describe("GitHub Actions hardening", () => { /validate:\s*\n\s*if: github\.event_name == 'issues' \|\| github\.event_name == 'workflow_dispatch'/, ); - const commentJob = workflow.split(/\n {2}translate-comment:\n/)[1]!.split(/\n {2}[a-zA-Z]/)[0]!; + const commentJob = workflow + .split(/\n {2}translate-comment:\n/)[1]! + .split(/\n {2}[a-zA-Z]/)[0]!; expect(commentJob).toContain("parse-issue-translation-response.cjs"); expect(commentJob).toContain("Apply inline comment translation"); expect(commentJob).toContain("isPreparedSourceStillCurrent"); expect(commentJob).toContain("updateComment"); expect(commentJob).toContain("requires_translation == 'true'"); - expect(commentJob).toContain("group: issue-translation-${{ github.event.issue.number }}"); - expect(commentJob).toContain("# Required to rewrite the triggering issue comment in place."); + expect(commentJob).toContain( + "group: issue-translation-${{ github.event.issue.number }}", + ); + expect(commentJob).toContain( + "# Required to rewrite the triggering issue comment in place.", + ); expect(commentJob).toContain("sourceKey:"); // Same fail-closed parse → apply gate as the issue path. const commentParse = commentJob @@ -2600,7 +3276,9 @@ describe("GitHub Actions hardening", () => { .split("- name: Persist comment translation control state")[0]!; const guardAt = commentApply.indexOf("isPreparedSourceStillCurrent({"); const updateAt = commentApply.indexOf("updateComment"); - const missingAt = commentApply.indexOf("missingRequiredTranslationFields({"); + const missingAt = commentApply.indexOf( + "missingRequiredTranslationFields({", + ); expect(guardAt).toBeGreaterThanOrEqual(0); expect(updateAt).toBeGreaterThanOrEqual(0); expect(missingAt).toBeGreaterThanOrEqual(0); @@ -2612,7 +3290,9 @@ describe("GitHub Actions hardening", () => { expect(workflow).toMatch( /jobs:\s*\n\s*translate:[\s\S]*?permissions:\s*\n(?:\s*#.*\n)*\s*contents: read\s*\n(?:\s*#.*\n)*\s*issues: write\s*\n(?:\s*#.*\n)*\s*models: read/, ); - const translateJob = workflow.split(/\n {2}translate:\n/)[1]!.split(/\n {2}[a-zA-Z]/)[0]!; + const translateJob = workflow + .split(/\n {2}translate:\n/)[1]! + .split(/\n {2}[a-zA-Z]/)[0]!; expect(translateJob).not.toMatch(/actions:\s*write/); expect(workflow).toMatch( /jobs:\s*\n\s*translate:[\s\S]*?validate:[\s\S]*?permissions:\s*\n\s*contents: read\s*\n\s*#.*\n\s*issues: write/, @@ -2621,8 +3301,12 @@ describe("GitHub Actions hardening", () => { expect(beforeJobs).not.toMatch(/^\s*permissions:/m); // Non-cancelling per-issue concurrency at workflow and translate-job scope. - expect(workflow).toContain("group: issue-quality-${{ github.event.issue.number || inputs.issue_number }}"); - expect(workflow).toContain("group: issue-translation-${{ github.event.issue.number || inputs.issue_number }}"); + expect(workflow).toContain( + "group: issue-quality-${{ github.event.issue.number || inputs.issue_number }}", + ); + expect(workflow).toContain( + "group: issue-translation-${{ github.event.issue.number || inputs.issue_number }}", + ); const workflowConcurrency = workflow.split(/jobs:\s*\n/)[0]!; expect(workflowConcurrency).toMatch( /concurrency:\s*\n\s*group: issue-quality-[^\n]*\n\s*cancel-in-progress:\s*false/, @@ -2637,7 +3321,9 @@ describe("GitHub Actions hardening", () => { const checkoutStep = workflow .split("- name: Checkout trusted workflow code")[1]! .split(/\n {6}- name:/)[0]!; - expect(checkoutStep).toContain("ref: ${{ github.event.repository.default_branch }}"); + expect(checkoutStep).toContain( + "ref: ${{ github.event.repository.default_branch }}", + ); expect(checkoutStep).toContain("persist-credentials: false"); expect(checkoutStep).toContain("sparse-checkout: .github/scripts"); @@ -2647,14 +3333,18 @@ describe("GitHub Actions hardening", () => { .split(/\n {6}- name:/)[0]!; // Invalid issue numbers fail before any issues API call. - const invalidNumberIdx = script.indexOf("Invalid workflow_dispatch issue_number:"); + const invalidNumberIdx = script.indexOf( + "Invalid workflow_dispatch issue_number:", + ); const firstIssuesGetIdx = script.indexOf("github.rest.issues.get({"); expect(invalidNumberIdx).toBeGreaterThan(-1); expect(firstIssuesGetIdx).toBeGreaterThan(-1); expect(invalidNumberIdx).toBeLessThan(firstIssuesGetIdx); // Non-default-branch dispatches fail before any issues API mutation. - const branchGuardIdx = script.indexOf("const nonDefaultBranchFailure = rejectsWorkflowDispatchNonDefaultBranch("); + const branchGuardIdx = script.indexOf( + "const nonDefaultBranchFailure = rejectsWorkflowDispatchNonDefaultBranch(", + ); const firstMutationIdx = script.indexOf("github.rest.issues.update({"); expect(branchGuardIdx).toBeGreaterThan(-1); expect(firstMutationIdx).toBeGreaterThan(-1); @@ -2662,7 +3352,9 @@ describe("GitHub Actions hardening", () => { expect(branchGuardIdx).toBeLessThan(firstIssuesGetIdx); // Pull-request numbers are rejected after issues.get and before mutations. - const prGuardIdx = script.indexOf("const pullRequestFailure = rejectsWorkflowDispatchPullRequest("); + const prGuardIdx = script.indexOf( + "const pullRequestFailure = rejectsWorkflowDispatchPullRequest(", + ); const listCommentsIdx = script.indexOf("github.rest.issues.listComments"); const addLabelsIdx = script.indexOf("github.rest.issues.addLabels"); expect(prGuardIdx).toBeGreaterThan(-1); @@ -2679,12 +3371,16 @@ describe("GitHub Actions hardening", () => { const branchGuardIdxTranslate = translateScript.indexOf( "rejectsWorkflowDispatchNonDefaultBranch(", ); - const issuesGetIdxTranslate = translateScript.indexOf("github.rest.issues.get({"); + const issuesGetIdxTranslate = translateScript.indexOf( + "github.rest.issues.get({", + ); expect(branchGuardIdxTranslate).toBeGreaterThan(-1); expect(issuesGetIdxTranslate).toBeGreaterThan(-1); expect(branchGuardIdxTranslate).toBeLessThan(issuesGetIdxTranslate); expect(translateScript).toContain("resolveControlState"); - expect(translateScript).toContain("Never trust author-editable issue body markers"); + expect(translateScript).toContain( + "Never trust author-editable issue body markers", + ); const applyScript = workflow .split("- name: Apply inline translation")[1]! @@ -2701,9 +3397,9 @@ describe("GitHub Actions hardening", () => { expect(applyScript).toContain("missingRequiredTranslationFields"); expect(applyScript).toContain("omitted required field(s)"); expect(applyScript).toMatch(/sourceComplete,\s*\n\s*\}/); - expect(applyScript.indexOf("missingRequiredTranslationFields({")).toBeLessThan( - applyScript.indexOf("github.rest.issues.update("), - ); + expect( + applyScript.indexOf("missingRequiredTranslationFields({"), + ).toBeLessThan(applyScript.indexOf("github.rest.issues.update(")); const parseStep = workflow .split("- name: Parse AI response")[1]! @@ -2722,7 +3418,9 @@ describe("GitHub Actions hardening", () => { expect(persistStep).toContain("persistTranslationControlState"); expect(persistStep).toContain("SOURCE_COMPLETE"); expect(persistStep).toContain("detectedLanguageForControlPersist"); - expect(persistStep).toContain('const sourceComplete = process.env.SOURCE_COMPLETE === "true"'); + expect(persistStep).toContain( + 'const sourceComplete = process.env.SOURCE_COMPLETE === "true"', + ); expect(persistStep).toMatch(/sourceComplete,\s*\n\s*\}/); // Missing DETECTED_LANG on incomplete/skipped parse must not default to English. expect(persistStep).not.toContain('DETECTED_LANG || "English"'); @@ -2736,7 +3434,9 @@ describe("GitHub Actions hardening", () => { const commentPersist = workflow .split("- name: Persist comment translation control state")[1]! .split(/\n {2}[a-zA-Z]/)[0]!; - expect(commentPersist).toContain('const sourceComplete = process.env.SOURCE_COMPLETE === "true"'); + expect(commentPersist).toContain( + 'const sourceComplete = process.env.SOURCE_COMPLETE === "true"', + ); expect(commentPersist).toContain("detectedLanguageForControlPersist"); expect(commentPersist).toMatch(/sourceComplete,\s*\n\s*\}/); expect(commentPersist).not.toContain('DETECTED_LANG || "English"'); @@ -2748,7 +3448,9 @@ describe("GitHub Actions hardening", () => { expect(commentApplyStep).toContain("source remains retryable"); expect(commentApplyStep).toContain("missingRequiredTranslationFields"); expect(commentApplyStep).toContain("omitted required field(s)"); - const commentMissingAt = commentApplyStep.indexOf("missingRequiredTranslationFields({"); + const commentMissingAt = commentApplyStep.indexOf( + "missingRequiredTranslationFields({", + ); const commentUpdateAt = commentApplyStep.indexOf("updateComment"); expect(commentMissingAt).toBeGreaterThanOrEqual(0); expect(commentUpdateAt).toBeGreaterThanOrEqual(0); @@ -2761,7 +3463,9 @@ describe("GitHub Actions hardening", () => { expect(helperSrc).toContain("detectedLanguageForControlPersist"); expect(helperSrc).toContain("Automated translation bookkeeping"); expect(helperSrc).toContain("canonical comment first"); - expect(helperSrc).toContain("Authoritative control state comes only from verified bot-owned comments"); + expect(helperSrc).toContain( + "Authoritative control state comes only from verified bot-owned comments", + ); expect(helperSrc).toContain("sourceComplete"); expect(helperSrc).not.toContain("writeFileControlState"); expect(helperSrc).not.toContain(".ocx-translation-state"); @@ -2795,7 +3499,10 @@ describe("GitHub Actions hardening", () => { >; }; - expect([...(workflow.on?.pull_request?.branches ?? [])].sort()).toEqual(["dev", "main"]); + expect([...(workflow.on?.pull_request?.branches ?? [])].sort()).toEqual([ + "dev", + "main", + ]); expect([...(workflow.on?.push?.branches ?? [])]).toEqual(["dev"]); expect(workflow.on?.push?.tags).toEqual(["v*.*.*"]); expect(workflow.on).toHaveProperty("workflow_dispatch"); @@ -2830,31 +3537,49 @@ describe("GitHub Actions hardening", () => { expect(text).not.toMatch( /^\s*-\s+uses:\s+\S+@(?![0-9a-f]{40}(?=[ \t]*(?:#.*)?$))\S+/m, ); - expect(text).toContain("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7"); - expect(text).toContain("docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0"); - expect(text).toContain("docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0"); + expect(text).toContain( + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7", + ); + expect(text).toContain( + "docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0", + ); + expect(text).toContain( + "docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0", + ); const imageSteps = image?.steps ?? []; const publishSteps = publish?.steps ?? []; - const imageCheckout = imageSteps.find(step => step.name === "Checkout"); - const publishCheckout = publishSteps.find(step => step.name === "Checkout"); + const imageCheckout = imageSteps.find((step) => step.name === "Checkout"); + const publishCheckout = publishSteps.find( + (step) => step.name === "Checkout", + ); expect(imageCheckout?.with?.["persist-credentials"]).toBe(false); expect(publishCheckout?.with?.["persist-credentials"]).toBe(false); expect(publishCheckout?.with?.["fetch-depth"]).toBe(0); - const onMain = publishSteps.find(step => step.name === "Verify commit is on main"); - const requireCi = publishSteps.find(step => step.name === "Require successful Cross-platform CI"); + const onMain = publishSteps.find( + (step) => step.name === "Verify commit is on main", + ); + const requireCi = publishSteps.find( + (step) => step.name === "Require successful Cross-platform CI", + ); expect(onMain?.run ?? "").toContain("merge-base --is-ancestor"); expect(onMain?.run ?? "").toContain("origin/main"); expect(onMain?.run ?? "").not.toContain("${{"); expect(requireCi?.run ?? "").toContain("gh run list --workflow ci.yml"); expect(requireCi?.run ?? "").not.toContain("${{"); - expect(imageSteps.find(step => step.name === "Log in to GHCR")).toBeUndefined(); - const login = publishSteps.find(step => step.name === "Log in to GHCR"); - const imageBuild = imageSteps.find(step => step.id === "build"); - const publishBuild = publishSteps.find(step => step.id === "build"); - const publishMeta = publishSteps.find(step => step.id === "meta"); - const imageSummary = imageSteps.find(step => step.name === "Record digest"); - const publishSummary = publishSteps.find(step => step.name === "Record digest"); + expect( + imageSteps.find((step) => step.name === "Log in to GHCR"), + ).toBeUndefined(); + const login = publishSteps.find((step) => step.name === "Log in to GHCR"); + const imageBuild = imageSteps.find((step) => step.id === "build"); + const publishBuild = publishSteps.find((step) => step.id === "build"); + const publishMeta = publishSteps.find((step) => step.id === "meta"); + const imageSummary = imageSteps.find( + (step) => step.name === "Record digest", + ); + const publishSummary = publishSteps.find( + (step) => step.name === "Record digest", + ); expect(login).toBeDefined(); expect(imageBuild).toBeDefined(); expect(publishBuild).toBeDefined(); @@ -2863,9 +3588,15 @@ describe("GitHub Actions hardening", () => { expect(publishSummary).toBeDefined(); expect(login!.if).toBeUndefined(); - expect(login!.uses).toBe("docker/login-action@dbcb813823bdd20940b903addbd779551569679f"); - expect(imageBuild!.uses).toBe("docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a"); - expect(publishBuild!.uses).toBe("docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a"); + expect(login!.uses).toBe( + "docker/login-action@dbcb813823bdd20940b903addbd779551569679f", + ); + expect(imageBuild!.uses).toBe( + "docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a", + ); + expect(publishBuild!.uses).toBe( + "docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a", + ); expect(imageBuild!.with?.push).toBe(false); expect(imageBuild!.with?.load).toBe(true); expect(publishBuild!.with?.push).toBe(true); @@ -2874,25 +3605,37 @@ describe("GitHub Actions hardening", () => { expect(publishBuild!.with?.provenance).toBe(false); expect(imageBuild!.with?.sbom).toBe(false); expect(publishBuild!.with?.sbom).toBe(false); - expect(String(imageBuild!.with?.["build-args"] ?? "")).toContain("VCS_REF=${{ github.sha }}"); - expect(String(publishBuild!.with?.["build-args"] ?? "")).toContain("VCS_REF=${{ github.sha }}"); - expect(String(publishBuild!.with?.["build-args"] ?? "")).toContain("VERSION=${{ steps.meta.outputs.version }}"); + expect(String(imageBuild!.with?.["build-args"] ?? "")).toContain( + "VCS_REF=${{ github.sha }}", + ); + expect(String(publishBuild!.with?.["build-args"] ?? "")).toContain( + "VCS_REF=${{ github.sha }}", + ); + expect(String(publishBuild!.with?.["build-args"] ?? "")).toContain( + "VERSION=${{ steps.meta.outputs.version }}", + ); - expect(publishMeta!.run ?? "").toContain("json.load(open('package.json'))['version']"); + expect(publishMeta!.run ?? "").toContain( + "json.load(open('package.json'))['version']", + ); expect(publishMeta!.run ?? "").not.toContain("${{"); - expect(publishMeta!.run ?? "").toContain("publish job ran outside the push gate"); + expect(publishMeta!.run ?? "").toContain( + "publish job ran outside the push gate", + ); const match = (publishMeta!.run ?? "").match(/=~ (\^\S+\$) \]\]/); expect(match).not.toBeNull(); const tagShape = new RegExp(match![1]!); const publishIf = String(publish?.if ?? ""); const shouldPublishJob = (event: string, ref: string) => - (event === "push" && publishIf.includes("refs/tags/v") && ref.startsWith("refs/tags/v")) - || (event === "workflow_dispatch" && ref === "refs/heads/main"); + (event === "push" && + publishIf.includes("refs/tags/v") && + ref.startsWith("refs/tags/v")) || + (event === "workflow_dispatch" && ref === "refs/heads/main"); const shouldPush = (event: string, ref: string) => - shouldPublishJob(event, ref) - && ((event === "push" && tagShape.test(ref)) - || (event === "workflow_dispatch" && ref === "refs/heads/main")); + shouldPublishJob(event, ref) && + ((event === "push" && tagShape.test(ref)) || + (event === "workflow_dispatch" && ref === "refs/heads/main")); expect(shouldPush("push", "refs/tags/v1.2.3")).toBe(true); expect(shouldPush("push", "refs/tags/v1.2.3-preview.4")).toBe(true); expect(shouldPush("workflow_dispatch", "refs/heads/main")).toBe(true); @@ -2909,8 +3652,12 @@ describe("GitHub Actions hardening", () => { test("React Doctor workflow is SHA-pinned, engine-pinned, gating, and read-only", async () => { const workflow = await readText(".github/workflows/react-doctor.yml"); - expect(workflow).toContain("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"); - expect(workflow).toContain("millionco/react-doctor@013f7373f91a3b9e68bd1dc7d4d354f4b041b117"); + expect(workflow).toContain( + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + ); + expect(workflow).toContain( + "millionco/react-doctor@013f7373f91a3b9e68bd1dc7d4d354f4b041b117", + ); expect(workflow).not.toMatch( /^\s*-\s+uses:\s+\S+@(?![0-9a-f]{40}(?=[ \t]*(?:#.*)?$))\S+/m, ); @@ -2942,17 +3689,24 @@ describe("GitHub Actions hardening", () => { expect(guiPkg).not.toContain("react-doctor@latest"); expect(rootPkg).not.toContain("react-doctor@latest"); expect(doctorConfig).toContain('"blocking": "warning"'); - expect(rootPkg).toContain('"doctor:gui:if-changed": "bun scripts/doctor-gui-if-changed.ts"'); + expect(rootPkg).toContain( + '"doctor:gui:if-changed": "bun scripts/doctor-gui-if-changed.ts"', + ); expect(rootPkg).toContain('"lint:gui": "cd gui && bun run lint"'); // Gating steps include React Doctor after privacy scan on gui/ pushes. - expect(rootPkg).toContain("bun run typecheck && bun run lint:gui && bun run test"); - expect(rootPkg).toContain("bun run privacy:scan && bun run doctor:gui:if-changed"); + expect(rootPkg).toContain( + "bun run typecheck && bun run lint:gui && bun run test", + ); + expect(rootPkg).toContain( + "bun run privacy:scan && bun run doctor:gui:if-changed", + ); }); }); describe("doctor-gui-if-changed", () => { test("guiPathsChanged is a slash-guarded gui/ prefix predicate", async () => { - const { guiPathsChanged } = await import("../scripts/doctor-gui-if-changed"); + const { guiPathsChanged } = + await import("../scripts/doctor-gui-if-changed"); expect(guiPathsChanged(["gui/src/App.tsx"])).toBe(true); expect(guiPathsChanged(["gui"])).toBe(true); @@ -2963,24 +3717,41 @@ describe("doctor-gui-if-changed", () => { }); test("looksLikeDoctorInfraFailure detects registry/network outages", async () => { - const { looksLikeDoctorInfraFailure } = await import("../scripts/doctor-gui-if-changed"); - expect(looksLikeDoctorInfraFailure("npm ERR! network getaddrinfo ENOTFOUND registry.npmjs.org")).toBe(true); + const { looksLikeDoctorInfraFailure } = + await import("../scripts/doctor-gui-if-changed"); + expect( + looksLikeDoctorInfraFailure( + "npm ERR! network getaddrinfo ENOTFOUND registry.npmjs.org", + ), + ).toBe(true); expect(looksLikeDoctorInfraFailure("npm ERR! code ECONNRESET")).toBe(true); expect(looksLikeDoctorInfraFailure("npm ERR! network timeout")).toBe(true); - expect(looksLikeDoctorInfraFailure("All 2 issues\nBugs > 1 errors")).toBe(false); + expect(looksLikeDoctorInfraFailure("All 2 issues\nBugs > 1 errors")).toBe( + false, + ); // Findings copy can mention "network" without being an infra outage. - expect(looksLikeDoctorInfraFailure("Network requests > 1 errors")).toBe(false); + expect(looksLikeDoctorInfraFailure("Network requests > 1 errors")).toBe( + false, + ); }); test("DRY_RUN prints the run/skip decision without spawning the doctor", () => { const run = Bun.spawnSync(["bun", doctorGuiIfChangedScript], { - env: { ...process.env, DOCTOR_DRY_RUN: "1", DOCTOR_FILES: "gui/src/App.tsx\nscripts/x.ts" }, + env: { + ...process.env, + DOCTOR_DRY_RUN: "1", + DOCTOR_FILES: "gui/src/App.tsx\nscripts/x.ts", + }, }); expect(run.exitCode).toBe(0); expect(run.stdout.toString()).toContain("doctor:run"); const skip = Bun.spawnSync(["bun", doctorGuiIfChangedScript], { - env: { ...process.env, DOCTOR_DRY_RUN: "1", DOCTOR_FILES: "scripts/x.ts\nREADME.md" }, + env: { + ...process.env, + DOCTOR_DRY_RUN: "1", + DOCTOR_FILES: "scripts/x.ts\nREADME.md", + }, }); expect(skip.exitCode).toBe(0); expect(skip.stdout.toString()).toContain("doctor:skip"); @@ -3025,9 +3796,12 @@ describe("doctor-gui-if-changed", () => { }); test("isDoctorBufferOverflow recognizes ENOBUFS / maxBuffer errors", async () => { - const { isDoctorBufferOverflow } = await import("../scripts/doctor-gui-if-changed"); + const { isDoctorBufferOverflow } = + await import("../scripts/doctor-gui-if-changed"); expect(isDoctorBufferOverflow("ENOBUFS")).toBe(true); - expect(isDoctorBufferOverflow("ERR_CHILD_PROCESS_STDIO_MAXBUFFER")).toBe(true); + expect(isDoctorBufferOverflow("ERR_CHILD_PROCESS_STDIO_MAXBUFFER")).toBe( + true, + ); expect(isDoctorBufferOverflow("ENOENT")).toBe(false); expect(isDoctorBufferOverflow(undefined)).toBe(false); }); From 1e75cbbde63e3c3c957ab70d3c49483524ab90f0 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 06:59:21 +0200 Subject: [PATCH 03/20] fix(client): make isolated artifact builds cross-platform --- scripts/build-client-artifact.ts | 44 +++++++++++++++++++++++++------ tests/client-artifact.test.ts | 45 ++++++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index 3184b3fb..fc02bfac 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -6,6 +6,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, renameSync, rmSync, writeFileSync, @@ -18,11 +19,24 @@ const ROOT = fileURLToPath(new URL("..", import.meta.url)); const sha256 = (data: string | Uint8Array) => createHash("sha256").update(data).digest("hex"); +export function isTrustedDarwinSystemPathAlias( + path: string, + physicalTarget: string, + platform = process.platform, +) { + return ( + platform === "darwin" && path === "/var" && physicalTarget === "/private/var" + ); +} + function assertNoSymlinkPathComponents(path: string) { let current = resolve(path); while (true) { try { - if (lstatSync(current).isSymbolicLink()) { + if ( + lstatSync(current).isSymbolicLink() && + !isTrustedDarwinSystemPathAlias(current, realpathSync(current)) + ) { throw new Error( `Destination path traverses a symlink; refusing publication: ${current}`, ); @@ -127,14 +141,14 @@ function prepareIsolatedBuildRoot( try { git( sourceRoot, - "clone", - "--shared", - "--no-checkout", + "worktree", + "add", + "--detach", + "--force", "--quiet", - sourceRoot, buildRoot, + sourceSha, ); - git(buildRoot, "checkout", "--detach", "--quiet", sourceSha); const install = Bun.spawnSync( [ process.execPath, @@ -152,11 +166,25 @@ function prepareIsolatedBuildRoot( } return buildRoot; } catch (error) { + Bun.spawnSync(["git", "worktree", "remove", "--force", buildRoot], { + cwd: sourceRoot, + stdout: "pipe", + stderr: "pipe", + }); rmSync(buildRoot, { recursive: true, force: true }); throw error; } } +function removeIsolatedBuildRoot(sourceRoot: string, buildRoot: string) { + Bun.spawnSync(["git", "worktree", "remove", "--force", buildRoot], { + cwd: sourceRoot, + stdout: "pipe", + stderr: "pipe", + }); + rmSync(buildRoot, { recursive: true, force: true }); +} + // The remote wrapper owns all mutation and lifecycle behavior. Direct bundle use // stops before the CLI's auto-repair hooks can run. export const CLIENT_GUARD = ` @@ -371,7 +399,7 @@ export async function buildClientArtifact(destination: string, root = ROOT) { try { staging = mkdtempSync(join(publicationParent, ".ocx-client-build-")); } catch (error) { - rmSync(buildRoot, { recursive: true, force: true }); + removeIsolatedBuildRoot(root, buildRoot); throw error; } try { @@ -452,7 +480,7 @@ export async function buildClientArtifact(destination: string, root = ROOT) { return manifest; } finally { rmSync(staging, { recursive: true, force: true }); - rmSync(buildRoot, { recursive: true, force: true }); + removeIsolatedBuildRoot(root, buildRoot); } } diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index b4f9d112..210a33b0 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -14,14 +14,49 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { buildClientArtifact } from "../scripts/build-client-artifact"; +import { + buildClientArtifact, + isTrustedDarwinSystemPathAlias, +} from "../scripts/build-client-artifact"; const scratch = mkdtempSync(join(tmpdir(), "ocx-client-artifact-test-")); const powershell = Bun.which("pwsh"); const posixShell = process.platform !== "win32"; afterAll(() => rmSync(scratch, { recursive: true, force: true })); +function addDetachedWorktree(destination: string) { + const root = join(import.meta.dir, ".."); + const result = Bun.spawnSync( + ["git", "worktree", "add", "--detach", "--quiet", destination, "HEAD"], + { cwd: root }, + ); + expect(result.success).toBe(true); +} + +function removeDetachedWorktree(destination: string) { + const root = join(import.meta.dir, ".."); + Bun.spawnSync(["git", "worktree", "remove", "--force", destination], { + cwd: root, + }); + rmSync(destination, { recursive: true, force: true }); +} + describe("remote client artifact", () => { + test("accepts only the verified macOS /var system alias", () => { + expect( + isTrustedDarwinSystemPathAlias("/var", "/private/var", "darwin"), + ).toBe(true); + expect( + isTrustedDarwinSystemPathAlias("/var", "/untrusted/var", "darwin"), + ).toBe(false); + expect( + isTrustedDarwinSystemPathAlias("/tmp", "/private/tmp", "darwin"), + ).toBe(false); + expect( + isTrustedDarwinSystemPathAlias("/var", "/private/var", "linux"), + ).toBe(false); + }); + test("CLI builds from an explicit clean source checkout", () => { const output = join(scratch, "explicit-source-candidate"); const root = join(import.meta.dir, ".."); @@ -42,8 +77,7 @@ describe("remote client artifact", () => { test("refuses an uncommitted artifact builder", () => { const root = join(import.meta.dir, ".."); const dirtyRoot = join(scratch, "dirty-builder-checkout"); - const clone = Bun.spawnSync(["git", "clone", "--shared", root, dirtyRoot]); - expect(clone.success).toBe(true); + addDetachedWorktree(dirtyRoot); const builder = join(dirtyRoot, "scripts/build-client-artifact.ts"); writeFileSync( builder, @@ -66,13 +100,13 @@ describe("remote client artifact", () => { "Artifact builder is dirty; commit the reviewed builder before building", ); expect(existsSync(output)).toBe(false); + removeDetachedWorktree(dirtyRoot); }); test("reinstalls frozen dependencies before bundling", async () => { const root = join(import.meta.dir, ".."); const sourceRoot = join(scratch, "dependency-drift-checkout"); - const clone = Bun.spawnSync(["git", "clone", "--shared", root, sourceRoot]); - expect(clone.success).toBe(true); + addDetachedWorktree(sourceRoot); const install = Bun.spawnSync( [process.execPath, "install", "--frozen-lockfile", "--ignore-scripts"], { cwd: sourceRoot }, @@ -100,6 +134,7 @@ describe("remote client artifact", () => { expect(manifest.lockSha256).toBe( createHash("sha256").update(locked).digest("hex"), ); + removeDetachedWorktree(sourceRoot); }, 15_000); test("builds a self-contained, SHA-bound candidate without activation", async () => { From a67ce99ab8614b1108cac4458fab8d2a7ca9c9d9 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:05:21 +0200 Subject: [PATCH 04/20] fix: support macOS live checkout checks --- scripts/assert-live-checkout-safe.sh | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/assert-live-checkout-safe.sh b/scripts/assert-live-checkout-safe.sh index cc8c0f73..8aa01bb8 100755 --- a/scripts/assert-live-checkout-safe.sh +++ b/scripts/assert-live-checkout-safe.sh @@ -11,6 +11,20 @@ fi checkout=$1 target=${2-} +# GNU coreutils exposes `timeout`, while macOS commonly exposes it as +# `gtimeout` when coreutils is installed (and otherwise has no equivalent). +# Keep the safety bound where available without making every macOS checkout +# look invalid just because the helper itself is absent. +run_git() { + if command -v timeout >/dev/null 2>&1; then + timeout 10s git "$@" + elif command -v gtimeout >/dev/null 2>&1; then + gtimeout 10s git "$@" + else + git "$@" + fi +} + if [[ ! -d $checkout ]]; then echo "assert-live-checkout-safe: not a directory: $(basename -- "$checkout")" >&2 exit 2 @@ -18,14 +32,14 @@ fi cd "$checkout" -if ! timeout 10s git rev-parse --is-inside-work-tree >/dev/null 2>&1; then +if ! run_git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "assert-live-checkout-safe: not a git checkout: $(basename -- "$checkout")" >&2 exit 2 fi # Porcelain only as a boolean. Do not print the listing (paths can be sensitive). porcelain=$( - timeout 10s git status --porcelain 2>/dev/null + run_git status --porcelain 2>/dev/null ) || { echo "assert-live-checkout-safe: git status probe failed" >&2 exit 2 @@ -36,7 +50,7 @@ if [[ -n $porcelain ]]; then fi if [[ -n $target ]]; then - if ! timeout 10s git merge-base --is-ancestor HEAD "$target"; then + if ! run_git merge-base --is-ancestor HEAD "$target"; then echo "assert-live-checkout-safe: HEAD is not an ancestor of $target (would drop live-only commits)" >&2 exit 1 fi From 3b09e3b182591b22e273539f44d2355def8aa004 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:09:38 +0200 Subject: [PATCH 05/20] fix: gate patch releases on complete platform verification --- .github/workflows/ci.yml | 9 +- .github/workflows/release.yml | 24 ++- package.json | 2 +- scripts/assert-live-checkout-safe.sh | 9 +- scripts/release.ts | 240 ++++++++++++++++++------ tests/ci-workflows.test.ts | 31 ++++ tests/client-artifact.test.ts | 9 +- tests/codex-runtime.test.ts | 12 +- tests/container-image.test.ts | 15 +- tests/live-checkout.test.ts | 98 ++++++++-- tests/release-helper.test.ts | 264 ++++++++++++++++++++------- 11 files changed, 557 insertions(+), 156 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c480eace..70379cf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,12 +56,13 @@ jobs: # and admission suites pushed the serial macOS/Windows jobs into this ceiling even though the # root tests themselves remained green. Slow platforms therefore separate root tests from GUI # quality work, and Windows root tests are deterministically balanced over two shards. - # ponytail: Linux-only on push/PR (saves macOS 10x / Windows 2x minutes); full matrix on release tags. + # Linux-only on push/PR; manual pre-release verification and release tags run every platform. timeout-minutes: 20 strategy: fail-fast: false matrix: - include: ${{ startsWith(github.ref, 'refs/tags/v') + include: + ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')) && fromJSON('[{"name":"ubuntu-latest","os":"ubuntu-latest","run_tests":true,"run_quality":true,"run_typecheck":true,"shard_index":0,"shard_count":1},{"name":"macos-latest","os":"macos-latest","run_tests":true,"run_quality":false,"run_typecheck":true,"shard_index":0,"shard_count":1},{"name":"macos-quality","os":"macos-latest","run_tests":false,"run_quality":true,"run_typecheck":false,"shard_index":0,"shard_count":1},{"name":"windows-latest","os":"windows-latest","run_tests":true,"run_quality":false,"run_typecheck":true,"shard_index":0,"shard_count":2},{"name":"windows-latest shard 2/2","os":"windows-latest","run_tests":true,"run_quality":false,"run_typecheck":false,"shard_index":1,"shard_count":2},{"name":"windows-quality","os":"windows-latest","run_tests":false,"run_quality":true,"run_typecheck":false,"shard_index":0,"shard_count":1}]') || fromJSON('[{"name":"ubuntu-latest","os":"ubuntu-latest","run_tests":true,"run_quality":true,"run_typecheck":true,"shard_index":0,"shard_count":1}]') }} steps: @@ -169,11 +170,11 @@ jobs: name: npm-global ${{ matrix.os }} runs-on: ${{ matrix.os }} timeout-minutes: 8 - # ponytail: Linux-only on push/PR; full OS matrix on release tags (matrix.os below is conditional). + # Match the runtime matrix: manual pre-release checks and tags cover every OS. strategy: fail-fast: false matrix: - os: ${{ startsWith(github.ref, 'refs/tags/v') && fromJSON('["ubuntu-latest", "windows-latest", "macos-latest"]') || fromJSON('["ubuntu-latest"]') }} + os: ${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')) && fromJSON('["ubuntu-latest", "windows-latest", "macos-latest"]') || fromJSON('["ubuntu-latest"]') }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1aa5e800..07193f5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -157,22 +157,36 @@ jobs: ;; esac - ci_url="$( + ci_id="$( gh run list \ --workflow ci.yml \ --commit "$GITHUB_SHA" \ + --event workflow_dispatch \ --status success \ --limit 10 \ - --json conclusion,headSha,url,workflowName \ - --jq '.[0].url // ""' + --json databaseId \ + --jq '.[0].databaseId // ""' )" - if [ -z "$ci_url" ]; then - echo "::error::No successful Cross-platform CI run found for ${GITHUB_SHA}. Wait for CI to pass before releasing." + if [ -z "$ci_id" ]; then + echo "::error::No successful full-platform pre-release CI run found for ${GITHUB_SHA}. Dispatch ci.yml on main and wait for success before releasing." gh run list --workflow ci.yml --commit "$GITHUB_SHA" --limit 10 || true exit 1 fi + # A Linux-only push run is not cross-platform release evidence. Require + # every runtime, quality and installation leg on the exact release SHA. + if [ "$(gh run view "$ci_id" --json headSha --jq '.headSha')" != "$GITHUB_SHA" ]; then + echo "::error::CI run ${ci_id} does not match the release SHA" + exit 1 + fi + matrix_ok="$(gh run view "$ci_id" --json headSha,conclusion,jobs --jq \ + '. as $run | [.jobs[] | select(.conclusion == "success") | .name] as $passed | ($run.conclusion == "success" and (["ubuntu-latest", "macos-latest", "macos-quality", "windows-latest", "windows-latest shard 2/2", "windows-quality", "npm-global ubuntu-latest", "npm-global macos-latest", "npm-global windows-latest", "Security audit", "Lint GitHub Actions"] | all(.[]; . as $name | $passed | index($name) != null)))')" + if [ "$matrix_ok" != "true" ]; then + echo "::error::CI run ${ci_id} lacks successful required platform jobs" + exit 1 + fi + ci_url="$(gh run view "$ci_id" --json url --jq '.url')" echo "Cross-platform CI passed for ${GITHUB_SHA}: ${ci_url}" # Notes / service baseline: diff --git a/package.json b/package.json index af9d96d0..8c15bc4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@groeponline/opencodex", - "version": "1.4.0", + "version": "1.4.1", "description": "GroepOnline fork — Universal provider proxy for OpenAI Codex & Claude Code. Use any LLM with Codex CLI/App/SDK and Claude Code.", "type": "module", "main": "./bin/package-main.mjs", diff --git a/scripts/assert-live-checkout-safe.sh b/scripts/assert-live-checkout-safe.sh index 8aa01bb8..a3be992b 100755 --- a/scripts/assert-live-checkout-safe.sh +++ b/scripts/assert-live-checkout-safe.sh @@ -13,15 +13,18 @@ target=${2-} # GNU coreutils exposes `timeout`, while macOS commonly exposes it as # `gtimeout` when coreutils is installed (and otherwise has no equivalent). -# Keep the safety bound where available without making every macOS checkout -# look invalid just because the helper itself is absent. +# Perl is part of macOS and preserves an alarm across exec. Never drop the +# deadline just because GNU coreutils is absent. run_git() { if command -v timeout >/dev/null 2>&1; then timeout 10s git "$@" elif command -v gtimeout >/dev/null 2>&1; then gtimeout 10s git "$@" + elif command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV or exit 127' 10 git "$@" else - git "$@" + echo "assert-live-checkout-safe: no bounded process runner available" >&2 + return 127 fi } diff --git a/scripts/release.ts b/scripts/release.ts index 25f09624..ecd70060 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -27,6 +27,7 @@ import { $ } from "bun"; const args = process.argv.slice(2); interface GhRun { conclusion: string | null; + event?: string; createdAt?: string; databaseId: number; headSha: string; @@ -57,35 +58,51 @@ async function runQuiet(command: string[]): Promise { async function readPackageName(): Promise { try { - const pkg = JSON.parse(await Bun.file("package.json").text()) as { name?: unknown }; + const pkg = JSON.parse(await Bun.file("package.json").text()) as { + name?: unknown; + }; if (typeof pkg.name !== "string" || !pkg.name) { console.error("✗ package.json is missing a valid name"); process.exit(1); } return pkg.name; } catch (error) { - console.error(`✗ failed to read package.json: ${error instanceof Error ? error.message : String(error)}`); + console.error( + `✗ failed to read package.json: ${error instanceof Error ? error.message : String(error)}`, + ); process.exit(1); } } async function readPackageVersion(): Promise { try { - const pkg = JSON.parse(await Bun.file("package.json").text()) as { version?: unknown }; - if (typeof pkg.version !== "string" || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(pkg.version)) { - console.error(`✗ package.json is missing a valid version (got ${JSON.stringify(pkg.version)})`); + const pkg = JSON.parse(await Bun.file("package.json").text()) as { + version?: unknown; + }; + if ( + typeof pkg.version !== "string" || + !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(pkg.version) + ) { + console.error( + `✗ package.json is missing a valid version (got ${JSON.stringify(pkg.version)})`, + ); process.exit(1); } return pkg.version; } catch (error) { - console.error(`✗ failed to read package.json: ${error instanceof Error ? error.message : String(error)}`); + console.error( + `✗ failed to read package.json: ${error instanceof Error ? error.message : String(error)}`, + ); process.exit(1); } } /** Bump a version. A prerelease (X.Y.Z-preview.N) bumps its preview number; a * stable version bumps the requested segment and drops any prerelease suffix. */ -function bumpVersion(current: string, bump: "patch" | "minor" | "major"): string { +function bumpVersion( + current: string, + bump: "patch" | "minor" | "major", +): string { const previewMatch = current.match(/^(\d+)\.(\d+)\.(\d+)-preview\.(\d+)$/); if (previewMatch) { return `${previewMatch[1]}.${previewMatch[2]}.${previewMatch[3]}-preview.${Number(previewMatch[4]) + 1}`; @@ -102,12 +119,21 @@ function bumpVersion(current: string, bump: "patch" | "minor" | "major"): string } } -async function npmVersionExists(packageName: string, version: string): Promise { - const result = await runQuiet(["npm", "view", `${packageName}@${version}`, "version"]); +async function npmVersionExists( + packageName: string, + version: string, +): Promise { + const result = await runQuiet([ + "npm", + "view", + `${packageName}@${version}`, + "version", + ]); if (result.exitCode === 0) return true; const output = `${result.stdout}\n${result.stderr}`; - if (output.includes("E404") || output.includes("No match found")) return false; + if (output.includes("E404") || output.includes("No match found")) + return false; console.error(`✗ failed to check npm version ${packageName}@${version}`); if (result.stderr) console.error(result.stderr); @@ -115,7 +141,13 @@ async function npmVersionExists(packageName: string, version: string): Promise { - const result = await runQuiet(["git", "ls-remote", "origin", `refs/tags/${tagName}`, `refs/tags/${tagName}^{}`]); + const result = await runQuiet([ + "git", + "ls-remote", + "origin", + `refs/tags/${tagName}`, + `refs/tags/${tagName}^{}`, + ]); if (result.exitCode !== 0) { console.error(`✗ failed to check remote tag ${tagName}`); if (result.stderr) console.error(result.stderr); @@ -123,25 +155,36 @@ async function remoteTagSha(tagName: string): Promise { } const lines = result.stdout.split("\n").filter(Boolean); - const peeled = lines.find(line => line.endsWith(`refs/tags/${tagName}^{}`)); - const exact = lines.find(line => line.endsWith(`refs/tags/${tagName}`)); + const peeled = lines.find((line) => line.endsWith(`refs/tags/${tagName}^{}`)); + const exact = lines.find((line) => line.endsWith(`refs/tags/${tagName}`)); const selected = peeled ?? exact; - return selected ? selected.split(/\s+/)[0] ?? null : null; + return selected ? (selected.split(/\s+/)[0] ?? null) : null; } async function githubReleaseExists(tagName: string): Promise { - const result = await runQuiet(["gh", "release", "view", tagName, "--json", "tagName"]); + const result = await runQuiet([ + "gh", + "release", + "view", + tagName, + "--json", + "tagName", + ]); if (result.exitCode === 0) return true; const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); - if (output.includes("release not found") || output.includes("not found")) return false; + if (output.includes("release not found") || output.includes("not found")) + return false; console.error(`✗ failed to check GitHub Release ${tagName}`); if (result.stderr) console.error(result.stderr); process.exit(1); } -async function assertUnusedReleaseVersion(packageName: string, version: string): Promise { +async function assertUnusedReleaseVersion( + packageName: string, + version: string, +): Promise { const releaseTag = `v${version}`; const [npmUsed, tagSha, releaseUsed] = await Promise.all([ npmVersionExists(packageName, version), @@ -151,20 +194,31 @@ async function assertUnusedReleaseVersion(packageName: string, version: string): const failures: string[] = []; if (npmUsed) failures.push(`- npm already has ${packageName}@${version}`); - if (tagSha) failures.push(`- remote Git tag ${releaseTag} already exists at ${tagSha}`); - if (releaseUsed) failures.push(`- GitHub Release ${releaseTag} already exists`); + if (tagSha) + failures.push(`- remote Git tag ${releaseTag} already exists at ${tagSha}`); + if (releaseUsed) + failures.push(`- GitHub Release ${releaseTag} already exists`); if (failures.length > 0) { - console.error(`✗ release version ${version} is already partially or fully used:`); + console.error( + `✗ release version ${version} is already partially or fully used:`, + ); console.error(failures.join("\n")); - console.error("Choose the next unused patch version, or make an explicit human decision to repair public metadata."); + console.error( + "Choose the next unused patch version, or make an explicit human decision to repair public metadata.", + ); process.exit(1); } } async function watchLatest(): Promise { - const id = (await $`gh run list --workflow release.yml --limit 1 --json databaseId -q '.[0].databaseId'`.text()).trim(); - if (!id) { console.error("No Release runs found yet."); process.exit(1); } + const id = ( + await $`gh run list --workflow release.yml --limit 1 --json databaseId -q '.[0].databaseId'`.text() + ).trim(); + if (!id) { + console.error("No Release runs found yet."); + process.exit(1); + } await watchRun(id); } @@ -173,55 +227,93 @@ async function watchRun(id: string | number): Promise { await $`gh run watch ${String(id)} --exit-status --interval 10`; } -async function waitForReleaseWorkflowRun(sha: string, branch: string, createdAfterIso: string): Promise { +async function waitForReleaseWorkflowRun( + sha: string, + branch: string, + createdAfterIso: string, +): Promise { const deadline = Date.now() + 2 * 60 * 1000; let attempt = 1; while (Date.now() < deadline) { - const raw = await $`gh run list --workflow release.yml --branch ${branch} --commit ${sha} --limit 20 --json createdAt,databaseId,headSha,status,url`.text(); + const raw = + await $`gh run list --workflow release.yml --branch ${branch} --commit ${sha} --limit 20 --json createdAt,databaseId,headSha,status,url`.text(); const runs = (JSON.parse(raw) as GhRun[]) - .filter(run => run.headSha === sha) - .filter(run => !run.createdAt || run.createdAt >= createdAfterIso) - .sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? ""))); + .filter((run) => run.headSha === sha) + .filter((run) => !run.createdAt || run.createdAt >= createdAfterIso) + .sort((a, b) => + String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")), + ); const run = runs[0]; if (run) { console.log(`→ Release workflow run found: ${run.url}`); return run; } - console.log(`→ waiting for dispatched Release run (${sha.slice(0, 7)}) attempt ${attempt}`); + console.log( + `→ waiting for dispatched Release run (${sha.slice(0, 7)}) attempt ${attempt}`, + ); attempt += 1; await Bun.sleep(5_000); } - console.error(`✗ timed out waiting for dispatched Release workflow run on ${sha}`); + console.error( + `✗ timed out waiting for dispatched Release workflow run on ${sha}`, + ); process.exit(1); } -async function listCiRuns(sha: string, workflow: string = CI_WORKFLOW): Promise { - const raw = await $`gh run list --workflow ${workflow} --commit ${sha} --limit 20 --json conclusion,databaseId,headSha,status,url`.text(); +async function listCiRuns( + sha: string, + workflow: string = CI_WORKFLOW, +): Promise { + const raw = + await $`gh run list --workflow ${workflow} --commit ${sha} --limit 20 --json conclusion,databaseId,event,headSha,status,url`.text(); const runs = JSON.parse(raw) as GhRun[]; - return runs.filter(run => run.headSha === sha); + return runs.filter( + (run) => + run.headSha === sha && + (workflow !== CI_WORKFLOW || run.event === "workflow_dispatch"), + ); } -async function waitForSuccessfulCi(sha: string, workflow: string = CI_WORKFLOW, label = "Cross-platform CI"): Promise { +async function waitForSuccessfulCi( + sha: string, + workflow: string = CI_WORKFLOW, + label = "Cross-platform CI", +): Promise { const deadline = Date.now() + CI_WAIT_TIMEOUT_MS; let attempt = 1; while (Date.now() < deadline) { const runs = await listCiRuns(sha, workflow); - const successful = runs.find(run => run.status === "completed" && run.conclusion === "success"); + const successful = runs.find( + (run) => run.status === "completed" && run.conclusion === "success", + ); if (successful) { console.log(`→ ${label} passed: ${successful.url}`); return successful; } - const failed = runs.find(run => run.status === "completed" && run.conclusion && run.conclusion !== "success"); + const failed = runs.find( + (run) => + run.status === "completed" && + run.conclusion && + run.conclusion !== "success", + ); if (failed) { console.error(`✗ ${label} failed for ${sha}: ${failed.url}`); process.exit(1); } - const state = runs.length > 0 - ? runs.map(run => `${run.status}${run.conclusion ? `/${run.conclusion}` : ""}`).join(", ") - : "not started yet"; - console.log(`→ waiting for ${label} (${sha.slice(0, 7)}) attempt ${attempt}: ${state}`); + const state = + runs.length > 0 + ? runs + .map( + (run) => + `${run.status}${run.conclusion ? `/${run.conclusion}` : ""}`, + ) + .join(", ") + : "not started yet"; + console.log( + `→ waiting for ${label} (${sha.slice(0, 7)}) attempt ${attempt}: ${state}`, + ); attempt += 1; await Bun.sleep(CI_POLL_MS); } @@ -242,7 +334,9 @@ async function _remoteMainSha(): Promise { /** Live (network) head of a remote branch — never the local remote-tracking ref. */ async function remoteBranchHead(branch: string): Promise { - const out = (await $`git ls-remote origin refs/heads/${branch}`.text()).trim(); + const out = ( + await $`git ls-remote origin refs/heads/${branch}`.text() + ).trim(); const [sha] = out.split(/\s+/); if (!sha) { console.error(`✗ could not resolve origin/${branch}`); @@ -256,7 +350,8 @@ if (args[0] === "watch") { process.exit(0); } -const explicitVersion = args[0] && !args[0].startsWith("--") ? args[0] : undefined; +const explicitVersion = + args[0] && !args[0].startsWith("--") ? args[0] : undefined; if (explicitVersion && !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(explicitVersion)) { console.error(`Invalid version: ${explicitVersion}`); process.exit(1); @@ -265,18 +360,28 @@ let version = explicitVersion; const dryRun = !args.includes("--publish"); if (!version) { - const bump = args.includes("--minor") ? "minor" : args.includes("--major") ? "major" : "patch"; + const bump = args.includes("--minor") + ? "minor" + : args.includes("--major") + ? "major" + : "patch"; const current = await readPackageVersion(); version = bumpVersion(current, bump); console.log(`→ no explicit version: bumping ${bump} → ${version}`); } if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) { - console.error("Usage: bun scripts/release.ts [|--minor|--major] [--linear GRO-123] [--tag latest|preview] [--publish]\n bun scripts/release.ts watch"); + console.error( + "Usage: bun scripts/release.ts [|--minor|--major] [--linear GRO-123] [--tag latest|preview] [--publish]\n bun scripts/release.ts watch", + ); process.exit(1); } const linearFlagIndex = args.indexOf("--linear"); -const linearIssue = linearFlagIndex === -1 ? undefined : args[linearFlagIndex + 1]; -if (linearFlagIndex !== -1 && (!linearIssue || !/^(?:GRO|CHE)-\d+$/.test(linearIssue))) { +const linearIssue = + linearFlagIndex === -1 ? undefined : args[linearFlagIndex + 1]; +if ( + linearFlagIndex !== -1 && + (!linearIssue || !/^(?:GRO|CHE)-\d+$/.test(linearIssue)) +) { console.error("Linear issue must use a GRO-123 or CHE-123 identifier."); process.exit(1); } @@ -289,18 +394,30 @@ const branch = (await $`git rev-parse --abbrev-ref HEAD`.text()).trim(); const releaseBranch = "main"; const isPrerelease = version.includes("-"); const expectedTag = isPrerelease ? "preview" : "latest"; -const tag = args.includes("--tag") ? (args[args.indexOf("--tag") + 1] ?? expectedTag) : expectedTag; +const tag = args.includes("--tag") + ? (args[args.indexOf("--tag") + 1] ?? expectedTag) + : expectedTag; if (tag !== expectedTag) { const kind = isPrerelease ? "Pre-release" : "Stable"; - console.error(`Release tag mismatch: ${kind} versions must use npm dist-tag '${expectedTag}' (got '${tag}').`); + console.error( + `Release tag mismatch: ${kind} versions must use npm dist-tag '${expectedTag}' (got '${tag}').`, + ); process.exit(1); } if (isPrerelease && !/^\d+\.\d+\.\d+-preview\.\d+$/.test(version)) { - console.error(`Pre-release versions must be X.Y.Z-preview.N (got ${version}).`); + console.error( + `Pre-release versions must be X.Y.Z-preview.N (got ${version}).`, + ); + process.exit(1); +} +if (branch !== releaseBranch) { + console.error(`✗ must be on ${releaseBranch} (currently ${branch}).`); + process.exit(1); +} +if ((await $`git status --porcelain`.text()).trim()) { + console.error("✗ working tree not clean — commit or stash first."); process.exit(1); } -if (branch !== releaseBranch) { console.error(`✗ must be on ${releaseBranch} (currently ${branch}).`); process.exit(1); } -if ((await $`git status --porcelain`.text()).trim()) { console.error("✗ working tree not clean — commit or stash first."); process.exit(1); } const packageName = await readPackageName(); console.log(`→ release metadata preflight (${packageName}@${version})`); await assertUnusedReleaseVersion(packageName, version); @@ -323,7 +440,8 @@ const releaseSha = (await $`git rev-parse HEAD`.text()).trim(); console.log(`→ push origin ${branch}`); await $`git push origin ${branch}`; -// 4. Wait for the pushed release commit to pass CI, then dispatch the Release workflow. +// 4. Manual CI covers every OS; push CI is intentionally Linux-only. +await $`gh workflow run ${CI_WORKFLOW} --ref ${branch}`; console.log(`→ wait for Cross-platform CI (${releaseSha})`); await waitForSuccessfulCi(releaseSha); @@ -339,7 +457,9 @@ await waitForSuccessfulCi(releaseSha, SERVICE_WORKFLOW, "Service lifecycle"); // to refuse publishing an unaudited newer commit. const liveOriginSha = await remoteBranchHead(branch); if (liveOriginSha !== releaseSha) { - console.error(`✗ origin/${branch} moved while waiting for CI (${liveOriginSha} != ${releaseSha}); aborting release dispatch.`); + console.error( + `✗ origin/${branch} moved while waiting for CI (${liveOriginSha} != ${releaseSha}); aborting release dispatch.`, + ); process.exit(1); } @@ -348,8 +468,14 @@ const dispatchStartedAt = new Date(Date.now() - 5_000).toISOString(); await $`gh workflow run release.yml --ref ${branch} -f version=${version} -f tag=${tag} -f expected-sha=${releaseSha} -f dry-run=${String(dryRun)}`; // 5. Watch it. -const releaseRun = await waitForReleaseWorkflowRun(releaseSha, branch, dispatchStartedAt); +const releaseRun = await waitForReleaseWorkflowRun( + releaseSha, + branch, + dispatchStartedAt, +); await watchRun(releaseRun.databaseId); -console.log(dryRun - ? "\n✓ Dry run complete. Re-run with --publish to publish for real." - : "\n✓ Published. Try: npm install -g @groeponline/opencodex"); +console.log( + dryRun + ? "\n✓ Dry run complete. Re-run with --publish to publish for real." + : "\n✓ Published. Try: npm install -g @groeponline/opencodex", +); diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index fd998841..4eb484a7 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -34,6 +34,37 @@ function count(text: string, fragment: string): number { } describe("GitHub Actions hardening", () => { + test("releases require all platforms before publication, not only after the tag", async () => { + const ci = await readText(".github/workflows/ci.yml"); + const release = await readText(".github/workflows/release.yml"); + expect( + count( + ci, + "github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')", + ), + ).toBe(2); + expect(release).toContain("--event workflow_dispatch"); + expect(release).toContain('--commit "$GITHUB_SHA"'); + expect(release).toContain('select(.conclusion == "success")'); + expect(release).toContain('$run.conclusion == "success"'); + expect(release).toContain('if [ "$matrix_ok" != "true" ]; then'); + for (const job of [ + "ubuntu-latest", + "macos-latest", + "macos-quality", + "windows-latest", + "windows-latest shard 2/2", + "windows-quality", + "npm-global ubuntu-latest", + "npm-global macos-latest", + "npm-global windows-latest", + "Security audit", + "Lint GitHub Actions", + ]) { + expect(release).toContain(JSON.stringify(job)); + } + }); + test("cross-platform CI keeps bounded jobs and immutable action references", async () => { const workflow = await readText(".github/workflows/ci.yml"); diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 210a33b0..f82a9f5d 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -22,7 +22,12 @@ import { const scratch = mkdtempSync(join(tmpdir(), "ocx-client-artifact-test-")); const powershell = Bun.which("pwsh"); const posixShell = process.platform !== "win32"; -afterAll(() => rmSync(scratch, { recursive: true, force: true })); +const fixtureWorktrees = new Set(); +afterAll(() => { + for (const destination of fixtureWorktrees) + removeDetachedWorktree(destination); + rmSync(scratch, { recursive: true, force: true }); +}); function addDetachedWorktree(destination: string) { const root = join(import.meta.dir, ".."); @@ -31,6 +36,7 @@ function addDetachedWorktree(destination: string) { { cwd: root }, ); expect(result.success).toBe(true); + fixtureWorktrees.add(destination); } function removeDetachedWorktree(destination: string) { @@ -39,6 +45,7 @@ function removeDetachedWorktree(destination: string) { cwd: root, }); rmSync(destination, { recursive: true, force: true }); + fixtureWorktrees.delete(destination); } describe("remote client artifact", () => { diff --git a/tests/codex-runtime.test.ts b/tests/codex-runtime.test.ts index 88c86227..2038d3f3 100644 --- a/tests/codex-runtime.test.ts +++ b/tests/codex-runtime.test.ts @@ -9,7 +9,10 @@ import { describe, expect, test } from "bun:test"; * loadBundledCodexCatalog() returns null. "/usr/bin:/bin" keeps the utilities reachable * and contains no `codex`, which is the only property this test depends on. */ -const NO_CODEX_PATH = "/usr/bin:/bin"; +const NO_CODEX_PATH = + process.platform === "win32" + ? join(process.env.SystemRoot ?? "C:\\Windows", "System32") + : "/usr/bin:/bin"; import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; @@ -561,7 +564,10 @@ describe("resolveCodexRuntime", () => { path, [ "@echo off", - `if "%~1"=="--version" ( echo codex-cli ${version} & exit /b 0 )`, + `if "%~1"=="--version" (`, + ` echo codex-cli ${version}`, + " exit /b 0", + ")", `type "%~dp0catalog.json"`, "", ].join("\r\n"), @@ -597,7 +603,7 @@ describe("resolveCodexRuntime", () => { const runtimeEnv: NodeJS.ProcessEnv = { ...process.env, OPENCODEX_HOME: home, - PATH: "", + PATH: NO_CODEX_PATH, CODEX_CLI_PATH: firstBin, }; const deps = { diff --git a/tests/container-image.test.ts b/tests/container-image.test.ts index 02c1f9cb..96ed6ee5 100644 --- a/tests/container-image.test.ts +++ b/tests/container-image.test.ts @@ -213,15 +213,26 @@ describe("container image", () => { }, 15_000); test("entrypoint exports token from file and execs the command", () => { + const shell = Bun.which("sh"); + expect(shell).not.toBeNull(); const dir = mkdtempSync(join(tmpdir(), "ocx-container-")); try { const tokenFile = join(dir, "token"); writeFileSync(tokenFile, "test-file-token\n", { mode: 0o600 }); const result = Bun.spawnSync( - ["/bin/sh", entrypoint, "printenv", "OPENCODEX_API_AUTH_TOKEN"], + [ + shell!, + entrypoint.replaceAll("\\", "/"), + shell!, + "-c", + 'printf "%s" "$OPENCODEX_API_AUTH_TOKEN"', + ], { cwd: repoRoot, - env: { ...process.env, OPENCODEX_API_AUTH_TOKEN_FILE: tokenFile }, + env: { + ...process.env, + OPENCODEX_API_AUTH_TOKEN_FILE: tokenFile.replaceAll("\\", "/"), + }, stdout: "pipe", stderr: "pipe", }, diff --git a/tests/live-checkout.test.ts b/tests/live-checkout.test.ts index 89701aaa..9428b86f 100644 --- a/tests/live-checkout.test.ts +++ b/tests/live-checkout.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync, chmodSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, + chmodSync, + readFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { collectStartupHealth } from "../src/codex/autostart-health"; @@ -100,26 +107,37 @@ describe("diagnoseLiveCheckout", () => { test("collectStartupHealth includes liveCheckout without diffs", () => { const health = collectStartupHealth({ codexAutoStart: true }); - expect(health.liveCheckout).toEqual(expect.objectContaining({ - detached: expect.any(Boolean), - dirty: expect.any(Boolean), - })); + expect(health.liveCheckout).toEqual( + expect.objectContaining({ + detached: expect.any(Boolean), + dirty: expect.any(Boolean), + }), + ); expect(JSON.stringify(health.liveCheckout)).not.toContain("diff --git"); }); }); describe("assert-live-checkout-safe.sh", () => { - const script = join(import.meta.dir, "../scripts/assert-live-checkout-safe.sh"); + const script = join( + import.meta.dir, + "../scripts/assert-live-checkout-safe.sh", + ); test("refuses dirty porcelain and a HEAD that is not an ancestor of the target", () => { if (!Bun.which("bash")) return; const dir = initRepo(); try { - const clean = Bun.spawnSync(["bash", script, dir], { stdout: "pipe", stderr: "pipe" }); + const clean = Bun.spawnSync(["bash", script, dir], { + stdout: "pipe", + stderr: "pipe", + }); expect(clean.exitCode).toBe(0); writeFileSync(join(dir, "dirty.txt"), "no\n"); - const dirty = Bun.spawnSync(["bash", script, dir], { stdout: "pipe", stderr: "pipe" }); + const dirty = Bun.spawnSync(["bash", script, dir], { + stdout: "pipe", + stderr: "pipe", + }); expect(dirty.exitCode).toBe(1); expect(dirty.stderr.toString()).toContain("refusing dirty working tree"); expect(dirty.stderr.toString()).not.toContain("dirty.txt"); @@ -128,20 +146,69 @@ describe("assert-live-checkout-safe.sh", () => { git(dir, ["commit", "--allow-empty", "-m", "second"]); const second = git(dir, ["rev-parse", "HEAD"]); - const ancestor = Bun.spawnSync(["bash", script, dir, second], { stdout: "pipe", stderr: "pipe" }); + const ancestor = Bun.spawnSync(["bash", script, dir, second], { + stdout: "pipe", + stderr: "pipe", + }); expect(ancestor.exitCode).toBe(0); git(dir, ["checkout", "-q", first]); - const wouldDrop = Bun.spawnSync(["bash", script, dir, first], { stdout: "pipe", stderr: "pipe" }); + const wouldDrop = Bun.spawnSync(["bash", script, dir, first], { + stdout: "pipe", + stderr: "pipe", + }); // HEAD is first; target first is ancestor of itself — allowed. expect(wouldDrop.exitCode).toBe(0); git(dir, ["checkout", "-q", second]); - const notAncestor = Bun.spawnSync(["bash", script, dir, first], { stdout: "pipe", stderr: "pipe" }); + const notAncestor = Bun.spawnSync(["bash", script, dir, first], { + stdout: "pipe", + stderr: "pipe", + }); expect(notAncestor.exitCode).toBe(1); - expect(notAncestor.stderr.toString()).toContain("would drop live-only commits"); + expect(notAncestor.stderr.toString()).toContain( + "would drop live-only commits", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("uses a bounded Perl runner when GNU timeout is absent", () => { + const bash = Bun.which("bash"); + const perl = Bun.which("perl"); + const realGit = Bun.which("git"); + expect(bash).not.toBeNull(); + expect(perl).not.toBeNull(); + expect(realGit).not.toBeNull(); + const dir = initRepo(); + const binDir = mkdtempSync(join(tmpdir(), "ocx-timeout-fallback-")); + const log = join(binDir, "calls"); + const quote = (path: string) => + `'${path.replaceAll("\\", "/").replaceAll("'", "'\\''")}'`; + try { + writeFileSync( + join(binDir, "git"), + `#!${bash}\nexec ${quote(realGit!)} "$@"\n`, + ); + writeFileSync( + join(binDir, "perl"), + `#!${bash}\nprintf '%s\\n' "$@" >> ${quote(log)}\nexec ${quote(perl!)} "$@"\n`, + ); + chmodSync(join(binDir, "git"), 0o755); + chmodSync(join(binDir, "perl"), 0o755); + const result = Bun.spawnSync([bash!, script, dir], { + env: { ...process.env, PATH: binDir }, + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(readFileSync(log, "utf8")).toContain( + "alarm shift; exec @ARGV or exit 127\n10\ngit\n", + ); } finally { rmSync(dir, { recursive: true, force: true }); + rmSync(binDir, { recursive: true, force: true }); } }); @@ -152,12 +219,15 @@ describe("assert-live-checkout-safe.sh", () => { const realGit = Bun.which("git"); if (!realGit) return; const binDir = mkdtempSync(join(tmpdir(), "ocx-live-checkout-bin-")); - writeFileSync(join(binDir, "git"), `#!/usr/bin/env bash + writeFileSync( + join(binDir, "git"), + `#!/usr/bin/env bash if [[ "$1" == "status" && "$2" == "--porcelain" ]]; then exit 1 fi exec "${realGit}" "$@" -`); +`, + ); chmodSync(join(binDir, "git"), 0o755); const probe = Bun.spawnSync(["bash", script, dir], { stdout: "pipe", diff --git a/tests/release-helper.test.ts b/tests/release-helper.test.ts index c2b049a3..4ccf0359 100644 --- a/tests/release-helper.test.ts +++ b/tests/release-helper.test.ts @@ -1,5 +1,11 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test"; -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -7,7 +13,9 @@ import { fileURLToPath } from "node:url"; setDefaultTimeout(30_000); -const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); +const repoRoot = dirname( + fileURLToPath(new URL("../package.json", import.meta.url)), +); const releaseScriptPath = join(repoRoot, "scripts", "release.ts"); interface LoggedCall { @@ -16,6 +24,7 @@ interface LoggedCall { } interface ReleaseScenario { + fullCiFails?: boolean; branch?: string; headSha?: string; linearIssue?: string; @@ -135,7 +144,10 @@ if (args[0] === "release" && args[1] === "view") { if (args[0] === "run" && args[1] === "list") { if (args.includes("ci.yml")) { - stdout(JSON.stringify([{ conclusion: "success", databaseId: 7, headSha, status: "completed", url: "https://example.test/ci" }])); + stdout(JSON.stringify([ + { conclusion: "success", event: "push", databaseId: 6, headSha, status: "completed", url: "https://example.test/linux-only" }, + { conclusion: process.env.FAKE_FULL_CI_FAILS === "1" ? "failure" : "success", event: "workflow_dispatch", databaseId: 7, headSha, status: "completed", url: "https://example.test/ci" }, + ])); process.exit(0); } @@ -163,24 +175,41 @@ process.exit(1); `; } -function installCommandShim(binDir: string, name: "bun" | "gh" | "git" | "npm"): void { +function installCommandShim( + binDir: string, + name: "bun" | "gh" | "git" | "npm", +): void { const jsPath = join(binDir, `${name}.js`); const launcherPath = join(binDir, name); const cmdPath = join(binDir, `${name}.cmd`); writeFileSync(jsPath, shimProgramSource(name), "utf8"); - writeExecutable(launcherPath, `#!${process.execPath}\nimport "./${name}.js";\n`); - writeFileSync(cmdPath, `@echo off\r\n"${process.execPath}" "%~dp0\\${name}.js" %*\r\n`, "utf8"); + writeExecutable( + launcherPath, + `#!${process.execPath}\nimport "./${name}.js";\n`, + ); + writeFileSync( + cmdPath, + `@echo off\r\n"${process.execPath}" "%~dp0\\${name}.js" %*\r\n`, + "utf8", + ); } function readLoggedCalls(logPath: string): LoggedCall[] { const raw = readFileSync(logPath, "utf8").trim(); if (!raw) return []; - return raw.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line) as LoggedCall); + return raw + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line) as LoggedCall); } -function findCallIndex(calls: LoggedCall[], name: string, matcher: (call: LoggedCall) => boolean): number { - return calls.findIndex(call => call.name === name && matcher(call)); +function findCallIndex( + calls: LoggedCall[], + name: string, + matcher: (call: LoggedCall) => boolean, +): number { + return calls.findIndex((call) => call.name === name && matcher(call)); } function runRelease(version: string, scenario: ReleaseScenario = {}) { @@ -192,25 +221,32 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) { installCommandShim(shimDir, name); } - const result = spawnSync(process.execPath, [ - releaseScriptPath, - version, - ...(scenario.linearIssue ? ["--linear", scenario.linearIssue] : []), - ], { - cwd: repoRoot, - env: { - ...process.env, - PATH: `${shimDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`, - FAKE_RELEASE_LOG: logPath, - FAKE_GIT_BRANCH: scenario.branch ?? "main", - FAKE_GIT_HEAD_SHA: scenario.headSha ?? "abc123def456", - ...(scenario.remoteHeadSha ? { FAKE_GIT_REMOTE_HEAD_SHA: scenario.remoteHeadSha } : {}), - FAKE_BUN_TSC_EXIT_CODE: String(scenario.typecheckExitCode ?? 0), - FAKE_BUN_TEST_EXIT_CODE: String(scenario.testExitCode ?? 0), - FAKE_BUN_PRIVACY_EXIT_CODE: String(scenario.privacyExitCode ?? 0), + const result = spawnSync( + process.execPath, + [ + releaseScriptPath, + version, + ...(scenario.linearIssue ? ["--linear", scenario.linearIssue] : []), + ], + { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${shimDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`, + FAKE_RELEASE_LOG: logPath, + FAKE_FULL_CI_FAILS: scenario.fullCiFails ? "1" : "0", + FAKE_GIT_BRANCH: scenario.branch ?? "main", + FAKE_GIT_HEAD_SHA: scenario.headSha ?? "abc123def456", + ...(scenario.remoteHeadSha + ? { FAKE_GIT_REMOTE_HEAD_SHA: scenario.remoteHeadSha } + : {}), + FAKE_BUN_TSC_EXIT_CODE: String(scenario.typecheckExitCode ?? 0), + FAKE_BUN_TEST_EXIT_CODE: String(scenario.testExitCode ?? 0), + FAKE_BUN_PRIVACY_EXIT_CODE: String(scenario.privacyExitCode ?? 0), + }, + encoding: "utf8", }, - encoding: "utf8", - }); + ); const calls = readLoggedCalls(logPath); rmSync(shimDir, { recursive: true, force: true }); @@ -218,21 +254,55 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) { } describe("release helper", () => { + test("Linux-only success cannot hide a failed full-platform preflight", () => { + const { calls, result } = runRelease("9.9.9", { fullCiFails: true }); + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toContain("Cross-platform CI failed"); + expect( + findCallIndex( + calls, + "gh", + (call) => + call.args[0] === "workflow" && + call.args[1] === "run" && + call.args.includes("release.yml"), + ), + ).toBe(-1); + }); test("preflight runs typecheck, test suite, and privacy scan before version bump on main dry-runs", () => { const { calls, result } = runRelease("9.9.9"); expect(result.status).toBe(0); - const typecheckIndex = findCallIndex(calls, "bun", call => call.args.join(" ") === "x tsc --noEmit"); - const testIndex = findCallIndex(calls, "bun", call => call.args.join(" ") === "test --isolate tests"); - const privacyIndex = findCallIndex(calls, "bun", call => call.args.join(" ") === "run privacy:scan"); - const versionIndex = findCallIndex(calls, "npm", call => call.args.join(" ") === "version 9.9.9 --no-git-tag-version"); - const dispatchIndex = findCallIndex(calls, "gh", call => - call.args[0] === "workflow" - && call.args[1] === "run" - && call.args.includes("release.yml") - && call.args.includes("tag=latest") - && call.args.includes("dry-run=true"), + const typecheckIndex = findCallIndex( + calls, + "bun", + (call) => call.args.join(" ") === "x tsc --noEmit", + ); + const testIndex = findCallIndex( + calls, + "bun", + (call) => call.args.join(" ") === "test --isolate tests", + ); + const privacyIndex = findCallIndex( + calls, + "bun", + (call) => call.args.join(" ") === "run privacy:scan", + ); + const versionIndex = findCallIndex( + calls, + "npm", + (call) => call.args.join(" ") === "version 9.9.9 --no-git-tag-version", + ); + const dispatchIndex = findCallIndex( + calls, + "gh", + (call) => + call.args[0] === "workflow" && + call.args[1] === "run" && + call.args.includes("release.yml") && + call.args.includes("tag=latest") && + call.args.includes("dry-run=true"), ); expect(typecheckIndex).toBeGreaterThanOrEqual(0); @@ -240,40 +310,74 @@ describe("release helper", () => { expect(privacyIndex).toBeGreaterThan(testIndex); expect(versionIndex).toBeGreaterThan(privacyIndex); expect(dispatchIndex).toBeGreaterThan(versionIndex); + const fullCiIndex = findCallIndex( + calls, + "gh", + (call) => call.args.join(" ") === "workflow run ci.yml --ref main", + ); + expect(fullCiIndex).toBeGreaterThan(versionIndex); + expect(dispatchIndex).toBeGreaterThan(fullCiIndex); }); test("failed privacy scan aborts before version bump, commit, and push", () => { const { calls, result } = runRelease("9.9.9", { privacyExitCode: 1 }); expect(result.status).not.toBe(0); - expect(findCallIndex(calls, "bun", call => call.args.join(" ") === "run privacy:scan")).toBeGreaterThanOrEqual(0); - expect(findCallIndex(calls, "npm", call => call.args[0] === "version")).toBe(-1); - expect(findCallIndex(calls, "git", call => call.args[0] === "commit")).toBe(-1); - expect(findCallIndex(calls, "git", call => call.args[0] === "push")).toBe(-1); + expect( + findCallIndex( + calls, + "bun", + (call) => call.args.join(" ") === "run privacy:scan", + ), + ).toBeGreaterThanOrEqual(0); + expect( + findCallIndex(calls, "npm", (call) => call.args[0] === "version"), + ).toBe(-1); + expect( + findCallIndex(calls, "git", (call) => call.args[0] === "commit"), + ).toBe(-1); + expect(findCallIndex(calls, "git", (call) => call.args[0] === "push")).toBe( + -1, + ); }); test("a prerelease on main defaults to preview tag and dry-run dispatch", () => { const { calls, result } = runRelease("9.9.9-preview.1"); expect(result.status).toBe(0); - expect(findCallIndex(calls, "gh", call => - call.args[0] === "workflow" - && call.args[1] === "run" - && call.args.includes("release.yml") - && call.args.includes("tag=preview") - && call.args.includes("dry-run=true"), - )).toBeGreaterThanOrEqual(0); + expect( + findCallIndex( + calls, + "gh", + (call) => + call.args[0] === "workflow" && + call.args[1] === "run" && + call.args.includes("release.yml") && + call.args.includes("tag=preview") && + call.args.includes("dry-run=true"), + ), + ).toBeGreaterThanOrEqual(0); }); // release.yml only accepts refs/heads/main, so the helper must refuse anything else // instead of dispatching a run the workflow will reject. test("releasing from a branch other than main aborts before the bump", () => { - const { calls, result } = runRelease("9.9.9-preview.1", { branch: "preview" }); + const { calls, result } = runRelease("9.9.9-preview.1", { + branch: "preview", + }); expect(result.status).not.toBe(0); expect(result.stderr + result.stdout).toContain("must be on main"); - expect(findCallIndex(calls, "npm", call => call.args[0] === "version")).toBe(-1); - expect(findCallIndex(calls, "gh", call => call.args[0] === "workflow" && call.args[1] === "run")).toBe(-1); + expect( + findCallIndex(calls, "npm", (call) => call.args[0] === "version"), + ).toBe(-1); + expect( + findCallIndex( + calls, + "gh", + (call) => call.args[0] === "workflow" && call.args[1] === "run", + ), + ).toBe(-1); }); // The update client only parses X.Y.Z-preview.N, so any other prerelease shape would @@ -283,28 +387,43 @@ describe("release helper", () => { expect(result.status).not.toBe(0); expect(result.stderr + result.stdout).toContain("must be X.Y.Z-preview.N"); - expect(findCallIndex(calls, "npm", call => call.args[0] === "version")).toBe(-1); + expect( + findCallIndex(calls, "npm", (call) => call.args[0] === "version"), + ).toBe(-1); }); test("dispatch pins the audited release SHA via expected-sha", () => { - const { calls, result } = runRelease("9.9.9", { headSha: "deadbeefcafe1234" }); + const { calls, result } = runRelease("9.9.9", { + headSha: "deadbeefcafe1234", + }); expect(result.status).toBe(0); - expect(findCallIndex(calls, "gh", call => - call.args[0] === "workflow" - && call.args[1] === "run" - && call.args.includes("release.yml") - && call.args.includes("expected-sha=deadbeefcafe1234"), - )).toBeGreaterThanOrEqual(0); + expect( + findCallIndex( + calls, + "gh", + (call) => + call.args[0] === "workflow" && + call.args[1] === "run" && + call.args.includes("release.yml") && + call.args.includes("expected-sha=deadbeefcafe1234"), + ), + ).toBeGreaterThanOrEqual(0); }); test("includes a validated Linear issue in the release commit", () => { const { calls, result } = runRelease("9.9.9", { linearIssue: "GRO-994" }); expect(result.status).toBe(0); - expect(findCallIndex(calls, "git", call => - call.args[0] === "commit" && call.args.join(" ").includes("release: v9.9.9 (GRO-994)"), - )).toBeGreaterThanOrEqual(0); + expect( + findCallIndex( + calls, + "git", + (call) => + call.args[0] === "commit" && + call.args.join(" ").includes("release: v9.9.9 (GRO-994)"), + ), + ).toBeGreaterThanOrEqual(0); }); test("rejects malformed Linear issue identifiers before the bump", () => { @@ -312,17 +431,30 @@ describe("release helper", () => { expect(result.status).not.toBe(0); expect(result.stderr + result.stdout).toContain("Linear issue must use"); - expect(findCallIndex(calls, "npm", call => call.args[0] === "version")).toBe(-1); + expect( + findCallIndex(calls, "npm", (call) => call.args[0] === "version"), + ).toBe(-1); }); - test("aborts before dispatch when the remote branch moved during the CI wait", () => { + test("aborts before release dispatch when the remote branch moved during the CI wait", () => { const { calls, result } = runRelease("9.9.9", { headSha: "abc123def456", remoteHeadSha: "9999999999999999999999999999999999999999", }); expect(result.status).not.toBe(0); - expect(result.stderr + result.stdout).toContain("moved while waiting for CI"); - expect(findCallIndex(calls, "gh", call => call.args[0] === "workflow" && call.args[1] === "run")).toBe(-1); + expect(result.stderr + result.stdout).toContain( + "moved while waiting for CI", + ); + expect( + findCallIndex( + calls, + "gh", + (call) => + call.args[0] === "workflow" && + call.args[1] === "run" && + call.args.includes("release.yml"), + ), + ).toBe(-1); }); }); From 7dcaf85c45a2b1fecd30c394381b4442104d7742 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:11:56 +0200 Subject: [PATCH 06/20] fix(ci): verify the shipped Bun runtime --- .github/workflows/ci.yml | 6 +++--- tests/ci-workflows.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70379cf0..7f902b61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.0 no-cache: false - name: Cache GUI node_modules @@ -119,7 +119,7 @@ jobs: exit 0 fi - # Bun 1.3.14 on Windows intermittently panics under Worker spawn/terminate churn in + # Bun on Windows can panic under Worker spawn/terminate churn in # storage policy tests ("Internal assertion failure" / "Bun has crashed"). Retry the # current shard once on that runtime crash only; ordinary assertion failures still fail. out="$(mktemp)" @@ -217,7 +217,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.14 + bun-version: 1.4.0 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index 4eb484a7..5be6f8f8 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -34,6 +34,22 @@ function count(text: string, fragment: string): number { } describe("GitHub Actions hardening", () => { + test("CI verifies the Bun runtime actually shipped in the package", async () => { + const pkg = JSON.parse(await readText("package.json")); + const ci = Bun.YAML.parse(await readText(".github/workflows/ci.yml")) as { + jobs: Record< + string, + { steps?: { uses?: string; with?: Record }[] } + >; + }; + const setups = Object.values(ci.jobs) + .flatMap((job) => job.steps ?? []) + .filter((step) => step.uses?.startsWith("oven-sh/setup-bun@")); + expect(setups.length).toBeGreaterThan(0); + for (const step of setups) + expect(step.with?.["bun-version"]).toBe(pkg.dependencies.bun); + }); + test("releases require all platforms before publication, not only after the tag", async () => { const ci = await readText(".github/workflows/ci.yml"); const release = await readText(".github/workflows/release.yml"); From b1ce5b9453315e6eaabdb972c6b454f137097e20 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:18:13 +0200 Subject: [PATCH 07/20] fix(ci): distinguish jq bindings from shell expansion --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07193f5b..41045165 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -180,6 +180,7 @@ jobs: echo "::error::CI run ${ci_id} does not match the release SHA" exit 1 fi + # shellcheck disable=SC2016 # $run/$passed/$name are jq bindings, not shell variables. matrix_ok="$(gh run view "$ci_id" --json headSha,conclusion,jobs --jq \ '. as $run | [.jobs[] | select(.conclusion == "success") | .name] as $passed | ($run.conclusion == "success" and (["ubuntu-latest", "macos-latest", "macos-quality", "windows-latest", "windows-latest shard 2/2", "windows-quality", "npm-global ubuntu-latest", "npm-global macos-latest", "npm-global windows-latest", "Security audit", "Lint GitHub Actions"] | all(.[]; . as $name | $passed | index($name) != null)))')" if [ "$matrix_ok" != "true" ]; then From 8a39346e40a4c5217f852adee97c79b263c6921c Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:25:13 +0200 Subject: [PATCH 08/20] test: use portable shell shebangs in bounded git fixtures --- tests/live-checkout.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/live-checkout.test.ts b/tests/live-checkout.test.ts index 9428b86f..01d50d35 100644 --- a/tests/live-checkout.test.ts +++ b/tests/live-checkout.test.ts @@ -189,11 +189,11 @@ describe("assert-live-checkout-safe.sh", () => { try { writeFileSync( join(binDir, "git"), - `#!${bash}\nexec ${quote(realGit!)} "$@"\n`, + `#!/bin/sh\nexec ${quote(realGit!)} "$@"\n`, ); writeFileSync( join(binDir, "perl"), - `#!${bash}\nprintf '%s\\n' "$@" >> ${quote(log)}\nexec ${quote(perl!)} "$@"\n`, + `#!/bin/sh\nprintf '%s\\n' "$@" >> ${quote(log)}\nexec ${quote(perl!)} "$@"\n`, ); chmodSync(join(binDir, "git"), 0o755); chmodSync(join(binDir, "perl"), 0o755); From 16e7a9ef5b5ac6860abe47b6883ea005d651ffe7 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:23:33 +0200 Subject: [PATCH 09/20] fix: accept macOS system var alias in client shim --- scripts/build-client-artifact.ts | 12 +++++++++++- tests/client-artifact.test.ts | 9 ++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index fc02bfac..46692840 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -235,6 +235,9 @@ export const CODEX_CLIENT_SHIM = [ ' [ -n "$path_component" ] || continue', ' path_part="${path_part}/${path_component}"', ' if [ -L "$path_part" ]; then', + ' # macOS exposes /var as the system-owned alias of /private/var. It is the', + ' # sole symlink component allowed here; every other link remains forbidden.', + ' if [ "$path_part" = "/var" ] && [ "$(uname -s)" = "Darwin" ] && [ "$(cd -P -- "$path_part" && pwd -P)" = "/private/var" ]; then continue; fi', ' echo "OCX client-only: refusing symlinked Codex home path $path_part" >&2', " exit 78", " fi", @@ -308,6 +311,10 @@ export const CODEX_CLIENT_POWERSHELL_SHIM = [ " }", " return [System.IO.Path]::GetFullPath($current)", "}", + "function Test-TrustedDarwinSystemPathAlias([string]$Path) {", + " if (-not $IsMacOS -or $Path -ne '/var') { return $false }", + " try { return [string]::Equals((Resolve-PhysicalPath $Path), '/private/var', [System.StringComparison]::Ordinal) } catch { return $false }", + "}", "$clientHomeRaw = if ($env:OCX_CLIENT_CODEX_HOME) { $env:OCX_CLIENT_CODEX_HOME } else { Join-Path $homeDir '.codex-ocx' }", "if (-not [System.IO.Path]::IsPathRooted($clientHomeRaw)) { [Console]::Error.WriteLine('OCX client-only: OCX_CLIENT_CODEX_HOME must be absolute'); exit 78 }", "$clientHomeCandidate = [System.IO.Path]::GetFullPath($clientHomeRaw)", @@ -319,7 +326,10 @@ export const CODEX_CLIENT_POWERSHELL_SHIM = [ " $current = Join-Path $current $part", " $item = Get-Item -Force -LiteralPath $current -ErrorAction SilentlyContinue", " if ($null -ne $item) {", - " if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { return $true }", + " if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {", + " if (Test-TrustedDarwinSystemPathAlias $current) { continue }", + " return $true", + " }", " }", " }", " return $false", diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index f82a9f5d..2acb47c8 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -5,6 +5,7 @@ import { existsSync, mkdirSync, mkdtempSync, + realpathSync, readFileSync, rmSync, statSync, @@ -343,10 +344,16 @@ describe("remote client artifact", () => { expect(readFileSync(powershellShim, "utf8")).toContain( "$env:USERPROFILE", ); + expect(readFileSync(shim, "utf8")).toContain( + '"$path_part" = "/var"', + ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "Test-TrustedDarwinSystemPathAlias", + ); const defaultRun = Bun.spawnSync([shim, "--version"], { env }); expect(defaultRun.exitCode).toBe(0); expect(readFileSync(capture, "utf8")).toBe( - join(home, ".codex-ocx") + "\n", + join(realpathSync.native(home), ".codex-ocx") + "\n", ); expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( "direct Azure config stays untouched\n", From 9b120201f534302fa9a0efea94310ab977ca7bb6 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:26:51 +0200 Subject: [PATCH 10/20] test: allow client artifact builds on Windows --- tests/client-artifact.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 2acb47c8..577da5d7 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -143,7 +143,7 @@ describe("remote client artifact", () => { createHash("sha256").update(locked).digest("hex"), ); removeDetachedWorktree(sourceRoot); - }, 15_000); + }, 30_000); test("builds a self-contained, SHA-bound candidate without activation", async () => { const output = join(scratch, "candidate"); @@ -344,9 +344,7 @@ describe("remote client artifact", () => { expect(readFileSync(powershellShim, "utf8")).toContain( "$env:USERPROFILE", ); - expect(readFileSync(shim, "utf8")).toContain( - '"$path_part" = "/var"', - ); + expect(readFileSync(shim, "utf8")).toContain('"$path_part" = "/var"'); expect(readFileSync(powershellShim, "utf8")).toContain( "Test-TrustedDarwinSystemPathAlias", ); @@ -509,6 +507,7 @@ describe("remote client artifact", () => { "native config\n", ); }, + 30_000, ); test.skipIf(!powershell)( From 4b45745f0cbd4822fde8406fc139a35bb6a6275d Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:28:30 +0200 Subject: [PATCH 11/20] test: declare runtime fixture imports before path constants --- tests/codex-runtime.test.ts | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/tests/codex-runtime.test.ts b/tests/codex-runtime.test.ts index 2038d3f3..337464ad 100644 --- a/tests/codex-runtime.test.ts +++ b/tests/codex-runtime.test.ts @@ -1,18 +1,4 @@ import { describe, expect, test } from "bun:test"; - -/** - * PATH for the runtime test that must stop PATH-based codex DISCOVERY while keeping its - * fake launcher runnable. The launcher is a /bin/sh script that calls `dirname` and `cat`, - * so the child still needs the standard utilities. `PATH = ""` used to work by accident: - * Bun 1.3.14 (the CI pin) leaked the parent PATH into children; Bun 1.4 passes the empty - * value through faithfully, the script then dies with "dirname: not found", and - * loadBundledCodexCatalog() returns null. "/usr/bin:/bin" keeps the utilities reachable - * and contains no `codex`, which is the only property this test depends on. - */ -const NO_CODEX_PATH = - process.platform === "win32" - ? join(process.env.SystemRoot ?? "C:\\Windows", "System32") - : "/usr/bin:/bin"; import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; @@ -31,6 +17,15 @@ import { type RuntimeExecFile, } from "../src/codex/runtime"; +/** + * Exclude PATH-based Codex discovery while retaining the operating-system + * utilities needed by the fake launcher. An empty PATH also hides those tools. + */ +const NO_CODEX_PATH = + process.platform === "win32" + ? join(process.env.SystemRoot ?? "C:\\Windows", "System32") + : "/usr/bin:/bin"; + function tempConfigDir(): string { return mkdtempSync(join(tmpdir(), "ocx-runtime-")); } From 1f18cbda52ded3cdb59555afacc80bf08d56a087 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:38:25 +0000 Subject: [PATCH 12/20] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`f?= =?UTF-8?q?ix/release-review-followup-20260908`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @MisterWanted. The following files were modified: * `scripts/assert-live-checkout-safe.sh` * `scripts/build-client-artifact.ts` * `scripts/release.ts` These files were ignored: * `tests/ci-workflows.test.ts` * `tests/client-artifact.test.ts` * `tests/codex-runtime.test.ts` * `tests/container-image.test.ts` * `tests/live-checkout.test.ts` * `tests/release-helper.test.ts` * `tests/review-execution-policy.test.ts` These file types are not supported: * `.github/workflows/ci.yml` * `.github/workflows/release.yml` * `docs-site/src/content/docs/reference/cli.md` * `package.json` --- scripts/assert-live-checkout-safe.sh | 2 +- scripts/build-client-artifact.ts | 30 ++++++++++ scripts/release.ts | 86 +++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 4 deletions(-) mode change 100755 => 100644 scripts/assert-live-checkout-safe.sh diff --git a/scripts/assert-live-checkout-safe.sh b/scripts/assert-live-checkout-safe.sh old mode 100755 new mode 100644 index a3be992b..867c2082 --- a/scripts/assert-live-checkout-safe.sh +++ b/scripts/assert-live-checkout-safe.sh @@ -14,7 +14,7 @@ target=${2-} # GNU coreutils exposes `timeout`, while macOS commonly exposes it as # `gtimeout` when coreutils is installed (and otherwise has no equivalent). # Perl is part of macOS and preserves an alarm across exec. Never drop the -# deadline just because GNU coreutils is absent. +# run_git executes a Git command with a 10-second deadline and returns its exit status. run_git() { if command -v timeout >/dev/null 2>&1; then timeout 10s git "$@" diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index 46692840..8152ef68 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -19,6 +19,14 @@ const ROOT = fileURLToPath(new URL("..", import.meta.url)); const sha256 = (data: string | Uint8Array) => createHash("sha256").update(data).digest("hex"); +/** + * Determines whether a path is the trusted macOS `/var` alias for `/private/var`. + * + * @param path - The logical path to evaluate + * @param physicalTarget - The path resolved by the filesystem + * @param platform - The operating-system platform to evaluate + * @returns `true` if the values represent the macOS `/var` to `/private/var` alias, `false` otherwise + */ export function isTrustedDarwinSystemPathAlias( path: string, physicalTarget: string, @@ -29,6 +37,13 @@ export function isTrustedDarwinSystemPathAlias( ); } +/** + * Ensures that an existing component of a path is not a symbolic link, except for the trusted macOS `/var` alias. + * + * Missing path components are allowed. + * + * @param path - The path whose components to inspect + */ function assertNoSymlinkPathComponents(path: string) { let current = resolve(path); while (true) { @@ -133,6 +148,14 @@ function normalizeGeneratedBundleSourceComments( return new TextEncoder().encode(normalized); } +/** + * Creates an isolated build worktree at a source revision with frozen dependencies installed. + * + * @param sourceRoot - The Git repository containing the source revision + * @param sourceSha - The commit SHA to check out + * @returns The path to the isolated build worktree + * @throws If worktree creation or dependency installation fails + */ function prepareIsolatedBuildRoot( sourceRoot: string, sourceSha: string, @@ -358,6 +381,13 @@ export const CODEX_CLIENT_POWERSHELL_SHIM = [ "", ].join("\r\n"); +/** + * Builds and publishes a client artifact from clean, committed runtime inputs. + * + * @param destination - Destination directory for the new artifact candidate + * @param root - Runtime source repository to build from + * @returns The generated artifact manifest + */ export async function buildClientArtifact(destination: string, root = ROOT) { const builderDirty = git( ROOT, diff --git a/scripts/release.ts b/scripts/release.ts index ecd70060..776b688a 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -56,6 +56,13 @@ async function runQuiet(command: string[]): Promise { return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() }; } +/** + * Reads and returns the package name from `package.json`. + * + * Exits the process if the file cannot be read, parsed, or does not contain a valid name. + * + * @returns The package name + */ async function readPackageName(): Promise { try { const pkg = JSON.parse(await Bun.file("package.json").text()) as { @@ -74,6 +81,11 @@ async function readPackageName(): Promise { } } +/** + * Reads and validates the package version from `package.json`. + * + * @returns The package version string + */ async function readPackageVersion(): Promise { try { const pkg = JSON.parse(await Bun.file("package.json").text()) as { @@ -97,8 +109,13 @@ async function readPackageVersion(): Promise { } } -/** Bump a version. A prerelease (X.Y.Z-preview.N) bumps its preview number; a - * stable version bumps the requested segment and drops any prerelease suffix. */ +/** + * Calculates the next package version. + * + * @param current - The current stable or preview version + * @param bump - The version segment to increment for stable versions + * @returns The incremented preview version or the requested stable version + */ function bumpVersion( current: string, bump: "patch" | "minor" | "major", @@ -119,6 +136,13 @@ function bumpVersion( } } +/** + * Checks whether a package version exists in the npm registry. + * + * @param packageName - The npm package name + * @param version - The package version to check + * @returns `true` if the version exists, `false` if it is not found + */ async function npmVersionExists( packageName: string, version: string, @@ -140,6 +164,12 @@ async function npmVersionExists( process.exit(1); } +/** + * Retrieves the commit SHA associated with a remote Git tag. + * + * @param tagName - The name of the tag to query + * @returns The tag's commit SHA, or `null` if the tag does not exist remotely + */ async function remoteTagSha(tagName: string): Promise { const result = await runQuiet([ "git", @@ -161,6 +191,12 @@ async function remoteTagSha(tagName: string): Promise { return selected ? (selected.split(/\s+/)[0] ?? null) : null; } +/** + * Checks whether a GitHub Release exists for a tag. + * + * @param tagName - The Git tag associated with the release + * @returns `true` if the release exists, `false` if it was not found + */ async function githubReleaseExists(tagName: string): Promise { const result = await runQuiet([ "gh", @@ -181,6 +217,12 @@ async function githubReleaseExists(tagName: string): Promise { process.exit(1); } +/** + * Ensures that a release version is unused across npm, the remote repository, and GitHub Releases. + * + * @param packageName - The npm package name to check + * @param version - The release version to verify + */ async function assertUnusedReleaseVersion( packageName: string, version: string, @@ -211,6 +253,11 @@ async function assertUnusedReleaseVersion( } } +/** + * Watches the most recent Release workflow run. + * + * Exits with an error if no Release workflow runs are found. + */ async function watchLatest(): Promise { const id = ( await $`gh run list --workflow release.yml --limit 1 --json databaseId -q '.[0].databaseId'`.text() @@ -222,11 +269,24 @@ async function watchLatest(): Promise { await watchRun(id); } +/** + * Watches a Release workflow run until it completes. + * + * @param id - The workflow run identifier + */ async function watchRun(id: string | number): Promise { console.log(`→ watching Release run ${id}`); await $`gh run watch ${String(id)} --exit-status --interval 10`; } +/** + * Waits for the Release workflow run associated with a commit to become available. + * + * @param sha - The commit SHA associated with the workflow run + * @param branch - The branch containing the commit + * @param createdAfterIso - The earliest allowed workflow creation timestamp in ISO format + * @returns The matching GitHub Actions workflow run + */ async function waitForReleaseWorkflowRun( sha: string, branch: string, @@ -260,6 +320,13 @@ async function waitForReleaseWorkflowRun( process.exit(1); } +/** + * Lists workflow runs for a commit, including only manually dispatched runs for the CI workflow. + * + * @param sha - The commit SHA to match. + * @param workflow - The workflow whose runs to list. + * @returns Workflow runs matching the commit and workflow criteria. + */ async function listCiRuns( sha: string, workflow: string = CI_WORKFLOW, @@ -274,6 +341,14 @@ async function listCiRuns( ); } +/** + * Waits for a CI workflow run to complete successfully. + * + * @param sha - The commit SHA to monitor + * @param workflow - The workflow to monitor + * @param label - The label used in status messages + * @returns The successful workflow run + */ async function waitForSuccessfulCi( sha: string, workflow: string = CI_WORKFLOW, @@ -332,7 +407,12 @@ async function _remoteMainSha(): Promise { return sha; } -/** Live (network) head of a remote branch — never the local remote-tracking ref. */ +/** + * Resolves the current commit at the remote branch. + * + * @param branch - The branch name on `origin` + * @returns The commit SHA at the remote branch + */ async function remoteBranchHead(branch: string): Promise { const out = ( await $`git ls-remote origin refs/heads/${branch}`.text() From 1dcf7acc4bc24e8ffde44e867d27267fa4f7c621 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:40:47 +0200 Subject: [PATCH 13/20] fix: preserve live checkout guard executable bit --- scripts/assert-live-checkout-safe.sh | 2 +- tests/live-checkout.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) mode change 100644 => 100755 scripts/assert-live-checkout-safe.sh diff --git a/scripts/assert-live-checkout-safe.sh b/scripts/assert-live-checkout-safe.sh old mode 100644 new mode 100755 index 867c2082..a3be992b --- a/scripts/assert-live-checkout-safe.sh +++ b/scripts/assert-live-checkout-safe.sh @@ -14,7 +14,7 @@ target=${2-} # GNU coreutils exposes `timeout`, while macOS commonly exposes it as # `gtimeout` when coreutils is installed (and otherwise has no equivalent). # Perl is part of macOS and preserves an alarm across exec. Never drop the -# run_git executes a Git command with a 10-second deadline and returns its exit status. +# deadline just because GNU coreutils is absent. run_git() { if command -v timeout >/dev/null 2>&1; then timeout 10s git "$@" diff --git a/tests/live-checkout.test.ts b/tests/live-checkout.test.ts index 01d50d35..435199ce 100644 --- a/tests/live-checkout.test.ts +++ b/tests/live-checkout.test.ts @@ -123,6 +123,12 @@ describe("assert-live-checkout-safe.sh", () => { "../scripts/assert-live-checkout-safe.sh", ); + test("is tracked as an executable script", () => { + const repoRoot = join(import.meta.dir, ".."); + const stage = git(repoRoot, ["ls-files", "--stage", "--", "scripts/assert-live-checkout-safe.sh"]); + expect(stage).toMatch(/^100755 [0-9a-f]{40} 0\tscripts\/assert-live-checkout-safe\.sh$/); + }); + test("refuses dirty porcelain and a HEAD that is not an ancestor of the target", () => { if (!Bun.which("bash")) return; const dir = initRepo(); From 2ee6076b61a10aa5874bd2b10e92686a212f7518 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:41:51 +0200 Subject: [PATCH 14/20] fix(client): canonicalize trusted aliases before containment --- scripts/build-client-artifact.ts | 14 ++++++++++---- tests/client-artifact.test.ts | 19 +++++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index 8152ef68..3dfed703 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -33,7 +33,9 @@ export function isTrustedDarwinSystemPathAlias( platform = process.platform, ) { return ( - platform === "darwin" && path === "/var" && physicalTarget === "/private/var" + platform === "darwin" && + path === "/var" && + physicalTarget === "/private/var" ); } @@ -258,9 +260,13 @@ export const CODEX_CLIENT_SHIM = [ ' [ -n "$path_component" ] || continue', ' path_part="${path_part}/${path_component}"', ' if [ -L "$path_part" ]; then', - ' # macOS exposes /var as the system-owned alias of /private/var. It is the', - ' # sole symlink component allowed here; every other link remains forbidden.', - ' if [ "$path_part" = "/var" ] && [ "$(uname -s)" = "Darwin" ] && [ "$(cd -P -- "$path_part" && pwd -P)" = "/private/var" ]; then continue; fi', + " # macOS exposes /var as the system-owned alias of /private/var. It is the", + " # sole symlink component allowed here; every other link remains forbidden.", + ' if [ "$path_part" = "/var" ] && [ "$(uname -s)" = "Darwin" ] && [ "$(cd -P -- "$path_part" && pwd -P)" = "/private/var" ]; then', + " # Canonicalize even a missing child before native-home containment.", + ' client_home="/private${client_home}"', + " continue", + " fi", ' echo "OCX client-only: refusing symlinked Codex home path $path_part" >&2', " exit 78", " fi", diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 577da5d7..11d9a713 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -382,11 +382,15 @@ describe("remote client artifact", () => { ); const selected = join(home, ".codex-client-test"); + const physicalSelected = join( + realpathSync.native(home), + ".codex-client-test", + ); const selectedRun = Bun.spawnSync([shim, "--version"], { env: { ...env, OCX_CLIENT_CODEX_HOME: selected }, }); expect(selectedRun.exitCode).toBe(0); - expect(readFileSync(capture, "utf8")).toBe(selected + "\n"); + expect(readFileSync(capture, "utf8")).toBe(physicalSelected + "\n"); const nativeRun = Bun.spawnSync([shim, "--version"], { env: { ...env, OCX_CLIENT_CODEX_HOME: nativeHome }, @@ -395,7 +399,7 @@ describe("remote client artifact", () => { expect(nativeRun.stderr.toString()).toContain( "refusing native Codex home", ); - expect(readFileSync(capture, "utf8")).toBe(selected + "\n"); + expect(readFileSync(capture, "utf8")).toBe(physicalSelected + "\n"); expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( "direct Azure config stays untouched\n", ); @@ -410,6 +414,17 @@ describe("remote client artifact", () => { expect(nestedNativeRun.stderr.toString()).toContain( "refusing native Codex home", ); + expect(existsSync(join(nativeHome, "ocx-client"))).toBe(false); + const missingNativeDescendantRun = Bun.spawnSync([shim, "--version"], { + env: { + ...env, + OCX_CLIENT_CODEX_HOME: join(nativeHome, "missing-parent", "client"), + }, + }); + expect(missingNativeDescendantRun.exitCode).toBe(78); + expect(missingNativeDescendantRun.stderr.toString()).toContain( + "refusing native Codex home", + ); expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( "direct Azure config stays untouched\n", ); From b3db530d0a0e414b85dc7dfd844f545cf50dffe9 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:43:24 +0200 Subject: [PATCH 15/20] test(client): share immutable launcher artifact across behavior cases --- tests/client-artifact.test.ts | 419 ++++++++++++++++++---------------- 1 file changed, 219 insertions(+), 200 deletions(-) diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 11d9a713..38c69bbf 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; import { chmodSync, @@ -24,6 +24,15 @@ const scratch = mkdtempSync(join(tmpdir(), "ocx-client-artifact-test-")); const powershell = Bun.which("pwsh"); const posixShell = process.platform !== "win32"; const fixtureWorktrees = new Set(); +// Frozen installs and native process startup are materially slower on Windows. +// Build one immutable launcher fixture; keep each test's homes and commands isolated. +const buildTimeout = process.platform === "win32" ? 120_000 : 30_000; +const launcherTimeout = process.platform === "win32" ? 45_000 : 15_000; +const launcherArtifact = join(scratch, "launcher-candidate"); +let launcherManifest: Awaited>; +beforeAll(async () => { + launcherManifest = await buildClientArtifact(launcherArtifact); +}, buildTimeout); afterAll(() => { for (const destination of fixtureWorktrees) removeDetachedWorktree(destination); @@ -65,22 +74,28 @@ describe("remote client artifact", () => { ).toBe(false); }); - test("CLI builds from an explicit clean source checkout", () => { - const output = join(scratch, "explicit-source-candidate"); - const root = join(import.meta.dir, ".."); - const script = join(root, "scripts/build-client-artifact.ts"); - const result = Bun.spawnSync( - [process.execPath, script, "--output", output, "--source-root", root], - { cwd: scratch }, - ); - expect(result.exitCode).toBe(0); - const sourceSha = Bun.spawnSync(["git", "rev-parse", "HEAD"], { cwd: root }) - .stdout.toString() - .trim(); - expect(readFileSync(join(output, "source-sha"), "utf8")).toBe( - sourceSha + "\n", - ); - }, 15_000); + test( + "CLI builds from an explicit clean source checkout", + () => { + const output = join(scratch, "explicit-source-candidate"); + const root = join(import.meta.dir, ".."); + const script = join(root, "scripts/build-client-artifact.ts"); + const result = Bun.spawnSync( + [process.execPath, script, "--output", output, "--source-root", root], + { cwd: scratch }, + ); + expect(result.exitCode).toBe(0); + const sourceSha = Bun.spawnSync(["git", "rev-parse", "HEAD"], { + cwd: root, + }) + .stdout.toString() + .trim(); + expect(readFileSync(join(output, "source-sha"), "utf8")).toBe( + sourceSha + "\n", + ); + }, + buildTimeout, + ); test("refuses an uncommitted artifact builder", () => { const root = join(import.meta.dir, ".."); @@ -111,186 +126,194 @@ describe("remote client artifact", () => { removeDetachedWorktree(dirtyRoot); }); - test("reinstalls frozen dependencies before bundling", async () => { - const root = join(import.meta.dir, ".."); - const sourceRoot = join(scratch, "dependency-drift-checkout"); - addDetachedWorktree(sourceRoot); - const install = Bun.spawnSync( - [process.execPath, "install", "--frozen-lockfile", "--ignore-scripts"], - { cwd: sourceRoot }, - ); - expect(install.success).toBe(true); - const zodEntry = join(sourceRoot, "node_modules/zod/v4/index.js"); - const originalDependency = readFileSync(zodEntry, "utf8"); - // Bun may hardlink installed package bytes into its shared cache. Unlink the - // fixture entry before tampering so this test cannot poison later installs. - rmSync(zodEntry); - writeFileSync( - zodEntry, - originalDependency + '\nconsole.error("DEPENDENCY_DRIFT_SENTINEL");\n', - ); + test( + "reinstalls frozen dependencies before bundling", + async () => { + const root = join(import.meta.dir, ".."); + const sourceRoot = join(scratch, "dependency-drift-checkout"); + addDetachedWorktree(sourceRoot); + const install = Bun.spawnSync( + [process.execPath, "install", "--frozen-lockfile", "--ignore-scripts"], + { cwd: sourceRoot }, + ); + expect(install.success).toBe(true); + const zodEntry = join(sourceRoot, "node_modules/zod/v4/index.js"); + const originalDependency = readFileSync(zodEntry, "utf8"); + // Bun may hardlink installed package bytes into its shared cache. Unlink the + // fixture entry before tampering so this test cannot poison later installs. + rmSync(zodEntry); + writeFileSync( + zodEntry, + originalDependency + '\nconsole.error("DEPENDENCY_DRIFT_SENTINEL");\n', + ); - const output = join(scratch, "dependency-drift-candidate"); - const manifest = await buildClientArtifact(output, sourceRoot); - expect( - readFileSync(join(output, "src/cli/index.js"), "utf8"), - ).not.toContain("DEPENDENCY_DRIFT_SENTINEL"); - expect(readFileSync(zodEntry, "utf8")).toContain( - "DEPENDENCY_DRIFT_SENTINEL", - ); - const locked = readFileSync(join(sourceRoot, "bun.lock")); - expect(manifest.lockSha256).toBe( - createHash("sha256").update(locked).digest("hex"), - ); - removeDetachedWorktree(sourceRoot); - }, 30_000); + const output = join(scratch, "dependency-drift-candidate"); + const manifest = await buildClientArtifact(output, sourceRoot); + expect( + readFileSync(join(output, "src/cli/index.js"), "utf8"), + ).not.toContain("DEPENDENCY_DRIFT_SENTINEL"); + expect(readFileSync(zodEntry, "utf8")).toContain( + "DEPENDENCY_DRIFT_SENTINEL", + ); + const locked = readFileSync(join(sourceRoot, "bun.lock")); + expect(manifest.lockSha256).toBe( + createHash("sha256").update(locked).digest("hex"), + ); + removeDetachedWorktree(sourceRoot); + }, + buildTimeout, + ); - test("builds a self-contained, SHA-bound candidate without activation", async () => { - const output = join(scratch, "candidate"); - const manifest = await buildClientArtifact(output); - const entry = join(output, "src/cli/index.js"); - const digest = createHash("sha256") - .update(readFileSync(entry)) - .digest("hex"); - const git = (...args: string[]) => { - const result = Bun.spawnSync(["git", ...args], { - cwd: join(import.meta.dir, ".."), - }); - expect(result.success).toBe(true); - return result.stdout.toString().trim(); - }; - const sourceSha = git("rev-parse", "HEAD"); - const packageText = git("show", `${sourceSha}:package.json`) + "\n"; - const lock = readFileSync(join(import.meta.dir, "../bun.lock")); - const builder = readFileSync( - fileURLToPath( - new URL("../scripts/build-client-artifact.ts", import.meta.url), - ), - ); - expect(manifest.sourceSha).toBe(sourceSha); - expect(readFileSync(join(output, "source-sha"), "utf8")).toBe( - manifest.sourceSha + "\n", - ); - expect(readFileSync(join(output, "index.js.sha256"), "utf8")).toBe( - `${digest} src/cli/index.js\n`, - ); - expect(manifest.files["src/cli/index.js"]).toBe(digest); - expect(readFileSync(entry, "utf8")).not.toMatch( - /ocx-client-source-[A-Za-z0-9_-]+/, - ); - expect(manifest.files["package.json"]).toBe( - createHash("sha256").update(packageText).digest("hex"), - ); - expect(manifest.lockSha256).toBe( - createHash("sha256").update(lock).digest("hex"), - ); - expect(manifest.builderSourceSha).toBe(sourceSha); - expect(manifest.builderSha256).toBe( - createHash("sha256").update(builder).digest("hex"), - ); - expect(readFileSync(join(output, "package.json"), "utf8")).toBe( - packageText, - ); - const metadata = JSON.parse( - readFileSync(join(output, "package.json"), "utf8"), - ); - expect(existsSync(join(output, "node_modules"))).toBe(false); - expect(existsSync(join(scratch, "current"))).toBe(false); - const env = { - ...process.env, - OPENCODEX_HOME: join(scratch, "ocx-home"), - CODEX_HOME: join(scratch, "codex-home"), - }; - mkdirSync(env.OPENCODEX_HOME); - mkdirSync(env.CODEX_HOME); - const version = Bun.spawnSync([process.execPath, entry, "--version"], { - env, - cwd: scratch, - }); - expect(version.exitCode).toBe(0); - expect(version.stdout.toString()).toContain( - `opencodex ${metadata.version}`, - ); - expect(readFileSync(entry, "utf8")).toContain("syncExternalOcxCatalog"); - for (const command of [ - ["start"], - ["ensure"], - ["service"], - ["init"], - ["__startup-health"], - ["sync"], - ["sync", "--restart-codex"], - ["sync-cache", "--restart-codex"], - ["v2", "mode", "v2"], - ["recover-history", "--legacy-openai"], - ["codex-shim", "install"], - ["status"], - ["health"], - ]) { - const denied = Bun.spawnSync([process.execPath, entry, ...command], { + test( + "builds a self-contained, SHA-bound candidate without activation", + async () => { + const output = join(scratch, "candidate"); + const manifest = await buildClientArtifact(output); + const entry = join(output, "src/cli/index.js"); + const digest = createHash("sha256") + .update(readFileSync(entry)) + .digest("hex"); + const git = (...args: string[]) => { + const result = Bun.spawnSync(["git", ...args], { + cwd: join(import.meta.dir, ".."), + }); + expect(result.success).toBe(true); + return result.stdout.toString().trim(); + }; + const sourceSha = git("rev-parse", "HEAD"); + const packageText = git("show", `${sourceSha}:package.json`) + "\n"; + const lock = readFileSync(join(import.meta.dir, "../bun.lock")); + const builder = readFileSync( + fileURLToPath( + new URL("../scripts/build-client-artifact.ts", import.meta.url), + ), + ); + expect(manifest.sourceSha).toBe(sourceSha); + expect(readFileSync(join(output, "source-sha"), "utf8")).toBe( + manifest.sourceSha + "\n", + ); + expect(readFileSync(join(output, "index.js.sha256"), "utf8")).toBe( + `${digest} src/cli/index.js\n`, + ); + expect(manifest.files["src/cli/index.js"]).toBe(digest); + expect(readFileSync(entry, "utf8")).not.toMatch( + /ocx-client-source-[A-Za-z0-9_-]+/, + ); + expect(manifest.files["package.json"]).toBe( + createHash("sha256").update(packageText).digest("hex"), + ); + expect(manifest.lockSha256).toBe( + createHash("sha256").update(lock).digest("hex"), + ); + expect(manifest.builderSourceSha).toBe(sourceSha); + expect(manifest.builderSha256).toBe( + createHash("sha256").update(builder).digest("hex"), + ); + expect(readFileSync(join(output, "package.json"), "utf8")).toBe( + packageText, + ); + const metadata = JSON.parse( + readFileSync(join(output, "package.json"), "utf8"), + ); + expect(existsSync(join(output, "node_modules"))).toBe(false); + expect(existsSync(join(scratch, "current"))).toBe(false); + const env = { + ...process.env, + OPENCODEX_HOME: join(scratch, "ocx-home"), + CODEX_HOME: join(scratch, "codex-home"), + }; + mkdirSync(env.OPENCODEX_HOME); + mkdirSync(env.CODEX_HOME); + const version = Bun.spawnSync([process.execPath, entry, "--version"], { env, cwd: scratch, }); - expect(denied.exitCode).toBe(64); - expect(denied.stderr.toString()).toContain( - "local lifecycle commands are disabled", - ); - } - const staleShimHome = join(scratch, "stale-shim-home"); - const staleShimBin = join(scratch, "stale-shim-bin"); - const staleWrapper = join(staleShimBin, "codex"); - const staleBackup = join(staleShimBin, "codex.opencodex-real"); - const staleReplacement = - "replacement that direct artifact status must not promote\n"; - mkdirSync(staleShimHome); - mkdirSync(staleShimBin); - writeFileSync(staleWrapper, staleReplacement); - writeFileSync(staleBackup, "known-good prior launcher\n"); - writeFileSync( - join(staleShimHome, "codex-shim.json"), - `${JSON.stringify({ - platform: process.platform, - wrapperPath: staleWrapper, - originalPath: staleWrapper, - backupPath: staleBackup, - })}\n`, - ); - const staleState = readFileSync(join(staleShimHome, "codex-shim.json")); - const staleRun = Bun.spawnSync([process.execPath, entry, "status"], { - env: { ...env, OPENCODEX_HOME: staleShimHome, PATH: staleShimBin }, - cwd: scratch, - }); - expect(staleRun.exitCode).toBe(64); - expect(readFileSync(staleWrapper, "utf8")).toBe(staleReplacement); - expect(readFileSync(staleBackup, "utf8")).toBe( - "known-good prior launcher\n", - ); - expect(readFileSync(join(staleShimHome, "codex-shim.json"))).toEqual( - staleState, - ); - expect(existsSync(join(scratch, "ocx-home", "proxy.pid"))).toBe(false); - await expect(buildClientArtifact(output)).rejects.toThrow( - "Destination already exists", - ); - expect(createHash("sha256").update(readFileSync(entry)).digest("hex")).toBe( - digest, - ); + expect(version.exitCode).toBe(0); + expect(version.stdout.toString()).toContain( + `opencodex ${metadata.version}`, + ); + expect(readFileSync(entry, "utf8")).toContain("syncExternalOcxCatalog"); + for (const command of [ + ["start"], + ["ensure"], + ["service"], + ["init"], + ["__startup-health"], + ["sync"], + ["sync", "--restart-codex"], + ["sync-cache", "--restart-codex"], + ["v2", "mode", "v2"], + ["recover-history", "--legacy-openai"], + ["codex-shim", "install"], + ["status"], + ["health"], + ]) { + const denied = Bun.spawnSync([process.execPath, entry, ...command], { + env, + cwd: scratch, + }); + expect(denied.exitCode).toBe(64); + expect(denied.stderr.toString()).toContain( + "local lifecycle commands are disabled", + ); + } + const staleShimHome = join(scratch, "stale-shim-home"); + const staleShimBin = join(scratch, "stale-shim-bin"); + const staleWrapper = join(staleShimBin, "codex"); + const staleBackup = join(staleShimBin, "codex.opencodex-real"); + const staleReplacement = + "replacement that direct artifact status must not promote\n"; + mkdirSync(staleShimHome); + mkdirSync(staleShimBin); + writeFileSync(staleWrapper, staleReplacement); + writeFileSync(staleBackup, "known-good prior launcher\n"); + writeFileSync( + join(staleShimHome, "codex-shim.json"), + `${JSON.stringify({ + platform: process.platform, + wrapperPath: staleWrapper, + originalPath: staleWrapper, + backupPath: staleBackup, + })}\n`, + ); + const staleState = readFileSync(join(staleShimHome, "codex-shim.json")); + const staleRun = Bun.spawnSync([process.execPath, entry, "status"], { + env: { ...env, OPENCODEX_HOME: staleShimHome, PATH: staleShimBin }, + cwd: scratch, + }); + expect(staleRun.exitCode).toBe(64); + expect(readFileSync(staleWrapper, "utf8")).toBe(staleReplacement); + expect(readFileSync(staleBackup, "utf8")).toBe( + "known-good prior launcher\n", + ); + expect(readFileSync(join(staleShimHome, "codex-shim.json"))).toEqual( + staleState, + ); + expect(existsSync(join(scratch, "ocx-home", "proxy.pid"))).toBe(false); + await expect(buildClientArtifact(output)).rejects.toThrow( + "Destination already exists", + ); + expect( + createHash("sha256").update(readFileSync(entry)).digest("hex"), + ).toBe(digest); - const duplicate = join(scratch, "duplicate"); - await buildClientArtifact(duplicate); - expect(readFileSync(join(duplicate, "src/cli/index.js"))).toEqual( - readFileSync(entry), - ); - expect(readFileSync(join(duplicate, "artifact-manifest.json"))).toEqual( - readFileSync(join(output, "artifact-manifest.json")), - ); - }, 30_000); + const duplicate = join(scratch, "duplicate"); + await buildClientArtifact(duplicate); + expect(readFileSync(join(duplicate, "src/cli/index.js"))).toEqual( + readFileSync(entry), + ); + expect(readFileSync(join(duplicate, "artifact-manifest.json"))).toEqual( + readFileSync(join(output, "artifact-manifest.json")), + ); + }, + buildTimeout, + ); test.skipIf(!posixShell)( "ships a client shim that cannot select the native Codex home", async () => { - const output = join(scratch, "shim-candidate"); - const manifest = await buildClientArtifact(output); + const output = launcherArtifact; + const manifest = launcherManifest; const shim = join(output, "bin/codex.ocx-client"); const powershellShim = join(output, "bin/codex.ocx-client.ps1"); const home = join(scratch, "shim-home"); @@ -429,14 +452,13 @@ describe("remote client artifact", () => { "direct Azure config stays untouched\n", ); }, - 15_000, + launcherTimeout, ); test.skipIf(!posixShell)( "refuses the physical target of a symlinked native Codex home", async () => { - const output = join(scratch, "symlinked-native-candidate"); - await buildClientArtifact(output); + const output = launcherArtifact; const shim = join(output, "bin/codex.ocx-client"); const home = join(scratch, "symlinked-native-home"); const nativeHome = join(home, ".codex"); @@ -462,14 +484,13 @@ describe("remote client artifact", () => { "native config\n", ); }, - 15_000, + launcherTimeout, ); test.skipIf(!powershell)( "PowerShell preserves the governed proxy failure exit code", async () => { - const output = join(scratch, "powershell-proxy-failure-candidate"); - await buildClientArtifact(output); + const output = launcherArtifact; const shim = join(output, "bin/codex.ocx-client.ps1"); const home = join(scratch, "powershell-proxy-failure-home"); const failingOcx = join(scratch, "failing-ocx.ps1"); @@ -488,14 +509,13 @@ describe("remote client artifact", () => { "central OCX proxy unavailable through the governed remote launcher", ); }, - 15_000, + launcherTimeout, ); test.skipIf(!powershell)( "PowerShell refuses the physical target of a symlinked native Codex home", async () => { - const output = join(scratch, "powershell-symlinked-native-candidate"); - await buildClientArtifact(output); + const output = launcherArtifact; const shim = join(output, "bin/codex.ocx-client.ps1"); const home = join(scratch, "powershell-symlinked-native-home"); const nativeHome = join(home, ".codex"); @@ -522,14 +542,13 @@ describe("remote client artifact", () => { "native config\n", ); }, - 30_000, + launcherTimeout, ); test.skipIf(!powershell)( "PowerShell refuses a client home nested under the native Codex home", async () => { - const output = join(scratch, "powershell-nested-native-candidate"); - await buildClientArtifact(output); + const output = launcherArtifact; const shim = join(output, "bin/codex.ocx-client.ps1"); const home = join(scratch, "powershell-nested-native-home"); const nativeHome = join(home, ".codex"); @@ -553,7 +572,7 @@ describe("remote client artifact", () => { "native config\n", ); }, - 15_000, + launcherTimeout, ); test("refuses publication through a symlinked destination parent", async () => { From 6022ddcc5db09d0d165464207c2b8815658552a6 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:43:55 +0000 Subject: [PATCH 16/20] Add regression tests for release and checkout safeguards --- tests/client-artifact.test.ts | 41 +++++++++++++++++++++++++++++++++++ tests/live-checkout.test.ts | 38 ++++++++++++++++++++++++++++++++ tests/release-helper.test.ts | 24 ++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 577da5d7..7ce17530 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -145,6 +145,47 @@ describe("remote client artifact", () => { removeDetachedWorktree(sourceRoot); }, 30_000); + test("removes the detached worktree when dependency installation fails", async () => { + const sourceRoot = join(scratch, "broken-dependency-checkout"); + mkdirSync(sourceRoot); + const runGit = (...args: string[]) => + Bun.spawnSync(["git", ...args], { + cwd: sourceRoot, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "ocx-test", + GIT_AUTHOR_EMAIL: "ocx-test@example.test", + GIT_COMMITTER_NAME: "ocx-test", + GIT_COMMITTER_EMAIL: "ocx-test@example.test", + }, + }); + expect(runGit("init", "-b", "main").success).toBe(true); + writeFileSync( + join(sourceRoot, "package.json"), + '{"name":"broken-artifact-fixture","version":"1.0.0"}\n', + ); + writeFileSync(join(sourceRoot, "bun.lock"), "not a valid Bun lockfile\n"); + expect(runGit("add", "package.json", "bun.lock").success).toBe(true); + expect(runGit("commit", "-m", "fixture").success).toBe(true); + + const listedWorktrees = () => + runGit("worktree", "list", "--porcelain") + .stdout.toString() + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => line.slice("worktree ".length)); + const before = listedWorktrees(); + const destination = join(scratch, "broken-dependency-output"); + + await expect(buildClientArtifact(destination, sourceRoot)).rejects.toThrow( + "Locked dependency refresh failed", + ); + expect(listedWorktrees()).toEqual(before); + expect(existsSync(destination)).toBe(false); + }); + test("builds a self-contained, SHA-bound candidate without activation", async () => { const output = join(scratch, "candidate"); const manifest = await buildClientArtifact(output); diff --git a/tests/live-checkout.test.ts b/tests/live-checkout.test.ts index 01d50d35..6c8d976b 100644 --- a/tests/live-checkout.test.ts +++ b/tests/live-checkout.test.ts @@ -212,6 +212,44 @@ describe("assert-live-checkout-safe.sh", () => { } }); + test("uses gtimeout before Perl when GNU timeout is absent", () => { + const bash = Bun.which("bash"); + const realGit = Bun.which("git"); + const realTimeout = Bun.which("timeout"); + expect(bash).not.toBeNull(); + expect(realGit).not.toBeNull(); + expect(realTimeout).not.toBeNull(); + const dir = initRepo(); + const binDir = mkdtempSync(join(tmpdir(), "ocx-gtimeout-fallback-")); + const log = join(binDir, "calls"); + const quote = (path: string) => + `'${path.replaceAll("\\", "/").replaceAll("'", "'\\''")}'`; + try { + writeFileSync( + join(binDir, "git"), + `#!/bin/sh\nexec ${quote(realGit!)} "$@"\n`, + ); + writeFileSync( + join(binDir, "gtimeout"), + `#!/bin/sh\nprintf '%s\\n' "$@" >> ${quote(log)}\nexec ${quote(realTimeout!)} "$@"\n`, + ); + chmodSync(join(binDir, "git"), 0o755); + chmodSync(join(binDir, "gtimeout"), 0o755); + const result = Bun.spawnSync([bash!, script, dir], { + env: { ...process.env, PATH: binDir }, + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(readFileSync(log, "utf8")).toContain( + "10s\ngit\nrev-parse\n--is-inside-work-tree\n", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(binDir, { recursive: true, force: true }); + } + }); + test("exits 2 when git status probe fails without printing porcelain", () => { if (!Bun.which("bash")) return; const dir = initRepo(); diff --git a/tests/release-helper.test.ts b/tests/release-helper.test.ts index 4ccf0359..9d9576b1 100644 --- a/tests/release-helper.test.ts +++ b/tests/release-helper.test.ts @@ -319,6 +319,30 @@ describe("release helper", () => { expect(dispatchIndex).toBeGreaterThan(fullCiIndex); }); + test("requests CI event metadata after dispatching the full-platform run", () => { + const { calls, result } = runRelease("9.9.9"); + expect(result.status).toBe(0); + + const dispatchIndex = findCallIndex( + calls, + "gh", + (call) => call.args.join(" ") === "workflow run ci.yml --ref main", + ); + const listIndex = findCallIndex( + calls, + "gh", + (call) => + call.args[0] === "run" && + call.args[1] === "list" && + call.args.includes("ci.yml"), + ); + expect(dispatchIndex).toBeGreaterThanOrEqual(0); + expect(listIndex).toBeGreaterThan(dispatchIndex); + const jsonFields = + calls[listIndex]!.args[calls[listIndex]!.args.indexOf("--json") + 1]; + expect(jsonFields?.split(",")).toContain("event"); + }); + test("failed privacy scan aborts before version bump, commit, and push", () => { const { calls, result } = runRelease("9.9.9", { privacyExitCode: 1 }); From 1470870cdb892cdfc26aa70e22339178d5fb0ce9 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:45:12 +0200 Subject: [PATCH 17/20] fix(ci): run every test batch while preserving failures --- scripts/ci-test-shard.ts | 96 +++++++++++++++++++++++++----------- tests/ci-test-shard.test.ts | 97 ++++++++++++++++++++++++++++--------- 2 files changed, 143 insertions(+), 50 deletions(-) diff --git a/scripts/ci-test-shard.ts b/scripts/ci-test-shard.ts index e78b43e9..7838357a 100644 --- a/scripts/ci-test-shard.ts +++ b/scripts/ci-test-shard.ts @@ -19,7 +19,7 @@ export async function collectTestFiles(directory: string): Promise { for (const entry of entries) { const absolute = join(directory, entry.name); if (entry.isDirectory()) { - files.push(...await collectTestFiles(absolute)); + files.push(...(await collectTestFiles(absolute))); continue; } if (!entry.isFile() || !TEST_FILE.test(entry.name)) continue; @@ -35,8 +35,10 @@ export async function collectTestFiles(directory: string): Promise { export function parseInteger(value: string | undefined, name: string): number { // Number("") coerces to 0, so a blank matrix value must be rejected explicitly. - const parsed = value === undefined || value.trim() === "" ? Number.NaN : Number(value); - if (!Number.isInteger(parsed)) throw new Error(`${name} must be an integer; got ${value ?? ""}`); + const parsed = + value === undefined || value.trim() === "" ? Number.NaN : Number(value); + if (!Number.isInteger(parsed)) + throw new Error(`${name} must be an integer; got ${value ?? ""}`); return parsed; } @@ -53,18 +55,26 @@ export function parseShardSelection( const shardCount = parseInteger(shardCountRaw, "shardCount"); if (shardCount < 1) throw new Error("shardCount must be at least 1"); if (shardIndex < 0 || shardIndex >= shardCount) { - throw new Error(`shardIndex must be between 0 and ${shardCount - 1}; got ${shardIndex}`); + throw new Error( + `shardIndex must be between 0 and ${shardCount - 1}; got ${shardIndex}`, + ); } return { shardIndex, shardCount }; } -export function assignBalancedShards(files: TestFile[], shardCount: number): TestFile[][] { +export function assignBalancedShards( + files: TestFile[], + shardCount: number, +): TestFile[][] { const shards = Array.from({ length: shardCount }, () => [] as TestFile[]); const totals = Array.from({ length: shardCount }, () => 0); // File size is a stable, repository-local proxy for test cost. Greedy assignment avoids the // severe imbalance produced by alphabetical modulo sharding while remaining deterministic. - const ordered = [...files].sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path)); + const ordered = [...files].sort( + (left, right) => + right.bytes - left.bytes || left.path.localeCompare(right.path), + ); for (const file of ordered) { let target = 0; for (let index = 1; index < shardCount; index += 1) { @@ -74,30 +84,63 @@ export function assignBalancedShards(files: TestFile[], shardCount: number): Tes totals[target] += file.bytes; } - for (const shard of shards) shard.sort((left, right) => left.path.localeCompare(right.path)); + for (const shard of shards) + shard.sort((left, right) => left.path.localeCompare(right.path)); return shards; } -async function runBatch(paths: string[], env: Record): Promise { +async function runBatch( + paths: string[], + env: Record, +): Promise { // Match the canonical scripts/test.ts orchestration: spawn the current Bun binary and run // against an isolated HOME so shards never read or mutate the runner's real configuration. // Bun treats bare positional test arguments as substring filters; a "./" prefix forces each // argument to be resolved as an exact file path so a batch never pulls in unrelated tests. - const child = Bun.spawn([process.execPath, "test", "--isolate", ...paths.map(path => `./${path}`)], { - cwd: process.cwd(), - env, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - }); + const child = Bun.spawn( + [ + process.execPath, + "test", + "--isolate", + ...paths.map((path) => `./${path}`), + ], + { + cwd: process.cwd(), + env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }, + ); return await child.exited; } +export async function runAllBatches( + paths: string[], + execute: (batch: string[]) => Promise, +): Promise { + let result = 0; + for (let offset = 0; offset < paths.length; offset += BATCH_SIZE) { + const batch = paths.slice(offset, offset + BATCH_SIZE); + console.log( + `[ci-test-shard] batch ${Math.floor(offset / BATCH_SIZE) + 1}: ${batch.length} files`, + ); + const exitCode = await execute(batch); + // Preserve failure, but collect the remaining platform evidence in this run. + if (exitCode !== 0 && result === 0) result = exitCode; + } + return result; +} + async function main(): Promise { - const { shardIndex, shardCount } = parseShardSelection(Bun.argv[2], Bun.argv[3]); + const { shardIndex, shardCount } = parseShardSelection( + Bun.argv[2], + Bun.argv[3], + ); const files = await collectTestFiles(TEST_ROOT); - if (files.length === 0) throw new Error("No Bun test files found under tests/"); + if (files.length === 0) + throw new Error("No Bun test files found under tests/"); const shards = assignBalancedShards(files, shardCount); const selected = shards[shardIndex]!; @@ -108,23 +151,20 @@ async function main(): Promise { const isolated = createIsolatedTestEnvironment(); try { - for (let offset = 0; offset < selected.length; offset += BATCH_SIZE) { - const batch = selected.slice(offset, offset + BATCH_SIZE).map(file => file.path); - console.log(`[ci-test-shard] batch ${Math.floor(offset / BATCH_SIZE) + 1}: ${batch.length} files`); - const exitCode = await runBatch(batch, isolated.env); - if (exitCode !== 0) { - process.exitCode = exitCode; - return; - } - } + process.exitCode = await runAllBatches( + selected.map((file) => file.path), + (batch) => runBatch(batch, isolated.env), + ); } finally { isolated.cleanup(); } } if (import.meta.main) { - main().catch(error => { - console.error(error instanceof Error ? error.stack ?? error.message : String(error)); + main().catch((error) => { + console.error( + error instanceof Error ? (error.stack ?? error.message) : String(error), + ); process.exit(1); }); } diff --git a/tests/ci-test-shard.test.ts b/tests/ci-test-shard.test.ts index 6bc5201f..a968fa46 100644 --- a/tests/ci-test-shard.test.ts +++ b/tests/ci-test-shard.test.ts @@ -5,10 +5,36 @@ import { collectTestFiles, parseInteger, parseShardSelection, + runAllBatches, TEST_ROOT, type TestFile, } from "../scripts/ci-test-shard"; +describe("ci-test-shard batch completion", () => { + test.each([ + [1, 0, 0], + [0, 2, 0], + [0, 0, 0], + ])( + "runs every batch and preserves nonzero results %j", + async (...codes: number[]) => { + const paths = Array.from( + { length: 161 }, + (_, i) => `tests/fixture-${i}.test.ts`, + ); + const visited: string[] = []; + let index = 0; + const result = await runAllBatches(paths, async (batch) => { + visited.push(...batch); + return codes[index++]!; + }); + expect(visited).toEqual(paths); + expect(index).toBe(3); + expect(result).toBe(codes.find((code) => code !== 0) ?? 0); + }, + ); +}); + // CI relies on the shard partitioner to run every root test exactly once across the configured // shard invocations (Windows currently uses two). A regression in discovery, path normalization, // or assignment would silently skip or duplicate tests on the sharded platforms only, so pin the @@ -18,21 +44,26 @@ describe("ci-test-shard partition invariants", () => { test("discovers this test file among the root tests", async () => { const files = await collectTestFiles(TEST_ROOT); - expect(files.map(file => file.path)).toContain("tests/ci-test-shard.test.ts"); + expect(files.map((file) => file.path)).toContain( + "tests/ci-test-shard.test.ts", + ); }); - test.each(shardCounts)("%i shard(s) cover every discovered test exactly once", async shardCount => { - const files = await collectTestFiles(TEST_ROOT); - expect(files.length).toBeGreaterThan(0); + test.each(shardCounts)( + "%i shard(s) cover every discovered test exactly once", + async (shardCount) => { + const files = await collectTestFiles(TEST_ROOT); + expect(files.length).toBeGreaterThan(0); - const shards = assignBalancedShards(files, shardCount); - expect(shards.length).toBe(shardCount); + const shards = assignBalancedShards(files, shardCount); + expect(shards.length).toBe(shardCount); - const union = shards.flat().map(file => file.path); - expect(union.length).toBe(files.length); - expect(new Set(union).size).toBe(union.length); - expect([...union].sort()).toEqual(files.map(file => file.path).sort()); - }); + const union = shards.flat().map((file) => file.path); + expect(union.length).toBe(files.length); + expect(new Set(union).size).toBe(union.length); + expect([...union].sort()).toEqual(files.map((file) => file.path).sort()); + }, + ); test("assignment is deterministic regardless of discovery order", () => { const files: TestFile[] = [ @@ -43,8 +74,12 @@ describe("ci-test-shard partition invariants", () => { ]; const shuffled = [files[2]!, files[0]!, files[3]!, files[1]!]; - const fromOrdered = assignBalancedShards(files, 2).map(shard => shard.map(file => file.path)); - const fromShuffled = assignBalancedShards(shuffled, 2).map(shard => shard.map(file => file.path)); + const fromOrdered = assignBalancedShards(files, 2).map((shard) => + shard.map((file) => file.path), + ); + const fromShuffled = assignBalancedShards(shuffled, 2).map((shard) => + shard.map((file) => file.path), + ); expect(fromShuffled).toEqual(fromOrdered); }); }); @@ -53,12 +88,19 @@ describe("ci-test-shard partition invariants", () => { // instead of silently running the wrong slice of the suite, so pin the CLI validation branches. describe("ci-test-shard argument validation", () => { test("parseInteger rejects missing values", () => { - expect(() => parseInteger(undefined, "shardIndex")).toThrow("shardIndex must be an integer; got "); + expect(() => parseInteger(undefined, "shardIndex")).toThrow( + "shardIndex must be an integer; got ", + ); }); - test.each(["", " ", "two", "1.5", "NaN"])("parseInteger rejects non-integer value %j", value => { - expect(() => parseInteger(value, "shardCount")).toThrow(/shardCount must be an integer/); - }); + test.each(["", " ", "two", "1.5", "NaN"])( + "parseInteger rejects non-integer value %j", + (value) => { + expect(() => parseInteger(value, "shardCount")).toThrow( + /shardCount must be an integer/, + ); + }, + ); test("parseInteger accepts integer strings", () => { expect(parseInteger("0", "shardIndex")).toBe(0); @@ -66,16 +108,27 @@ describe("ci-test-shard argument validation", () => { }); test("parseShardSelection rejects shardCount below 1", () => { - expect(() => parseShardSelection("0", "0")).toThrow("shardCount must be at least 1"); - expect(() => parseShardSelection("0", "-2")).toThrow("shardCount must be at least 1"); + expect(() => parseShardSelection("0", "0")).toThrow( + "shardCount must be at least 1", + ); + expect(() => parseShardSelection("0", "-2")).toThrow( + "shardCount must be at least 1", + ); }); test("parseShardSelection rejects out-of-range shardIndex", () => { - expect(() => parseShardSelection("-1", "2")).toThrow("shardIndex must be between 0 and 1; got -1"); - expect(() => parseShardSelection("2", "2")).toThrow("shardIndex must be between 0 and 1; got 2"); + expect(() => parseShardSelection("-1", "2")).toThrow( + "shardIndex must be between 0 and 1; got -1", + ); + expect(() => parseShardSelection("2", "2")).toThrow( + "shardIndex must be between 0 and 1; got 2", + ); }); test("parseShardSelection accepts a valid in-range configuration", () => { - expect(parseShardSelection("1", "2")).toEqual({ shardIndex: 1, shardCount: 2 }); + expect(parseShardSelection("1", "2")).toEqual({ + shardIndex: 1, + shardCount: 2, + }); }); }); From 38d5b665098c0ff481a169f8bd99f5fe9b30d169 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:43:00 +0200 Subject: [PATCH 18/20] test: use native Windows release helper fixtures --- tests/release-helper.test.ts | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/tests/release-helper.test.ts b/tests/release-helper.test.ts index 4ccf0359..6bd7825d 100644 --- a/tests/release-helper.test.ts +++ b/tests/release-helper.test.ts @@ -181,18 +181,27 @@ function installCommandShim( ): void { const jsPath = join(binDir, `${name}.js`); const launcherPath = join(binDir, name); - const cmdPath = join(binDir, `${name}.cmd`); writeFileSync(jsPath, shimProgramSource(name), "utf8"); + if (process.platform === "win32") { + // Bun 1.4 rejects valid peeled Git refs ending in `^{}` before a .cmd + // shim can receive them. A native fixture executable bypasses cmd.exe so + // the release helper still exercises its exact peeled-ref validation. + const nativePath = `${launcherPath}.exe`; + const compiled = spawnSync( + process.execPath, + ["build", jsPath, "--compile", "--outfile", nativePath], + { encoding: "utf8" }, + ); + if (compiled.status !== 0) { + throw new Error(`failed to compile ${name} release fixture: ${compiled.stderr}`); + } + return; + } writeExecutable( launcherPath, `#!${process.execPath}\nimport "./${name}.js";\n`, ); - writeFileSync( - cmdPath, - `@echo off\r\n"${process.execPath}" "%~dp0\\${name}.js" %*\r\n`, - "utf8", - ); } function readLoggedCalls(logPath: string): LoggedCall[] { @@ -304,11 +313,20 @@ describe("release helper", () => { call.args.includes("tag=latest") && call.args.includes("dry-run=true"), ); + const tagLookupIndex = findCallIndex( + calls, + "git", + (call) => + call.args[0] === "ls-remote" && + call.args.includes("refs/tags/v9.9.9") && + call.args.includes("refs/tags/v9.9.9^{}"), + ); expect(typecheckIndex).toBeGreaterThanOrEqual(0); expect(testIndex).toBeGreaterThan(typecheckIndex); expect(privacyIndex).toBeGreaterThan(testIndex); expect(versionIndex).toBeGreaterThan(privacyIndex); + expect(tagLookupIndex).toBeGreaterThanOrEqual(0); expect(dispatchIndex).toBeGreaterThan(versionIndex); const fullCiIndex = findCallIndex( calls, From df28f60ec5548459a1e91b876a987697309e614f Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:44:52 +0200 Subject: [PATCH 19/20] test: cache native release helper fixtures --- tests/release-helper.test.ts | 41 ++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/tests/release-helper.test.ts b/tests/release-helper.test.ts index 6bd7825d..253bbc24 100644 --- a/tests/release-helper.test.ts +++ b/tests/release-helper.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; import { chmodSync, + copyFileSync, mkdtempSync, readFileSync, rmSync, @@ -17,6 +18,8 @@ const repoRoot = dirname( fileURLToPath(new URL("../package.json", import.meta.url)), ); const releaseScriptPath = join(repoRoot, "scripts", "release.ts"); +const fixtureCommandNames = ["bun", "gh", "git", "npm"] as const; +let nativeFixtureDir: string | null = null; interface LoggedCall { args: string[]; @@ -39,6 +42,29 @@ function writeExecutable(path: string, contents: string): void { chmodSync(path, 0o755); } +beforeAll(() => { + if (process.platform !== "win32") return; + nativeFixtureDir = mkdtempSync(join(tmpdir(), "ocx-release-helper-native-")); + for (const name of fixtureCommandNames) { + const sourcePath = join(nativeFixtureDir, `${name}.js`); + const executablePath = join(nativeFixtureDir, `${name}.exe`); + writeFileSync(sourcePath, shimProgramSource(name), "utf8"); + const compiled = spawnSync( + process.execPath, + ["build", sourcePath, "--compile", "--outfile", executablePath], + { encoding: "utf8" }, + ); + if (compiled.status !== 0) { + throw new Error(`failed to compile ${name} release fixture: ${compiled.stderr}`); + } + } +}, 120_000); + +afterAll(() => { + if (nativeFixtureDir) rmSync(nativeFixtureDir, { recursive: true, force: true }); + nativeFixtureDir = null; +}); + function shimProgramSource(name: "bun" | "gh" | "git" | "npm"): string { if (name === "bun") { return `import { appendFileSync } from "node:fs"; @@ -182,22 +208,15 @@ function installCommandShim( const jsPath = join(binDir, `${name}.js`); const launcherPath = join(binDir, name); - writeFileSync(jsPath, shimProgramSource(name), "utf8"); if (process.platform === "win32") { // Bun 1.4 rejects valid peeled Git refs ending in `^{}` before a .cmd // shim can receive them. A native fixture executable bypasses cmd.exe so // the release helper still exercises its exact peeled-ref validation. - const nativePath = `${launcherPath}.exe`; - const compiled = spawnSync( - process.execPath, - ["build", jsPath, "--compile", "--outfile", nativePath], - { encoding: "utf8" }, - ); - if (compiled.status !== 0) { - throw new Error(`failed to compile ${name} release fixture: ${compiled.stderr}`); - } + if (!nativeFixtureDir) throw new Error("native release fixtures were not initialized"); + copyFileSync(join(nativeFixtureDir, `${name}.exe`), `${launcherPath}.exe`); return; } + writeFileSync(jsPath, shimProgramSource(name), "utf8"); writeExecutable( launcherPath, `#!${process.execPath}\nimport "./${name}.js";\n`, From ab55b7814654478f03416a5e901fe02c5870c846 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 07:51:04 +0200 Subject: [PATCH 20/20] test: cancel delayed SSE fixture timers --- tests/fetch-header-timeout.test.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/fetch-header-timeout.test.ts b/tests/fetch-header-timeout.test.ts index 89d7c578..c436b4f8 100644 --- a/tests/fetch-header-timeout.test.ts +++ b/tests/fetch-header-timeout.test.ts @@ -30,16 +30,24 @@ async function observedEncoding(headers: HeadersInit | undefined, streaming: boo return response.text(); } -function delayedSseStream(delayMs = 80): ReadableStream { +function delayedSseStream( + delayMs = 80, + onSecondFrame?: () => void, +): ReadableStream { const encoder = new TextEncoder(); + let timer: ReturnType | undefined; return new ReadableStream({ start(controller) { controller.enqueue(encoder.encode("data: first\n\n")); - setTimeout(() => { + timer = setTimeout(() => { + onSecondFrame?.(); controller.enqueue(encoder.encode("data: second\n\n")); controller.close(); }, delayMs); }, + cancel() { + if (timer !== undefined) clearTimeout(timer); + }, }); } @@ -82,6 +90,17 @@ describe("fetchWithHeaderTimeout content-encoding policy", () => { expect(await observedEncoding(new Headers({ "ACCEPT-ENCODING": "deflate" }), true)).toBe("deflate"); }); + test("cancelling an SSE reader clears its delayed fixture enqueue", async () => { + let secondFrameEnqueued = false; + const reader = delayedSseStream(20, () => { + secondFrameEnqueued = true; + }).getReader(); + expect(await readChunk(reader)).toBe("data: first\n\n"); + await reader.cancel(); + await Bun.sleep(40); + expect(secondFrameEnqueued).toBe(false); + }); + test("identity keeps SSE frames incremental instead of waiting for a gzip block", async () => { const server = startCompressionAwareSseServer();