Files
LibreMail/docs/ci/mergify-integration-spec.md
T
JMR-devandClaude Opus 4.8 0307df88a1 docs(ci): propose Mergify merge-queue integration spec (#407)
Investigate Mergify (free-for-OSS merge queue + batching + speculative checks)
as the right-way replacement for the hand-rolled traffic-controller
(autoupdate.yml + ci-trigger.yml + mothballed traffic-control.yml) and the
manual serial-bump grind, now that GitHub's native merge queue is org-only and
unavailable to a user account.

Proposal only — NO live .mergify.yml, nothing activates:
- docs/ci/mergify-integration-spec.md: how the queue coexists with the single
  `CI passed` gate; the require-up-to-date x merge-commits x batching trilemma
  and its resolution (Phase 1 serial keeps the rule literally; Phase 2 merge-batch
  moves the up-to-date GUARANTEE into the queue); P0-P9 -> priority_rules mapping;
  what it replaces; interaction with path-filter/sharding/wedge-diag; risks;
  phased adopt recommendation.
- docs/ci/mergify.yml.proposed: annotated, NOT-active proposed config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:55:51 -05:00

342 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
# Mergify integration spec (proposal for issue #407)
> **Status: PROPOSAL — NOTHING IS ACTIVE.** This document and its companion
> `docs/ci/mergify.yml.proposed` are a review artifact. No Mergify app is installed and
> **no live `.mergify.yml` exists** in the repo, so Mergify does nothing until a maintainer
> explicitly adopts it. This spec exists to be reviewed *before* adoption. It must not weaken
> the `require branches to be up to date` invariant — see [The require-up-to-date trilemma](#the-require-up-to-date-trilemma),
> which is the central decision this proposal surfaces.
## TL;DR / recommendation
- **Mergify is free for open source** (public repos, unlimited contributors) and the free
"Open Source" plan includes the full **Merge Queue + batching + speculative (parallel) checks**.
- Adopting it lets us **retire the entire hand-rolled traffic-controller** (`autoupdate.yml`
+ `ci-trigger.yml` + the mothballed `traffic-control.yml`) and the manual serial-bump grind,
replacing a "poor-man's merge queue" with a real one.
- **Recommendation: adopt Mergify, in two phases.**
- **Phase 1 (adopt now, zero impact on the hard rule):** a **serial** queue
(`batch_size: 1`, `merge_method: merge`). Mergify respects branch protection, keeps
`require branches up to date` **literally ON**, does the up-to-date-ing + merge itself.
This alone kills the manual grind. No throughput multiplier yet.
- **Phase 2 (the batching throughput win — needs one conscious maintainer decision):**
batch N test-only PRs into **one** CI run. Batching is **incompatible with GitHub's
literal `require branches up to date` checkbox**; to get it, the guarantee must move
*into* Mergify's queue (checkbox off, invariant preserved/strengthened by speculative
testing) — or history must go linear via `fast-forward` (which breaks the merge-commit
policy). **This is a trilemma: `require-up-to-date` literal × merge-commits × batching —
you can keep at most two.** Phase 2 should not ship until the maintainer picks which
constraint to relax.
- **Do I recommend batching?** Yes, *if* the maintainer accepts moving the up-to-date
guarantee from GitHub's checkbox into Mergify (Phase 2, `merge-batch`). That preserves the
merge-commit policy **and** the actual invariant (nothing merges without being tested on top
of the latest `main`), and it is the only path to the #373-style throughput win now that the
native merge queue is off the table. If the literal checkbox is non-negotiable *and* linear
history is unacceptable, batching is unreachable and we stop at Phase 1.
---
## 1. Problem recap
The merge cascade (see the `ci-merge-cascade` note + issue #349) comes from three things
multiplying together:
1. **Branch protection `require branches to be up to date before merging`** — a **hard
maintainer rule that stays**. Every PR must be tested against the current tip of `main`
before it merges.
2. **Serial merges** — merging PR *A* advances `main`, which makes every other open PR
out-of-date, so each must be re-bumped and re-run before it can merge.
3. **A slow, wedge-prone E2E matrix** — each PR fans out to API 29–36 + the API 37 preview
(~15 min, occasionally wedging on an emulator boot race).
Draining a multi-PR batch is therefore painful: the #373 Robolectric epic was ~6 PRs, each a
full E2E run, hand-bumped one at a time. GitHub's **native merge queue** solves exactly this,
but it is **org/enterprise-only** and **JMR-dev is a user account**, so it is unavailable.
The repo's current mitigation is a hand-built "poor-man's merge queue":
| Piece | File | Role | Status |
|---|---|---|---|
| Auto-update | `.github/workflows/autoupdate.yml` | rebases behind PRs with `GITHUB_TOKEN` (no CI re-trigger, by anti-recursion) | active, being mothballed |
| CI trigger / scheduler | `.github/workflows/ci-trigger.yml` | re-triggers CI for the top-priority PRs, `MAX_INFLIGHT_RUNS: 2` | active, being mothballed |
| Runner priority | `.github/workflows/traffic-control.yml` | in-run P0-preempt / P1–P9 hold-back ordering | **mothballed/disabled** |
| Decision core | `.github/scripts/traffic_control.py` | pure, unit-tested priority logic | kept (still unit-tested by `traffic-control-tests`) |
**Mergify replaces the first three outright.** The single required gate — the `ci-passed`
job, status name **`CI passed`** — stays exactly as is.
---
## 2. What Mergify is + free-for-OSS eligibility
Mergify is a third-party GitHub App providing a real **merge queue** with **batching**,
**speculative/parallel checks**, auto-update/rebase, and label-driven **priority**.
- **Free for open source.** The **"Open Source" plan is $0** with **unlimited users** for
public repositories (private teams are free up to 5 active contributors). LibreMail is a
public GPL-3.0 repo, so it qualifies. ([pricing](https://mergify.com/pricing),
[marketplace](https://github.com/marketplace/mergify))
- **Full product on the free tier.** "Every plan includes … Merge Queue, Merge Protections …"
— batching and speculative checks are **not** gated behind a paid tier.
([pricing](https://mergify.com/pricing))
---
## 3. How Mergify's merge queue works
1. A PR is **queued** (by a `pull_request_rules` `queue` action when it is green + approved,
or manually via a `@mergifyio queue` comment).
2. Mergify builds a **temporary branch** = *latest `main`* + the queued PR(s), and runs your
CI on that temporary branch — i.e. it tests **against an up-to-date base speculatively**,
without touching the PR's own branch. ([merge queue](https://docs.mergify.com/merge-queue/),
[parallel checks](https://docs.mergify.com/merge-queue/parallel-checks/))
3. When the temporary branch's checks pass, Mergify **merges** the original PR(s).
4. **Speculative / parallel checks:** Mergify validates several positions of the queue at once
— `(A)`, `(A+B)`, `(A+B+C)` — so it does not pay one-CI-run-per-PR serially.
`merge_queue.max_parallel_checks` bounds the concurrency (our analogue of the old
`MAX_INFLIGHT_RUNS: 2`). ([parallel checks](https://docs.mergify.com/merge-queue/parallel-checks/))
5. **Batching** (`batch_size > 1`): Mergify combines the next *N* PRs into **one** temporary
batch branch and validates the whole batch with **a single CI run** — the key win for
test-only batches. It groups by priority + similarity (touching-similar-files) so batches
are cohesive. ([batches](https://docs.mergify.com/merge-queue/batches/))
6. **Batch failure → automatic bisection:** a red batch is **not** wholesale dequeued; Mergify
splits it (down toward single PRs) to isolate the culprit, dequeues only that PR, and lets
the rest proceed — bounded by `batch_max_failure_resolution_attempts`.
([batches](https://docs.mergify.com/merge-queue/batches/))
---
## The require-up-to-date trilemma
**This is the crux of the whole proposal.** Mergify's own docs are explicit:
> "Batches require the branch protection setting *Require branches to be up to date before
> merging* to be **disabled**." — [Mergify: Merge Queue Batches](https://docs.mergify.com/merge-queue/batches/)
The reason: with batching, Mergify tests a *temporary batch branch* that is up-to-date, then
merges the **original** PRs — whose own branches are **not** up-to-date per GitHub — so the
literal checkbox would block the merge. The documented ways out each cost something:
| You want to keep… | …and also… | …then batching is | Cost |
|---|---|---|---|
| `require-up-to-date` checkbox **literally on** | merge commits (`merge_method: merge`) | **serial only** (no batching) | no throughput win |
| `require-up-to-date` checkbox **literally on** | **batching** | via `queue_branch_merge_method: fast-forward` | **linear history — breaks the merge-commit policy** ([discussion #5138](https://github.com/Mergifyio/mergify/discussions/5138)) |
| merge commits **and** batching | (the throughput win) | yes, `merge_method: merge-batch` | **checkbox must be off**; guarantee moves into Mergify |
So `{ require-up-to-date literal · merge-commits · batching }` — **pick two.**
**The key reframing:** the maintainer rule is really about the **invariant** — *never merge
code that wasn't tested against the latest `main`.* Mergify's speculative queue **enforces
that invariant by construction** (it literally builds `latest-main + PRs` and tests *that*
before merging). Whether the *invariant* is enforced by GitHub's checkbox or by Mergify's
queue, it holds either way. Turning the checkbox off in Phase 2 does **not** weaken the
invariant — arguably it strengthens it (GitHub's checkbox only guarantees the branch *was*
up-to-date at some point and then requires a fresh run; Mergify guarantees the *exact merged
commit set* passed on top of the exact tip). But it **does** flip a literal setting the rule
names, so it is a conscious call for the maintainer, not something this proposal does silently.
**Recommended resolution:** Phase 1 keeps the checkbox literally on (serial). Phase 2 uses
**`merge_method: merge-batch`** (one merge commit per batch — preserves the merge-commit
policy), turns the **checkbox off**, and closes the bypass with "block any merge outside
Mergify" (below). The `fast-forward` row is documented for completeness but **not
recommended** — it would silently drop merge commits.
> **Open item to verify in a trial** (do not take on faith from docs): confirm on a throwaway
> branch that `merge-batch` lands a real merge commit and that, with the checkbox off + "merge
> only via Mergify" set, no path exists to merge a stale PR outside the queue.
---
## 4. Coexistence with the single `CI passed` gate
- Branch protection keeps requiring the one context **`CI passed`**. No change to
`ci.yml`'s `ci-passed` fan-in.
- Mergify **injects the repo's required checks as queue conditions automatically**
(`branch_protection_injection_mode`, default `queue`), and the proposed config *also* names
`check-success = CI passed` in `merge_conditions` explicitly, so a PR merges **only** when
the same gate branch protection requires is green. ([queue action](https://docs.mergify.com/workflow/actions/queue/))
- During Phase-1 serial operation Mergify "**respects your GitHub branch protections and
rulesets**" out of the box — including `require-up-to-date`, which it satisfies by updating
the branch itself before merging. ([setup](https://docs.mergify.com/merge-queue/setup/))
> **Gotcha:** the condition string must match the check name **exactly** — `CI passed` (the
> `name:` of the `ci-passed` job), *not* `ci-passed`. A wrong name means PRs queue but never
> merge (or never queue).
---
## 5. Priority: mapping the P0–P9 labels
Mergify `priority_rules` assign a priority from PR conditions; higher merges first (keywords
`low`=1000 / `medium`=2000 / `high`=3000, or numeric 1–10000; unmatched → `medium`).
([priority](https://docs.mergify.com/merge-queue/priority/)) The proposed config maps
**P0 → 10000 … P9 → 1000** linearly, so:
- **P0** (emergency-only) outranks everything and is picked first for the next batch.
- Unlabelled PRs fall to Mergify's default `medium` (2000) — the same "no label ⇒ P5-ish
default" behaviour `traffic_control.py` uses today.
- `broken`/`draft` PRs are **excluded from queueing** (they simply shouldn't merge), rather
than run at the bottom as the old traffic-controller did.
`allow_checks_interruption` (default true) reproduces the old **P0-preempts** behaviour: a
higher-priority arrival can interrupt in-flight speculative checks of lower-priority PRs.
---
## 6. What it replaces
| Old piece | Fate under Mergify |
|---|---|
| `autoupdate.yml` (branch bumping) | **Disable** — the queue updates branches itself. |
| `ci-trigger.yml` (priority scheduler, `MAX_INFLIGHT_RUNS`) | **Disable** — replaced by the queue + `max_parallel_checks`. |
| `traffic-control.yml` (already mothballed) | **Delete** (or leave disabled). |
| `traffic_control.py` + `traffic-control-tests` job | **Keep** — harmless, still unit-tested; can be removed later. Note: `ci-passed` currently lists `traffic-control-tests` in its `needs`; leave it or drop it, either is fine. |
| `AUTOUPDATE_TOKEN` secret / PAT plumbing | Becomes unnecessary — Mergify acts via its App installation, not a PAT. |
Net: a large amount of bespoke CI-orchestration YAML + Python is superseded by one
`.mergify.yml` and the App.
---
## 7. Interaction with the other CI workstreams
- **E2E path-filter (#399 / #402):** synergistic. Mergify runs CI on the *batch* branch, so
the path filter evaluates the batch's *combined* diff: a docs-only batch still skips E2E and
merges fast; a test-only batch still triggers the E2E it needs. The one requirement — that a
path-skipped run still reports a green **`CI passed`** — is already how branch protection is
satisfied, and Mergify keys on the same context, so no extra work. (Verify the skip path
produces `CI passed`, not an absent check, or the queue will wait forever.)
- **E2E sharding (#372, and the API 37 N=2 shards):** orthogonal and compounding — faster CI
⇒ faster batch validation ⇒ shorter queue latency. No config interaction.
- **Wedge diagnostics (#404 / #406):** still run inside the CI Mergify triggers. Better,
`checks_timeout` on the queue bounds a wedged leg: instead of a hung run blocking the queue,
the batch times out and Mergify bisects/re-runs — turning "hand-babysit a wedged emulator"
into an automatic dequeue of the offending PR.
---
## 8. Proposed `.mergify.yml`
The full proposed config is in **`docs/ci/mergify.yml.proposed`** (kept as `*.proposed`, **not**
a live `.mergify.yml`, so nothing activates). Outline below.
```yaml
# NOT ACTIVE — see docs/ci/mergify.yml.proposed for the annotated version.
queue_rules:
- name: default
merge_conditions:
- check-success = CI passed # the single required gate
- "#approved-reviews-by >= 1"
- -draft
- label != broken
# Phase 1 (keeps require-up-to-date literally ON): batch_size: 1 + merge_method: merge
# Phase 2 (batching — checkbox off, guarantee moved into the queue):
batch_size: { min: 1, max: 5 } # dynamic; one CI run per batch
batch_max_wait_time: 5 min
merge_method: merge-batch # one MERGE COMMIT per batch (keeps merge policy)
checks_timeout: 45 min # slow/wedge-prone E2E: time out -> bisect
merge_queue:
max_parallel_checks: 2 # == old MAX_INFLIGHT_RUNS
priority_rules: # P0 -> 10000 ... P9 -> 1000 (higher merges first)
- { name: p0-emergency, conditions: [label = P0], priority: 10000 }
# … P1..P9 …
pull_request_rules:
- name: Queue green, approved, non-draft PRs targeting main
conditions:
- base = main
- -draft
- label != broken
- "#approved-reviews-by >= 1"
- check-success = CI passed
actions:
queue: { name: default }
```
---
## 9. Setup steps (free-OSS)
1. **Install the Mergify GitHub App** on `JMR-dev/LibreMail` from the
[marketplace](https://github.com/marketplace/mergify) (Open Source plan, $0). Requires
GitHub admin on the repo. The App requests read/write on pull requests, checks, and
contents (it must be able to create temporary branches and merge).
2. **Commit `.mergify.yml`** at the repo root — start from `docs/ci/mergify.yml.proposed`,
**Phase-1 variant** (`batch_size: 1`, `merge_method: merge`). Mergify validates the config
on push.
3. **Keep** branch protection `require branches up to date` + the required `CI passed` context
unchanged (Phase 1). Confirm PRs queue and merge.
4. **Phase 2 (only after the maintainer signs off on the trilemma):** switch to
`merge_method: merge-batch` + `batch_size`, **turn off** GitHub's literal
`require branches up to date` checkbox, and **block merges outside Mergify** (restrict who
can push/merge to `main` so the queue is the only merge path — Mergify's recommended
companion to disabling the checkbox
([discussion #5138](https://github.com/Mergifyio/mergify/discussions/5138))).
5. **Disable** `autoupdate.yml` and `ci-trigger.yml`; delete/retire `traffic-control.yml`.
---
## 10. Risks & tradeoffs
- **Third-party in the merge path.** Mergify gets write/merge authority on `main`. It is an
OSS-standard, widely used app, but it is a new external dependency and a supply-chain /
availability surface. If Mergify is down, merges pause (fail-safe, not fail-open).
- **The require-up-to-date decision (Phase 2).** Turning off the literal checkbox is the
price of batching. Mitigated by moving the guarantee into the queue **and** blocking
out-of-queue merges — but it is a real, conscious change to a rule the maintainer has
pinned, and must be signed off, not assumed.
- **Speculative-check CI cost.** Parallel checks and batch bisection **run CI more**: a failed
batch can re-run the ~15-min E2E matrix several times while splitting to find the culprit.
On a slow/wedge-prone matrix that is a genuine runner-minute cost. Mitigate with a small
`batch_size` cap, `batch_max_wait_time`, `checks_timeout`, and modest `max_parallel_checks`.
- **Batching hides which PR broke it (until bisection finishes).** A red batch doesn't
immediately name the culprit; Mergify bisects automatically, but that costs runs + latency.
Best for **test-only / low-risk** batches (the #373 case); route risky feature PRs through a
serial path (batch_size 1) or lower priority so they validate alone.
- **Config complexity + a new failure mode.** `.mergify.yml` is another surface. The classic
footgun is a condition that doesn't match the real check name (`CI passed`) — PRs then queue
but never merge. Needs a short trial to shake out.
- **Loss of bespoke control.** The traffic-controller's exact P0-preempt / hold-back semantics
are approximated (not byte-identical) by Mergify priorities + `allow_checks_interruption`.
Close enough in practice, but not the same code.
---
## 11. Recommendation
**Adopt Mergify, phased.**
- **Phase 1 now:** serial queue. Immediate elimination of the manual serial-bump grind and the
whole traffic-controller apparatus, with **zero** change to the require-up-to-date rule or
the merge-commit policy. Low risk, high daily-quality-of-life win.
- **Phase 2 when the maintainer signs off:** batching via `merge-batch`, moving the
up-to-date **guarantee** into the queue (checkbox off, invariant preserved, merges locked to
Mergify). This is the only remaining path to the #373-scale throughput win (native queue is
unavailable) and it keeps the merge-commit policy. Gate it on the explicit trilemma decision
and a throwaway-branch trial that verifies (a) `merge-batch` really lands merge commits and
(b) no stale PR can merge outside the queue.
If the literal checkbox is truly immovable *and* linear history is unacceptable, **stop at
Phase 1** — that is still a large win over today.
---
## Sources
- [Mergify — Merge Queue](https://docs.mergify.com/merge-queue/)
- [Mergify — Merge Queue Batches](https://docs.mergify.com/merge-queue/batches/)
- [Mergify — Parallel Checks](https://docs.mergify.com/merge-queue/parallel-checks/)
- [Mergify — Using Queue Rules](https://docs.mergify.com/merge-queue/rules/)
- [Mergify — Using Priorities](https://docs.mergify.com/merge-queue/priority/)
- [Mergify — Merge Strategies](https://docs.mergify.com/merge-queue/merge-strategies/)
- [Mergify — Configuration File Format](https://docs.mergify.com/configuration/file-format/)
- [Mergify — queue action](https://docs.mergify.com/workflow/actions/queue/)
- [Mergify — Merge Queue setup](https://docs.mergify.com/merge-queue/setup/)
- [Mergify changelog — new `merge-batch` merge method](https://docs.mergify.com/changelog/2026-04-07-new-merge-batch-merge-method-for-merge-queue/)
- [Mergify discussion #5138 — Merge Queue and branch up-to-date protection](https://github.com/Mergifyio/mergify/discussions/5138)
- [Mergify pricing](https://mergify.com/pricing) · [Mergify on GitHub Marketplace](https://github.com/marketplace/mergify)
- Repo: `.github/workflows/ci.yml`, `autoupdate.yml`, `ci-trigger.yml`, `traffic-control.yml`, `.github/scripts/traffic_control.py`; issue #349 + the `ci-merge-cascade` note.