main when they land together. That is how a default branch goes red with nobody to blame.
The merge train closes that gap. Ankra tests each pull request as the commit its merge will produce, on top of the base branch and every pull request ahead of it. It merges only after that exact commit passes. It does what GitLab’s merge trains and GitHub’s merge queue do, on any GitHub plan, inside your own CI cluster.
The merge train works with GitHub repositories today, and merges with squash. It is off until you turn it on for a repository.
Why it is worth turning on
mainstays green. Nothing merges onto a base its car did not test, so a semantic conflict between two pull requests is caught before it lands, not after.- No bisection. Each car tests everything ahead of it. When a car fails, the pull request in that car is the one that broke it.
- Parallel, not serial. With several cars, the train tests up to eight pull requests at once, each stacked on the ones ahead.
- Merge to published is a re-tag. The merge commit’s tree is exactly what the car tested, so a pipeline with
promote_frompublishes the car’s image instead of building and testing the same tree again. - Your rules still apply. The train merges through GitHub’s own merge API. Branch protection, required reviews and required checks hold exactly as for a person’s merge. Ankra never pushes to or force-pushes the base branch.
How a car is built
For each pull request in the train, Ankra writes a car: the pull request squashed onto the base branch, or onto the car ahead of it.- The car’s branch,
ankra-merge-train/<entry id>, starts at its base: the base branch head for the first car, or the car ahead for every other car. - The pull request’s pinned head is merged in, using the same merge GitHub’s squash merge computes.
- A single-parent commit with that tree is written. It is exactly the squash commit the merge will create, titled
<title> (#<number>). - A pipeline run tests that commit. The run records the car’s tree, which is what lets the merge publish without a rebuild.
- It runs only when the pipeline declares
on.pull_requestwithbranchesthat match the base branch. when.events: [pull_request]stages run. Push-onlypublishand deploy stages do not.- It never joins the pull request’s own concurrency group, and a push to an
ankra-merge-train/*branch never starts a run of its own. - A
[skip ci]marker never skips a car.
The life of a pull request in the train
A car whose run concludes skipped counts as passing. For example, every changed file is outside
on.pull_request.paths. A skipped car is never a promotion source, though.
Several cars at once
With--max-cars 3, the train tests three pull requests side by side, each on top of the ones ahead.
- Only the first car merges. It merges once its run passed and the base branch head, re-read just before the merge, is still the head it was built on.
- When a car fails, its pull request leaves the train with the failing run linked. Every car behind it included the failed change, so they are rebuilt without it (
predecessor_changed). Nothing has to be bisected. - When someone merges around the train, every built car goes back to
queuedwithbase_movedand is rebuilt on the new head. - When you lower
max_cars, cars beyond the new limit are requeued withtrain_shortened.
Turn it on
1
Make sure the pipeline tests pull requests into the base branch
The car run is a pull request run, so the pipeline needs:Without it, a car is evicted with
pipeline_not_triggered.2
Enable the train
--application <name> or --repository <id>. This needs pipelines.manage. --max-cars takes 1 to 8 and defaults to 1.3
Require the train's check on the base branch
Ankra posts an Ankra merge train check on every pull request it holds, and concludes it
success only immediately before it merges. In GitHub’s branch protection, require that check, pinned to the Ankra GitHub App rather than any source. Every merge then goes through the train. A pull request outside the train shows the check as expected and waiting, and cannot merge.4
Optionally, publish the tested image on merge
Add
promote_from: pull_request to your publish stage, so a train merge publishes the car’s image instead of rebuilding it. See Build once, publish the tested image.Put a pull request on the train
There are three ways in, and they all lead to the same queue.- CLI
- Label
- Merge when green
pipelines.operate and must be a person, not a service token. You also need one of these: organisation administrator, the pull request’s author, or write access to the repository on GitHub through your verified GitHub identity. You can always remove an entry you added yourself. When GitHub cannot be asked, nothing changes and the command says so.
Pushing to a pull request in the train takes it out (head_changed). The train merges the head a person put in it, never one that arrived later. Push your fix, then add it again.
Merges GitHub declines
The train merges through GitHub’s merge API, so GitHub can say no, usually because one of the pull request’s own required reviews or checks has not arrived.- The car stays
passedand the merge is retried on every pass, with GitHub’s message shown as the reason. - Cars behind it keep building and testing meanwhile. The train does not freeze behind one stuck merge.
- When GitHub says a required status check is failing, the car is evicted after 15 minutes. A failed check does not turn green by waiting, so re-run it and add the pull request again.
- For any other reason, such as a review that has not arrived yet, the car waits up to 3 hours before it is evicted.
Why a pull request left the train
ankra pipeline train list shows what recently left the train and why. The same sentence is on the pull request’s check run.
A car sent back to be rebuilt shows
queued (<reason>): base_moved, predecessor_changed, train_shortened, run_missing or run_replaced. That is normal train motion, not a failure.
After every merge, the train compares the merge commit’s tree with the car’s tree. The TESTED AS MERGED column of train list is that comparison: it is the proof that what merged is what was tested.
Build once, publish the tested image
Without the train, a merge tomain builds, scans, tests and gates the merged commit again, even when the pull request run already did the same work. With the train, the merge commit’s tree is exactly the tree the car tested, so that second build is pure waste.
Declare it on the publish stage:
- Every
publishstage declarespromote_from, under approved authority. It is a protected section, so a pull request cannot turn it on for itself. - The push’s plan is only
publishand the stages it needs. - A pull request or car run tested the same git tree, under the same approved authority, within the last 72 hours.
- Every stage
publishneeds passed in that run on its newest attempt. A stage that run skipped as not affected counts too. - That run’s
gatejudged exactly the digest itsbuildreported. - The run was not from a fork, and was not a skipped car.
Promotion: promoted (from run #N), or names the reason it fell back, such as no_tested_run, source_inputs_differ or source_too_old.
Commands
Every command takes
--application <name> or --repository <id>, or infers the repository from your checkout’s origin. Add -o json for scripts. merge-train is an alias for train. Over the MCP server, a coding agent enters a pull request through merge when green (scm_merge_pull_request), which joins the train on a repository that has one, and follows the car’s run with watch_pipeline_run.