diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 452a80fbbb..d115a2576c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,13 +28,10 @@ concurrency: cancel-in-progress: true jobs: - # Two single-`rg`-assertion jobs (formerly `ios-runner-swift-compat`, - # `no-test-di-seams`, added independently in b79bd8601 / 9eb060406) folded - # into steps here: each was checkout + one grep, paying full job - # scheduling/checkout overhead and its own PR status-check line for what is - # a single assertion. Each step keeps its own failure message. See #1462. - static-checks: - name: Static Checks + # Text-only assertions run before the toolchain setup, so a grep failure does + # not wait on an install. + lint: + name: Lint & Format runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -55,111 +52,6 @@ jobs: exit 1 fi - # The runner's pure-decision XCTests, on the macOS host with no simulator (#1781 A7). This - # job already compiled the macOS runner with the unit-test flag on every PR (as "Swift - # Runner Unit Compile") and threw the bundle away; running it costs one - # `test-without-building` on the host. Which tests that reaches is not a list: it is - # everything the macOS build compiles, and the simulator-only tests keep themselves out - # with an `os(iOS)` guard (see the classification note in RunnerTests.swift). The - # `-skip-testing:` entry is validated by `pnpm check:xctest-selection` for the same - # reason the nightly's is: `RunnerTests/testCommand` is the runner's 24-hour server - # entry point, and a typo re-arms the hang. The lane's reporter asserts the executed - # count equals the reach the check derives from source, so a build without the compile - # flag or a guard that compiles a file out reads as red rather than as a smaller green. - # - # - Catches: regressions in the ~130 host-runnable tests on every PR, in about a - # minute, independent of ios.yml — which is fifteen minutes long, cancelled ~19% of - # the time, and names only a subset of them. - # - Evidence: the nightly's first execution found #1812 in exactly this class of test - # (a geometry guard); the same tests pass on this lane's platform. - # - Cost: ~1 minute more on a job that already occupies a macOS slot for the build; - # no simulator boot; no new job. - # - Kill criterion: if the host run proves flaky where the simulator run of the same - # tests is not (a platform-branch difference this classification missed), gate the - # offender `os(iOS)` and it returns to the simulator lanes; if that happens to more - # than a handful, the host lane is the wrong tool and goes. - swift-runner-host-tests: - name: Swift Runner Host XCTests - runs-on: macos-26 - timeout-minutes: 20 - # Was an inline `VAR=1 pnpm gate …` prefix on the action's build-command input. The - # input is a gate id now, so the variable lives where ios.yml already puts it: the job. - env: - AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS: '1' - AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/swift-runner-unit-derived - RESULT_BUNDLE_PATH: ${{ github.workspace }}/.tmp/xctest-host/RunnerTests.xcresult - RESULT_SUMMARY_PATH: ${{ github.workspace }}/.tmp/xctest-host/test-results-summary.json - XCTEST_LANE: host - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup toolchain - uses: ./.github/actions/setup-node-pnpm - - - name: Restore and compile Swift runner unit-test surface - uses: ./.github/actions/setup-apple-runner-build - with: - derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }} - cache-key-prefix: swift-runner-unit - gate: swift-runner-macos - xcuitest-platform: macos - xcuitest-destination: platform=macOS,arch=arm64 - - - name: Run the host-runnable runner XCTests - run: | - set -euo pipefail - XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)" - test -n "$XCTESTRUN_PATH" - mkdir -p "$(dirname "$RESULT_BUNDLE_PATH")" - xcodebuild test-without-building \ - -xctestrun "$XCTESTRUN_PATH" \ - -destination 'platform=macOS,arch=arm64' \ - -skip-testing:AgentDeviceRunnerUITests/RunnerTests/testCommand \ - -resultBundlePath "$RESULT_BUNDLE_PATH" - - # Same shape as xctest-nightly.yml: extract is best-effort, the report step asserts. - - name: Extract the test-results summary - if: always() - run: | - set -uo pipefail - [ -d "$RESULT_BUNDLE_PATH" ] || exit 0 - xcrun xcresulttool get test-results summary --path "$RESULT_BUNDLE_PATH" --compact \ - > "$RESULT_SUMMARY_PATH" 2>/dev/null || - xcrun xcresulttool get test-results summary --path "$RESULT_BUNDLE_PATH" \ - > "$RESULT_SUMMARY_PATH" 2>/dev/null || - rm -f "$RESULT_SUMMARY_PATH" - exit 0 - - - name: Report the run and assert it executed the source-derived set - if: always() - run: | - set -euo pipefail - if [ ! -s "$RESULT_SUMMARY_PATH" ]; then - echo 'No usable test-results summary was produced, so nothing here can show a test ran.' - echo 'The suite step above failed before or during the run; read its log first.' - echo '### iOS runner host XCTest lane' >> "$GITHUB_STEP_SUMMARY" - echo 'No result-bundle summary; see the run log and the uploaded bundle.' >> "$GITHUB_STEP_SUMMARY" - exit 1 - fi - node --experimental-strip-types scripts/xctest-run-summary.ts - - - name: Upload result bundle - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: xctest-host-results-${{ github.run_id }}-${{ github.run_attempt }} - path: .tmp/xctest-host - if-no-files-found: warn - - lint: - name: Lint & Format - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm @@ -171,35 +63,35 @@ jobs: uses: ./.github/actions/run-gate with: { gate: format } - layering-guard: - name: Layering Guard + # Structural guards share one checkout and install. Every gate stays an + # independently named step with its own failure message; the gate manifest + # derives lane ownership from these declarations, so merging jobs changes + # nothing it asserts. + repo-guards: + name: Repo Guards runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - # This job used to run with install-deps: false. It opted out when R7 - # (SessionState ownership) started parsing the daemon with `oxc-parser` instead - # of matching assignment operators with a regex: a regex cannot see `??=` or a - # computed `session[key] =` write, so the choice was a real parser or a rule with - # holes in it. It was the last such job, so R8 zero-dep-job-closure retired for - # want of subjects (#1781 A6). Keep install-deps enabled. + # The layering gate parses production sources with `oxc-parser`, so + # dependencies are required; keep install-deps enabled. - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm - name: Check import-direction DAG - # Generalizes the former inline commands/-import grep into a structured - # import-direction lint over the resolved graph. See scripts/layering/check.ts - # and CONTEXT.md (Architecture: folder DAG + layering lint). + # Structured import-direction lint over the resolved graph. See + # scripts/layering/check.ts and CONTEXT.md (Architecture: folder DAG + + # layering lint). uses: ./.github/actions/run-gate with: { gate: layering } - name: Check the depgraph report agrees with the gate # scripts/depgraph reads the same model as the gate, so its inversion count must # reproduce TYPE_INVERSION_BASELINE. Free two-sources check: if the tree changes - # and only one side is updated, this fails and names the difference. Runs here - # rather than in its own job so the two can never be green independently. + # and only one side is updated, this fails and names the difference. Runs beside + # the layering gate so the two can never be green independently. uses: ./.github/actions/run-gate with: { gate: depgraph } @@ -215,22 +107,6 @@ jobs: uses: ./.github/actions/run-gate with: { gate: tmpdir-leaks-model } - affected-selector: - name: Affected-check Selector - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - # The selector's entry closure reaches `@agent-device/kernel` workspace - # specifiers through src/utils/exec.ts and diagnostics.ts (#1490 W0), and - # workspace package resolution needs the pnpm link in node_modules. R11 - # grants no relative route into a package, so this job installs - # dependencies instead. - - name: Setup toolchain - uses: ./.github/actions/setup-node-pnpm - # The selector is fail-open and advisory (GitHub CI stays authoritative), # so the gate only guards the derivation model. - name: Check affected-selector model @@ -260,73 +136,42 @@ jobs: uses: ./.github/actions/run-gate with: { gate: xctest-selection } - maestro-conformance: - name: Maestro Conformance Oracle - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - # Unlike the layering/affected guards, this job DOES install deps: the - # verifier parses corpus flows with the live engine, and the Maestro parser - # imports the `yaml` package. Keep install-deps enabled. - - name: Setup toolchain - uses: ./.github/actions/setup-node-pnpm - # Layers 1-2 of the conformance oracle: replay the JVM-generated fixtures # against the live engine. Deterministic and Java-free — the generated # fixtures are checked in and only regenerated on an upstream-pin bump. The # device-backed layer 3 runs on the scheduled conformance-differential - # workflow. See scripts/maestro-conformance/README.md. + # workflow. See scripts/maestro-conformance/README.md. Unlike the guards + # above, this parses corpus flows with the live engine and imports the + # `yaml` package — covered by the shared install above. - name: Verify Maestro conformance fixtures uses: ./.github/actions/run-gate with: { gate: maestro-conformance } - packaged-cli-node-22-12: - name: Packaged CLI Node 22.12 - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup build toolchain - uses: ./.github/actions/setup-node-pnpm - - - name: Build CLI + # server.json/smithery.yaml drift otherwise surfaces at publish time (where + # publish-mcp-registry.yml duplicates the same command). Parse-only. + - name: Check MCP registry metadata is in sync uses: ./.github/actions/run-gate - with: { gate: build } + with: { gate: mcp-metadata } - - name: Verify emitted chunk ownership + # Slowest guard (~3 min), so it runs last and structural failures surface + # before it. Runs on plain Node via the shared install above. + - name: Check numeric ranges uses: ./.github/actions/run-gate - with: { gate: bundle-owner-files } - - # The build runs on the default toolchain Node and the package is verified on the minimum - # supported Node, so this job covers what a user on `engines.node` floor actually installs. - - name: Setup Node.js 22.12 - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: '22.12' - - # Packs, lints the tarball with publint/attw, installs it outside the workspace, and imports - # every published entry point before running the CLI. See scripts/check-package.ts. - # - # Runs the script directly rather than through `pnpm check:package`: the repo's pinned pnpm - # requires Node >= 22.13 and refuses to start on the 22.12 floor this job exists to cover. The - # gate itself only needs `node` and `npm`, so it is the package.json script minus the launcher. - - name: Verify the published package on Node.js 22.12 - run: node --experimental-strip-types scripts/check-package.ts + with: { gate: freerange } - fallow: - name: Fallow Code Quality + # History-backed compatibility gates share one deep checkout: fallow, + # replay-compat, and daemon-wire-compat all need either full history or tags, + # which no shallow unit lane can read. + compat-provenance: + name: Compatibility & Provenance runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + fetch-tags: true - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm @@ -345,59 +190,82 @@ jobs: uses: ./.github/actions/run-gate with: { gate: production-exports } - replay-compat-provenance: - # The frozen replay-compat corpus (#1417) claims each entry was published by - # a released tag. Only a full-history checkout can re-derive that claim, so - # this job exists separately from the shallow-clone-safe unit lane. - name: Replay-Compat Provenance - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Setup toolchain - uses: ./.github/actions/setup-node-pnpm - + # The frozen replay-compat corpus (#1417) claims each entry was published by + # a released tag. Only a full-history checkout can re-derive that claim. - name: Verify corpus entries against their released blobs uses: ./.github/actions/run-gate with: { gate: replay-compat } - released-surface-compat: - # The daemon RPC wire ledger (#1432) is compared against the ledger as it - # stood at the last RELEASED tag, which only a full-history checkout can - # read. Same split as the replay-compat corpus above: the shallow unit lane - # holds the ledger to its source, this job holds it to the last release. - name: Released-Surface Compatibility + - name: Verify the wire-compat rules + uses: ./.github/actions/run-gate + with: { gate: wire-compat-model } + + # The daemon RPC wire ledger (#1432) is compared against the ledger as it + # stood at the last RELEASED tag, which only a tagged checkout can read. + # The shallow Repo Guards lane holds the ledger to its source; this step + # holds it to the last release. + - name: Compare the daemon RPC wire surface against the last released tag + uses: ./.github/actions/run-gate + with: { gate: daemon-wire-compat } + + # Typecheck and package verification share one checkout and install; both + # need the default toolchain only, and the package step re-pins Node itself. + typecheck-package: + name: Typecheck & Package runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - fetch-tags: true - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm - - name: Verify the wire-compat rules + - name: Run typecheck uses: ./.github/actions/run-gate - with: { gate: wire-compat-model } + with: { gate: typecheck } - - name: Compare the daemon RPC wire surface against the last released tag + - name: Build CLI uses: ./.github/actions/run-gate - with: { gate: daemon-wire-compat } + with: { gate: build } + + - name: Verify emitted chunk ownership + uses: ./.github/actions/run-gate + with: { gate: bundle-owner-files } + + # The build runs on the default toolchain Node and the package is verified on the minimum + # supported Node, so this job covers what a user on `engines.node` floor actually installs. + - name: Setup Node.js 22.12 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '22.12' + + # Packs, lints the tarball with publint/attw, installs it outside the workspace, and imports + # every published entry point before running the CLI. See scripts/check-package.ts. + # + # Runs the script directly rather than through `pnpm check:package`: the repo's pinned pnpm + # requires Node >= 22.13 and refuses to start on the 22.12 floor this job exists to cover. The + # gate itself only needs `node` and `npm`, so it is the package.json script minus the launcher. + - name: Verify the published package on Node.js 22.12 + run: node --experimental-strip-types scripts/check-package.ts + # Runs the full unit + provider-integration suites under coverage with + # thresholds, so a separate unit-tests job would rerun the same tests. The + # suite is sharded across runners; each shard writes a blob report and the + # Coverage Report job merges them, evaluates thresholds once over the full + # suite, and produces every coverage artifact (vitest.config.ts carries the + # shard/merge switches). coverage: - # Runs the full unit + provider-integration suites under coverage with - # thresholds, so a separate unit-tests job would rerun the same tests. name: Coverage runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2] + env: + AGENT_DEVICE_COVERAGE_SHARD: ${{ matrix.shard }}/2 + OUTPUT_ECONOMY_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -408,81 +276,86 @@ jobs: uses: ./.github/actions/setup-node-pnpm - name: Test changed-line coverage gate + if: matrix.shard == 1 uses: ./.github/actions/run-gate with: { gate: coverage-model } - - name: Run coverage - id: run-coverage - env: - OUTPUT_ECONOMY_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + - name: Run coverage shard uses: ./.github/actions/run-gate with: { gate: unit-ci } + # The TMPDIR redirection both test lanes depend on (#1593/#1595). The check is a real # package script that no workflow ran: it is reachable only through `check:unit`, an - # aggregate CI never invokes, so a leak regression could not fail a PR. Placed here - # because this is the lane whose instrumented suite would leak a run directory. + # aggregate CI never invokes, so a leak regression could not fail a PR. Runs per shard, + # because a leak lands on whichever runner executed the leaking file. - name: Check for leaked temp directories uses: ./.github/actions/run-gate with: { gate: tmpdir-leaks } - # Reuses the lcov the coverage step just wrote (never runs coverage twice) - # and fails when changed-line coverage < the threshold in - # scripts/coverage-changed/model.ts. The `coverage-waiver` PR label maps to - # the waiver env, which skips the failure but still prints the numbers. - # Gated on the coverage step's own outcome (#1781 A5): when `Run coverage` - # fails, lcov.info is never written, so this step would just re-report that - # failure as its own red ("no lcov report") instead of a coverage verdict. - - name: Enforce changed-line coverage gate - if: steps.run-coverage.outcome == 'success' && github.event_name == 'pull_request' - env: - AGENT_DEVICE_COVERAGE_WAIVER: ${{ contains(github.event.pull_request.labels.*.name, 'coverage-waiver') }} - uses: ./.github/actions/run-gate + - name: Upload coverage blob + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - gate: coverage - args: | - --base - ${{ github.event.pull_request.base.sha }} - - typecheck: - name: Typecheck + name: coverage-blob-${{ matrix.shard }} + path: .vitest-reports/ + # The directory is dot-prefixed, which v4 excludes by default. + include-hidden-files: true + if-no-files-found: error + + coverage-report: + name: Coverage Report + needs: coverage runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 10 + env: + AGENT_DEVICE_COVERAGE_MERGE: '1' + OUTPUT_ECONOMY_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm - - name: Run typecheck - uses: ./.github/actions/run-gate - with: { gate: typecheck } - - # CHECK_CATALOG has always claimed this job mirrors the `mcp-metadata` - # check; until #1429 checked the claim, no PR job ran it at all, so - # server.json/smithery.yaml drift only surfaced at publish time (where - # publish-mcp-registry.yml duplicates the same command). Parse-only. - - name: Check MCP registry metadata is in sync - uses: ./.github/actions/run-gate - with: { gate: mcp-metadata } - - freerange: - name: FreeRange - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Download coverage blobs + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: coverage-blob-* + path: .tmp/coverage-blobs - - name: Setup toolchain - uses: ./.github/actions/setup-node-pnpm + # download-artifact nests each artifact in its own subdirectory; the blob + # merge reads one flat directory. + - name: Collect coverage blobs + run: | + set -euo pipefail + mkdir -p .vitest-reports + find .tmp/coverage-blobs -name '*.json' -exec mv {} .vitest-reports/ \; + ls .vitest-reports - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + # Reuses the blobs the shards wrote (never reruns tests) and fails when + # merged changed-line coverage < the threshold in + # scripts/coverage-changed/model.ts. The `coverage-waiver` PR label maps to + # the waiver env, which skips the failure but still prints the numbers. + # Gated on the merge step's own outcome (#1781 A5): when it fails, + # lcov.info is never written, so this step would just re-report that + # failure as its own red ("no lcov report") instead of a coverage verdict. + - name: Merge coverage shards + id: run-coverage + uses: ./.github/actions/run-gate + with: { gate: unit-ci } - - name: Check numeric ranges + - name: Enforce changed-line coverage gate + if: steps.run-coverage.outcome == 'success' && github.event_name == 'pull_request' + env: + AGENT_DEVICE_COVERAGE_WAIVER: ${{ contains(github.event.pull_request.labels.*.name, 'coverage-waiver') }} uses: ./.github/actions/run-gate - with: { gate: freerange } + with: + gate: coverage + args: | + --base + ${{ github.event.pull_request.base.sha }} integration: name: Integration Tests @@ -531,25 +404,16 @@ jobs: uses: ./.github/actions/run-gate with: { gate: fixture-cache } - web-smoke: - name: Web Platform Smoke - runs-on: ubuntu-latest - timeout-minutes: 30 - env: - AGENT_DEVICE_WEB_E2E: '1' - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup toolchain - uses: ./.github/actions/setup-node-pnpm - with: - node-version: '24.13' - + # Shares this job's ubuntu toolchain. AGENT_DEVICE_WEB_E2E is step-scoped + # so it cannot leak into the node/provider suites above. - name: Run live web smoke + env: + AGENT_DEVICE_WEB_E2E: '1' run: pnpm clean:daemon - name: Execute live web smoke + env: + AGENT_DEVICE_WEB_E2E: '1' uses: ./.github/actions/run-gate with: { gate: web-smoke } diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index a4348f40fe..37b6259a11 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -28,6 +28,26 @@ concurrency: cancel-in-progress: true jobs: + # One macOS lane for everything the macOS host can prove: command coverage, + # the runner's pure-decision XCTests, the helper, and the replay smoke. All + # of it consumes one runner build, compiled with the unit-test flag so the + # host XCTest run below has a bundle to execute (#1781 A7). + # + # Which tests that build reaches is not a list: it is everything the macOS + # build compiles, and the simulator-only tests keep themselves out with an + # `os(iOS)` guard (see the classification note in RunnerTests.swift). The + # `-skip-testing:` entry is validated by `pnpm check:xctest-selection` for + # the same reason the nightly's is: `RunnerTests/testCommand` is the + # runner's 24-hour server entry point, and a typo re-arms the hang. The + # lane's reporter asserts the executed count equals the reach the check + # derives from source, so a build without the compile flag or a guard that + # compiles a file out reads as red rather than as a smaller green. + # + # Kill criterion: if the host run proves flaky where the simulator run of the + # same tests is not (a platform-branch difference this classification + # missed), gate the offender `os(iOS)` and it returns to the simulator lanes; + # if that happens to more than a handful, the host lane is the wrong tool and + # goes. smoke-macos: name: Smoke Tests runs-on: macos-26 @@ -35,6 +55,10 @@ jobs: env: AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/macos-runner-derived + AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS: '1' + RESULT_BUNDLE_PATH: ${{ github.workspace }}/.tmp/xctest-host/RunnerTests.xcresult + RESULT_SUMMARY_PATH: ${{ github.workspace }}/.tmp/xctest-host/test-results-summary.json + XCTEST_LANE: host steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -55,6 +79,52 @@ jobs: xcuitest-platform: macos xcuitest-destination: platform=macOS,arch=arm64 + - name: Run the host-runnable runner XCTests + run: | + set -euo pipefail + XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)" + test -n "$XCTESTRUN_PATH" + mkdir -p "$(dirname "$RESULT_BUNDLE_PATH")" + xcodebuild test-without-building \ + -xctestrun "$XCTESTRUN_PATH" \ + -destination 'platform=macOS,arch=arm64' \ + -skip-testing:AgentDeviceRunnerUITests/RunnerTests/testCommand \ + -resultBundlePath "$RESULT_BUNDLE_PATH" + + # Same shape as xctest-nightly.yml: extract is best-effort, the report step asserts. + - name: Extract the test-results summary + if: always() + run: | + set -uo pipefail + [ -d "$RESULT_BUNDLE_PATH" ] || exit 0 + xcrun xcresulttool get test-results summary --path "$RESULT_BUNDLE_PATH" --compact \ + > "$RESULT_SUMMARY_PATH" 2>/dev/null || + xcrun xcresulttool get test-results summary --path "$RESULT_BUNDLE_PATH" \ + > "$RESULT_SUMMARY_PATH" 2>/dev/null || + rm -f "$RESULT_SUMMARY_PATH" + exit 0 + + - name: Report the run and assert it executed the source-derived set + if: always() + run: | + set -euo pipefail + if [ ! -s "$RESULT_SUMMARY_PATH" ]; then + echo 'No usable test-results summary was produced, so nothing here can show a test ran.' + echo 'The suite step above failed before or during the run; read its log first.' + echo '### iOS runner host XCTest lane' >> "$GITHUB_STEP_SUMMARY" + echo 'No result-bundle summary; see the run log and the uploaded bundle.' >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + node --experimental-strip-types scripts/xctest-run-summary.ts + + - name: Upload result bundle + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: xctest-host-results-${{ github.run_id }}-${{ github.run_attempt }} + path: .tmp/xctest-host + if-no-files-found: warn + - name: Build macOS helper uses: ./.github/actions/run-gate with: { gate: macos-helper } diff --git a/.github/workflows/test-app-build-cache.yml b/.github/workflows/test-app-build-cache.yml index f5eb0bbc2d..9ab95b5f13 100644 --- a/.github/workflows/test-app-build-cache.yml +++ b/.github/workflows/test-app-build-cache.yml @@ -77,24 +77,22 @@ jobs: --argjson iosBuild "$IOS_BUILD" \ --arg androidName "$ANDROID_NAME" \ --argjson androidBuild "$ANDROID_BUILD" \ - '{ - include: [ - { - name: "iOS Release", - platform: "ios", - runsOn: (if $iosBuild then "macos-26" else "ubuntu-latest" end), - artifactName: $iosName, - build: $iosBuild - }, - { - name: "Android Release", - platform: "android", - runsOn: "ubuntu-latest", - artifactName: $androidName, - build: $androidBuild - } - ] - }')" + '[ + { + name: "iOS Release", + platform: "ios", + runsOn: (if $iosBuild then "macos-26" else "ubuntu-latest" end), + artifactName: $iosName, + build: $iosBuild + }, + { + name: "Android Release", + platform: "android", + runsOn: "ubuntu-latest", + artifactName: $androidName, + build: $androidBuild + } + ] | { include: map(select(.build)) }')" echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" release: diff --git a/.gitignore b/.gitignore index 99f4443763..498b70ab65 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ android/ime-helper/dist/ # Workspace package declaration output (tsc -b project references) packages/*/dist-types/ *.tsbuildinfo +.vitest-reports/ diff --git a/scripts/check-affected/checks.ts b/scripts/check-affected/checks.ts index c642732e9b..82fa76a870 100644 --- a/scripts/check-affected/checks.ts +++ b/scripts/check-affected/checks.ts @@ -68,8 +68,8 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ gate('android-helpers', 'Android helper builds (snapshot + IME)', 'build:android', false), gate('macos-helper', 'macOS helper build', 'build:macos-helper', false), gate('web-smoke', 'Live web platform smoke', 'test:smoke:web', false), - // Needs full history and tags, so it runs in its own fetch-depth: 0 job rather - // than inside the shallow-clone-safe unit lane. + // Needs full history and tags, so it runs in the shared fetch-depth: 0 job + // rather than inside the shallow-clone-safe unit lane. gate('replay-compat', 'Replay-compat corpus provenance (released blobs)', 'check:replay-compat'), gate( 'daemon-wire-compat', @@ -95,9 +95,9 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ gate('wire-compat-model', 'Wire-compat rules model', 'check:daemon-wire-compat:test'), gate('production-exports', 'Production-unused exports', 'check:production-exports'), gate('bundle-owner-files', 'Bundle owner-file manifest', 'check:bundle-owner-files'), - // Not locally runnable: `@chenglou/freerange`'s bin is `fr.ts`, so the CI job installs Bun - // for it and `pnpm check` never runs this gate. Left default, fail-open would have made - // every `scripts/**`/`.github/**`/`package.json` edit require Bun on the pre-push path. + // Not locally runnable: the audit takes ~90s, more than the pre-push affected + // path should pay on every scripts/** edit. It runs on plain Node; CI needs + // no runtime beyond the default toolchain. gate('freerange', 'Numeric range audit', 'check:freerange', false), gate('fixture-cache', 'Trusted fixture-artifact selection', 'test:fixture-cache'), gate('fixture-fallback', 'Fixture-app cache-failure fallback', 'test:fixture-fallback'), diff --git a/scripts/check-xctest-selection.ts b/scripts/check-xctest-selection.ts index bc9020aa89..7d6977d301 100644 --- a/scripts/check-xctest-selection.ts +++ b/scripts/check-xctest-selection.ts @@ -5,7 +5,7 @@ // // Three lanes run the `AgentDeviceRunnerUITests` bundle, and each reaches a different set: // -// - host ci.yml, macOS host, every PR: the whole bundle as compiled for macOS, minus +// - host macos.yml, macOS host, every PR: the whole bundle as compiled for macOS, minus // `-skip-testing:` — the pure runner-decision tests, whose guard is // `#if AGENT_DEVICE_RUNNER_UNIT_TESTS` alone. // - pr ios.yml, iOS Simulator, every PR: the hand-written `-only-testing:` list. @@ -49,7 +49,7 @@ const repoRoot = path.resolve(import.meta.dirname, '..'); const packageAppleRunnerScript = path.join(repoRoot, 'scripts/package-apple-runner-source.mjs'); /** The macOS host lane, which runs the whole macOS-compiled bundle on every PR. */ -export const HOST_WORKFLOW_FILE = '.github/workflows/ci.yml'; +export const HOST_WORKFLOW_FILE = '.github/workflows/macos.yml'; /** The PR lane, whose `-only-testing:` list decides what every pull request runs on the simulator. */ export const PR_WORKFLOW_FILE = '.github/workflows/ios.yml'; diff --git a/vitest.config.ts b/vitest.config.ts index 3e85d1f5dc..e54ac6703c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,8 +25,22 @@ export const SETUP_FILES = [ 'src/__tests__/process-memo-setup.ts', ]; +// The CI Coverage lane shards the instrumented suite across runners and merges +// the results on one of them (see ci.yml). AGENT_DEVICE_COVERAGE_SHARD="/" +// turns an invocation into shard i of n writing a blob report; both unset means +// the ordinary full run. AGENT_DEVICE_COVERAGE_MERGE=1 aggregates previously +// written blobs instead of collecting tests — it still evaluates thresholds and +// writes every configured coverage report. +const COVERAGE_SHARD = process.env.AGENT_DEVICE_COVERAGE_SHARD; +const COVERAGE_MERGE = process.env.AGENT_DEVICE_COVERAGE_MERGE === '1'; + export default defineConfig({ test: { + ...(COVERAGE_SHARD ? { shard: COVERAGE_SHARD } : {}), + ...(COVERAGE_MERGE ? { mergeReports: '.vitest-reports' } : {}), + outputFile: COVERAGE_SHARD + ? { blob: `.vitest-reports/blob-${COVERAGE_SHARD.split('/')[0]}.json` } + : undefined, // Redirects TMPDIR to one per-run directory for the whole invocation (all // projects, every worker) and removes it once at the end — see the file // for why a single global hook beats per-file cleanup here. @@ -51,7 +65,9 @@ export default defineConfig({ // assumption without reducing ordinary file-level parallelism. maxConcurrency: 1, // Gate reporters for every lane; a `--reporter` flag would replace them, so no lane passes one. - reporters: ['default', slowTestGateReporter()], + // A coverage shard swaps in the blob reporter alongside the default one: its console output is + // only per-shard noise anyway, and the merge run below needs the blobs to exist. + reporters: COVERAGE_SHARD ? ['default', 'blob'] : ['default', slowTestGateReporter()], projects: [ { test: { @@ -165,10 +181,9 @@ export default defineConfig({ coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov', 'json-summary'], - thresholds: { - statements: 78, - lines: 80, - }, + // A shard sees roughly half the suite, so its own numbers sit far below + // the gate; thresholds are enforced once on the merged full-suite run. + thresholds: COVERAGE_SHARD ? { statements: 0, lines: 0 } : { statements: 78, lines: 80 }, include: ['src/**/*.ts', 'packages/*/src/**/*.ts'], exclude: [ 'src/**/*.test.ts',