From 9f1179e83a182a782acac0116df13dcd03c96215 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 18:12:39 +0800 Subject: [PATCH 01/13] ci: merge planning and validation into the required test job Splitting path planning from validation made every pull request queue for a scarce runner three times to reach one verdict. `plan` executed for 19 seconds and the `test` aggregation job for four, yet each allocation waited on its own: across 32 runs with complete timing the three queues averaged 5m57s, 4m46s and 7m57s. The distribution is bimodal, about a minute per stage while the pool is free and about twenty while it is starved, so the two extra allocations cost roughly 12m44s of pure waiting per run. Planning is now the first step of one job and every later step gates on `steps.plan.outputs`, which is what those steps already did through the job boundary. Nothing is validated that was not validated before, and a documentation-only change costs one short allocation rather than the two it previously paid for the planning and aggregation jobs. The job keeps the name `test` because that is the required context in `.asf.yaml`. Renaming it would leave that check unreported on every open pull request until the rename merged, and nothing could merge while it was unreported. The longest validation observed is 25m37s with every lane selected, so a single job stays well inside the 45-minute limit. Generated-by: Claude Code --- .github/workflows/ci.yml | 167 +++++++------------- scripts/asf-source-workflow-policy.test.mjs | 2 +- scripts/ci-test-plan.test.mjs | 62 ++++---- 3 files changed, 85 insertions(+), 146 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b96b70a3c2..9885a0386a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,26 +32,18 @@ permissions: contents: read jobs: - # Path planning and install-free repository contracts stay on the cheap lane - # that every pull request must run. The outputs select the separate heavy lane - # without teaching the workflow a second copy of the path rules. - plan: + # One job, not three. A runner slot is scarcer than a minute on shared + # infrastructure, and splitting planning from validation made every pull + # request queue for a runner three times to reach one verdict. Planning is + # the first step; every later step gates on its outputs, so a + # documentation-only change costs one short allocation rather than two. + # + # The name is `test` because that is the required context in `.asf.yaml`. + # Renaming it would leave that check unreported on every open pull request + # until the rename merged, and nothing could merge while it was unreported. + test: runs-on: ubuntu-latest timeout-minutes: 45 - outputs: - asf_source: ${{ steps.plan.outputs.asf_source }} - astryx_surface: ${{ steps.plan.outputs.astryx_surface }} - cli_package: ${{ steps.plan.outputs.cli_package }} - code: ${{ steps.plan.outputs.code }} - e2e: ${{ steps.plan.outputs.e2e }} - heavy: ${{ steps.plan.outputs.heavy }} - release_contract: ${{ steps.plan.outputs.release_contract }} - runtime_host: ${{ steps.plan.outputs.runtime_host }} - runtime_sandbox: ${{ steps.plan.outputs.runtime_sandbox }} - state_root_compat: ${{ steps.plan.outputs.state_root_compat }} - storage_stress: ${{ steps.plan.outputs.storage_stress }} - storybook: ${{ steps.plan.outputs.storybook }} - standard_workspaces: ${{ steps.plan.outputs.standard_workspaces }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -128,28 +120,20 @@ jobs: - name: Check ASF source headers run: npm run check:asf-headers - heavy: - needs: plan - if: needs.plan.outputs.heavy == 'true' - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - + # Everything below needs an installed toolchain. A plan that selected no + # surface stops at the install-free checks above. - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + if: steps.plan.outputs.heavy == 'true' with: node-version: '24' cache: npm - name: Select the release npm toolchain - if: needs.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.cli_package == 'true' run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" - name: Restore Electron artifact cache - if: needs.plan.outputs.code == 'true' + if: steps.plan.outputs.code == 'true' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/electron @@ -157,13 +141,13 @@ jobs: restore-keys: electron-${{ runner.os }}- - name: Install Linux runtime dependencies - if: needs.plan.outputs.runtime_sandbox == 'true' || needs.plan.outputs.state_root_compat == 'true' + if: steps.plan.outputs.runtime_sandbox == 'true' || steps.plan.outputs.state_root_compat == 'true' run: sudo apt-get update && sudo apt-get install -y ripgrep bubblewrap # Ubuntu 24.04 hosted runners gate unprivileged user namespaces through # AppArmor, which otherwise makes bwrap fail while configuring loopback. - name: Enable bubblewrap user namespaces - if: needs.plan.outputs.runtime_sandbox == 'true' || needs.plan.outputs.state_root_compat == 'true' + if: steps.plan.outputs.runtime_sandbox == 'true' || steps.plan.outputs.state_root_compat == 'true' run: | if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 @@ -173,21 +157,21 @@ jobs: fi - name: Install dependencies - if: needs.plan.outputs.code == 'true' || needs.plan.outputs.astryx_surface == 'true' || needs.plan.outputs.asf_source == 'true' || needs.plan.outputs.cli_package == 'true' || needs.plan.outputs.release_contract == 'true' + if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' run: npm ci # The header audit above remains install-free. The complete source gate # also exercises generation and therefore runs after its pinned formatter # dependency is installed, matching the source-candidate workflow. - name: Verify ASF source release mechanics - if: needs.plan.outputs.asf_source == 'true' + if: steps.plan.outputs.asf_source == 'true' run: npm run check:asf-source # Parsed dependency rules and an exact legacy-debt ledger keep the # renderer root from absorbing new feature or Desktop ownership while # the existing AppShell is migrated behind stable boundaries. - name: Check renderer architecture - if: needs.plan.outputs.code == 'true' + if: steps.plan.outputs.code == 'true' env: BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} run: | @@ -198,11 +182,11 @@ jobs: fi - name: Lint - if: needs.plan.outputs.code == 'true' + if: steps.plan.outputs.code == 'true' run: npm run lint - name: Check formatting - if: needs.plan.outputs.code == 'true' + if: steps.plan.outputs.code == 'true' run: npm run format:check # This generated artifact describes the whole renderer/UI tree, so every @@ -210,21 +194,21 @@ jobs: # an Astryx surface. Keep it before Build so stale output is reported # directly instead of being hidden behind an earlier compilation failure. - name: Astryx surface inventory - if: needs.plan.outputs.code == 'true' || needs.plan.outputs.astryx_surface == 'true' + if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' run: | npm run astryx:surface-inventory npm run astryx:surface-inventory:test - name: Build - if: needs.plan.outputs.code == 'true' || needs.plan.outputs.cli_package == 'true' || needs.plan.outputs.release_contract == 'true' + if: steps.plan.outputs.code == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' run: npm run build - name: Release contracts - if: needs.plan.outputs.release_contract == 'true' + if: steps.plan.outputs.release_contract == 'true' run: npm run check:release - name: Typecheck - if: needs.plan.outputs.code == 'true' + if: steps.plan.outputs.code == 'true' run: npm run typecheck # Two drift contracts over the shipped app-icon artwork, sitting beside @@ -233,32 +217,32 @@ jobs: # the generator, to DEFAULT_APP_ICON, or to the packaging config can go # green while the artwork it names no longer matches. - name: App icon artwork drift - if: needs.plan.outputs.code == 'true' + if: steps.plan.outputs.code == 'true' run: node --test scripts/verify-packaged-app-icons.test.mjs scripts/generate-app-icons.test.mjs - name: Astryx theme drift - if: needs.plan.outputs.code == 'true' + if: steps.plan.outputs.code == 'true' run: npm run astryx:theme -- --check - name: Knip (apps/desktop) - if: needs.plan.outputs.code == 'true' + if: steps.plan.outputs.code == 'true' run: npx knip --workspace apps/desktop - name: Knip (packages/ui) - if: needs.plan.outputs.code == 'true' + if: steps.plan.outputs.code == 'true' run: npx knip --workspace packages/ui - name: Linux sandbox smoke - if: needs.plan.outputs.runtime_sandbox == 'true' + if: steps.plan.outputs.runtime_sandbox == 'true' env: MAKA_REQUIRE_LINUX_SANDBOX_SMOKE: '1' run: npm exec -w @maka/runtime -- node --test dist/__tests__/linux-sandbox-smoke.test.js - name: Run affected standard workspace tests - if: needs.plan.outputs.standard_workspaces != '' + if: steps.plan.outputs.standard_workspaces != '' env: - STORAGE_STRESS: ${{ needs.plan.outputs.storage_stress }} - WORKSPACES: ${{ needs.plan.outputs.standard_workspaces }} + STORAGE_STRESS: ${{ steps.plan.outputs.storage_stress }} + WORKSPACES: ${{ steps.plan.outputs.standard_workspaces }} run: | if [[ "$STORAGE_STRESS" == "true" ]]; then export MAKA_STORAGE_STRESS=1 @@ -266,7 +250,7 @@ jobs: node scripts/run-workspace-tests-parallel.mjs --concurrency=3 --workspaces="$WORKSPACES" - name: Run live Eval egress proxy test - if: contains(needs.plan.outputs.standard_workspaces, 'packages/eval') + if: contains(steps.plan.outputs.standard_workspaces, 'packages/eval') env: MAKA_EVAL_EGRESS_PROXY_TEST: '1' run: | @@ -278,7 +262,7 @@ jobs: npm --workspace @maka/eval run test:egress-proxy:live - name: Run Runtime Host tests - if: needs.plan.outputs.runtime_host == 'true' + if: steps.plan.outputs.runtime_host == 'true' run: npm --workspace @maka/runtime-host run test:dist # A published predecessor writes the durable state; the workspace built @@ -287,11 +271,11 @@ jobs: # read that state. The release lanes still qualify exact tarballs. - id: forward-roll-baseline name: Resolve the published forward-roll baseline - if: needs.plan.outputs.state_root_compat == 'true' + if: steps.plan.outputs.state_root_compat == 'true' run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT" - name: Download the forward-roll baseline - if: needs.plan.outputs.state_root_compat == 'true' + if: steps.plan.outputs.state_root_compat == 'true' env: SOURCE_URL: ${{ steps.forward-roll-baseline.outputs.tarball_url }} SOURCE_INTEGRITY: ${{ steps.forward-roll-baseline.outputs.integrity }} @@ -313,7 +297,7 @@ jobs: } >> "$GITHUB_ENV" - name: Qualify durable state against the published baseline - if: needs.plan.outputs.state_root_compat == 'true' + if: steps.plan.outputs.state_root_compat == 'true' env: MAKA_QUALIFICATION_BWRAP_USE_SUDO: '1' run: | @@ -325,42 +309,42 @@ jobs: | tee "$RUNNER_TEMP/durable-state-report.json" - name: Ensure xvfb - if: needs.plan.outputs.e2e == 'true' + if: steps.plan.outputs.e2e == 'true' run: command -v xvfb-run >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y xvfb; } - name: Desktop e2e - if: needs.plan.outputs.e2e == 'true' + if: steps.plan.outputs.e2e == 'true' run: xvfb-run -a npm exec -w @maka/desktop -- playwright test --config e2e/playwright.config.ts - name: Browser WebContentsView semantic smoke - if: needs.plan.outputs.e2e == 'true' + if: steps.plan.outputs.e2e == 'true' # Hosted Linux runners cannot configure Electron's SUID helper. This # smoke loads only its loopback fixture; production stays sandboxed. run: xvfb-run -a npm exec --workspace @maka/desktop -- electron --no-sandbox scripts/browser-observe-act-smoke.mjs - name: Alignment audit - if: needs.plan.outputs.e2e == 'true' + if: steps.plan.outputs.e2e == 'true' run: xvfb-run -a node scripts/audit-alignment.mjs - name: Install Playwright Chromium - if: needs.plan.outputs.storybook == 'true' + if: steps.plan.outputs.storybook == 'true' run: npx playwright install --with-deps chromium - name: Build Storybook - if: needs.plan.outputs.storybook == 'true' + if: steps.plan.outputs.storybook == 'true' run: npm --workspace @maka/desktop run build-storybook - name: Storybook smoke - if: needs.plan.outputs.storybook == 'true' + if: steps.plan.outputs.storybook == 'true' run: npm --workspace @maka/desktop run smoke:storybook - name: Update stable Rust for CLI packaging - if: needs.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.cli_package == 'true' run: rustup update stable --no-self-update - id: cli-rustc name: Resolve CLI Rust cache version - if: needs.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.cli_package == 'true' shell: bash run: | echo "version=$(rustc --version | cut -d ' ' -f 2)" >> "$GITHUB_OUTPUT" @@ -372,14 +356,14 @@ jobs: } >> "$GITHUB_ENV" - name: Install Kache for CLI packaging - if: needs.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.cli_package == 'true' uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 with: tool: kache@0.16.0 - id: cli-kache-cache name: Restore CLI Rust build cache - if: needs.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.cli_package == 'true' uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/kache-cli-package @@ -388,68 +372,27 @@ jobs: kache-runtime-host-peer-cli-package-v0.16.0-${{ runner.os }}-${{ runner.arch }}-rust-${{ steps.cli-rustc.outputs.version }}- - name: Install cargo-deny for CLI packaging - if: needs.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.cli_package == 'true' uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 with: tool: cargo-deny@0.20.2 - name: Build CLI release candidate - if: needs.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.cli_package == 'true' run: npm run release:cli:pack -- --allow-dirty - name: Report CLI Rust build cache - if: needs.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.cli_package == 'true' shell: bash run: kache report --format github >> "$GITHUB_STEP_SUMMARY" - name: Save CLI Rust build cache - if: needs.plan.outputs.cli_package == 'true' && github.ref_name == github.event.repository.default_branch + if: steps.plan.outputs.cli_package == 'true' && github.ref_name == github.event.repository.default_branch uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/kache-cli-package key: ${{ steps.cli-kache-cache.outputs.cache-primary-key }} - name: Validate installed CLI release candidate - if: needs.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.cli_package == 'true' run: npm run release:cli:smoke -- packages/cli/release/*.tgz - - # Branch protection requires the `test` context on every pull request. Keep - # this aggregation job unconditional so documentation-only plans report - # success while selected heavy validation still propagates every failure. - test: - needs: [plan, heavy] - if: always() - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - name: Report required test status - env: - HEAVY_RESULT: ${{ needs.heavy.result }} - HEAVY_SELECTED: ${{ needs.plan.outputs.heavy }} - PLAN_RESULT: ${{ needs.plan.result }} - run: | - if [[ "$PLAN_RESULT" != "success" ]]; then - echo "::error::CI planning and lightweight checks ended with $PLAN_RESULT" - exit 1 - fi - - case "$HEAVY_SELECTED" in - true) - if [[ "$HEAVY_RESULT" != "success" ]]; then - echo "::error::Selected heavy CI ended with $HEAVY_RESULT" - exit 1 - fi - ;; - false) - if [[ "$HEAVY_RESULT" != "skipped" ]]; then - echo "::error::Unselected heavy CI ended with $HEAVY_RESULT instead of skipped" - exit 1 - fi - ;; - *) - echo "::error::CI planner produced an invalid heavy selection: $HEAVY_SELECTED" - exit 1 - ;; - esac - - echo "Required CI passed (heavy selected: $HEAVY_SELECTED)" diff --git a/scripts/asf-source-workflow-policy.test.mjs b/scripts/asf-source-workflow-policy.test.mjs index cb8e2784b5..1ba67fab64 100644 --- a/scripts/asf-source-workflow-policy.test.mjs +++ b/scripts/asf-source-workflow-policy.test.mjs @@ -75,7 +75,7 @@ describe('ASF source CI policy', () => { assert.ok(installIndex < sourceGateIndex); assert.match( workflow, - /name: Install dependencies\n\s+if: .*needs\.plan\.outputs\.asf_source == 'true'/, + /name: Install dependencies\n\s+if: .*steps\.plan\.outputs\.asf_source == 'true'/, ); }); }); diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 16e0c011ff..720bef9235 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -403,50 +403,46 @@ 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'); - const declaredOutputs = new Map( - [ - ...workflow.matchAll(/^ {6}([a-z0-9_]+): \$\{\{ steps\.plan\.outputs\.([a-z0-9_]+) \}\}$/gmu), - ].map((match) => [match[1], match[2]]), - ); - assert.deepEqual(new Set(declaredOutputs.keys()), outputKeys); - for (const [name, source] of declaredOutputs) assert.equal(source, name); + // The planner writes one step's outputs and every later step gates on them, + // so what it emits and what CI reads are the same set. A key nothing reads, + // or a gate on a key the planner never writes, is a dead lane either way. const consumedKeys = new Set( - [...workflow.matchAll(/needs\.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('core CI always reports the required test after optional heavy validation', () => { +test('one unconditional job carries the required context on every pull request', () => { const workflow = readWorkflow('ci.yml'); + // `.asf.yaml` requires `test`. A paths filter would stop the workflow and + // leave that check pending forever, and a second job would make the same + // pull request queue for a scarce runner twice to reach one verdict. assert.doesNotMatch(triggerBlock('ci.yml'), /\bpaths(-ignore)?:/u); - assert.match( - workflow, - /\n {2}heavy:\n {4}needs: plan\n {4}if: needs\.plan\.outputs\.heavy == 'true'/u, - ); - assert.match(workflow, /\n {2}test:\n {4}needs: \[plan, heavy\]\n {4}if: always\(\)/u); - assert.match(workflow, /PLAN_RESULT: \$\{\{ needs\.plan\.result \}\}/u); - assert.match(workflow, /HEAVY_RESULT: \$\{\{ needs\.heavy\.result \}\}/u); - assert.match(workflow, /if \[\[ "\$PLAN_RESULT" != "success" \]\]/u); - assert.match(workflow, /if \[\[ "\$HEAVY_RESULT" != "success" \]\]/u); - assert.match(workflow, /if \[\[ "\$HEAVY_RESULT" != "skipped" \]\]/u); + + const jobsBlock = workflow.slice(workflow.indexOf('\njobs:')); + const jobs = [...jobsBlock.matchAll(/^ {2}([a-z0-9_-]+):$/gmu)].map((match) => match[1]); + assert.deepEqual(jobs, ['test']); + assert.doesNotMatch(jobsBlock, /^ {4}needs:/mu); + assert.doesNotMatch(jobsBlock, /^ {4}if:/mu); }); -test('heavy CI consumes planner outputs through the plan job', () => { +test('planning runs first and every later step gates on its outputs', () => { const workflow = readWorkflow('ci.yml'); - const heavyStart = workflow.indexOf('\n heavy:\n'); - const testStart = workflow.indexOf('\n test:\n', heavyStart); - assert.ok(heavyStart >= 0); - assert.ok(testStart > heavyStart); - const heavy = workflow.slice(heavyStart, testStart); + // With the job split gone there is no `needs` context to read. GitHub + // resolves a leftover `needs.plan.outputs.x` to the empty string rather + // than failing, so the step it guards would silently never run again. + assert.doesNotMatch(workflow, /needs\.plan\.outputs/u); - assert.doesNotMatch(heavy, /steps\.plan\.outputs/u); + const planStep = workflow.indexOf(' - id: plan\n'); + assert.ok(planStep >= 0, 'no planning step'); + assert.ok(planStep < workflow.indexOf('steps.plan.outputs')); assert.match( - heavy, - /- name: Check renderer architecture\n\s+if: needs\.plan\.outputs\.code == 'true'/u, + workflow, + /- name: Check renderer architecture\n\s+if: steps\.plan\.outputs\.code == 'true'/u, ); }); @@ -507,7 +503,7 @@ test('core CI checks the Astryx inventory for every code change before building' const inventoryStep = workflow.slice(inventoryStart, inventoryEnd); assert.match( inventoryStep, - /if: needs\.plan\.outputs\.code == 'true' \|\| needs\.plan\.outputs\.astryx_surface == 'true'/u, + /if: steps\.plan\.outputs\.code == 'true' \|\| steps\.plan\.outputs\.astryx_surface == 'true'/u, ); assert.doesNotMatch(inventoryStep, /continue-on-error/u); }); @@ -523,7 +519,7 @@ test('CI installs dependencies whenever the Astryx surface inventory runs', () = const stepStart = workflow.lastIndexOf('\n - name:', npmCi) + 1; const stepEnd = workflow.indexOf('\n - ', npmCi); const installStep = workflow.slice(stepStart, stepEnd); - assert.match(installStep, /needs\.plan\.outputs\.astryx_surface == 'true'/u); + assert.match(installStep, /steps\.plan\.outputs\.astryx_surface == 'true'/u); }); test('core CI validates affected installed CLI packages on the heavy runner', () => { @@ -533,7 +529,7 @@ test('core CI validates affected installed CLI packages on the heavy runner', () ); const pack = workflow.indexOf('run: npm run release:cli:pack'); - assert.match(workflow, /if: needs\.plan\.outputs\.cli_package == 'true'/u); + assert.match(workflow, /if: steps\.plan\.outputs\.cli_package == 'true'/u); assert.ok(toolchain >= 0); assert.ok(toolchain < pack); assert.match(workflow, /run: npm run release:cli:smoke/u); @@ -579,7 +575,7 @@ test('release contracts run against built CLI outputs', () => { assert.ok(buildIndex < releaseIndex); assert.match( workflow.slice(releaseIndex), - /if: needs\.plan\.outputs\.release_contract == 'true'/u, + /if: steps\.plan\.outputs\.release_contract == 'true'/u, ); }); @@ -910,7 +906,7 @@ test('core CI runs the live Eval proxy lifecycle when Eval is selected', () => { assert.match( workflow, - /if: contains\(needs\.plan\.outputs\.standard_workspaces, 'packages\/eval'\)/u, + /if: contains\(steps\.plan\.outputs\.standard_workspaces, 'packages\/eval'\)/u, ); assert.match(workflow, /MAKA_EVAL_EGRESS_PROXY_TEST: '1'/u); assert.match(workflow, /docker build[\s\S]*maka-eval-egress-proxy:12\.2\.3/u); From 4c307ccea89fa4a2b55cbf0022d268e1b5947e70 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 18:14:53 +0800 Subject: [PATCH 02/13] ci: gate app icon drift on the artwork it verifies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two app-icon drift tests regenerate the shipped PNGs through Python and take about 52 seconds, but they were selected by `code`, so every change anywhere in the tree paid for them. Replayed over the last 200 commits on main, `code` is true for 193 of them while the surface those tests actually read is true for 41. That surface is now its own planner selection, derived from what the tests open: the committed artwork, the generator that must still reproduce it, the `APP_ICONS` catalog they check it against, and the packaged-resource list that has to keep naming every file. Coverage is unchanged — every input that could make these tests fail still selects them. Each of those inputs lives in a workspace or under `scripts/`, so an app-icon selection still implies `code` and the build the tests need has already run. A test asserts that implication rather than leaving it to be rediscovered. Generated-by: Claude Code --- .github/workflows/ci.yml | 8 ++++--- scripts/ci-test-plan.mjs | 22 ++++++++++++++++++ scripts/ci-test-plan.test.mjs | 43 +++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9885a0386a..3cccb720e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,10 +214,12 @@ jobs: # Two drift contracts over the shipped app-icon artwork, sitting beside # the theme drift check for the same reason: the committed bytes are a # build output that nothing else re-derives, so without this a change to - # the generator, to DEFAULT_APP_ICON, or to the packaging config can go - # green while the artwork it names no longer matches. + # the generator, to the `APP_ICONS` catalog, or to the packaged-resource + # list can go green while the artwork it names no longer matches. Those + # are the inputs it selects on; regenerating the artwork costs about a + # minute, and every other code change used to pay it. - name: App icon artwork drift - if: steps.plan.outputs.code == 'true' + if: steps.plan.outputs.app_icons == 'true' run: node --test scripts/verify-packaged-app-icons.test.mjs scripts/generate-app-icons.test.mjs - name: Astryx theme drift diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 7567dfc7cf..75184387c6 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -257,6 +257,25 @@ function isAstryxSurfaceInventoryPath(path) { return isUiProductSourcePath(path); } +/** + * The two app-icon drift tests read exactly this surface: the committed + * artwork, the generator that must still reproduce it, the `APP_ICONS` catalog + * they check it against, and the packaged-resource list that has to keep + * naming every file. Regenerating the artwork costs about a minute, which + * every unrelated code change used to pay. + */ +const APP_ICON_FILES = new Set([ + 'packages/core/src/settings.ts', + 'scripts/generate-app-icons.py', + 'scripts/generate-app-icons.test.mjs', + 'scripts/verify-packaged-app.mjs', + 'scripts/verify-packaged-app-icons.test.mjs', +]); + +function isAppIconPath(path) { + return APP_ICON_FILES.has(path) || path.startsWith('apps/desktop/assets/app-icons/'); +} + function isE2eProductPath(path) { if (E2E_DRIVING_SCRIPTS.has(path)) return true; if (isDocumentation(path)) return false; @@ -363,6 +382,7 @@ export function planTests(changedFiles, options = {}) { if (full) { const workspaces = [...graph.dirs]; return { + appIcons: true, asfSource: true, astryxSurface: true, cliPackage: true, @@ -432,6 +452,7 @@ export function planTests(changedFiles, options = {}) { const cliPackage = files.some((path) => isCliPackagePath(path)); return { + appIcons: files.some((path) => isAppIconPath(path)), asfSource: files.some((path) => isAsfSourcePath(path)), astryxSurface: files.some((path) => isAstryxSurfaceInventoryPath(path)), cliPackage, @@ -483,6 +504,7 @@ export function requiresHeavyValidation(plan) { export function formatGitHubOutputs(plan) { return [ + `app_icons=${plan.appIcons}`, `asf_source=${plan.asfSource}`, `astryx_surface=${plan.astryxSurface}`, `cli_package=${plan.cliPackage}`, diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 720bef9235..829aeec577 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -491,6 +491,49 @@ test('contract checks run before dependency setup and can fail the job', () => { } }); +/** Every file the two app-icon drift tests read. */ +const APP_ICON_INPUTS = [ + 'apps/desktop/assets/app-icons/sky.png', + 'packages/core/src/settings.ts', + 'scripts/generate-app-icons.py', + 'scripts/generate-app-icons.test.mjs', + 'scripts/verify-packaged-app.mjs', + 'scripts/verify-packaged-app-icons.test.mjs', +]; + +test('app icon drift selects the artwork and whatever derives or names it', () => { + for (const path of APP_ICON_INPUTS) { + assert.equal(planTests([path], { graph }).appIcons, true, path); + } + + // Regenerating the artwork costs about a minute. Ordinary product code is + // exactly what must stop paying it. + for (const path of [ + 'apps/desktop/src/renderer/app-shell.tsx', + 'packages/core/src/artifacts.ts', + 'packages/runtime/src/edit-replace.ts', + ]) { + assert.equal(planTests([path], { graph }).appIcons, false, path); + } +}); + +test('an app icon selection always brings the build those tests need', () => { + // `generate-app-icons.test.mjs` imports `APP_ICONS` from built `@maka/core` + // and the step runs after Build, which gates on `code`. Every input above + // lives in a workspace or under `scripts/`, so `code` follows rather than + // needing its own gate — an entry that broke that would fail here. + for (const path of APP_ICON_INPUTS) { + assert.equal(planTests([path], { graph }).code, true, path); + } +}); + +test('core CI gates app icon drift on the artwork surface', () => { + assert.match( + readWorkflow('ci.yml'), + /- name: App icon artwork drift\n\s+if: steps\.plan\.outputs\.app_icons == 'true'/u, + ); +}); + test('core CI checks the Astryx inventory for every code change before building', () => { const workflow = readWorkflow('ci.yml'); const inventoryStart = workflow.indexOf(' - name: Astryx surface inventory\n'); From cddb7f02bf2877785147c92b05983dc1fed23cb0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 18:20:53 +0800 Subject: [PATCH 03/13] ci: fold redundant CLI package validation jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This lane opened fourteen runners per pull request to produce evidence that four of them were not needed to hold. `release-predecessor` resolved one npm version in fifteen seconds and then released its runner. It now runs on `build`, which already waits about sixteen minutes on the addon builds, so the resolution costs nothing it was not already waiting through. The exported `release_predecessor_*` names are unchanged, so `npm-publication`, `release-cli-stage` and `asf-npm-candidate` consume exactly what they consumed before. `state-root-qualification` ran three matrix jobs. Two of them qualify a transition between tarballs that were published and frozen, so nothing in a pull request can change their outcome except the qualifier itself, and the third reads the candidate this run built. They are three invocations of one script against one sandbox, so they now share a runner. The scenario table survives as three calls with the same digests, epoch relations and identity flags; each writes its own report and all three are uploaded together. The Linux x64 smoke ran twice on two runners for two Node versions. Same machine, same tarball, same architecture assertion — it now installs the second version and repeats the smoke in place. Pull requests allocate ten runners instead of fourteen. Nothing that was verified before is unverified now. Generated-by: Claude Code --- .github/workflows/cli-package-validation.yml | 204 +++++++++---------- scripts/release-cli-workflow-policy.test.mjs | 45 ++-- 2 files changed, 118 insertions(+), 131 deletions(-) diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index 1bd0b62743..ac66e97aa1 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -66,13 +66,13 @@ on: value: ${{ jobs.build.outputs.release_candidate_run_attempt }} release_predecessor_version: description: Exact npm Nightly version qualified against this candidate - value: ${{ jobs.release-predecessor.outputs.version }} + value: ${{ jobs.build.outputs.release_predecessor_version }} release_predecessor_tarball_url: description: Exact npm Nightly tarball qualified against this candidate - value: ${{ jobs.release-predecessor.outputs.tarball_url }} + value: ${{ jobs.build.outputs.release_predecessor_tarball_url }} release_predecessor_integrity: description: npm SHA-512 integrity of the Nightly tarball qualified against this candidate - value: ${{ jobs.release-predecessor.outputs.integrity }} + value: ${{ jobs.build.outputs.release_predecessor_integrity }} workflow_dispatch: permissions: @@ -82,26 +82,6 @@ concurrency: group: cli-package-validation-${{ github.workflow }}-${{ github.ref }} jobs: - release-predecessor: - name: Resolve immutable release predecessor - runs-on: ubuntu-24.04 - timeout-minutes: 45 - outputs: - version: ${{ steps.predecessor.outputs.version }} - tarball_url: ${{ steps.predecessor.outputs.tarball_url }} - integrity: ${{ steps.predecessor.outputs.integrity }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.source_commit || github.sha }} - persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - - name: Resolve the current npm Nightly as immutable evidence - id: predecessor - run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT" - peer-native: name: Build direct-peer addon (${{ matrix.target }}) runs-on: ${{ matrix.runner }} @@ -212,6 +192,9 @@ jobs: outputs: release_candidate_artifact_id: ${{ steps.release-candidate.outputs.artifact-id }} release_candidate_run_attempt: ${{ github.run_attempt }} + release_predecessor_version: ${{ steps.predecessor.outputs.version }} + release_predecessor_tarball_url: ${{ steps.predecessor.outputs.tarball_url }} + release_predecessor_integrity: ${{ steps.predecessor.outputs.integrity }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -223,6 +206,12 @@ jobs: cache: npm - name: Select the release npm toolchain run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" + # Resolving one npm version is seconds of work against a registry this + # job already reaches, so it rides the runner that waits on the addon + # builds instead of holding a second one for the whole wait. + - name: Resolve the current npm Nightly as immutable evidence + id: predecessor + run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT" - name: Install cargo-deny uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 with: @@ -259,29 +248,30 @@ jobs: fail-fast: false matrix: include: - - name: Linux x64 / Node 22.19 + # The two supported Node versions share a machine and a tarball, so + # they are two runs of the same smoke rather than two runners. + - name: Linux x64 / Node 22.19 and 24 runner: ubuntu-24.04 node: '22.19.0' - platform: linux - arch: x64 - - name: Linux x64 / Node 24 - runner: ubuntu-24.04 - node: '24' + second_node: '24' platform: linux arch: x64 - name: Linux arm64 / Node 24 runner: ubuntu-24.04-arm node: '24' + second_node: '' platform: linux arch: arm64 - name: macOS arm64 / Node 24 runner: macos-15 node: '24' + second_node: '' platform: darwin arch: arm64 - name: Windows x64 / Node 24 runner: windows-2025 node: '24' + second_node: '' platform: win32 arch: x64 steps: @@ -307,38 +297,22 @@ jobs: path: packages/cli/release - name: Validate the installed tarball run: node scripts/smoke-release-cli-package.mjs + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + if: matrix.second_node != '' + with: + node-version: ${{ matrix.second_node }} + - name: Select the release npm toolchain for the second Node + if: matrix.second_node != '' + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" + - name: Validate the installed tarball on the second Node + if: matrix.second_node != '' + run: node scripts/smoke-release-cli-package.mjs state-root-qualification: - name: Qualify released State Root (${{ matrix.name }}) - needs: [build, release-predecessor] + name: Qualify released State Roots + needs: build runs-on: ubuntu-24.04 timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - include: - - name: cross epoch 74 to 76 - source_url: https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.3.20260830.tgz - source_sha256: 66b1ce9307c9d5c06eaa7a6cbf533d4747d02caf71c1776c69c7dbfa12c3f414 - target_kind: published - target_url: https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.4.20260830.tgz - target_sha256: b7d48adb466e16be7ffefbda3a0fcd833cc4108ea502b27778d0f4da680e1fc0 - epoch_relation: different - - name: same epoch 76 - source_url: https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.4.20260830.tgz - source_sha256: b7d48adb466e16be7ffefbda3a0fcd833cc4108ea502b27778d0f4da680e1fc0 - target_kind: published - target_url: https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.5.20260830.tgz - target_sha256: e7a682157c6899fc7f1be86a2d7b0bd0696195a5771d8cc97bd1389a5b74989f - epoch_relation: same - - name: current Nightly predecessor to candidate - source_url: ${{ needs.release-predecessor.outputs.tarball_url }} - source_sha256: '' - source_integrity: ${{ needs.release-predecessor.outputs.integrity }} - target_kind: candidate - target_url: '' - target_sha256: '' - epoch_relation: any steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -355,80 +329,90 @@ jobs: sudo apt-get install --yes bubblewrap bwrap --version - name: Download the release candidate - if: matrix.target_kind == 'candidate' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: artifact-ids: ${{ needs.build.outputs.release_candidate_artifact_id }} path: packages/cli/release - - name: Prepare exact source and target artifacts + # Three runs of one script against one sandbox, not three runners. Two of + # these transitions are between tarballs that were published and frozen, + # so nothing in a pull request can change their outcome except the + # qualifier itself, and the third reads the candidate this run built. + - name: Qualify the released State Root transitions env: - SOURCE_URL: ${{ matrix.source_url }} - SOURCE_SHA256: ${{ matrix.source_sha256 }} - SOURCE_INTEGRITY: ${{ matrix.source_integrity }} - TARGET_KIND: ${{ matrix.target_kind }} - TARGET_URL: ${{ matrix.target_url }} - TARGET_SHA256: ${{ matrix.target_sha256 }} + MAKA_QUALIFICATION_BWRAP_USE_SUDO: '1' + PREDECESSOR_TARBALL_URL: ${{ needs.build.outputs.release_predecessor_tarball_url }} + PREDECESSOR_INTEGRITY: ${{ needs.build.outputs.release_predecessor_integrity }} run: | set -euo pipefail evidence_root="$RUNNER_TEMP/released-state-root" mkdir -p "$evidence_root" - source_path="$evidence_root/source.tgz" - curl --fail --location --max-filesize 67108864 --proto '=https' --tlsv1.2 "$SOURCE_URL" --output "$source_path" - source_sha256="$SOURCE_SHA256" - if [[ -n "$SOURCE_INTEGRITY" ]]; then - node - "$source_path" "$SOURCE_INTEGRITY" <<'NODE' + + qualify() { + local slug="$1" source_url="$2" source_sha256="$3" source_integrity="$4" + local target_kind="$5" target_url="$6" target_sha256="$7" epoch_relation="$8" + local source_path target_path + echo "::group::Qualify $slug" + source_path="$evidence_root/$slug-source.tgz" + curl --fail --location --max-filesize 67108864 --proto '=https' --tlsv1.2 "$source_url" --output "$source_path" + if [[ -n "$source_integrity" ]]; then + node - "$source_path" "$source_integrity" <<'NODE' const { createHash } = require('node:crypto'); const { readFileSync } = require('node:fs'); const bytes = readFileSync(process.argv[2]); const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; if (actual !== process.argv[3]) throw new Error('Source tarball integrity mismatch'); NODE - source_sha256="$(sha256sum "$source_path" | cut -d ' ' -f 1)" - else - test -n "$source_sha256" - fi - if [[ "$TARGET_KIND" == 'published' ]]; then - target_path="$evidence_root/target.tgz" - curl --fail --location --max-filesize 67108864 --proto '=https' --tlsv1.2 "$TARGET_URL" --output "$target_path" - target_sha256="$TARGET_SHA256" - else - target_path="$(find packages/cli/release -maxdepth 1 -name '*.tgz' -print -quit)" - test -n "$target_path" - target_path="$(realpath "$target_path")" - target_sha256="$(sha256sum "$target_path" | cut -d ' ' -f 1)" - fi - { - echo "SOURCE_PATH=$source_path" - echo "SOURCE_SHA256=$source_sha256" - echo "TARGET_PATH=$target_path" - echo "TARGET_SHA256=$target_sha256" - } >> "$GITHUB_ENV" - - name: Qualify the released State Root transition - env: - EXPECTED_EPOCH_RELATION: ${{ matrix.epoch_relation }} - MAKA_QUALIFICATION_BWRAP_USE_SUDO: '1' - run: | - set -o pipefail - npm run --silent release:cli:qualify-state-root -- \ - --source "$SOURCE_PATH" \ - --source-sha256 "$SOURCE_SHA256" \ - --target "$TARGET_PATH" \ - --target-sha256 "$TARGET_SHA256" \ - --expect-epoch-relation "$EXPECTED_EPOCH_RELATION" \ - | tee "$RUNNER_TEMP/released-state-root-report.json" - - name: Preserve the qualification report + source_sha256="$(sha256sum "$source_path" | cut -d ' ' -f 1)" + else + test -n "$source_sha256" + fi + if [[ "$target_kind" == 'published' ]]; then + target_path="$evidence_root/$slug-target.tgz" + curl --fail --location --max-filesize 67108864 --proto '=https' --tlsv1.2 "$target_url" --output "$target_path" + test -n "$target_sha256" + else + target_path="$(find packages/cli/release -maxdepth 1 -name '*.tgz' -print -quit)" + test -n "$target_path" + target_path="$(realpath "$target_path")" + target_sha256="$(sha256sum "$target_path" | cut -d ' ' -f 1)" + fi + npm run --silent release:cli:qualify-state-root -- \ + --source "$source_path" \ + --source-sha256 "$source_sha256" \ + --target "$target_path" \ + --target-sha256 "$target_sha256" \ + --expect-epoch-relation "$epoch_relation" \ + | tee "$evidence_root/$slug-report.json" + echo "::endgroup::" + } + + qualify cross-epoch-74-to-76 \ + https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.3.20260830.tgz \ + 66b1ce9307c9d5c06eaa7a6cbf533d4747d02caf71c1776c69c7dbfa12c3f414 '' \ + published https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.4.20260830.tgz \ + b7d48adb466e16be7ffefbda3a0fcd833cc4108ea502b27778d0f4da680e1fc0 different + + qualify same-epoch-76 \ + https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.4.20260830.tgz \ + b7d48adb466e16be7ffefbda3a0fcd833cc4108ea502b27778d0f4da680e1fc0 '' \ + published https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.5.20260830.tgz \ + e7a682157c6899fc7f1be86a2d7b0bd0696195a5771d8cc97bd1389a5b74989f same + + qualify current-nightly-predecessor-to-candidate \ + "$PREDECESSOR_TARBALL_URL" '' "$PREDECESSOR_INTEGRITY" \ + candidate '' '' any + - name: Preserve the qualification reports uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: released-state-root-${{ strategy.job-index }} - path: ${{ runner.temp }}/released-state-root-report.json + name: released-state-root + path: ${{ runner.temp }}/released-state-root/*-report.json if-no-files-found: error retention-days: 7 - name: Require the qualified Nightly predecessor to remain current - if: matrix.target_kind == 'candidate' env: - PREDECESSOR_VERSION: ${{ needs.release-predecessor.outputs.version }} - PREDECESSOR_TARBALL_URL: ${{ needs.release-predecessor.outputs.tarball_url }} - PREDECESSOR_INTEGRITY: ${{ needs.release-predecessor.outputs.integrity }} + PREDECESSOR_VERSION: ${{ needs.build.outputs.release_predecessor_version }} + PREDECESSOR_TARBALL_URL: ${{ needs.build.outputs.release_predecessor_tarball_url }} + PREDECESSOR_INTEGRITY: ${{ needs.build.outputs.release_predecessor_integrity }} run: | node scripts/release-cli-publication.mjs assert-nightly-predecessor \ "$PREDECESSOR_VERSION" \ diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index 4abe6ef970..5a9cd312d2 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -48,52 +48,55 @@ test('validation consumers download the artifact produced by the build job', () test('CLI validation qualifies exact published State Roots without weakening artifact identity', () => { const workflow = readWorkflow('cli-package-validation.yml'); + // The predecessor is resolved on the job that already waits on the addon + // builds, so the exported identity comes from `build` rather than a job of + // its own. The exported names are the contract callers hold. assert.match( workflow, - /release_predecessor_version:[\s\S]*?value: \$\{\{ jobs\.release-predecessor\.outputs\.version \}\}/u, + /release_predecessor_version:[\s\S]*?value: \$\{\{ jobs\.build\.outputs\.release_predecessor_version \}\}/u, ); assert.match( workflow, - /release-predecessor:[\s\S]*?resolve-nightly-predecessor "\$GITHUB_OUTPUT"/u, + /release_predecessor_integrity:[\s\S]*?jobs\.build\.outputs\.release_predecessor_integrity/u, ); assert.match( workflow, - /release_predecessor_integrity:[\s\S]*?jobs\.release-predecessor\.outputs\.integrity/u, + /id: predecessor\n\s+run: node scripts\/release-cli-publication\.mjs resolve-nightly-predecessor "\$GITHUB_OUTPUT"/u, ); + assert.match(workflow, /state-root-qualification:\n[\s\S]*?needs: build\n/u); + + // Both frozen transitions keep their exact digests and the epoch relation + // each one exists to prove. They are positional arguments now, so anchor on + // the digest immediately preceding the relation rather than on a YAML key. + assert.match(workflow, /^\s+[a-f0-9]{64} different$/mu); + assert.match(workflow, /^\s+[a-f0-9]{64} same$/mu); assert.match( workflow, - /state-root-qualification:\n[\s\S]*?needs: \[build, release-predecessor\]/u, + /"\$PREDECESSOR_TARBALL_URL" '' "\$PREDECESSOR_INTEGRITY" \\\n\s+candidate/u, ); - assert.match(workflow, /source_sha256: [a-f0-9]{64}/u); - assert.match(workflow, /target_sha256: [a-f0-9]{64}/u); - assert.match(workflow, /epoch_relation: different/u); - assert.match(workflow, /epoch_relation: same/u); + const steps = workflowSteps(workflow); const sandbox = namedStep(steps, 'Require the account-isolation sandbox'); assert.match(sandbox, /apt-get install --yes bubblewrap/u); - const qualify = namedStep(steps, 'Qualify the released State Root transition'); + const qualify = namedStep(steps, 'Qualify the released State Root transitions'); assert.match(qualify, /release:cli:qualify-state-root/u); assert.match(qualify, /MAKA_QUALIFICATION_BWRAP_USE_SUDO:\s*'1'/u); assert.match(qualify, /--source-sha256/u); assert.match(qualify, /--target-sha256/u); assert.match(qualify, /--expect-epoch-relation/u); - assert.match(qualify, /set -o pipefail/u); + // `| tee` would otherwise report the exit code of tee, not the qualifier. + assert.match(qualify, /set -euo pipefail/u); assert.match(qualify, /npm run --silent/u); - const prepare = namedStep(steps, 'Prepare exact source and target artifacts'); - assert.match(prepare, /--max-filesize 67108864/gu); - assert.match(prepare, /SOURCE_INTEGRITY/u); - assert.match(prepare, /createHash\('sha512'\)/u); - assert.match(prepare, /source_sha256="\$\(sha256sum/u); - const preserve = namedStep(steps, 'Preserve the qualification report'); + assert.match(qualify, /--max-filesize 67108864/u); + assert.match(qualify, /source_integrity/u); + assert.match(qualify, /createHash\('sha512'\)/u); + assert.match(qualify, /source_sha256="\$\(sha256sum/u); + const preserve = namedStep(steps, 'Preserve the qualification reports'); assert.match(preserve, /if-no-files-found: error/u); const freshness = namedStep(steps, 'Require the qualified Nightly predecessor to remain current'); assert.match(freshness, /assert-nightly-predecessor/u); - assert.match(freshness, /needs\.release-predecessor\.outputs\.version/u); + assert.match(freshness, /needs\.build\.outputs\.release_predecessor_version/u); assert.ok(steps.indexOf(freshness) > steps.indexOf(preserve)); - assert.match( - workflow, - /source_url: \$\{\{ needs\.release-predecessor\.outputs\.tarball_url \}\}/u, - ); }); test('npm mutations revalidate the exact qualified Nightly predecessor', () => { From 854973857272b18c8aefc32a2753df84440a9d57 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 18:27:16 +0800 Subject: [PATCH 04/13] ci: scope the packaged Windows gate to its own inputs This lane packages, installs and updates Maka on Windows. It takes about 25 minutes on a runner class that is scarce on shared infrastructure, and it was being allocated for two reasons that its own steps cannot justify. `apps/desktop/src/main/runtime-host-boot.ts` was in the path filter because the packaged updater is driven through `MAKA_UPDATE_TEST_FEED`, which that file hands to the update service. The wiring is a handful of lines; the module is 1900 of them, and over the last 200 commits on main it was the sole reason this lane ran 13 times. It is now asserted by `scripts/update-test-feed-wiring.test.mjs`, which runs on every change in the required job before any toolchain is installed and costs milliseconds. The filter matches 41 of those 200 commits, down from 55. The pinned-baseline steps qualify a transition out of an already-released installer, so a pull request's diff is not their input and cannot change their outcome. Downloading that baseline is also this lane's most common failure: 14 of 36 failures across the last 300 runs, every one of them on a branch that could not have caused it, each costing another 25-minute Windows allocation. Those three steps move to a nightly schedule, which is still far ahead of release day, the moment they exist to precede. Packaging, release verification and the end-to-end autoupdate check still run on every matching pull request. Generated-by: Claude Code --- .github/workflows/ci.yml | 6 ++ .github/workflows/release-windows-check.yml | 20 ++++++- scripts/update-test-feed-wiring.test.mjs | 61 +++++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 scripts/update-test-feed-wiring.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cccb720e2..15cd6334e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,12 @@ jobs: - name: Test script entrypoint contracts run: node --test scripts/script-entrypoints.test.mjs + # The packaged Windows lane drives the updater through this wiring. It + # is a few lines, so it is asserted here on every change rather than by + # naming its module in that lane's 25-minute path filter. + - name: Test the packaged update feed wiring + run: node --test --test-concurrency=1 scripts/update-test-feed-wiring.test.mjs + # Install-free like its neighbours: the gate reads one source file and # compares it to a hand-edited inventory, so a hook that silently widens # its scope to the whole tree fails here rather than in a profile (#4109). diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index bc99c6e019..5c03c5e550 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -46,11 +46,13 @@ on: - 'scripts/package-windows-autoupdate-next.mjs' # The Abort-path rollback hook ships inside the installer itself. - 'apps/desktop/build/installer.nsh' - # The packaged updater's feed behavior — and the boot wiring that hands - # MAKA_UPDATE_TEST_FEED to it — is only observable on this path. + # The packaged updater's feed behavior is only observable on this path. + # The boot wiring that hands MAKA_UPDATE_TEST_FEED to it is not here: + # that is a few lines, `scripts/update-test-feed-wiring.test.mjs` asserts + # them on every change for free, and naming the file made a 1900-line + # module the single largest source of runs on this 25-minute lane. - 'apps/desktop/src/main/app-update-service.ts' - 'apps/desktop/src/main/main-window.ts' - - 'apps/desktop/src/main/runtime-host-boot.ts' - 'packages/runtime-host/src/client/connect-or-spawn.ts' - 'packages/runtime-host/src/client/launcher.ts' - 'apps/desktop/src/main/windows-maximize-renderer-sync.ts' @@ -94,6 +96,11 @@ on: - 'apps/desktop/resources/licenses/cargo/THIRD_PARTY_NOTICES.txt' - '.github/workflows/release.yml' - '.github/workflows/release-windows-check.yml' + # The installer upgrade and rollback steps below qualify a transition from a + # pinned historical release, so their input is that release rather than the + # diff. They run here instead, which is still far ahead of release day. + schedule: + - cron: '17 5 * * *' workflow_dispatch: permissions: @@ -135,8 +142,13 @@ jobs: version="$(node -p "require('./apps/desktop/package.json').version")" npm run verify:windows-x64 -- "apps/desktop/release/Maka-${version}-win-x64.exe" + # Qualifies a transition out of a pinned historical release, so a pull + # request cannot change the outcome. Downloading that release is also + # this lane's most frequent failure, on branches that cannot have caused + # it, and each false red costs another 25-minute Windows runner. - name: Download and verify the pinned Windows upgrade baseline id: previous + if: github.event_name != 'pull_request' env: GH_TOKEN: ${{ github.token }} run: | @@ -146,6 +158,7 @@ jobs: echo "exe=$previous_exe" >> "$GITHUB_OUTPUT" - name: Exercise pinned-version upgrade and uninstall + if: github.event_name != 'pull_request' run: | version="$(node -p "require('./apps/desktop/package.json').version")" npm run verify:windows-installer -- \ @@ -163,6 +176,7 @@ jobs: apps/desktop/release-autoupdate-next - name: Prove deterministic mid-install failure rollback + if: github.event_name != 'pull_request' run: | version="$(node -p "require('./apps/desktop/package.json').version")" npm run verify:windows-installer-rollback -- \ diff --git a/scripts/update-test-feed-wiring.test.mjs b/scripts/update-test-feed-wiring.test.mjs new file mode 100644 index 0000000000..7e5c14b122 --- /dev/null +++ b/scripts/update-test-feed-wiring.test.mjs @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * `verify-windows-autoupdate.mjs` drives the packaged updater by handing it + * `MAKA_UPDATE_TEST_FEED`, and the boot path is what carries that value to the + * update service. Break the wiring and the packaged run stops reaching the + * harness feed, so the verifier proves nothing while still passing. + * + * The wiring is a handful of lines. Asserting them costs milliseconds on every + * change, which is why naming their 1900-line module in the packaged Windows + * lane's path filter — a 25-minute Windows job — was the wrong instrument. + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const FEED = 'MAKA_UPDATE_TEST_FEED'; + +function read(path) { + return readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); +} + +test('the packaged boot path hands the harness feed to the update service', () => { + const boot = read('apps/desktop/src/main/runtime-host-boot.ts'); + + assert.match(boot, /const updateTestFeed = process\.env\.MAKA_UPDATE_TEST_FEED;/u); + assert.match(boot, /createAppUpdateService\(\{[\s\S]*?testFeedUrl: updateTestFeed,/u); +}); + +test('the harness feed still redirects packaged user data away from the real root', () => { + // Without this the update test would write into the developer's own profile. + const main = read('apps/desktop/src/main/main.ts'); + + assert.match( + main, + /resolveUpdateTestUserDataDirectory\(\{\n\s+feedUrl: process\.env\.MAKA_UPDATE_TEST_FEED,/u, + ); +}); + +test('the Windows autoupdate verifier is what supplies the feed', () => { + const verifier = read('scripts/verify-windows-autoupdate.mjs'); + + assert.match(verifier, new RegExp(`${FEED}: feed\\.url`, 'u')); +}); From e31c4fd7597356cf55ce89c821d90d553603ffbe Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 18:37:14 +0800 Subject: [PATCH 05/13] ci: leave the Windows recovery suites to the required test lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This lane's pull-request filter named every source directory in the workspace closure of the crash and owner-death tests it runs, so it took a Windows runner on 118 of the last 200 merges. The filter was accurate — those suites really do import most of `runtime`, `runtime-host`, `storage` and `core`, and a generated import closure measured at 106 of the same 200, because they boot a real Runtime Host. The list was not the problem. What the run history shows is that the pull-request trigger was. Across 300 runs, 241 of them pull requests, this lane produced 12 reds and not one of them was unique: every pull-request failure sat on a commit whose `test` run had already failed, usually at the very same step — six at `Install dependencies`, one at the workspace tests that run the same owner-death suite on Linux. Its single unique catch in that window was a Windows-only NTFS alternate stream regression found by the unfiltered main push, which is the trigger that exists for exactly that. So the filter now names only what a Windows runner can prove and `test` cannot: how `npm ci` resolves and what the dependency patches and the Electron installer produce there, what the clean step removes there, and the Local IPC trust boundary, a PowerShell script with no other caller. That is 14 of 200 merges instead of 118. The install and build steps stay unconditional, because proving those suites still build and run on Windows is what the lane is for once it does run. The recovery authorities are now covered after merge rather than before it, by the unfiltered main push minutes later and by the nightly. `windows_recovery` is deliberately not a required context in `.asf.yaml`, so it was never what stood between a regression and `main` in any case. Generated-by: Claude Code --- .github/workflows/windows-recovery.yml | 48 +++++++++++--------------- scripts/ci-test-plan.test.mjs | 37 ++++++++++++-------- 2 files changed, 42 insertions(+), 43 deletions(-) diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 2ea0d13e62..7ae334b730 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -17,24 +17,31 @@ 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. +# The paths below name only what a Windows runner can prove and the required +# `test` lane cannot: how `npm ci` behaves here, what the dependency patches and +# the Electron installer produce here, and the Local IPC trust boundary, which +# is a PowerShell script with no other caller. # -# 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. +# They deliberately do not name the recovery authorities themselves. Those used +# to be here, as every source directory in the workspace closure of the crash +# and owner-death tests below, which put this lane on 118 of the last 200 +# merges. Over 300 runs it never once produced a pull-request red that the +# `test` lane had not already produced on the same commit, usually at the very +# same step: the recovery suites are ordinary TypeScript, so a defect in them +# fails on Linux first and blocks the merge there. Its one unique catch in that +# window — a Windows-only NTFS alternate stream regression — came from the main +# push below, which is what that trigger exists for. +# +# So the authorities are covered after merge rather than before it, minutes +# later, by a push run that carries no filter at all. That trigger is also +# unfiltered because `required_status_checks` is `strict: false`, so a pull +# request can go green against a stale base, and because GitHub evaluates a path +# filter against the first 300 files of a diff only — and a repository-wide +# sweep is exactly the change that touches every recovery authority at once. on: pull_request: branches: [main] paths: - - 'package.json' - 'package-lock.json' - 'patches/**' - 'scripts/apply-dependency-patches.mjs' @@ -43,21 +50,6 @@ on: - '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 diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 829aeec577..d0bd42ad0f 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -718,15 +718,14 @@ test('the recovery lane keeps every run kind out of one shared concurrency group assert.match(workflow, /\n {2}cancel-in-progress: true/u); }); -test('the recovery lane filters pull requests by the workspaces its steps execute', () => { +test('the recovery lane leaves the suites it executes to the required test lane', () => { 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. + // legal. const executed = [ ...new Set( [...workflow.matchAll(/packages[/\\]([^/\\]+)[/\\]dist[/\\]/gu)].map((match) => match[1]), @@ -734,13 +733,23 @@ test('the recovery lane filters pull requests by the workspaces its steps execut ].sort(); assert.deepEqual(executed, ['runtime', 'runtime-host', 'storage']); + // None of that closure belongs in the filter. These suites are ordinary + // TypeScript, so `test` runs them on Linux on every pull request and fails + // first; naming their sources here only bought a second, slower red. Listing + // one again would put this lane back on most merges, so it fails here. 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`); + for (const entry of [`${dir}/src/**`, `${dir}/tsconfig.json`, `${dir}/package.json`]) { + assert.ok(!filtered.has(entry), `${entry} belongs to the required test lane`); + } } + + // What the Windows runner proves instead is that the suites still build and + // run here at all, which is why the unconditional install and build steps stay + // unconditional even though nothing in the filter names a workspace. + assert.match(workflow, /\n {6}- name: Install dependencies\n {8}run: npm\.cmd ci\n/u); + assert.match(workflow, /\n {8}run: npm\.cmd run build:test\n/u); }); test('the recovery lane filter follows the postinstall launcher chain', () => { @@ -763,25 +772,23 @@ test('the recovery lane filter follows the postinstall launcher chain', () => { } }); -test('the recovery lane filters pull requests by what its install and clean steps consume', () => { +test('the recovery lane filters pull requests by what only Windows can prove', () => { 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. + // What is left after the workspace sources came out: how `npm.cmd ci` resolves + // and what it produces on Windows, what `npm.cmd run build:test` cleans up + // there, and a PowerShell script this lane is the only caller of. Each of + // these can be green on Linux and red here, which is the whole test for + // membership in this list. for (const path of [ - 'package.json', 'package-lock.json', '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', - 'tsconfig.base.json', - 'tsconfig.lib.json', - 'packages/runtime/scripts/**', '.github/workflows/windows-recovery.yml', ]) { assert.ok(filtered.has(path), path); From a6c3e1e843020bb05f5c8d75dbad117b86651eda Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 19:23:09 +0800 Subject: [PATCH 06/13] ci: keep the candidate installer's own upgrade and rollback on pull requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both steps were moved to the schedule on the grounds that they qualify a transition out of a pinned historical release, so a pull request could not change their outcome. That is only true of the baseline. The other input to `verify:windows-installer` is the installer this run just built, which it installs over the pinned one, and `verify:windows-installer-rollback` does not read the baseline at all: it takes the candidate installer and the version-bumped one, and it is the only place the `installer.nsh` Abort path is exercised. `installer.nsh` and both verifiers stayed in the path filter the whole time, so a change to them scheduled this lane and then skipped the steps that read them. Both run on pull requests again. The flake that motivated the move — 14 of 36 failures across 300 runs, always on branches that could not have caused it — is handled where it belongs: the baseline is pinned by version, tag, asset name and SHA-256 in a committed manifest, so it is immutable and cacheable on that manifest's hash. A hit skips the network and a corrupt entry still fails, because the download step verifies the checksum either way. The schedule stays. Its reason is now the one the other two Windows lanes give for theirs: the path list is a pre-filter, not this lane's import closure, so a transitive edit it cannot match would otherwise first be observed on release day. Generated-by: Claude Code --- .github/workflows/release-windows-check.yml | 32 ++++++++++++++------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index 5c03c5e550..8c7a98cfc1 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -96,9 +96,9 @@ on: - 'apps/desktop/resources/licenses/cargo/THIRD_PARTY_NOTICES.txt' - '.github/workflows/release.yml' - '.github/workflows/release-windows-check.yml' - # The installer upgrade and rollback steps below qualify a transition from a - # pinned historical release, so their input is that release rather than the - # diff. They run here instead, which is still far ahead of release day. + # The list above is a pre-filter, not this lane's import closure, so a + # transitive edit it cannot match would otherwise first be observed on release + # day. Same pairing as the other two Windows lanes. schedule: - cron: '17 5 * * *' workflow_dispatch: @@ -142,13 +142,21 @@ jobs: version="$(node -p "require('./apps/desktop/package.json').version")" npm run verify:windows-x64 -- "apps/desktop/release/Maka-${version}-win-x64.exe" - # Qualifies a transition out of a pinned historical release, so a pull - # request cannot change the outcome. Downloading that release is also - # this lane's most frequent failure, on branches that cannot have caused - # it, and each false red costs another 25-minute Windows runner. + # The baseline is pinned by version, tag, asset name and SHA-256 in + # `scripts/windows-upgrade-baseline.json`, so it is immutable and the + # cache key is that file. Downloading it is this lane's most frequent + # failure — 14 of 36 across 300 runs, always on branches that could not + # have caused it, each false red costing another 25-minute Windows + # runner. A hit skips the network; the step below re-verifies the + # checksum either way, so a corrupt entry still fails loudly. + - name: Restore the pinned Windows upgrade baseline + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: artifacts/windows-upgrade-baseline + key: windows-upgrade-baseline-${{ hashFiles('scripts/windows-upgrade-baseline.json') }} + - name: Download and verify the pinned Windows upgrade baseline id: previous - if: github.event_name != 'pull_request' env: GH_TOKEN: ${{ github.token }} run: | @@ -157,8 +165,10 @@ jobs: "$version" artifacts/windows-upgrade-baseline)" echo "exe=$previous_exe" >> "$GITHUB_OUTPUT" + # The pinned release is only one input here. The other is the installer + # this run just built, which this step installs over it, so a pull request + # does decide the outcome. - name: Exercise pinned-version upgrade and uninstall - if: github.event_name != 'pull_request' run: | version="$(node -p "require('./apps/desktop/package.json').version")" npm run verify:windows-installer -- \ @@ -175,8 +185,10 @@ jobs: "apps/desktop/release/Maka-${version}-win-x64.exe" \ apps/desktop/release-autoupdate-next + # Consumes only what this run built — the candidate installer and the + # version-bumped one — so it is the pull request's own evidence that the + # `installer.nsh` Abort path still restores the previous installation. - name: Prove deterministic mid-install failure rollback - if: github.event_name != 'pull_request' run: | version="$(node -p "require('./apps/desktop/package.json').version")" npm run verify:windows-installer-rollback -- \ From 0d4301c6e05e48f86c2891535b81e714c590bb4d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 19:23:18 +0800 Subject: [PATCH 07/13] ci: keep the Windows-only recovery surface on the recovery filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping the workspace source directories dropped something with them. Part of these suites is portable TypeScript that Linux fails first, which is why removing them cost no observed pull-request signal — but part is guarded by `process.platform === 'win32'` and is skipped off Windows by construction, so `test` cannot go red on it however carefully it runs. `assertNoWindowsAlternateStreams` and its three NTFS alternate stream regressions are exactly that, and they are the one thing this lane has caught that no other lane could have. The Windows-branching modules under the executed suites are named again, individually: seventeen files, which the last 200 merges touch twice more than the filter already matched. That buys back the whole Windows-only half for the price of two runs, where readmitting the closure would cost 118 of 200. The rule is now checkable rather than remembered, so a stale entry cannot sit here: every workspace path on this filter must be one file, not a glob, and must contain a Windows branch. A module that stops forking on the platform leaves the list, and a portable file cannot enter it. Generated-by: Claude Code --- .github/workflows/windows-recovery.yml | 65 +++++++++++++++++++------- scripts/ci-test-plan.test.mjs | 18 +++++++ 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 7ae334b730..6ddc515a28 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -18,26 +18,36 @@ name: Windows recovery # The paths below name only what a Windows runner can prove and the required -# `test` lane cannot: how `npm ci` behaves here, what the dependency patches and -# the Electron installer produce here, and the Local IPC trust boundary, which -# is a PowerShell script with no other caller. +# `test` lane cannot. Two groups. # -# They deliberately do not name the recovery authorities themselves. Those used -# to be here, as every source directory in the workspace closure of the crash -# and owner-death tests below, which put this lane on 118 of the last 200 -# merges. Over 300 runs it never once produced a pull-request red that the -# `test` lane had not already produced on the same commit, usually at the very -# same step: the recovery suites are ordinary TypeScript, so a defect in them -# fails on Linux first and blocks the merge there. Its one unique catch in that -# window — a Windows-only NTFS alternate stream regression — came from the main -# push below, which is what that trigger exists for. +# First, how the toolchain behaves here: `npm ci` resolution, what the +# dependency patches and the Electron installer produce, what the clean step +# removes, and the Local IPC trust boundary, a PowerShell script with no other +# caller. # -# So the authorities are covered after merge rather than before it, minutes -# later, by a push run that carries no filter at all. That trigger is also -# unfiltered because `required_status_checks` is `strict: false`, so a pull -# request can go green against a stale base, and because GitHub evaluates a path -# filter against the first 300 files of a diff only — and a repository-wide -# sweep is exactly the change that touches every recovery authority at once. +# Second, the modules the suites below branch on `process.platform === 'win32'` +# for. Those cases are skipped on Linux by construction, so `test` cannot go red +# on them however carefully it runs. `assertNoWindowsAlternateStreams` is the +# example that matters: it is the one regression this lane has caught that no +# other lane could have. +# +# What is deliberately absent is the rest of those workspaces. Every source +# directory in the closure of the crash and owner-death suites used to be here, +# which put this lane on 118 of the last 200 merges. Over 300 runs it never once +# produced a pull-request red that `test` had not already produced on the same +# commit, usually at the very same step — the portable half of these suites is +# ordinary TypeScript, so a defect in it fails on Linux first and blocks the +# merge there. +# +# The wide filter also did not prevent the one regression it is tempting to +# cite. #4400 changed `packages/storage/src/**`, matched that filter, ran this +# lane three times on the pull request, and passed all three; the alternate +# stream failure appeared only on the main push afterwards. That is a stale-base +# interaction, which no path filter can see and which the unfiltered push below +# exists to catch. That trigger stays unfiltered for the same reason +# `required_status_checks` is `strict: false`, and because GitHub evaluates a +# path filter against the first 300 files of a diff only — a repository-wide +# sweep being exactly the change that touches every recovery authority at once. on: pull_request: branches: [main] @@ -50,6 +60,25 @@ on: - 'scripts/clean-build.mjs' - 'scripts/clean-paths.mjs' - 'scripts/windows-runtime-host-local-ipc-trust.ps1' + # Windows-branching modules under the suites this lane runs, and the two + # test files whose Windows-only cases exist nowhere else. + - 'packages/storage/src/managed-dependency-environment.ts' + - 'packages/storage/src/__tests__/managed-dependency-environment.test.ts' + - 'packages/storage/src/__tests__/fixtures/managed-dependency-environment-crash-child.ts' + - 'packages/storage/src/root-authority.ts' + - 'packages/storage/src/__tests__/root-authority.test.ts' + - 'packages/storage/src/native-file-lock.ts' + - 'packages/storage/src/marker-file.ts' + - 'packages/storage/src/file-lifetime-owner.ts' + - 'packages/storage/src/process-lifetime-owner.ts' + - 'packages/storage/src/sqlite-long-term-memory-store.ts' + - 'packages/runtime/src/process-tree-terminator.ts' + - 'packages/runtime/src/file-stable-write.ts' + - 'packages/runtime-host/src/control/endpoint.ts' + - 'packages/runtime-host/src/__tests__/fixtures/windows-local-ipc-trust-host.ts' + - 'packages/runtime-host/src/protocol/skill-catalog.ts' + - 'packages/runtime-host/src/server/skill-catalog-repository.ts' + - 'packages/runtime-host/src/server/skill-catalog-transaction.ts' - '.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 diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index d0bd42ad0f..28fbc2dc9c 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -772,6 +772,24 @@ test('the recovery lane filter follows the postinstall launcher chain', () => { } }); +test('every workspace file on the recovery filter branches on Windows', () => { + const filtered = pullRequestPathFilter('windows-recovery.yml').filter((path) => + path.startsWith('packages/'), + ); + + // The rule that admits a workspace file here is that Linux cannot go red on + // it: these are the modules whose behaviour forks on the platform, and the + // test files whose cases are skipped off Windows. Anything portable belongs + // to the required `test` lane instead, so a file that stops branching has to + // leave this list. A glob would defeat the rule, so each is named. + assert.ok(filtered.length > 0, 'no Windows-specific surface is filtered'); + for (const path of filtered) { + assert.doesNotMatch(path, /\*/u, `${path}: must name one file`); + const source = readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); + assert.match(source, /win32/u, `${path}: no Windows branch`); + } +}); + test('the recovery lane filters pull requests by what only Windows can prove', () => { const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); From 54d81647bec401e28b35d37ae178ccb772315855 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 19:43:30 +0800 Subject: [PATCH 08/13] test(release): compare durable state paths the way the platform spells them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `durableStateLocations` builds its paths with `join`, so on Windows the State Root is `\qualification-scope\state-root`. The assertion compared it to a literal `'/qualification-scope/state-root'` and failed there, taking `npm run check:release` and the packaged Windows lane with it — the account-local assertion beside it already used `join` and passed. The nesting assertion below had the same blind spot without failing: `golden.startsWith(`${live}/`)` cannot match a Windows path, so it would have accepted a golden copy nested inside its live directory, which is the one thing it exists to reject. Both now spell the separator the way the platform does. Generated-by: Claude Code --- scripts/qualify-released-cli-state-root.test.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/qualify-released-cli-state-root.test.mjs b/scripts/qualify-released-cli-state-root.test.mjs index 9a02a466ba..8660793431 100644 --- a/scripts/qualify-released-cli-state-root.test.mjs +++ b/scripts/qualify-released-cli-state-root.test.mjs @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { isAbsolute, join, resolve } from 'node:path'; +import { isAbsolute, join, resolve, sep } from 'node:path'; import test from 'node:test'; import { assertExpectedEpochRelation, @@ -199,14 +199,17 @@ test('durable state covers the control namespace, not only the State Root', () = // transition it proved was never the one a user performs. const locations = durableStateLocations('/qualification-scope'); assert.ok(locations.length >= 2); - assert.ok(locations.some(({ live }) => live === '/qualification-scope/state-root')); + assert.ok(locations.some(({ live }) => live === join('/qualification-scope', 'state-root'))); assert.ok( locations.some(({ live }) => live.endsWith(join('.cache', 'maka', 'runtime-hosts'))), 'the account-local control namespace must be captured and restored', ); for (const { live, golden } of locations) { assert.ok(isAbsolute(live) && isAbsolute(golden)); - assert.ok(!golden.startsWith(`${live}/`), 'a golden copy must not nest inside its live path'); + assert.ok( + !golden.startsWith(`${live}${sep}`), + 'a golden copy must not nest inside its live path', + ); } }); From b915970c6b86b57052a8ba9b5b38888cb2052137 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 20:31:07 +0800 Subject: [PATCH 09/13] ci: derive every narrowed gate from its authority instead of a reading of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reviewers found three holes in this branch, and they are one hole. Every gate it narrowed took its new input set from my reading of what the gated work consumes, and every contract test I wrote to defend that set proved only that what I kept belonged — never that what I dropped was unnecessary. Containment in the easy direction. The other direction is an absence, and an absence has no line number to check. So each list was short by whatever my reading could not see. The recovery filter missed `stable-storage.ts`, reached transitively through the two lock authorities it did name, along with six test files. `APP_ICON_FILES` missed `electron-builder.config.mjs`, which the drift test opens by path rather than imports. And the baseline cache missed that `prepareWindowsUpgradeBaseline` deletes its output directory before downloading unconditionally, so a restored entry was never once consulted. Each is now computed from the authority and asserted as set equality: - The recovery filter is the import closure of the dist suites its steps execute, restricted to files that branch on `win32` — 56 files, checked with `deepEqual` so an omission and a stale entry both fail. That is 40 of the last 200 merges rather than 17, and unlike 17 it can be shown to be complete. `collectWindowsPackageSourceClosure` is now a caller of a general `collectWorkspaceSourceClosure`, which the packaged Windows lane already used for exactly this purpose. - The app icon surface is read off the step: whatever `App icon artwork drift` runs is scanned for the repository paths it opens, and each must select the gate. - `prepareWindowsUpgradeBaseline` reuses a copy that hashes to the pinned digest and downloads only otherwise. A cache entry is never an authority on what the run installs: a mismatch or an unreadable file goes to the network, and only a fresh download that fails the digest fails the run. Applying the same rule to the gate none of the three reviews reached found a fourth instance, in the required job. `heavy` decides whether the toolchain is installed, and nothing asserted that a selection gating a later step is one of its disjuncts — so `app_icons`, added by this branch, gated a step that imports `@maka/core/settings` without selecting the install it needs. It reached green only because every icon input happens to select `code` as well. That disjunct is now present, and the workflow is scanned for the rest. Generated-by: Claude Code --- .github/workflows/windows-recovery.yml | 96 +++++++++---- scripts/ci-test-plan.mjs | 11 +- scripts/ci-test-plan.test.mjs | 118 +++++++++++++-- scripts/prepare-windows-upgrade-baseline.mjs | 23 ++- .../prepare-windows-upgrade-baseline.test.mjs | 136 ++++++++++++++++++ scripts/windows-package-source-closure.mjs | 15 +- 6 files changed, 354 insertions(+), 45 deletions(-) create mode 100644 scripts/prepare-windows-upgrade-baseline.test.mjs diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 6ddc515a28..c81c82c50d 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -25,19 +25,20 @@ name: Windows recovery # removes, and the Local IPC trust boundary, a PowerShell script with no other # caller. # -# Second, the modules the suites below branch on `process.platform === 'win32'` -# for. Those cases are skipped on Linux by construction, so `test` cannot go red -# on them however carefully it runs. `assertNoWindowsAlternateStreams` is the -# example that matters: it is the one regression this lane has caught that no -# other lane could have. +# Second, every file in the import closure of the suites below that branches on +# `process.platform === 'win32'`. Those branches are skipped on Linux by +# construction, so `test` cannot go red on them however carefully it runs. +# `assertNoWindowsAlternateStreams` is the example that matters: it is the one +# regression this lane has caught that no other lane could have. That second +# group is generated rather than curated, because a hand-picked list cannot be +# shown to be complete and this one has to be. # -# What is deliberately absent is the rest of those workspaces. Every source -# directory in the closure of the crash and owner-death suites used to be here, -# which put this lane on 118 of the last 200 merges. Over 300 runs it never once -# produced a pull-request red that `test` had not already produced on the same -# commit, usually at the very same step — the portable half of these suites is -# ordinary TypeScript, so a defect in it fails on Linux first and blocks the -# merge there. +# What is deliberately absent is the portable rest of those workspaces. Every +# source directory in the closure used to be here, which put this lane on 118 of +# the last 200 merges against 40 now. Over 300 runs it never once produced a +# pull-request red that `test` had not already produced on the same commit, +# usually at the very same step — the portable half of these suites is ordinary +# TypeScript, so a defect in it fails on Linux first and blocks the merge there. # # The wide filter also did not prevent the one regression it is tempting to # cite. #4400 changed `packages/storage/src/**`, matched that filter, ran this @@ -60,25 +61,66 @@ on: - 'scripts/clean-build.mjs' - 'scripts/clean-paths.mjs' - 'scripts/windows-runtime-host-local-ipc-trust.ps1' - # Windows-branching modules under the suites this lane runs, and the two - # test files whose Windows-only cases exist nowhere else. - - 'packages/storage/src/managed-dependency-environment.ts' - - 'packages/storage/src/__tests__/managed-dependency-environment.test.ts' - - 'packages/storage/src/__tests__/fixtures/managed-dependency-environment-crash-child.ts' - - 'packages/storage/src/root-authority.ts' - - 'packages/storage/src/__tests__/root-authority.test.ts' - - 'packages/storage/src/native-file-lock.ts' - - 'packages/storage/src/marker-file.ts' - - 'packages/storage/src/file-lifetime-owner.ts' - - 'packages/storage/src/process-lifetime-owner.ts' - - 'packages/storage/src/sqlite-long-term-memory-store.ts' - - 'packages/runtime/src/process-tree-terminator.ts' - - 'packages/runtime/src/file-stable-write.ts' + # Every file in the import closure of the suites below that branches on + # `process.platform === 'win32'`. Generated, not curated: + # `scripts/ci-test-plan.test.mjs` recomputes this set from the dist tests + # the steps run and fails on any difference in either direction. + - 'packages/core/src/diagnostic-log.ts' + - 'packages/runtime-host/src/__tests__/control-endpoint.test.ts' + - 'packages/runtime-host/src/__tests__/fixtures/endpoint-hygiene.ts' + - 'packages/runtime-host/src/__tests__/skill-catalog-protocol.test.ts' + - 'packages/runtime-host/src/__tests__/skill-catalog-repository.test.ts' + - 'packages/runtime-host/src/__tests__/skill-catalog-transaction.test.ts' + - 'packages/runtime-host/src/__tests__/skill-catalog-two-client-uds.test.ts' + - 'packages/runtime-host/src/client/client-instance-identity.ts' + - 'packages/runtime-host/src/client/host-profile.ts' + - 'packages/runtime-host/src/client/ssh-tunnel.ts' + - 'packages/runtime-host/src/client/wsl-control.ts' + - 'packages/runtime-host/src/control/access-credential-delivery.ts' - 'packages/runtime-host/src/control/endpoint.ts' - - 'packages/runtime-host/src/__tests__/fixtures/windows-local-ipc-trust-host.ts' + - 'packages/runtime-host/src/control/registration.ts' + - 'packages/runtime-host/src/control/startup-diagnostic.ts' + - 'packages/runtime-host/src/operator/local-deployment-owner.ts' + - 'packages/runtime-host/src/operator/managed-deployment.ts' + - 'packages/runtime-host/src/peer-mesh/owner.ts' + - 'packages/runtime-host/src/peer-mesh/store.ts' + - 'packages/runtime-host/src/protocol/host-status.ts' - 'packages/runtime-host/src/protocol/skill-catalog.ts' + - 'packages/runtime-host/src/server/access-credential-store.ts' - 'packages/runtime-host/src/server/skill-catalog-repository.ts' - 'packages/runtime-host/src/server/skill-catalog-transaction.ts' + - 'packages/runtime/src/__tests__/runtime-continuation-crash.test.ts' + - 'packages/runtime/src/__tests__/runtime-resume-crash.test.ts' + - 'packages/runtime/src/builtin-tools.ts' + - 'packages/runtime/src/file-stable-write.ts' + - 'packages/runtime/src/filesystem-worker/client.ts' + - 'packages/runtime/src/filesystem-worker/launch-spec.ts' + - 'packages/runtime/src/filesystem-worker/process-runner.ts' + - 'packages/runtime/src/pipe-process-driver.ts' + - 'packages/runtime/src/process-tree-terminator.ts' + - 'packages/runtime/src/pty-process-driver.ts' + - 'packages/runtime/src/sandbox-boundary-declaration.ts' + - 'packages/runtime/src/sandbox/default-sandbox-manager.ts' + - 'packages/runtime/src/sandbox/sandbox-manager.ts' + - 'packages/runtime/src/sandbox/windows-profile.ts' + - 'packages/runtime/src/sandbox/windows-sandbox.ts' + - 'packages/runtime/src/shell-detect.ts' + - 'packages/runtime/src/shell-exec.ts' + - 'packages/storage/src/__tests__/managed-dependency-environment.test.ts' + - 'packages/storage/src/__tests__/root-authority.test.ts' + - 'packages/storage/src/artifact-store.ts' + - 'packages/storage/src/artifact-writer-bootstrap-lock.ts' + - 'packages/storage/src/artifact-writer-lock.ts' + - 'packages/storage/src/credential-store.ts' + - 'packages/storage/src/file-lifetime-owner.ts' + - 'packages/storage/src/managed-dependency-environment.ts' + - 'packages/storage/src/marker-file.ts' + - 'packages/storage/src/memory-bundle-io.ts' + - 'packages/storage/src/native-file-lock.ts' + - 'packages/storage/src/root-authority.ts' + - 'packages/storage/src/runtime-policy/document-io.ts' + - 'packages/storage/src/sqlite-long-term-memory-store.ts' + - 'packages/storage/src/stable-storage.ts' - '.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 diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 75184387c6..19c90b7a5f 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -260,11 +260,13 @@ function isAstryxSurfaceInventoryPath(path) { /** * The two app-icon drift tests read exactly this surface: the committed * artwork, the generator that must still reproduce it, the `APP_ICONS` catalog - * they check it against, and the packaged-resource list that has to keep - * naming every file. Regenerating the artwork costs about a minute, which - * every unrelated code change used to pay. + * they check it against, the packaged-resource list that has to keep naming + * every file, and the packaging config, which the drift test opens by path to + * prove the bundle icon is still `DEFAULT_APP_ICON`. Regenerating the artwork + * costs about a minute, which every unrelated code change used to pay. */ const APP_ICON_FILES = new Set([ + 'apps/desktop/electron-builder.config.mjs', 'packages/core/src/settings.ts', 'scripts/generate-app-icons.py', 'scripts/generate-app-icons.test.mjs', @@ -488,7 +490,8 @@ export function planTests(changedFiles, options = {}) { export function requiresHeavyValidation(plan) { return Boolean( - plan.asfSource || + plan.appIcons || + plan.asfSource || plan.astryxSurface || plan.cliPackage || plan.code || diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 28fbc2dc9c..fefd9f2841 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -20,6 +20,7 @@ import assert from 'node:assert/strict'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { changedFilesBetween, @@ -28,6 +29,7 @@ import { planTests, requiresHeavyValidation, } from './ci-test-plan.mjs'; +import { collectWorkspaceSourceClosure } from './windows-package-source-closure.mjs'; const dirs = [ 'packages/core', @@ -494,6 +496,9 @@ test('contract checks run before dependency setup and can fail the job', () => { /** Every file the two app-icon drift tests read. */ const APP_ICON_INPUTS = [ 'apps/desktop/assets/app-icons/sky.png', + // Read by path in the drift test, so pointing it at a different icon that + // happens to exist would otherwise package cleanly with the assertion unrun. + 'apps/desktop/electron-builder.config.mjs', 'packages/core/src/settings.ts', 'scripts/generate-app-icons.py', 'scripts/generate-app-icons.test.mjs', @@ -501,6 +506,76 @@ const APP_ICON_INPUTS = [ 'scripts/verify-packaged-app-icons.test.mjs', ]; +test('every selection that gates an installed step is one heavy validation covers', () => { + const workflow = readWorkflow('ci.yml'); + + // `heavy` decides whether the toolchain is installed at all, so any later + // step gated on a selection outside that disjunction would run against a + // runner with no dependencies — or, if the step is a `run:` that tolerates + // it, report green having done nothing. Derived from the workflow rather + // than restated, so a new plan output cannot gate a step without joining + // the disjunction first. + const [, installed] = workflow.split(/\n\s+- uses: actions\/setup-node[^\n]*\n/u); + assert.ok(installed, 'ci.yml no longer installs a toolchain'); + + const gating = [ + ...new Set([...installed.matchAll(/steps\.plan\.outputs\.(\w+) ==/gu)].map(([, name]) => name)), + ].sort(); + assert.ok(gating.length > 0, 'no installed step is gated on a selection'); + + // The planner names selections in camelCase and publishes them in snake_case. + const source = readFileSync(new URL('./ci-test-plan.mjs', import.meta.url), 'utf8'); + const disjunction = source.match(/export function requiresHeavyValidation[\s\S]*?\n\}/u)?.[0]; + assert.ok(disjunction, 'requiresHeavyValidation is no longer a single expression'); + + for (const output of gating) { + if (output === 'heavy') continue; + const camel = output.replace(/_(\w)/gu, (_, letter) => letter.toUpperCase()); + assert.match( + disjunction, + new RegExp(`plan\\.${camel}\\b`, 'u'), + `${output} gates an installed step but does not select the install`, + ); + } +}); + +test('the app icon gate selects every file its own tests open', () => { + // Derived from the step, not from a memory of it: whatever `App icon artwork + // drift` runs is the authority on what has to select it. The list this + // replaces was assembled by reading those tests, which is how the packaging + // config — opened by path rather than imported — stayed off it while the + // drift assertion it feeds could be skipped silently. + const step = readWorkflow('ci.yml').match( + /if: steps\.plan\.outputs\.app_icons == 'true'\n\s+run: node --test ([^\n]+)/u, + ); + assert.ok(step, 'no step is gated on the app icon selection'); + const suites = step[1].trim().split(/\s+/u); + assert.ok(suites.length > 0); + + for (const suite of suites) { + assert.equal(planTests([suite], { graph }).appIcons, true, suite); + const source = readFileSync(new URL(`../${suite}`, import.meta.url), 'utf8'); + + // Every repository path those suites name, however they reach it: a + // `new URL` read, a relative import, or a directory of artwork. + const named = [ + ...[...source.matchAll(/new URL\('([^']+)'/gu)].map((match) => match[1]), + ...[...source.matchAll(/from '(\.[^']+)'/gu)].map((match) => match[1]), + ].map((path) => new URL(path, new URL(`../${suite}`, import.meta.url)).pathname); + + const root = new URL('..', import.meta.url).pathname; + for (const absolute of named) { + // A trailing slash names a directory, so probe inside it as well: the + // artwork is selected by prefix rather than file by file. + const path = absolute.slice(root.length).replace(/\/$/u, ''); + const selects = [path, `${path}/probe`].some( + (candidate) => planTests([candidate], { graph }).appIcons, + ); + assert.ok(selects, `${suite} reads ${path}`); + } + } +}); + test('app icon drift selects the artwork and whatever derives or names it', () => { for (const path of APP_ICON_INPUTS) { assert.equal(planTests([path], { graph }).appIcons, true, path); @@ -772,22 +847,43 @@ test('the recovery lane filter follows the postinstall launcher chain', () => { } }); -test('every workspace file on the recovery filter branches on Windows', () => { +test('the recovery filter is exactly the Windows-branching closure of its tests', async () => { + const workflow = readWorkflow('windows-recovery.yml'); const filtered = pullRequestPathFilter('windows-recovery.yml').filter((path) => path.startsWith('packages/'), ); - // The rule that admits a workspace file here is that Linux cannot go red on - // it: these are the modules whose behaviour forks on the platform, and the - // test files whose cases are skipped off Windows. Anything portable belongs - // to the required `test` lane instead, so a file that stops branching has to - // leave this list. A glob would defeat the rule, so each is named. - assert.ok(filtered.length > 0, 'no Windows-specific surface is filtered'); - for (const path of filtered) { - assert.doesNotMatch(path, /\*/u, `${path}: must name one file`); - const source = readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); - assert.match(source, /win32/u, `${path}: no Windows branch`); + // Derived from the dist tests the steps actually execute, mapped back to + // source. A suite added to a step joins this set without anyone remembering + // to widen the filter. + const entrypoints = [ + ...new Set( + [...workflow.matchAll(/packages\/([\w-]+)\/dist\/__tests__\/([\w.-]+)\.test\.js/gu)].map( + ([, workspace, name]) => `packages/${workspace}/src/__tests__/${name}.test.ts`, + ), + ), + ].sort(); + assert.ok(entrypoints.length > 0, 'no executed suite was recognised'); + for (const entrypoint of entrypoints) { + assert.ok(existsSync(new URL(`../${entrypoint}`, import.meta.url)), entrypoint); } + + // Set equality, not containment, and in both directions on purpose. A subset + // check cannot see an omission, which is how `stable-storage.ts` — reached + // through the two listed lock authorities — sat outside a filter that was + // supposed to cover exactly it. A superset check cannot see a file that + // stopped branching and now schedules a Windows runner for nothing. + const closure = await collectWorkspaceSourceClosure( + entrypoints, + fileURLToPath(new URL('..', import.meta.url)), + ); + const windowsBranching = closure + .filter((path) => + readFileSync(new URL(`../${path}`, import.meta.url), 'utf8').includes('win32'), + ) + .sort(); + + assert.deepEqual(filtered.sort(), windowsBranching); }); test('the recovery lane filters pull requests by what only Windows can prove', () => { diff --git a/scripts/prepare-windows-upgrade-baseline.mjs b/scripts/prepare-windows-upgrade-baseline.mjs index 87c388c045..0376b71b8b 100644 --- a/scripts/prepare-windows-upgrade-baseline.mjs +++ b/scripts/prepare-windows-upgrade-baseline.mjs @@ -18,6 +18,7 @@ */ import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { mkdir, readFile, rm } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -59,6 +60,16 @@ export async function prepareWindowsUpgradeBaseline( candidateVersion, ); const directory = resolve(outputDirectory); + const installer = join(directory, manifest.assetName); + + // The manifest pins one immutable asset, so a copy already sitting here that + // hashes to the pinned digest is that asset and downloading it again would + // return the same bytes. CI restores this directory from a cache keyed on the + // manifest, and the release download is the most frequent failure on this + // lane, so reusing a verified copy is what removes those false reds. Anything + // that does not verify is discarded rather than trusted. + if (await hasVerifiedBaseline(installer, manifest.sha256, checksum)) return installer; + await rm(directory, { recursive: true, force: true }); await mkdir(directory, { recursive: true }); await run('gh', [ @@ -72,7 +83,6 @@ export async function prepareWindowsUpgradeBaseline( '--dir', directory, ]); - const installer = join(directory, manifest.assetName); const actual = await checksum(installer); if (actual !== manifest.sha256) { throw new Error( @@ -82,6 +92,17 @@ export async function prepareWindowsUpgradeBaseline( return installer; } +async function hasVerifiedBaseline(installer, expected, checksum) { + if (!existsSync(installer)) return false; + // A truncated or half-written cache entry must send us to the network, not + // stop the run: only a digest mismatch on a fresh download is a real defect. + try { + return (await checksum(installer)) === expected; + } catch { + return false; + } +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const installer = await prepareWindowsUpgradeBaseline(process.argv[2], process.argv[3]); process.stdout.write(installer); diff --git a/scripts/prepare-windows-upgrade-baseline.test.mjs b/scripts/prepare-windows-upgrade-baseline.test.mjs new file mode 100644 index 0000000000..9d9d9c8651 --- /dev/null +++ b/scripts/prepare-windows-upgrade-baseline.test.mjs @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The packaged Windows lane restores this directory from a cache keyed on the + * pinned manifest, because downloading the baseline is that lane's most + * frequent failure and the asset it names never changes. A restored copy is + * only worth anything if the preparation step actually reuses it, which is the + * contract below: reuse what verifies, download what does not, and never let a + * cache decide what the run installs. + */ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { prepareWindowsUpgradeBaseline } from './prepare-windows-upgrade-baseline.mjs'; + +const MANIFEST = { + version: '0.1.0', + tag: 'v0.1.0', + assetName: 'Maka-0.1.0-win-x64.exe', + sha256: 'a'.repeat(64), +}; + +function scenario({ cached, digest = async () => MANIFEST.sha256 }) { + const root = mkdtempSync(join(tmpdir(), 'upgrade-baseline-')); + const manifestPath = join(root, 'baseline.json'); + writeFileSync(manifestPath, JSON.stringify(MANIFEST)); + const directory = join(root, 'artifacts'); + const installer = join(directory, MANIFEST.assetName); + if (cached !== undefined) { + mkdirSync(directory, { recursive: true }); + writeFileSync(installer, cached); + } + const downloads = []; + return { + root, + directory, + installer, + downloads, + prepare: () => + prepareWindowsUpgradeBaseline('0.2.0', directory, { + manifestPath, + checksum: digest, + run: (...args) => { + downloads.push(args); + mkdirSync(directory, { recursive: true }); + writeFileSync(installer, 'downloaded'); + return Promise.resolve(); + }, + }), + }; +} + +test('a cached installer matching the pinned digest is used without downloading', async () => { + const { installer, downloads, prepare, root } = scenario({ cached: 'cached' }); + try { + assert.equal(await prepare(), installer); + assert.deepEqual(downloads, []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('an absent installer is downloaded and verified', async () => { + const { installer, downloads, prepare, root } = scenario({ cached: undefined }); + try { + assert.equal(await prepare(), installer); + assert.equal(downloads.length, 1); + assert.equal(downloads[0][0], 'gh'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('a cached installer with the wrong digest is replaced rather than trusted', async () => { + // The cache is not an authority on what this lane installs. First call is the + // stale entry, second is the fresh download, which must be the one verified. + const digests = [`${'b'.repeat(63)}0`, MANIFEST.sha256]; + const { downloads, prepare, root } = scenario({ + cached: 'stale', + digest: async () => digests.shift(), + }); + try { + await prepare(); + assert.equal(downloads.length, 1); + assert.deepEqual(digests, []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('an unreadable cache entry falls back to the network instead of failing', async () => { + const digests = [null, MANIFEST.sha256]; + const { downloads, prepare, root } = scenario({ + cached: '', + digest: async () => { + const next = digests.shift(); + if (next === null) throw new Error('unreadable'); + return next; + }, + }); + try { + await prepare(); + assert.equal(downloads.length, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('a fresh download that does not match the pinned digest fails the run', async () => { + const { prepare, root } = scenario({ cached: undefined, digest: async () => 'c'.repeat(64) }); + try { + await assert.rejects(prepare(), /checksum mismatch/u); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/windows-package-source-closure.mjs b/scripts/windows-package-source-closure.mjs index 6b7eefe958..68f7f928b3 100644 --- a/scripts/windows-package-source-closure.mjs +++ b/scripts/windows-package-source-closure.mjs @@ -32,15 +32,26 @@ export const windowsPackageSourceEntrypoints = [ ]; export async function collectWindowsPackageSourceClosure(repoRoot = defaultRepoRoot) { + return collectWorkspaceSourceClosure(windowsPackageSourceEntrypoints, repoRoot); +} + +/** + * Workspace sources `entryPoints` reach, transitively, resolved through the + * workspace `exports` map back to `src` rather than to built `dist`. Two lanes + * derive a path filter from this instead of maintaining one by hand, so a + * dependency added anywhere under an entry point cannot escape the filter that + * is supposed to schedule the runner able to observe it. + */ +export async function collectWorkspaceSourceClosure(entryPoints, repoRoot = defaultRepoRoot) { const workspaces = loadWorkspacePackages(repoRoot); const result = await build({ absWorkingDir: repoRoot, bundle: true, - entryPoints: windowsPackageSourceEntrypoints, + entryPoints, format: 'esm', logLevel: 'silent', metafile: true, - outdir: 'windows-package-source-closure', + outdir: 'workspace-source-closure', packages: 'external', platform: 'node', plugins: [workspaceSourcePlugin(repoRoot, workspaces)], From 7c8e9240369e679c0fdff98bf3cbd04eddf5bc39 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 21:00:20 +0800 Subject: [PATCH 10/13] test(ci): give the install-free lane a name and a contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ci-test-plan.test.mjs` was the only suite running before `npm ci`, so every assertion that needed a bare checkout accreted there regardless of subject: 66 tests, of which 35 read `.github/workflows/*.yml` and had nothing to do with the test planner. The file name carried no hint that its one hard rule is "import nothing that is not installed yet", which is how a closure assertion needing esbuild was very nearly added to it. That import would have thrown `ERR_MODULE_NOT_FOUND` in the sole required context and frozen every merge in the repository. Split by what a test reads. `ci-test-plan.test.mjs` keeps the 30 tests that exercise the planner against an in-memory graph; the 36 that read a workflow move to `ci-workflow-policy.test.mjs`, beside the two policy suites that already follow that name. Naming the constraint is not enforcing it, so derive it: the new suite reads which files the steps above `setup-node` run and walks their transitive local imports, failing on any specifier that is neither a `node:` builtin nor a repository module holding the same rule. Moving a step below the install lifts the constraint and adding one above imposes it, with no list to maintain. The closure assertion itself now lives in `windows-package-source-closure.test.mjs`, which `check:release` runs after installing, and `windows-recovery.yml` joined the release-contract inputs so editing that filter selects the gate that checks it. Two app-icon tests iterated a hand-written `APP_ICON_INPUTS`; the derived test computes that same set from the suites the step runs, so they folded into it and the list is gone. `prepare-windows-upgrade-baseline.test.mjs` joined `check:release` without joining the planner's release set — the guardrail that exists for exactly that caught it. Generated-by: Claude Code --- .github/workflows/ci.yml | 4 +- .github/workflows/windows-recovery.yml | 5 +- package.json | 2 +- scripts/ci-test-plan.mjs | 2 + scripts/ci-test-plan.test.mjs | 786 +----------------- scripts/ci-workflow-policy.test.mjs | 776 +++++++++++++++++ scripts/windows-package-source-closure.mjs | 8 +- .../windows-package-source-closure.test.mjs | 49 ++ 8 files changed, 855 insertions(+), 777 deletions(-) create mode 100644 scripts/ci-workflow-policy.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15cd6334e0..60e432e9e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,9 @@ jobs: fi - name: Test CI planner - run: node --test --test-concurrency=1 scripts/ci-test-plan.test.mjs scripts/verify-windows-harness.test.mjs + run: >- + node --test --test-concurrency=1 scripts/ci-test-plan.test.mjs + scripts/ci-workflow-policy.test.mjs scripts/verify-windows-harness.test.mjs # Pure Node like the planner test, and the labelling workflow imports this # module directly, so a tier or exclusion change is caught here rather diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index c81c82c50d..cbf1ea3791 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -63,8 +63,9 @@ on: - 'scripts/windows-runtime-host-local-ipc-trust.ps1' # Every file in the import closure of the suites below that branches on # `process.platform === 'win32'`. Generated, not curated: - # `scripts/ci-test-plan.test.mjs` recomputes this set from the dist tests - # the steps run and fails on any difference in either direction. + # `scripts/windows-package-source-closure.test.mjs` recomputes this set + # from the dist tests the steps run and fails on any difference in either + # direction. - 'packages/core/src/diagnostic-log.ts' - 'packages/runtime-host/src/__tests__/control-endpoint.test.ts' - 'packages/runtime-host/src/__tests__/fixtures/endpoint-hygiene.ts' diff --git a/package.json b/package.json index d2c7cc144f..08a3131b68 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "check:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs --check", "generate:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs", "check:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-nightly.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/qualify-released-cli-state-root.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-nightly.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/qualify-released-cli-state-root.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/prepare-windows-upgrade-baseline.test.mjs scripts/windows-package-source-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", "package:macos-autoupdate-next": "node scripts/package-macos-autoupdate-next.mjs", diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 19c90b7a5f..149dd30152 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -49,12 +49,14 @@ const RELEASE_CONTRACT_FILES = new Set([ '.github/workflows/release-cli-stage.yml', '.github/workflows/release.yml', '.github/workflows/release-windows-check.yml', + '.github/workflows/windows-recovery.yml', 'scripts/package-macos-arm64.mjs', 'scripts/package-macos-autoupdate-next.mjs', 'scripts/package-macos-arm64-cli.mjs', 'scripts/package-windows-autoupdate-next.mjs', 'scripts/package-windows-x64.mjs', 'scripts/prepare-windows-upgrade-baseline.mjs', + 'scripts/prepare-windows-upgrade-baseline.test.mjs', 'scripts/generate-third-party-notices.test.mjs', 'scripts/product-release.test.mjs', 'scripts/qualify-released-cli-state-root.test.mjs', diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index fefd9f2841..a9d03ddeb0 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -17,19 +17,26 @@ * under the License. */ +/** + * Behaviour of the test planner itself: which lanes a set of changed files + * selects. Assertions about the workflows that consume those selections live in + * `ci-workflow-policy.test.mjs`. + * + * This suite runs before `npm ci` installs anything, so it may import only + * `node:` builtins and repository modules that do the same. + * `ci-workflow-policy.test.mjs` asserts that constraint for every suite the + * install-free steps run. + */ import assert from 'node:assert/strict'; -import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; -import { fileURLToPath } from 'node:url'; import { changedFilesBetween, formatGitHubOutputs, - loadWorkspaceGraph, planTests, requiresHeavyValidation, } from './ci-test-plan.mjs'; -import { collectWorkspaceSourceClosure } from './windows-package-source-closure.mjs'; const dirs = [ 'packages/core', @@ -401,188 +408,10 @@ test('ordinary changes do not pay for the released forward roll', () => { assert.equal(plan.stateRootCompat, false); }); -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'); - - // The planner writes one step's outputs and every later step gates on them, - // so what it emits and what CI reads are the same set. A key nothing reads, - // or a gate on a key the planner never writes, is a dead lane either way. - const consumedKeys = new Set( - [...workflow.matchAll(/steps\.plan\.outputs\.([a-z0-9_]+)/gu)].map((match) => match[1]), - ); - - assert.deepEqual(outputKeys, consumedKeys); -}); - -test('one unconditional job carries the required context on every pull request', () => { - const workflow = readWorkflow('ci.yml'); - - // `.asf.yaml` requires `test`. A paths filter would stop the workflow and - // leave that check pending forever, and a second job would make the same - // pull request queue for a scarce runner twice to reach one verdict. - assert.doesNotMatch(triggerBlock('ci.yml'), /\bpaths(-ignore)?:/u); - - const jobsBlock = workflow.slice(workflow.indexOf('\njobs:')); - const jobs = [...jobsBlock.matchAll(/^ {2}([a-z0-9_-]+):$/gmu)].map((match) => match[1]); - assert.deepEqual(jobs, ['test']); - assert.doesNotMatch(jobsBlock, /^ {4}needs:/mu); - assert.doesNotMatch(jobsBlock, /^ {4}if:/mu); -}); - -test('planning runs first and every later step gates on its outputs', () => { - const workflow = readWorkflow('ci.yml'); - - // With the job split gone there is no `needs` context to read. GitHub - // resolves a leftover `needs.plan.outputs.x` to the empty string rather - // than failing, so the step it guards would silently never run again. - assert.doesNotMatch(workflow, /needs\.plan\.outputs/u); - - const planStep = workflow.indexOf(' - id: plan\n'); - assert.ok(planStep >= 0, 'no planning step'); - assert.ok(planStep < workflow.indexOf('steps.plan.outputs')); - assert.match( - workflow, - /- name: Check renderer architecture\n\s+if: steps\.plan\.outputs\.code == 'true'/u, - ); -}); - -test('core CI validates pull requests and the resulting main branch state', () => { - const workflow = readWorkflow('ci.yml'); - - assert.match(workflow, /pull_request:\n\s+branches: \[main\]/u); - assert.match(workflow, /push:\n\s+branches: \[main\]/u); - assert.match( - workflow, - /BASE_SHA: \$\{\{ github\.event_name == 'push' && github\.event\.before \|\| github\.event\.pull_request\.base\.sha \}\}/u, - ); - assert.match( - workflow, - /HEAD_SHA: \$\{\{ github\.event_name == 'push' && github\.sha \|\| github\.event\.pull_request\.head\.sha \}\}/u, - ); - assert.match(workflow, /\[\[ "\$BASE_SHA" =~ \^0\+\$ \]\]/u); -}); - -test('core CI uses the Windows inventory package-script authority', () => { - const workflow = readWorkflow('ci.yml'); - - assert.match(workflow, /run: npm run windows:inventory/u); - assert.doesNotMatch(workflow, /run: node scripts\/windows-test-inventory\.mjs --check/u); -}); - -test('contract checks run before dependency setup and can fail the job', () => { - const workflow = readWorkflow('ci.yml'); - const setupNodeStart = workflow.indexOf(' - uses: actions/setup-node@'); - - // These 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', - 'Verify ASF npm preflight policy', - ]) { - 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); - } -}); - -/** Every file the two app-icon drift tests read. */ -const APP_ICON_INPUTS = [ - 'apps/desktop/assets/app-icons/sky.png', - // Read by path in the drift test, so pointing it at a different icon that - // happens to exist would otherwise package cleanly with the assertion unrun. - 'apps/desktop/electron-builder.config.mjs', - 'packages/core/src/settings.ts', - 'scripts/generate-app-icons.py', - 'scripts/generate-app-icons.test.mjs', - 'scripts/verify-packaged-app.mjs', - 'scripts/verify-packaged-app-icons.test.mjs', -]; - -test('every selection that gates an installed step is one heavy validation covers', () => { - const workflow = readWorkflow('ci.yml'); - - // `heavy` decides whether the toolchain is installed at all, so any later - // step gated on a selection outside that disjunction would run against a - // runner with no dependencies — or, if the step is a `run:` that tolerates - // it, report green having done nothing. Derived from the workflow rather - // than restated, so a new plan output cannot gate a step without joining - // the disjunction first. - const [, installed] = workflow.split(/\n\s+- uses: actions\/setup-node[^\n]*\n/u); - assert.ok(installed, 'ci.yml no longer installs a toolchain'); - - const gating = [ - ...new Set([...installed.matchAll(/steps\.plan\.outputs\.(\w+) ==/gu)].map(([, name]) => name)), - ].sort(); - assert.ok(gating.length > 0, 'no installed step is gated on a selection'); - - // The planner names selections in camelCase and publishes them in snake_case. - const source = readFileSync(new URL('./ci-test-plan.mjs', import.meta.url), 'utf8'); - const disjunction = source.match(/export function requiresHeavyValidation[\s\S]*?\n\}/u)?.[0]; - assert.ok(disjunction, 'requiresHeavyValidation is no longer a single expression'); - - for (const output of gating) { - if (output === 'heavy') continue; - const camel = output.replace(/_(\w)/gu, (_, letter) => letter.toUpperCase()); - assert.match( - disjunction, - new RegExp(`plan\\.${camel}\\b`, 'u'), - `${output} gates an installed step but does not select the install`, - ); - } -}); - -test('the app icon gate selects every file its own tests open', () => { - // Derived from the step, not from a memory of it: whatever `App icon artwork - // drift` runs is the authority on what has to select it. The list this - // replaces was assembled by reading those tests, which is how the packaging - // config — opened by path rather than imported — stayed off it while the - // drift assertion it feeds could be skipped silently. - const step = readWorkflow('ci.yml').match( - /if: steps\.plan\.outputs\.app_icons == 'true'\n\s+run: node --test ([^\n]+)/u, - ); - assert.ok(step, 'no step is gated on the app icon selection'); - const suites = step[1].trim().split(/\s+/u); - assert.ok(suites.length > 0); - - for (const suite of suites) { - assert.equal(planTests([suite], { graph }).appIcons, true, suite); - const source = readFileSync(new URL(`../${suite}`, import.meta.url), 'utf8'); - - // Every repository path those suites name, however they reach it: a - // `new URL` read, a relative import, or a directory of artwork. - const named = [ - ...[...source.matchAll(/new URL\('([^']+)'/gu)].map((match) => match[1]), - ...[...source.matchAll(/from '(\.[^']+)'/gu)].map((match) => match[1]), - ].map((path) => new URL(path, new URL(`../${suite}`, import.meta.url)).pathname); - - const root = new URL('..', import.meta.url).pathname; - for (const absolute of named) { - // A trailing slash names a directory, so probe inside it as well: the - // artwork is selected by prefix rather than file by file. - const path = absolute.slice(root.length).replace(/\/$/u, ''); - const selects = [path, `${path}/probe`].some( - (candidate) => planTests([candidate], { graph }).appIcons, - ); - assert.ok(selects, `${suite} reads ${path}`); - } - } -}); - -test('app icon drift selects the artwork and whatever derives or names it', () => { - for (const path of APP_ICON_INPUTS) { - assert.equal(planTests([path], { graph }).appIcons, true, path); - } - - // Regenerating the artwork costs about a minute. Ordinary product code is - // exactly what must stop paying it. +// Which files select the app icon gate is derived in `ci-workflow-policy.test.mjs` +// from the suites the step runs. What stays here is the complement: regenerating +// the artwork costs about a minute, and ordinary product code must stop paying it. +test('ordinary product code does not pay for app icon drift', () => { for (const path of [ 'apps/desktop/src/renderer/app-shell.tsx', 'packages/core/src/artifacts.ts', @@ -591,588 +420,3 @@ test('app icon drift selects the artwork and whatever derives or names it', () = assert.equal(planTests([path], { graph }).appIcons, false, path); } }); - -test('an app icon selection always brings the build those tests need', () => { - // `generate-app-icons.test.mjs` imports `APP_ICONS` from built `@maka/core` - // and the step runs after Build, which gates on `code`. Every input above - // lives in a workspace or under `scripts/`, so `code` follows rather than - // needing its own gate — an entry that broke that would fail here. - for (const path of APP_ICON_INPUTS) { - assert.equal(planTests([path], { graph }).code, true, path); - } -}); - -test('core CI gates app icon drift on the artwork surface', () => { - assert.match( - readWorkflow('ci.yml'), - /- name: App icon artwork drift\n\s+if: steps\.plan\.outputs\.app_icons == 'true'/u, - ); -}); - -test('core CI checks the Astryx inventory for every code change before building', () => { - const workflow = readWorkflow('ci.yml'); - const inventoryStart = workflow.indexOf(' - name: Astryx surface inventory\n'); - const inventoryEnd = workflow.indexOf('\n - ', inventoryStart + 1); - const buildStart = workflow.indexOf(' - name: Build\n'); - - assert.ok(inventoryStart >= 0); - assert.ok(inventoryStart < buildStart); - - const inventoryStep = workflow.slice(inventoryStart, inventoryEnd); - assert.match( - inventoryStep, - /if: steps\.plan\.outputs\.code == 'true' \|\| steps\.plan\.outputs\.astryx_surface == 'true'/u, - ); - assert.doesNotMatch(inventoryStep, /continue-on-error/u); -}); - -test('CI installs dependencies whenever the Astryx surface inventory runs', () => { - const workflow = readWorkflow('ci.yml'); - // The inventory step imports the generator, which resolves @astryxdesign/core - // and parses the @maka/ui barrel. An inventory-doc-only PR is `astryx_surface` - // without `code`, so the `npm ci` step must gate on astryx_surface too — else - // the generator runs with no dependencies installed and fails closed. - const npmCi = workflow.indexOf('run: npm ci'); - assert.ok(npmCi >= 0, 'expected an `npm ci` install step'); - const stepStart = workflow.lastIndexOf('\n - name:', npmCi) + 1; - const stepEnd = workflow.indexOf('\n - ', npmCi); - const installStep = workflow.slice(stepStart, stepEnd); - assert.match(installStep, /steps\.plan\.outputs\.astryx_surface == 'true'/u); -}); - -test('core CI validates affected installed CLI packages on the heavy runner', () => { - const workflow = readWorkflow('ci.yml'); - const toolchain = workflow.indexOf( - 'npm install --global --no-audit --no-fund "$(node -p \'require("./package.json").packageManager\')"', - ); - const pack = workflow.indexOf('run: npm run release:cli:pack'); - - assert.match(workflow, /if: steps\.plan\.outputs\.cli_package == 'true'/u); - assert.ok(toolchain >= 0); - assert.ok(toolchain < pack); - assert.match(workflow, /run: npm run release:cli:smoke/u); -}); - -test('Rust build caches publish immutable source generations only from the default branch', () => { - const workflows = readdirSync(WORKFLOW_DIR) - .filter((name) => name.endsWith('.yml')) - .map((name) => [name, readWorkflow(name)]) - .filter(([, workflow]) => workflow.includes('tool: kache@0.16.0')); - - assert.equal(workflows.length, 5); - for (const [name, workflow] of workflows) { - assert.match(workflow, /echo "revision=\$\(git rev-parse HEAD\)"/u, name); - const primaryKeys = [...workflow.matchAll(/^\s+key: (kache-[^\n]+)$/gmu)].map(([, key]) => key); - assert.ok(primaryKeys.length > 0, name); - const restoreKeys = [...workflow.matchAll(/^\s+(kache-[^\n]+-)$/gmu)].map(([, key]) => key); - assert.equal(restoreKeys.length, primaryKeys.length, name); - primaryKeys.forEach((key) => { - assert.match(key, /\$\{\{ steps\.[^.]+\.outputs\.revision \}\}$/u, name); - assert.ok( - restoreKeys.includes(key.replace(/\$\{\{ steps\.[^.]+\.outputs\.revision \}\}$/u, '')), - name, - ); - }); - assert.match( - workflow, - /name: Save [^\n]*Rust build cache\n\s+if: [^\n]*github\.event\.repository\.default_branch/u, - name, - ); - assert.doesNotMatch(workflow, /kache report [^\n]*--since/u, name); - } -}); - -test('release contracts run against built CLI outputs', () => { - const workflow = readWorkflow('ci.yml'); - const buildIndex = workflow.indexOf(' - name: Build\n'); - const buildEnd = workflow.indexOf('\n - ', buildIndex + 1); - const releaseIndex = workflow.indexOf(' - name: Release contracts\n'); - - assert.ok(buildIndex >= 0); - assert.match(workflow.slice(buildIndex, buildEnd), /release_contract == 'true'/u); - assert.ok(buildIndex < releaseIndex); - assert.match( - workflow.slice(releaseIndex), - /if: steps\.plan\.outputs\.release_contract == 'true'/u, - ); -}); - -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', - 'cli-package-validation.yml', - 'copilot-auto-review.yml', - 'dependency-audit.yml', - 'gitoxide-helper-admission.yml', - 'pr-effort-label.yml', - 'release-windows-check.yml', - 'runtime-host-owner-platform.yml', - 'runtime-host-peer-admission.yml', - 'windows-recovery.yml', - 'windows-sandbox-w0.yml', - ]); -}); - -test('every pull request lane holds a scarce runner for the same bounded time', () => { - // One tier, not per-lane values. The worst observed successful runs are 19 - // minutes (ci.yml) and 20 (release-windows-check), so 45 is about 2.3x the - // slowest lane: enough headroom for a cold cache and a flake retry, and far - // short of the 120 and 90 a hung job used to hold. A lane with no limit at - // all inherits GitHub's 360 and fails here. - // `pull_request` only: a `pull_request_target` lane reads the pull request - // rather than gating it, so it is not competing for a runner the author is - // waiting on and keeps its own tighter limit. - // Granularity is the file, not the job: a job inside a gating workflow that - // opts out of pull requests still carries the tier, because reading a job's - // `if:` would need the YAML parser this file cannot install. - const gates = readdirSync(WORKFLOW_DIR).filter(hasPullRequestGate); - assert.ok(gates.length > 0, 'no pull request lane found'); - - for (const name of gates) { - // From `jobs:` on, with comment lines stripped, so prose above the triggers - // cannot be read as a job. - const workflow = readWorkflow(name).replaceAll(/^[ \t]*#.*$/gmu, ''); - const start = workflow.indexOf('\njobs:'); - assert.ok(start >= 0, `${name}: no jobs block`); - const jobs = workflow.slice(start); - - const limits = [...jobs.matchAll(/^ {4}timeout-minutes: (\d+)$/gmu)].map((match) => match[1]); - // Counted by `runs-on`, one per job that consumes a runner, rather than by - // job id: a quoted id escapes an id pattern, and a two-space line inside a - // `run: |` block satisfies one. - const runners = [...jobs.matchAll(/^ {4}runs-on:/gmu)].length; - assert.ok(runners > 0, `${name}: no job consumes a runner`); - assert.deepEqual( - limits, - Array.from({ length: runners }, () => '45'), - name, - ); - } -}); - -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 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); - 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 leaves the suites it executes to the required test lane', () => { - 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. - const executed = [ - ...new Set( - [...workflow.matchAll(/packages[/\\]([^/\\]+)[/\\]dist[/\\]/gu)].map((match) => match[1]), - ), - ].sort(); - assert.deepEqual(executed, ['runtime', 'runtime-host', 'storage']); - - // None of that closure belongs in the filter. These suites are ordinary - // TypeScript, so `test` runs them on Linux on every pull request and fails - // first; naming their sources here only bought a second, slower red. Listing - // one again would put this lane back on most merges, so it fails here. - const closure = dependencyClosure(executed.map((workspace) => `packages/${workspace}`)); - assert.ok(closure.includes('packages/core'), 'dependency closure must reach core'); - for (const dir of closure) { - for (const entry of [`${dir}/src/**`, `${dir}/tsconfig.json`, `${dir}/package.json`]) { - assert.ok(!filtered.has(entry), `${entry} belongs to the required test lane`); - } - } - - // What the Windows runner proves instead is that the suites still build and - // run here at all, which is why the unconditional install and build steps stay - // unconditional even though nothing in the filter names a workspace. - assert.match(workflow, /\n {6}- name: Install dependencies\n {8}run: npm\.cmd ci\n/u); - assert.match(workflow, /\n {8}run: npm\.cmd run build:test\n/u); -}); - -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 filter is exactly the Windows-branching closure of its tests', async () => { - const workflow = readWorkflow('windows-recovery.yml'); - const filtered = pullRequestPathFilter('windows-recovery.yml').filter((path) => - path.startsWith('packages/'), - ); - - // Derived from the dist tests the steps actually execute, mapped back to - // source. A suite added to a step joins this set without anyone remembering - // to widen the filter. - const entrypoints = [ - ...new Set( - [...workflow.matchAll(/packages\/([\w-]+)\/dist\/__tests__\/([\w.-]+)\.test\.js/gu)].map( - ([, workspace, name]) => `packages/${workspace}/src/__tests__/${name}.test.ts`, - ), - ), - ].sort(); - assert.ok(entrypoints.length > 0, 'no executed suite was recognised'); - for (const entrypoint of entrypoints) { - assert.ok(existsSync(new URL(`../${entrypoint}`, import.meta.url)), entrypoint); - } - - // Set equality, not containment, and in both directions on purpose. A subset - // check cannot see an omission, which is how `stable-storage.ts` — reached - // through the two listed lock authorities — sat outside a filter that was - // supposed to cover exactly it. A superset check cannot see a file that - // stopped branching and now schedules a Windows runner for nothing. - const closure = await collectWorkspaceSourceClosure( - entrypoints, - fileURLToPath(new URL('..', import.meta.url)), - ); - const windowsBranching = closure - .filter((path) => - readFileSync(new URL(`../${path}`, import.meta.url), 'utf8').includes('win32'), - ) - .sort(); - - assert.deepEqual(filtered.sort(), windowsBranching); -}); - -test('the recovery lane filters pull requests by what only Windows can prove', () => { - const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); - - // What is left after the workspace sources came out: how `npm.cmd ci` resolves - // and what it produces on Windows, what `npm.cmd run build:test` cleans up - // there, and a PowerShell script this lane is the only caller of. Each of - // these can be green on Linux and red here, which is the whole test for - // membership in this list. - for (const path of [ - 'package-lock.json', - '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', - '.github/workflows/windows-recovery.yml', - ]) { - assert.ok(filtered.has(path), path); - } -}); - -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('the packaged Windows gate owns Runtime Host candidate election changes', () => { - const workflow = readWorkflow('release-windows-check.yml'); - - assert.match(workflow, /'packages\/runtime-host\/src\/client\/connect-or-spawn\.ts'/u); - assert.match(workflow, /'packages\/runtime-host\/src\/client\/launcher\.ts'/u); -}); - -test('the packaged Windows gate triggers on release orchestration changes', () => { - const workflow = readWorkflow('release-windows-check.yml'); - - assert.match(workflow, /'\.github\/workflows\/release\.yml'/u); -}); - -test('the packaged Windows gate workflow is itself a release-contract input', () => { - assert.equal( - planTests(['.github/workflows/release-windows-check.yml'], { graph }).releaseContract, - true, - ); - assert.match( - readWorkflow('release-windows-check.yml'), - /'\.github\/workflows\/release-windows-check\.yml'/u, - ); -}); - -test('the packaged Windows gate triggers on packaged sandbox inputs', () => { - const workflow = readWorkflow('release-windows-check.yml'); - - for (const path of [ - 'apps/desktop/scripts/copy-runtime-filesystem-worker.mjs', - 'packages/runtime/scripts/build-filesystem-worker.mjs', - 'packages/runtime/src/filesystem-worker/**', - 'packages/runtime/src/sandbox/**', - 'packages/runtime/src/path-containment.ts', - 'packages/runtime/src/sandbox-boundary-path.ts', - 'packages/core/src/permission-profile.ts', - 'packages/core/src/permission-profile-compiler.ts', - ]) { - assert.ok(workflow.includes(` - '${path}'`), path); - } -}); - -test('pull-request and release lanes share the packaged sandbox lifecycle verifier', () => { - for (const name of ['release-windows-check.yml', 'release.yml']) { - assert.match(readWorkflow(name), /npm run verify:windows-x64/u, name); - } - - const verifier = readFileSync(new URL('verify-windows-x64.mjs', import.meta.url), 'utf8'); - assert.match( - verifier, - /await verifyPackagedWindowsSandboxLifecycle\(sandboxExecutable, \{ run \}\)/u, - ); -}); - -test('the Gitoxide gate owns repository admission changes', () => { - const workflow = readWorkflow('gitoxide-helper-admission.yml'); - - assert.match( - workflow, - /'packages\/runtime-host\/src\/server\/gitoxide-repository-admission-authority-internal\.ts'/u, - ); - assert.match( - workflow, - /'packages\/runtime-host\/src\/__tests__\/gitoxide-repository-admission-authority-internal\.test\.ts'/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.match(workflow, /\n workflow_dispatch:/u); - } - assert.match(cli, /\n workflow_call:/u); - assert.match(baseline, /\n schedule:/u); -}); - -test('Windows recovery executes the exact managed dependency ADS regressions', () => { - const recovery = readWorkflow('windows-recovery.yml'); - - assert.match(recovery, /name: Verify managed dependency alternate streams/u); - assert.match(recovery, /--test-name-pattern="NTFS alternate stream"/u); - assert.match( - recovery, - /packages\/storage\/dist\/__tests__\/managed-dependency-environment\.test\.js/u, - ); - assert.match(recovery, /# tests 3/u); - assert.match(recovery, /# pass 3/u); - assert.match(recovery, /# skipped 0/u); -}); - -test('Windows recovery executes the root initialization replacement race', () => { - const recovery = readWorkflow('windows-recovery.yml'); - - assert.match(recovery, /name: Verify root initialization replacement race/u); - assert.match( - recovery, - /--test-name-pattern="rejects replacement before opening the temporary marker"/u, - ); - assert.match(recovery, /packages\/storage\/dist\/__tests__\/root-authority\.test\.js/u); - assert.match(recovery, /# tests 1/u); - assert.match(recovery, /# pass 1/u); - assert.match(recovery, /# skipped 0/u); -}); - -test('Windows recovery executes the complete Skill catalog suite', () => { - const recovery = readWorkflow('windows-recovery.yml'); - - assert.match(recovery, /skill-catalog-coordinator\.test\.js/u); - assert.match(recovery, /skill-catalog-protocol\.test\.js/u); - assert.match(recovery, /skill-catalog-repository\.test\.js/u); - assert.match(recovery, /skill-catalog-transaction\.test\.js/u); - assert.match(recovery, /skill-catalog-two-client-uds\.test\.js/u); - assert.match(recovery, /# tests 91/u); - assert.match(recovery, /# pass 91/u); - assert.match(recovery, /# skipped 0/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()}`); - } - } -}); - -test('a pull_request_target checkout is pinned to the trusted base commit', () => { - // This event hands the job a writable token while the pull request is fork - // controlled, so what gets checked out is what decides whether that token can - // reach author-supplied code. `github.sha` is the base branch commit here; - // `head.sha` and a bare checkout under a merge-ref event are both the pull - // request's own tree. Nothing else in CI would notice that edit, which is why - // the rule lives here rather than in a comment. - for (const name of readdirSync(WORKFLOW_DIR)) { - if (!/\bpull_request_target\b/u.test(triggerBlock(name))) continue; - - for (const step of checkoutSteps(name)) { - assert.match(step, /\n\s+ref: \$\{\{ github\.sha \}\}\n/u, `${name}: ${step.trim()}`); - } - } -}); - -test('core CI runs the live Eval proxy lifecycle when Eval is selected', () => { - const workflow = readWorkflow('ci.yml'); - const evalPackage = JSON.parse( - readFileSync(new URL('../packages/eval/package.json', import.meta.url), 'utf8'), - ); - - assert.match( - workflow, - /if: contains\(steps\.plan\.outputs\.standard_workspaces, 'packages\/eval'\)/u, - ); - assert.match(workflow, /MAKA_EVAL_EGRESS_PROXY_TEST: '1'/u); - assert.match(workflow, /docker build[\s\S]*maka-eval-egress-proxy:12\.2\.3/u); - assert.match(workflow, /npm --workspace @maka\/eval run test:egress-proxy:live/u); - assert.equal( - evalPackage.scripts['test:egress-proxy:live'], - 'python3 harbor/test_egress_filter_live.py', - ); - assert.doesNotMatch(evalPackage.scripts['test:dist'], /test_egress_filter_live\.py/u); -}); - -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'); -} - -/** - * 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 triggerBlock(name) { - const withoutComments = readWorkflow(name).replaceAll(/^[ \t]*#.*$/gmu, ''); - - return withoutComments.match(/^on:(.*(?:\n(?![^\s#]).*)*)/mu)?.[1] ?? ''; -} - -function hasPullRequestTrigger(name) { - const block = triggerBlock(name); - if (!/\bpull_request(_target)?\b/u.test(block)) return false; - - // Event-only maintenance workflows may listen for a lifecycle action without - // becoming a normal pull-request validation lane. They do not belong in the - // scarce-runner allowlist or its timeout tier. - return !/pull_request(?:_target)?:\s*\n\s+types:\s*\[\s*reopened\s*\]/u.test(block); -} - -function hasPullRequestGate(name) { - const block = triggerBlock(name); - return /^\s*pull_request:\s*$/mu.test(block) && hasPullRequestTrigger(name); -} - -/** - * 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) ?? - [] - ); -} diff --git a/scripts/ci-workflow-policy.test.mjs b/scripts/ci-workflow-policy.test.mjs new file mode 100644 index 0000000000..452be63866 --- /dev/null +++ b/scripts/ci-workflow-policy.test.mjs @@ -0,0 +1,776 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * What the workflows themselves must say: which triggers a lane carries, which + * steps a selection gates, and where a lane's path filter has to come from. + * Behaviour of the planner these assertions refer to lives in + * `ci-test-plan.test.mjs`. + * + * This suite runs before `npm ci` installs anything, so it may import only + * `node:` builtins and repository modules that do the same. The last test in + * this file asserts exactly that, for every suite the install-free steps run. + */ +import assert from 'node:assert/strict'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { formatGitHubOutputs, loadWorkspaceGraph, planTests } from './ci-test-plan.mjs'; + +test('GitHub output matches the selections consumed by CI', () => { + const output = formatGitHubOutputs(planTests([], { forceFull: true })); + const outputKeys = new Set(output.split('\n').map((line) => line.split('=', 1)[0])); + const workflow = readWorkflow('ci.yml'); + + // The planner writes one step's outputs and every later step gates on them, + // so what it emits and what CI reads are the same set. A key nothing reads, + // or a gate on a key the planner never writes, is a dead lane either way. + const consumedKeys = new Set( + [...workflow.matchAll(/steps\.plan\.outputs\.([a-z0-9_]+)/gu)].map((match) => match[1]), + ); + + assert.deepEqual(outputKeys, consumedKeys); +}); + +test('one unconditional job carries the required context on every pull request', () => { + const workflow = readWorkflow('ci.yml'); + + // `.asf.yaml` requires `test`. A paths filter would stop the workflow and + // leave that check pending forever, and a second job would make the same + // pull request queue for a scarce runner twice to reach one verdict. + assert.doesNotMatch(triggerBlock('ci.yml'), /\bpaths(-ignore)?:/u); + + const jobsBlock = workflow.slice(workflow.indexOf('\njobs:')); + const jobs = [...jobsBlock.matchAll(/^ {2}([a-z0-9_-]+):$/gmu)].map((match) => match[1]); + assert.deepEqual(jobs, ['test']); + assert.doesNotMatch(jobsBlock, /^ {4}needs:/mu); + assert.doesNotMatch(jobsBlock, /^ {4}if:/mu); +}); + +test('planning runs first and every later step gates on its outputs', () => { + const workflow = readWorkflow('ci.yml'); + + // With the job split gone there is no `needs` context to read. GitHub + // resolves a leftover `needs.plan.outputs.x` to the empty string rather + // than failing, so the step it guards would silently never run again. + assert.doesNotMatch(workflow, /needs\.plan\.outputs/u); + + const planStep = workflow.indexOf(' - id: plan\n'); + assert.ok(planStep >= 0, 'no planning step'); + assert.ok(planStep < workflow.indexOf('steps.plan.outputs')); + assert.match( + workflow, + /- name: Check renderer architecture\n\s+if: steps\.plan\.outputs\.code == 'true'/u, + ); +}); + +test('core CI validates pull requests and the resulting main branch state', () => { + const workflow = readWorkflow('ci.yml'); + + assert.match(workflow, /pull_request:\n\s+branches: \[main\]/u); + assert.match(workflow, /push:\n\s+branches: \[main\]/u); + assert.match( + workflow, + /BASE_SHA: \$\{\{ github\.event_name == 'push' && github\.event\.before \|\| github\.event\.pull_request\.base\.sha \}\}/u, + ); + assert.match( + workflow, + /HEAD_SHA: \$\{\{ github\.event_name == 'push' && github\.sha \|\| github\.event\.pull_request\.head\.sha \}\}/u, + ); + assert.match(workflow, /\[\[ "\$BASE_SHA" =~ \^0\+\$ \]\]/u); +}); + +test('core CI uses the Windows inventory package-script authority', () => { + const workflow = readWorkflow('ci.yml'); + + assert.match(workflow, /run: npm run windows:inventory/u); + assert.doesNotMatch(workflow, /run: node scripts\/windows-test-inventory\.mjs --check/u); +}); + +test('contract checks run before dependency setup and can fail the job', () => { + const workflow = readWorkflow('ci.yml'); + const setupNodeStart = workflow.indexOf(' - uses: actions/setup-node@'); + + // These 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', + 'Verify ASF npm preflight policy', + ]) { + 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('every selection that gates an installed step is one heavy validation covers', () => { + const workflow = readWorkflow('ci.yml'); + + // `heavy` decides whether the toolchain is installed at all, so any later + // step gated on a selection outside that disjunction would run against a + // runner with no dependencies — or, if the step is a `run:` that tolerates + // it, report green having done nothing. Derived from the workflow rather + // than restated, so a new plan output cannot gate a step without joining + // the disjunction first. + const [, installed] = workflow.split(/\n\s+- uses: actions\/setup-node[^\n]*\n/u); + assert.ok(installed, 'ci.yml no longer installs a toolchain'); + + const gating = [ + ...new Set([...installed.matchAll(/steps\.plan\.outputs\.(\w+) ==/gu)].map(([, name]) => name)), + ].sort(); + assert.ok(gating.length > 0, 'no installed step is gated on a selection'); + + // The planner names selections in camelCase and publishes them in snake_case. + const source = readFileSync(new URL('./ci-test-plan.mjs', import.meta.url), 'utf8'); + const disjunction = source.match(/export function requiresHeavyValidation[\s\S]*?\n\}/u)?.[0]; + assert.ok(disjunction, 'requiresHeavyValidation is no longer a single expression'); + + for (const output of gating) { + if (output === 'heavy') continue; + const camel = output.replace(/_(\w)/gu, (_, letter) => letter.toUpperCase()); + assert.match( + disjunction, + new RegExp(`plan\\.${camel}\\b`, 'u'), + `${output} gates an installed step but does not select the install`, + ); + } +}); + +test('the app icon gate selects every file its own tests open', () => { + // Derived from the step, not from a memory of it: whatever `App icon artwork + // drift` runs is the authority on what has to select it. The list this + // replaces was assembled by reading those tests, which is how the packaging + // config — opened by path rather than imported — stayed off it while the + // drift assertion it feeds could be skipped silently. + const step = readWorkflow('ci.yml').match( + /if: steps\.plan\.outputs\.app_icons == 'true'\n\s+run: node --test ([^\n]+)/u, + ); + assert.ok(step, 'no step is gated on the app icon selection'); + const suites = step[1].trim().split(/\s+/u); + assert.ok(suites.length > 0); + + for (const suite of suites) { + assert.equal(planTests([suite]).appIcons, true, suite); + const source = readFileSync(new URL(`../${suite}`, import.meta.url), 'utf8'); + + // Every repository path those suites name, however they reach it: a + // `new URL` read, a relative import, or a directory of artwork. + const named = [ + ...[...source.matchAll(/new URL\('([^']+)'/gu)].map((match) => match[1]), + ...[...source.matchAll(/from '(\.[^']+)'/gu)].map((match) => match[1]), + ].map((path) => new URL(path, new URL(`../${suite}`, import.meta.url)).pathname); + + const root = new URL('..', import.meta.url).pathname; + for (const absolute of named) { + // A trailing slash names a directory, so probe inside it as well: the + // artwork is selected by prefix rather than file by file. + const path = absolute.slice(root.length).replace(/\/$/u, ''); + const selecting = [path, `${path}/probe`].find( + (candidate) => planTests([candidate]).appIcons, + ); + assert.ok(selecting, `${suite} reads ${path}`); + + // The step runs after Build and `generate-app-icons.test.mjs` imports + // `APP_ICONS` from built `@maka/core`, so an input that selected the + // drift check without selecting the build would run against no build. + assert.equal(planTests([selecting]).code, true, `${selecting} skips the build`); + } + } +}); + +test('core CI gates app icon drift on the artwork surface', () => { + assert.match( + readWorkflow('ci.yml'), + /- name: App icon artwork drift\n\s+if: steps\.plan\.outputs\.app_icons == 'true'/u, + ); +}); + +test('core CI checks the Astryx inventory for every code change before building', () => { + const workflow = readWorkflow('ci.yml'); + const inventoryStart = workflow.indexOf(' - name: Astryx surface inventory\n'); + const inventoryEnd = workflow.indexOf('\n - ', inventoryStart + 1); + const buildStart = workflow.indexOf(' - name: Build\n'); + + assert.ok(inventoryStart >= 0); + assert.ok(inventoryStart < buildStart); + + const inventoryStep = workflow.slice(inventoryStart, inventoryEnd); + assert.match( + inventoryStep, + /if: steps\.plan\.outputs\.code == 'true' \|\| steps\.plan\.outputs\.astryx_surface == 'true'/u, + ); + assert.doesNotMatch(inventoryStep, /continue-on-error/u); +}); + +test('CI installs dependencies whenever the Astryx surface inventory runs', () => { + const workflow = readWorkflow('ci.yml'); + // The inventory step imports the generator, which resolves @astryxdesign/core + // and parses the @maka/ui barrel. An inventory-doc-only PR is `astryx_surface` + // without `code`, so the `npm ci` step must gate on astryx_surface too — else + // the generator runs with no dependencies installed and fails closed. + const npmCi = workflow.indexOf('run: npm ci'); + assert.ok(npmCi >= 0, 'expected an `npm ci` install step'); + const stepStart = workflow.lastIndexOf('\n - name:', npmCi) + 1; + const stepEnd = workflow.indexOf('\n - ', npmCi); + const installStep = workflow.slice(stepStart, stepEnd); + assert.match(installStep, /steps\.plan\.outputs\.astryx_surface == 'true'/u); +}); + +test('core CI validates affected installed CLI packages on the heavy runner', () => { + const workflow = readWorkflow('ci.yml'); + const toolchain = workflow.indexOf( + 'npm install --global --no-audit --no-fund "$(node -p \'require("./package.json").packageManager\')"', + ); + const pack = workflow.indexOf('run: npm run release:cli:pack'); + + assert.match(workflow, /if: steps\.plan\.outputs\.cli_package == 'true'/u); + assert.ok(toolchain >= 0); + assert.ok(toolchain < pack); + assert.match(workflow, /run: npm run release:cli:smoke/u); +}); + +test('Rust build caches publish immutable source generations only from the default branch', () => { + const workflows = readdirSync(WORKFLOW_DIR) + .filter((name) => name.endsWith('.yml')) + .map((name) => [name, readWorkflow(name)]) + .filter(([, workflow]) => workflow.includes('tool: kache@0.16.0')); + + assert.equal(workflows.length, 5); + for (const [name, workflow] of workflows) { + assert.match(workflow, /echo "revision=\$\(git rev-parse HEAD\)"/u, name); + const primaryKeys = [...workflow.matchAll(/^\s+key: (kache-[^\n]+)$/gmu)].map(([, key]) => key); + assert.ok(primaryKeys.length > 0, name); + const restoreKeys = [...workflow.matchAll(/^\s+(kache-[^\n]+-)$/gmu)].map(([, key]) => key); + assert.equal(restoreKeys.length, primaryKeys.length, name); + primaryKeys.forEach((key) => { + assert.match(key, /\$\{\{ steps\.[^.]+\.outputs\.revision \}\}$/u, name); + assert.ok( + restoreKeys.includes(key.replace(/\$\{\{ steps\.[^.]+\.outputs\.revision \}\}$/u, '')), + name, + ); + }); + assert.match( + workflow, + /name: Save [^\n]*Rust build cache\n\s+if: [^\n]*github\.event\.repository\.default_branch/u, + name, + ); + assert.doesNotMatch(workflow, /kache report [^\n]*--since/u, name); + } +}); + +test('release contracts run against built CLI outputs', () => { + const workflow = readWorkflow('ci.yml'); + const buildIndex = workflow.indexOf(' - name: Build\n'); + const buildEnd = workflow.indexOf('\n - ', buildIndex + 1); + const releaseIndex = workflow.indexOf(' - name: Release contracts\n'); + + assert.ok(buildIndex >= 0); + assert.match(workflow.slice(buildIndex, buildEnd), /release_contract == 'true'/u); + assert.ok(buildIndex < releaseIndex); + assert.match( + workflow.slice(releaseIndex), + /if: steps\.plan\.outputs\.release_contract == 'true'/u, + ); +}); + +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', + 'cli-package-validation.yml', + 'copilot-auto-review.yml', + 'dependency-audit.yml', + 'gitoxide-helper-admission.yml', + 'pr-effort-label.yml', + 'release-windows-check.yml', + 'runtime-host-owner-platform.yml', + 'runtime-host-peer-admission.yml', + 'windows-recovery.yml', + 'windows-sandbox-w0.yml', + ]); +}); + +test('every pull request lane holds a scarce runner for the same bounded time', () => { + // One tier, not per-lane values. The worst observed successful runs are 19 + // minutes (ci.yml) and 20 (release-windows-check), so 45 is about 2.3x the + // slowest lane: enough headroom for a cold cache and a flake retry, and far + // short of the 120 and 90 a hung job used to hold. A lane with no limit at + // all inherits GitHub's 360 and fails here. + // `pull_request` only: a `pull_request_target` lane reads the pull request + // rather than gating it, so it is not competing for a runner the author is + // waiting on and keeps its own tighter limit. + // Granularity is the file, not the job: a job inside a gating workflow that + // opts out of pull requests still carries the tier, because reading a job's + // `if:` would need the YAML parser this file cannot install. + const gates = readdirSync(WORKFLOW_DIR).filter(hasPullRequestGate); + assert.ok(gates.length > 0, 'no pull request lane found'); + + for (const name of gates) { + // From `jobs:` on, with comment lines stripped, so prose above the triggers + // cannot be read as a job. + const workflow = readWorkflow(name).replaceAll(/^[ \t]*#.*$/gmu, ''); + const start = workflow.indexOf('\njobs:'); + assert.ok(start >= 0, `${name}: no jobs block`); + const jobs = workflow.slice(start); + + const limits = [...jobs.matchAll(/^ {4}timeout-minutes: (\d+)$/gmu)].map((match) => match[1]); + // Counted by `runs-on`, one per job that consumes a runner, rather than by + // job id: a quoted id escapes an id pattern, and a two-space line inside a + // `run: |` block satisfies one. + const runners = [...jobs.matchAll(/^ {4}runs-on:/gmu)].length; + assert.ok(runners > 0, `${name}: no job consumes a runner`); + assert.deepEqual( + limits, + Array.from({ length: runners }, () => '45'), + name, + ); + } +}); + +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 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); + 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 leaves the suites it executes to the required test lane', () => { + 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. + const executed = [ + ...new Set( + [...workflow.matchAll(/packages[/\\]([^/\\]+)[/\\]dist[/\\]/gu)].map((match) => match[1]), + ), + ].sort(); + assert.deepEqual(executed, ['runtime', 'runtime-host', 'storage']); + + // None of that closure belongs in the filter. These suites are ordinary + // TypeScript, so `test` runs them on Linux on every pull request and fails + // first; naming their sources here only bought a second, slower red. Listing + // one again would put this lane back on most merges, so it fails here. + const closure = dependencyClosure(executed.map((workspace) => `packages/${workspace}`)); + assert.ok(closure.includes('packages/core'), 'dependency closure must reach core'); + for (const dir of closure) { + for (const entry of [`${dir}/src/**`, `${dir}/tsconfig.json`, `${dir}/package.json`]) { + assert.ok(!filtered.has(entry), `${entry} belongs to the required test lane`); + } + } + + // What the Windows runner proves instead is that the suites still build and + // run here at all, which is why the unconditional install and build steps stay + // unconditional even though nothing in the filter names a workspace. + assert.match(workflow, /\n {6}- name: Install dependencies\n {8}run: npm\.cmd ci\n/u); + assert.match(workflow, /\n {8}run: npm\.cmd run build:test\n/u); +}); + +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 only Windows can prove', () => { + const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); + + // What is left after the workspace sources came out: how `npm.cmd ci` resolves + // and what it produces on Windows, what `npm.cmd run build:test` cleans up + // there, and a PowerShell script this lane is the only caller of. Each of + // these can be green on Linux and red here, which is the whole test for + // membership in this list. + for (const path of [ + 'package-lock.json', + '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', + '.github/workflows/windows-recovery.yml', + ]) { + assert.ok(filtered.has(path), path); + } +}); + +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('the packaged Windows gate owns Runtime Host candidate election changes', () => { + const workflow = readWorkflow('release-windows-check.yml'); + + assert.match(workflow, /'packages\/runtime-host\/src\/client\/connect-or-spawn\.ts'/u); + assert.match(workflow, /'packages\/runtime-host\/src\/client\/launcher\.ts'/u); +}); + +test('the packaged Windows gate triggers on release orchestration changes', () => { + const workflow = readWorkflow('release-windows-check.yml'); + + assert.match(workflow, /'\.github\/workflows\/release\.yml'/u); +}); + +test('the packaged Windows gate workflow is itself a release-contract input', () => { + assert.equal(planTests(['.github/workflows/release-windows-check.yml']).releaseContract, true); + assert.match( + readWorkflow('release-windows-check.yml'), + /'\.github\/workflows\/release-windows-check\.yml'/u, + ); +}); + +test('the packaged Windows gate triggers on packaged sandbox inputs', () => { + const workflow = readWorkflow('release-windows-check.yml'); + + for (const path of [ + 'apps/desktop/scripts/copy-runtime-filesystem-worker.mjs', + 'packages/runtime/scripts/build-filesystem-worker.mjs', + 'packages/runtime/src/filesystem-worker/**', + 'packages/runtime/src/sandbox/**', + 'packages/runtime/src/path-containment.ts', + 'packages/runtime/src/sandbox-boundary-path.ts', + 'packages/core/src/permission-profile.ts', + 'packages/core/src/permission-profile-compiler.ts', + ]) { + assert.ok(workflow.includes(` - '${path}'`), path); + } +}); + +test('pull-request and release lanes share the packaged sandbox lifecycle verifier', () => { + for (const name of ['release-windows-check.yml', 'release.yml']) { + assert.match(readWorkflow(name), /npm run verify:windows-x64/u, name); + } + + const verifier = readFileSync(new URL('verify-windows-x64.mjs', import.meta.url), 'utf8'); + assert.match( + verifier, + /await verifyPackagedWindowsSandboxLifecycle\(sandboxExecutable, \{ run \}\)/u, + ); +}); + +test('the Gitoxide gate owns repository admission changes', () => { + const workflow = readWorkflow('gitoxide-helper-admission.yml'); + + assert.match( + workflow, + /'packages\/runtime-host\/src\/server\/gitoxide-repository-admission-authority-internal\.ts'/u, + ); + assert.match( + workflow, + /'packages\/runtime-host\/src\/__tests__\/gitoxide-repository-admission-authority-internal\.test\.ts'/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.match(workflow, /\n workflow_dispatch:/u); + } + assert.match(cli, /\n workflow_call:/u); + assert.match(baseline, /\n schedule:/u); +}); + +test('Windows recovery executes the exact managed dependency ADS regressions', () => { + const recovery = readWorkflow('windows-recovery.yml'); + + assert.match(recovery, /name: Verify managed dependency alternate streams/u); + assert.match(recovery, /--test-name-pattern="NTFS alternate stream"/u); + assert.match( + recovery, + /packages\/storage\/dist\/__tests__\/managed-dependency-environment\.test\.js/u, + ); + assert.match(recovery, /# tests 3/u); + assert.match(recovery, /# pass 3/u); + assert.match(recovery, /# skipped 0/u); +}); + +test('Windows recovery executes the root initialization replacement race', () => { + const recovery = readWorkflow('windows-recovery.yml'); + + assert.match(recovery, /name: Verify root initialization replacement race/u); + assert.match( + recovery, + /--test-name-pattern="rejects replacement before opening the temporary marker"/u, + ); + assert.match(recovery, /packages\/storage\/dist\/__tests__\/root-authority\.test\.js/u); + assert.match(recovery, /# tests 1/u); + assert.match(recovery, /# pass 1/u); + assert.match(recovery, /# skipped 0/u); +}); + +test('Windows recovery executes the complete Skill catalog suite', () => { + const recovery = readWorkflow('windows-recovery.yml'); + + assert.match(recovery, /skill-catalog-coordinator\.test\.js/u); + assert.match(recovery, /skill-catalog-protocol\.test\.js/u); + assert.match(recovery, /skill-catalog-repository\.test\.js/u); + assert.match(recovery, /skill-catalog-transaction\.test\.js/u); + assert.match(recovery, /skill-catalog-two-client-uds\.test\.js/u); + assert.match(recovery, /# tests 91/u); + assert.match(recovery, /# pass 91/u); + assert.match(recovery, /# skipped 0/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()}`); + } + } +}); + +test('a pull_request_target checkout is pinned to the trusted base commit', () => { + // This event hands the job a writable token while the pull request is fork + // controlled, so what gets checked out is what decides whether that token can + // reach author-supplied code. `github.sha` is the base branch commit here; + // `head.sha` and a bare checkout under a merge-ref event are both the pull + // request's own tree. Nothing else in CI would notice that edit, which is why + // the rule lives here rather than in a comment. + for (const name of readdirSync(WORKFLOW_DIR)) { + if (!/\bpull_request_target\b/u.test(triggerBlock(name))) continue; + + for (const step of checkoutSteps(name)) { + assert.match(step, /\n\s+ref: \$\{\{ github\.sha \}\}\n/u, `${name}: ${step.trim()}`); + } + } +}); + +test('core CI runs the live Eval proxy lifecycle when Eval is selected', () => { + const workflow = readWorkflow('ci.yml'); + const evalPackage = JSON.parse( + readFileSync(new URL('../packages/eval/package.json', import.meta.url), 'utf8'), + ); + + assert.match( + workflow, + /if: contains\(steps\.plan\.outputs\.standard_workspaces, 'packages\/eval'\)/u, + ); + assert.match(workflow, /MAKA_EVAL_EGRESS_PROXY_TEST: '1'/u); + assert.match(workflow, /docker build[\s\S]*maka-eval-egress-proxy:12\.2\.3/u); + assert.match(workflow, /npm --workspace @maka\/eval run test:egress-proxy:live/u); + assert.equal( + evalPackage.scripts['test:egress-proxy:live'], + 'python3 harbor/test_egress_filter_live.py', + ); + assert.doesNotMatch(evalPackage.scripts['test:dist'], /test_egress_filter_live\.py/u); +}); + +test('every suite that runs before dependency setup imports only node builtins', () => { + // The steps above `setup-node` run against a bare checkout, so a suite there + // that imports a devDependency throws `ERR_MODULE_NOT_FOUND` and turns the + // one required context red on every pull request — no merge, anywhere, until + // someone notices. Nothing about the file says so, which is how a closure + // assertion needing esbuild was very nearly added to one of them. + // + // Derived from the workflow: whichever suites those steps name are the ones + // that carry the constraint, so moving a step below the install lifts it and + // adding a step above imposes it, without anyone editing this test. + const workflow = readWorkflow('ci.yml'); + const [installFree] = workflow.split(/\n\s+- uses: actions\/setup-node[^\n]*\n/u); + const suites = [ + ...new Set( + [...installFree.matchAll(/(scripts\/[\w.-]+\.test\.mjs)/gu)].map(([, path]) => path), + ), + ].sort(); + assert.ok(suites.length > 0, 'no suite runs before dependency setup'); + + // The constraint is transitive: a local module may be imported only if it too + // stays inside `node:`. + const pending = [...suites]; + const seen = new Set(pending); + while (pending.length > 0) { + const path = pending.shift(); + const source = readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); + for (const [, specifier] of source.matchAll(/^import[^'"]*['"]([^'"]+)['"]/gmu)) { + if (specifier.startsWith('node:')) continue; + assert.ok( + specifier.startsWith('.'), + `${path} imports ${specifier}, which is not installed when it runs`, + ); + const resolved = new URL(specifier, new URL(`../${path}`, import.meta.url)).pathname.slice( + new URL('..', import.meta.url).pathname.length, + ); + if (seen.has(resolved)) continue; + seen.add(resolved); + pending.push(resolved); + } + } +}); + +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'); +} + +/** + * 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 triggerBlock(name) { + const withoutComments = readWorkflow(name).replaceAll(/^[ \t]*#.*$/gmu, ''); + + return withoutComments.match(/^on:(.*(?:\n(?![^\s#]).*)*)/mu)?.[1] ?? ''; +} + +function hasPullRequestTrigger(name) { + const block = triggerBlock(name); + if (!/\bpull_request(_target)?\b/u.test(block)) return false; + + // Event-only maintenance workflows may listen for a lifecycle action without + // becoming a normal pull-request validation lane. They do not belong in the + // scarce-runner allowlist or its timeout tier. + return !/pull_request(?:_target)?:\s*\n\s+types:\s*\[\s*reopened\s*\]/u.test(block); +} + +function hasPullRequestGate(name) { + const block = triggerBlock(name); + return /^\s*pull_request:\s*$/mu.test(block) && hasPullRequestTrigger(name); +} + +/** + * 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) ?? + [] + ); +} diff --git a/scripts/windows-package-source-closure.mjs b/scripts/windows-package-source-closure.mjs index 68f7f928b3..f006863e85 100644 --- a/scripts/windows-package-source-closure.mjs +++ b/scripts/windows-package-source-closure.mjs @@ -65,11 +65,15 @@ export async function collectWorkspaceSourceClosure(entryPoints, repoRoot = defa } export function readWindowsReleasePathPatterns(repoRoot = defaultRepoRoot) { - const workflowPath = join(repoRoot, '.github', 'workflows', 'release-windows-check.yml'); + return readPullRequestPathPatterns('release-windows-check.yml', repoRoot); +} + +export function readPullRequestPathPatterns(workflowName, repoRoot = defaultRepoRoot) { + const workflowPath = join(repoRoot, '.github', 'workflows', workflowName); const workflow = parseYaml(readFileSync(workflowPath, 'utf8')); const paths = workflow?.on?.pull_request?.paths; if (!Array.isArray(paths) || !paths.every((path) => typeof path === 'string')) { - throw new Error('Release Windows check must declare pull_request.paths as strings.'); + throw new Error(`${workflowName} must declare pull_request.paths as strings.`); } return paths; } diff --git a/scripts/windows-package-source-closure.test.mjs b/scripts/windows-package-source-closure.test.mjs index 9c39cb28fa..a5a1c01cc5 100644 --- a/scripts/windows-package-source-closure.test.mjs +++ b/scripts/windows-package-source-closure.test.mjs @@ -18,9 +18,12 @@ */ import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; import { collectWindowsPackageSourceClosure, + collectWorkspaceSourceClosure, + readPullRequestPathPatterns, readWindowsReleasePathPatterns, windowsReleasePatternCoversSource, } from './windows-package-source-closure.mjs'; @@ -51,3 +54,49 @@ test('the Windows package workflow path list has no duplicate entries', () => { const patterns = readWindowsReleasePathPatterns(); assert.equal(new Set(patterns).size, patterns.length); }); + +/** + * This belongs beside the other closure contract rather than with the planner + * tests, because computing a closure needs esbuild and the planner tests run + * before `npm ci` installs it. `.github/workflows/windows-recovery.yml` is in + * `RELEASE_CONTRACT_FILES` so that editing the filter selects the gate that + * checks it. + */ +test('the recovery filter is exactly the Windows-branching closure of its tests', async () => { + const workflow = readFileSync( + new URL('../.github/workflows/windows-recovery.yml', import.meta.url), + 'utf8', + ); + const filtered = readPullRequestPathPatterns('windows-recovery.yml') + .filter((path) => path.startsWith('packages/')) + .sort(); + + // Derived from the dist tests the steps actually execute, mapped back to + // source. A suite added to a step joins this set without anyone remembering + // to widen the filter. + const entrypoints = [ + ...new Set( + [...workflow.matchAll(/packages\/([\w-]+)\/dist\/__tests__\/([\w.-]+)\.test\.js/gu)].map( + ([, workspace, name]) => `packages/${workspace}/src/__tests__/${name}.test.ts`, + ), + ), + ].sort(); + assert.ok(entrypoints.length > 0, 'no executed suite was recognised'); + for (const entrypoint of entrypoints) { + assert.ok(existsSync(new URL(`../${entrypoint}`, import.meta.url)), entrypoint); + } + + // Set equality, not containment, and in both directions on purpose. A subset + // check cannot see an omission, which is how `stable-storage.ts` — reached + // through the two listed lock authorities — sat outside a filter that was + // supposed to cover exactly it. A superset check cannot see a file that + // stopped branching and now schedules a Windows runner for nothing. + const closure = await collectWorkspaceSourceClosure(entrypoints); + const windowsBranching = closure + .filter((path) => + readFileSync(new URL(`../${path}`, import.meta.url), 'utf8').includes('win32'), + ) + .sort(); + + assert.deepEqual(filtered, windowsBranching); +}); From 74be33e3b43e41c308fc84e94434fcc3f4ccfc1e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 21:02:16 +0800 Subject: [PATCH 11/13] test(ci): assert both directions of every narrowed filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two filters in this branch were proved only in the easy direction, which is the direction that cannot see an omission. `release-windows-check.yml` asserted that its import closure is covered by the filter. Nothing asserted the converse, so a `packages/` entry backed by nothing stayed in the list forever and booked a 25-minute Windows runner every time it matched, with no test able to report it. Five of its twenty five entries turn out to be underived — peer dependency manifests, Runtime Host candidate election, the script that builds the worker the closure starts from. Each is legitimate, and each is now declared with its reason, so the set is exact in both directions and a stale exception fails as loudly as a missing entry. Three hand-written tests asserted that a lane pairs its path filter with a nightly run, one lane each. The rule they encode is not lane-specific: a filter is a pre-filter, not an import closure, so something has to run the lane without consulting it. Replaced by one test enumerating the workflow directory and requiring a schedule, an unfiltered push, or a `workflow_call` caller. The three pairings covered three lanes; the rule covers eight and would have caught `release-windows-check.yml`, whose schedule this branch added without an assertion to hold it there. That enumeration also surfaces three lanes that have no escape at all: `gitoxide-helper-admission` and `runtime-host-peer-admission` pair the pull request with a `push: main` carrying the same filter, and `runtime-host-owner-platform` pairs it with `workflow_dispatch`, which nothing fires on its own. They predate the gates narrowed here, so they are declared rather than changed — the point is that the gap is now countable instead of invisible. Generated-by: Claude Code --- scripts/ci-workflow-policy.test.mjs | 44 +++++++++++++++---- .../windows-package-source-closure.test.mjs | 33 +++++++++++++- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/scripts/ci-workflow-policy.test.mjs b/scripts/ci-workflow-policy.test.mjs index 452be63866..f0b79c12ca 100644 --- a/scripts/ci-workflow-policy.test.mjs +++ b/scripts/ci-workflow-policy.test.mjs @@ -371,7 +371,6 @@ test('the recovery lane pairs its path filter with a nightly run and a main push 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); }); @@ -468,14 +467,43 @@ test('the recovery lane filters pull requests by what only Windows can prove', ( } }); -test('the sandbox lane pairs its path filter with a nightly run', () => { - const workflow = readWorkflow('windows-sandbox-w0.yml'); +/** + * Filtered lanes with no automatic path that runs them when the filter misses. + * Each one first observes a transitive edit it cannot match wherever that edit + * eventually lands, which for a release lane is release day. They predate the + * gates narrowed here and are listed rather than fixed so the gap is countable. + */ +const LANES_WITHOUT_A_FILTER_ESCAPE = new Set([ + // Both pair the pull request with a `push: main` carrying the same filter, + // which observes the same set and so is not an escape at all. + 'gitoxide-helper-admission.yml', + 'runtime-host-peer-admission.yml', + // Pairs it with `workflow_dispatch`, which nothing fires on its own. + 'runtime-host-owner-platform.yml', +]); + +test('a filtered pull-request lane can still run when its filter misses', () => { + // A path filter is a pre-filter, not the lane's import closure, so an edit it + // cannot match is invisible to it. Something must therefore run the lane + // without consulting the filter: a schedule, an unfiltered push, or a caller + // that reaches it through `workflow_call`. Enumerated over the directory + // rather than asserted lane by lane, because the three hand-written pairings + // this replaces covered three lanes and missed every other one — including + // `release-windows-check.yml`, whose filter was narrowed in this branch. + const uncovered = []; + for (const name of readdirSync(WORKFLOW_DIR).filter((file) => file.endsWith('.yml'))) { + const triggers = triggerBlock(name); + if (!/\n {2}pull_request:\n(?:(?: {4}[^\n]*)?\n)* {4}paths:/u.test(triggers)) continue; + + const push = triggers.match(/\n {2}push:\n(?:(?: {4,}[^\n]*)?\n)*/u)?.[0] ?? ''; + const escapes = + /\n {2}schedule:/u.test(triggers) || + /\n {2}workflow_call:/u.test(triggers) || + (push !== '' && !/\bpaths(-ignore)?:/u.test(push)); + if (!escapes) uncovered.push(name); + } - // 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); + assert.deepEqual(uncovered.sort(), [...LANES_WITHOUT_A_FILTER_ESCAPE].sort()); }); test('the packaged Windows gate owns Runtime Host candidate election changes', () => { diff --git a/scripts/windows-package-source-closure.test.mjs b/scripts/windows-package-source-closure.test.mjs index a5a1c01cc5..262717c7dc 100644 --- a/scripts/windows-package-source-closure.test.mjs +++ b/scripts/windows-package-source-closure.test.mjs @@ -28,7 +28,27 @@ import { windowsReleasePatternCoversSource, } from './windows-package-source-closure.mjs'; -test('the Windows package trigger covers the packaged worker and driver import closure', async () => { +/** + * The `packages/` half of this lane's filter that the import closure does not + * account for. Each entry schedules a 25-minute Windows runner on its own, so + * it has to earn that here rather than sit indistinguishable among the twenty + * derived entries — which is the only way a rotted one is ever noticed. + */ +const UNDERIVED_PACKAGE_PATTERNS = new Map([ + // Regenerated and diffed by `check:release`, and consumed by the packaging + // step rather than imported by the worker. + ['packages/cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv', 'peer dependency manifest'], + ['packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt', 'peer dependency manifest'], + // Candidate election runs in the packaged app, not in the worker closure, and + // has broken this path before. + ['packages/runtime-host/src/client/connect-or-spawn.ts', 'Runtime Host candidate election'], + ['packages/runtime-host/src/client/launcher.ts', 'Runtime Host candidate election'], + // Builds the worker the closure starts from, so it precedes rather than joins + // it. + ['packages/runtime/scripts/build-filesystem-worker.mjs', 'builds the worker itself'], +]); + +test('the Windows package trigger is exactly its closure plus declared exceptions', async () => { const closure = await collectWindowsPackageSourceClosure(); const patterns = readWindowsReleasePathPatterns(); const missing = closure.filter( @@ -48,6 +68,17 @@ test('the Windows package trigger covers the packaged worker and driver import c assert.ok(closure.includes(expected), `closure omitted ${expected}`); } assert.deepEqual(missing, []); + + // The other direction, which containment alone cannot see: a pattern backed + // by nothing is dead weight that still books a Windows runner every time it + // matches, and nothing reports it. Exceptions are declared above, so this + // fails both when the closure stops reaching an entry and when a stale + // exception outlives its reason. + const underived = patterns + .filter((pattern) => pattern.startsWith('packages/')) + .filter((pattern) => !closure.some((path) => windowsReleasePatternCoversSource(path, pattern))) + .sort(); + assert.deepEqual(underived, [...UNDERIVED_PACKAGE_PATTERNS.keys()].sort()); }); test('the Windows package workflow path list has no duplicate entries', () => { From 0e91c0fecca3930dd6beb2c6ad1a16ab8b70edf8 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 21:03:34 +0800 Subject: [PATCH 12/13] ci: restore what folding jobs and lanes took away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four leftovers from the consolidations earlier in this branch, each one a guarantee the old shape supplied and the new shape does not. `release-windows-check.yml` keyed its concurrency group on `github.ref`, which is `refs/heads/main` for the nightly and for a dispatch alike, so a dispatch could queue behind the nightly and be cancelled while still pending. Keyed on the pull request number like the other two Windows lanes. The three State Root transitions used to be three matrix jobs, so one failing left the others to upload their reports. Folded into one step they share a `set -e`, and the reports are wanted most on the run that failed, so the upload now runs with `if: always()`. `tee` has already written the failing transition's own output by then, and `if-no-files-found` stays `error` so a broken path is still caught on a green run. Moving predecessor resolution onto `build` rewrote the assertions that bound it, and `PREDECESSOR_TARBALL_URL` lost its only one — the third transition would still be spelled correctly while pointing at nothing. Both env bindings and the `workflow_call` output are asserted again. `.asf.yaml` still described the required context as an aggregation job propagating failures from a plan lane and a heavy job, none of which exist since the three merged into one. It now describes what is there, and names splitting the work back across jobs as the third way to freeze the queue. Also bounds `update-test-feed-wiring.test.mjs` to the argument object it means: an unbounded span would have accepted a `testFeedUrl` from any later call in a 1900-line module. Generated-by: Claude Code --- .asf.yaml | 14 ++++++++------ .github/workflows/cli-package-validation.yml | 7 +++++++ .github/workflows/release-windows-check.yml | 6 +++++- scripts/release-cli-workflow-policy.test.mjs | 20 ++++++++++++++++++++ scripts/update-test-feed-wiring.test.mjs | 8 +++++++- 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/.asf.yaml b/.asf.yaml index 2bcc12b41f..453ff07f19 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -62,12 +62,14 @@ github: required_approving_review_count: 1 required_status_checks: strict: false - # test is the always-reporting aggregation job in - # .github/workflows/ci.yml. It propagates failures from the install-free - # plan lane and any selected heavy validation while letting an ordinary - # documentation-only change skip the heavy job. 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. + # test is the single unconditional job in .github/workflows/ci.yml. It + # runs the install-free contract checks on every change and installs the + # toolchain only for the validation its own planning step selects, so a + # documentation-only change still reports without paying for a build. + # Renaming the job there, adding a paths filter that stops ci.yml from + # running, or splitting the work back across jobs so this context comes + # from an aggregator that can be skipped, 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. diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index ac66e97aa1..7e24e9afaa 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -401,7 +401,14 @@ jobs: qualify current-nightly-predecessor-to-candidate \ "$PREDECESSOR_TARBALL_URL" '' "$PREDECESSOR_INTEGRITY" \ candidate '' '' any + # The three transitions used to be three matrix jobs, so one failing left + # the others to upload their own reports. Folded into one step they share + # a `set -e`, and the reports are wanted most on the run that failed — + # `tee` has already written the failing transition's own output by then. + # `if-no-files-found` stays `error` so a broken path is still caught on a + # green run. - name: Preserve the qualification reports + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: released-state-root diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index 8c7a98cfc1..2d7b47f811 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -106,8 +106,12 @@ on: permissions: contents: read +# Keyed on the pull request number rather than the ref, because `github.ref` is +# refs/heads/main for the nightly and for a dispatch alike: one shared group +# would let a dispatch queue behind the nightly and then be discarded while +# still pending. Same fix the other two Windows lanes already carry. concurrency: - group: release-windows-check-${{ github.ref }} + group: release-windows-check-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true jobs: diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index 5a9cd312d2..d61004b437 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -75,6 +75,26 @@ test('CLI validation qualifies exact published State Roots without weakening art /"\$PREDECESSOR_TARBALL_URL" '' "\$PREDECESSOR_INTEGRITY" \\\n\s+candidate/u, ); + // The env those two names come from. Without this the third transition would + // still be spelled correctly while pointing at nothing, which is how the + // `tarball_url` binding lost its only assertion when the predecessor moved + // off its own job. `release_predecessor_tarball_url` is also a declared + // `workflow_call` output, so callers hold it too. + assert.match( + workflow, + /release_predecessor_tarball_url:[\s\S]*?value: \$\{\{ jobs\.build\.outputs\.release_predecessor_tarball_url \}\}/u, + ); + for (const name of ['tarball_url', 'integrity']) { + assert.match( + workflow, + new RegExp( + `PREDECESSOR_${name.toUpperCase()}: \\$\\{\\{ needs\\.build\\.outputs\\.release_predecessor_${name} \\}\\}`, + 'u', + ), + name, + ); + } + const steps = workflowSteps(workflow); const sandbox = namedStep(steps, 'Require the account-isolation sandbox'); assert.match(sandbox, /apt-get install --yes bubblewrap/u); diff --git a/scripts/update-test-feed-wiring.test.mjs b/scripts/update-test-feed-wiring.test.mjs index 7e5c14b122..a669ecd860 100644 --- a/scripts/update-test-feed-wiring.test.mjs +++ b/scripts/update-test-feed-wiring.test.mjs @@ -41,7 +41,13 @@ test('the packaged boot path hands the harness feed to the update service', () = const boot = read('apps/desktop/src/main/runtime-host-boot.ts'); assert.match(boot, /const updateTestFeed = process\.env\.MAKA_UPDATE_TEST_FEED;/u); - assert.match(boot, /createAppUpdateService\(\{[\s\S]*?testFeedUrl: updateTestFeed,/u); + // Bounded to the call's own argument object — the span may not cross a `});` + // — because an unbounded `[\s\S]*?` would accept a `testFeedUrl` belonging to + // some later call several hundred lines away. + assert.match( + boot, + /createAppUpdateService\(\{(?:(?!\}\);)[\s\S])*?testFeedUrl: updateTestFeed,/u, + ); }); test('the harness feed still redirects packaged user data away from the real root', () => { From 83a56953350c16728bd23bc2a6cc648c08703acc Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 21:04:43 +0800 Subject: [PATCH 13/13] test(ci): drop the filter assertions the closure now proves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine assertions restated `packages/` entries of `release-windows-check.yml` as literals. Every one could only ever catch "this exact line was deleted", and the previous commit made that case fail already: the filter's `packages/` half is computed from the import closure and compared as a set, so removing an entry fails there whether or not anyone remembered to also name it here. Verified by deleting `connect-or-spawn.ts` from the filter and watching the closure test fail with these gone. Two of the nine carried a reason for being in the filter without being in the closure; that reason now lives on the declared exception beside the path it explains, which is where someone auditing the entry will look. `copy-runtime-filesystem-worker.mjs` stays asserted by hand. It is the one entry outside the derived half: the desktop app copies the built worker in rather than importing it, so no closure reaches it and only naming it keeps it on the lane. Kept as well are the assertions that encode a rule rather than a copy — that the gate triggers on `release.yml` and on itself, and that Windows recovery still runs the three regression suites it exists for. Those state something no derivation produces. Generated-by: Claude Code --- scripts/ci-workflow-policy.test.mjs | 33 ++++++++++------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/scripts/ci-workflow-policy.test.mjs b/scripts/ci-workflow-policy.test.mjs index f0b79c12ca..d0cd004fb4 100644 --- a/scripts/ci-workflow-policy.test.mjs +++ b/scripts/ci-workflow-policy.test.mjs @@ -506,13 +506,6 @@ test('a filtered pull-request lane can still run when its filter misses', () => assert.deepEqual(uncovered.sort(), [...LANES_WITHOUT_A_FILTER_ESCAPE].sort()); }); -test('the packaged Windows gate owns Runtime Host candidate election changes', () => { - const workflow = readWorkflow('release-windows-check.yml'); - - assert.match(workflow, /'packages\/runtime-host\/src\/client\/connect-or-spawn\.ts'/u); - assert.match(workflow, /'packages\/runtime-host\/src\/client\/launcher\.ts'/u); -}); - test('the packaged Windows gate triggers on release orchestration changes', () => { const workflow = readWorkflow('release-windows-check.yml'); @@ -527,21 +520,17 @@ test('the packaged Windows gate workflow is itself a release-contract input', () ); }); -test('the packaged Windows gate triggers on packaged sandbox inputs', () => { - const workflow = readWorkflow('release-windows-check.yml'); - - for (const path of [ - 'apps/desktop/scripts/copy-runtime-filesystem-worker.mjs', - 'packages/runtime/scripts/build-filesystem-worker.mjs', - 'packages/runtime/src/filesystem-worker/**', - 'packages/runtime/src/sandbox/**', - 'packages/runtime/src/path-containment.ts', - 'packages/runtime/src/sandbox-boundary-path.ts', - 'packages/core/src/permission-profile.ts', - 'packages/core/src/permission-profile-compiler.ts', - ]) { - assert.ok(workflow.includes(` - '${path}'`), path); - } +test('the packaged Windows gate triggers on the worker copy step it cannot import', () => { + // The seven `packages/` entries this used to restate are held by + // `windows-package-source-closure.test.mjs`, which computes the filter's + // `packages/` half from the import closure and fails in both directions. + // This one is outside that half: the desktop app copies the built worker in, + // so no import reaches it and only naming it keeps it on the lane. + assert.ok( + readWorkflow('release-windows-check.yml').includes( + " - 'apps/desktop/scripts/copy-runtime-filesystem-worker.mjs'", + ), + ); }); test('pull-request and release lanes share the packaged sandbox lifecycle verifier', () => {