Skip to content

Commit c501322

Browse files
authored
feat: enforce GitHub fleet repository contract
* feat: define GitHub fleet repository contract * feat: add read-only GitHub fleet contract checker * ci: continuously validate GitHub fleet controls
1 parent 997c532 commit c501322

3 files changed

Lines changed: 330 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
name: Fleet Contract
3+
4+
on:
5+
workflow_dispatch:
6+
schedule:
7+
- cron: "17 5 * * 1"
8+
pull_request:
9+
branches:
10+
- main
11+
paths:
12+
- "fleet/repositories.json"
13+
- "scripts/check-github-fleet.py"
14+
- ".github/workflows/fleet-contract.yml"
15+
16+
permissions:
17+
contents: read
18+
19+
concurrency:
20+
group: ${{ github.workflow }}-${{ github.ref }}
21+
cancel-in-progress: true
22+
23+
jobs:
24+
validate-manifest:
25+
name: Validate fleet manifest
26+
runs-on: ubuntu-latest
27+
timeout-minutes: 5
28+
steps:
29+
- name: Checkout
30+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
31+
32+
- name: Validate schema and repository uniqueness
33+
run: python scripts/check-github-fleet.py --manifest-only
34+
35+
validate-live:
36+
name: Validate live GitHub controls
37+
runs-on: ubuntu-latest
38+
timeout-minutes: 10
39+
steps:
40+
- name: Checkout
41+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
42+
43+
- name: Check repository and ruleset invariants
44+
env:
45+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46+
run: python scripts/check-github-fleet.py

fleet/repositories.json

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
{
2+
"schema_version": 1,
3+
"owner": "rmednitzer",
4+
"defaults": {
5+
"default_branch": "main",
6+
"visibility": "public",
7+
"require_active_ruleset": true,
8+
"require_pull_request": true,
9+
"require_no_bypass": true,
10+
"require_non_fast_forward": true,
11+
"require_deletion_protection": true,
12+
"require_status_checks": true
13+
},
14+
"repositories": [
15+
{
16+
"name": "infra",
17+
"ruleset": "main-protection",
18+
"preferred_aggregate_context": "ci-success",
19+
"aggregate_gate_state": "planned"
20+
},
21+
{
22+
"name": "agents",
23+
"ruleset": "main-protection",
24+
"preferred_aggregate_context": "ci-success",
25+
"aggregate_gate_state": "migration-pending"
26+
},
27+
{
28+
"name": "ai-stack",
29+
"ruleset": "main-protection",
30+
"preferred_aggregate_context": "ci-success",
31+
"aggregate_gate_state": "enforced"
32+
},
33+
{
34+
"name": "aiops-mcp",
35+
"ruleset": "main-protection",
36+
"preferred_aggregate_context": "ci-success",
37+
"aggregate_gate_state": "enforced"
38+
},
39+
{
40+
"name": "core-graph",
41+
"ruleset": "main-protection",
42+
"preferred_aggregate_context": "ci-success",
43+
"aggregate_gate_state": "migration-pending"
44+
},
45+
{
46+
"name": "relay-shell",
47+
"ruleset": "main-protection",
48+
"preferred_aggregate_context": "ci-success",
49+
"aggregate_gate_state": "migration-pending"
50+
},
51+
{
52+
"name": "6dof-ascent-sim",
53+
"ruleset": "main-protection",
54+
"preferred_aggregate_context": "ci-success",
55+
"aggregate_gate_state": "migration-pending"
56+
},
57+
{
58+
"name": "automation",
59+
"ruleset": "main-protection",
60+
"preferred_aggregate_context": "ci-success",
61+
"aggregate_gate_state": "migration-pending"
62+
},
63+
{
64+
"name": "runbooks",
65+
"ruleset": "main-protection",
66+
"preferred_aggregate_context": "ci-success",
67+
"aggregate_gate_state": "migration-pending"
68+
},
69+
{
70+
"name": "rmednitzer.github.io",
71+
"ruleset": "main-protection",
72+
"preferred_aggregate_context": "validate",
73+
"aggregate_gate_state": "enforced"
74+
},
75+
{
76+
"name": "renovate-config",
77+
"ruleset": "protect-default-branch",
78+
"preferred_aggregate_context": "Validate re-export",
79+
"aggregate_gate_state": "enforced",
80+
"lifecycle": "archive-candidate",
81+
"superseded_by": "infra/renovate-preset.json"
82+
}
83+
]
84+
}

