From 3c02a77fd53b961e39aaa2b7ac0bac3971b2f2f4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 20 Aug 2026 19:11:22 +0800 Subject: [PATCH 1/3] ci(windows): scope the sandbox W0 lane to what owns the sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lane's `paths` filter had grown to `packages/runtime/**` plus `packages/core/**`, which put a windows-2025 job — capped at 25 minutes — on the pull request for 21 of the last 60 first-parent commits. Exactly 1 of those 60 touched anything this evidence can observe. It had no concurrency group either, so every push to those pull requests started another one. The filter grew that way because it was asked to be a correctness claim, and it cannot be one: the lane's real input is the import closure of packages/runtime/src/sandbox and packages/runtime/src/filesystem-worker, which reaches 9 files at the top of packages/runtime/src and 5 @maka/core modules, inside source roots holding more than 130 top-level files each. No hand-written list is that closure, and one that tried would drift silently. So stop claiming it is one. The filter now names the directories that own the sandbox — matching 1 of those same 60 commits, so a change there still blocks before merge — and a nightly run covers the transitive edits the filter cannot match, after they land. Three packaging inputs came out of the list at the same time: no step here reads them, and release-windows-check already owns the packaged sandbox. The lane stays separate from windows-baseline. Baseline sets continue-on-error at the job level and on each diagnostic step, so a failure there does not fail the workflow; it carries a known Phase 1 backlog. Folding these assertions in would trade a regression signal for one fewer file. Give it the concurrency group it never had, keyed so that pull request pushes supersede each other while scheduled and manual runs each get a unique group and cannot discard one another, and cache npm now that it also installs on a schedule. The trigger allowlist that replaces the old contract reads each workflow's `on:` block rather than naming lanes that must stay off pull requests. The previous version named three and omitted this one, which is how the lane kept an unbounded trigger through a CI reduction that touched every workflow around it; matching the block also means `on: [pull_request]` cannot slip past it. The second contract pins both halves of the design, because a filter without the nightly run silently loses the transitive edits, and a nightly run without the filter puts the whole runtime back on pull requests. Generated-by: Claude Code --- .github/workflows/windows-sandbox-w0.yml | 21 ++++++++--- scripts/ci-test-plan.test.mjs | 44 +++++++++++++++++++++--- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/.github/workflows/windows-sandbox-w0.yml b/.github/workflows/windows-sandbox-w0.yml index 605da92129..0d8cf8f0cc 100644 --- a/.github/workflows/windows-sandbox-w0.yml +++ b/.github/workflows/windows-sandbox-w0.yml @@ -1,22 +1,32 @@ name: Windows sandbox W0 evidence +# The paths below are a pre-filter, not this lane's real input. The real input +# is the import closure of the sandbox and filesystem-worker sources, which +# reaches well past any list worth hand-maintaining. So they name the +# directories that own the sandbox, which keeps a change there blocking before +# merge, and the nightly run is what covers transitive edits once they land. on: pull_request: paths: - 'experiments/windows-sandbox/**' - 'packages/runtime/src/sandbox/**' - - 'packages/runtime/**' - - 'packages/core/**' - 'packages/runtime/src/filesystem-worker/**' - - 'scripts/package-windows-x64.mjs' - - 'scripts/verify-packaged-app.mjs' - - 'apps/desktop/electron-builder.config.mjs' + - 'packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts' - '.github/workflows/windows-sandbox-w0.yml' + schedule: + # Offset from windows-baseline so the two Windows lanes do not overlap. + - cron: '17 7 * * *' workflow_dispatch: permissions: contents: read +# Pull request pushes supersede each other. Scheduled and manual runs each get +# a unique group, so neither can discard the other while pending or running. +concurrency: + group: windows-sandbox-w0-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + jobs: protocol: name: windows_sandbox_w0_protocol @@ -29,6 +39,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' + cache: npm - name: Record atomic launcher capability shell: pwsh run: ./experiments/windows-sandbox/atomic-launch-capability.ps1 diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 2a8841bd5f..672e4d00b0 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import test from 'node:test'; import { formatGitHubOutputs, planTests } from './ci-test-plan.mjs'; @@ -183,19 +183,55 @@ test('core CI validates affected installed CLI packages on its existing runner', assert.match(workflow, /run: npm run release:cli:smoke/u); }); -test('specialized platform workflows never create pull request jobs', () => { +test('pull request triggers stay on an explicit allowlist', () => { + // Naming the lanes that must not run on pull requests only covers the ones + // someone remembered to name; W0 kept an unbounded trigger that way. + const onPullRequests = readdirSync(WORKFLOW_DIR).filter(hasPullRequestTrigger).sort(); + + assert.deepEqual(onPullRequests, [ + 'ci.yml', + 'copilot-auto-review.yml', + 'dependency-audit.yml', + 'release-windows-check.yml', + 'windows-sandbox-w0.yml', + ]); +}); + +test('the sandbox lane pairs its path filter with a nightly run', () => { + const workflow = readWorkflow('windows-sandbox-w0.yml'); + + // The filter is a pre-filter, not the lane's import closure, so dropping the + // schedule would silently lose every transitive edit it cannot match, and + // dropping the filter would put the whole runtime back on pull requests. + assert.match(workflow, /\n {2}pull_request:\n {4}paths:/u); + assert.match(workflow, /\n {2}schedule:/u); +}); + +test('specialized platform workflows stay reachable without pull requests', () => { const cli = readWorkflow('cli-package-validation.yml'); const baseline = readWorkflow('windows-baseline.yml'); const recovery = readWorkflow('windows-recovery.yml'); for (const workflow of [cli, baseline, recovery]) { - assert.doesNotMatch(workflow, /\n pull_request:/u); assert.match(workflow, /\n workflow_dispatch:/u); } assert.match(cli, /\n workflow_call:/u); assert.match(baseline, /\n schedule:/u); }); +const WORKFLOW_DIR = new URL('../.github/workflows/', import.meta.url); + function readWorkflow(name) { - return readFileSync(new URL(`../.github/workflows/${name}`, import.meta.url), 'utf8'); + return readFileSync(new URL(name, WORKFLOW_DIR), 'utf8'); +} + +/** + * Reads the `on:` block only, so a workflow cannot escape a trigger contract by + * writing `on: [pull_request]`, and prose elsewhere in the file cannot fake one. + */ +function hasPullRequestTrigger(name) { + const withoutComments = readWorkflow(name).replaceAll(/^[ \t]*#.*$/gmu, ''); + const triggers = withoutComments.match(/^on:(.*(?:\n(?![^\s#]).*)*)/mu)?.[1] ?? ''; + + return /\bpull_request(_target)?\b/u.test(triggers); } From 08e5f255f70a464ac2d954dc5ed68011d9c0c280 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 20 Aug 2026 19:12:25 +0800 Subject: [PATCH 2/3] ci: make the Windows inventory check a real gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run windows:inventory` regenerates the Windows test skip inventory and diffs it. It carried `continue-on-error: true`, so a drifted inventory reported a green run and merged — a gate that cannot fail the job is not a gate. The check is clean today (62 declarations), so removing it blocks nothing that passes now. Leave it unconditional and move it beside the planner test. It needs only the checkout: it runs on Node's built-ins, in about a third of a second, without `npm ci`. That is the same shape as `Test CI planner`, which already runs before dependency setup and can fail, so the two now sit together and are covered by one contract. The alternative was to gate it on the `code` surface like the Astryx checks next to it, but those are gated because they are expensive, and copying the shape without the reason costs more than it saves: `code` does not cover this check's own output under docs/, so the planner would need a special case for a generated file, and a pull request that only regenerates the inventory would then pay for install, lint, build, typecheck and knip to run a sub-second diff. Generated-by: Claude Code --- .github/workflows/ci.yml | 10 ++++++---- scripts/ci-test-plan.test.mjs | 19 +++++++++++++------ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a675cb2bd8..84dcadb1fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,12 @@ jobs: - name: Test CI planner run: node --test --test-concurrency=1 scripts/ci-test-plan.test.mjs + # Same shape and the same needs: a regenerate-and-diff contract that runs + # on Node alone, so it belongs beside the planner test rather than behind + # an install. + - name: Check Windows test inventory + run: npm run windows:inventory + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' with: @@ -94,10 +100,6 @@ jobs: if: steps.plan.outputs.code == 'true' run: npm run format:check - - name: Check Windows test inventory - continue-on-error: true - run: npm run windows:inventory - - name: Build if: steps.plan.outputs.code == 'true' run: npm run build diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 672e4d00b0..78d01647c8 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -159,15 +159,22 @@ test('core CI uses the Windows inventory package-script authority', () => { assert.doesNotMatch(workflow, /run: node scripts\/windows-test-inventory\.mjs --check/u); }); -test('CI planner contracts run before dependency setup on every change', () => { +test('contract checks run before dependency setup and can fail the job', () => { const workflow = readWorkflow('ci.yml'); - const testStepStart = workflow.indexOf(' - name: Test CI planner\n'); const setupNodeStart = workflow.indexOf(' - uses: actions/setup-node@'); - const testStepEnd = workflow.indexOf('\n - ', testStepStart + 1); - assert.ok(testStepStart >= 0); - assert.ok(testStepStart < setupNodeStart); - assert.doesNotMatch(workflow.slice(testStepStart, testStepEnd), /\n\s+if:/u); + // Both contracts need nothing but the checkout, so they run on every change + // rather than behind a surface flag — and a gate that cannot fail the job is + // not a gate. + for (const name of ['Test CI planner', 'Check Windows test inventory']) { + const start = workflow.indexOf(` - name: ${name}\n`); + assert.ok(start >= 0, name); + assert.ok(start < setupNodeStart, name); + + const step = workflow.slice(start, workflow.indexOf('\n - ', start + 1)); + assert.doesNotMatch(step, /\n\s+if:/u, name); + assert.doesNotMatch(step, /continue-on-error/u, name); + } }); test('core CI validates affected installed CLI packages on its existing runner', () => { From c837555fb7f2a325082750170d1ce630ea4b1cf3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 20 Aug 2026 19:13:04 +0800 Subject: [PATCH 3/3] ci(windows): stop persisting the job credential on the Windows lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten workflows check out the repository and eight of them disable persist-credentials. windows-baseline and windows-recovery did not, so both left the job token reachable through the checkout's git config for the rest of the run — and both then execute npm ci with lifecycle scripts, the desktop smoke, and the full storage and runtime suites. Neither pushes, calls gh, or reads a token anywhere, so nothing needed it. Assert the property across the whole directory rather than on the two files being fixed, and slice each checkout step so the assertion is per checkout: a bare one cannot be balanced out by a sibling step that opts out, or by the string appearing in a comment. Generated-by: Claude Code --- .github/workflows/windows-baseline.yml | 2 ++ .github/workflows/windows-recovery.yml | 2 ++ scripts/ci-test-plan.test.mjs | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/.github/workflows/windows-baseline.yml b/.github/workflows/windows-baseline.yml index 0772d4af2b..e2b5aca90d 100644 --- a/.github/workflows/windows-baseline.yml +++ b/.github/workflows/windows-baseline.yml @@ -26,6 +26,8 @@ jobs: WINDOWS_BASELINE_LOG_DIR: artifacts/windows-baseline steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index ec70595c40..b1a3741714 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -16,6 +16,8 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 78d01647c8..ab48fa3b91 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -226,6 +226,14 @@ test('specialized platform workflows stay reachable without pull requests', () = assert.match(baseline, /\n schedule:/u); }); +test('workflows never persist the job credential into the checkout', () => { + for (const name of readdirSync(WORKFLOW_DIR)) { + for (const step of checkoutSteps(name)) { + assert.match(step, /persist-credentials: false/u, `${name}: ${step.trim()}`); + } + } +}); + const WORKFLOW_DIR = new URL('../.github/workflows/', import.meta.url); function readWorkflow(name) { @@ -242,3 +250,17 @@ function hasPullRequestTrigger(name) { return /\bpull_request(_target)?\b/u.test(triggers); } + +/** + * Slices each checkout step from its `uses:` line to the next step, so the + * assertion is per checkout: a bare one cannot be balanced out by a sibling + * step that opts out, or by the string appearing in a comment. + */ +function checkoutSteps(name) { + const withoutComments = readWorkflow(name).replaceAll(/^[ \t]*#.*$/gmu, ''); + + return ( + withoutComments.match(/^[ \t]*- uses: actions\/checkout@.*\n(?:(?![ \t]*- )[ \t]+.*\n)*/gmu) ?? + [] + ); +}