ci: plan native matrices before requesting platform runners
This commit is contained in:
1 parent
7f3a8d1072
commit
598e58a8cf
6 files changed
+450
-40
No files matched your search
@@ -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
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user