Skip to content

feat: require branch protection at repo init (close enforcement gap)#77

Merged
CybotTM merged 8 commits into
mainfrom
feat/require-branch-protection-at-init
May 23, 2026
Merged

feat: require branch protection at repo init (close enforcement gap)#77
CybotTM merged 8 commits into
mainfrom
feat/require-branch-protection-at-init

Conversation

@CybotTM
Copy link
Copy Markdown
Member

@CybotTM CybotTM commented May 23, 2026

Summary

Close the structural enforcement gap that just allowed three unresolved bot-reviewer threads — including a HIGH-severity token leak — to ship to main in netresearch/snipe-it-docker-compose-stack#17. The skill already documented branch protection and already had a checkpoint (GH-31) for required_conversation_resolution: true, but there was nothing prompting an operator to actually apply protection at gh repo create time, before the first PR opens. By the time /assess github-project would 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 to repos/{owner}/{repo}/branches/{default_branch}/protection. Uses gh 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: false explicit (so solo-maintainer repos like ldap-selfservice-… or usercentrics-widgets aren't painted into a corner), and required_signatures omitted (not set to false) 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-checks follow-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 with strict: true.
  • SKILL.md — new "Required First Step After `gh repo create`" section right after "When to Use". The "When to Use" list now leads with the init step. "Quick Diagnostics" gets a one-liner (gh api repos/OWNER/REPO/branches/.../protection --jq '.required_conversation_resolution.enabled'). "Running Scripts" lists the new script alongside the existing verifier.
  • checkpoints.yaml feat: add skill validation CI job #31 — severity was already error (verified). The desc: 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.
  • README.md — 7-line callout at the top so someone landing on the repo page sees the init step without scrolling.

The new flow

gh repo create netresearch/new-thing --public --clone
cd new-thing
git push -u origin main          # need at least one commit so the default branch ref exists
bash ../github-project-skill/main/skills/github-project/scripts/init-branch-protection.sh \\
    netresearch/new-thing
# ... open PRs, run CI ...
bash .../init-branch-protection.sh netresearch/new-thing --from-current-checks

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 — see references/security-config.md lines 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:

gh api graphql -f query='query{repository(owner:\"netresearch\",name:\"github-project-skill\"){
  pullRequest(number:NUMBER){reviewThreads(first:100){nodes{isResolved}}}
}}' --jq '.data.repository.pullRequest.reviewThreads.nodes | map(select(.isResolved == false)) | length'

Abort merge if non-zero. (This PR's own correctness is also a useful test of the rule it documents.)

Test plan

  • shellcheck clean on init-branch-protection.sh
  • bash -n clean
  • Live-tested against netresearch/snipe-it-docker-compose-stack — reports already compliant (idempotent path works against the repo whose incident motivated this PR)
  • Error paths exercised: no arg → exit 2, bad slug → exit 2, 404 repo → exit 3, bad flag → exit 2
  • markdownlint-cli2 clean on SKILL.md and README.md
  • yamllint clean on checkpoints.yaml
  • All four commits signed (verified via git log --show-signature)
  • CI green on this branch
  • Unresolved-threads GraphQL check returns 0 before merge

Atomic commits

  1. feat(scripts): add init-branch-protection.sh for new-repo bootstrap
  2. docs(skill): require branch-protection init step before first PR/commit
  3. feat(checkpoints): clarify required_conversation_resolution failure mode in GH-31
  4. docs(readme): announce required init step at top of README

Copilot AI review requested due to automatic review settings May 23, 2026 05:40
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 23, 2026

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.sh script 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-31 messaging 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.

Comment thread skills/github-project/SKILL.md Outdated
Comment thread skills/github-project/SKILL.md Outdated
Comment thread skills/github-project/scripts/init-branch-protection.sh Outdated
Comment thread skills/github-project/scripts/init-branch-protection.sh Outdated
Comment thread skills/github-project/scripts/init-branch-protection.sh
Comment thread skills/github-project/scripts/init-branch-protection.sh
Comment thread skills/github-project/checkpoints.yaml Outdated
Comment thread skills/github-project/scripts/init-branch-protection.sh Outdated
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread skills/github-project/scripts/init-branch-protection.sh Outdated
Comment thread skills/github-project/scripts/init-branch-protection.sh Outdated
CybotTM added a commit that referenced this pull request May 23, 2026
…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>
CybotTM added a commit that referenced this pull request May 23, 2026
…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>
CybotTM added 8 commits May 23, 2026 08:50
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>
@CybotTM CybotTM force-pushed the feat/require-branch-protection-at-init branch from 02d4ff7 to fa66bd5 Compare May 23, 2026 06:50
@sonarqubecloud
Copy link
Copy Markdown

@CybotTM CybotTM merged commit efa0249 into main May 23, 2026
16 checks passed
@CybotTM CybotTM deleted the feat/require-branch-protection-at-init branch May 23, 2026 06:52
CybotTM added a commit that referenced this pull request May 23, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants