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/ci.yml b/.github/workflows/ci.yml index b96b70a3c2..60e432e9e3 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: @@ -74,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 @@ -110,6 +104,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). @@ -128,28 +128,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 +149,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 +165,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 +190,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,55 +202,57 @@ 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 # 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: needs.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 - 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 +260,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 +272,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 +281,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 +307,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 +319,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 +366,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 +382,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/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index 1bd0b62743..7e24e9afaa 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,97 @@ 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 + # 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-${{ 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/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index bc99c6e019..2d7b47f811 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,13 +96,22 @@ on: - 'apps/desktop/resources/licenses/cargo/THIRD_PARTY_NOTICES.txt' - '.github/workflows/release.yml' - '.github/workflows/release-windows-check.yml' + # 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: 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: @@ -135,6 +146,19 @@ jobs: version="$(node -p "require('./apps/desktop/package.json').version")" npm run verify:windows-x64 -- "apps/desktop/release/Maka-${version}-win-x64.exe" + # 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 env: @@ -145,6 +169,9 @@ 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 run: | version="$(node -p "require('./apps/desktop/package.json').version")" @@ -162,6 +189,9 @@ 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 run: | version="$(node -p "require('./apps/desktop/package.json').version")" diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 2ea0d13e62..cbf1ea3791 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -17,24 +17,42 @@ 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. Two groups. # -# 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. +# 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. +# +# 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 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 +# 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] paths: - - 'package.json' - 'package-lock.json' - 'patches/**' - 'scripts/apply-dependency-patches.mjs' @@ -43,21 +61,67 @@ 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/**' + # Every file in the import closure of the suites below that branches on + # `process.platform === 'win32'`. Generated, not curated: + # `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' + - '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/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/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/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.mjs b/scripts/ci-test-plan.mjs index 7567dfc7cf..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', @@ -257,6 +259,27 @@ 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, 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', + '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 +386,7 @@ export function planTests(changedFiles, options = {}) { if (full) { const workspaces = [...graph.dirs]; return { + appIcons: true, asfSource: true, astryxSurface: true, cliPackage: true, @@ -432,6 +456,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, @@ -467,7 +492,8 @@ export function planTests(changedFiles, options = {}) { export function requiresHeavyValidation(plan) { return Boolean( - plan.asfSource || + plan.appIcons || + plan.asfSource || plan.astryxSurface || plan.cliPackage || plan.code || @@ -483,6 +509,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 16e0c011ff..a9d03ddeb0 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -17,14 +17,23 @@ * 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 { changedFilesBetween, formatGitHubOutputs, - loadWorkspaceGraph, planTests, requiresHeavyValidation, } from './ci-test-plan.mjs'; @@ -399,620 +408,15 @@ 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'); - 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); - - const consumedKeys = new Set( - [...workflow.matchAll(/needs\.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', () => { - const workflow = readWorkflow('ci.yml'); - - 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); -}); - -test('heavy CI consumes planner outputs through the plan job', () => { - 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); - - assert.doesNotMatch(heavy, /steps\.plan\.outputs/u); - assert.match( - heavy, - /- name: Check renderer architecture\n\s+if: needs\.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('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: needs\.plan\.outputs\.code == 'true' \|\| needs\.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, /needs\.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: needs\.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: needs\.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 filters pull requests by the workspaces its steps execute', () => { - const workflow = readWorkflow('windows-recovery.yml'); - const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); - - // Derived from the dist paths the steps run, then widened along the workspace - // dependency graph the planner selects with. The separator class matches the - // backslash form too, because these steps run under pwsh where both are - // legal. A new workspace on this lane, or a new dependency under one of them, - // fails here until the filter admits its sources and project file. - const executed = [ - ...new Set( - [...workflow.matchAll(/packages[/\\]([^/\\]+)[/\\]dist[/\\]/gu)].map((match) => match[1]), - ), - ].sort(); - assert.deepEqual(executed, ['runtime', 'runtime-host', 'storage']); - - const closure = dependencyClosure(executed.map((workspace) => `packages/${workspace}`)); - assert.ok(closure.includes('packages/core'), 'dependency closure must reach core'); - for (const dir of closure) { - assert.ok(filtered.has(`${dir}/src/**`), `${dir}: sources`); - assert.ok(filtered.has(`${dir}/tsconfig.json`), `${dir}: project file`); - assert.ok(filtered.has(`${dir}/package.json`), `${dir}: manifest`); - } -}); - -test('the recovery lane filter follows the postinstall launcher chain', () => { - const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); - const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); - // Derived from postinstall itself, then one hop into whatever those entry - // points launch, because a launcher the filter cannot see still decides what - // `npm ci` produces on Windows. A restated list missed exactly that hop. - const entrypoints = [...manifest.scripts.postinstall.matchAll(/node (scripts\/[\w.-]+)/gu)].map( - (match) => match[1], - ); - assert.ok(entrypoints.length > 0, 'postinstall runs no script'); - - for (const entrypoint of entrypoints) { - assert.ok(filtered.has(entrypoint), entrypoint); - const source = readFileSync(new URL(`../${entrypoint}`, import.meta.url), 'utf8'); - for (const launched of source.matchAll(/new URL\('\.\/([\w.-]+)'/gu)) { - assert.ok(filtered.has(`scripts/${launched[1]}`), `${entrypoint} launches ${launched[1]}`); - } - } -}); - -test('the recovery lane filters pull requests by what its install and clean steps consume', () => { - const filtered = new Set(pullRequestPathFilter('windows-recovery.yml')); - - // `npm.cmd ci` and `npm.cmd run build:test` run unconditionally, so these are - // first-class inputs of the lane rather than transitive edits the nightly can - // be left to cover. A grouped dependabot bump touches only the manifests, and - // the crash gates sit on a native file lock the Linux `test` lane never sees. - for (const path of [ - 'package.json', - 'package-lock.json', - 'patches/**', - 'scripts/apply-dependency-patches.mjs', - 'scripts/install-electron-with-retry.mjs', - 'scripts/clean-build.mjs', - 'scripts/clean-paths.mjs', - 'scripts/windows-runtime-host-local-ipc-trust.ps1', - 'tsconfig.base.json', - 'tsconfig.lib.json', - 'packages/runtime/scripts/**', - '.github/workflows/windows-recovery.yml', - ]) { - assert.ok(filtered.has(path), path); - } -}); - -test('the sandbox lane pairs its path filter with a nightly run', () => { - 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'); - +// 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/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', + 'apps/desktop/src/renderer/app-shell.tsx', + 'packages/core/src/artifacts.ts', + 'packages/runtime/src/edit-replace.ts', ]) { - assert.ok(workflow.includes(` - '${path}'`), path); + assert.equal(planTests([path], { graph }).appIcons, false, 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\(needs\.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..d0cd004fb4 --- /dev/null +++ b/scripts/ci-workflow-policy.test.mjs @@ -0,0 +1,793 @@ +/* + * 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}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); + } +}); + +/** + * 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); + } + + assert.deepEqual(uncovered.sort(), [...LANES_WITHOUT_A_FILTER_ESCAPE].sort()); +}); + +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 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', () => { + 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/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/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', + ); } }); diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index 4abe6ef970..d61004b437 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -48,52 +48,75 @@ 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, + /"\$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, - /state-root-qualification:\n[\s\S]*?needs: \[build, release-predecessor\]/u, + /release_predecessor_tarball_url:[\s\S]*?value: \$\{\{ jobs\.build\.outputs\.release_predecessor_tarball_url \}\}/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); + 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); - 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', () => { diff --git a/scripts/update-test-feed-wiring.test.mjs b/scripts/update-test-feed-wiring.test.mjs new file mode 100644 index 0000000000..a669ecd860 --- /dev/null +++ b/scripts/update-test-feed-wiring.test.mjs @@ -0,0 +1,67 @@ +/* + * 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); + // 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', () => { + // 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')); +}); diff --git a/scripts/windows-package-source-closure.mjs b/scripts/windows-package-source-closure.mjs index 6b7eefe958..f006863e85 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)], @@ -54,11 +65,15 @@ export async function collectWindowsPackageSourceClosure(repoRoot = defaultRepoR } 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..262717c7dc 100644 --- a/scripts/windows-package-source-closure.test.mjs +++ b/scripts/windows-package-source-closure.test.mjs @@ -18,14 +18,37 @@ */ 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'; -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( @@ -45,9 +68,66 @@ 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', () => { 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); +});