From 33e7b1d00d5728e96b745eaa3d8a21feb97a1669 Mon Sep 17 00:00:00 2001 From: Kuddev Date: Sat, 26 Sep 2026 13:25:29 +0800 Subject: [PATCH] ci: speed up full cross-platform PR validation (#296) Use pinned nextest for full native tests while retaining doctests, production-feature checks and both macOS release checks. Publish shared dependency snapshots from successful default-branch runs, keep compiled artifacts architecture-specific, and preserve cross-process exclusion for shared theme fixtures. Run the full platform matrix for both draft and ready pull requests without repeating it for readiness-only transitions. --- .config/nextest.toml | 8 ++ .github/actions/rust-cache/action.yml | 47 +++++-- .github/workflows/linux-lua.yml | 28 ++-- CONTRIBUTING.md | 21 +-- .../ci/2026-09-23-native-matrix-planning.md | 2 + .../ci/2026-09-25-shared-native-pr-cache.md | 122 ++++++++++++++++++ scripts/ci_native_tests.py | 31 ++++- scripts/ci_plan.py | 29 ++--- scripts/tests/test_ci_cache.py | 35 ++++- scripts/tests/test_ci_native_tests.py | 116 +++++++++++++---- scripts/tests/test_ci_plan.py | 13 +- 11 files changed, 368 insertions(+), 84 deletions(-) create mode 100644 .config/nextest.toml create mode 100644 architecture/notes/scripts/ci/2026-09-25-shared-native-pr-cache.md diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..51b7cc3 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,8 @@ +# 这些现有夹具共享真实设置文件;进程内 Mutex 在 nextest 多进程模型下不再互斥。 +# 只还原该夹具组的串行合同,其余测试继续使用 runner 的并行度。 +[test-groups] +theme-studio = { max-threads = 1 } + +[[profile.default.overrides]] +filter = 'test(gpui_shell::settings_pane::theme_studio_tests::)' +test-group = 'theme-studio' diff --git a/.github/actions/rust-cache/action.yml b/.github/actions/rust-cache/action.yml index 3415bd8..2bd1d34 100644 --- a/.github/actions/rust-cache/action.yml +++ b/.github/actions/rust-cache/action.yml @@ -14,6 +14,12 @@ inputs: extra-key: description: Additional inputs for native build tools stored with this target default: "" + revision: + description: Snapshot suffix; empty uses the source SHA, dependency snapshots use a fixed generation + default: "" + save-if: + description: Allow publishing caches; PR consumers can restore without competing with the default branch + default: "true" outputs: cache-key: @@ -25,6 +31,9 @@ outputs: cache-matched-key: description: The compiled target cache key actually restored by actions/cache value: ${{ steps.target.outputs.cache-matched-key }} + save-enabled: + description: Whether the caller permits publishing cache snapshots + value: ${{ inputs.save-if }} legacy-fallback-invoked: description: Whether the optional legacy cache fallback action was invoked value: ${{ steps.cache-summary.outputs.legacy-fallback-invoked }} @@ -42,7 +51,7 @@ runs: CACHE_OS: ${{ runner.os }} CACHE_ARCH: ${{ runner.arch }} CACHE_WORKLOAD: ${{ inputs.key }} - CACHE_REVISION: ${{ github.sha }} + CACHE_REVISION: ${{ inputs.revision || github.sha }} CACHE_MANIFESTS: ${{ hashFiles('Cargo.lock', '**/Cargo.toml', 'rust-toolchain.toml', '**/.cargo/config.toml', '.github/ci-profile.toml') }}${{ inputs.extra-key }} run: | python - <<'PY' @@ -66,8 +75,8 @@ runs: ("CACHE_OS", "CACHE_ARCH", "CACHE_WORKLOAD")) prefix += "-" + fingerprint + "-" with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: - # Immutable caches need a new entry after a source change, including - # when the prior revision failed before it compiled every profile. + # 发布任务按源码刷新;原生 CI 可固定代次,只在依赖或工具链变化后新建快照。 + # 缓存命中只复用编译输入,调用方仍须执行当前提交的全部检查。 output.write("key=" + prefix + os.environ["CACHE_MANIFESTS"] + "-" + os.environ["CACHE_REVISION"] + "\n") output.write("manifest-key=" + prefix + os.environ["CACHE_MANIFESTS"] + "\n") output.write("restore-key=" + prefix + "\n") @@ -92,8 +101,11 @@ runs: ${{ steps.identity.outputs.cargo-home }}/registry/cache ${{ steps.identity.outputs.cargo-home }}/git/db ${{ steps.identity.outputs.cargo-home }}/git/checkouts - key: cargo-downloads-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock', '**/Cargo.toml') }} - restore-keys: cargo-downloads-v2-${{ runner.os }}-${{ runner.arch }}- + # 同一 OS 的两种架构共享源码,编译产物仍按架构隔离。 + key: cargo-downloads-v3-${{ runner.os }}-${{ hashFiles('Cargo.lock', '**/Cargo.toml') }} + restore-keys: | + cargo-downloads-v3-${{ runner.os }}- + cargo-downloads-v2-${{ runner.os }}-${{ runner.arch }}- - name: Restore compiled targets id: target uses: actions/cache/restore@v4 @@ -127,6 +139,7 @@ runs: CACHE_WORKLOAD: ${{ inputs.key }} RUNNER_OS_VALUE: ${{ runner.os }} RUNNER_ARCH_VALUE: ${{ runner.arch }} + CACHE_SAVE_ENABLED: ${{ inputs.save-if }} run: | set -euo pipefail @@ -148,6 +161,7 @@ runs: echo "- Target cache exact hit: \`$target_cache_hit\`" echo "- Target cache matched key: \`$target_cache_matched_key\`" echo "- Save key: \`$save_key\`" + echo "- Cache publishing enabled: \`$CACHE_SAVE_ENABLED\`" echo "- Legacy fallback configured: \`$legacy_configured\`" echo "- Legacy fallback invoked: \`$legacy_invoked\`" echo "- Legacy fallback outcome: \`$legacy_outcome\`" @@ -159,14 +173,27 @@ runs: echo "legacy-fallback-outcome=$legacy_outcome" } >> "$GITHUB_OUTPUT" - name: Populate complete downloads before sharing the cache - if: steps.downloads.outputs.cache-hit != 'true' + if: inputs.save-if == 'true' && steps.downloads.outputs.cache-hit != 'true' shell: bash + env: + CACHE_OS: ${{ runner.os }} run: | - host_target="$(rustc -vV | sed -n 's/^host: //p')" - test -n "$host_target" - cargo fetch --locked --target "$host_target" + # 缓存由先完成的架构写入,必须预取两者的源码,不能把半份下载标成精确命中。 + case "$CACHE_OS" in + Windows) + cargo fetch --locked --target x86_64-pc-windows-msvc --target aarch64-pc-windows-msvc + ;; + macOS) + cargo fetch --locked --target x86_64-apple-darwin --target aarch64-apple-darwin + ;; + *) + host_target="$(rustc -vV | sed -n 's/^host: //p')" + test -n "$host_target" + cargo fetch --locked --target "$host_target" + ;; + esac - name: Save complete shared Cargo downloads - if: steps.downloads.outputs.cache-hit != 'true' + if: inputs.save-if == 'true' && steps.downloads.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 8205936..2d70f4d 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -11,14 +11,15 @@ on: push: branches: [main] # Include docs, catalogs, assets and workflow edits: every PR gets test results. - # ready_for_review starts the scarce-runner jobs that draft pushes skip. + # 草稿已跑完整矩阵,转为 Ready 而没有新提交时不重复申请七个原生 runner。 pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened] merge_group: concurrency: group: native-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} + # main 的过期验证也应释放 runner;手动诊断和发布调用不互相取消。 + cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} permissions: contents: read @@ -75,9 +76,7 @@ jobs: --output "$GITHUB_OUTPUT" # The required lint job validates the matrix before heavy runners are - # requested. Draft PRs omit deferred platforms entirely; ready PRs and - # release callers require the full matrix. Deferred checks remain missing - # on drafts instead of being reported as successful tests. + # requested. Every PR, including a draft, requires the complete matrix. native-tests: needs: lint if: ${{ inputs.windows_diagnostics_run == '' }} @@ -131,6 +130,10 @@ jobs: if ($LASTEXITCODE -ne 0 -or $compiler -notcontains 'host: aarch64-pc-windows-msvc') { throw 'The Windows ARM job must compile and run ARM64 tests.' } + - name: Install the pinned native test runner + uses: taiki-e/install-action@v2 + with: + tool: cargo-nextest@0.9.146 - uses: ./.github/actions/rust-cache id: rust-cache with: @@ -139,7 +142,9 @@ jobs: target: | target/ci ${{ runner.os != 'macOS' && 'target/release' || '' }} - legacy-key: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'Linux' && 'linux' || 'macos' }} + # PR merge-ref 缓存不与其他 PR 共享;只由默认分支维护可复用的依赖快照。 + revision: dependencies-v1 + save-if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} - name: Prepare pinned Windows console runtime if: ${{ runner.os == 'Windows' }} shell: pwsh @@ -148,7 +153,7 @@ jobs: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost -Architecture $architecture ./scripts/tests/windows-runtime.tests.ps1 -Architecture $architecture - name: Test complete workspace and native harness - run: python scripts/ci_native_tests.py + run: python scripts/ci_native_tests.py --runner nextest - name: Check source architecture if: ${{ !cancelled() && runner.os == 'Linux' }} run: python scripts/check_architecture.py @@ -159,7 +164,7 @@ jobs: if: ${{ !cancelled() && runner.os != 'macOS' }} run: cargo check --locked --workspace --release - name: Save compiled workload - if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }} + if: ${{ success() && steps.rust-cache.outputs.save-enabled == 'true' && steps.rust-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 with: path: | @@ -214,11 +219,12 @@ jobs: with: key: native-release-check target: target/release - legacy-key: macos + revision: dependencies-v1 + save-if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} - name: Check the release workspace run: cargo check --locked --workspace --release --timings - name: Save compiled workload - if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }} + if: ${{ success() && steps.rust-cache.outputs.save-enabled == 'true' && steps.rust-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 with: path: target/release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2547b2..6ef5132 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -130,13 +130,18 @@ compile check into a claim that UI tests or a packaged application were run. `Tests ()` jobs and both `Release workspace ()` jobs are required checks on `main`, together with Code Owner approval; see the [activation checklist](docs/project-constraints.md#server-side-activation). -Open pull requests as drafts while iterating: drafts run Linux, Windows x64 and -Apple Silicon tests plus the Apple Silicon release-profile compile check. The -required lint job validates the event and selects the matrix before requesting -platform runners. Drafts omit Intel Mac and Windows ARM64 jobs entirely, so their -required contexts may be missing until the PR is marked ready. Ready PRs run the -full matrix; every required check must succeed before merging. The ready event -still reruns core validation rather than reusing an earlier check conclusion. +Draft and ready pull requests both run all five native platforms and both macOS +release-profile compile checks. The required lint job validates the event and +selects the matrix before requesting platform runners. Every required check must +succeed before merging. Marking a draft ready without changing its commits does +not repeat the matrix; source updates and reopen events run it again. Native CI +uses pinned `cargo-nextest` for unit and +integration tests, followed by `cargo test --doc` with the same workspace features; +the production feature graph and release workspace are still checked separately. +PRs restore Cargo caches without uploading merge-ref snapshots. The default branch +publishes reusable snapshots per dependency/toolchain configuration; a cache hit +never skips current-commit tests. Downloads are shared between architectures of +the same OS, while compiled targets remain isolated by architecture and SDK. Local hooks are convenient, but bypassable; they are not the enforcement boundary. Submitting a workflow or `CODEOWNERS` file does not configure server-side rules. @@ -150,7 +155,7 @@ feature work; there is no routine `--skip-architecture` option. - 先读架构图、工程合同和决策记录;按职责拆分,不按行号切片。 - 一个 PR 只做一件事;改动超过 1500 行源码(不计文档、lockfile、资源)`pr-size` 会失败,请拆分。 -- Draft PR 先运行必需的格式检查和矩阵规划,再创建 Linux / Windows x64 / Apple Silicon 测试及 Apple Silicon release 编译检查;不创建 Intel Mac / Windows ARM64 任务。Ready 后运行完整矩阵,十项必需检查全绿才能合并。 +- Draft 和 Ready PR 都先运行必需的格式检查和矩阵规划,再执行五平台原生测试及两项 macOS release 编译检查;十项必需检查全绿才能合并。提速使用共享缓存与并行测试,不省略平台或 doctest。 - 2000 行是现有仓库的防灾上限,800 行只提示审查,不是“大厂标准”。 - 普通功能 PR 不得增加存量债务;有问题的规则可以修订,但要有反例、测试和维护者审批。 - 新增核心抽象、依赖方向、持久化或线程模型改变要先说明设计,不强迫每个小修复写 ADR。 diff --git a/architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md b/architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md index dfe22fd..5fc1d97 100644 --- a/architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md +++ b/architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md @@ -1,5 +1,7 @@ # Plan the native matrix before requesting runners +Draft coverage policy superseded by [shared native PR caches](2026-09-25-shared-native-pr-cache.md). + ## Status Implemented for review. Preserves the existing ten required check names and diff --git a/architecture/notes/scripts/ci/2026-09-25-shared-native-pr-cache.md b/architecture/notes/scripts/ci/2026-09-25-shared-native-pr-cache.md new file mode 100644 index 0000000..8c827f7 --- /dev/null +++ b/architecture/notes/scripts/ci/2026-09-25-shared-native-pr-cache.md @@ -0,0 +1,122 @@ +# Speed up native PR checks without dropping platform coverage + +## Status + +Implemented for review. The five native check names and two macOS release-check +names are unchanged. Hosted-runner measurements are pending the independent PR; +this change does not modify GitHub rulesets or claim a fixed completion time. + +## Context + +PR speed must improve without omitting native architectures, doctests, production +features or release-profile compilation. Draft PRs now have the same coverage as +ready PRs. Existing test assertions and failure propagation remain in force. + +## Evidence + +In [run 36119856851](https://github.com/Kuddev/pebrel/actions/runs/36119856851), +the Intel Mac job took 23m55s, including 12m59s of test compilation after both +cache layers missed. In +[run 36084876307](https://github.com/Kuddev/pebrel/actions/runs/36084876307), its +queue alone took 109m24s. Queue time and test execution are different bottlenecks. + +Inspected native target snapshots were approximately 1–1.37 GB. Saving a new +snapshot for every PR commit consumes repository storage even though a sibling +PR cannot restore that merge-ref cache. GitHub documents this scope boundary and +the default cache limit in its +[dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching). +Default-branch caches can be restored by PRs. + +Nextest 0.9.146 publishes native Windows ARM64 and x64 binaries and a universal +macOS binary. Its CLI supports the current Cargo profile and timing options. +[Nextest](https://nexte.st/docs/running/) runs unit/integration tests separately +but does not replace Cargo's doctest execution. + +The first hosted nextest run, [Linux job 108135574634](https://github.com/Kuddev/pebrel/actions/runs/36154433909/job/108135574634), +ran 2,245 tests and exposed six theme-studio fixture conflicts. Those tests already +share `lock_theme_studio` because they read and restore the same settings file. +An in-process mutex does not protect separate nextest processes. Nextest's +[test groups](https://nexte.st/docs/configuration/test-groups/) provide the +corresponding cross-process scheduling boundary. + +## Decision + +- The existing validated platform catalog selects all five native platforms and + both macOS release checks for draft and ready PRs. Keep the lint prerequisite, + unfiltered PR triggers, native Windows ARM compiler assertion and SDK checks. + Readiness alone no longer reruns the matrix: its commit already receives full + coverage on `opened`/`synchronize`, and `reopened` still revalidates the PR. +- Native CI installs pinned nextest, runs the complete workspace without test + filters or automatic retries, and explicitly runs `cargo test --doc` with the + same profile/features. Keep the independent production-feature check. The + script's default remains Cargo for existing local and release callers. +- Give the entire existing theme-studio fixture module one nextest group with + `max-threads = 1`, including readers and writers. This restores its previous + mutual exclusion without serializing other tests or changing assertions. +- Native jobs restore caches on PRs but publish only on the default branch. + Use a fixed `dependencies-v1` snapshot suffix together with the existing + compiler, OS, architecture, workload, SDK, flags and manifest identity. Source + changes still run Cargo and all tests; cache hits never substitute results. +- Save the immutable native snapshot only after successful checks. A failed + first attempt must not permanently publish an incomplete snapshot under the + fixed key. Dependency/toolchain changes create new keys; deliberate generation + changes can refresh an otherwise unchanged snapshot. +- Share Cargo downloads between architectures of the same OS. Before publishing, + fetch both supported Windows or macOS targets. Keep compiled targets separate. + Read-only PR consumers fetch only what their actual commands require. Restore + old per-architecture downloads during the cache transition. +- Cancel obsolete main validation runs as well as obsolete PR/merge-group runs. + Do not add more macOS runner jobs or change required status contexts. + +## Rejected alternatives + +- Omit draft, docs-only or platform-specific PR tests: violates the required + cross-platform coverage. +- Drop doctests or the production feature graph when adopting nextest: they + cover contracts not replaced by its unit/integration test execution. +- Remove macOS release checks or fold them back into the long native test job: + they remain required, and prior execution measurements motivated parallelism. +- Cache a new full target directory under every source SHA: duplicates large + snapshots while merge-ref scope prevents reuse by other PRs. +- Add sccache and incremental compilation together: sccache's Rust cache requires + incremental compilation to be disabled and does not cache executable linking. + This change leaves the existing tested compiler profile intact. +- Retry failing tests until green or copy earlier check conclusions: obscures + current-commit failures rather than making validation more efficient. + +## Consequences + +There are still seven native runner jobs for every PR. Drafts request more jobs +than the superseded policy, not fewer. Shared caches and parallel test execution +target avoidable work; hosted-runner capacity still determines queue latency. + +The first successful default-branch run seeds each new snapshot. A dependency +change can still cause a cold build. Release/package callers retain SHA-based +target snapshots; this PR does not change their build or publication semantics. + +## Validation + +The existing planner, native-workflow, cache, stable-release, platform-cfg and +PR-size contract suites ran 58 tests locally: 57 passed, one POSIX-only summary +execution test was skipped on Windows. Tests cover full draft/ready matrices, +read-only PR cache behavior, cross-architecture download completeness, retained +doctests/features and failure propagation at each command. + +Actionlint 1.7.12 passed for the native workflow. The architecture check passed +against the PR base without policy/budget changes. Nextest ran all 71 +`nebula-settings` tests successfully on Windows; its discovered test names match +Cargo's inventory exactly. Rustfmt and the subset's doctest command also passed +(that subset has zero doctests). Full native/GPUI execution, +cache restore/save behavior and cold/warm timing comparisons are validated by +the hosted PR runs, not inferred from the local subset. + +## Supersedes + +The draft coverage reduction in +[native matrix planning](2026-09-23-native-matrix-planning.md). Its validated +event planning and required lint ownership remain unchanged. + +## Revisit when + +Measured cache hit rates, archive transfer costs, nextest process overhead or +runner queue times justify a different bounded cache or test scheduling policy. diff --git a/scripts/ci_native_tests.py b/scripts/ci_native_tests.py index e68b682..2b3ac21 100644 --- a/scripts/ci_native_tests.py +++ b/scripts/ci_native_tests.py @@ -3,19 +3,32 @@ from __future__ import annotations +import argparse import subprocess import sys +from typing import Sequence -def native_commands() -> list[list[str]]: +def native_commands(runner: str = "cargo") -> list[list[str]]: profile = ["--config", ".github/ci-profile.toml", "--profile", "ci"] + workspace = ["--locked", "--workspace", "--features", "nebula/gpui-test-support", "--timings"] + if runner == "cargo": + tests = [["cargo", "test", *profile, *workspace]] + elif runner == "nextest": + tests = [ + [ + "cargo", "nextest", "run", "--config", ".github/ci-profile.toml", + "--cargo-profile", "ci", *workspace, "--no-fail-fast", "--retries", "0", + ], + # nextest 不执行 doctest;显式补跑,提速不改变 workspace 的覆盖范围。 + ["cargo", "test", *profile, *workspace, "--doc"], + ] + else: + raise ValueError(f"unknown test runner: {runner}") return [ [sys.executable, "-m", "unittest", "discover", "-s", "scripts/tests", "-v"], [sys.executable, "-m", "unittest", "discover", "-s", "scripts/conformance/tests", "-v"], - [ - "cargo", "test", "--locked", *profile, "--workspace", - "--features", "nebula/gpui-test-support", "--timings", - ], + *tests, # Link the test graph first: check can reuse compatible compiled # dependencies, while metadata-only check output cannot link the tests. # Keep the actual production feature graph independently checked. @@ -26,8 +39,12 @@ def native_commands() -> list[list[str]]: ] -def main() -> int: - for command in native_commands(): +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + # 发布脚本和本地入口保留 cargo 默认值,仅原生 CI 显式选择已安装的 nextest。 + parser.add_argument("--runner", choices=("cargo", "nextest"), default="cargo") + arguments = parser.parse_args(argv) + for command in native_commands(arguments.runner): print("Running:", " ".join(command), flush=True) subprocess.run(command, check=True) return 0 diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py index 4e6432f..7fbce87 100644 --- a/scripts/ci_plan.py +++ b/scripts/ci_plan.py @@ -1,9 +1,8 @@ #!/usr/bin/env python3 """Plan the native CI matrices before GitHub creates runner jobs. -The output file is the path normally supplied as ``GITHUB_OUTPUT``. Planning -is deliberately separate from workflow steps: a draft pull request therefore -does not materialize jobs for the scarce platforms. +The output file is the path normally supplied as ``GITHUB_OUTPUT``. Validate +the event before requesting runners; draft and ready PRs get the same coverage. """ from __future__ import annotations @@ -19,17 +18,16 @@ from typing import Any, Sequence @dataclass(frozen=True) class Platform: runner: str - draft: bool release_check: bool = False -# One platform catalog owns both draft eligibility and release compilation. +# 所有 PR 共用平台清单,避免提速时把草稿的原生覆盖悄悄省略。 PLATFORMS = ( - Platform("ubuntu-24.04", draft=True), - Platform("windows-2022", draft=True), - Platform("macos-26", draft=True, release_check=True), - Platform("windows-11-arm", draft=False), - Platform("macos-26-intel", draft=False, release_check=True), + Platform("ubuntu-24.04"), + Platform("windows-2022"), + Platform("macos-26", release_check=True), + Platform("windows-11-arm"), + Platform("macos-26-intel", release_check=True), ) FULL_EVENTS = frozenset( @@ -41,11 +39,10 @@ class PlanError(ValueError): """An event cannot be converted into a safe CI plan.""" -def _matrices(draft: bool) -> tuple[list[dict[str, str]], list[dict[str, str]]]: - selected = [platform for platform in PLATFORMS if not draft or platform.draft] +def _matrices() -> tuple[list[dict[str, str]], list[dict[str, str]]]: return ( - [{"os": platform.runner} for platform in selected], - [{"os": platform.runner} for platform in selected if platform.release_check], + [{"os": platform.runner} for platform in PLATFORMS], + [{"os": platform.runner} for platform in PLATFORMS if platform.release_check], ) @@ -79,10 +76,10 @@ def plan_matrices(event_name: str, payload: dict[str, Any]) -> tuple[list[dict[s draft = pull_request.get("draft") if not isinstance(draft, bool): raise PlanError("pull_request.draft must be a boolean") - return _matrices(draft) + return _matrices() if event_name in FULL_EVENTS: - return _matrices(draft=False) + return _matrices() supported = sorted(FULL_EVENTS | {"pull_request"}) raise PlanError(f"unsupported event name {event_name!r}; expected one of {', '.join(supported)}") diff --git a/scripts/tests/test_ci_cache.py b/scripts/tests/test_ci_cache.py index 764ea3d..99a083a 100644 --- a/scripts/tests/test_ci_cache.py +++ b/scripts/tests/test_ci_cache.py @@ -108,6 +108,7 @@ class CacheIdentityTests(unittest.TestCase): "Target cache exact hit", "Target cache matched key", "Save key", + "Cache publishing enabled", "Legacy fallback invoked", "Legacy fallback outcome", ): @@ -149,21 +150,51 @@ class CacheIdentityTests(unittest.TestCase): "GITHUB_OUTPUT": str(root / "outputs"), "TARGET_CACHE_HIT": hit, "TARGET_CACHE_MATCHED_KEY": matched, "SAVE_KEY": "new-save-key", "LEGACY_CONFIGURED": configured, + "CACHE_SAVE_ENABLED": "false", "LEGACY_OUTCOME": outcome, "CACHE_WORKLOAD": workload, "RUNNER_OS_VALUE": "Linux", "RUNNER_ARCH_VALUE": "X64", } result = subprocess.run([shutil.which("bash"), "-c", script], env=environment, text=True, capture_output=True, check=False) self.assertEqual(result.returncode, 0, result.stderr) - summary = (root / "summary").read_text() - outputs = (root / "outputs").read_text() + summary = (root / "summary").read_text(encoding="utf-8") + outputs = (root / "outputs").read_text(encoding="utf-8") self.assertIn(f"Target cache exact hit: `{hit or 'false'}`", summary) self.assertIn(f"Target cache matched key: `{matched or ''}`", summary) + self.assertIn("Cache publishing enabled: `false`", summary) self.assertIn(f"legacy-fallback-invoked={invoked}\n", outputs) self.assertIn(f"legacy-fallback-outcome={outcome}\n", outputs) self.assertIn(workload, summary) self.assertFalse(marker.exists(), "summary values must never execute as shell code") + def test_dependency_snapshot_revision_preserves_compatibility_boundaries(self): + source = self.action_text() + self.assertIn("CACHE_REVISION: ${{ inputs.revision || github.sha }}", source) + snapshot = self.identity(CACHE_REVISION="dependencies-v1") + changed = self.identity(CACHE_REVISION="dependencies-v1", CACHE_MANIFESTS="new-dependencies") + self.assertNotEqual(snapshot["key"], changed["key"]) + self.assertEqual(snapshot["restore-key"], changed["restore-key"]) + + def test_source_downloads_share_architectures_but_targets_do_not(self): + source = self.action_text() + self.assertIn("key: cargo-downloads-v3-${{ runner.os }}-${{ hashFiles('Cargo.lock', '**/Cargo.toml') }}", source) + self.assertIn("cargo-downloads-v2-${{ runner.os }}-${{ runner.arch }}-", source) + fetch = source.split("- name: Populate complete downloads before sharing the cache", 1)[1] + for targets in ( + "--target x86_64-pc-windows-msvc --target aarch64-pc-windows-msvc", + "--target x86_64-apple-darwin --target aarch64-apple-darwin", + ): + self.assertIn(f"cargo fetch --locked {targets}", fetch) + self.assertNotEqual(self.identity(CACHE_ARCH="X64")["restore-key"], + self.identity(CACHE_ARCH="ARM64")["restore-key"]) + + def test_read_only_consumers_neither_prefetch_other_architectures_nor_save(self): + source = self.action_text() + self.assertIn("value: ${{ inputs.save-if }}", source) + for step in ("Populate complete downloads before sharing the cache", "Save complete shared Cargo downloads"): + body = source.split(f"- name: {step}", 1)[1].split("\n - name:", 1)[0] + self.assertIn("if: inputs.save-if == 'true' && steps.downloads.outputs.cache-hit != 'true'", body) + @unittest.skipUnless(os.name == "nt", "Windows cache migration") def test_windows_migration_preserves_compiler_and_workload_boundaries(self): before = self.identity() diff --git a/scripts/tests/test_ci_native_tests.py b/scripts/tests/test_ci_native_tests.py index 0055e55..383f8a5 100644 --- a/scripts/tests/test_ci_native_tests.py +++ b/scripts/tests/test_ci_native_tests.py @@ -13,7 +13,7 @@ from scripts.ci_native_tests import main, native_commands class NativeSuiteTests(unittest.TestCase): def test_required_lint_plans_runners_before_native_jobs(self): root = Path(__file__).resolve().parents[2] - workflow = (root / ".github/workflows/linux-lua.yml").read_text() + workflow = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8") lint = workflow.split("\n lint:\n", 1)[1].split("\n native-tests:\n", 1)[0] self.assertIn("name: lint", lint) self.assertIn("Test CI contracts before matrix planning", lint) @@ -61,7 +61,7 @@ class NativeSuiteTests(unittest.TestCase): def test_packages_run_after_merge_or_manual_dispatch_not_for_prs(self): root = Path(__file__).resolve().parents[2] - workflow = (root / ".github/workflows/preview-packages.yml").read_text() + workflow = (root / ".github/workflows/preview-packages.yml").read_text(encoding="utf-8") events = workflow.split("\non:\n", 1)[1].split("\nconcurrency:", 1)[0] triggers = set(re.findall(r"^ ([a-z_]+):", events, re.M)) self.assertEqual(triggers, {"push", "workflow_dispatch"}) @@ -71,7 +71,7 @@ class NativeSuiteTests(unittest.TestCase): self.assertIn("paths:", push.group(1)) # Packaging still needs explicit dispatch to create a public release. self.assertIn("github.event_name == 'workflow_dispatch' && inputs.publish == true", workflow) - stable = (root / ".github/workflows/release.yml").read_text() + stable = (root / ".github/workflows/release.yml").read_text(encoding="utf-8") stable_events = stable.split("\non:\n", 1)[1].split("\nconcurrency:", 1)[0] self.assertEqual(set(re.findall(r"^ ([a-z_]+):", stable_events, re.M)), {"push", "workflow_dispatch"}) @@ -79,7 +79,7 @@ class NativeSuiteTests(unittest.TestCase): def test_every_pr_and_merge_group_runs_without_path_exclusions(self): root = Path(__file__).resolve().parents[2] - workflow = (root / ".github/workflows/linux-lua.yml").read_text() + workflow = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8") events = workflow.split("\non:\n", 1)[1].split("\nconcurrency:", 1)[0] for event in ("pull_request", "merge_group"): declaration = re.search(rf"^ {event}:(.*?)(?=^ [a-z_]+:|\Z)", events, re.M | re.S) @@ -87,9 +87,9 @@ class NativeSuiteTests(unittest.TestCase): self.assertNotIn("paths", declaration.group(1)) self.assertNotIn("branches", declaration.group(1)) if event == "pull_request": - # ready_for_review starts the scarce-runner jobs that draft - # pushes skip; the default activity types must stay listed. - self.assertIn("types: [opened, synchronize, reopened, ready_for_review]", declaration.group(1)) + # Drafts already run the full matrix; readiness alone changes no source. + self.assertIn("types: [opened, synchronize, reopened]", declaration.group(1)) + self.assertNotIn("ready_for_review", declaration.group(1)) else: self.assertNotIn("types", declaration.group(1)) self.assertIn("branches: [main]", events) @@ -100,14 +100,14 @@ class NativeSuiteTests(unittest.TestCase): def test_arm_job_requires_native_execution_and_matching_console_runtime(self): root = Path(__file__).resolve().parents[2] - workflow = (root / ".github/workflows/linux-lua.yml").read_text() + workflow = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8") self.assertIn("windows-11-arm", workflow) self.assertIn("host: aarch64-pc-windows-msvc", workflow) self.assertIn("OSArchitecture -ne 'Arm64'", workflow) self.assertIn("-Architecture $architecture", workflow) self.assertLess(workflow.index("Prepare pinned Windows console runtime"), workflow.index("Test complete workspace")) - preview = (root / ".github/workflows/preview-packages.yml").read_text() + preview = (root / ".github/workflows/preview-packages.yml").read_text(encoding="utf-8") windows = preview.split("\n windows:\n", 1)[1].split("\n aggregate:", 1)[0] self.assertNotIn("prepare-windows-runtime.ps1", preview.split("\njobs:", 1)[1].split("\n windows:", 1)[0]) self.assertLess(windows.index("prepare-windows-runtime.ps1"), @@ -128,16 +128,75 @@ class NativeSuiteTests(unittest.TestCase): self.assertNotIn("--", command) def test_actual_product_feature_graph_is_also_checked(self): - checks = [command for command in native_commands() if command[:2] == ["cargo", "check"]] - self.assertEqual(len(checks), 1) - command = checks[0] - self.assertEqual(command[command.index("--features") + 1], "gpui-shell") - self.assertEqual(command[command.index("--bin") + 1], "pebrel") + for runner in ("cargo", "nextest"): + with self.subTest(runner=runner): + checks = [command for command in native_commands(runner) if command[:2] == ["cargo", "check"]] + self.assertEqual(len(checks), 1) + command = checks[0] + self.assertEqual(command[command.index("--features") + 1], "gpui-shell") + self.assertEqual(command[command.index("--bin") + 1], "pebrel") + + def test_nextest_runs_all_native_targets_and_preserves_doctests(self): + commands = native_commands("nextest") + tests = [command for command in commands if command[:3] == ["cargo", "nextest", "run"]] + docs = [command for command in commands if command[:2] == ["cargo", "test"]] + self.assertEqual(len(tests), 1) + self.assertEqual(len(docs), 1) + self.assertIn("--doc", docs[0]) + self.assertNotIn("--doc", tests[0]) + for command, profile_flag in ((tests[0], "--cargo-profile"), (docs[0], "--profile")): + self.assertIn("--locked", command) + self.assertIn("--workspace", command) + self.assertIn("--timings", command) + self.assertEqual(command[command.index("--features") + 1], "nebula/gpui-test-support") + self.assertEqual(command[command.index(profile_flag) + 1], "ci") + self.assertEqual(command[command.index("--config") + 1], ".github/ci-profile.toml") + for excluded in ("--exclude", "--lib", "--skip", "--filterset", "--partition", "--no-run"): + self.assertNotIn(excluded, command) + self.assertIn("--no-fail-fast", tests[0]) + self.assertEqual(tests[0][tests[0].index("--retries") + 1], "0") + self.assertLess(commands.index(tests[0]), commands.index(docs[0])) + + def test_native_workflow_installs_nextest_without_changing_release_callers(self): + root = Path(__file__).resolve().parents[2] + workflow = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8") + native = workflow.split("\n native-tests:\n", 1)[1].split("\n macos-release-check:\n", 1)[0] + install = native.split("- name: Install the pinned native test runner", 1)[1].split(" - uses:", 1)[0] + self.assertIn("uses: taiki-e/install-action@v2", install) + self.assertIn("tool: cargo-nextest@0.9.146", install) + self.assertNotIn("if:", install) + self.assertIn("run: python scripts/ci_native_tests.py --runner nextest", native) + release = (root / ".github/workflows/release.yml").read_text(encoding="utf-8") + self.assertIn("run: python scripts/ci_native_tests.py\n", release) + + def test_nextest_preserves_only_the_existing_shared_theme_fixture_mutex(self): + root = Path(__file__).resolve().parents[2] + config = tomllib.loads((root / ".config/nextest.toml").read_text(encoding="utf-8")) + self.assertEqual(config["test-groups"], {"theme-studio": {"max-threads": 1}}) + self.assertEqual(config["profile"]["default"], { + "overrides": [{ + "filter": "test(gpui_shell::settings_pane::theme_studio_tests::)", + "test-group": "theme-studio", + }], + }) + + def test_native_caches_are_default_branch_snapshots_not_per_pr_uploads(self): + root = Path(__file__).resolve().parents[2] + workflow = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8") + for job in ("native-tests", "macos-release-check"): + body = workflow.split(f"\n {job}:\n", 1)[1] + body = re.split(r"\n [a-z][a-z-]*:\n", body, maxsplit=1)[0] + self.assertIn("revision: dependencies-v1", body) + self.assertIn("save-if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}", body) + save = body.split("- name: Save compiled workload", 1)[1].split(" - name:", 1)[0] + self.assertIn("success() && steps.rust-cache.outputs.save-enabled == 'true'", save) + self.assertNotIn("always()", save) + self.assertIn("steps.rust-cache.outputs.cache-hit != 'true'", save) def test_fast_test_profile_preserves_runtime_checks_and_resets_named_overrides(self): root = Path(__file__).resolve().parents[2] - config = tomllib.loads((root / ".github/ci-profile.toml").read_text()) - workspace = tomllib.loads((root / "Cargo.toml").read_text()) + config = tomllib.loads((root / ".github/ci-profile.toml").read_text(encoding="utf-8")) + workspace = tomllib.loads((root / "Cargo.toml").read_text(encoding="utf-8")) profile = config["profile"]["ci"] self.assertEqual(profile["inherits"], "dev") self.assertTrue(profile["debug-assertions"]) @@ -155,17 +214,24 @@ class NativeSuiteTests(unittest.TestCase): self.assertTrue(all("discover" in command for command in suites)) def test_success_runs_every_command_and_checks_exit_codes(self): - with patch("scripts.ci_native_tests.subprocess.run") as run: - self.assertEqual(main(), 0) - self.assertEqual(run.call_count, len(native_commands())) - self.assertTrue(all(call.kwargs["check"] for call in run.call_args_list)) + for runner in ("cargo", "nextest"): + with self.subTest(runner=runner), patch("scripts.ci_native_tests.subprocess.run") as run: + self.assertEqual(main(["--runner", runner]), 0) + self.assertEqual([call.args[0] for call in run.call_args_list], native_commands(runner)) + self.assertTrue(all(call.kwargs["check"] for call in run.call_args_list)) def test_failure_stops_the_suite_and_is_not_reported_as_success(self): - with patch("scripts.ci_native_tests.subprocess.run") as run: - run.side_effect = subprocess.CalledProcessError(7, "test") - with self.assertRaises(subprocess.CalledProcessError): - main() - self.assertEqual(run.call_count, 1) + for runner in ("cargo", "nextest"): + for failed in range(len(native_commands(runner))): + with self.subTest(runner=runner, failed=failed), patch("scripts.ci_native_tests.subprocess.run") as run: + run.side_effect = [None] * failed + [subprocess.CalledProcessError(7, "test")] + with self.assertRaises(subprocess.CalledProcessError): + main(["--runner", runner]) + self.assertEqual(run.call_count, failed + 1) + + def test_unknown_runner_is_rejected_before_running_commands(self): + with self.assertRaises(ValueError): + native_commands("typo") if __name__ == "__main__": diff --git a/scripts/tests/test_ci_plan.py b/scripts/tests/test_ci_plan.py index b4f2309..e485559 100644 --- a/scripts/tests/test_ci_plan.py +++ b/scripts/tests/test_ci_plan.py @@ -36,7 +36,7 @@ class CiPlanCliTests(unittest.TestCase): lines = path.read_text(encoding="utf-8").splitlines() return {name: json.loads(value) for name, value in (line.split("=", 1) for line in lines)} - def test_draft_pr_does_not_materialize_scarce_runner_rows(self) -> None: + def test_draft_pr_has_the_same_full_native_coverage_as_ready_pr(self) -> None: with tempfile.TemporaryDirectory() as directory: output = Path(directory) / "github-output" result = self.run_cli("pull_request", {"pull_request": {"draft": True}}, output) @@ -44,12 +44,15 @@ class CiPlanCliTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual( output.read_text(encoding="utf-8"), - 'native_matrix=[{"os":"ubuntu-24.04"},{"os":"windows-2022"},{"os":"macos-26"}]\n' - 'release_matrix=[{"os":"macos-26"}]\n', + 'native_matrix=[{"os":"ubuntu-24.04"},{"os":"windows-2022"},{"os":"macos-26"},' + '{"os":"windows-11-arm"},{"os":"macos-26-intel"}]\n' + 'release_matrix=[{"os":"macos-26"},{"os":"macos-26-intel"}]\n', ) plan = self.outputs(output) - self.assertNotIn({"os": "windows-11-arm"}, plan["native_matrix"]) - self.assertNotIn({"os": "macos-26-intel"}, plan["native_matrix"]) + ready_output = Path(directory) / "ready-output" + ready = self.run_cli("pull_request", {"pull_request": {"draft": False}}, ready_output) + self.assertEqual(ready.returncode, 0, ready.stderr) + self.assertEqual(plan, self.outputs(ready_output)) def test_ready_pr_has_all_native_and_release_checks(self) -> None: with tempfile.TemporaryDirectory() as directory: