ops: capture branch protection and the deploy environment as code

Both were configured by hand through the GitHub API today. Nothing in the repo
reflected them, so they were invisible here, unversioned, and removable without
a trace — which undercuts their purpose, since they exist so that nothing reaches
main or production unreviewed.

apply-repo-settings.sh is now the source of truth for both, and idempotent: each
call PUTs the full desired state rather than stacking changes. --check reports
drift without touching anything, which is what makes this useful later — a
setting silently relaxed is otherwise only discovered when it matters.

Documented alongside it: why CI Gate ✅ is the only required check (it is an
aggregate that already inspects the others, and listing each job would mean a
rename quietly drops a requirement), and why enforce_admins stays false (an
incident needs an escape hatch — rare, visible, recorded).

This is the same class of problem as the migrations fixed earlier today: state
that only exists because someone once clicked something, with no record of what
or why.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-07-31 02:07:21 -07:00
1 parent e4abb38757
commit 7482421f3d
2 files changed
+210

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
# ops
Operational configuration that lives outside the application code.
## `apply-repo-settings.sh`
The repository's GitHub protections, as code.
Branch protection and the `production` environment are configured through the
GitHub API. Nothing in the repository reflects them, so by default they are
invisible here, unversioned, and gone without a trace if someone removes them —
which undercuts the point, since both exist precisely so that nothing reaches
`main` or production unreviewed.
```bash
./ops/apply-repo-settings.sh # apply the desired state
./ops/apply-repo-settings.sh --check # report drift, change nothing
```
Idempotent: each call PUTs the full desired state, so repeated runs converge.
Requires `gh` authenticated with admin rights on the repo.
### What it enforces
**Branch protection on `main`**
| setting | value | why |
|---|---|---|
| required check | `CI Gate ✅` | aggregate job — fails if web, backend or security-audit did not succeed |
| strict | true | branch must be up to date with `main` before merging |
| approvals | 1 | no unreviewed code on `main` |
| dismiss stale reviews | true | an approval does not carry over to new commits |
| force pushes | blocked | history cannot be rewritten |
| deletions | blocked | `main` cannot be deleted |
| conversation resolution | required | review comments cannot be merged past |
Only `CI Gate ✅` is listed as a required check, deliberately. It runs with
`if: always()` and inspects the results of the jobs it needs, so it already
covers them. Listing each job here as well would mean editing this file whenever
one is renamed — and a rename would then quietly drop a requirement instead of
failing loudly.
`enforce_admins` stays `false` on purpose: an admin needs a way through during a
real incident. It is an escape hatch, not the normal path — rare, visible, and
recorded in the branch's protection history.
**`production` environment**
Deployment approval is an environment protection rule, not something the workflow
implements. GitHub holds the run until a reviewer approves it:
- no runner is occupied while waiting
- the window is 30 days, so a missed notification costs nothing
- who approved which SHA is recorded in the deployment history
- approval state survives a runner restart
The deploy jobs in `quality-gate.yml` opt in with `environment: production`.
`notify-deploy-pending` only sends a Telegram message pointing at the run; it
cannot block anything.
This replaced a job that polled `/tmp/pexsec-gates` for 30 minutes while holding
a runner slot. On 2026-07-30 that window expired unseen and cancelled a deploy —
a merged migration never reached the database and the only trace was a failed
job.
### Reviewer
`REVIEWER_ID` is a numeric user id rather than a login, because the API takes ids
and a login rename would silently break the rule instead of erroring.
Override any of it via environment variables:
```bash
REPO=owner/repo BRANCH=main REVIEWER_ID=123456 ./ops/apply-repo-settings.sh
```
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
#
# apply-repo-settings.sh — the repo's GitHub protections, as code.
#
# Branch protection and the production environment are configured through
# GitHub's API, not through anything in this repository. That means they are
# invisible here, unversioned, and gone without trace if someone removes them.
# The whole point of both is that nobody can push to main or deploy unreviewed,
# so leaving them as undocumented clicks in a settings page undercuts them.
#
# This script is the source of truth. It is idempotent — every call is a PUT of
# the full desired state, so running it repeatedly converges rather than stacks.
#
# ./ops/apply-repo-settings.sh # apply
# ./ops/apply-repo-settings.sh --check # report drift, change nothing
#
# Requires gh with admin rights on the repo.
set -euo pipefail
REPO="${REPO:-pezkuwichain/pwap}"
BRANCH="${BRANCH:-main}"
ENVIRONMENT="${ENVIRONMENT:-production}"
# The reviewer who approves deployments. Numeric id rather than login, because
# the API takes ids and a login rename would silently break the rule.
REVIEWER_ID="${REVIEWER_ID:-224622464}" # SatoshiQaziMuhammed
CHECK_ONLY=0
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1
info() { printf ' %s\n' "$*"; }
ok() { printf ' \033[32m✔\033[0m %s\n' "$*"; }
bad() { printf ' \033[31m✗\033[0m %s\n' "$*"; }
# ── Branch protection ────────────────────────────────────────────────────────
# CI Gate ✅ is the only required check on purpose: it is an aggregate job that
# runs with if:always() and fails if web, backend or security-audit did not
# succeed. Listing the individual jobs here as well would mean this file has to
# be edited every time one is renamed, and a rename would silently drop a
# requirement rather than fail loudly.
read -r -d '' PROTECTION <<'JSON' || true
{
"required_status_checks": { "strict": true, "contexts": ["CI Gate ✅"] },
"enforce_admins": false,
"required_pull_request_reviews": {
"dismiss_stale_reviews": true,
"require_code_owner_reviews": false,
"required_approving_review_count": 1,
"require_last_push_approval": false
},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false,
"required_conversation_resolution": true
}
JSON
# enforce_admins stays false deliberately: an admin needs a way out during a real
# incident. It is an escape hatch, not the normal path — using it should be rare
# and visible, and it is recorded in the branch's protection history.
# ── Deployment approval ──────────────────────────────────────────────────────
# Approval is an environment protection rule, not something the workflow
# implements. GitHub holds the run without occupying a runner, waits up to 30
# days, and records who approved which SHA. The workflow's deploy jobs opt in
# with `environment: production`.
read -r -d '' ENV_CONFIG <<JSON || true
{
"wait_timer": 0,
"prevent_self_review": false,
"reviewers": [{ "type": "User", "id": ${REVIEWER_ID} }],
"deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": false }
}
JSON
echo "▶ repo settings: $REPO"
if [[ $CHECK_ONLY -eq 1 ]]; then
echo "── branch protection ($BRANCH)"
cur="$(gh api "repos/$REPO/branches/$BRANCH/protection" 2>/dev/null || echo '{}')"
if [[ "$cur" == "{}" ]]; then
bad "branch is NOT protected"
else
python3 - "$cur" <<'PY'
import json, sys
d = json.loads(sys.argv[1])
checks = (d.get('required_status_checks') or {})
rev = (d.get('required_pull_request_reviews') or {})
want = {
'required checks': (checks.get('contexts'), ['CI Gate ✅']),
'strict': (checks.get('strict'), True),
'approvals': (rev.get('required_approving_review_count'), 1),
'dismiss stale': (rev.get('dismiss_stale_reviews'), True),
'force pushes blocked': (not (d.get('allow_force_pushes') or {}).get('enabled'), True),
'deletions blocked': (not (d.get('allow_deletions') or {}).get('enabled'), True),
'conversation resolution': ((d.get('required_conversation_resolution') or {}).get('enabled'), True),
}
for label, (got, exp) in want.items():
mark = '\033[32m✔\033[0m' if got == exp else '\033[31m✗\033[0m'
extra = '' if got == exp else f' (expected {exp}, got {got})'
print(f' {mark} {label}{extra}')
PY
fi
echo "── environment ($ENVIRONMENT)"
env_cur="$(gh api "repos/$REPO/environments/$ENVIRONMENT" 2>/dev/null || echo '{}')"
python3 - "$env_cur" <<'PY'
import json, sys
d = json.loads(sys.argv[1])
if not d:
print(' \033[31m✗\033[0m environment does not exist')
raise SystemExit
rules = d.get('protection_rules') or []
reviewers = []
for r in rules:
if r.get('type') == 'required_reviewers':
reviewers = [x['reviewer'].get('login') for x in r.get('reviewers', [])]
mark = '\033[32m✔\033[0m' if reviewers else '\033[31m✗\033[0m'
print(f' {mark} required reviewers: {reviewers or "NONE — deploys are not gated"}')
pol = d.get('deployment_branch_policy') or {}
mark = '\033[32m✔\033[0m' if pol.get('protected_branches') else '\033[31m✗\033[0m'
print(f' {mark} protected branches only: {pol.get("protected_branches")}')
PY
exit 0
fi
echo "── applying branch protection ($BRANCH)"
gh api -X PUT "repos/$REPO/branches/$BRANCH/protection" --input - <<<"$PROTECTION" >/dev/null
ok "protected: 1 approval, CI Gate required, no force push, no deletion"
echo "── applying environment ($ENVIRONMENT)"
gh api -X PUT "repos/$REPO/environments/$ENVIRONMENT" --input - <<<"$ENV_CONFIG" >/dev/null
ok "deployments require review, protected branches only"
echo "✔ done — verify with: $0 --check"