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/.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/.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..ab48fa3b91 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'; @@ -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', () => { @@ -183,19 +190,77 @@ 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); }); +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) { - 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); +} + +/** + * 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) ?? + [] + ); }