diff --git a/.github/workflows/check-ubuntu.yml b/.github/workflows/check-ubuntu.yml index 263d11fe..1e1e0a36 100644 --- a/.github/workflows/check-ubuntu.yml +++ b/.github/workflows/check-ubuntu.yml @@ -28,7 +28,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v6 + - name: Checkout source + uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ inputs.ref }} @@ -39,7 +40,8 @@ jobs: run: | [[ "$CANDIDATE_SHA" =~ ^[0-9a-f]{40}$ ]] test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" - - uses: astral-sh/setup-uv@v3 + - name: Set up uv + uses: astral-sh/setup-uv@v3 with: python-version: "3.12" # Workspace tests also link the embedded-Python test target, even @@ -113,7 +115,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - name: Checkout source + uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ inputs.ref }} @@ -124,7 +127,8 @@ jobs: run: | [[ "$CANDIDATE_SHA" =~ ^[0-9a-f]{40}$ ]] test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" - - uses: astral-sh/setup-uv@v3 + - name: Set up uv + uses: astral-sh/setup-uv@v3 with: python-version: "3.10" - name: Setup soldr diff --git a/.github/workflows/ci-minimal.yml b/.github/workflows/ci-minimal.yml index d4c57f1b..efee9592 100644 --- a/.github/workflows/ci-minimal.yml +++ b/.github/workflows/ci-minimal.yml @@ -21,17 +21,23 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 + - name: Checkout source + uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 2 persist-credentials: false - - uses: astral-sh/setup-uv@v3 + - name: Set up uv + uses: astral-sh/setup-uv@v3 - name: Verify source-bound local proof - if: github.event_name == 'pull_request' - run: >- - uvx --from git+https://github.com/zackees/ci.yml@9e44971219cd870a2263fa694debc94f57722405 - ci-lint local-gate verify --repo . + env: + GATE_EVENT: ${{ github.event_name }} + run: | + if [ "$GATE_EVENT" = "pull_request" ]; then + uvx --from git+https://github.com/zackees/ci.yml@1047fa448f90ea1c9c2abc66441c6b399e58bc29 ci-lint local-gate verify --repo . + else + echo "Local proof verification is required on pull requests" + fi linux: if: github.event_name != 'pull_request' || (!contains(github.event.pull_request.labels.*.name, 'ci-test') && !contains(github.event.pull_request.labels.*.name, 'ci-full')) needs: verify diff --git a/ci/README.md b/ci/README.md index 95725f1f..bc5a1a88 100644 --- a/ci/README.md +++ b/ci/README.md @@ -6,13 +6,16 @@ Python scripts for CI, packaging, and development tooling. All invoked via `uv r engine, then replays the entire existing `dylint.yml` workflow with the ordinary unlabelled PR event. It checks clean source identity, native Linux x64 execution, all three Dylint job verdicts and the completed policy, library and workspace -steps. Install bosn 0.1.11 or newer for the stock runner tools. +steps. The wrapper uses released bosn 0.1.13 for the stock runner tools. `local-gate.toml` enforces PR attestations. The local gate uses the existing workflow-dispatch event for the Ubuntu pair and PR/minimal for Dylint before stamping the tree. Dispatch Dylint adds cross-target work, so it is not used as a substitute for the ordinary PR selection. Library UI fixtures retain their -existing source-change condition. Neither workflow is modified or filtered; -PR verification is confined to PR events. Board builds, extended/full +existing source-change condition. Shared replay coverage binds every Ubuntu action and command to its literal +execution name, including the verifier prerequisite. Its event decision executes +on dispatch; the attestation check remains required on PRs. The original Ubuntu +terminal report is forwarded unchanged to the pinned shared checker; Dylint +retains its additional private command checks and still runs before stamping. Board builds, extended/full mode and other native hosts retain their remote coverage. The migration is tracked in [#1635](https://github.com/FastLED/fbuild/issues/1635), with Dylint parity and complete cold/warm measurements in [#1643](https://github.com/FastLED/fbuild/issues/1643). diff --git a/ci/local_dylint_gate.py b/ci/local_dylint_gate.py index 6ad02402..7583a402 100644 --- a/ci/local_dylint_gate.py +++ b/ci/local_dylint_gate.py @@ -10,6 +10,7 @@ JsonValue, RunProof, RunSelection, + bosn_command, document, verify_run, wire_string, @@ -111,8 +112,7 @@ def verify_dylint(raw: dict[str, JsonValue], workspace: Path, sha: str) -> None: def run_dylint(workspace: Path, sha: str) -> None: submitted = document( - [ - "bosn", + bosn_command( "ci", "run", "--workspace", @@ -128,9 +128,11 @@ def run_dylint(workspace: Path, sha: str) -> None: "--timeout-secs", "7200", "--json", - ] + ) ) run_id = wire_string(submitted, "run") print(f"bosn ordinary-PR Dylint run: {run_id}", flush=True) - subprocess.run(["bosn", "ci", "wait", run_id], cwd=workspace, check=True) - verify_dylint(document(["bosn", "ci", "show", run_id, "--json"]), workspace, sha) + subprocess.run(bosn_command("ci", "wait", run_id), cwd=workspace, check=True) + verify_dylint( + document(bosn_command("ci", "show", run_id, "--json")), workspace, sha + ) diff --git a/ci/local_gate.py b/ci/local_gate.py index 015d86cb..b2b1d2ff 100644 --- a/ci/local_gate.py +++ b/ci/local_gate.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import os import platform import subprocess import tempfile @@ -163,6 +164,15 @@ def verify_run( raise ValueError("selected job evidence includes a non-successful job") +def bosn_command(*arguments: str) -> list[str]: + """Use the released runner and an optional session-owned daemon root.""" + argv = ["uvx", "--from", "bosn==0.1.13", "bosn", *arguments] + state = os.environ.get("BOSN_GATE_STATE_DIR") + if state: + argv.extend(["--state-dir", str(Path(state).resolve())]) + return argv + + def output(argv: list[str]) -> str: with tempfile.TemporaryFile(mode="w+", encoding="utf-8") as stream: subprocess.run(argv, cwd=ROOT, stdout=stream, check=True) @@ -192,9 +202,12 @@ def main() -> None: "--no-project", "--with", "pyyaml==6.0.2", + "--with", + "ci-lint @ git+https://github.com/zackees/ci.yml@1047fa448f90ea1c9c2abc66441c6b399e58bc29", "python", "-m", "unittest", + "ci.test_shared_replay", "ci.test_local_gate", "ci.test_local_dylint_gate", "ci.test_fractional_workflows", @@ -226,8 +239,7 @@ def main() -> None: raise ValueError("Linux x64 tests require a native Linux x64 host and daemon") sha = output(["git", "rev-parse", "HEAD"]).strip() submitted = document( - [ - "bosn", + bosn_command( "ci", "run", "--workspace", @@ -245,16 +257,19 @@ def main() -> None: "--timeout-secs", "7200", "--json", - ] + ) ) run_id = wire_string(submitted, "run") print(f"bosn local gate run: {run_id}", flush=True) - subprocess.run(["bosn", "ci", "wait", run_id], cwd=ROOT, check=True) - verify_run( - RunProof.from_json(document(["bosn", "ci", "show", run_id, "--json"])), - ROOT, - sha, - ) + subprocess.run(bosn_command("ci", "wait", run_id), cwd=ROOT, check=True) + report = output(bosn_command("ci", "show", run_id, "--json")) + raw: JsonValue = json.loads(report) + if not isinstance(raw, dict): + raise ValueError("bosn returned a non-object terminal report") + verify_run(RunProof.from_json(raw), ROOT, sha) + report_path = os.environ.get("CI_LINT_GATE_REPLAY_REPORT") + if report_path: + Path(report_path).write_text(report, encoding="utf-8") from ci.local_dylint_gate import run_dylint run_dylint(ROOT, sha) diff --git a/ci/render_workflows.py b/ci/render_workflows.py index 81435512..c90d7610 100644 --- a/ci/render_workflows.py +++ b/ci/render_workflows.py @@ -379,17 +379,23 @@ def render_local_gate_verify() -> str: permissions: contents: read steps: - - uses: actions/checkout@v6 + - name: Checkout source + uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 2 persist-credentials: false - - uses: astral-sh/setup-uv@v3 + - name: Set up uv + uses: astral-sh/setup-uv@v3 - name: Verify source-bound local proof - if: github.event_name == 'pull_request' - run: >- - uvx --from git+https://github.com/zackees/ci.yml@9e44971219cd870a2263fa694debc94f57722405 - ci-lint local-gate verify --repo . + env: + GATE_EVENT: ${{ github.event_name }} + run: | + if [ "$GATE_EVENT" = "pull_request" ]; then + uvx --from git+https://github.com/zackees/ci.yml@1047fa448f90ea1c9c2abc66441c6b399e58bc29 ci-lint local-gate verify --repo . + else + echo "Local proof verification is required on pull requests" + fi """ diff --git a/ci/test_fractional_workflows.py b/ci/test_fractional_workflows.py index 2643bedd..efb9e16c 100644 --- a/ci/test_fractional_workflows.py +++ b/ci/test_fractional_workflows.py @@ -17,7 +17,19 @@ def test_pr_verifier_enforces_attestation_without_gating_dispatch(self): self.assertEqual("enforce", config["gate"]["mode"]) steps = self.load("ci-minimal.yml")["jobs"]["verify"]["steps"] verifier = next(step for step in steps if "local-gate verify" in step.get("run", "")) - self.assertEqual("github.event_name == 'pull_request'", verifier.get("if")) + self.assertNotIn("if", verifier) + self.assertEqual("${{ github.event_name }}", verifier["env"]["GATE_EVENT"]) + # Run the generated shell decision itself. A refused attestation must + # fail a PR; dispatch must execute the decision without requiring one. + script = "uvx() { return 7; }\n" + verifier["run"] + for event, expected in (("pull_request", 7), ("workflow_dispatch", 0)): + with self.subTest(event=event), tempfile.TemporaryFile() as stream: + result = subprocess.run( + ["bash", "-e", "-c", script], + env={**os.environ, "GATE_EVENT": event}, + stdout=stream, stderr=stream, + ) + self.assertEqual(expected, result.returncode) def test_ubuntu_build_cache_writers_receive_final_job_status(self): jobs = self.load("check-ubuntu.yml")["jobs"] diff --git a/ci/test_shared_replay.py b/ci/test_shared_replay.py new file mode 100644 index 00000000..01cbcbe7 --- /dev/null +++ b/ci/test_shared_replay.py @@ -0,0 +1,39 @@ +"""The actual reusable Ubuntu graph must have complete shared proof coverage.""" + +import tomllib +import unittest +from pathlib import Path + +from ci_lint.workflow_replay_config import parse_replay +from ci_lint.workflow_replay_static import check_replay_static + +ROOT = Path(__file__).resolve().parent.parent + + +class SharedReplayTests(unittest.TestCase): + def test_repository_declares_complete_reusable_ubuntu_graph(self) -> None: + raw = tomllib.loads((ROOT / "local-gate.toml").read_text()) + self.assertIn("replay", raw["gate"]) + findings = [] + config = parse_replay( + raw["gate"]["replay"], + source="local-gate.toml", + path="gate.replay", + findings=findings, + ) + self.assertEqual(findings, []) + self.assertIsNotNone(config) + assert config is not None + self.assertEqual(check_replay_static(config, ROOT), []) + self.assertEqual( + {job.source_job for job in config.jobs}, + { + "ci-minimal.yml:verify", + "check-ubuntu.yml:check", + "check-ubuntu.yml:python-facade-tests", + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/local-gate.toml b/local-gate.toml index 23bfaf8d..5bbb19fd 100644 --- a/local-gate.toml +++ b/local-gate.toml @@ -8,3 +8,33 @@ mode = "enforce" [gate.lanes.linux-minimal] run = ["uv", "run", "--no-project", "--python", "3.12", "python", "-m", "ci.local_gate"] tools = ["bosn", "uv"] + +# Shared proof binds every concrete Ubuntu job and its verifier prerequisite. +[gate.replay] +repository = "FastLED/fbuild" +workflow = "ci-minimal.yml" +mode = "minimal" + +[[gate.replay.jobs]] +source-job = "check-ubuntu.yml:check" +key = "linux/Check Ubuntu/Check (ubuntu-latest)" +steps = ["Checkout source", "Verify candidate SHA", "Set up uv", "Select workspace test interpreter", "Setup soldr", "Clippy", "Check tracked Python complexity ratchet", "Lint subprocess spawns", "Guard production USB VID/PID catalogue literals", "Test"] +lanes = ["linux-minimal"] + +[[gate.replay.jobs]] +source-job = "check-ubuntu.yml:python-facade-tests" +key = "linux/Check Ubuntu/Python facade tests (ubuntu-latest)" +steps = ["Checkout source", "Verify candidate SHA", "Set up uv", "Setup soldr", "Run embedded-CPython facade tests", "Test (python facades, --ignored)"] +lanes = ["linux-minimal"] + +[[gate.replay.jobs]] +source-job = "ci-minimal.yml:verify" +key = "ci-minimal/Verify local gate" +steps = ["Checkout source", "Set up uv", "Verify source-bound local proof"] +lanes = ["linux-minimal"] + +[[gate.replay.selections]] +lane = "linux-minimal" +job = "linux" +event = "workflow_dispatch" +inputs = {}