From 7d2649289ff32da9781c040c02ecc7c14075a3b1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 17:25:21 +0800 Subject: [PATCH 1/7] ci(windows): scope the recovery lane to its own inputs The recovery lane went back to an unconditional pull request trigger and became a second required context in #3789, which puts a three-minute Windows job on every pull request whether or not it can observe the change. That reverses the automatic-runner-start reduction agreed in #3261, and it does not scale: the platform matrix is heading for four Node versions across Linux, Apple x64, Apple silicon and Windows, where one channel must not gate every other pull request. Give the lane the shape the other conditional Windows lane already uses. A path pre-filter names the storage, runtime and Runtime Host source trees whose crash and owner-death authorities its steps execute, and a nightly run covers the transitive edits a static list cannot match. Drop windows_recovery from the ASF-managed required contexts, because a required context behind a path filter never reports and would freeze every pull request instead. Across the last 30 merged pull requests the filter selects 17 and skips 13. Generated-by: Claude Opus --- .asf.yaml | 4 ---- .github/workflows/windows-recovery.yml | 18 +++++++++++++++--- scripts/ci-test-plan.test.mjs | 24 +++++++++++++++++++++--- scripts/product-release.test.mjs | 5 +---- 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/.asf.yaml b/.asf.yaml index abb77a54e5..6ac3fe6b8b 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -69,10 +69,6 @@ github: # reports and no committer can override it. contexts: - test - # Windows recovery is a separate native crash/owner-death boundary. - # The workflow runs on every PR and main push so this context can be - # required without leaving unrelated pull requests pending forever. - - windows_recovery rulesets: - name: Immutable release tags diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 91de576062..38cb4c826c 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -17,11 +17,23 @@ name: Windows recovery +# The paths below are a pre-filter, not this lane's real input. The real input +# is the import closure of the crash and owner-death recovery authorities in +# storage, runtime, and Runtime Host, which reaches well past any list worth +# hand-maintaining. So they name the directories that own those authorities, +# which keeps a change there blocking before merge, and the nightly run is what +# covers transitive edits once they land. on: pull_request: - branches: [main] - push: - branches: [main] + paths: + - 'packages/storage/src/**' + - 'packages/runtime/src/**' + - 'packages/runtime-host/src/**' + - 'scripts/windows-runtime-host-local-ipc-trust.ps1' + - '.github/workflows/windows-recovery.yml' + schedule: + # Offset from windows-sandbox-w0 so the Windows lanes do not overlap. + - cron: '17 8 * * *' workflow_dispatch: concurrency: diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index e0e9e9c852..2bbf27f21b 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -352,16 +352,34 @@ test('pull request triggers stay on an explicit allowlist', () => { ]); }); -test('Windows recovery publishes one stable PR and main check for ruleset enforcement', () => { +test('the recovery lane pairs its path filter with a nightly run', () => { const workflow = readWorkflow('windows-recovery.yml'); - assert.match(workflow, /\n {2}pull_request:\n {4}branches: \[main\]/u); - assert.match(workflow, /\n {2}push:\n {4}branches: \[main\]/u); + // Same contract as the sandbox lane: 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 every + // Windows recovery run back on every pull request. + assert.match(workflow, /\n {2}pull_request:\n {4}paths:/u); + assert.match(workflow, /\n {2}schedule:/u); assert.match(workflow, /\n {2}workflow_dispatch:/u); assert.match(workflow, /\n {4}name: windows_recovery/u); assert.match(workflow, /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/u); }); +test('the recovery lane filter names the authorities its steps execute', () => { + const workflow = readWorkflow('windows-recovery.yml'); + + for (const path of [ + 'packages/storage/src/**', + 'packages/runtime/src/**', + 'packages/runtime-host/src/**', + 'scripts/windows-runtime-host-local-ipc-trust.ps1', + '.github/workflows/windows-recovery.yml', + ]) { + assert.ok(workflow.includes(` - '${path}'`), path); + } +}); + test('the sandbox lane pairs its path filter with a nightly run', () => { const workflow = readWorkflow('windows-sandbox-w0.yml'); diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 0a3754e071..4a8bfc4fd6 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -724,10 +724,7 @@ test('one product workflow gates one draft release on every required artifact', test('repository control plane admits only reviewed immutable release tags', async () => { const config = parseYaml(await readFile(new URL('../.asf.yaml', import.meta.url), 'utf8')); - assert.deepEqual(config.github.protected_branches.main.required_status_checks.contexts, [ - 'test', - 'windows_recovery', - ]); + assert.deepEqual(config.github.protected_branches.main.required_status_checks.contexts, ['test']); const environments = config.github.environments; for (const [name, tagPattern] of [ ['release', 'v*-incubating-rc*'], From eb19b4808266b278ef36d254f99075552f0dded1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 17:42:30 +0800 Subject: [PATCH 2/7] ci(windows): cover the recovery lane's install and build inputs Adversarial review found three gaps in the previous commit. The filter omitted package.json and package-lock.json, which the unconditional `npm ci` step consumes. A grouped dependabot bump touches only those two files, so the weekly npm update would have skipped this lane while its crash gates sit on the native fs-native-extensions file lock that the Linux test lane cannot observe at all. The sibling Windows gate in runtime-host-owner-platform.yml already lists both. Project files, patches and the clean/build scripts are inputs of the same two steps and join them. Removing `push: branches: [main]` was outside this change's goal. The filter applies to pull_request alone, so the main push never cost anything on the pull request side, while `strict: false` lets a stale-base pull request go green and only the merged result proves two independently green halves still agree. The concurrency group stayed keyed on github.ref after push left, so the nightly, a dispatch and a main push shared one group with cancel-in-progress evaluating to false: a manual run queued behind the nightly for up to the job timeout, and the next dispatch discarded it while still pending. It now uses the sandbox lane's head_ref-or-run_id key. The filter contract test derived nothing and only restated the list. It now derives the executed workspaces from the dist paths the steps run, so adding a workspace to this lane fails until its sources and project file are filtered. Generated-by: Claude Opus --- .asf.yaml | 4 ++ .github/workflows/windows-baseline.yml | 2 +- .github/workflows/windows-recovery.yml | 34 ++++++++++++++--- scripts/ci-test-plan.test.mjs | 51 ++++++++++++++++++++++---- 4 files changed, 77 insertions(+), 14 deletions(-) diff --git a/.asf.yaml b/.asf.yaml index 6ac3fe6b8b..afff5f4c74 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -67,6 +67,10 @@ github: # Storybook. Renaming it there, or adding a paths filter that stops # ci.yml from running, freezes every pull request: the check never # reports and no committer can override it. + # A required context must report on every pull request, so a lane + # behind a paths filter cannot be listed here: the filter would keep + # the workflow from starting and the check would stay pending forever. + # windows_recovery is filtered and therefore deliberately absent. contexts: - test diff --git a/.github/workflows/windows-baseline.yml b/.github/workflows/windows-baseline.yml index a2f812c7a3..06f2dafaaa 100644 --- a/.github/workflows/windows-baseline.yml +++ b/.github/workflows/windows-baseline.yml @@ -149,7 +149,7 @@ jobs: # Full packages/storage test:dist is ~10 minutes on windows-latest and # mostly duplicates the Linux unit lane. Baseline keeps process/path/ - # lock-sensitive gates here. Release-blocking crash evidence belongs to + # lock-sensitive gates here. Crash and owner-death evidence belongs to # windows-recovery.yml, so this diagnostic lane does not duplicate it. # Bump concurrency carefully — several suites spawn child processes and # fight for disk under high fan-out. diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 38cb4c826c..f4c24e8bc7 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -19,26 +19,48 @@ name: Windows recovery # The paths below are a pre-filter, not this lane's real input. The real input # is the import closure of the crash and owner-death recovery authorities in -# storage, runtime, and Runtime Host, which reaches well past any list worth -# hand-maintaining. So they name the directories that own those authorities, -# which keeps a change there blocking before merge, and the nightly run is what -# covers transitive edits once they land. +# storage, runtime and Runtime Host, which reaches well past any list worth +# hand-maintaining. So they name the directories owning those authorities, plus +# the manifests, patches and project files the unconditional install and build +# steps consume directly. That keeps a change there reported before merge, and +# the nightly run is what covers transitive edits once they land. on: pull_request: paths: + - 'package.json' + - 'package-lock.json' + - 'patches/**' + - 'tsconfig.base.json' + - 'tsconfig.lib.json' + - 'packages/storage/tsconfig.json' - 'packages/storage/src/**' + - 'packages/runtime/tsconfig.json' - 'packages/runtime/src/**' + - 'packages/runtime/scripts/**' + - 'packages/runtime-host/tsconfig.json' - 'packages/runtime-host/src/**' + - 'scripts/clean-build.mjs' + - 'scripts/clean-paths.mjs' - 'scripts/windows-runtime-host-local-ipc-trust.ps1' - '.github/workflows/windows-recovery.yml' + # Unfiltered on purpose: required_status_checks is `strict: false`, so a pull + # request goes green against a stale base and only the merged result proves + # two independently green halves still agree. The nightly alone would find + # that a day later, against a batch of commits instead of one. + push: + branches: [main] schedule: # Offset from windows-sandbox-w0 so the Windows lanes do not overlap. - cron: '17 8 * * *' workflow_dispatch: +# Pull request pushes supersede each other. Scheduled, manual and main-push +# runs each get a unique group, because github.ref is refs/heads/main for all +# three: a shared group made a dispatch queue behind the nightly and let the +# next dispatch discard it while still pending. concurrency: - group: windows-recovery-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: windows-recovery-${{ github.head_ref || github.run_id }} + cancel-in-progress: true permissions: contents: read diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 2bbf27f21b..03d00ba37f 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -352,27 +352,64 @@ test('pull request triggers stay on an explicit allowlist', () => { ]); }); -test('the recovery lane pairs its path filter with a nightly run', () => { +test('the recovery lane pairs its path filter with a nightly run and a main push', () => { const workflow = readWorkflow('windows-recovery.yml'); // Same contract as the sandbox lane: 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 every - // Windows recovery run back on every pull request. + // Windows recovery run back on every pull request. The main push carries no + // filter because `strict: false` lets a stale-base pull request go green, + // and only the merged result proves two green halves still agree. assert.match(workflow, /\n {2}pull_request:\n {4}paths:/u); + assert.match(workflow, /\n {2}push:\n {4}branches: \[main\]/u); assert.match(workflow, /\n {2}schedule:/u); assert.match(workflow, /\n {2}workflow_dispatch:/u); assert.match(workflow, /\n {4}name: windows_recovery/u); - assert.match(workflow, /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/u); }); -test('the recovery lane filter names the authorities its steps execute', () => { +test('the recovery lane keeps scheduled and manual runs out of one shared group', () => { const workflow = readWorkflow('windows-recovery.yml'); + // github.ref is refs/heads/main for the nightly, a dispatch and a main push + // alike, so a ref-keyed group made a dispatch queue behind the nightly for + // up to the job timeout and let the next dispatch discard it while pending. + assert.match( + workflow, + /group: windows-recovery-\$\{\{ github\.head_ref \|\| github\.run_id \}\}/u, + ); + assert.match(workflow, /\n {2}cancel-in-progress: true/u); +}); + +test('the recovery lane filter covers every workspace its steps execute', () => { + const workflow = readWorkflow('windows-recovery.yml'); + // Derived from the dist paths the steps actually run, so adding a workspace + // to this lane fails here until its sources and project file are filtered. + const executed = [ + ...new Set([...workflow.matchAll(/packages\/([^/]+)\/dist\//gu)].map((match) => match[1])), + ].sort(); + + assert.deepEqual(executed, ['runtime', 'runtime-host', 'storage']); + for (const workspace of executed) { + assert.ok(workflow.includes(` - 'packages/${workspace}/src/**'`), workspace); + assert.ok(workflow.includes(` - 'packages/${workspace}/tsconfig.json'`), workspace); + } +}); + +test('the recovery lane filter names what its install and build steps consume', () => { + const workflow = readWorkflow('windows-recovery.yml'); + + // `npm.cmd ci` and `npm.cmd run build:test` run unconditionally, so their + // inputs are first-class inputs of this lane rather than transitive edits + // the nightly can be left to cover. A grouped dependabot bump touches only + // the manifests, and the crash gates below sit on a native file lock that + // the Linux `test` lane cannot observe at all. for (const path of [ - 'packages/storage/src/**', - 'packages/runtime/src/**', - 'packages/runtime-host/src/**', + 'package.json', + 'package-lock.json', + 'patches/**', + 'tsconfig.base.json', + 'tsconfig.lib.json', 'scripts/windows-runtime-host-local-ipc-trust.ps1', '.github/workflows/windows-recovery.yml', ]) { From 2f0ed7b3e034273a9c25d1437533a3e90b84074a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 17:54:41 +0800 Subject: [PATCH 3/7] ci(windows): anchor the recovery filter contract to its trigger A second adversarial review pass broke the previous commit's contract tests without failing them, and found two gaps in the filter itself. The four filter tests matched entry text anywhere in the workflow file, so moving every entry under `paths-ignore`, or under the main push while the pull request kept a single `docs/**` entry, left all of them green while the lane stopped running on pull requests entirely. They now read the `paths` list that belongs to the `pull_request` trigger and assert against that list, and both reversals fail. The filter omitted packages/core, which storage, runtime and Runtime Host each hold a TypeScript project reference to and which `build:test` compiles before them. The workspace-coverage test now walks those references from the workspaces the steps execute, so the closure reaches core on its own rather than by a hand-maintained name. Across the last 30 merged pull requests this does not change how many are selected, because every core change in that sample also touched another filtered path; it closes the case where a pull request changes core alone. `scripts/apply-dependency-patches.mjs` and `scripts/install-electron-with-retry.mjs` are the two halves of postinstall and decide what `npm ci` produces, so they join the install inputs. The concurrency key moves from github.head_ref to the pull request number. head_ref is a bare branch name, so two contributors pushing their own fork `main` shared a group and cancelled each other, and a cancelled check is not a failed one. `pull_request` regains `branches: [main]`, matching the sibling Windows gate, so stacked pull requests onto a non-main base do not start a Windows runner. The trigger comment now records that a diff over 3,000 files can skip a paths filter outright, which is a second reason the main push carries none. Generated-by: Claude Opus --- .github/workflows/windows-recovery.yml | 44 +++++++---- scripts/ci-test-plan.test.mjs | 105 +++++++++++++++++++------ 2 files changed, 110 insertions(+), 39 deletions(-) diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index f4c24e8bc7..0212d3be53 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -20,18 +20,33 @@ name: Windows recovery # The paths below are a pre-filter, not this lane's real input. The real input # is the import closure of the crash and owner-death recovery authorities in # storage, runtime and Runtime Host, which reaches well past any list worth -# hand-maintaining. So they name the directories owning those authorities, plus -# the manifests, patches and project files the unconditional install and build -# steps consume directly. That keeps a change there reported before merge, and -# the nightly run is what covers transitive edits once they land. +# hand-maintaining. So they name those workspaces and every workspace they hold +# a TypeScript project reference to, plus the manifests, patches and scripts +# the unconditional install and clean steps consume. That keeps a change there +# reported before merge, and the nightly run covers the transitive edits the +# list cannot match. +# +# A pull request whose diff exceeds 3,000 files can skip the filter outright, +# because GitHub does not promise the matched files are in the first 3,000 it +# returns. A repository-wide sweep is exactly the change that touches every +# recovery authority at once, which is one more reason the main push below +# carries no filter at all. on: pull_request: + branches: [main] paths: - 'package.json' - 'package-lock.json' - 'patches/**' + - 'scripts/apply-dependency-patches.mjs' + - 'scripts/install-electron-with-retry.mjs' + - 'scripts/clean-build.mjs' + - 'scripts/clean-paths.mjs' + - 'scripts/windows-runtime-host-local-ipc-trust.ps1' - 'tsconfig.base.json' - 'tsconfig.lib.json' + - 'packages/core/tsconfig.json' + - 'packages/core/src/**' - 'packages/storage/tsconfig.json' - 'packages/storage/src/**' - 'packages/runtime/tsconfig.json' @@ -39,14 +54,12 @@ on: - 'packages/runtime/scripts/**' - 'packages/runtime-host/tsconfig.json' - 'packages/runtime-host/src/**' - - 'scripts/clean-build.mjs' - - 'scripts/clean-paths.mjs' - - 'scripts/windows-runtime-host-local-ipc-trust.ps1' - '.github/workflows/windows-recovery.yml' # Unfiltered on purpose: required_status_checks is `strict: false`, so a pull # request goes green against a stale base and only the merged result proves - # two independently green halves still agree. The nightly alone would find - # that a day later, against a batch of commits instead of one. + # two independently green halves still agree. It is also the backstop for the + # 3,000-file case above. The nightly alone would find either a day later, + # against a batch of commits instead of one. push: branches: [main] schedule: @@ -54,12 +67,15 @@ on: - cron: '17 8 * * *' workflow_dispatch: -# Pull request pushes supersede each other. Scheduled, manual and main-push -# runs each get a unique group, because github.ref is refs/heads/main for all -# three: a shared group made a dispatch queue behind the nightly and let the -# next dispatch discard it while still pending. +# Pull request pushes supersede each other, keyed on the pull request number +# because github.head_ref is a bare branch name two forks can share: a second +# contributor pushing to their own `main` would otherwise cancel the first +# contributor's run, and a cancelled check is not a failed one. +# Scheduled, manual and main-push runs fall back to the run id, since +# github.ref is refs/heads/main for all three and one shared group would let a +# dispatch queue behind the nightly and then be discarded while still pending. concurrency: - group: windows-recovery-${{ github.head_ref || github.run_id }} + group: windows-recovery-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true permissions: diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 03d00ba37f..821462ebfb 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -360,60 +360,71 @@ test('the recovery lane pairs its path filter with a nightly run and a main push // transitive edit it cannot match, and dropping the filter would put every // Windows recovery run back on every pull request. The main push carries no // filter because `strict: false` lets a stale-base pull request go green, - // and only the merged result proves two green halves still agree. - assert.match(workflow, /\n {2}pull_request:\n {4}paths:/u); - assert.match(workflow, /\n {2}push:\n {4}branches: \[main\]/u); - assert.match(workflow, /\n {2}schedule:/u); + // and because a diff over 3,000 files can skip a paths filter outright. + assert.match(workflow, /\n {2}pull_request:\n {4}branches: \[main\]\n {4}paths:/u); + assert.match(workflow, /\n {2}push:\n {4}branches: \[main\]\n {2}schedule:/u); assert.match(workflow, /\n {2}workflow_dispatch:/u); assert.match(workflow, /\n {4}name: windows_recovery/u); }); -test('the recovery lane keeps scheduled and manual runs out of one shared group', () => { +test('the recovery lane keeps every run kind out of one shared concurrency group', () => { const workflow = readWorkflow('windows-recovery.yml'); - // github.ref is refs/heads/main for the nightly, a dispatch and a main push - // alike, so a ref-keyed group made a dispatch queue behind the nightly for - // up to the job timeout and let the next dispatch discard it while pending. + // github.head_ref is a bare branch name, so two forks pushing their own + // `main` would share a group and cancel each other; github.ref is + // refs/heads/main for the nightly, a dispatch and a main push alike, so a + // ref-keyed group made a dispatch queue behind the nightly and let the next + // dispatch discard it while pending. assert.match( workflow, - /group: windows-recovery-\$\{\{ github\.head_ref \|\| github\.run_id \}\}/u, + /group: windows-recovery-\$\{\{ github\.event\.pull_request\.number \|\| github\.run_id \}\}/u, ); assert.match(workflow, /\n {2}cancel-in-progress: true/u); }); -test('the recovery lane filter covers every workspace its steps execute', () => { +test('the recovery lane filters pull requests by the workspaces its steps build', () => { const workflow = readWorkflow('windows-recovery.yml'); - // Derived from the dist paths the steps actually run, so adding a workspace - // to this lane fails here until its sources and project file are filtered. + const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); + + // Derived from the dist paths the steps actually run, then widened along the + // TypeScript project references those workspaces compile against. A new + // workspace on this lane, or a new reference under one of them, fails here + // until the filter admits its sources and project file. const executed = [ ...new Set([...workflow.matchAll(/packages\/([^/]+)\/dist\//gu)].map((match) => match[1])), ].sort(); - assert.deepEqual(executed, ['runtime', 'runtime-host', 'storage']); - for (const workspace of executed) { - assert.ok(workflow.includes(` - 'packages/${workspace}/src/**'`), workspace); - assert.ok(workflow.includes(` - 'packages/${workspace}/tsconfig.json'`), workspace); + + const closure = projectReferenceClosure(executed); + assert.ok(closure.includes('core'), 'project reference closure must reach core'); + for (const workspace of closure) { + assert.ok(filtered.has(`packages/${workspace}/src/**`), `${workspace}: sources`); + assert.ok(filtered.has(`packages/${workspace}/tsconfig.json`), `${workspace}: project file`); } }); -test('the recovery lane filter names what its install and build steps consume', () => { - const workflow = readWorkflow('windows-recovery.yml'); +test('the recovery lane filters pull requests by what its install and clean steps consume', () => { + const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); - // `npm.cmd ci` and `npm.cmd run build:test` run unconditionally, so their - // inputs are first-class inputs of this lane rather than transitive edits - // the nightly can be left to cover. A grouped dependabot bump touches only - // the manifests, and the crash gates below sit on a native file lock that - // the Linux `test` lane cannot observe at all. + // `npm.cmd ci` and `npm.cmd run build:test` run unconditionally, so these are + // first-class inputs of the lane rather than transitive edits the nightly can + // be left to cover. A grouped dependabot bump touches only the manifests, and + // the crash gates sit on a native file lock the Linux `test` lane never sees. for (const path of [ 'package.json', 'package-lock.json', 'patches/**', + 'scripts/apply-dependency-patches.mjs', + 'scripts/install-electron-with-retry.mjs', + 'scripts/clean-build.mjs', + 'scripts/clean-paths.mjs', + 'scripts/windows-runtime-host-local-ipc-trust.ps1', 'tsconfig.base.json', 'tsconfig.lib.json', - 'scripts/windows-runtime-host-local-ipc-trust.ps1', + 'packages/runtime/scripts/**', '.github/workflows/windows-recovery.yml', ]) { - assert.ok(workflow.includes(` - '${path}'`), path); + assert.ok(filtered.has(path), path); } }); @@ -549,6 +560,50 @@ test('core CI runs the live Eval proxy lifecycle when Eval is selected', () => { const WORKFLOW_DIR = new URL('../.github/workflows/', import.meta.url); +/** + * Reads the `paths` list belonging to a workflow's `pull_request` trigger. + * Anchoring to the trigger, instead of matching entry text anywhere in the + * file, is what makes the filter assertions fail when entries move under + * `paths-ignore`, under another trigger, or out of `on:` altogether. + */ +function pullRequestPathFilter(name) { + const lines = readWorkflow(name).split('\n'); + const start = lines.indexOf(' pull_request:'); + assert.ok(start >= 0, `${name}: no pull_request trigger`); + + const paths = []; + let inPaths = false; + for (const line of lines.slice(start + 1)) { + if (/^ {0,2}\S/u.test(line)) break; + if (/^ {4}\S/u.test(line)) { + inPaths = line === ' paths:'; + continue; + } + const entry = inPaths ? /^ {6}- '(.+)'$/u.exec(line) : null; + if (entry) paths.push(entry[1]); + } + return paths; +} + +/** Workspace names reachable from `workspaces` through tsconfig references. */ +function projectReferenceClosure(workspaces) { + const selected = new Set(workspaces); + const pending = [...workspaces]; + while (pending.length > 0) { + const workspace = pending.shift(); + const config = JSON.parse( + readFileSync(new URL(`../packages/${workspace}/tsconfig.json`, import.meta.url), 'utf8'), + ); + for (const reference of config.references ?? []) { + const name = reference.path.replace(/^\.\.\//u, ''); + if (selected.has(name)) continue; + selected.add(name); + pending.push(name); + } + } + return [...selected].sort(); +} + function readWorkflow(name) { return readFileSync(new URL(name, WORKFLOW_DIR), 'utf8'); } From 9b20b573be617cb048dce107370a11ca4a66e35c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 18:19:30 +0800 Subject: [PATCH 4/7] ci(windows): select the recovery lane inside its own job Three adversarial review rounds converged on the same objection to the previous approach: a paths filter and a required context cannot coexist, so filtering the lane meant giving up the only Windows gate that blocks a merge to main. That trade was not worth making. The filter skipped 12 of the last 30 merged pull requests while the other 18 paid the full three minutes anyway, so it bought roughly a minute of unbilled Windows runner per pull request and paid for it by making every recovery regression advisory. Keep the trigger and the required context exactly as #3789 left them, and move the relevance decision inside the job instead. `scripts/ci-test-plan.mjs` already owns "what does this diff affect" for the core lane, so it gains a windows_recovery selection and the lane consumes it the same way ci.yml consumes its own: one planner step after checkout, then `if:` on every step that installs, builds or verifies. An unrelated diff now costs a checkout and one node invocation instead of three minutes, and the check still reports on every pull request, so a red recovery gate still blocks the merge. Selection is the reverse dependency closure of storage, runtime and Runtime Host, which is why packages/core needs no mention: a core change reaches all three on its own. That closure is also why this replaces rather than supplements a path list. The earlier attempt maintained twenty paths, a tsconfig reference walk and a bespoke YAML scanner, each a second authority next to loadWorkspaceGraph, hasPullRequestTrigger and the planner, and each one of them missed packages/core until review caught it. The contract tests assert the shape this depends on: no paths filter anywhere in the trigger block, every non-checkout step behind the planner output, fetch-depth 0 so the base commit is resolvable, and every planner output consumed by some workflow. All four fail when their subject is reverted. Generated-by: Claude Opus --- .asf.yaml | 8 +- .github/workflows/windows-baseline.yml | 2 +- .github/workflows/windows-recovery.yml | 81 ++++------- scripts/ci-test-plan.mjs | 27 ++++ scripts/ci-test-plan.test.mjs | 189 +++++++++---------------- scripts/product-release.test.mjs | 5 +- 6 files changed, 135 insertions(+), 177 deletions(-) diff --git a/.asf.yaml b/.asf.yaml index afff5f4c74..abb77a54e5 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -67,12 +67,12 @@ github: # Storybook. Renaming it there, or adding a paths filter that stops # ci.yml from running, freezes every pull request: the check never # reports and no committer can override it. - # A required context must report on every pull request, so a lane - # behind a paths filter cannot be listed here: the filter would keep - # the workflow from starting and the check would stay pending forever. - # windows_recovery is filtered and therefore deliberately absent. contexts: - test + # Windows recovery is a separate native crash/owner-death boundary. + # The workflow runs on every PR and main push so this context can be + # required without leaving unrelated pull requests pending forever. + - windows_recovery rulesets: - name: Immutable release tags diff --git a/.github/workflows/windows-baseline.yml b/.github/workflows/windows-baseline.yml index 06f2dafaaa..a2f812c7a3 100644 --- a/.github/workflows/windows-baseline.yml +++ b/.github/workflows/windows-baseline.yml @@ -149,7 +149,7 @@ jobs: # Full packages/storage test:dist is ~10 minutes on windows-latest and # mostly duplicates the Linux unit lane. Baseline keeps process/path/ - # lock-sensitive gates here. Crash and owner-death evidence belongs to + # lock-sensitive gates here. Release-blocking crash evidence belongs to # windows-recovery.yml, so this diagnostic lane does not duplicate it. # Bump concurrency carefully — several suites spawn child processes and # fight for disk under high fan-out. diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 0212d3be53..1396c4e762 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -17,66 +17,16 @@ name: Windows recovery -# The paths below are a pre-filter, not this lane's real input. The real input -# is the import closure of the crash and owner-death recovery authorities in -# storage, runtime and Runtime Host, which reaches well past any list worth -# hand-maintaining. So they name those workspaces and every workspace they hold -# a TypeScript project reference to, plus the manifests, patches and scripts -# the unconditional install and clean steps consume. That keeps a change there -# reported before merge, and the nightly run covers the transitive edits the -# list cannot match. -# -# A pull request whose diff exceeds 3,000 files can skip the filter outright, -# because GitHub does not promise the matched files are in the first 3,000 it -# returns. A repository-wide sweep is exactly the change that touches every -# recovery authority at once, which is one more reason the main push below -# carries no filter at all. on: pull_request: branches: [main] - paths: - - 'package.json' - - 'package-lock.json' - - 'patches/**' - - 'scripts/apply-dependency-patches.mjs' - - 'scripts/install-electron-with-retry.mjs' - - 'scripts/clean-build.mjs' - - 'scripts/clean-paths.mjs' - - 'scripts/windows-runtime-host-local-ipc-trust.ps1' - - 'tsconfig.base.json' - - 'tsconfig.lib.json' - - 'packages/core/tsconfig.json' - - 'packages/core/src/**' - - 'packages/storage/tsconfig.json' - - 'packages/storage/src/**' - - 'packages/runtime/tsconfig.json' - - 'packages/runtime/src/**' - - 'packages/runtime/scripts/**' - - 'packages/runtime-host/tsconfig.json' - - 'packages/runtime-host/src/**' - - '.github/workflows/windows-recovery.yml' - # Unfiltered on purpose: required_status_checks is `strict: false`, so a pull - # request goes green against a stale base and only the merged result proves - # two independently green halves still agree. It is also the backstop for the - # 3,000-file case above. The nightly alone would find either a day later, - # against a batch of commits instead of one. push: branches: [main] - schedule: - # Offset from windows-sandbox-w0 so the Windows lanes do not overlap. - - cron: '17 8 * * *' workflow_dispatch: -# Pull request pushes supersede each other, keyed on the pull request number -# because github.head_ref is a bare branch name two forks can share: a second -# contributor pushing to their own `main` would otherwise cancel the first -# contributor's run, and a cancelled check is not a failed one. -# Scheduled, manual and main-push runs fall back to the run id, since -# github.ref is refs/heads/main for all three and one shared group would let a -# dispatch queue behind the nightly and then be discarded while still pending. concurrency: - group: windows-recovery-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true + group: windows-recovery-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -89,20 +39,42 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 persist-credentials: false + - id: plan + name: Select the recovery surface + shell: bash + env: + BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event_name == 'push' && github.sha || github.event.pull_request.head.sha }} + run: | + # The same planner the core lane selects with, so this lane and + # `test` agree on what a diff touches instead of keeping a second + # relevance authority in YAML. A dispatch, a first push and any + # unavailable history all fail safe to running every gate. + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] || [[ "$BASE_SHA" =~ ^0+$ ]] || ! git cat-file -e "${BASE_SHA}^{commit}"; then + node scripts/ci-test-plan.mjs --full >> "$GITHUB_OUTPUT" + else + node scripts/ci-test-plan.mjs --base "$BASE_SHA" --head "$HEAD_SHA" >> "$GITHUB_OUTPUT" + fi + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + if: steps.plan.outputs.windows_recovery == 'true' with: node-version: "24" cache: npm - name: Install dependencies + if: steps.plan.outputs.windows_recovery == 'true' run: npm.cmd ci - name: Build test artifacts + if: steps.plan.outputs.windows_recovery == 'true' run: npm.cmd run build:test - name: Verify managed dependency alternate streams + if: steps.plan.outputs.windows_recovery == 'true' shell: pwsh run: | node.exe --test --test-reporter=tap --test-concurrency=1 ` @@ -118,12 +90,14 @@ jobs: } - name: Verify Runtime Host Local IPC trust boundary + if: steps.plan.outputs.windows_recovery == 'true' shell: pwsh run: | node.exe --test packages/runtime-host/dist/__tests__/control-endpoint.test.js ./scripts/windows-runtime-host-local-ipc-trust.ps1 - name: Verify SQLite crash recovery + if: steps.plan.outputs.windows_recovery == 'true' shell: pwsh run: | node.exe --test --test-concurrency=1 ` @@ -131,6 +105,7 @@ jobs: packages/storage/dist/__tests__/sqlite-long-term-memory-crash.test.js - name: Verify Runtime continuation recovery + if: steps.plan.outputs.windows_recovery == 'true' shell: pwsh run: | node.exe --test --test-concurrency=1 ` @@ -138,6 +113,7 @@ jobs: packages/runtime/dist/__tests__/runtime-continuation-crash.test.js - name: Verify Runtime Host owner-death recovery + if: steps.plan.outputs.windows_recovery == 'true' shell: pwsh run: | node.exe --test --test-reporter=tap --test-concurrency=1 ` @@ -154,6 +130,7 @@ jobs: } - name: Verify managed workspace crash recovery + if: steps.plan.outputs.windows_recovery == 'true' shell: pwsh run: | $env:MAKA_STORAGE_STRESS = '1' diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 15a6010e3f..9379445424 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -145,6 +145,30 @@ function isReleaseContractPath(path) { ); } +// The Windows recovery lane executes the crash and owner-death gates that live +// in these workspaces. Selection is the reverse dependency closure below, so a +// packages/core change reaches them without being named here. +const WINDOWS_RECOVERY_WORKSPACES = [ + 'packages/storage', + 'packages/runtime', + 'packages/runtime-host', +]; + +// Inputs that lane consumes from outside the workspace graph: its own +// definition, the trust probe it shells out to, and the base project files its +// build step compiles every workspace against. +const WINDOWS_RECOVERY_FILES = new Set([ + '.github/workflows/windows-recovery.yml', + 'scripts/windows-runtime-host-local-ipc-trust.ps1', + 'tsconfig.base.json', + 'tsconfig.lib.json', +]); + +function selectsWindowsRecovery(files, workspaces) { + if (files.some((path) => WINDOWS_RECOVERY_FILES.has(path))) return true; + return WINDOWS_RECOVERY_WORKSPACES.some((workspace) => workspaces.includes(workspace)); +} + const DEDICATED_WORKSPACE_LANES = new Set(['packages/runtime-host']); // Scripts the Electron e2e job runs. Editing one of these changes what that @@ -350,6 +374,7 @@ export function planTests(changedFiles, options = {}) { // every unrelated merge into a 10K-chunk pressure run. storageStress: false, storybook: true, + windowsRecovery: true, workspaces, ...workspaceLanes(workspaces, graph), }; @@ -420,6 +445,7 @@ export function planTests(changedFiles, options = {}) { // PR — product ship gates are typecheck, unit, and Electron e2e. See // isStorybookPath. storybook: files.some((path) => isStorybookPath(path)), + windowsRecovery: selectsWindowsRecovery(files, workspaces), workspaces, ...workspaceLanes(workspaces, graph), }; @@ -437,6 +463,7 @@ export function formatGitHubOutputs(plan) { `release_contract=${plan.releaseContract}`, `storage_stress=${plan.storageStress}`, `storybook=${plan.storybook}`, + `windows_recovery=${plan.windowsRecovery}`, `standard_workspaces=${plan.standardWorkspaces.join(',')}`, ].join('\n'); } diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 821462ebfb..010e55d914 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -236,14 +236,72 @@ test('full-suite authority files select every surface', () => { test('GitHub output matches the selections consumed by CI', () => { const output = formatGitHubOutputs(planTests([], { graph, forceFull: true })); const outputKeys = new Set(output.split('\n').map((line) => line.split('=', 1)[0])); - const workflow = readWorkflow('ci.yml'); + // Both planning lanes, so an output that no workflow reads fails here rather + // than accumulating as a selection nobody acts on. const consumedKeys = new Set( - [...workflow.matchAll(/steps\.plan\.outputs\.([a-z0-9_]+)/gu)].map((match) => match[1]), + ['ci.yml', 'windows-recovery.yml'] + .flatMap((name) => [...readWorkflow(name).matchAll(/steps\.plan\.outputs\.([a-z0-9_]+)/gu)]) + .map((match) => match[1]), ); assert.deepEqual(outputKeys, consumedKeys); }); +test('the Windows recovery lane is selected through the workspaces it executes', () => { + // packages/core is not named in the selection: the reverse dependency closure + // reaches storage, runtime and Runtime Host on its own, which is why this + // lane does not need a second hand-maintained path list. + for (const [path, expected] of [ + ['packages/core/src/session.ts', true], + ['packages/storage/src/root-authority.ts', true], + ['packages/runtime-host/src/server/control-endpoint.ts', true], + ['scripts/windows-runtime-host-local-ipc-trust.ps1', true], + ['.github/workflows/windows-recovery.yml', true], + ['package-lock.json', true], + ['apps/desktop/src/main/index.ts', false], + ['packages/ui/src/button.tsx', false], + ['docs/windows-support.md', false], + ]) { + assert.equal(planTests([path], { graph }).windowsRecovery, expected, path); + } +}); + +test('every Windows recovery gate runs behind the planner selection', () => { + const workflow = readWorkflow('windows-recovery.yml'); + + // The lane stays a required context, so it has to report on every pull + // request and cannot carry a paths filter. The relevance check therefore + // lives inside the job, and every step that installs, builds or verifies has + // to sit behind it or an unrelated diff pays the lane's full cost. + assert.match(workflow, /\n {6}- id: plan\n {8}name: Select the recovery surface/u); + assert.match(workflow, /node scripts\/ci-test-plan\.mjs --full/u); + // Read from the `on:` block rather than the whole file, and reject the filter + // wherever it sits inside a trigger: a required context behind one would stop + // reporting and leave every unrelated pull request pending forever. + assert.doesNotMatch(triggerBlock('windows-recovery.yml'), /\bpaths(-ignore)?:/u); + + const steps = workflow.split(/\n {6}- (?=id:|name:|uses:)/u).slice(1); + assert.ok(steps.length >= 9, `unexpected step count: ${steps.length}`); + for (const step of steps) { + const heading = step.split('\n', 1)[0]; + if (heading.startsWith('id: plan') || step.includes('actions/checkout@')) continue; + assert.ok( + step.includes("if: steps.plan.outputs.windows_recovery == 'true'"), + `step runs unconditionally: ${heading}`, + ); + } +}); + +test('the recovery planner needs history to compare against', () => { + // Without full history `git cat-file -e` on the base fails and the lane falls + // back to running every gate, which is safe but silently permanent. + const workflow = readWorkflow('windows-recovery.yml'); + const checkout = checkoutSteps('windows-recovery.yml')[0]; + + assert.match(checkout, /fetch-depth: 0/u); + assert.match(workflow, /git cat-file -e "\$\{BASE_SHA\}\^\{commit\}"/u); +}); + test('core CI validates pull requests and the resulting main branch state', () => { const workflow = readWorkflow('ci.yml'); @@ -352,80 +410,14 @@ test('pull request triggers stay on an explicit allowlist', () => { ]); }); -test('the recovery lane pairs its path filter with a nightly run and a main push', () => { +test('Windows recovery publishes one stable PR and main check for ruleset enforcement', () => { const workflow = readWorkflow('windows-recovery.yml'); - // Same contract as the sandbox lane: 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 every - // Windows recovery run back on every pull request. The main push carries no - // filter because `strict: false` lets a stale-base pull request go green, - // and because a diff over 3,000 files can skip a paths filter outright. - assert.match(workflow, /\n {2}pull_request:\n {4}branches: \[main\]\n {4}paths:/u); - assert.match(workflow, /\n {2}push:\n {4}branches: \[main\]\n {2}schedule:/u); + assert.match(workflow, /\n {2}pull_request:\n {4}branches: \[main\]/u); + assert.match(workflow, /\n {2}push:\n {4}branches: \[main\]/u); assert.match(workflow, /\n {2}workflow_dispatch:/u); assert.match(workflow, /\n {4}name: windows_recovery/u); -}); - -test('the recovery lane keeps every run kind out of one shared concurrency group', () => { - const workflow = readWorkflow('windows-recovery.yml'); - - // github.head_ref is a bare branch name, so two forks pushing their own - // `main` would share a group and cancel each other; github.ref is - // refs/heads/main for the nightly, a dispatch and a main push alike, so a - // ref-keyed group made a dispatch queue behind the nightly and let the next - // dispatch discard it while pending. - assert.match( - workflow, - /group: windows-recovery-\$\{\{ github\.event\.pull_request\.number \|\| github\.run_id \}\}/u, - ); - assert.match(workflow, /\n {2}cancel-in-progress: true/u); -}); - -test('the recovery lane filters pull requests by the workspaces its steps build', () => { - const workflow = readWorkflow('windows-recovery.yml'); - const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); - - // Derived from the dist paths the steps actually run, then widened along the - // TypeScript project references those workspaces compile against. A new - // workspace on this lane, or a new reference under one of them, fails here - // until the filter admits its sources and project file. - const executed = [ - ...new Set([...workflow.matchAll(/packages\/([^/]+)\/dist\//gu)].map((match) => match[1])), - ].sort(); - assert.deepEqual(executed, ['runtime', 'runtime-host', 'storage']); - - const closure = projectReferenceClosure(executed); - assert.ok(closure.includes('core'), 'project reference closure must reach core'); - for (const workspace of closure) { - assert.ok(filtered.has(`packages/${workspace}/src/**`), `${workspace}: sources`); - assert.ok(filtered.has(`packages/${workspace}/tsconfig.json`), `${workspace}: project file`); - } -}); - -test('the recovery lane filters pull requests by what its install and clean steps consume', () => { - const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); - - // `npm.cmd ci` and `npm.cmd run build:test` run unconditionally, so these are - // first-class inputs of the lane rather than transitive edits the nightly can - // be left to cover. A grouped dependabot bump touches only the manifests, and - // the crash gates sit on a native file lock the Linux `test` lane never sees. - for (const path of [ - 'package.json', - 'package-lock.json', - 'patches/**', - 'scripts/apply-dependency-patches.mjs', - 'scripts/install-electron-with-retry.mjs', - 'scripts/clean-build.mjs', - 'scripts/clean-paths.mjs', - 'scripts/windows-runtime-host-local-ipc-trust.ps1', - 'tsconfig.base.json', - 'tsconfig.lib.json', - 'packages/runtime/scripts/**', - '.github/workflows/windows-recovery.yml', - ]) { - assert.ok(filtered.has(path), path); - } + assert.match(workflow, /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/u); }); test('the sandbox lane pairs its path filter with a nightly run', () => { @@ -560,50 +552,6 @@ test('core CI runs the live Eval proxy lifecycle when Eval is selected', () => { const WORKFLOW_DIR = new URL('../.github/workflows/', import.meta.url); -/** - * Reads the `paths` list belonging to a workflow's `pull_request` trigger. - * Anchoring to the trigger, instead of matching entry text anywhere in the - * file, is what makes the filter assertions fail when entries move under - * `paths-ignore`, under another trigger, or out of `on:` altogether. - */ -function pullRequestPathFilter(name) { - const lines = readWorkflow(name).split('\n'); - const start = lines.indexOf(' pull_request:'); - assert.ok(start >= 0, `${name}: no pull_request trigger`); - - const paths = []; - let inPaths = false; - for (const line of lines.slice(start + 1)) { - if (/^ {0,2}\S/u.test(line)) break; - if (/^ {4}\S/u.test(line)) { - inPaths = line === ' paths:'; - continue; - } - const entry = inPaths ? /^ {6}- '(.+)'$/u.exec(line) : null; - if (entry) paths.push(entry[1]); - } - return paths; -} - -/** Workspace names reachable from `workspaces` through tsconfig references. */ -function projectReferenceClosure(workspaces) { - const selected = new Set(workspaces); - const pending = [...workspaces]; - while (pending.length > 0) { - const workspace = pending.shift(); - const config = JSON.parse( - readFileSync(new URL(`../packages/${workspace}/tsconfig.json`, import.meta.url), 'utf8'), - ); - for (const reference of config.references ?? []) { - const name = reference.path.replace(/^\.\.\//u, ''); - if (selected.has(name)) continue; - selected.add(name); - pending.push(name); - } - } - return [...selected].sort(); -} - function readWorkflow(name) { return readFileSync(new URL(name, WORKFLOW_DIR), 'utf8'); } @@ -612,11 +560,14 @@ function readWorkflow(name) { * 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) { +function triggerBlock(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); + return withoutComments.match(/^on:(.*(?:\n(?![^\s#]).*)*)/mu)?.[1] ?? ''; +} + +function hasPullRequestTrigger(name) { + return /\bpull_request(_target)?\b/u.test(triggerBlock(name)); } /** diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 4a8bfc4fd6..0a3754e071 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -724,7 +724,10 @@ test('one product workflow gates one draft release on every required artifact', test('repository control plane admits only reviewed immutable release tags', async () => { const config = parseYaml(await readFile(new URL('../.asf.yaml', import.meta.url), 'utf8')); - assert.deepEqual(config.github.protected_branches.main.required_status_checks.contexts, ['test']); + assert.deepEqual(config.github.protected_branches.main.required_status_checks.contexts, [ + 'test', + 'windows_recovery', + ]); const environments = config.github.environments; for (const [name, tagPattern] of [ ['release', 'v*-incubating-rc*'], From cb354b444d6c19a9faea90a4dedff4d353d9c040 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 19:32:10 +0800 Subject: [PATCH 5/7] ci(windows): scope the recovery lane and stop requiring it Reverts the in-job planner selection and restores the paths filter, with the review findings from both approaches folded in. The in-job version kept windows_recovery required, which is only possible if the check reports on every pull request, which is only possible if the lane starts a Windows runner on every pull request. It cut the time an unrelated diff spent there but not the runner start, and #3261 measured starts. Windows is one of four platforms this project is heading for, and the other three hold no required gate today: runtime-host-owner-platform covers macOS and Windows, release-windows-check and windows-sandbox-w0 cover Windows, and none of them blocks a merge. A required gate for one platform out of four is the asymmetry being removed here, not a guarantee being given up. What replaces the blocking check is the unfiltered main push, which observes the merged result immediately and attributes a regression to one commit, plus the nightly for the transitive edits a static list cannot match. The concurrency key deliberately gives each main push its own group so no merge supersedes another's evidence. The filter covers the workspaces whose gates the steps execute, every workspace they depend on, and the manifests, project files, patches and scripts the unconditional install and clean steps consume. Its contract test derives the workspace set from the dist paths the steps run and widens it with loadWorkspaceGraph, the same graph the planner selects with, so this does not introduce a second definition of the dependency edges. The workflow scanners read the comment-stripped `on:` block through the existing triggerBlock helper and accept the quoting YAML allows, so documenting a trigger or requoting an entry no longer reports a filter that is plainly there as missing. Generated-by: Claude Opus --- .asf.yaml | 8 +- .github/workflows/windows-baseline.yml | 2 +- .github/workflows/windows-recovery.yml | 88 +++++++---- scripts/ci-test-plan.mjs | 27 ---- scripts/ci-test-plan.test.mjs | 205 +++++++++++++++++-------- scripts/product-release.test.mjs | 5 +- 6 files changed, 203 insertions(+), 132 deletions(-) diff --git a/.asf.yaml b/.asf.yaml index abb77a54e5..afff5f4c74 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -67,12 +67,12 @@ github: # Storybook. Renaming it there, or adding a paths filter that stops # ci.yml from running, freezes every pull request: the check never # reports and no committer can override it. + # A required context must report on every pull request, so a lane + # behind a paths filter cannot be listed here: the filter would keep + # the workflow from starting and the check would stay pending forever. + # windows_recovery is filtered and therefore deliberately absent. contexts: - test - # Windows recovery is a separate native crash/owner-death boundary. - # The workflow runs on every PR and main push so this context can be - # required without leaving unrelated pull requests pending forever. - - windows_recovery rulesets: - name: Immutable release tags diff --git a/.github/workflows/windows-baseline.yml b/.github/workflows/windows-baseline.yml index a2f812c7a3..06f2dafaaa 100644 --- a/.github/workflows/windows-baseline.yml +++ b/.github/workflows/windows-baseline.yml @@ -149,7 +149,7 @@ jobs: # Full packages/storage test:dist is ~10 minutes on windows-latest and # mostly duplicates the Linux unit lane. Baseline keeps process/path/ - # lock-sensitive gates here. Release-blocking crash evidence belongs to + # lock-sensitive gates here. Crash and owner-death evidence belongs to # windows-recovery.yml, so this diagnostic lane does not duplicate it. # Bump concurrency carefully — several suites spawn child processes and # fight for disk under high fan-out. diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 1396c4e762..84711e39f1 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -17,16 +17,73 @@ name: Windows recovery +# The paths below are a pre-filter, not this lane's real input. The real input +# is the import closure of the crash and owner-death recovery authorities in +# storage, runtime and Runtime Host, which reaches well past any list worth +# hand-maintaining. So they name those workspaces and every workspace they hold +# a TypeScript project reference to, plus the manifests, patches and scripts +# the unconditional install and clean steps consume. That keeps a change there +# reported before merge, and the nightly run covers the transitive edits the +# list cannot match. +# +# A pull request whose diff exceeds 3,000 files can skip the filter outright, +# because GitHub does not promise the matched files are in the first 3,000 it +# returns. A repository-wide sweep is exactly the change that touches every +# recovery authority at once, which is one more reason the main push below +# carries no filter at all. on: pull_request: branches: [main] + paths: + - 'package.json' + - 'package-lock.json' + - 'patches/**' + - 'scripts/apply-dependency-patches.mjs' + - 'scripts/install-electron-with-retry.mjs' + - 'scripts/clean-build.mjs' + - 'scripts/clean-paths.mjs' + - 'scripts/windows-runtime-host-local-ipc-trust.ps1' + - 'tsconfig.base.json' + - 'tsconfig.lib.json' + - 'packages/core/package.json' + - 'packages/core/tsconfig.json' + - 'packages/core/src/**' + - 'packages/storage/package.json' + - 'packages/storage/tsconfig.json' + - 'packages/storage/src/**' + - 'packages/runtime/package.json' + - 'packages/runtime/tsconfig.json' + - 'packages/runtime/src/**' + - 'packages/runtime/scripts/**' + - 'packages/runtime-host/package.json' + - 'packages/runtime-host/tsconfig.json' + - 'packages/runtime-host/src/**' + - '.github/workflows/windows-recovery.yml' + # Unfiltered on purpose: required_status_checks is `strict: false`, so a pull + # request goes green against a stale base and only the merged result proves + # two independently green halves still agree. It is also the backstop for the + # 3,000-file case above. The nightly alone would find either a day later, + # against a batch of commits instead of one. push: branches: [main] + schedule: + # Offset from windows-sandbox-w0 so the Windows lanes do not overlap. + - cron: '17 8 * * *' workflow_dispatch: +# Pull request pushes supersede each other, keyed on the pull request number +# because github.head_ref is a bare branch name two forks can share: a second +# contributor pushing to their own `main` would otherwise cancel the first +# contributor's run, and a cancelled check is not a failed one. +# Scheduled, manual and main-push runs fall back to the run id, since +# github.ref is refs/heads/main for all three and one shared group would let a +# dispatch queue behind the nightly and then be discarded while still pending. +# That deliberately stops main pushes from superseding each other: this lane is +# the only place a merged Windows regression is observed, so every merge needs +# its own evidence rather than only the newest one surviving. concurrency: - group: windows-recovery-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: windows-recovery-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true permissions: contents: read @@ -39,42 +96,20 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false - - id: plan - name: Select the recovery surface - shell: bash - env: - BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event_name == 'push' && github.sha || github.event.pull_request.head.sha }} - run: | - # The same planner the core lane selects with, so this lane and - # `test` agree on what a diff touches instead of keeping a second - # relevance authority in YAML. A dispatch, a first push and any - # unavailable history all fail safe to running every gate. - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] || [[ "$BASE_SHA" =~ ^0+$ ]] || ! git cat-file -e "${BASE_SHA}^{commit}"; then - node scripts/ci-test-plan.mjs --full >> "$GITHUB_OUTPUT" - else - node scripts/ci-test-plan.mjs --base "$BASE_SHA" --head "$HEAD_SHA" >> "$GITHUB_OUTPUT" - fi - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - if: steps.plan.outputs.windows_recovery == 'true' with: node-version: "24" cache: npm - name: Install dependencies - if: steps.plan.outputs.windows_recovery == 'true' run: npm.cmd ci - name: Build test artifacts - if: steps.plan.outputs.windows_recovery == 'true' run: npm.cmd run build:test - name: Verify managed dependency alternate streams - if: steps.plan.outputs.windows_recovery == 'true' shell: pwsh run: | node.exe --test --test-reporter=tap --test-concurrency=1 ` @@ -90,14 +125,12 @@ jobs: } - name: Verify Runtime Host Local IPC trust boundary - if: steps.plan.outputs.windows_recovery == 'true' shell: pwsh run: | node.exe --test packages/runtime-host/dist/__tests__/control-endpoint.test.js ./scripts/windows-runtime-host-local-ipc-trust.ps1 - name: Verify SQLite crash recovery - if: steps.plan.outputs.windows_recovery == 'true' shell: pwsh run: | node.exe --test --test-concurrency=1 ` @@ -105,7 +138,6 @@ jobs: packages/storage/dist/__tests__/sqlite-long-term-memory-crash.test.js - name: Verify Runtime continuation recovery - if: steps.plan.outputs.windows_recovery == 'true' shell: pwsh run: | node.exe --test --test-concurrency=1 ` @@ -113,7 +145,6 @@ jobs: packages/runtime/dist/__tests__/runtime-continuation-crash.test.js - name: Verify Runtime Host owner-death recovery - if: steps.plan.outputs.windows_recovery == 'true' shell: pwsh run: | node.exe --test --test-reporter=tap --test-concurrency=1 ` @@ -130,7 +161,6 @@ jobs: } - name: Verify managed workspace crash recovery - if: steps.plan.outputs.windows_recovery == 'true' shell: pwsh run: | $env:MAKA_STORAGE_STRESS = '1' diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 9379445424..15a6010e3f 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -145,30 +145,6 @@ function isReleaseContractPath(path) { ); } -// The Windows recovery lane executes the crash and owner-death gates that live -// in these workspaces. Selection is the reverse dependency closure below, so a -// packages/core change reaches them without being named here. -const WINDOWS_RECOVERY_WORKSPACES = [ - 'packages/storage', - 'packages/runtime', - 'packages/runtime-host', -]; - -// Inputs that lane consumes from outside the workspace graph: its own -// definition, the trust probe it shells out to, and the base project files its -// build step compiles every workspace against. -const WINDOWS_RECOVERY_FILES = new Set([ - '.github/workflows/windows-recovery.yml', - 'scripts/windows-runtime-host-local-ipc-trust.ps1', - 'tsconfig.base.json', - 'tsconfig.lib.json', -]); - -function selectsWindowsRecovery(files, workspaces) { - if (files.some((path) => WINDOWS_RECOVERY_FILES.has(path))) return true; - return WINDOWS_RECOVERY_WORKSPACES.some((workspace) => workspaces.includes(workspace)); -} - const DEDICATED_WORKSPACE_LANES = new Set(['packages/runtime-host']); // Scripts the Electron e2e job runs. Editing one of these changes what that @@ -374,7 +350,6 @@ export function planTests(changedFiles, options = {}) { // every unrelated merge into a 10K-chunk pressure run. storageStress: false, storybook: true, - windowsRecovery: true, workspaces, ...workspaceLanes(workspaces, graph), }; @@ -445,7 +420,6 @@ export function planTests(changedFiles, options = {}) { // PR — product ship gates are typecheck, unit, and Electron e2e. See // isStorybookPath. storybook: files.some((path) => isStorybookPath(path)), - windowsRecovery: selectsWindowsRecovery(files, workspaces), workspaces, ...workspaceLanes(workspaces, graph), }; @@ -463,7 +437,6 @@ export function formatGitHubOutputs(plan) { `release_contract=${plan.releaseContract}`, `storage_stress=${plan.storageStress}`, `storybook=${plan.storybook}`, - `windows_recovery=${plan.windowsRecovery}`, `standard_workspaces=${plan.standardWorkspaces.join(',')}`, ].join('\n'); } diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 010e55d914..931226b726 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -21,7 +21,7 @@ import assert from 'node:assert/strict'; import { readdirSync, readFileSync } from 'node:fs'; import test from 'node:test'; -import { formatGitHubOutputs, planTests } from './ci-test-plan.mjs'; +import { formatGitHubOutputs, loadWorkspaceGraph, planTests } from './ci-test-plan.mjs'; const dirs = [ 'packages/core', @@ -236,72 +236,14 @@ test('full-suite authority files select every surface', () => { test('GitHub output matches the selections consumed by CI', () => { const output = formatGitHubOutputs(planTests([], { graph, forceFull: true })); const outputKeys = new Set(output.split('\n').map((line) => line.split('=', 1)[0])); - // Both planning lanes, so an output that no workflow reads fails here rather - // than accumulating as a selection nobody acts on. + const workflow = readWorkflow('ci.yml'); const consumedKeys = new Set( - ['ci.yml', 'windows-recovery.yml'] - .flatMap((name) => [...readWorkflow(name).matchAll(/steps\.plan\.outputs\.([a-z0-9_]+)/gu)]) - .map((match) => match[1]), + [...workflow.matchAll(/steps\.plan\.outputs\.([a-z0-9_]+)/gu)].map((match) => match[1]), ); assert.deepEqual(outputKeys, consumedKeys); }); -test('the Windows recovery lane is selected through the workspaces it executes', () => { - // packages/core is not named in the selection: the reverse dependency closure - // reaches storage, runtime and Runtime Host on its own, which is why this - // lane does not need a second hand-maintained path list. - for (const [path, expected] of [ - ['packages/core/src/session.ts', true], - ['packages/storage/src/root-authority.ts', true], - ['packages/runtime-host/src/server/control-endpoint.ts', true], - ['scripts/windows-runtime-host-local-ipc-trust.ps1', true], - ['.github/workflows/windows-recovery.yml', true], - ['package-lock.json', true], - ['apps/desktop/src/main/index.ts', false], - ['packages/ui/src/button.tsx', false], - ['docs/windows-support.md', false], - ]) { - assert.equal(planTests([path], { graph }).windowsRecovery, expected, path); - } -}); - -test('every Windows recovery gate runs behind the planner selection', () => { - const workflow = readWorkflow('windows-recovery.yml'); - - // The lane stays a required context, so it has to report on every pull - // request and cannot carry a paths filter. The relevance check therefore - // lives inside the job, and every step that installs, builds or verifies has - // to sit behind it or an unrelated diff pays the lane's full cost. - assert.match(workflow, /\n {6}- id: plan\n {8}name: Select the recovery surface/u); - assert.match(workflow, /node scripts\/ci-test-plan\.mjs --full/u); - // Read from the `on:` block rather than the whole file, and reject the filter - // wherever it sits inside a trigger: a required context behind one would stop - // reporting and leave every unrelated pull request pending forever. - assert.doesNotMatch(triggerBlock('windows-recovery.yml'), /\bpaths(-ignore)?:/u); - - const steps = workflow.split(/\n {6}- (?=id:|name:|uses:)/u).slice(1); - assert.ok(steps.length >= 9, `unexpected step count: ${steps.length}`); - for (const step of steps) { - const heading = step.split('\n', 1)[0]; - if (heading.startsWith('id: plan') || step.includes('actions/checkout@')) continue; - assert.ok( - step.includes("if: steps.plan.outputs.windows_recovery == 'true'"), - `step runs unconditionally: ${heading}`, - ); - } -}); - -test('the recovery planner needs history to compare against', () => { - // Without full history `git cat-file -e` on the base fails and the lane falls - // back to running every gate, which is safe but silently permanent. - const workflow = readWorkflow('windows-recovery.yml'); - const checkout = checkoutSteps('windows-recovery.yml')[0]; - - assert.match(checkout, /fetch-depth: 0/u); - assert.match(workflow, /git cat-file -e "\$\{BASE_SHA\}\^\{commit\}"/u); -}); - test('core CI validates pull requests and the resulting main branch state', () => { const workflow = readWorkflow('ci.yml'); @@ -410,14 +352,93 @@ test('pull request triggers stay on an explicit allowlist', () => { ]); }); -test('Windows recovery publishes one stable PR and main check for ruleset enforcement', () => { +test('the recovery lane pairs its path filter with a nightly run and a main push', () => { + // Read from the `on:` block with comments stripped, so documenting a trigger + // cannot break its contract. + const triggers = triggerBlock('windows-recovery.yml'); + + // Same contract as the sandbox lane: 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 every + // Windows recovery run back on every pull request. The main push carries no + // filter because `strict: false` lets a stale-base pull request go green, + // and because a diff over 3,000 files can skip a paths filter outright. + // Stripped comment lines survive as blank ones, so the gap between the + // trigger and its list is any mix of blank and four-space lines. + assert.match(triggers, /\n {2}pull_request:\n(?:(?: {4}[^\n]*)?\n)* {4}paths:/u); + assert.match(triggers, /\n {2}push:\n {4}branches: \[main\]\n/u); + assert.doesNotMatch( + triggers.match(/\n {2}push:\n(?:(?: {4}[^\n]*)?\n)*/u)?.[0] ?? '', + /\bpaths(-ignore)?:/u, + ); + assert.match(triggers, /\n {2}schedule:\n/u); + assert.match(triggers, /\n {2}workflow_dispatch:/u); + assert.match(readWorkflow('windows-recovery.yml'), /\n {4}name: windows_recovery/u); +}); + +test('the recovery lane keeps every run kind out of one shared concurrency group', () => { + const workflow = readWorkflow('windows-recovery.yml'); + + // github.head_ref is a bare branch name, so two forks pushing their own + // `main` would share a group and cancel each other; github.ref is + // refs/heads/main for the nightly, a dispatch and a main push alike, so a + // ref-keyed group made a dispatch queue behind the nightly and let the next + // dispatch discard it while pending. + assert.match( + workflow, + /group: windows-recovery-\$\{\{ github\.event\.pull_request\.number \|\| github\.run_id \}\}/u, + ); + assert.match(workflow, /\n {2}cancel-in-progress: true/u); +}); + +test('the recovery lane filters pull requests by the workspaces its steps execute', () => { const workflow = readWorkflow('windows-recovery.yml'); + const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); + + // Derived from the dist paths the steps run, then widened along the workspace + // dependency graph the planner selects with. The separator class matches the + // backslash form too, because these steps run under pwsh where both are + // legal. A new workspace on this lane, or a new dependency under one of them, + // fails here until the filter admits its sources and project file. + const executed = [ + ...new Set( + [...workflow.matchAll(/packages[/\\]([^/\\]+)[/\\]dist[/\\]/gu)].map((match) => match[1]), + ), + ].sort(); + assert.deepEqual(executed, ['runtime', 'runtime-host', 'storage']); + + const closure = dependencyClosure(executed.map((workspace) => `packages/${workspace}`)); + assert.ok(closure.includes('packages/core'), 'dependency closure must reach core'); + for (const dir of closure) { + assert.ok(filtered.has(`${dir}/src/**`), `${dir}: sources`); + assert.ok(filtered.has(`${dir}/tsconfig.json`), `${dir}: project file`); + assert.ok(filtered.has(`${dir}/package.json`), `${dir}: manifest`); + } +}); + +test('the recovery lane filters pull requests by what its install and clean steps consume', () => { + const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); - assert.match(workflow, /\n {2}pull_request:\n {4}branches: \[main\]/u); - assert.match(workflow, /\n {2}push:\n {4}branches: \[main\]/u); - assert.match(workflow, /\n {2}workflow_dispatch:/u); - assert.match(workflow, /\n {4}name: windows_recovery/u); - assert.match(workflow, /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/u); + // `npm.cmd ci` and `npm.cmd run build:test` run unconditionally, so these are + // first-class inputs of the lane rather than transitive edits the nightly can + // be left to cover. A grouped dependabot bump touches only the manifests, and + // the crash gates sit on a native file lock the Linux `test` lane never sees. + for (const path of [ + 'package.json', + 'package-lock.json', + 'patches/**', + 'scripts/apply-dependency-patches.mjs', + 'scripts/install-electron-with-retry.mjs', + 'scripts/clean-build.mjs', + 'scripts/clean-paths.mjs', + 'scripts/windows-runtime-host-local-ipc-trust.ps1', + 'tsconfig.base.json', + 'tsconfig.lib.json', + 'packages/runtime/scripts/**', + '.github/workflows/windows-recovery.yml', + ]) { + assert.ok(filtered.has(path), path); + } }); test('the sandbox lane pairs its path filter with a nightly run', () => { @@ -552,6 +573,56 @@ test('core CI runs the live Eval proxy lifecycle when Eval is selected', () => { const WORKFLOW_DIR = new URL('../.github/workflows/', import.meta.url); +/** + * Reads the `paths` list belonging to a workflow's `pull_request` trigger. + * Anchoring to the trigger, instead of matching entry text anywhere in the + * file, is what makes the filter assertions fail when entries move under + * `paths-ignore`, under another trigger, or out of `on:` altogether. + */ +function pullRequestPathFilter(name) { + // Reads the `on:` block with comments already stripped, so a comment between + // the trigger and its list cannot end the scan, and accepts the quoting and + // spacing YAML allows, so a legal rewrite reports the entries it really has + // instead of an empty list that reads as a missing filter. + const lines = triggerBlock(name).split('\n'); + const start = lines.findIndex((line) => /^ {2}pull_request:\s*$/u.test(line)); + assert.ok(start >= 0, `${name}: no pull_request trigger`); + + const paths = []; + let inPaths = false; + for (const line of lines.slice(start + 1)) { + if (line.trim() === '') continue; + if (/^ {0,2}\S/u.test(line)) break; + if (/^ {4}\S/u.test(line)) { + inPaths = /^ {4}paths:\s*$/u.test(line); + continue; + } + const entry = inPaths ? /^\s+-\s+['"]?(.+?)['"]?\s*$/u.exec(line) : null; + if (entry) paths.push(entry[1]); + } + return paths; +} + +/** + * Workspace dirs `seeds` depend on, transitively, read off the same graph the + * planner selects with rather than a second definition of the same edges. The + * graph stores dependents, so a dependency is any dir listing one of ours. + */ +function dependencyClosure(seeds) { + const graph = loadWorkspaceGraph(); + const selected = new Set(seeds); + const pending = [...seeds]; + while (pending.length > 0) { + const dir = pending.shift(); + for (const [dependency, dependents] of graph.dependents) { + if (!dependents.has(dir) || selected.has(dependency)) continue; + selected.add(dependency); + pending.push(dependency); + } + } + return [...selected].sort(); +} + function readWorkflow(name) { return readFileSync(new URL(name, WORKFLOW_DIR), 'utf8'); } diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 0a3754e071..4a8bfc4fd6 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -724,10 +724,7 @@ test('one product workflow gates one draft release on every required artifact', test('repository control plane admits only reviewed immutable release tags', async () => { const config = parseYaml(await readFile(new URL('../.asf.yaml', import.meta.url), 'utf8')); - assert.deepEqual(config.github.protected_branches.main.required_status_checks.contexts, [ - 'test', - 'windows_recovery', - ]); + assert.deepEqual(config.github.protected_branches.main.required_status_checks.contexts, ['test']); const environments = config.github.environments; for (const [name, tagPattern] of [ ['release', 'v*-incubating-rc*'], From 33f3eea292f1cb21de6f162314561f9c08c1a381 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 21:21:18 +0800 Subject: [PATCH 6/7] ci(windows): follow the postinstall launcher into the recovery filter `scripts/install-electron-with-retry.mjs` launches `scripts/run-electron-installer.cjs`, and postinstall runs the former on every `npm ci`, so the launcher decides what the install step produces on Windows. It was missing from the filter, and the contract test did not notice because it restated a hand-written list rather than deriving one. The test now reads postinstall out of package.json, requires each entry point it invokes to be filtered, and follows one hop into the local scripts those entry points launch. Removing the launcher from the filter fails it. Generated-by: Claude Opus --- .github/workflows/windows-recovery.yml | 1 + scripts/ci-test-plan.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 84711e39f1..79de12a5cc 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -40,6 +40,7 @@ on: - 'patches/**' - 'scripts/apply-dependency-patches.mjs' - 'scripts/install-electron-with-retry.mjs' + - 'scripts/run-electron-installer.cjs' - 'scripts/clean-build.mjs' - 'scripts/clean-paths.mjs' - 'scripts/windows-runtime-host-local-ipc-trust.ps1' diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 931226b726..73aed17667 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -416,6 +416,26 @@ test('the recovery lane filters pull requests by the workspaces its steps execut } }); +test('the recovery lane filter follows the postinstall launcher chain', () => { + const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); + const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + // Derived from postinstall itself, then one hop into whatever those entry + // points launch, because a launcher the filter cannot see still decides what + // `npm ci` produces on Windows. A restated list missed exactly that hop. + const entrypoints = [...manifest.scripts.postinstall.matchAll(/node (scripts\/[\w.-]+)/gu)].map( + (match) => match[1], + ); + assert.ok(entrypoints.length > 0, 'postinstall runs no script'); + + for (const entrypoint of entrypoints) { + assert.ok(filtered.has(entrypoint), entrypoint); + const source = readFileSync(new URL(`../${entrypoint}`, import.meta.url), 'utf8'); + for (const launched of source.matchAll(/new URL\('\.\/([\w.-]+)'/gu)) { + assert.ok(filtered.has(`scripts/${launched[1]}`), `${entrypoint} launches ${launched[1]}`); + } + } +}); + test('the recovery lane filters pull requests by what its install and clean steps consume', () => { const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); From be898fb2908da4d7516dbe15e939f0e01330d2ed Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 26 Aug 2026 21:56:44 +0800 Subject: [PATCH 7/7] docs(ci): correct the path-filter diff limit to 300 files GitHub evaluates a pull request path filter against the first 300 files of the diff, not 3,000; 3,000 is the separate per-page ceiling on the pull request Files REST API. The recovery lane's trigger comment and its contract test both cited the wrong limit. The unfiltered main push that covers the case is unchanged. Generated-by: Claude Opus --- .github/workflows/windows-recovery.yml | 7 +++---- scripts/ci-test-plan.test.mjs | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 79de12a5cc..8f5c4966bf 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -26,9 +26,8 @@ name: Windows recovery # reported before merge, and the nightly run covers the transitive edits the # list cannot match. # -# A pull request whose diff exceeds 3,000 files can skip the filter outright, -# because GitHub does not promise the matched files are in the first 3,000 it -# returns. A repository-wide sweep is exactly the change that touches every +# GitHub evaluates a path filter against the first 300 files of the diff only, +# so a pull request wider than that can skip the filter outright. A repository-wide sweep is exactly the change that touches every # recovery authority at once, which is one more reason the main push below # carries no filter at all. on: @@ -63,7 +62,7 @@ on: # Unfiltered on purpose: required_status_checks is `strict: false`, so a pull # request goes green against a stale base and only the merged result proves # two independently green halves still agree. It is also the backstop for the - # 3,000-file case above. The nightly alone would find either a day later, + # 300-file case above. The nightly alone would find either a day later, # against a batch of commits instead of one. push: branches: [main] diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 73aed17667..974b4c24c4 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -362,7 +362,7 @@ test('the recovery lane pairs its path filter with a nightly run and a main push // transitive edit it cannot match, and dropping the filter would put every // Windows recovery run back on every pull request. The main push carries no // filter because `strict: false` lets a stale-base pull request go green, - // and because a diff over 3,000 files can skip a paths filter outright. + // and because a paths filter only sees the first 300 files of a diff. // Stripped comment lines survive as blank ones, so the gap between the // trigger and its list is any mix of blank and four-space lines. assert.match(triggers, /\n {2}pull_request:\n(?:(?: {4}[^\n]*)?\n)* {4}paths:/u);