Forgejo now allows only squash and fast-forward-only, so the three method= calls in this file named styles the API rejects: rebase for feat->dev, and merge commits for both promotions. An orchestrator following them gets a 405. The fast-forward paragraph said adopting ff-only 'needs a one-time reconciliation of the protected branches, which is the owner's decision'. That decision was taken and the reconciliation done — the trees were already identical, so it carried no content. release subset-of main subset-of dev now holds and promotions land the branches on the same SHA. The paragraph keeps the structural explanation, since it is what makes the failure mode recognisable, and now points at reconciliation as the remedy.
11 KiB
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 againstdevand 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. Callpromote(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.
-
The three branches exist.
forge_branches(create={branch:"main", from:"dev"})and the same forrelease. If a branch exists but points elsewhere, the tool says so — that is a divergence to reconcile deliberately, not to overwrite. -
devis 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 rootCLAUDE.md. Ifapply_flow_defaultsre-enables them, that is the tool disagreeing with the policy, not permission to use them. -
Protection.
forge_branch_protection(branch="dev", apply=true), thenmain, thenrelease.All three branches get the required check.
releasewas the exception until 2026-08-01:pr-build.ymlfired only for PRs intodevandmain, so a PR intoreleasereported no status at all, and requiring a context nothing produces makes every production promotion permanently unmergeable — a 405 that names no cause. This estate has already lost an afternoon to exactly that, from a single mistyped check name.It was closed in the only safe order, and that order still governs any change to the context name, or a new protected branch: add the branch to
pr-build.yml'son.pull_request.branches, confirm on a real PR that the context appears, then require it. Confirmed on #167 — the gate reportedPR build (required check) / gate (pull_request)— before the check was enabled. The other order blocks prod. -
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 astale-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.
-
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:
forge_prs(base="dev")— the queue, with each PR's head sha and triage flag.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.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.forge_pr_merge(pr=N, method="squash", delete_branch=true).- 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="fast-forward-only".
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="fast-forward-only" and tag it: forge_releases(create={tag:"v<semver>", target:"release", ...}).
On fast-forward. Both promotions move by fast-forward, and as of
2026-08-01 that is in force rather than aspirational. It was blocked until then
for a structural reason worth remembering: every promotion had been a merge
commit into the target, which the source never receives, so the branches were
mutually divergent by construction and Forgejo refused fast-forward-only —
correctly. The one-time reconciliation the old policy was waiting for has
happened; the trees were already identical, so it carried no content.
release ⊆ main ⊆ dev now holds, and a promotion lands the branches on the
same SHA rather than merely the same tree. Merge commits and rebase merges are
disabled repo-wide, so a slip fails loudly instead of quietly re-diverging the
chain. If Forgejo refuses with "diverging branches", the target has picked up a
commit the source lacks — reconcile it once, target into source, fast-forward.
Do not attempt to "fix" it with a rebase or a force-push.
Hotfix
forge_branches(create={branch:"hotfix/<slug>", from:"release"}).- Fix, PR into
release, checks green, merge withconfirm_protected_base: true, tagv<semver-patch>. - Immediately down-merge
release→mainandmain→dev, as merges, not cherry-picks, in the same session. A hotfix that exists only onreleaseis 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,mainorrelease. - 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 anythingageingorstale.