Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/check-ubuntu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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
Expand Down
18 changes: 12 additions & 6 deletions .github/workflows/ci-minimal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
12 changes: 7 additions & 5 deletions ci/local_dylint_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
JsonValue,
RunProof,
RunSelection,
bosn_command,
document,
verify_run,
wire_string,
Expand Down Expand Up @@ -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",
Expand All @@ -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
)
33 changes: 24 additions & 9 deletions ci/local_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import json
import os
import platform
import subprocess
import tempfile
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand Down
18 changes: 12 additions & 6 deletions ci/render_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""


Expand Down
14 changes: 13 additions & 1 deletion ci/test_fractional_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
39 changes: 39 additions & 0 deletions ci/test_shared_replay.py
Original file line number Diff line number Diff line change
@@ -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()
30 changes: 30 additions & 0 deletions local-gate.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Loading