|
| 1 | +# ADR-0034 — Git/CI operating model: trunk-based, PR-per-goal, ruleset-gated main |
| 2 | + |
| 3 | +Status: accepted (owner-ratified 2026-08-11, "full go" on the researched |
| 4 | +recommendation). |
| 5 | + |
| 6 | +## Context |
| 7 | + |
| 8 | +CI (ADR-0002, `.github/workflows/ci.yml`) existed but was dormant: main |
| 9 | +went unpushed for 273 commits over 5 days, so the cloud pipeline gated |
| 10 | +nothing. The owner asked for researched precedent before resuming, |
| 11 | +naming a specific past failure: AI-agent projects where branch/PR |
| 12 | +sprawl grew until "we ended up always ignoring the pipeline." |
| 13 | + |
| 14 | +The research pass found that failure ON RECORD in this repo's own |
| 15 | +history: of the last 11 Actions runs (2026-08-06), 5 were cancelled and |
| 16 | +3 failed — rapid direct pushes to main were outrunning a suite that |
| 17 | +took up to 43 minutes, `cancel-in-progress: true` killing each previous |
| 18 | +run, so the signal never meant anything; pushing then stopped entirely. |
| 19 | +The ignored pipeline was a mechanism, not a mood. |
| 20 | + |
| 21 | +Load-bearing mechanical finding (GitHub's own docs + community |
| 22 | +confirmation): **required status checks cannot gate a direct push** — a |
| 23 | +check must already have passed on that exact SHA before the ref update |
| 24 | +is allowed, and GitHub never runs checks synchronously at push time. A |
| 25 | +never-seen SHA pushed directly at a check-protected branch is rejected |
| 26 | +as "no status". So "main never receives an un-CI'd commit" is only |
| 27 | +expressible through a branch/PR-shaped flow; there is no |
| 28 | +direct-push-with-checks configuration to prefer instead. |
| 29 | + |
| 30 | +## Decision |
| 31 | + |
| 32 | +- **Local discipline unchanged**: commit every verified change to the |
| 33 | + working branch, gated by lefthook's full local mirror (CLAUDE.md's |
| 34 | + standing rule). Trunk-based development's own guidance for |
| 35 | + very-small teams, already in effect. |
| 36 | +- **`main` is protected by a GitHub Ruleset** (not classic branch |
| 37 | + protection — legacy per GitHub's own docs): restrict deletions, block |
| 38 | + force pushes, require a pull request, require the CI status checks, |
| 39 | + with the owner on the bypass list as **"for pull requests only"** — |
| 40 | + direct pushes are blocked even for the owner, but a green PR |
| 41 | + self-merges with no reviewer requirement (there is no second person). |
| 42 | +- **One PR per delivered goal** (`docs/goals/BACKLOG.md`'s own delivery |
| 43 | + unit): the PR is the CI gate plus a durable, skimmable change |
| 44 | + artifact, and feeds `release.yml`'s `--generate-notes`. Never |
| 45 | + PR-per-commit (ceremony), never omnibus. |
| 46 | +- **Push cadence: at least once per completed goal, ideally per |
| 47 | + session** — an unpushed batch is a bisect-blind spot (a multi-commit |
| 48 | + push produces ONE workflow run, on the head commit only; CI never |
| 49 | + evaluates the intermediate SHAs). No tool enforces cadence; the |
| 50 | + ruleset removes direct-push as the path of least resistance, and |
| 51 | + CLAUDE.md records the habit. |
| 52 | +- **Worktree branches stay short-lived** (Anthropic's own worktree |
| 53 | + lifecycle: branch → PR/merge → remote branch deleted → worktree |
| 54 | + recycled), never a second long-lived line of history. |
| 55 | +- **Secret-scanning push protection enabled before the catch-up push** |
| 56 | + (free on public repos; blocks recognized secret patterns before they |
| 57 | + reach the remote) — the catch-up carries 273 commits of history never |
| 58 | + passed through that net, plus a local heuristic sweep of the unpushed |
| 59 | + range first. |
| 60 | +- **Catch-up sequencing**: one final direct push of main (CI runs on |
| 61 | + its head), THEN the ruleset activates — so the gate never has to |
| 62 | + block the push that establishes it. |
| 63 | + |
| 64 | +## Rejected / deferred |
| 65 | + |
| 66 | +- **Merge queue**: GitHub's own positioning is concurrent-merger teams; |
| 67 | + one human merging one PR at a time has no queue to manage. |
| 68 | +- **CI path filtering** (skip the heavy matrix for docs-only changes) — |
| 69 | + deliberately DEFERRED, not adopted with the rest: `paths`-filtered |
| 70 | + workflows report no status at all for skipped runs, which makes |
| 71 | + required checks hang at "Expected" and block the PR forever — a |
| 72 | + documented footgun whose workarounds (no-op twin workflows, |
| 73 | + job-level change detection) add real complexity. Mill's same-change |
| 74 | + rule (docs ride with the code that motivates them) makes docs-only |
| 75 | + PRs rare, so the savings are small today. Revisit if PR wall-clock |
| 76 | + cost becomes a felt problem; the cancellation cascade this was meant |
| 77 | + to help with is already structurally fixed by per-ref concurrency |
| 78 | + plus the PR flow. |
| 79 | +- **Force-push/history rewrites**: unchanged — CLAUDE.md's existing |
| 80 | + "never without being explicitly asked" rule; now also enforced |
| 81 | + server-side by the ruleset. |
| 82 | + |
| 83 | +## Consequences |
| 84 | + |
| 85 | +- Agent sessions targeting a goal work on a short-lived branch (or |
| 86 | + worktree) and end by opening/merging the goal's PR once CI is green; |
| 87 | + quick fixes outside a goal ride the next goal PR or a small |
| 88 | + dedicated one. |
| 89 | +- CI must stay green-and-trusted to stay authoritative |
| 90 | + (trunkbaseddevelopment.com: a red-often build "is of greatly reduced |
| 91 | + value") — a persistently red or flaky job is a defect to fix, never |
| 92 | + to rerun-until-green. |
| 93 | +- The ruleset's live rejection behavior gets one manual dry-run |
| 94 | + verification (attempt a direct push after activation) rather than |
| 95 | + being trusted from docs alone. |
0 commit comments