From fd9842d08978405e3803d588261f7e9a35ab271e Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Fri, 21 Aug 2026 19:45:15 -0400 Subject: [PATCH] feat(policy): allow explicit arm64 contracts --- .github/workflows/fleet-contract.yml | 18 ++++++++++++++---- .github/workflows/fleet-policy.yml | 8 +++++++- CLAUDE.md | 7 ++++--- catalog.json | 2 +- docs/workflow-catalog.md | 2 +- scripts/fleet_contract.py | 15 ++++++++++----- scripts/validate.py | 2 +- tests/test_fleet_contract.py | 23 +++++++++++++++++++++++ tests/test_fleet_policy.py | 14 +++++++++++++- 9 files changed, 74 insertions(+), 17 deletions(-) diff --git a/.github/workflows/fleet-contract.yml b/.github/workflows/fleet-contract.yml index 15c9efc..dd612d0 100644 --- a/.github/workflows/fleet-contract.yml +++ b/.github/workflows/fleet-contract.yml @@ -11,6 +11,10 @@ on: description: Exact workflows repository commit containing the contract implementation. type: string required: true + allow-arm64: + description: Allow an explicit ARM64/AArch64 installer and package contract. + type: boolean + default: false timeout-minutes: type: number default: 5 @@ -38,7 +42,13 @@ jobs: - name: Validate repository contract env: PROFILE: ${{ inputs.profile }} - run: >- - python3 workflow-library/scripts/fleet_contract.py check - --repo target - --profile "$PROFILE" + ALLOW_ARM64: ${{ inputs.allow-arm64 }} + run: | + args=() + if [[ "$ALLOW_ARM64" == "true" ]]; then + args+=(--allow-arm64) + fi + python3 workflow-library/scripts/fleet_contract.py check \ + --repo target \ + --profile "$PROFILE" \ + "${args[@]}" diff --git a/.github/workflows/fleet-policy.yml b/.github/workflows/fleet-policy.yml index 29e6444..5462dd5 100644 --- a/.github/workflows/fleet-policy.yml +++ b/.github/workflows/fleet-policy.yml @@ -9,6 +9,10 @@ on: allow-hosted-fast: type: boolean default: false + allow-arm64: + description: Allow an explicit ARM64/AArch64 workflow contract. + type: boolean + default: false timeout-minutes: type: number default: 10 @@ -32,6 +36,7 @@ jobs: env: RELEASE_FILE_REGEX: ${{ inputs.release-file-regex }} ALLOW_HOSTED_FAST: ${{ inputs.allow-hosted-fast }} + ALLOW_ARM64: ${{ inputs.allow-arm64 }} run: | python - <<'PY' import os, pathlib, re, sys, yaml @@ -41,6 +46,7 @@ jobs: needs_result = re.compile(r"needs\.([A-Za-z0-9_-]+)\.result") release_re = re.compile(os.environ["RELEASE_FILE_REGEX"]) allow_hosted_fast = os.environ["ALLOW_HOSTED_FAST"].lower() == "true" + allow_arm64 = os.environ.get("ALLOW_ARM64", "false").lower() == "true" sha = re.compile(r"^[0-9a-f]{40}$") errors = [] @@ -53,7 +59,7 @@ jobs: if "permissions" not in data: errors.append(f"{path}: missing top-level permissions") - if re.search(r"(?i)\b(arm64|aarch64|linux/arm64|setup-qemu)\b", text): + if not allow_arm64 and re.search(r"(?i)\b(arm64|aarch64|linux/arm64|setup-qemu)\b", text): errors.append(f"{path}: ARM/QEMU contract is forbidden") for match in re.finditer(r"(?m)^\s*uses:\s*([^#\s]+)", text): diff --git a/CLAUDE.md b/CLAUDE.md index efa17f2..078be03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,10 @@ moving workflow tags. ## Invariants - Fast Linux validation: self-hosted with exactly one `ci-pool-*` label. -- Heavy release work: release-only, GitHub-hosted, x86_64/amd64 only. -- No ARM/AArch64/QEMU build, package, installer, or documentation contract. +- Heavy release work: release-only and GitHub-hosted. +- ARM64/AArch64 workflow and repository contracts require the reusable + workflows' explicit `allow-arm64` opt-in; callers remain x86_64/amd64-only + by default. - External actions use full 40-character SHAs. - Top-level least-privilege permissions, job timeouts, locked installs, and `persist-credentials: false` are mandatory. @@ -27,4 +29,3 @@ actionlint -config-file .github/actionlint.yaml Use `apply_patch` for edits. Preserve unrelated dirt. Create/claim a bead before non-trivial implementation. - diff --git a/catalog.json b/catalog.json index e5430ca..3336088 100644 --- a/catalog.json +++ b/catalog.json @@ -93,7 +93,7 @@ {"file": "fast-python.yml", "kind": "fast", "category": "python", "summary": "Frozen uv sync, Ruff lint/format, ty typecheck, and Pytest."}, {"file": "fast-rust.yml", "kind": "fast", "category": "rust", "summary": "Optional project setup, Rust fmt, check, Clippy, targeted tests, and MinIO kache."}, {"file": "fleet-contract.yml", "kind": "fast", "category": "policy", "summary": "Enforce durable repository, metadata, toolchain, documentation, and hygiene contracts."}, - {"file": "fleet-policy.yml", "kind": "fast", "category": "policy", "summary": "Enforce runner, action pin, permission, timeout, ARM, release, and gate-wiring boundaries."}, + {"file": "fleet-policy.yml", "kind": "fast", "category": "policy", "summary": "Enforce runner, action pin, permission, timeout, opt-in architecture, release, and gate-wiring boundaries."}, {"file": "github-release.yml", "kind": "release", "category": "release", "summary": "Attest and attach exact workflow artifacts to an existing GitHub release."}, {"file": "hosted-android-release.yml", "kind": "release", "category": "android", "summary": "Hosted Android release lint, tests, assembly, and managed-device evidence."}, {"file": "hosted-bun-web-release.yml", "kind": "release", "category": "typescript", "summary": "Hosted Bun coverage, production build, E2E, and artifact assembly."}, diff --git a/docs/workflow-catalog.md b/docs/workflow-catalog.md index 32affb0..dfa9a2f 100644 --- a/docs/workflow-catalog.md +++ b/docs/workflow-catalog.md @@ -62,7 +62,7 @@ pure-Rust callers free of Python tooling. | `fast-pnpm.yml` | fast | TypeScript | Locked pnpm audit, lint, typecheck, tests, contracts | | `fast-python.yml` | fast | Python | Frozen uv sync, Ruff lint/format, ty typecheck, Pytest | | `fast-rust.yml` | fast | Rust | Optional project setup, fmt, check, Clippy, targeted tests, MinIO kache | -| `fleet-policy.yml` | fast | Policy | Runner, action, permission, timeout, architecture, release policy, and gate wiring (no job may skip a required check silently) | +| `fleet-policy.yml` | fast | Policy | Runner, action, permission, timeout, opt-in architecture, release policy, and gate wiring (no job may skip a required check silently) | | `github-release.yml` | release | Release | Attest and attach exact artifacts to an existing release | | `hosted-android-release.yml` | release | Android | Hosted release lint, tests, assembly, device evidence | | `hosted-bun-web-release.yml` | release | TypeScript | Bun coverage, production build, performance, E2E | diff --git a/scripts/fleet_contract.py b/scripts/fleet_contract.py index a1a32ce..a03284d 100755 --- a/scripts/fleet_contract.py +++ b/scripts/fleet_contract.py @@ -335,7 +335,9 @@ def check_symlinks(repo: pathlib.Path) -> list[Finding]: return findings -def check_scoped_text(repo: pathlib.Path) -> list[Finding]: +def check_scoped_text( + repo: pathlib.Path, *, allow_arm64: bool = False +) -> list[Finding]: findings: list[Finding] = [] for path in tracked_files(repo): rel = relative(repo, path) @@ -369,7 +371,7 @@ def check_scoped_text(repo: pathlib.Path) -> list[Finding]: ) ) - if arm_scope and ARM_CONTRACT.search(text): + if not allow_arm64 and arm_scope and ARM_CONTRACT.search(text): findings.append( Finding( "no-arm-contract", @@ -591,7 +593,9 @@ def check_docs(repo: pathlib.Path) -> list[Finding]: return findings -def check(repo: pathlib.Path, profile: str) -> list[Finding]: +def check( + repo: pathlib.Path, profile: str, *, allow_arm64: bool = False +) -> list[Finding]: findings: list[Finding] = [] if profile == "rust": findings.extend(check_toolchain(repo)) @@ -600,7 +604,7 @@ def check(repo: pathlib.Path, profile: str) -> list[Finding]: findings.extend(check_exact_dependencies(repo)) findings.extend(check_descriptions(repo)) findings.extend(check_symlinks(repo)) - findings.extend(check_scoped_text(repo)) + findings.extend(check_scoped_text(repo, allow_arm64=allow_arm64)) findings.extend(check_tracked_paths(repo)) findings.extend(check_env_schema(repo)) findings.extend(check_docs(repo)) @@ -617,13 +621,14 @@ def parse_args() -> argparse.Namespace: choices=("rust", "python", "node", "go", "ops"), required=True, ) + check_parser.add_argument("--allow-arm64", action="store_true") return parser.parse_args() def main() -> int: args = parse_args() repo = args.repo.resolve() - findings = check(repo, args.profile) + findings = check(repo, args.profile, allow_arm64=args.allow_arm64) if findings: for finding in findings: print(finding.render()) diff --git a/scripts/validate.py b/scripts/validate.py index 3850d80..cb429df 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -71,7 +71,7 @@ def validate() -> list[str]: kind = entry["kind"] text = path.read_text() - if path.name != "fleet-policy.yml" and FORBIDDEN_ARCH.search(text): + if path.name not in {"fleet-policy.yml", "fleet-contract.yml"} and FORBIDDEN_ARCH.search(text): errors.append(f"{path.name}: forbidden ARM/QEMU contract") if "permissions" not in data: diff --git a/tests/test_fleet_contract.py b/tests/test_fleet_contract.py index 7420ba6..b3a197d 100644 --- a/tests/test_fleet_contract.py +++ b/tests/test_fleet_contract.py @@ -142,6 +142,29 @@ def test_reports_independent_contract_violations(self) -> None: with self.subTest(check=check): self.assertIn(check, result.stdout) + def test_arm64_contract_can_be_explicitly_enabled(self) -> None: + repo = self.make_rust_repo() + (repo / "install.sh").write_text("#!/bin/sh\necho aarch64\n") + subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True) + + result = subprocess.run( + [ + "python3", + str(CHECKER), + "check", + "--repo", + str(repo), + "--profile", + "rust", + "--allow-arm64", + ], + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_docs_frontmatter_checks_tracked_markdown_only(self) -> None: repo = self.make_rust_repo() (repo / "docs/guide.md").write_text("# Missing metadata\n") diff --git a/tests/test_fleet_policy.py b/tests/test_fleet_policy.py index 8caf1a9..090ec97 100644 --- a/tests/test_fleet_policy.py +++ b/tests/test_fleet_policy.py @@ -57,7 +57,9 @@ def policy_script() -> str: return run.split("python - <<'PY'\n", 1)[1].rsplit("PY\n", 1)[0] -def run_policy(workflows: dict[str, str]) -> subprocess.CompletedProcess[str]: +def run_policy( + workflows: dict[str, str], *, allow_arm64: bool = False +) -> subprocess.CompletedProcess[str]: with tempfile.TemporaryDirectory() as temp: root = pathlib.Path(temp) (root / ".github/workflows").mkdir(parents=True) @@ -72,6 +74,7 @@ def run_policy(workflows: dict[str, str]) -> subprocess.CompletedProcess[str]: "PATH": "/usr/local/bin:/usr/bin:/bin", "RELEASE_FILE_REGEX": r"(^|/)(release|publish|deploy)[^/]*\.ya?ml$", "ALLOW_HOSTED_FAST": "false", + "ALLOW_ARM64": str(allow_arm64).lower(), }, capture_output=True, text=True, @@ -80,6 +83,15 @@ def run_policy(workflows: dict[str, str]) -> subprocess.CompletedProcess[str]: class GateWiringPolicyTests(unittest.TestCase): + def test_arm64_requires_explicit_opt_in(self) -> None: + arm = CLEAN_WORKFLOW.replace("echo building", "echo linux/arm64") + blocked = run_policy({"ci.yml": arm}) + allowed = run_policy({"ci.yml": arm}, allow_arm64=True) + + self.assertEqual(blocked.returncode, 1, blocked.stdout) + self.assertIn("ARM/QEMU contract is forbidden", blocked.stdout) + self.assertEqual(allowed.returncode, 0, allowed.stdout + allowed.stderr) + def test_correctly_wired_gates_pass(self) -> None: result = run_policy({"ci.yml": CLEAN_WORKFLOW}) self.assertEqual(result.returncode, 0, result.stdout + result.stderr)