ADRs
ADR 0089 — Merge queue for the shared Studio trunk
  • Status: Accepted
  • Date: 2026-09-07
  • Relates to: ADR 0022 v2.0 (branch strategy + deploy slots) — this amends how the shared trunk is landed into, not the trunk-based model itself.

Context

Several Claude sessions work on the Studio in parallel, each in its own worktree, and all of them land into one long-lived branch — feat/sorg-layout — because they share one review surface: test-sorg-layout.arnomake.com/app/workflow. That is a deliberate choice, not an accident: the maintainer wants everything visible together on one stand, not one stand per session.

What that costs today, measured on 2026-09-06:

  • 17 commits landed into the trunk in one day, pushed directly. Recent history carries no PR merges — sessions push into the same ref and race each other for it.
  • deploy-test.yml ran the whole verify-and-deploy sequence as a single job behind concurrency: cancel-in-progress: false. Baseline run 7–11 minutes; runs that started while a previous one was still in flight took 15 and 17.6 minutes end to end. So a landing was visible on the stand roughly 10–20 minutes later, and every intermediate commit was deployed on the way.
  • Four consecutive red runs between 05:25 and 05:53 left the stand frozen on an older build for ~30 minutes. One session's breakage stopped every other session's work from appearing.

Three distinct failures hide in that: a push race for the shared ref, a queue delay from serialising deploys nobody needed, and collective blocking when one landing is red.

A repository ruleset is available on this account (org plan reports enterprise; GET /repos/Arnomake/arno/rulesets answers [], and branch protection answers 404 Branch not protected rather than an upgrade error). The note in Rules.md claiming protection is unavailable on this plan is stale and is corrected alongside this ADR.

Decision

1. The trunk is landed into through GitHub's merge queue, never by direct push.

A ruleset on feat/sorg-layout requires a pull request and the merge queue, with stand-gate as the required check. A session works on land/<topic>, opens a PR into the trunk, and enables auto-merge. It does nothing else — the queue decides ordering, batches up to five PRs into one speculative commit (trunk + PR-1..PR-n), runs the gate on that commit, and ejects a failing PR while merging the rest.

This is what removes the push race and the collective block, and it is deliberately not hand-built: an in-house merge train would have to re-implement batching, bisection and ejection, and would own their bugs.

2. stand-gate.yml is the queue's gate, and it is lean.

merge_group (and pull_request into the trunk, for fast feedback before queueing): typecheck, the full test graph, migrations applied to a throwaway Postgres. No build, no deploy — ~5 minutes. Queue throughput is bounded by this number, so the heavy gates (Playwright, bundle budgets, worker size) stay on ci.yml for main.

3. A migration rides alone.

The live test Neon is shared and migrations are applied for real. Code can be speculatively batched and un-batched; an applied schema change cannot. stand-gate therefore fails a merge_group batch that carries tools/migrate/drizzle/** alongside other entries, which makes GitHub re-form the queue in smaller groups until the migration PR rides on its own. This assumes the queue's squash method (one commit per entry).

4. deploy-test.yml splits into gate → freshness → deploy.

  • gate — per-commit (concurrency keyed on the SHA, never cancelled), so no pushed commit goes unverified, and gates of different commits run in parallel instead of queueing. Same CI minutes, lower wall-clock. It first asks the API whether a successful stand-gate check-run already exists for this exact SHA and skips its own verification when it does, so a queued landing is not verified twice.
  • freshness — runs after the gate and asks whether this commit is still the branch tip. A superseded commit is not deployed. This is what makes the deploy step's cancellation safe to rely on: ordering is enforced by fact, not by winning a race.
  • deploy — build, Pages, and (only when backend paths changed) live migrations, worker and secrets. cancel-in-progress: true: the stand only ever wants the newest commit, and every step is additive or idempotent.

5. Per-branch stands stay. feat/** and fix/** keep their own test-<topic>.arnomake.com, so a session can verify itself before queueing. The shared trunk stand remains the place where everything is seen together.

Consequences

Fixed:

  • Two sessions can no longer collide on the trunk ref; integration is machine-performed and serialised by the queue.
  • A red landing ejects its own PR instead of freezing the stand for everyone.
  • The stand stops replaying intermediate commits; deploys collapse onto the newest one.

Not fixed, and deliberately so:

  • The stand shows the whole trunk, not one session's change. This is the maintainer's explicit preference. Per-session isolation, when wanted, is the branch's own test-<topic> stand.
  • Semantic conflicts that no test covers. Neither a queue nor bisection can see breakage the suite does not assert. The only remedy is coverage.
  • Shared data. One test Neon and one prj-arno-sorgente-seed mean two sessions editing the same composition or token still overwrite each other. That needs per-topic Neon branches — a separate decision, not taken here.
  • End-to-end latency. A landing still needs roughly the gate plus the deploy (~10–15 minutes) to appear. The queue removes waiting on other sessions' deploys, not the work itself.

Operational: allow_auto_merge must be enabled on the repository, and every session's flow changes from "push to trunk" to "PR + auto-merge". Sessions already running against the old flow will see their next direct push rejected — the flip is announced, not silent.

Alternatives rejected

  • Hand-built merge train (assemble trunk + all candidate branches in CI, bisect on red, force-push the result). Same behaviour, entirely our own bugs, and it was designed before checking that the platform offers it here.
  • Branch pinning in the preview proxy (a KV entry deciding which branch test-sorg-layout serves) and per-viewer cookie override. Both only choose which single branch the stand shows. The maintainer wants everything shown together, so neither addresses the actual problem.
  • Ephemeral per-topic backends (arno-api-test-<topic> + per-topic Neon). Would additionally solve the shared-data collision, but costs a worker, secrets and a seed per topic. Left open for the data problem specifically.