feat: require branch protection at repo init (close enforcement gap)#77
Conversation
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
There was a problem hiding this comment.
Pull request overview
Adds a repository-initialization step to apply a baseline GitHub branch protection configuration at repo creation time, closing the gap where “required conversation resolution” was documented/audited but not structurally enforced early enough to prevent unresolved-thread merges.
Changes:
- Introduces an idempotent
init-branch-protection.shscript plus a JSON template to apply baseline branch protection and optionally derive required checks from the latest CI run. - Updates skill documentation (SKILL.md + README) to require running the init step for new repositories and adds quick diagnostics commands.
- Clarifies checkpoint
GH-31messaging to point to the new apply path and describe the concrete failure mode.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| skills/github-project/SKILL.md | Documents the required “post-gh repo create” initialization flow and adds diagnostics/script references. |
| skills/github-project/scripts/init-branch-protection.sh | New script to apply baseline branch protection and optionally set required checks from current CI runs. |
| skills/github-project/checkpoints.yaml | Updates GH-31 description to describe the enforcement gap and reference the new init script. |
| skills/github-project/assets/branch-protection.json.template | Adds the baseline branch protection JSON used by the init script. |
| README.md | Adds a prominent callout instructing new repos to run the init branch protection script before the first PR. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Code Review
This pull request introduces a new script, init-branch-protection.sh, and a JSON template to automate the application of standard branch protection rules to GitHub repositories. It also updates documentation across README.md, SKILL.md, and checkpoints.yaml to mandate this process after repository creation. Review feedback highlighted a bug in the script where missing jq flags would cause incorrect job name encoding and suggested using $ARGS.positional for more robust JSON array construction.
…er inconsistencies Addresses 7 of 10 review threads on PR #77. Architectural fix (Copilot thread on line 214): --from-current-checks previously rebuilt the entire branch-protection document and PUT it back, which silently dropped any fields the hardcoded jq filter didn't enumerate (bypass_pull_request_allowances, dismissal_restrictions, lock_branch settings, anything GitHub adds later). Switched to PATCH /branches/{b}/protection/required_status_checks — the dedicated subresource endpoint that accepts a partial body ({strict, contexts}) and leaves all other protection fields untouched. Now also requires baseline protection to already exist before --from-current-checks runs (errors with exit 1 if not), making the apply-then-discover flow explicit. Success-conditional gap (Copilot thread on line 156): --from-current-checks previously captured 'success' check-runs from the latest commit regardless of the commit's combined status. If the commit's overall status was 'failure' or 'pending', we'd silently require an incomplete subset of checks. Now we read the combined /commits/{sha}/status and warn (not abort) if it's not 'success', surfacing exactly which contexts were/weren't captured. Wording inconsistencies (Copilot threads on lines 9, 15, 267): - Header now says 'after gh repo create + initial push, BEFORE first PR' instead of 'before first commit' (script needs the default branch ref to exist — exits 4 on empty repos, was contradictory). - 'Exits 0 (or drift)' confusion clarified: exits 0 only on already- compliant, exits 1 with per-field diff on drift. - Drift-warning message no longer mentions '--from-current-checks' as the remedy (apply mode and --from-current-checks both refuse to clobber); now points at the manual gh-api PUT against the template. - Header notes enforce_admins IS in the template (was previously claimed not to be — Copilot thread on SKILL.md). Not changed (deliberate): gemini-code-assist HIGH on missing 'jq -r': false positive. 'gh api --jq' uses raw output by default (verified by xxd on actual output); the standalone-jq quoting rule doesn't apply to gh's embedded jq. Live-verified against snipe-it-docker-compose-stack — apply-mode still reports 'already compliant', shellcheck + bash -n clean. Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
…dition Addresses 3 of 10 review threads on PR #77 (Copilot threads on SKILL.md, checkpoints.yaml line 356, and the enforce_admins description inside repo-bootstrap.md). The init script requires the default branch ref to exist (exits 4 on empty repos). Earlier wording said 'before pushing the first commit' which is operationally impossible — the script needs a ref to protect. Corrected uniformly across: - SKILL.md 'When to Use' bullet: 'post gh repo create + initial push, before first PR' (was 'before first commit/PR') - references/repo-bootstrap.md intro paragraph: explicit three-step flow (create -> initial push -> protect, then open PRs) - checkpoints.yaml GH-31 desc: 'right after gh repo create + initial push, before opening the first PR' (was 'immediately after gh repo create') Also corrected repo-bootstrap.md's 'baseline deliberately omits' section: the template DOES include enforce_admins=false (explicit permissive default). Only required_signatures is omitted entirely. Section renamed to 'Deliberately permissive knobs' to match reality. Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
Add a runnable script + JSON template that operators invoke immediately
after `gh repo create`, before pushing the first commit or opening
the first PR. The script applies the Netresearch baseline branch
protection:
- required_conversation_resolution: true (load-bearing)
- required_approving_review_count: 1
- allow_force_pushes: false
- allow_deletions: false
- required_linear_history: false
The template deliberately omits required_signatures (so the script
never resets a repo that has already enabled signing) and ships
enforce_admins as false to accommodate solo-maintainer repos.
Operators tighten both per-repo via the documented one-liners in the
script header.
Two-step flow:
1. bash init-branch-protection.sh OWNER/REPO
(applies baseline; no required status checks yet)
2. bash init-branch-protection.sh OWNER/REPO --from-current-checks
(after first CI run completes, captures check-run names as
required contexts with strict=true)
Idempotent: a second run on an already-compliant repo reports drift
on opinionated fields only (or 'already compliant') and exits 0.
Live-tested against netresearch/snipe-it-docker-compose-stack —
correctly reports already-compliant. shellcheck + bash -n clean.
Closes the enforcement gap demonstrated in
netresearch/snipe-it-docker-compose-stack#17 where 3 of 8 merged PRs
shipped with unresolved bot-reviewer threads (including a HIGH-severity
token-leak flagged by both Copilot and gemini-code-assist) because
branch protection was never applied at repo creation time.
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
Promote the new init-branch-protection.sh to a first-class surface in
the skill:
- 'When to Use' now leads with 'After gh repo create, before first
commit/PR' (REQUIRED).
- New 'Required First Step After gh repo create' section documents
the two-step flow (apply baseline, then --from-current-checks after
first CI run), links the snipe-it#17 incident as the pain that
triggered this, and points at /assess github-project's GH-31
checkpoint for read-only verification.
- 'Security & Compliance Quick Checks' gets a one-liner that prints
whether required_conversation_resolution is enabled on the
repo's default branch.
- 'Running Scripts' lists init-branch-protection.sh alongside the
existing verify-github-project.sh.
The structural enforcement (required_conversation_resolution=true plus
min-1-approver) is what makes the 'abort merge if unresolved threads'
memory rule actually safe. Documentation without a runnable apply path
demonstrably wasn't enough — operator discipline failed in snipe-it#17.
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
…ode in GH-31 Severity was already 'error' (verified). This expands the desc to name the concrete user-visible failure mode that motivated promoting this to a hard checkpoint in the first place: - PR merges may silently include unresolved bot-reviewer feedback - Including security findings like token leakage in build logs - Concrete incident: netresearch/snipe-it-docker-compose-stack#17 The desc now also points operators at the new init script as the apply path, so /assess findings have an actionable remediation embedded in the failure message rather than a generic 'enable it'. Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
5-line callout at the top of the repo README pointing operators at scripts/init-branch-protection.sh. The full two-step flow + rationale lives in skills/github-project/SKILL.md; this is just the discovery surface for someone landing on the repo page. Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
…ecks
GitHub's required_status_checks.contexts are matched against the
check-run *display name*, which includes the 'workflow / job' prefix
for matrix and called-workflow jobs (e.g. 'container-lint / hadolint').
The /actions/runs/{id}/jobs endpoint returns just the bare job name
('hadolint'), which would never match.
Live-verified against netresearch/snipe-it-docker-compose-stack: the
check-runs API returns names that align character-for-character with
that repo's existing required_status_checks.contexts list (10 of 12
contexts present in the latest run on main; the other 2 are
conditionally-triggered build matrix jobs not exercised in that run).
Switched source to:
GET /repos/{owner}/{repo}/commits/{default_branch}/check-runs
?per_page=100 (paginated, deduped)
Caught by review before any live --from-current-checks invocation.
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
…ference
CI's Skill Validation enforces a hard 500-word cap on SKILL.md
(see netresearch/skill-repo-skill validate-skill.sh:101-107). The
docs(skill) commit pushed it to 810 words and CI rejected.
Resolution:
- New skills/github-project/references/repo-bootstrap.md captures
the full two-step flow, per-knob rationale (why enforce_admins
and required_signatures are intentionally NOT in the template),
verification commands, exit-code table, and the not-closed
pending-reviewer gap acknowledgement.
- SKILL.md keeps a single one-line REQUIRED-callout right above
Quick Diagnostics that points at the script + the new reference.
- Compressed several dense GraphQL blocks in Quick Diagnostics to
gh-pr-view equivalents (semantically identical, far fewer words).
- Tightened references table column-2 descriptions.
- Slimmed front-matter description while preserving the 'Use when'
prefix the validator requires (validate-skill.sh:89).
Net: 496 words (was 500 on origin/main; was 810 after the docs commit
this fix resolves). All other CI checks were already green; this
brings Skill Validation green too.
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
…er inconsistencies Addresses 7 of 10 review threads on PR #77. Architectural fix (Copilot thread on line 214): --from-current-checks previously rebuilt the entire branch-protection document and PUT it back, which silently dropped any fields the hardcoded jq filter didn't enumerate (bypass_pull_request_allowances, dismissal_restrictions, lock_branch settings, anything GitHub adds later). Switched to PATCH /branches/{b}/protection/required_status_checks — the dedicated subresource endpoint that accepts a partial body ({strict, contexts}) and leaves all other protection fields untouched. Now also requires baseline protection to already exist before --from-current-checks runs (errors with exit 1 if not), making the apply-then-discover flow explicit. Success-conditional gap (Copilot thread on line 156): --from-current-checks previously captured 'success' check-runs from the latest commit regardless of the commit's combined status. If the commit's overall status was 'failure' or 'pending', we'd silently require an incomplete subset of checks. Now we read the combined /commits/{sha}/status and warn (not abort) if it's not 'success', surfacing exactly which contexts were/weren't captured. Wording inconsistencies (Copilot threads on lines 9, 15, 267): - Header now says 'after gh repo create + initial push, BEFORE first PR' instead of 'before first commit' (script needs the default branch ref to exist — exits 4 on empty repos, was contradictory). - 'Exits 0 (or drift)' confusion clarified: exits 0 only on already- compliant, exits 1 with per-field diff on drift. - Drift-warning message no longer mentions '--from-current-checks' as the remedy (apply mode and --from-current-checks both refuse to clobber); now points at the manual gh-api PUT against the template. - Header notes enforce_admins IS in the template (was previously claimed not to be — Copilot thread on SKILL.md). Not changed (deliberate): gemini-code-assist HIGH on missing 'jq -r': false positive. 'gh api --jq' uses raw output by default (verified by xxd on actual output); the standalone-jq quoting rule doesn't apply to gh's embedded jq. Live-verified against snipe-it-docker-compose-stack — apply-mode still reports 'already compliant', shellcheck + bash -n clean. Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
…dition Addresses 3 of 10 review threads on PR #77 (Copilot threads on SKILL.md, checkpoints.yaml line 356, and the enforce_admins description inside repo-bootstrap.md). The init script requires the default branch ref to exist (exits 4 on empty repos). Earlier wording said 'before pushing the first commit' which is operationally impossible — the script needs a ref to protect. Corrected uniformly across: - SKILL.md 'When to Use' bullet: 'post gh repo create + initial push, before first PR' (was 'before first commit/PR') - references/repo-bootstrap.md intro paragraph: explicit three-step flow (create -> initial push -> protect, then open PRs) - checkpoints.yaml GH-31 desc: 'right after gh repo create + initial push, before opening the first PR' (was 'immediately after gh repo create') Also corrected repo-bootstrap.md's 'baseline deliberately omits' section: the template DOES include enforce_admins=false (explicit permissive default). Only required_signatures is omitted entirely. Section renamed to 'Deliberately permissive knobs' to match reality. Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
02d4ff7 to
fa66bd5
Compare
|
…emplate (#79) ## Why [PR #77](#77) shipped `assets/branch-protection.json.template` with two deliberate choices: - `enforce_admins: false` — solo-maintainer Netresearch repos benefit from admin-bypass in emergencies - `required_signatures` omitted — Dependabot/Renovate bot PRs without per-repo signing setup would otherwise be blocked `references/security-config.md` was already in the repo with stricter language: | Line | Old | Conflict | |---|---|---| | ~98 | `enforce_admins` **MUST be `true`** | Template ships `false` | | ~166 (table) | `required_signatures | true | Enforces GPG/SSH signed commits` | Template omits the field | Anyone reading the skill now would see the contradiction as either a documentation bug or as license to "fix" their per-repo config (and admin-bypass themselves out of an emergency-merge path). ## What changed - `enforce_admins` section: switched from "MUST be `true`" to "SHOULD be `true` on mature multi-maintainer repos as a hardening target". Added explicit acknowledgement that the init script ships `false` as the pragmatic baseline, plus the upgrade command + emergency-bypass rationale. - `required_signatures` table cell: now shows both states (target: `true`; init: unset) with the bot-signing precondition and per-repo upgrade trigger. - The under-`enforce_admins` security-note callout now points at the unresolved-threads operator-side safety valve for repos where the admin-bypass IS the right choice. ## What did NOT change - No template change. PR #77's `branch-protection.json.template` stays as-is. - No script change. The init flow + GH-31 checkpoint behaviour is unchanged. - No SKILL.md change. (Already at the 499-word ceiling; left it alone.) ## Test plan - [x] Markdown lint will run via CI on push - [x] Word count check: `wc -w skills/github-project/SKILL.md` = 499 (no change; this PR only touches `security-config.md`) - [ ] CI green ## Pre-merge gate I'll run the unresolved-threads GraphQL check before merging — the hard rule I just had to bake into memory after burning 3 PRs on the same mistake.



Summary
Close the structural enforcement gap that just allowed three unresolved bot-reviewer threads — including a HIGH-severity token leak — to ship to
mainin netresearch/snipe-it-docker-compose-stack#17. The skill already documented branch protection and already had a checkpoint (GH-31) forrequired_conversation_resolution: true, but there was nothing prompting an operator to actually apply protection atgh repo createtime, before the first PR opens. By the time/assess github-projectwould have caught the miss, PRs were already merging.This PR adds a first-class init surface so the structural rule is applied at repo-creation time, not audited after the fact.
What's in the PR
skills/github-project/scripts/init-branch-protection.sh— runnable, idempotent. Takes<owner>/<repo>, reads the new template, PUTs torepos/{owner}/{repo}/branches/{default_branch}/protection. Usesgh api -X PUT --input -with JSON body (NOT--raw-field, which silently breaks on nested JSON — that's exactly what wasted three retries in snipe-it#15). Handles empty-repo (exit 4), no access (exit 3), already-compliant (exit 0), and drift on opinionated fields (exit 1 with a per-field diff, no silent clobber).skills/github-project/assets/branch-protection.json.template— synthesized from snipe-it's live post-fix state with two deliberate adjustments:enforce_admins: falseexplicit (so solo-maintainer repos like ldap-selfservice-… or usercentrics-widgets aren't painted into a corner), andrequired_signaturesomitted (not set tofalse) so the script never resets a repo that has already opted into signing. Both are documented in the script header as per-repo tightening one-liners.--from-current-checksfollow-up flag — after the first CI run completes on the default branch, re-invoke with this flag and the script discovers the successful check-run names from the most recent completed run and PATCHes them in as required status contexts withstrict: true.gh api repos/OWNER/REPO/branches/.../protection --jq '.required_conversation_resolution.enabled'). "Running Scripts" lists the new script alongside the existing verifier.checkpoints.yamlfeat: add skill validation CI job #31 — severity was alreadyerror(verified). Thedesc:now names the user-visible failure mode (silent unresolved-thread merges including token leaks like snipe-it#17) and points operators at the new script as the apply path.The new flow
What this does NOT fix (deliberately, follow-up)
GitHub branch protection still cannot block on a requested-but-pending review. The snipe-it#17 leak slipped through the unresolved-threads class, which this PR's baseline (
required_conversation_resolution: true) DOES close structurally. The pending-reviewer class (Copilot is mid-review, merge anyway) remains a workflow-discipline gap — seereferences/security-config.mdlines 144-152. Closing it requires a status-check workflow (out of scope here).Verification before merge
Run the unresolved-threads GraphQL check before any merge attempt:
Abort merge if non-zero. (This PR's own correctness is also a useful test of the rule it documents.)
Test plan
shellcheckclean oninit-branch-protection.shbash -ncleannetresearch/snipe-it-docker-compose-stack— reportsalready compliant(idempotent path works against the repo whose incident motivated this PR)markdownlint-cli2clean onSKILL.mdandREADME.mdyamllintclean oncheckpoints.yamlgit log --show-signature)Atomic commits
feat(scripts): add init-branch-protection.sh for new-repo bootstrapdocs(skill): require branch-protection init step before first PR/commitfeat(checkpoints): clarify required_conversation_resolution failure mode in GH-31docs(readme): announce required init step at top of README