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
18 changes: 14 additions & 4 deletions .github/workflows/fleet-contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[@]}"
8 changes: 7 additions & 1 deletion .github/workflows/fleet-policy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 = []

Expand All @@ -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):
Expand Down
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

2 changes: 1 addition & 1 deletion catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."},
Expand Down
2 changes: 1 addition & 1 deletion docs/workflow-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
15 changes: 10 additions & 5 deletions scripts/fleet_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand All @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion scripts/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions tests/test_fleet_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
14 changes: 13 additions & 1 deletion tests/test_fleet_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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)
Expand Down