diff --git a/.gitea/workflows/backend-build.yml b/.gitea/workflows/backend-build.yml index b9fc418..78cfd4b 100644 --- a/.gitea/workflows/backend-build.yml +++ b/.gitea/workflows/backend-build.yml @@ -11,6 +11,7 @@ on: type: string env: + BUILD_LANE: candidate CARGO_TERM_COLOR: never PYTHONDONTWRITEBYTECODE: '1' EXPECTED_BUILD_SHA: ${{ inputs.expected_sha }} @@ -29,6 +30,12 @@ jobs: RUSTUP_HOME: ${{ runner.temp }}/rustup-backend-candidate CARGO_HOME: ${{ runner.temp }}/cargo-backend-candidate steps: + - name: Require exact main dispatch before checkout + run: | + test "$GITHUB_EVENT_NAME" = workflow_dispatch || { echo 'Candidate event must be workflow_dispatch.' >&2; exit 1; } + test "$GITHUB_REF" = refs/heads/main || { echo 'Candidate dispatch must select main.' >&2; exit 1; } + [[ "$EXPECTED_BUILD_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo 'Candidate expected_sha must be a full lowercase commit.' >&2; exit 1; } + test "$GITHUB_SHA" = "$EXPECTED_BUILD_SHA" || { echo 'Dispatch main moved from the reviewed expected_sha.' >&2; exit 1; } - uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8c8369b..2315b77 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -9,6 +9,7 @@ on: branches: [main] env: + BUILD_LANE: ci CARGO_TERM_COLOR: never PYTHONDONTWRITEBYTECODE: '1' EXPECTED_BUILD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} diff --git a/tools/check-build-source.py b/tools/check-build-source.py index 3d7f9f2..55ecba4 100755 --- a/tools/check-build-source.py +++ b/tools/check-build-source.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Admit one reviewed canonical source or its exact Builds merge wrapper.""" +import json import os +from pathlib import Path import re import subprocess import sys @@ -11,7 +13,11 @@ class Refusal(ValueError): def git(*args): - return subprocess.check_output(["git", *args], text=True, stderr=subprocess.PIPE).strip() + try: + return subprocess.check_output(["git", *args], text=True, stderr=subprocess.PIPE).strip() + except subprocess.CalledProcessError as error: + cause = " ".join((error.stderr or "Git returned no diagnostic").split())[:600] + raise Refusal(f"Git evidence failed (exit {error.returncode}): {cause}") from None def commit(value, label): @@ -48,16 +54,36 @@ def admit(source, expected_head=None, require_wrapper=False): return {"source_commit": source, "build_commit": head, "source_tree": tree} -def main(): - if os.environ.get("GITHUB_REPOSITORY") != "Builds/smirk-backend-core": +def build_context(environment): + if environment.get("GITHUB_REPOSITORY") != "Builds/smirk-backend-core": raise Refusal("this workflow is admitted only on Builds/smirk-backend-core") - event = os.environ.get("GITHUB_EVENT_NAME") - expected = os.environ.get("EXPECTED_BUILD_SHA") - if not expected: - raise Refusal("this build requires the exact reviewed event or dispatch head") - if event not in {"push", "pull_request", "workflow_dispatch"}: - raise Refusal("this build event is not admitted") - wrapper = event != "pull_request" + event = environment.get("GITHUB_EVENT_NAME") + lane = environment.get("BUILD_LANE") + expected = commit(environment.get("EXPECTED_BUILD_SHA"), "expected event or dispatch SHA") + if lane == "candidate": + if event != "workflow_dispatch": + raise Refusal("candidate builds require workflow_dispatch") + elif lane == "ci": + if event not in {"push", "pull_request"}: + raise Refusal("fundless CI requires push or pull_request") + else: + raise Refusal("build lane is not admitted") + if event == "pull_request": + try: + payload = json.loads(Path(environment.get("GITHUB_EVENT_PATH", "")).read_text()) + head = payload["pull_request"]["head"]["sha"] + base = payload["pull_request"]["base"]["ref"] + except (OSError, ValueError, KeyError, TypeError) as error: + raise Refusal(f"pull-request event evidence is unavailable: {type(error).__name__}") from None + if head != expected or base != "main": + raise Refusal("CI checkout does not match the exact main-targeted pull-request event head") + elif environment.get("GITHUB_REF") != "refs/heads/main" or environment.get("GITHUB_SHA") != expected: + raise Refusal("main event head must equal the reviewed full expected_sha") + return expected, event != "pull_request" + + +def main(): + expected, wrapper = build_context(os.environ) canonical = source_for_checkout(wrapper) result = admit(canonical, expected, require_wrapper=wrapper) print(f"Admitted canonical source {result['source_commit']} at Builds {result['build_commit']}") @@ -68,5 +94,3 @@ if __name__ == "__main__": main() except Refusal as error: sys.exit(f"Build source refused: {error}") - except subprocess.CalledProcessError: - sys.exit("Build source refused: Git could not resolve the reviewed source or checkout") diff --git a/tools/stage-backend-candidate.py b/tools/stage-backend-candidate.py index 11cdb78..f3a8cd3 100755 --- a/tools/stage-backend-candidate.py +++ b/tools/stage-backend-candidate.py @@ -40,8 +40,10 @@ def main(): parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() os.chdir(ROOT) - admitted = source.admit(source.source_for_checkout(True), - os.environ.get("EXPECTED_BUILD_SHA", ""), require_wrapper=True) + expected, wrapper = source.build_context(os.environ) + if not wrapper or os.environ.get("BUILD_LANE") != "candidate": + raise source.Refusal("artifact staging requires the admitted candidate dispatch lane") + admitted = source.admit(source.source_for_checkout(True), expected, require_wrapper=True) binary = ROOT / "target/x86_64-unknown-linux-gnu/release/smirk-backend-core" required = elf_requirements(binary) rustc = subprocess.check_output(["rustc", "--version"], text=True).strip() diff --git a/tools/tests/test_build_admission.py b/tools/tests/test_build_admission.py index 1843e4c..22a34e7 100644 --- a/tools/tests/test_build_admission.py +++ b/tools/tests/test_build_admission.py @@ -101,6 +101,53 @@ class Admission(unittest.TestCase): source.main() +class EventBoundary(unittest.TestCase): + def setUp(self): + self.head = "a" * 40 + self.environment = {"GITHUB_REPOSITORY": "Builds/smirk-backend-core", + "BUILD_LANE": "candidate", "GITHUB_EVENT_NAME": "workflow_dispatch", + "GITHUB_REF": "refs/heads/main", "GITHUB_SHA": self.head, + "EXPECTED_BUILD_SHA": self.head} + + def test_exact_dispatch_event_admitted(self): + self.assertEqual(source.build_context(self.environment), (self.head, True)) + + def test_bad_ref_event_sha_or_malformed_input_refused(self): + for key, value in (("GITHUB_REF", "refs/heads/topic"), ("GITHUB_EVENT_NAME", "push"), + ("GITHUB_SHA", "b" * 40), ("EXPECTED_BUILD_SHA", "main"), + ("EXPECTED_BUILD_SHA", "a" * 39), ("BUILD_LANE", "unknown")): + with self.subTest(key=key, value=value), self.assertRaises(source.Refusal): + source.build_context({**self.environment, key: value}) + + def test_push_requires_main_and_exact_event_sha(self): + env = {**self.environment, "BUILD_LANE": "ci", "GITHUB_EVENT_NAME": "push"} + self.assertEqual(source.build_context(env), (self.head, True)) + with self.assertRaises(source.Refusal): + source.build_context({**env, "GITHUB_REF": "refs/heads/topic"}) + + def test_pr_binds_actual_event_head_and_target(self): + import json + with tempfile.TemporaryDirectory() as directory: + event = Path(directory) / "event.json" + event.write_text(json.dumps({"pull_request": {"head": {"sha": self.head}, "base": {"ref": "main"}}})) + env = {**self.environment, "BUILD_LANE": "ci", "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_EVENT_PATH": str(event)} + self.assertEqual(source.build_context(env), (self.head, False)) + with self.assertRaises(source.Refusal): + source.build_context({**env, "EXPECTED_BUILD_SHA": "b" * 40}) + event.write_text(json.dumps({"pull_request": {"head": {"sha": self.head}, "base": {"ref": "topic"}}})) + with self.assertRaises(source.Refusal): + source.build_context(env) + + def test_git_refusal_reports_bounded_underlying_cause(self): + failure = subprocess.CalledProcessError(128, ["git"], stderr="fatal: object unavailable " + "x" * 2000) + with patch.object(subprocess, "check_output", side_effect=failure): + with self.assertRaises(source.Refusal) as caught: + source.git("rev-parse", "HEAD") + self.assertIn("object unavailable", str(caught.exception)) + self.assertLess(len(str(caught.exception)), 700) + + class BinaryCompatibility(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix="smirk-elf-fixture-")