diff --git a/.github/actions/rust-cache/action.yml b/.github/actions/rust-cache/action.yml index 2bd1d34..0323208 100644 --- a/.github/actions/rust-cache/action.yml +++ b/.github/actions/rust-cache/action.yml @@ -20,6 +20,9 @@ inputs: save-if: description: Allow publishing caches; PR consumers can restore without competing with the default branch default: "true" + restore-downloads: + description: Restore and populate shared downloads; disable only when an earlier call in this job already restored them + default: "true" outputs: cache-key: @@ -90,6 +93,7 @@ runs: PY - name: Restore shared Cargo downloads id: downloads + if: inputs.restore-downloads == 'true' uses: actions/cache/restore@v4 with: # Preserve Git source checkouts with their mtimes. Recreating them from @@ -173,7 +177,7 @@ runs: echo "legacy-fallback-outcome=$legacy_outcome" } >> "$GITHUB_OUTPUT" - name: Populate complete downloads before sharing the cache - if: inputs.save-if == 'true' && steps.downloads.outputs.cache-hit != 'true' + if: inputs.save-if == 'true' && steps.downloads.outputs.cache-hit != 'true' && inputs.restore-downloads == 'true' shell: bash env: CACHE_OS: ${{ runner.os }} @@ -193,7 +197,7 @@ runs: ;; esac - name: Save complete shared Cargo downloads - if: inputs.save-if == 'true' && steps.downloads.outputs.cache-hit != 'true' + if: inputs.save-if == 'true' && steps.downloads.outputs.cache-hit != 'true' && inputs.restore-downloads == 'true' uses: actions/cache/save@v4 with: path: | diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 2d70f4d..6868ca8 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -11,7 +11,7 @@ on: push: branches: [main] # Include docs, catalogs, assets and workflow edits: every PR gets test results. - # 草稿已跑完整矩阵,转为 Ready 而没有新提交时不重复申请七个原生 runner。 + # 草稿已跑完整矩阵,转为 Ready 而没有新提交时不重复申请五个原生 runner。 pull_request: types: [opened, synchronize, reopened] merge_group: @@ -145,6 +145,18 @@ jobs: # PR merge-ref 缓存不与其他 PR 共享;只由默认分支维护可复用的依赖快照。 revision: dependencies-v1 save-if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + # 构建前计算两类缓存身份,避免生成文件进入 hashFiles 后改变原有缓存键。 + # release 保留独立产物路径,但不重复恢复同一份源码下载。 + - name: Restore macOS release workload + if: ${{ !cancelled() && runner.os == 'macOS' }} + uses: ./.github/actions/rust-cache + id: macos-release-cache + with: + key: native-release-check + target: target/release + revision: dependencies-v1 + save-if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + restore-downloads: "false" - name: Prepare pinned Windows console runtime if: ${{ runner.os == 'Windows' }} shell: pwsh @@ -163,6 +175,7 @@ jobs: - name: Check the release workspace if: ${{ !cancelled() && runner.os != 'macOS' }} run: cargo check --locked --workspace --release + # Mac 原生快照先于 release 检查保存,避免后者失败或取消丢掉已验证的编译结果。 - name: Save compiled workload if: ${{ success() && steps.rust-cache.outputs.save-enabled == 'true' && steps.rust-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 @@ -171,6 +184,15 @@ jobs: target/ci ${{ runner.os != 'macOS' && 'target/release' || '' }} key: ${{ steps.rust-cache.outputs.cache-key }} + - name: Check macOS release workspace + if: ${{ !cancelled() && runner.os == 'macOS' }} + run: cargo check --locked --workspace --release --timings + - name: Save macOS release workload + if: ${{ success() && steps.macos-release-cache.outputs.save-enabled == 'true' && steps.macos-release-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: target/release + key: ${{ steps.macos-release-cache.outputs.cache-key }} - name: Upload native compilation timings if: ${{ always() }} uses: actions/upload-artifact@v4 @@ -181,60 +203,26 @@ jobs: compression-level: 0 retention-days: 14 - # The release check has no dependency on the test executable. Keeping it in - # the Intel test job added 11m33s after a 22m29s test/compile step in run - # 34856491552. Both jobs remain part of this reusable workflow's result, so - # the stable release aggregation still requires every check to succeed. + # 保留服务端已有的必需检查名称,但不为结果汇总再申请稀缺的 macOS runner。 + # 只接受本轮完整原生矩阵成功;上游失败或跳过时不能把汇总直接标绿。 macos-release-check: - needs: lint - if: ${{ inputs.windows_diagnostics_run == '' }} + needs: [lint, native-tests] + if: ${{ !cancelled() && inputs.windows_diagnostics_run == '' }} name: Release workspace (${{ matrix.os }}) strategy: fail-fast: false matrix: include: ${{ fromJSON(needs.lint.outputs.release_matrix) }} - runs-on: ${{ matrix.os }} - timeout-minutes: 120 - env: - PYTHONUTF8: "1" - CARGO_TERM_COLOR: always - RUST_BACKTRACE: "1" - RUST_TOOLCHAIN: "1.97.1" + runs-on: ubuntu-24.04 + timeout-minutes: 5 steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Require the AppKit SDK used by this PR + - name: Require this run's native and release checks + shell: python + env: + NATIVE_RESULT: ${{ needs.native-tests.result }} run: | - sdk_version="$(xcrun --sdk macosx --show-sdk-version)" - test "${sdk_version%%.*}" -ge 26 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.97.1" - - uses: ./.github/actions/rust-cache - id: rust-cache - with: - key: native-release-check - target: target/release - 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: ${{ success() && steps.rust-cache.outputs.save-enabled == 'true' && steps.rust-cache.outputs.cache-hit != 'true' }} - uses: actions/cache/save@v4 - with: - path: target/release - key: ${{ steps.rust-cache.outputs.cache-key }} - - name: Upload release check compilation timings - if: ${{ always() }} - uses: actions/upload-artifact@v4 - with: - name: release-check-timings-${{ matrix.os }} - path: target/cargo-timings - if-no-files-found: warn - compression-level: 0 - retention-days: 14 + import os + result = os.environ["NATIVE_RESULT"] + if result != "success": + raise SystemExit("Native validation did not succeed: " + result) + print("Both macOS release checks passed in the native matrix.") diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ef5132..06fad6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -131,7 +131,13 @@ compile check into a claim that UI tests or a packaged application were run. on `main`, together with Code Owner approval; see the [activation checklist](docs/project-constraints.md#server-side-activation). 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 +release-profile compile checks. Each macOS native job also runs its release check +on the same runner, reusing the checkout, toolchain and source downloads while +keeping the two compiled workloads in their existing separate caches. The two +`Release workspace ()` contexts are lightweight Linux result checks: they +require this run's entire native matrix to succeed, not a previous run's result. +The release compilation itself still runs natively on each Mac architecture. +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 @@ -155,7 +161,7 @@ feature work; there is no routine `--skip-architecture` option. - 先读架构图、工程合同和决策记录;按职责拆分,不按行号切片。 - 一个 PR 只做一件事;改动超过 1500 行源码(不计文档、lockfile、资源)`pr-size` 会失败,请拆分。 -- Draft 和 Ready PR 都先运行必需的格式检查和矩阵规划,再执行五平台原生测试及两项 macOS release 编译检查;十项必需检查全绿才能合并。提速使用共享缓存与并行测试,不省略平台或 doctest。 +- Draft 和 Ready PR 都先运行必需的格式检查和矩阵规划,再执行五平台原生测试及两项 macOS release 编译检查;每个 Mac 在同一 runner 完成两类检查,原有 release 检查名称由轻量结果汇总保留。十项必需检查全绿才能合并,不省略平台或 doctest。 - 2000 行是现有仓库的防灾上限,800 行只提示审查,不是“大厂标准”。 - 普通功能 PR 不得增加存量债务;有问题的规则可以修订,但要有反例、测试和维护者审批。 - 新增核心抽象、依赖方向、持久化或线程模型改变要先说明设计,不强迫每个小修复写 ADR。 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 index 8c827f7..1f862c4 100644 --- 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 @@ -1,5 +1,8 @@ # Speed up native PR checks without dropping platform coverage +Independent macOS runner placement superseded by +[shared macOS runners](2026-09-27-shared-macos-runners.md). + ## Status Implemented for review. The five native check names and two macOS release-check diff --git a/architecture/notes/scripts/ci/2026-09-27-shared-macos-runners.md b/architecture/notes/scripts/ci/2026-09-27-shared-macos-runners.md new file mode 100644 index 0000000..26f38f3 --- /dev/null +++ b/architecture/notes/scripts/ci/2026-09-27-shared-macos-runners.md @@ -0,0 +1,105 @@ +# Share macOS runners without dropping native or release checks + +## Status + +Implemented for review. Required check names and coverage are preserved; +GitHub protection settings are unchanged. Hosted execution and timing of this +revision remain to be measured. + +## Context + +Separating each Mac's native tests and release-profile compile check requests +four macOS runners per PR. Parallel work can shorten execution, but an additional +runner request can also become the critical path when capacity is scarce. + +## Evidence + +In [run 36314579470](https://github.com/Kuddev/pebrel/actions/runs/36314579470), +the Intel release check executed for 3m16s after waiting 60m09s. The workflow +finished after 66m21s. This is a scheduling problem, not an hour of compilation. + +That run's Windows ARM64 job missed its compiled target cache at 11:09:51 UTC. +The same key's currently available default-branch entry was created at 11:31:52 +UTC on 2026-09-27. This establishes availability at those two observations, +not the cause of any earlier eviction or a broken cache key. Do not invalidate +existing caches as a speculative fix. + +GitHub's [cache documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching) +states that the cached paths participate in cache versioning. Combining the +two target-directory lists would prevent reuse even if the visible key stayed +unchanged. Its [job dependency contract](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idneeds) +also requires an explicit status condition to continue after a failed dependency. + +## Decision + +- Run the original `cargo check --locked --workspace --release --timings` after + each Mac's native suite on the same runner. Retain the native architecture, + AppKit SDK requirement, pinned toolchain, test features and profile settings. + Linux and both Windows jobs retain their existing release checks. +- Keep both `Release workspace ()` names as short Ubuntu jobs. They depend + on lint and the current native matrix, and reject any result other than exact + `success`. They do not run Mac compilation on Linux, read older check results, + upload fabricated statuses, or require write permissions. +- Preserve separate `native` and `native-release-check` cache keys, generations + and target paths. Both identities are computed before build/test commands can + generate additional manifests. The second cache action reuses downloads already + restored by the first action; other callers keep the default download behavior. +- Save the fully successful native workload before beginning the Mac release + workload. A later release failure or cancellation need not discard that + completed warmup. Neither incomplete workload nor read-only PR consumers may + publish a compiled snapshot. +- Retain all five platforms for draft/ready PRs and merge groups, both Python + suites, nextest, doctests, the product feature check and current cancellation + policy. Release/package callers and branch protection are not redesigned. + +## Rejected alternatives + +- Remove a platform, release compilation, doctests or assertions: changes the + required coverage rather than removing scheduling waste. +- Keep requesting a separate Mac just to preserve a status name: a current-run + dependency check retains the required context without another scarce runner. +- Introduce per-platform result artifacts or custom check-writing credentials: + unnecessary for the conservative rule that every native job must succeed. +- Combine existing target archives or bump their generation: causes a cold-cache + transition without changing any compiler inputs or fixing a proven defect. +- Delete remote caches or increase storage quota: not needed for this runner + change, and not evidence that cache retention has been solved. + +## Consequences + +Each full invocation requests two rather than four macOS runners. The two short +result jobs still request Ubuntu runners, so this is not a reduction in the +number of named checks. Each Mac's execution becomes sequential and can take +longer on a cold cache; the change targets additional runner queue time. + +Both release contexts wait for the entire native matrix. An unrelated native +failure also fails these summaries; the per-platform job logs retain the actual +compile outcome. This is conservative and does not weaken merge acceptance. +Native timing artifacts now include the Mac release compilation timings too. + +Existing cache footprint, dependency cold builds and default-branch seed timing +remain relevant. This change does not promise a fixed CI duration or eliminate +Windows ARM64 compilation cost. + +## Validation + +The six existing lint contract suites ran 62 tests on Windows: 61 passed and the +existing POSIX-only cache-summary case was skipped. The workflow's actual Python +result-check body accepts `success` and rejects failure, cancellation, skipped, +empty and malformed results. Contracts retain full platform/event coverage, +release compilation, default-branch-only cache publication and independent +cache identities. Actionlint 1.7.12, workspace rustfmt, architecture checking +against the PR base and the platform budget passed. Hosted cache restore/save +and cross-platform timings remain pending a real workflow run. + +## Supersedes + +Only the separate macOS runner placement in +[shared native PR caches](2026-09-25-shared-native-pr-cache.md). Its coverage, +cache ownership and native test-runner decisions remain in force. + +## Revisit when + +Hosted measurements show sequential cold compilation exceeds the queue savings, +macOS capacity changes, or a demonstrated cache-retention issue warrants its own +bounded change. Compare queue, active execution and cache hit status separately. diff --git a/scripts/tests/test_ci_cache.py b/scripts/tests/test_ci_cache.py index 99a083a..2afad6c 100644 --- a/scripts/tests/test_ci_cache.py +++ b/scripts/tests/test_ci_cache.py @@ -195,6 +195,18 @@ class CacheIdentityTests(unittest.TestCase): 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) + def test_second_workload_can_reuse_downloads_without_restoring_or_saving_them_again(self): + source = self.action_text() + option = source.split("\n restore-downloads:\n", 1)[1].split("\noutputs:", 1)[0] + self.assertIn('default: "true"', option) + for step in ("Restore shared Cargo downloads", "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] + condition = next(line for line in body.splitlines() if line.strip().startswith("if:")) + self.assertIn("inputs.restore-downloads == 'true'", condition) + target = source.split("- name: Restore compiled targets", 1)[1].split("\n - name:", 1)[0] + self.assertNotIn("restore-downloads", target) + @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 383f8a5..14083b4 100644 --- a/scripts/tests/test_ci_native_tests.py +++ b/scripts/tests/test_ci_native_tests.py @@ -1,8 +1,11 @@ from __future__ import annotations +import os import subprocess import re from pathlib import Path +import sys +import textwrap import tomllib import unittest from unittest.mock import patch @@ -43,11 +46,13 @@ class NativeSuiteTests(unittest.TestCase): platform_step = lint.split("Check platform cfg budget before native jobs", 1)[1].split(" - name:", 1)[0] self.assertNotIn("continue-on-error", platform_step) self.assertNotIn("--update", platform_step) - for job, output in (("native-tests", "native_matrix"), - ("macos-release-check", "release_matrix")): + for job, output, needs in ( + ("native-tests", "native_matrix", "needs: lint"), + ("macos-release-check", "release_matrix", "needs: [lint, native-tests]"), + ): 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("needs: lint", body) + self.assertIn(needs, body) self.assertIn(f"fromJSON(needs.lint.outputs.{output})", body) self.assertNotIn("pull_request.draft", body) self.assertNotIn("matrix.tier", body) @@ -183,15 +188,59 @@ class NativeSuiteTests(unittest.TestCase): 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) + native = workflow.split("\n native-tests:\n", 1)[1].split("\n macos-release-check:\n", 1)[0] + for cache_id, save_name in (("rust-cache", "Save compiled workload"), + ("macos-release-cache", "Save macOS release workload")): + cache = native.split(f"id: {cache_id}\n", 1)[1].split("\n - ", 1)[0] + self.assertIn("revision: dependencies-v1", cache) + self.assertIn("save-if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}", cache) + save = native.split(f"- name: {save_name}", 1)[1].split("\n - ", 1)[0] + self.assertIn(f"success() && steps.{cache_id}.outputs.save-enabled == 'true'", save) self.assertNotIn("always()", save) - self.assertIn("steps.rust-cache.outputs.cache-hit != 'true'", save) + self.assertIn(f"steps.{cache_id}.outputs.cache-hit != 'true'", save) + self.assertLess(native.index("Test complete workspace"), native.index("Save compiled workload")) + self.assertLess(native.index("Restore macOS release workload"), native.index("Test complete workspace")) + self.assertLess(native.index("Save compiled workload"), native.index("Check macOS release workspace")) + release_cache = native.split("- name: Restore macOS release workload", 1)[1].split("\n - ", 1)[0] + self.assertIn("key: native-release-check", release_cache) + self.assertIn("target: target/release", release_cache) + self.assertIn('restore-downloads: "false"', release_cache) + + def test_macos_release_compilation_reuses_the_native_runner(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] + release = native.split("- name: Check macOS release workspace", 1)[1].split("\n - ", 1)[0] + self.assertIn("if: ${{ !cancelled() && runner.os == 'macOS' }}", release) + self.assertIn("run: cargo check --locked --workspace --release --timings", release) + self.assertNotIn("continue-on-error", native) + self.assertLess(native.index("Require the AppKit SDK"), native.index("Check macOS release workspace")) + self.assertLess(native.index("Restore macOS release workload"), native.index("Check macOS release workspace")) + self.assertLess(native.index("Check macOS release workspace"), native.index("Save macOS release workload")) + + def test_release_required_contexts_reject_failed_or_missing_native_results(self): + root = Path(__file__).resolve().parents[2] + workflow = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8") + report = workflow.split("\n macos-release-check:\n", 1)[1] + self.assertIn("needs: [lint, native-tests]", report) + self.assertIn("if: ${{ !cancelled() && inputs.windows_diagnostics_run == '' }}", report) + self.assertIn("name: Release workspace (${{ matrix.os }})", report) + self.assertIn("fromJSON(needs.lint.outputs.release_matrix)", report) + self.assertIn("runs-on: ubuntu-24.04", report) + self.assertIn("shell: python", report) + self.assertIn("NATIVE_RESULT: ${{ needs.native-tests.result }}", report) + self.assertNotIn("uses:", report) + self.assertNotIn("continue-on-error", report) + script = textwrap.dedent(report.split(" run: |\n", 1)[1]) + for result in ("success", "failure", "cancelled", "skipped", "", "success\n"): + with self.subTest(result=result): + completed = subprocess.run( + [sys.executable, "-c", script], + env={**os.environ, "NATIVE_RESULT": result}, + capture_output=True, text=True, encoding="utf-8", check=False, + ) + self.assertEqual(completed.returncode, 0 if result == "success" else 1, + completed.stderr) def test_fast_test_profile_preserves_runtime_checks_and_resets_named_overrides(self): root = Path(__file__).resolve().parents[2]