scripts/check-github-fleet.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
#!/usr/bin/env python3
2+
"""Validate repository-level GitHub controls against fleet/repositories.json.
3+
4+
The checker is deliberately read-only and stdlib-only. It validates hard invariants
5+
(server-side branch protection, PR enforcement, no bypass actors, force-push/deletion
6+
protection, and at least one required status check) and reports aggregate-gate drift
7+
as an advisory until each repository is migrated explicitly in the manifest.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import json
14+
import os
15+
import sys
16+
import urllib.error
17+
import urllib.request
18+
from pathlib import Path
19+
from typing import Any
20+
21+
API = "https://api.github.com"
22+
23+
24+
def load_json(path: Path) -> dict[str, Any]:
25+
with path.open(encoding="utf-8") as handle:
26+
return json.load(handle)
27+
28+
29+
def api_get(path: str, token: str | None) -> Any:
30+
headers = {
31+
"Accept": "application/vnd.github+json",
32+
"X-GitHub-Api-Version": "2022-11-28",
33+
"User-Agent": "rmednitzer-infra-fleet-contract",
34+
}
35+
if token:
36+
headers["Authorization"] = f"Bearer {token}"
37+
request = urllib.request.Request(f"{API}{path}", headers=headers)
38+
try:
39+
with urllib.request.urlopen(request, timeout=20) as response:
40+
return json.load(response)
41+
except urllib.error.HTTPError as exc:
42+
body = exc.read().decode("utf-8", errors="replace")
43+
raise RuntimeError(f"GET {path} -> HTTP {exc.code}: {body[:300]}") from exc
44+
except urllib.error.URLError as exc:
45+
raise RuntimeError(f"GET {path} failed: {exc.reason}") from exc
46+
47+
48+
def rule_by_type(rules: list[dict[str, Any]], rule_type: str) -> dict[str, Any] | None:
49+
return next((rule for rule in rules if rule.get("type") == rule_type), None)
50+
51+
52+
def required_contexts(rules: list[dict[str, Any]]) -> list[str]:
53+
rule = rule_by_type(rules, "required_status_checks")
54+
if not rule:
55+
return []
56+
checks = rule.get("parameters", {}).get("required_status_checks", [])
57+
return [str(check.get("context")) for check in checks if check.get("context")]
58+
59+
60+
def validate_manifest_only(manifest: dict[str, Any]) -> list[str]:
61+
errors: list[str] = []
62+
if manifest.get("schema_version") != 1:
63+
errors.append("unsupported schema_version")
64+
owner = manifest.get("owner")
65+
if not isinstance(owner, str) or not owner:
66+
errors.append("owner must be a non-empty string")
67+
repos = manifest.get("repositories")
68+
if not isinstance(repos, list) or not repos:
69+
errors.append("repositories must be a non-empty list")
70+
return errors
71+
names: set[str] = set()
72+
for index, repo in enumerate(repos):
73+
if not isinstance(repo, dict):
74+
errors.append(f"repositories[{index}] must be an object")
75+
continue
76+
name = repo.get("name")
77+
if not isinstance(name, str) or not name:
78+
errors.append(f"repositories[{index}].name must be a non-empty string")
79+
continue
80+
if name in names:
81+
errors.append(f"duplicate repository: {name}")
82+
names.add(name)
83+
if not isinstance(repo.get("ruleset"), str) or not repo["ruleset"]:
84+
errors.append(f"{name}: ruleset must be a non-empty string")
85+
state = repo.get("aggregate_gate_state")
86+
if state not in {"enforced", "migration-pending", "planned"}:
87+
errors.append(f"{name}: invalid aggregate_gate_state {state!r}")
88+
return errors
89+
90+
91+
def validate_repo(
92+
owner: str,
93+
repo_cfg: dict[str, Any],
94+
defaults: dict[str, Any],
95+
token: str | None,
96+
) -> tuple[list[str], list[str]]:
97+
name = repo_cfg["name"]
98+
hard: list[str] = []
99+
advisory: list[str] = []
100+
101+
metadata = api_get(f"/repos/{owner}/{name}", token)
102+
expected_branch = defaults.get("default_branch", "main")
103+
if metadata.get("default_branch") != expected_branch:
104+
hard.append(f"default branch is {metadata.get('default_branch')!r}, expected {expected_branch!r}")
105+
expected_visibility = defaults.get("visibility")
106+
if expected_visibility and metadata.get("visibility") != expected_visibility:
107+
hard.append(f"visibility is {metadata.get('visibility')!r}, expected {expected_visibility!r}")
108+
109+
rulesets = api_get(f"/repos/{owner}/{name}/rulesets", token)
110+
wanted = repo_cfg["ruleset"]
111+
candidates = [item for item in rulesets if item.get("name") == wanted]
112+
if not candidates:
113+
hard.append(f"ruleset {wanted!r} not found")
114+
return hard, advisory
115+
116+
ruleset_id = candidates[0].get("id")
117+
ruleset = api_get(f"/repos/{owner}/{name}/rulesets/{ruleset_id}", token)
118+
rules = ruleset.get("rules", [])
119+
120+
if defaults.get("require_active_ruleset", True) and ruleset.get("enforcement") != "active":
121+
hard.append(f"ruleset enforcement is {ruleset.get('enforcement')!r}, expected 'active'")
122+
if defaults.get("require_no_bypass", True) and ruleset.get("bypass_actors"):
123+
hard.append(f"ruleset has bypass actors: {ruleset.get('bypass_actors')!r}")
124+
if defaults.get("require_pull_request", True) and not rule_by_type(rules, "pull_request"):
125+
hard.append("missing pull_request rule")
126+
if defaults.get("require_non_fast_forward", True) and not rule_by_type(rules, "non_fast_forward"):
127+
hard.append("missing non_fast_forward rule")
128+
if defaults.get("require_deletion_protection", True) and not rule_by_type(rules, "deletion"):
129+
hard.append("missing deletion protection rule")
130+
131+
contexts = required_contexts(rules)
132+
if defaults.get("require_status_checks", True) and not contexts:
133+
hard.append("required_status_checks is absent or empty")
134+
135+
preferred = repo_cfg.get("preferred_aggregate_context")
136+
gate_state = repo_cfg.get("aggregate_gate_state")
137+
if preferred and gate_state == "enforced" and contexts != [preferred]:
138+
hard.append(f"required contexts are {contexts!r}; enforced aggregate contract requires [{preferred!r}]")
139+
elif preferred and gate_state in {"planned", "migration-pending"} and contexts != [preferred]:
140+
advisory.append(f"aggregate migration pending: current={contexts!r}, target=[{preferred!r}]")
141+
142+
if rule_by_type(rules, "required_linear_history") and metadata.get("allow_merge_commit"):
143+
advisory.append("merge commits are enabled although required_linear_history is enforced")
144+
145+
if repo_cfg.get("lifecycle") == "archive-candidate" and not metadata.get("archived"):
146+
advisory.append(f"lifecycle={repo_cfg['lifecycle']}; superseded by {repo_cfg.get('superseded_by', 'unspecified')}")
147+
148+
return hard, advisory
149+
150+
151+
def main() -> int:
152+
parser = argparse.ArgumentParser()
153+
parser.add_argument("--manifest", default="fleet/repositories.json")
154+
parser.add_argument("--manifest-only", action="store_true")
155+
parser.add_argument("--strict-advisories", action="store_true")
156+
args = parser.parse_args()
157+
158+
manifest = load_json(Path(args.manifest))
159+
manifest_errors = validate_manifest_only(manifest)
160+
if manifest_errors:
161+
for error in manifest_errors:
162+
print(f"ERROR manifest: {error}")
163+
return 2
164+
if args.manifest_only:
165+
print(f"OK manifest: {len(manifest['repositories'])} repositories")
166+
return 0
167+
168+
owner = manifest["owner"]
169+
defaults = manifest.get("defaults", {})
170+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
171+
hard_count = 0
172+
advisory_count = 0
173+
174+
for repo_cfg in manifest["repositories"]:
175+
name = repo_cfg["name"]
176+
try:
177+
hard, advisory = validate_repo(owner, repo_cfg, defaults, token)
178+
except RuntimeError as exc:
179+
hard = [str(exc)]
180+
advisory = []
181+
if not hard and not advisory:
182+
print(f"OK {owner}/{name}")
183+
else:
184+
for message in hard:
185+
hard_count += 1
186+
print(f"ERROR {owner}/{name}: {message}")
187+
for message in advisory:
188+
advisory_count += 1
189+
print(f"WARN {owner}/{name}: {message}")
190+
191+
print(f"SUMMARY repositories={len(manifest['repositories'])} errors={hard_count} advisories={advisory_count}")
192+
if hard_count:
193+
return 1
194+
if args.strict_advisories and advisory_count:
195+
return 1
196+
return 0
197+
198+
199+
if __name__ == "__main__":
200+
raise SystemExit(main())

0 commit comments

Comments
 (0)