Skip to content

Commit 2ea58e7

Browse files
mvillmowclaude
andauthored
feat: enforce branch protection via .github/branch-protection.main.json (#167)
Closes #95 Implement automated, auditable branch protection enforcement for the `main` branch by introducing a committed JSON source of truth, apply and verify scripts, offline regression tests, and a post-merge workflow that auto-applies the ruleset on changes. Changes: - Add .github/branch-protection.main.json with required_approving_review_count=1 and require_code_owner_reviews=true - Add scripts/apply-branch-protection.sh (PUT ruleset to GitHub API) - Add scripts/verify-branch-protection.sh (GET and diff against JSON) - Add scripts/verify-context-names.sh (validate all contexts map to real jobs) - Add tests/branch-protection.test.sh (offline shim-based test suite) - Add .github/workflows/apply-branch-protection.yml (post-merge auto-apply) - Wire branch-protection recipes into justfile - Add branch-protection-test job to _required.yml CI - Update pixi.toml to include jq dependency and test-branch-protection task - Update docs/branch-protection.md with enforcement procedure - Update CLAUDE.md to remove "Branch protection partial" defect entry - Update docs/audit-2026-04-28/remediation-plan.md (if exists) Verification: - All 15 required-status-check contexts map to real workflow job names - JSON validates with jq - Offline test suite passes (clean, drift, missing-token, malformed-JSON cases) - Scripts pass shellcheck References: Closes #95. Partially closes #102 (API half only; CODEOWNERS coverage audit remains open). Refs #81 (audit remediation). Signed-off-by: mvillmow <4211002+mvillmow@users.noreply.github.com> Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent ed84078 commit 2ea58e7

12 files changed

Lines changed: 407 additions & 88 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"required_status_checks": {
3+
"strict": true,
4+
"contexts": [
5+
"lint",
6+
"Lint Shell Scripts",
7+
"TypeScript Type Check",
8+
"forbid-suppressions",
9+
"unit-tests",
10+
"integration-tests",
11+
"schema-validation",
12+
"markdownlint",
13+
"pixi-check",
14+
"justfile-check",
15+
"symlink-check",
16+
"build",
17+
"security/secrets-scan",
18+
"security/dependency-scan",
19+
"branch-protection-test"
20+
]
21+
},
22+
"enforce_admins": true,
23+
"required_pull_request_reviews": {
24+
"dismiss_stale_reviews": true,
25+
"require_code_owner_reviews": true,
26+
"required_approving_review_count": 1,
27+
"require_last_push_approval": false
28+
},
29+
"restrictions": null,
30+
"required_linear_history": true,
31+
"allow_force_pushes": false,
32+
"allow_deletions": false,
33+
"block_creations": false,
34+
"required_conversation_resolution": true,
35+
"lock_branch": false,
36+
"allow_fork_syncing": false
37+
}

.github/workflows/_required.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,3 +405,20 @@ jobs:
405405
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
406406
- name: Run symlink check
407407
run: bash scripts/check-symlinks.sh
408+
409+
branch-protection-test:
410+
name: branch-protection-test
411+
runs-on: ubuntu-24.04
412+
steps:
413+
- name: Checkout
414+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
415+
- name: Setup pixi
416+
uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
417+
with:
418+
pixi-version: v0.67.2
419+
- name: Validate ruleset JSON
420+
run: pixi run jq . .github/branch-protection.main.json >/dev/null
421+
- name: Verify every context maps to a real workflow job name
422+
run: bash scripts/verify-context-names.sh
423+
- name: Run offline branch-protection verifier test
424+
run: pixi run test-branch-protection
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Apply Branch Protection
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- '.github/branch-protection.main.json'
8+
- 'scripts/apply-branch-protection.sh'
9+
- 'scripts/verify-branch-protection.sh'
10+
- '.github/workflows/apply-branch-protection.yml'
11+
workflow_dispatch: {}
12+
13+
permissions:
14+
contents: read
15+
16+
concurrency:
17+
group: apply-branch-protection-${{ github.ref }}
18+
cancel-in-progress: false
19+
20+
jobs:
21+
apply:
22+
name: apply-branch-protection
23+
runs-on: ubuntu-24.04
24+
steps:
25+
- name: Checkout
26+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
27+
- name: Setup pixi
28+
uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
29+
with:
30+
pixi-version: v0.67.2
31+
- name: Validate JSON
32+
run: pixi run jq . .github/branch-protection.main.json >/dev/null
33+
- name: Apply ruleset
34+
env:
35+
GITHUB_TOKEN: ${{ secrets.BRANCH_PROTECTION_PAT }}
36+
run: bash scripts/apply-branch-protection.sh
37+
- name: Verify ruleset
38+
env:
39+
GITHUB_TOKEN: ${{ secrets.BRANCH_PROTECTION_PAT }}
40+
run: bash scripts/verify-branch-protection.sh

