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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/windows-baseline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/windows-recovery.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 16 additions & 5 deletions .github/workflows/windows-sandbox-w0.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
85 changes: 75 additions & 10 deletions scripts/ci-test-plan.test.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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) ??
[]
);
}