ci: plan native matrices before requesting platform runners

This commit is contained in:
kuddev committed 2026-09-23 16:51:30 +08:00
1 parent 7f3a8d1072
commit 598e58a8cf
6 files changed
+450 -40

No files matched your search

+38 -36
View File
@@ -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 }}
+7 -4
View File
@@ -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。
@@ -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.
+132
View File
@@ -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())
+24
View File
@@ -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()
+172
View File
@@ -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()