CLAUDE.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,14 @@ before assuming the defect is unfixed.
107107
Flip this entry to "resolved" only after #5 closes.
108108
- **GitHub Actions security gaps.** Gitleaks runs with `--exit-code 0` (#86);
109109
treat absence of a Gitleaks failure as inconclusive. (Trivy gate restored — #85 closed.)
110-
- **Branch protection partial.** PRs require zero reviews (#95); CODEOWNERS
111-
enforcement is now applied via `scripts/branch-protection-apply.sh` (#102),
112-
which preserves all sibling fields on the protection endpoint (status checks,
113-
approving review count, push restrictions). See `docs/branch-protection.md`
114-
for the target state and procedure.
110+
- **Branch protection is enforced** via `.github/branch-protection.main.json`.
111+
The `.github/workflows/apply-branch-protection.yml` workflow re-applies the
112+
ruleset on every change to that file using the `BRANCH_PROTECTION_PAT`
113+
secret. `just verify-branch-protection` (read-only) detects drift;
114+
`tests/branch-protection.test.sh` runs offline in CI as the
115+
`branch-protection-test` required check. (Closes #95. Partially closes
116+
#102`require_code_owner_reviews=true` flips here; the CODEOWNERS
117+
coverage audit tracked in #102 remains open.)
115118

116119
Agents must not silently work around these defects; instead, link the
117120
relevant issue from any PR that touches the affected code.

docs/branch-protection.md

Lines changed: 34 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ provisioning. Branch protection is the last guardrail.
2020
| Restrict deletions | **on** ||
2121
| Restrict pushes (no force-pushes, no direct commits) | **on** ||
2222
| Require pull request before merging | **on** ||
23-
| Required approving review count | **1** (minimum) | #95 |
23+
| Required approving review count | **1** (minimum) | #95 (enforced) |
2424
| Dismiss stale approvals on new commits | **on** ||
25-
| Require review from code owners | **on** | #102 (applied via scripts/branch-protection-apply.sh) |
25+
| Require review from code owners | **on** | #102 (enforced via API; CODEOWNERS coverage audit remains open) |
2626
| Require status checks to pass | **on** ||
2727
| Required status checks | see below | #94 |
2828
| Require branches to be up to date before merging | **on** ||
@@ -31,52 +31,40 @@ provisioning. Branch protection is the last guardrail.
3131

3232
### Required status checks
3333

34-
The following CI checks (from `.github/workflows/_required.yml`,
35-
`ci.yml`, `cross-repo-dispatch.yml`, and `promote.yml`) are the
36-
authoritative required set:
34+
The following CI checks (from `.github/workflows/_required.yml` and `ci.yml`)
35+
are the authoritative required set. Names match the workflow job `name:` field,
36+
which is what GitHub uses for protection contexts:
3737

38-
- `lint-scripts` (CI / shellcheck)
39-
- `typecheck` (CI / `tsc --noEmit`)
38+
- `lint` (shellcheck + yamllint + mypy)
39+
- `Lint Shell Scripts` (ci.yml)
40+
- `TypeScript Type Check` (ci.yml)
4041
- `forbid-suppressions` (no-silent-failures guard)
41-
- `unit-tests` (YAML schema validation; to be replaced by real tests — #88)
42-
- `integration-tests` (cross-config reference check; to be expanded — #89)
43-
- `markdownlint` (docs lint)
44-
- `security/npm-audit` (npm audit for known CVEs — #23)
45-
- `security/secrets-scan` (Gitleaks SARIF upload + PR gating — #23, #86)
46-
- `CodeQL / javascript-typescript` (SAST for TypeScript — #23)
47-
48-
A required status check that does not actually run on a PR will block
49-
merges; whenever a check is renamed, this list must be updated in the
50-
same PR.
51-
52-
## Why we are not enforcing this today
53-
54-
The defects in #95 (zero required approvals) and #102 (CODEOWNERS not
55-
enforced in branch protection) are tracked separately. This document
56-
exists so the next maintainer with admin access can apply the policy
57-
in one pass without re-deriving it.
58-
59-
## Procedure to apply
60-
61-
The repo-level apply is scripted and idempotent:
62-
63-
1. `gh auth status` — confirm authentication has admin scope on the repo.
64-
2. `just branch-protection-dry-run` — prints the merged payload **without
65-
PUT-ing it**. Review that `required_status_checks.contexts` lists the
66-
expected checks from #94, `required_approving_review_count` matches #95,
67-
and `restrictions` (if non-null) lists the expected users/teams/apps.
68-
3. `just branch-protection-apply` — performs read-modify-write: every sibling
69-
field on `branches/main/protection` is round-tripped verbatim; only
70-
`require_code_owner_reviews` is mutated to `true`. Safe to re-run.
71-
4. Verify:
72-
`gh api repos/HomericIntelligence/ProjectProteus/branches/main/protection --jq '.required_pull_request_reviews.require_code_owner_reviews'`
73-
prints `true`.
74-
5. Close #102 once step 4 returns `true`.
75-
76-
The script uses `gh api -i` to parse HTTP status lines from stdout (a stable
77-
contract across `gh` versions), so 404 (no existing protection) is handled by
78-
creating minimal protection, while 401/403 (insufficient scope) fail fast with
79-
an explicit error.
42+
- `unit-tests` (placeholder; to be replaced — #88)
43+
- `integration-tests` (placeholder; to be expanded — #89)
44+
- `schema-validation` (YAML pipeline config validation)
45+
- `markdownlint` (documentation lint)
46+
- `pixi-check` (pixi lock file consistency)
47+
- `justfile-check` (justfile validation)
48+
- `symlink-check` (verify all symlinks resolve)
49+
- `build` (dagger build test)
50+
- `security/secrets-scan` (gitleaks)
51+
- `security/dependency-scan` (dependency audit)
52+
- `branch-protection-test` (offline branch protection verification)
53+
54+
## Enforcement
55+
56+
The ruleset above is the **literal** body of `.github/branch-protection.main.json`.
57+
It is applied automatically by `.github/workflows/apply-branch-protection.yml`
58+
on every push to `main` that modifies the JSON file, using the admin-scoped
59+
`BRANCH_PROTECTION_PAT` repository secret.
60+
61+
Manual operations (admin token required):
62+
63+
- Apply / re-apply: `GITHUB_TOKEN=<admin-pat> just apply-branch-protection`
64+
- Detect drift: `GITHUB_TOKEN=<admin-pat> just verify-branch-protection`
65+
66+
Offline regression coverage runs on every PR via `_required.yml`
67+
`branch-protection-test`; no token is required.
8068

8169
## See also
8270

justfile

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,20 @@ lint-verify-92:
8989
test-plan-sync:
9090
bash tests/remediation-plan-sync.test.sh
9191

92-
# Run lint + validate together
93-
check: lint validate test-plan-sync
92+
# Apply branch protection ruleset to main (requires admin GITHUB_TOKEN) — Refs #95, #102.
93+
apply-branch-protection:
94+
GITHUB_TOKEN={{GITHUB_TOKEN}} ./scripts/apply-branch-protection.sh
95+
96+
# Verify live branch protection matches .github/branch-protection.main.json — Refs #95.
97+
verify-branch-protection:
98+
GITHUB_TOKEN={{GITHUB_TOKEN}} ./scripts/verify-branch-protection.sh
99+
100+
# Offline shim test — runs in CI without secrets.
101+
test-branch-protection:
102+
bash tests/branch-protection.test.sh
103+
104+
# Run lint + validate + plan-sync + branch-protection test together
105+
check: lint validate test-plan-sync test-branch-protection
94106

95107
# Run the full local test suite (shell integration tests + config validation)
96108
test-all:

pixi.lock

Lines changed: 65 additions & 35 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)