From 7482421f3d16b8ee0010c98324f85fd3795666fc Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 31 Jul 2026 02:07:21 -0700 Subject: [PATCH] ops: capture branch protection and the deploy environment as code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ops/README.md | 75 +++++++++++++++++++++ ops/apply-repo-settings.sh | 135 +++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 ops/README.md create mode 100755 ops/apply-repo-settings.sh diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 00000000..cc583ac2 --- /dev/null +++ b/ops/README.md @@ -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 +``` diff --git a/ops/apply-repo-settings.sh b/ops/apply-repo-settings.sh new file mode 100755 index 00000000..85bf3a5a --- /dev/null +++ b/ops/apply-repo-settings.sh @@ -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 </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"