From 598e58a8cfdce78310042067210b25e10eb8a7e4 Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 16:51:30 +0800 Subject: [PATCH] ci: plan native matrices before requesting platform runners --- .github/workflows/linux-lua.yml | 74 ++++---- CONTRIBUTING.md | 11 +- .../ci/2026-09-23-native-matrix-planning.md | 77 ++++++++ scripts/ci_plan.py | 132 ++++++++++++++ scripts/tests/test_ci_native_tests.py | 24 +++ scripts/tests/test_ci_plan.py | 172 ++++++++++++++++++ 6 files changed, 450 insertions(+), 40 deletions(-) create mode 100644 architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md create mode 100644 scripts/ci_plan.py create mode 100644 scripts/tests/test_ci_plan.py diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 8010cce..40a4806 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -36,36 +36,45 @@ jobs: lint: if: ${{ inputs.windows_diagnostics_run == '' }} name: lint + outputs: + native_matrix: ${{ steps.matrix.outputs.native_matrix }} + release_matrix: ${{ steps.matrix.outputs.release_matrix }} runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@stable with: toolchain: "1.97.1" components: rustfmt - name: rustfmt run: cargo fmt --all -- --check + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Plan native validation + id: matrix + shell: bash + run: | + python scripts/ci_plan.py \ + --event-name "$GITHUB_EVENT_NAME" \ + --event-path "$GITHUB_EVENT_PATH" \ + --output "$GITHUB_OUTPUT" - # Draft pull requests run the three fast platforms (about 20 minutes) on - # every push. The scarce runners (Intel Mac, Windows ARM64) join once the - # pull request is marked ready for review and on main, so every merge is - # verified on all five platforms without paying their queue time while a - # change is still being iterated. Skipped jobs report success, which keeps - # all five names usable as required checks; a ready PR cannot merge red. + # 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. native-tests: + needs: lint if: ${{ inputs.windows_diagnostics_run == '' }} name: Tests (${{ matrix.os }}) strategy: fail-fast: false matrix: - os: [ubuntu-24.04, windows-2022, macos-26] - tier: [core] - include: - - os: windows-11-arm - tier: full - - os: macos-26-intel - tier: full + include: ${{ fromJSON(needs.lint.outputs.native_matrix) }} runs-on: ${{ matrix.os }} timeout-minutes: 120 env: @@ -75,15 +84,14 @@ jobs: RUST_TOOLCHAIN: "1.97.1" steps: - uses: actions/checkout@v4 - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) }} with: + persist-credentials: false fetch-depth: 0 - uses: actions/setup-python@v5 - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) }} with: python-version: "3.13" - name: Install native dependencies - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) && (runner.os == 'Linux') }} + if: ${{ runner.os == 'Linux' }} run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ @@ -94,16 +102,15 @@ jobs: libx11-xcb-dev libxcb1-dev libxcb-render0-dev \ libxcb-shape0-dev libxcb-xfixes0-dev libxcb-xinput-dev libxcb-xkb-dev libxi-dev - name: Require the AppKit SDK used by this PR - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) && (runner.os == 'macOS') }} + if: ${{ runner.os == 'macOS' }} run: | sdk_version="$(xcrun --sdk macosx --show-sdk-version)" test "${sdk_version%%.*}" -ge 26 - uses: dtolnay/rust-toolchain@stable - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) }} with: toolchain: "1.97.1" - name: Require a native Windows ARM64 compiler - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) && (matrix.os == 'windows-11-arm') }} + if: ${{ matrix.os == 'windows-11-arm' }} shell: pwsh run: | if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'Arm64') { @@ -114,7 +121,6 @@ jobs: throw 'The Windows ARM job must compile and run ARM64 tests.' } - uses: ./.github/actions/rust-cache - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) }} id: rust-cache with: key: native @@ -124,26 +130,25 @@ jobs: ${{ runner.os != 'macOS' && 'target/release' || '' }} legacy-key: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'Linux' && 'linux' || 'macos' }} - name: Prepare pinned Windows console runtime - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) && (runner.os == 'Windows') }} + if: ${{ runner.os == 'Windows' }} shell: pwsh run: | $architecture = if ('${{ runner.arch }}' -eq 'ARM64') { 'arm64' } else { 'x64' } ./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 - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) }} run: python scripts/ci_native_tests.py - name: Check source architecture - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) && (!cancelled() && runner.os == 'Linux') }} + if: ${{ !cancelled() && runner.os == 'Linux' }} run: python scripts/check_architecture.py - name: Test translation and allocation contracts - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) && (!cancelled() && runner.os == 'Linux') }} + if: ${{ !cancelled() && runner.os == 'Linux' }} run: cargo test --locked --manifest-path tools/i18n-contract/Cargo.toml - name: Check the release workspace - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) && (!cancelled() && runner.os != 'macOS') }} + if: ${{ !cancelled() && runner.os != 'macOS' }} run: cargo check --locked --workspace --release - name: Save compiled workload - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) && (always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true') }} + if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 with: path: | @@ -151,7 +156,7 @@ jobs: ${{ runner.os != 'macOS' && 'target/release' || '' }} key: ${{ steps.rust-cache.outputs.cache-key }} - name: Upload native compilation timings - if: ${{ (matrix.tier != 'full' || github.event_name != 'pull_request' || !github.event.pull_request.draft) && (always()) }} + if: ${{ always() }} uses: actions/upload-artifact@v4 with: name: native-timings-${{ matrix.os }} @@ -165,12 +170,13 @@ jobs: # 34856491552. Both jobs remain part of this reusable workflow's result, so # the stable release aggregation still requires every check to succeed. macos-release-check: + needs: lint if: ${{ inputs.windows_diagnostics_run == '' }} name: Release workspace (${{ matrix.os }}) strategy: fail-fast: false matrix: - os: [macos-26, macos-26-intel] + include: ${{ fromJSON(needs.lint.outputs.release_matrix) }} runs-on: ${{ matrix.os }} timeout-minutes: 120 env: @@ -180,38 +186,34 @@ jobs: RUST_TOOLCHAIN: "1.97.1" steps: - uses: actions/checkout@v4 - if: ${{ (matrix.os != 'macos-26-intel' || github.event_name != 'pull_request' || !github.event.pull_request.draft) }} + with: + persist-credentials: false - uses: actions/setup-python@v5 - if: ${{ (matrix.os != 'macos-26-intel' || github.event_name != 'pull_request' || !github.event.pull_request.draft) }} with: python-version: "3.13" - name: Require the AppKit SDK used by this PR - if: ${{ (matrix.os != 'macos-26-intel' || github.event_name != 'pull_request' || !github.event.pull_request.draft) }} run: | sdk_version="$(xcrun --sdk macosx --show-sdk-version)" test "${sdk_version%%.*}" -ge 26 - uses: dtolnay/rust-toolchain@stable - if: ${{ (matrix.os != 'macos-26-intel' || github.event_name != 'pull_request' || !github.event.pull_request.draft) }} with: toolchain: "1.97.1" - uses: ./.github/actions/rust-cache - if: ${{ (matrix.os != 'macos-26-intel' || github.event_name != 'pull_request' || !github.event.pull_request.draft) }} id: rust-cache with: key: native-release-check target: target/release legacy-key: macos - name: Check the release workspace - if: ${{ (matrix.os != 'macos-26-intel' || github.event_name != 'pull_request' || !github.event.pull_request.draft) }} run: cargo check --locked --workspace --release --timings - name: Save compiled workload - if: ${{ (matrix.os != 'macos-26-intel' || github.event_name != 'pull_request' || !github.event.pull_request.draft) && always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }} + if: ${{ always() && steps.rust-cache.outcome == 'success' && 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: ${{ (matrix.os != 'macos-26-intel' || github.event_name != 'pull_request' || !github.event.pull_request.draft) && always() }} + if: ${{ always() }} uses: actions/upload-artifact@v4 with: name: release-check-timings-${{ matrix.os }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9f10a1..d2547b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -131,9 +131,12 @@ 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). Open pull requests as drafts while iterating: drafts run Linux, Windows x64 and -Apple Silicon on every push (about 20 minutes). Marking the PR ready for review -adds Intel Mac and Windows ARM64, so every merge is verified on all five -platforms without paying for the scarce runners on each push. +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. Local hooks are convenient, but bypassable; they are not the enforcement boundary. Submitting a workflow or `CODEOWNERS` file does not configure server-side rules. @@ -147,7 +150,7 @@ feature work; there is no routine `--skip-architecture` option. - 先读架构图、工程合同和决策记录;按职责拆分,不按行号切片。 - 一个 PR 只做一件事;改动超过 1500 行源码(不计文档、lockfile、资源)`pr-size` 会失败,请拆分。 -- 迭代期间用 Draft PR:每次 push 跑 Linux / Windows x64 / Apple Silicon(约 20 分钟);标记 Ready for review 后补跑 Intel Mac 与 Windows ARM64,十项必需检查全绿才能合并。 +- Draft PR 先运行必需的格式检查和矩阵规划,再创建 Linux / Windows x64 / Apple Silicon 测试及 Apple Silicon release 编译检查;不创建 Intel Mac / Windows ARM64 任务。Ready 后运行完整矩阵,十项必需检查全绿才能合并。 - 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 new file mode 100644 index 0000000..3b3c9d5 --- /dev/null +++ b/architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md @@ -0,0 +1,77 @@ +# Plan the native matrix before requesting runners + +## Status + +Implemented for review. Preserves the existing ten required check names and +ready-PR platform coverage; server-side rules are not changed. + +## Context + +The initial draft tier skipped individual steps on Intel Mac and Windows ARM64 +jobs. GitHub still assigned runners before skipping those steps. In PR #257's +draft run 35820869856, the Intel test job waited 309 seconds before spending +eight seconds on a job whose test steps were skipped. + +## Evidence + +`runs-on` belongs to a job, while the old draft conditions belonged to its steps. +Avoiding the platform command does not avoid allocating that job's runner. +The required `lint` check already runs on Ubuntu and can validate both formatting +and the platform plan before expensive jobs are created. + +## Decision + +`scripts/ci_plan.py` is the authority for platform selection. It reads the event +JSON and produces native-test and release-compile matrices. Draft PRs select the +three existing core platforms and the Apple Silicon release check. Ready PRs, +main pushes, merge groups and release callers retain all five native platforms +and both macOS release checks. + +The existing required `lint` job owns planning, after rustfmt. Native and release +jobs depend on that job and consume its validated outputs. Invalid input fails +the required check rather than silently returning an empty matrix. Checkouts +explicitly discard Git credentials; the workflow retains read-only permissions. + +Draft-only deferred checks are absent, not reported as successful executions. +A ready PR must still supply every required platform result. This change does +not copy previous runs' check conclusions. Cargo caches accelerate rebuilding; +they are not evidence that the current validation succeeded. + +## Rejected alternatives + +- Keep step-only skipping: retains the unnecessary runner allocation. +- Move planning into an unrequired job: its failure could be less visible than + a failure in an existing required check, especially on repeated runs. +- Remove release-profile checks with Preview packaging: `cargo check --release` + verifies compilation and does not create installation packages. +- Mirror an earlier green check onto a new run: requires independently verified + source, base, workflow, configuration and provenance rules; cache hits alone + cannot establish those facts. + +## Consequences + +Heavy jobs begin after the Ubuntu lint/planning job. Formatting errors and bad +event metadata stop before requesting platform runners. Draft PR pages may show +deferred required contexts as missing; drafts are not mergeable, and the ready +event runs the full matrix. Ready transitions still rerun core validation. + +This reduces avoidable runner requests without promising a fixed queue time. +The existing Windows diagnostic dispatch continues to use its separate workflow. + +## Validation + +Planner tests cover draft and ready PRs, all full-validation caller events, +malformed input and real command-line output. Workflow contract tests require the +existing lint check to own planning, both matrices to depend on it, draft step +guards to be absent, and release compilation to remain present. Native PR and +merge-group coverage and explicit failure propagation retain their regressions. +Actual hosted-runner execution remains subject to the PR's required CI results. + +## Supersedes + +Step-level draft skipping in PR #257's initial native matrix. + +## Revisit when + +Queue and execution measurements justify a different platform schedule, or a +separately reviewed artifact-provenance design supports build-once test shards. diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py new file mode 100644 index 0000000..4e6432f --- /dev/null +++ b/scripts/ci_plan.py @@ -0,0 +1,132 @@ +#!/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. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass +from pathlib import Path +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. +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), +) + +FULL_EVENTS = frozenset( + {"push", "merge_group", "workflow_dispatch", "workflow_call", "schedule"} +) + + +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] + return ( + [{"os": platform.runner} for platform in selected], + [{"os": platform.runner} for platform in selected if platform.release_check], + ) + + +def _reject_json_constant(value: str) -> None: + raise ValueError(f"non-standard JSON constant {value}") + + +def _read_event(path: Path) -> dict[str, Any]: + try: + raw = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise PlanError(f"cannot read event payload {path}: {exc}") from exc + + try: + payload = json.loads(raw, parse_constant=_reject_json_constant) + except (json.JSONDecodeError, ValueError) as exc: + raise PlanError(f"invalid event JSON in {path}: {exc}") from exc + + if not isinstance(payload, dict): + raise PlanError("event payload must be a JSON object") + return payload + + +def plan_matrices(event_name: str, payload: dict[str, Any]) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + """Return native and release matrices for one validated event.""" + + if event_name == "pull_request": + pull_request = payload.get("pull_request") + if not isinstance(pull_request, dict): + raise PlanError("pull_request event must contain a pull_request object") + draft = pull_request.get("draft") + if not isinstance(draft, bool): + raise PlanError("pull_request.draft must be a boolean") + return _matrices(draft) + + if event_name in FULL_EVENTS: + return _matrices(draft=False) + + supported = sorted(FULL_EVENTS | {"pull_request"}) + raise PlanError(f"unsupported event name {event_name!r}; expected one of {', '.join(supported)}") + + +def _compact_output(name: str, value: list[dict[str, str]]) -> str: + return f"{name}={json.dumps(value, separators=(',', ':'), ensure_ascii=True)}" + + +def write_outputs(path: Path, native_matrix: list[dict[str, str]], release_matrix: list[dict[str, str]]) -> None: + """Append both outputs in one write after planning has fully succeeded.""" + + content = "\n".join( + ( + _compact_output("native_matrix", native_matrix), + _compact_output("release_matrix", release_matrix), + ) + ) + "\n" + try: + with path.open("a", encoding="utf-8", newline="\n") as output: + output.write(content) + except (OSError, UnicodeError) as exc: + raise PlanError(f"cannot append CI plan to {path}: {exc}") from exc + + +def _arguments(argv: Sequence[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--event-name", required=True) + parser.add_argument("--event-path", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = _arguments(argv) + try: + payload = _read_event(arguments.event_path) + native_matrix, release_matrix = plan_matrices(arguments.event_name, payload) + write_outputs(arguments.output, native_matrix, release_matrix) + except PlanError as exc: + print(f"ci_plan: {exc}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_ci_native_tests.py b/scripts/tests/test_ci_native_tests.py index 23e230c..17d5912 100644 --- a/scripts/tests/test_ci_native_tests.py +++ b/scripts/tests/test_ci_native_tests.py @@ -11,6 +11,30 @@ 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() + lint = workflow.split("\n lint:\n", 1)[1].split("\n native-tests:\n", 1)[0] + self.assertIn("name: lint", lint) + self.assertIn("python scripts/ci_plan.py", lint) + self.assertIn('--event-path "$GITHUB_EVENT_PATH"', lint) + self.assertLess(lint.index("cargo fmt"), lint.index("python scripts/ci_plan.py")) + for job, output in (("native-tests", "native_matrix"), + ("macos-release-check", "release_matrix")): + 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(f"fromJSON(needs.lint.outputs.{output})", body) + self.assertNotIn("pull_request.draft", body) + self.assertNotIn("matrix.tier", body) + self.assertIn("cargo check --locked --workspace --release", workflow) + # Native validation never needs to retain checkout credentials. + checkouts = re.findall(r"- uses: actions/checkout@[^\n]+\n(.*?)(?= - |\Z)", + workflow, re.S) + self.assertTrue(checkouts) + for checkout in checkouts: + self.assertIn("persist-credentials: false", checkout) + 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() diff --git a/scripts/tests/test_ci_plan.py b/scripts/tests/test_ci_plan.py new file mode 100644 index 0000000..b4f2309 --- /dev/null +++ b/scripts/tests/test_ci_plan.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "ci_plan.py" + + +class CiPlanCliTests(unittest.TestCase): + def run_cli(self, event_name: str, payload: object, output: Path) -> subprocess.CompletedProcess[str]: + event_path = output.parent / "event.json" + event_path.write_text(json.dumps(payload), encoding="utf-8") + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--event-name", + event_name, + "--event-path", + str(event_path), + "--output", + str(output), + ], + text=True, + capture_output=True, + check=False, + ) + + @staticmethod + def outputs(path: Path) -> dict[str, object]: + 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: + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "github-output" + result = self.run_cli("pull_request", {"pull_request": {"draft": True}}, output) + + 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', + ) + plan = self.outputs(output) + self.assertNotIn({"os": "windows-11-arm"}, plan["native_matrix"]) + self.assertNotIn({"os": "macos-26-intel"}, plan["native_matrix"]) + + def test_ready_pr_has_all_native_and_release_checks(self) -> None: + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "github-output" + result = self.run_cli("pull_request", {"pull_request": {"draft": False}}, output) + + self.assertEqual(result.returncode, 0, result.stderr) + plan = self.outputs(output) + self.assertEqual( + [row["os"] for row in plan["native_matrix"]], + ["ubuntu-24.04", "windows-2022", "macos-26", "windows-11-arm", "macos-26-intel"], + ) + self.assertEqual( + [row["os"] for row in plan["release_matrix"]], + ["macos-26", "macos-26-intel"], + ) + + def test_success_appends_outputs_without_replacing_existing_lines(self) -> None: + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "github-output" + output.write_text("existing=preserved\n", encoding="utf-8") + result = self.run_cli("push", {}, output) + + self.assertEqual(result.returncode, 0, result.stderr) + lines = output.read_text(encoding="utf-8").splitlines() + self.assertEqual(lines[0], "existing=preserved") + self.assertTrue(lines[1].startswith("native_matrix=[{")) + self.assertTrue(lines[2].startswith("release_matrix=[{")) + + def test_merge_group_and_non_pr_events_have_full_coverage(self) -> None: + events = ("merge_group", "push", "workflow_dispatch", "workflow_call", "schedule") + with tempfile.TemporaryDirectory() as directory: + for event_name in events: + with self.subTest(event_name=event_name): + output = Path(directory) / f"{event_name}.out" + result = self.run_cli(event_name, {}, output) + self.assertEqual(result.returncode, 0, result.stderr) + plan = self.outputs(output) + self.assertEqual( + [row["os"] for row in plan["native_matrix"]], + ["ubuntu-24.04", "windows-2022", "macos-26", "windows-11-arm", "macos-26-intel"], + ) + self.assertEqual( + [row["os"] for row in plan["release_matrix"]], + ["macos-26", "macos-26-intel"], + ) + + def test_invalid_events_fail_without_matrix_outputs(self) -> None: + cases = ( + ("pull_request", {}, "missing pull request"), + ("pull_request", {"pull_request": []}, "wrong pull request type"), + ("pull_request", {"pull_request": {}}, "missing draft"), + ("pull_request", {"pull_request": {"draft": "false"}}, "wrong draft type"), + ("pull_request", {"pull_request": {"draft": 0}}, "integer draft type"), + ("unknown", {}, "unknown event"), + ("push", [], "non-object payload"), + ) + with tempfile.TemporaryDirectory() as directory: + for event_name, payload, label in cases: + with self.subTest(label=label): + output = Path(directory) / f"{label.replace(' ', '-')}.out" + output.write_text("existing=preserved\n", encoding="utf-8") + result = self.run_cli(event_name, payload, output) + self.assertNotEqual(result.returncode, 0) + self.assertIn("ci_plan:", result.stderr) + self.assertEqual(output.read_text(encoding="utf-8"), "existing=preserved\n") + + def test_malformed_json_fails_without_creating_output(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + event_path = root / "broken.json" + output = root / "github-output" + event_path.write_text('{"pull_request":', encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--event-name", + "pull_request", + "--event-path", + str(event_path), + "--output", + str(output), + ], + text=True, + capture_output=True, + check=False, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertFalse(output.exists()) + + def test_non_standard_json_constant_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + event_path = root / "non-standard.json" + output = root / "github-output" + event_path.write_text('{"value":NaN}', encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--event-name", + "push", + "--event-path", + str(event_path), + "--output", + str(output), + ], + text=True, + capture_output=True, + check=False, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main()