mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-10-20 18:56:16 +00:00
The check reported branch protection fully green while a repository ruleset on the same branch required two status checks that no workflow produces — 'Build, Lint & Test' and 'All checks passed', names left behind when the jobs were renamed. Nothing could merge without --admin, and the report said everything was fine. That is the worst shape a check can take: confident and blind. A branch can be governed by classic protection and a ruleset at once, independently, and the stricter wins. Reading one and calling it the answer is how a broken gate stays invisible for months while the team learns to route around it. --check now reads active rulesets too, and compares each required check against what recent pull requests actually report. A rule waiting on a check that never arrives does not slow a branch — it closes it. Sampling uses recent PR heads rather than main's head, because the aggregate gate only runs on pull_request; sampling main would report it as never seen and manufacture the opposite false alarm.
158 lines
7.0 KiB
Markdown
158 lines
7.0 KiB
Markdown
# 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 | 0 | the PR is the gate, not a second reader — see below |
|
|
| 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.
|
|
|
|
### Why zero required approvals
|
|
|
|
Requiring an approval on a four-person team means every change waits on somebody
|
|
else being awake. That cost buys less than it looks like: the thing worth
|
|
protecting here is not code review — it is that nothing reaches production
|
|
unnoticed, and that funds cannot move on one person's say-so. Those live
|
|
elsewhere. Production deploys wait on the `production` environment, and
|
|
fund-moving operations are being put behind on-chain multisig, where a stolen
|
|
account or a compromised host cannot forge a second signature the way a
|
|
collected token can forge a second approval.
|
|
|
|
What the branch rule still buys, at zero waiting: nothing lands on `main`
|
|
without a PR, and nothing merges with red CI. That is the part a four-person
|
|
team actually gets value from.
|
|
|
|
`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
|
|
|
|
Exactly one job in `quality-gate.yml` opts in with `environment: production`:
|
|
`approve-deploy`. The four deploy jobs are held by depending on it.
|
|
`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.
|
|
|
|
### Why one gate job and not `environment:` on each deploy
|
|
|
|
The first version put `environment: production` on all four deploy jobs, which
|
|
reads as the more direct design — GitHub itself holds each one. It does not
|
|
behave that way.
|
|
|
|
GitHub opens an approval request for the jobs that are *eligible at that moment*,
|
|
and these four never are. `deploy-supabase` waits only on the notification;
|
|
the rest wait on image builds that take minutes. So a run asks twice. On
|
|
2026-07-31 the first approval shipped the Supabase functions and migrations, and
|
|
the run then went back to waiting — unnoticed, because the notification had
|
|
already been sent and answered. The schema had moved and the app serving it had
|
|
not. Nobody is told about the second round; you have to be looking at the run.
|
|
|
|
One gate job removes the split by construction: everything that deploys hangs off
|
|
a single approval, so there is only ever one to give.
|
|
|
|
The cost is that approval is now enforced by a dependency edge rather than by
|
|
GitHub. `ops/check-deploy-gate.py` restores the guarantee — it fails CI if a
|
|
`deploy-*` job does not depend on `approve-deploy`, if a second job declares the
|
|
environment (which brings the split back), or if a job uses `always()` without
|
|
asserting the gate succeeded, which silently un-holds it. The `Deploy gate
|
|
wiring` job runs it on every PR.
|
|
|
|
```bash
|
|
python3 ops/check-deploy-gate.py
|
|
```
|
|
|
|
### Known: the version bump lands before approval
|
|
|
|
`bump-version` commits `web/package.json` to `main` and pushes it before the gate
|
|
— so rejecting a deploy leaves a bumped version for a release that never shipped.
|
|
It is a number in a file, `[skip ci]`, and touches no server. Left as is rather
|
|
than changed quietly; moving it behind `approve-deploy` is a one-line change if
|
|
that is preferred.
|
|
|
|
### Stopping a run that is waiting for approval
|
|
|
|
`gh run cancel` does not work on a run held at an environment gate — it reports
|
|
success and the run stays `waiting`, still holding the queue behind it. The
|
|
pending deployment has to be **rejected**:
|
|
|
|
```bash
|
|
gh api repos/:owner/:repo/actions/runs/<RUN_ID>/pending_deployments \
|
|
-X POST -f state=rejected -f comment='superseded' \
|
|
-F 'environment_ids[]=<ENV_ID>'
|
|
```
|
|
|
|
Get `<ENV_ID>` from `gh api repos/:owner/:repo/actions/runs/<RUN_ID>/pending_deployments`.
|
|
This came up on 2026-07-31: a stale run sat in `waiting` and blocked the deploy
|
|
of the run behind it until it was rejected.
|
|
|
|
### Two layers, one branch
|
|
|
|
A branch can be governed by classic protection **and** a repository ruleset at
|
|
the same time, independently, and the stricter of the two wins. That is how
|
|
`main` here spent months unmergeable without anyone knowing why: classic
|
|
protection asked for `CI Gate ✅`, while a ruleset on the same branch asked for
|
|
`Build, Lint & Test` and `All checks passed` — two names no workflow has
|
|
produced since the jobs were renamed. Every merge went through `--admin`, and
|
|
the habit read as "review is slow" rather than "the gate is broken".
|
|
|
|
`--check` now reports both, and flags a required check that nothing reports.
|
|
A rule demanding a check that never arrives does not slow a branch down; it
|
|
closes it, silently, and the drift report will say everything is fine.
|
|
|
|
### 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
|
|
```
|