From b7200f2509eff28361cb937a7b86ee77b19ec588 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 27 Oct 2026 10:32:37 +0900 Subject: [PATCH] ci: run import-graph tests on pull requests and verify merged batches on dev --- .github/workflows/ci.yml | 55 +++++++-- AGENTS.md | 7 +- MAINTAINERS.md | 10 ++ scripts/ci/run-changed-tests.sh | 50 ++++++++ scripts/ci/select-test-lane.sh | 82 +++++++++++++ tests/ci-workflows/ci-review-lanes.test.ts | 18 ++- tests/ci-workflows/ci-scope-gaps.test.ts | 2 +- tests/ci-workflows/ci-scope-reduction.test.ts | 30 +++++ tests/ci-workflows/ci-test-lane.test.ts | 116 ++++++++++++++++++ 9 files changed, 357 insertions(+), 13 deletions(-) create mode 100755 scripts/ci/run-changed-tests.sh create mode 100755 scripts/ci/select-test-lane.sh create mode 100644 tests/ci-workflows/ci-test-lane.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f302557ba68..b6d7e7ad467 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ # ============================================================================ # CI POLICY (maintainer decision — do not change in a contributor PR) -# * Pull requests run ONLY the fast Ubuntu subset. +# * Pull requests run ONLY the fast Ubuntu subset (default: gates + `test changed`). # * Full cross-platform (macOS/Windows) CI runs ONLY via manual # workflow_dispatch and on push to main/preview (release lines). # * NEVER add a dev push trigger or pull_request runs for macOS/Windows jobs. @@ -25,11 +25,9 @@ on: # carry contributor prefixes (`fix/`, `feat/`, `agent/`) as readily as # `codex/`, and contributor stacks need CI most. The `changes` job below is # the real scope gate, using the same allowlist as the push trigger. Safe to - # widen here - # because this workflow is `pull_request` (not `pull_request_target`), + # widen here because this workflow is `pull_request` (not `pull_request_target`), # declares `contents: read`, and reads no secrets. # - # # Pull requests run only the fast Ubuntu subset. Every macOS and Windows # job (and the macOS/Windows legs of the keyring, npm-global, setup-action # and remote-helper matrices) is skipped on `pull_request` and runs after @@ -94,7 +92,6 @@ on: permissions: contents: read -# Retrigger CI after dir-fsync / oauth deadline follow-ups (tip 34a1ac46). concurrency: # `push` and `pull_request` want supersession: a newer head on the same ref # makes the older one irrelevant, and cancelling it saves an hour of runners @@ -216,6 +213,7 @@ jobs: # of silently making every expensive job skip. ci: ${{ steps.scope.outputs.ci }} desktop: ${{ steps.scope.outputs.desktop }} + fast: ${{ steps.lane.outputs.fast }} # true: `test changed` replaces the four shards native: ${{ steps.matrices.outputs.native }} # Matrix include lists for keyring-smoke and npm-global-smoke, built and # shape-checked by the same validation step as `native`. @@ -590,6 +588,15 @@ jobs: esac done + - name: Choose the pull request test lane + id: lane + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + CI_SCOPE: ${{ steps.scope.outputs.ci }} + FULL_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }} + run: bash scripts/ci/select-test-lane.sh + - name: Decide whether the Windows shards run id: windows shell: bash @@ -647,7 +654,7 @@ jobs: test: name: test ${{ matrix.shard }}/4 needs: changes - if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.fast != 'true') runs-on: ubuntu-latest # A quarter of the suite. A shard that needs longer than this is wedged, not # slow — the old 30-minute ceiling was margin for the Windows leg, which no @@ -710,6 +717,27 @@ jobs: TEST_SHARD: ${{ matrix.shard }}/4 run: bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD" + # Fast lane: import-graph tests for the diff; the batched dev dispatch is the backstop. + test-changed: + name: test changed + needs: changes + if: github.event_name == 'pull_request' && needs.changes.outputs.fast == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} + fetch-depth: 2 # merge commit + base tip, which the selection diffs against + - uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. + - name: Install dependencies and build the GUI the tests serve + run: bun install --frozen-lockfile && cd gui && bun install --frozen-lockfile && bun run build + - name: Test the files this pull request reaches + run: bash scripts/ci/run-changed-tests.sh + # Bun 1.3.14 has shown a Linux isolate/epoll race around the storage-policy # harness. Keep the entire six-file family in one fresh process so a runtime # failure is bounded to this job instead of poisoning a general test shard. @@ -1750,7 +1778,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped`, which is the shape the step below is written to catch. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, privacy-gate, npm-global-smoke, widget, desktop-shell, setup-action, remote-helper] + needs: [changes, select-windows-runner, test, test-changed, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, privacy-gate, npm-global-smoke, widget, desktop-shell, setup-action, remote-helper] runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -1766,6 +1794,7 @@ jobs: EVENT_NAME: ${{ github.event_name }} LANE: ${{ github.event.inputs.lane }} CHANGES_CI: ${{ needs.changes.outputs.ci }} + CHANGES_FAST: ${{ needs.changes.outputs.fast }} CHANGES_PACKAGING: ${{ needs.changes.outputs.packaging }} CHANGES_DOCS: ${{ needs.changes.outputs.docs }} CHANGES_STRUCTURE: ${{ needs.changes.outputs.structure }} @@ -1796,6 +1825,12 @@ jobs: if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_CI" != "true" ]; then scoped=not-requested fi + full_tests=$scoped # the fast lane swaps the shards for test changed; unset = full + changed_tests=not-requested + if [ "$EVENT_NAME" = "pull_request" ] && [ "$scoped" = requested ] && [ "${CHANGES_FAST:-false}" = "true" ]; then + full_tests=not-requested + changed_tests=requested + fi # platform-macos and widget never run on a pull request (macOS runs after merge). # desktop-shell (ubuntu) carries the ordinary scope gate AND the native or # package-E2E path filters. This mirrors those expressions exactly; where it @@ -1866,7 +1901,7 @@ jobs: # One line per job, mirroring that job's own `if:`. Adding a job to this workflow # without adding it here fails the gate by name rather than passing unnoticed. - GATED_JOBS="changes select-windows-runner test storage-policy api-usage gates" + GATED_JOBS="changes select-windows-runner test test-changed storage-policy api-usage gates" GATED_JOBS="$GATED_JOBS platform-macos keyring-smoke docker-smoke npm-global-smoke" GATED_JOBS="$GATED_JOBS macos-control platform-windows docs-site-build" GATED_JOBS="$GATED_JOBS structure-gate widget" @@ -1877,7 +1912,9 @@ jobs: expected_for() { case "$1" in changes|select-windows-runner) echo requested ;; - test|storage-policy|api-usage|gates) + test) echo "$full_tests" ;; + test-changed) echo "$changed_tests" ;; + storage-policy|api-usage|gates) echo "$scoped" ;; docker-smoke) echo "$docker" ;; keyring-smoke) echo "$keyring" ;; diff --git a/AGENTS.md b/AGENTS.md index 2ba03009389..2e1c13e6dca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -367,7 +367,12 @@ than nudged. ## CI policy (maintainer decision — do not change) -- Pull requests run **only the fast Ubuntu subset**. +- Pull requests run **only the fast Ubuntu subset**. By default that is `gates` plus + `test changed` (import-graph tests for the diff); the four full Ubuntu shards run on a + pull request only with the `ci:full` label or a CI/harness/toolchain/dependency change. + Merged pull requests are verified in batches by a manual `dev` dispatch + (`MAINTAINERS.md`, "Batched merges"). Because `test changed` follows the module graph, + add `ci:full` for changes it cannot see (subprocesses, files read as data, generated output). - Full cross-platform (macOS/Windows) CI runs **only** via manual `workflow_dispatch` and on push to `main`/`preview` (release lines). - **Never** add a `dev` push trigger, or `pull_request` runs for macOS/Windows diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 18741234818..db2798905e0 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -62,6 +62,16 @@ when a maintainer steps down. the ruleset does not check ancestry (see the note under the change log). - Pull requests require successful required CI checks before merge. Contributor pull requests normally require approval from at least one maintainer other than the author. +- **Batched merges.** A pull request's own CI is the fast lane by default: `gates` plus + `test changed` (the tests Bun's import graph reaches from the diff), not the four full + Ubuntu shards. The shards still run on a pull request that carries the `ci:full` label at + push time or touches the CI/test harness, fixtures, toolchain or dependency set + (`scripts/ci/select-test-lane.sh`). Add `ci:full` before pushing when a change reaches shared + contracts in ways an import graph cannot see (subprocesses, files read as data, generated + output). Merge green pull requests one at a time; after roughly every five merges, run + `gh workflow run ci.yml --ref dev` (the default `lane=all`). If that run is red, stop merging, + fix `dev` from the batch it covers, and resume once it is green. Always run it before a + promotion to `main`. - A current maintainer with GitHub `maintain` or `admin` access may explicitly integrate a pull request into `dev` without another maintainer's approval, including their own pull request. Record that choice and the exact-head verification in the pull-request description or comment. diff --git a/scripts/ci/run-changed-tests.sh b/scripts/ci/run-changed-tests.sh new file mode 100755 index 00000000000..9a54d3a5db9 --- /dev/null +++ b/scripts/ci/run-changed-tests.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Pull-request fast lane (`test changed` in ci.yml). +# +# Runs only the test files Bun's parsed import graph reaches from this pull +# request's diff, against the merge commit's first parent (the base tip). The +# four full Ubuntu shards are skipped on such a pull request; the manual dev +# dispatch after each merged batch (MAINTAINERS.md "Batched merges") and the +# nightly schedule are the backstop for dependencies the graph cannot follow. +# +# A diff that reaches no test file is not a failure here: `gates` already +# typechecked and linted it, and scripts/test.ts reports that case as an error +# only so a local run does not mistake "nothing selected" for "tested". +set -euo pipefail + +# One runner instead of four shards: allow a wide selection up to 25 minutes. +export OCX_TEST_MAIN_TIMEOUT_MS="${OCX_TEST_MAIN_TIMEOUT_MS:-1500000}" + +base="$(git rev-parse --verify --quiet 'HEAD^1^{commit}' || true)" +if [ -z "$base" ]; then + echo "::error::run-changed-tests.sh needs the merge commit's first parent; check out with fetch-depth: 2." >&2 + exit 1 +fi +echo "changed selection against ${base}" + +log="$(mktemp)" +trap 'rm -f "$log"' EXIT + +# storage-policy and api-usage keep their own fresh-process jobs (see ci.yml); +# keep them out of this shared pool for the same reason the shards do. +set +e +bun scripts/test.ts "--changed=${base}" \ + --path-ignore-patterns='**/api-storage-policy*.test.ts' \ + --path-ignore-patterns='**/api-storage.test.ts' \ + --path-ignore-patterns='**/api-usage.test.ts' 2>&1 | tee "$log" +status="${PIPESTATUS[0]}" +set -e + +if [ "$status" -ne 0 ] && grep -Eq 'no test files are affected|selected 0 tests across 0 files|No tests found!' "$log" \ + && ! grep -Eq '^\s*[1-9][0-9]* fail\b' "$log"; then + echo "::notice::No test file imports a changed module. gates covered this pull request; the batched dev run is the backstop." + status=0 +fi + +# Read-as-data guards the import graph never selects. The file-size ratchet is +# the one that most often goes red only after two green pull requests merge. +if ! bun scripts/test.ts ./tests/ci-workflows/file-size-ratchet.test.ts; then + status=1 +fi + +exit "$status" diff --git a/scripts/ci/select-test-lane.sh b/scripts/ci/select-test-lane.sh new file mode 100755 index 00000000000..b25836cb55f --- /dev/null +++ b/scripts/ci/select-test-lane.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Choose a pull request's test lane (changes.outputs.fast in ci.yml). +# +# fast=true: the pull request runs `gates` plus `test changed` (the tests Bun's +# import graph reaches from the diff) instead of the four full Ubuntu shards. +# Merged batches are verified by a manual dev dispatch (MAINTAINERS.md +# "Batched merges"). fast=false keeps the four shards. It is false on every +# event other than pull_request, on an out-of-scope pull request, when the +# pull request carries the `ci:full` label, and when the diff touches an input +# the import graph cannot follow (the harness itself, golden fixtures read as +# data, the toolchain or the dependency set). +# +# Inputs (environment): EVENT_NAME, CI_SCOPE (true|false), FULL_LABEL +# (true|false), GITHUB_OUTPUT. The diff is treated strictly as data. +# Failure contract: invalid inputs or a failing git diff fail the job; a +# missing base parent cannot be scanned, so it selects the full lane. +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +event="${EVENT_NAME:-}" + +emit() { + printf 'fast=%s\n' "$1" >> "$GITHUB_OUTPUT" + printf 'test lane: %s (%s)\n' "$([ "$1" = true ] && echo fast || echo full)" "$2" + if [ "$event" = "pull_request" ] && [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + if [ "$1" = "true" ]; then + printf '**Fast test lane**: gates + `test changed`. Add the `ci:full` label and push to run the four full shards.\n' >> "$GITHUB_STEP_SUMMARY" + else + printf '**Full test lane** (%s).\n' "$2" >> "$GITHUB_STEP_SUMMARY" + fi + fi +} + +if [ "$event" != "pull_request" ]; then + emit false "event ${event:-unset} keeps the full suite" + exit 0 +fi + +for pair in "CI_SCOPE=${CI_SCOPE:-}" "FULL_LABEL=${FULL_LABEL:-}"; do + case "${pair#*=}" in + true|false) ;; + *) + printf '::error::%s was %q, expected true or false\n' "${pair%%=*}" "${pair#*=}" + exit 1 + ;; + esac +done + +if [ "$CI_SCOPE" != "true" ]; then + emit false "out of the ci scope" + exit 0 +fi +if [ "$FULL_LABEL" = "true" ]; then + emit false "ci:full label" + exit 0 +fi +if ! git rev-parse --verify --quiet 'HEAD^1^{commit}' >/dev/null; then + emit false "no base parent to diff" + exit 0 +fi + +changed="$(mktemp)" +trap 'rm -f "$changed"' EXIT +git diff --name-only 'HEAD^1' HEAD -- > "$changed" + +# Anchored extended regexes over repository-relative paths. +full_lane_paths='^(\.github/(workflows|actions|scripts)/|scripts/ci/|scripts/test(\.ts$|-[^/]*\.ts$)|tests/preload\.ts$|tests/fixtures/|bunfig\.toml$|tsconfig\.json$|package\.json$|bun\.lock$|gui/package\.json$|gui/bun\.lock$)' +status=0 +grep -Eq "$full_lane_paths" "$changed" || status=$? +case "$status" in + 0) + emit false "harness, fixture, toolchain or dependency change" + exit 0 + ;; + 1) ;; + *) + echo "::error::grep failed while selecting the test lane (exit $status)" >&2 + exit 1 + ;; +esac + +emit true "import-graph selection" diff --git a/tests/ci-workflows/ci-review-lanes.test.ts b/tests/ci-workflows/ci-review-lanes.test.ts index 09fc9956ad2..9b8eccfe05f 100644 --- a/tests/ci-workflows/ci-review-lanes.test.ts +++ b/tests/ci-workflows/ci-review-lanes.test.ts @@ -166,11 +166,21 @@ describe("CI review lanes", () => { const scopedCondition = "github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'"; // platform-macos is native-gated and pinned to the native condition above, // so it must not be read against this ci-only condition. - for (const jobName of ["test", "storage-policy", "gates"]) { + for (const jobName of ["storage-policy", "gates"]) { const job = ci.jobs?.[jobName] as { needs?: string; if?: string } | undefined; expect(`${jobName}:${job?.needs}`).toBe(`${jobName}:changes`); expect(`${jobName}:${job?.if}`).toBe(`${jobName}:${scopedCondition}`); } + // The pull-request fast lane swaps the four full shards for `test changed`; every + // other event keeps the shards, and `fast` is only ever true on a pull request. + const shards = ci.jobs?.test as { needs?: string; if?: string } | undefined; + expect(shards?.needs).toBe("changes"); + expect(shards?.if).toBe("github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.fast != 'true')"); + const changedTests = ci.jobs?.["test-changed"] as { needs?: string; if?: string } | undefined; + expect(changedTests?.needs).toBe("changes"); + expect(changedTests?.if).toBe("github.event_name == 'pull_request' && needs.changes.outputs.fast == 'true'"); + expect((ci.jobs?.changes as { outputs?: Record } | undefined)?.outputs?.fast) + .toBe("${{ steps.lane.outputs.fast }}"); // The two smoke jobs keep the ci scope and, on a pull request only, also need their // own narrow filter. Every other event still requests them unconditionally. for (const [jobName, output] of [["keyring-smoke", "keyring"], ["docker-smoke", "docker"]] as const) { @@ -229,6 +239,8 @@ describe("CI review lanes", () => { for (const job of ["test", "gates", "storage-policy", "api-usage"]) { expect(enabled(job, event, lane, "true", "true", "true", "false", "false")).toBe(true); } + // Without the fast lane `test changed` never runs; the shards cover every event. + expect(enabled("test-changed", event, lane)).toBe(false); // macOS jobs never run on a pull request; they run after merge, on push. for (const job of ["platform-macos", "widget"]) { for (const scope of ["true", "false"]) { @@ -285,6 +297,8 @@ describe("CI review lanes", () => { (aggregate.needs ?? []).map(job => [job, { result: "success" }]), ); results["platform-windows"] = { result: "skipped" }; + // Only a fast-lane pull request runs `test changed`; a dispatch keeps the full shards. + results["test-changed"] = { result: "skipped" }; results["macos-control"] = { result: "skipped" }; results["docs-site-build"] = { result: "skipped" }; results["structure-gate"] = { result: "skipped" }; @@ -379,7 +393,7 @@ describe("Windows and nightly CI contracts", () => { checkouts += 1; } } - expect(checkouts).toBe(17); + expect(checkouts).toBe(18); }); test.skipIf(process.platform === "win32" || !Bun.which("jq"))("aggregate never requests Windows on a pull request and requests both diagnostic jobs nightly", async () => { diff --git a/tests/ci-workflows/ci-scope-gaps.test.ts b/tests/ci-workflows/ci-scope-gaps.test.ts index 64d74100a1e..75a5e1aef72 100644 --- a/tests/ci-workflows/ci-scope-gaps.test.ts +++ b/tests/ci-workflows/ci-scope-gaps.test.ts @@ -91,7 +91,7 @@ describe("an edit to the setup action alone", () => { // Keep runtime and test-runner role selection distinct even when package.json pins both to 1.4.4. // Check the active setup at each test command, so a later role switch cannot evade the guard. const runsBunTests = (step: Step): boolean => - /\bbun\s+test\b|\bbun\s+run\s+test(?::changed)?(?:\s|$)|\bbun\s+(?:run\s+)?scripts\/test\.ts\b|scripts\/ci\/run-bun-test-batches\.sh/.test( + /\bbun\s+test\b|\bbun\s+run\s+test(?::changed)?(?:\s|$)|\bbun\s+(?:run\s+)?scripts\/test\.ts\b|scripts\/ci\/run-(?:bun|changed)-tests?(?:-batches)?\.sh/.test( (step.run ?? "").split("\n").filter(line => !line.trimStart().startsWith("#")).join("\n"), ); const workflowJobs = new Map(readdirSync(repoPath(".github", "workflows")) diff --git a/tests/ci-workflows/ci-scope-reduction.test.ts b/tests/ci-workflows/ci-scope-reduction.test.ts index da389e9544f..6a8c9741f4b 100644 --- a/tests/ci-workflows/ci-scope-reduction.test.ts +++ b/tests/ci-workflows/ci-scope-reduction.test.ts @@ -248,6 +248,7 @@ type GateInputs = { lane: string; docker?: string; keyring?: string; + fast?: string; }; const PR_WITHOUT_NATIVE: GateInputs = { @@ -273,6 +274,7 @@ function gateEnvironment(inputs: GateInputs, results: Record): R CHANGES_STRUCTURE: inputs.structure, CHANGES_DOCKER: inputs.docker ?? "false", CHANGES_KEYRING: inputs.keyring ?? "false", + ...(inputs.fast === undefined ? {} : { CHANGES_FAST: inputs.fast }), LANE: inputs.lane, // `needs` serializes as an object per job, and the gate reads // `.value.result` out of it. Passing a flat name-to-string map makes jq @@ -489,6 +491,34 @@ describe("the aggregate gate", () => { expect(bad).toBe(""); }); + test.skipIf(cannotRunGate)("swaps the full shards for test changed only on a fast-lane pull request", () => { + const fast = runGate({ ...PR_WITHOUT_NATIVE, fast: "true" }, {}).expectations; + expect([fast.test, fast["test-changed"]]).toEqual(["not-requested", "requested"]); + for (const name of ["storage-policy", "api-usage", "gates"]) { + expect(`${name}:${fast[name]}`).toBe(`${name}:requested`); + } + // A full-lane pull request, and every other event, keeps the shards and never runs test changed. + for (const inputs of [ + { ...PR_WITHOUT_NATIVE, fast: "false" }, + PR_WITHOUT_NATIVE, + { ...PR_WITHOUT_NATIVE, eventName: "push", fast: "true" }, + { ...PR_WITHOUT_NATIVE, eventName: "workflow_dispatch", fast: "true" }, + ]) { + const other = runGate(inputs, {}).expectations; + expect(`${inputs.eventName}/${inputs.fast}:${other.test}:${other["test-changed"]}`) + .toBe(`${inputs.eventName}/${inputs.fast}:requested:not-requested`); + } + // An out-of-scope pull request requests neither, even if fast were set. + const outOfScope = runGate({ ...PR_WITHOUT_NATIVE, ci: "false", fast: "true" }, {}).expectations; + expect([outOfScope.test, outOfScope["test-changed"]]).toEqual(["not-requested", "not-requested"]); + // Results consistent with the fast lane pass; a shard that ran anyway fails by name. + const consistent = Object.fromEntries(Object.keys(jobs) + .filter(name => name !== "ci") + .map(name => [name, fast[name] === "requested" ? "success" : "skipped"])); + expect(runGate({ ...PR_WITHOUT_NATIVE, fast: "true" }, consistent).bad).toBe(""); + expect(runGate({ ...PR_WITHOUT_NATIVE, fast: "true" }, { ...consistent, test: "success" }).bad).toContain("test was not requested"); + }); + test.skipIf(cannotRunGate)("never requests macOS on a native pull request, but still requests desktop-shell", () => { const { expectations } = runGate({ ...PR_WITHOUT_NATIVE, native: "true" }, {}); for (const name of MACOS_JOBS) expect(`${name}:${expectations[name]}`).toBe(`${name}:not-requested`); diff --git a/tests/ci-workflows/ci-test-lane.test.ts b/tests/ci-workflows/ci-test-lane.test.ts new file mode 100644 index 00000000000..f81307d76ec --- /dev/null +++ b/tests/ci-workflows/ci-test-lane.test.ts @@ -0,0 +1,116 @@ +/** + * The pull-request test lane (scripts/ci/select-test-lane.sh). + * + * A fast-lane pull request runs `gates` plus `test changed` instead of the four + * full Ubuntu shards. The selector must keep the full lane for every other + * event, an out-of-scope pull request, the `ci:full` label, and any diff that + * touches an input the import graph cannot follow. + */ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; + +type Scenario = { + path?: string; + event?: string; + scope?: string; + label?: string; + orphan?: boolean; + expected?: "true" | "false"; + status?: number; +}; + +function select(scenario: Scenario): { status: number | null; fast: string | undefined; stderr: string } { + const directory = mkdtempSync(join(tmpdir(), "ocx-test-lane-")); + try { + const git = (...args: string[]) => { + const result = spawnSync("git", args, { + cwd: directory, encoding: "utf8", timeout: 5_000, + env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null" }, + }); + expect(result.status, result.stderr).toBe(0); + }; + const commit = () => { + git("add", "."); + git("-c", "user.name=CI Fixture", "-c", "user.email=test@example.test", "-c", "commit.gpgsign=false", + "-c", "core.hooksPath=/dev/null", "commit", "--quiet", "--allow-empty", "-m", "fixture"); + }; + git("init", "--quiet"); + writeFileSync(join(directory, "seed.txt"), "seed\n"); + commit(); + if (!scenario.orphan) { + const path = join(directory, scenario.path ?? "src/example.ts"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "changed\n"); + commit(); + } + const output = join(directory, "output"); + writeFileSync(output, ""); + const result = spawnSync("bash", [repoPath("scripts", "ci", "select-test-lane.sh")], { + cwd: directory, encoding: "utf8", timeout: 5_000, + env: { + ...process.env, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: "", + EVENT_NAME: scenario.event ?? "pull_request", CI_SCOPE: scenario.scope ?? "true", FULL_LABEL: scenario.label ?? "false", + }, + }); + const fast = /^fast=(.*)$/m.exec(readFileSync(output, "utf8"))?.[1]; + return { status: result.status, fast, stderr: result.stderr }; + } finally { + removeTreeWithRetry(directory); + } +} + +describe.skipIf(process.platform === "win32")("pull-request test lane", () => { + test("an ordinary source or test change takes the fast lane", () => { + for (const path of ["src/router.ts", "tests/server/example.test.ts", "gui/src/App.tsx", "docs/notes.md"]) { + expect(`${path}:${select({ path }).fast}`).toBe(`${path}:true`); + } + }); + + test("harness, fixture, toolchain and dependency changes keep the full shards", () => { + for (const path of [ + ".github/workflows/ci.yml", ".github/actions/setup-project-bun/action.yml", ".github/scripts/x.mjs", + "scripts/ci/run-changed-tests.sh", "scripts/test.ts", "scripts/test-durations.ts", + "tests/preload.ts", "tests/fixtures/file-size-baseline.json", "bunfig.toml", "tsconfig.json", + "package.json", "bun.lock", "gui/package.json", "gui/bun.lock", + ]) { + expect(`${path}:${select({ path }).fast}`).toBe(`${path}:false`); + } + }); + + test("the label, the scope, other events and a missing parent keep the full shards", () => { + expect(select({ label: "true" }).fast).toBe("false"); + expect(select({ scope: "false" }).fast).toBe("false"); + expect(select({ orphan: true }).fast).toBe("false"); + for (const event of ["push", "workflow_dispatch", "schedule"]) { + expect(`${event}:${select({ event, scope: "invalid", label: "invalid" }).fast}`).toBe(`${event}:false`); + } + }); + + test("a malformed pull-request input fails the job instead of choosing a lane", () => { + for (const scenario of [{ scope: "" }, { label: "yes" }]) { + const result = select(scenario); + expect(result.status).toBe(1); + expect(result.fast).toBeUndefined(); + } + }); + + test("ci.yml wires the selector and the fast-lane job", () => { + const ci = Bun.YAML.parse(readFileSync(repoPath(".github", "workflows", "ci.yml"), "utf8")) as { + jobs: Record }> }>; + }; + const lane = ci.jobs.changes?.steps?.find(step => step.id === "lane"); + expect(lane?.run).toBe("bash scripts/ci/select-test-lane.sh"); + expect(lane?.env).toEqual({ + EVENT_NAME: "${{ github.event_name }}", + CI_SCOPE: "${{ steps.scope.outputs.ci }}", + FULL_LABEL: "${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }}", + }); + const run = ci.jobs["test-changed"]?.steps?.map(step => step.run ?? "").join("\n") ?? ""; + expect(run).toContain("bash scripts/ci/run-changed-tests.sh"); + }); +});