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
25 changes: 24 additions & 1 deletion docs/adr/0003-merge-gate-push-allowlist.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0003: The merge gate is a classic branch-protection push allowlist, not ruleset bypass

**Status:** Accepted (2026-08-12; reconstructed 2026-08-13; amended 2026-08-22 — see [Amendment](#amendment-2026-08-22))
**Status:** Accepted (2026-08-12; reconstructed 2026-08-13; amended 2026-08-22 and 2026-08-27 — see [Amendment (2026-08-22)](#amendment-2026-08-22) and [Amendment (2026-08-27)](#amendment-2026-08-27))

## Context

Expand Down Expand Up @@ -104,3 +104,26 @@ names explicitly.
Also adopted under #148: the apply job now runs `terraform plan -detailed-exitcode`
immediately after `apply` and fails the run if changes remain. "Apply complete" is the
provider's claim, not the state's; a silent drop is now a red run.

## Amendment (2026-08-27)

**The write grant this ADR was built to survive is now live, and the gate held.** The
org base repository permission moved `none` → `write`, so every member holds write on
every repo; the `Players` team, which carried `triage` on fifteen of them, was retired
the same day as strictly redundant beneath the new base. No member lost access.

This is the contingency the Context names — "a future write grant to anyone else still
cannot update the ref" — arriving in practice, and it confirms the mechanism choice
rather than disturbing it. `restrictions` was re-read on every public repo after the
change and remains `users: ["cpitzi"]`, `teams: []`, `apps: []`: because the allowlist
is evaluated against the ref rather than derived from permission levels, raising the
base grant changed nothing about who may update `main`. Had the gate been expressed as
a permission level, this would have been a merge-policy change; it was a no-op.

Two consequences worth recording. First, `teams: []` is now load-bearing in a way it
was not before — with no team in the org, the allowlist's team dimension is unused, and
`gate_extra_allowances` remains the only supported route for adding one. Second, the
base permission itself is **not** Terraform-managed: `default_repository_permission` is
live-only org state, so the invariant this ADR declares is enforced by `protection.tf`
alone and does not depend on the base grant staying put. Codifying the org settings
alongside it is open work, not a gap in the gate.
11 changes: 6 additions & 5 deletions terraform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,12 @@ The state removal is the deliberate step that says "this is intentional."

`protection.tf` puts a classic branch-protection **push allowlist** on `main`
in every public repo: only `cpitzi` (org owner) can update the ref — direct
push or PR merge. Colleagues on the Players team contribute via PRs; an owner
reviews and arms auto-merge. A future write grant to anyone else still cannot
reach `main`. No repo carries an extra allowance today; `gate_extra_allowances`
in `locals.tf` is the extension point, and its comment records what GitHub will
and will not accept there.
push or PR merge. Colleagues hold org-base `write` and contribute via PRs; an
owner reviews and arms auto-merge. That write grant does not reach `main` — the
allowlist, not the permission level, decides who updates the ref. No repo
carries an extra allowance today; `gate_extra_allowances` in `locals.tf` is the
extension point, and its comment records what GitHub will and will not accept
there.

Two things about the shape that look odd and are load-bearing:

Expand Down
7 changes: 4 additions & 3 deletions terraform/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ locals {

# ---------------------------------------------------------------------------
# The merge gate (protection.tf): the push allowlist on `main`. Every merge
# lands through an org owner/admin — colleagues (Players team, triage)
# contribute via PRs that an owner reviews and arms, and a future write grant
# still cannot update `main`. "/username" is the provider's user syntax.
# lands through an org owner/admin — colleagues hold org-base `write` and
# contribute via PRs that an owner reviews and arms; write does not reach
# `main`, because this allowlist and not the permission level decides who
# updates the ref. "/username" is the provider's user syntax.
# ---------------------------------------------------------------------------
gate_allowlist = ["/cpitzi"]

Expand Down
12 changes: 7 additions & 5 deletions terraform/protection.tf
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# The merge gate: who can update `main` at all.
#
# The org is a shared learning lab — colleagues on the Players team hold triage
# on every active repo. Triage cannot push or merge today, but this resource
# turns that from a side-effect of permission levels into a declared invariant:
# every merge to `main` lands through an org owner/admin, and a future write
# grant to anyone else still cannot reach `main` directly.
# The org is a shared learning lab — colleagues hold org-base `write` on every
# repo (2026-08-27; previously `none` plus a triage-only Players team, which was
# retired the same day as redundant once the base grant exceeded it). Write is
# exactly the grant this resource was built to survive: the gate is an allowlist
# on the ref, not a consequence of permission levels, so every merge to `main`
# still lands through an org owner/admin and a write grant to anyone else does
# not reach `main` directly.
#
# Why classic branch protection and not a ruleset `update` rule: rulesets gate
# by BYPASS, and GitHub's async auto-merge path does not honor bypass_actors —
Expand Down