thermograph/.claude/BRANCHING.md
Emi Griffith ea613e33ca
All checks were successful
PR build (required check) / changes (pull_request) Successful in 8s
shell-lint / shellcheck (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 10s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 3s
flow: squash into dev, fast-forward the promotions
Forgejo cannot scope merge styles per branch — they live on the repo's
pull-request unit, and BranchProtection carries no merge-style or linear-history
field in 9.0.3. So the chain is enforced repo-wide by allowing exactly two
styles: squash and fast-forward-only. Merge commits and rebase merges are off.

Squash belongs on the short-lived hop. Squashing dev into main would put a
commit on main that does not have dev's commits as ancestors, freezing
merge-base(dev, main); every later promotion would then list all commits since
that stale point and re-apply changes main already has. Fast-forwarding the
promotions instead means a promotion creates no commit at all, so the three
branches keep identical history rather than merely identical trees, and
`release ⊆ main ⊆ dev` holds permanently.

Rebase-updates stay enabled. They are a different setting from rebase merges,
and BRANCHING.md is right that the merge queue 403s without them.

Also adds `release` to pr-build.yml's pull_request branches. This is step one of
the order BRANCHING.md prescribes for closing the missing check on release:
teach the workflow to fire there, confirm on a real PR that the context appears,
and only then require it. Requiring a context nothing produces is what makes a
production promotion unmergeable with a 405 that names no cause.
2026-08-01 17:14:45 -07:00

225 lines
11 KiB
Markdown

# Orchestrator instructions — trunk-based flow
`feat/*``dev``main``release`. One PR per hop, merges serialised by the
orchestrator. Forgejo has no native merge queue, so you are the merge queue.
Use the Centralis `forge_*` tools for every remote operation — PRs, protection,
merges, tags. Use local git only for rebases you have to resolve by hand.
Never call the Forgejo API with curl and a pasted token; the server holds it.
The same file exists in `centralis` with the same rules. Where the two repos
differ it is noted inline.
---
## The branches
Each branch has its own environment here, and `infra/deploy/env-topology.sh` is
the single source of truth for where each one lives:
| Branch | Deploys to | Receives |
|---|---|---|
| `feat/*`, `fix/*` | nothing | your work |
| `dev` | dev — vps1, mesh-only, own Postgres | feature PRs |
| `main` | beta — beta.thermograph.org, vps2 | promotions from `dev` |
| `release` | prod — thermograph.org, vps2 | promotions from `main`, and hotfixes |
`deploy.yml` handles all three: the branch selects the environment, a matrix
covers backend and frontend, and each leg checks whether the push actually
touched its domain before rolling. So **FE and BE ship out of lockstep** — a
backend change lands without a frontend deploy. That is the point of the split,
and it is why `/api/version` and `PAYLOAD_VER` discipline are load-bearing:
whatever you promote, the other half may already be running something older.
`dev` is the default branch, so PRs target it without anyone remembering to
retarget. All three are protected: **everything is a PR**, for humans and agents
alike.
## Who may merge what
This is not the same question as how a change travels, and it is deliberately
asymmetric. `MERGE_AUTHORITY` in centralis'
`src/lib/promotion.ts` is the whole policy; `forge_pr_merge` enforces it.
- **Into `dev` — free.** Merge feature PRs yourself, and do not leave them open.
A PR opened against `dev` and abandoned is an unfinished task wearing the
costume of a delivered one.
- **`dev` → `main` — yours, batched.** Beta is the test environment, so
withholding this merge withholds the testing. The judgement being asked of you
is *when a batch is coherent enough to be worth testing*, not whether you are
allowed to test it.
- **`main` → `release` — the owner's.** That merge deploys to prod. Call
`promote(from="main", to="release")`, which prepares the PR and returns what
would ship, the CI evidence and a recommendation. Then put it to the owner —
including "I would wait", if that is what you think. No flag overrides this,
and routing around it is not an option.
`confirm_protected_base` is unrelated to the above: it is the hotfix path for a
*non-promotion* PR aimed straight at `main` or `release`, and it is audited
loudly.
---
## Phase 1 — repo setup
Idempotent. Re-run it whenever you are unsure; every step reports "already
correct" rather than erroring.
1. **The three branches exist.**
`forge_branches(create={branch:"main", from:"dev"})` and the same for
`release`. If a branch exists but points elsewhere, the tool says so — that
is a divergence to reconcile deliberately, not to overwrite.
2. **`dev` is the default branch.**
`forge_repo_settings(apply_flow_defaults=true)` — sets the default branch and
permits the merge styles the chain uses, plus rebase-updates (without which
the merge queue's core primitive 403s).
The chain uses exactly two: **squash** for `feat/*``dev`, and
**fast-forward-only** for both promotions. Merge commits and rebase merges
are disabled deliberately — see the merge-style table in the root
`CLAUDE.md`. If `apply_flow_defaults` re-enables them, that is the tool
disagreeing with the policy, not permission to use them.
3. **Protection.** `forge_branch_protection(branch="dev", apply=true)`, then
`main`, then `release`.
`dev` and `main` get the required check; **`release` does not**, and that is
deliberate. `pr-build.yml` fires only for PRs into `dev` and `main`, so a PR
into `release` reports no status at all — requiring a context nothing
produces would make every production promotion permanently unmergeable, with
a 405 that names no cause. This estate has already lost an afternoon to
exactly that, from a single mistyped check name. If you want it closed
properly, do it in this order: add `release` to `pr-build.yml`'s
`on.pull_request.branches`, confirm on a real PR that the context appears,
*then* pass `require_checks_on_release: true`. The other order blocks prod.
4. **Stale branches.** `forge_branches()` lists every branch with its age, how
much unmerged work it carries, and a verdict:
- `merged` — nothing on it the trunk lacks. Delete freely.
- `stale` — unmerged work, untouched 14+ days. **File a `stale-branch: <name>`
issue summarising its diff first**, then delete. Never discard work
silently.
- `ageing` — 2+ days with work still on it. Report it; do not delete.
5. **Module map.** `.claude/ownership.md` — read it before partitioning work.
## Phase 2 — the ongoing loop
### Dispatching subagents
Partition by module using `.claude/ownership.md`. Never give two concurrent
agents tasks touching the same files; if that is unavoidable, sequence them.
Inject this into every subagent task:
```
Branch off latest `dev` as feat/<slug>. Touch only these files/modules: <scope>.
One logical change, small diff. Rebase onto dev before opening your PR.
PR targets `dev`. The description must list: what changed, how it was tested,
files touched. Do not merge your own PR — the orchestrator handles all merges.
```
Keep a branch alive **less than a day**. A task bigger than that is several
stacked tasks.
### The merge queue
You merge **one PR into `dev` at a time**. For each:
1. `forge_prs(base="dev")` — the queue, with each PR's head sha and triage flag.
2. `forge_pr_update(pr=N)` — replay it onto the current tip. If the head sha
does not move, it was already current and no CI will re-fire; do not sit
waiting for a run that was never scheduled.
3. `forge_pr_await(pr=N, until="checks_complete")` — checks on the **rebased**
head. The run you saw before the update was for a merge base that no longer
exists.
4. `forge_pr_merge(pr=N, method="rebase", delete_branch=true)`.
5. Only then move to the next PR.
`block_on_outdated_branch` is set on `dev`, so Forgejo enforces step 2 rather
than trusting you to remember it: after one merge, the next PR is refused until
it has been replayed. Treat that 405 as the queue working, not as a fault.
If a rebase conflicts: resolve it yourself if it is trivial and mechanical
(under ~20 lines), otherwise hand it back to the owning agent with the conflict
context. Do not resolve a large conflict on someone else's behalf.
### Promotion: `dev` → `main`
Promote when `dev` is green and a coherent batch is complete — not on a timer,
not mid-feature. `promote(from="dev", to="main")` opens the PR with the
divergence, the conflict prediction and the changelog written into its body.
Merge it with `method="merge"`.
Unready work belongs behind a feature flag, or not on `dev` yet. **Never promote
around it with cherry-picks.**
`promote` refuses a hop whose two tips have identical trees — the commit counts
look like real work while the content is already on the target under different
SHAs, and merging it would fire the target's deploys for nothing.
### Promotion: `main` → `release`
Prepare with `promote`, then ask. On the owner's yes, merge with `method="merge"`
and tag it: `forge_releases(create={tag:"v<semver>", target:"release", ...})`.
**On fast-forward.** The written policy is that `release` moves only by
fast-forward. It cannot today: every promotion so far has been a merge commit
*into* the target, which the source never receives, so the branches are mutually
divergent by construction and Forgejo will refuse a `fast-forward-only` merge —
correctly. The style is implemented and accepted by `forge_pr_merge`; adopting
it needs a one-time reconciliation of the protected branches, which is the
owner's decision. Until that happens, promotions are merge commits. Do not
attempt to "fix" this with a rebase or a force-push.
### Hotfix
1. `forge_branches(create={branch:"hotfix/<slug>", from:"release"})`.
2. Fix, PR into `release`, checks green, merge with
`confirm_protected_base: true`, tag `v<semver-patch>`.
3. **Immediately down-merge** `release``main` and `main``dev`, as merges,
not cherry-picks, in the same session. A hotfix that exists only on `release`
is a regression scheduled for the next promotion.
Note that CI does not gate PRs into `release``pr-build.yml` fires only for
`dev` and `main`. A hotfix's green evidence therefore has to come from somewhere
else; say plainly where it came from rather than implying a check passed.
A hotfix that touches only one domain deploys only that domain. Check the other
half's `/api/version` before assuming the fix is live end to end.
### When you genuinely need to push directly
`apply_to_admins` is on, which means these rules bind the owner too. That is
deliberate — protection an admin walks straight through is documentation, not a
control, and every account here is an admin — but it does remove the escape
hatch, so the way back in is worth writing down before it is needed at 3am.
There is no force flag. The supported route is to lift the rule, do the thing,
and put it back:
```
forge_branch_protection(branch="dev", delete=true) # rule gone; direct push allowed
git push origin dev # the emergency change
forge_branch_protection(branch="dev", apply=true) # restored to policy
```
Both the removal and the restore are audited, which is the point: the hatch is
open on the record rather than by a quiet exception. Restore it in the same
session — a protection rule "temporarily" removed is how a repo ends up
unprotected for a month.
The failure mode that actually justifies this: if the required status context
is ever renamed and no longer matches, **nothing can merge into `dev` or `main`
at all**, including by you, because `apply_to_admins` no longer exempts anyone.
`forge_pr_await` and `forge_pr_merge` both name that case explicitly when they
see it. The fix is to correct `pr-build.yml`'s workflow/job names and
`CENTRALIS_REQUIRED_CHECK` together — but if you need to land the fix itself,
lift the rule as above.
## Guardrails
- Never force-push `dev`, `main` or `release`.
- Never merge a red PR, hotfix included. Fix CI, or fix the tests in the same PR.
- Never merge two green PRs back to back without re-checking the second against
the new tip.
- If two down-merges or promotions conflict beyond the trivial, stop and report
rather than resolving autonomously.
- Weekly: `forge_branches()` and report anything `ageing` or `stale`.