ci: auto-cancel superseded merge-request pipelines
What
Superseded merge-request pipelines now get canceled instead of running to completion.
Five pieces across three files, and all five are needed:
| Where | Change |
|---|---|
.gitlab-ci.yml default: |
interruptible: true |
.gitlab-ci.yml workflow: |
auto_cancel: {on_new_commit: none}, with on_new_commit: interruptible on the merge-request rule only |
.gitlab-ci.yml cache:go-mod-warm |
interruptible: false |
.gitlab/ci/docs.gitlab-ci.yml .review-docs |
interruptible: false |
.gitlab/ci/security-mr-review.yml security-mr-review |
interruptible: false |
The first two do the work. The last three are the exclusions that make the first two safe, and the reason for each lives on the job it guards.
Why
Neither .gitlab-ci.yml nor any file under .gitlab/ci/ set interruptible:,
and there was no workflow: auto_cancel:. The keyword defaults to false, so a
pipeline stopped being cancelable the moment its first job started, and in
practice superseded pipelines always ran to completion.
What that costs depends on the pipeline's shape: roughly 190 job-minutes when integration tests are triggered, and 36 to 44 when they are not. Sharding the datastore suite (!1781) would raise the first figure to around 250. These are measurements of a suite that changes, so re-measure rather than quoting them.
This buys no latency. Queue times in the sampled window were 3 to 37 seconds, so a shorter queue is not the win. The win is recovered compute and less fleet contention.
Why the scoping is not optional
This is the part that makes the change larger than the one-liner the issue suggested, so it is worth being explicit about.
GitLab's default auto-cancel mode is conservative, and conservative does two
things people do not expect:
- It cancels the whole superseded pipeline, including jobs marked
interruptible: false. Per-job exclusions are not honored in this mode. - Its only protection is that the pipeline stops being cancelable once an
interruptible: falsejob has started (running,success, orfailed). A job still sitting inpendingprotects nothing, andmanualis not a started status either.
So default: interruptible: true on its own, which is the shape the issue
proposed, would make every pipeline on every ref fully cancelable at any
point in its life, and cancel it wholesale. Concretely:
- A push to
mainwhile the previousmainpipeline'ssemantic_releaseor fairway'sPush Helm chartwas queued or running would kill it, and that commit's release attempt would simply not happen. - A schedule pipeline shares the ref
mainwith branch-push pipelines, so a push tomaincould kill a runningrenovate_bot,sync:adrs, orsync:doc-principles.
auto_cancel: {on_new_commit: none} at the workflow: level opts every pipeline
source out, and the *if-mr rule opts merge requests back in with
on_new_commit: interruptible. That mode cancels only jobs marked
interruptible: true and leaves the rest running, which is what turns a per-job
interruptible: false into a guarantee rather than a hint.
<<: *if-mr plus sibling keys in one rule is the pattern this file already uses
(.non-docs-rules does <<: *if-mr with changes:).
Why .review-docs needs its own exclusion
Its two jobs, docs:review-deploy and docs:review-cleanup, run on
merge-request pipelines, which is exactly what the scoping enables cancellation
for. The pipeline-level rule cannot protect them, so the exclusion goes on the
job.
They are stage: deploy, when: manual, and carry environment: with
auto_stop_in: 2 weeks and on_stop: docs:review-cleanup. Canceling a running
deploy leaves the environment in a state nothing reconciles, and canceling the
cleanup orphans the review app. One interruptible: false on the hidden parent
covers both.
These are the only jobs with an environment: anywhere in the merged
configuration, not just this repository's own files: of its 135 visible job keys
(alongside 68 hidden .-prefixed templates), docs:review-deploy and
docs:review-cleanup are the two. That is stronger than an earlier revision of
this section claimed, and it answers the reviewer question about
component-provided deploy jobs.
But "manual jobs carrying environment:" is the wrong test, and an earlier
revision used it to conclude nothing else needed excluding. Side effects are
not confined to jobs with an environment:. Four locally defined jobs pass that
test while writing state outside the runner, and two of them are reachable on
merge-request pipelines, which are exactly the pipelines this MR makes
cancelable:
| Job | Writes | Now |
|---|---|---|
security-mr-review |
non-idempotent POST creating an MR note | interruptible: false |
cache:go-mod-warm |
sole writer of the shared go-mod cache slot |
interruptible: false |
sync:adrs, sync:doc-principles |
push to the handbook and this repo | schedule-only, so none covers them |
cache:go-mod-warm is the clearer of the two: the runner archives a cache after
the script, so a canceled run writes nothing, and every reader then falls back
to a stale slot with no signal at all, because the job is advisory and nobody
watches its duration. Excluding it costs about a job-minute per supersede
(median run 45s, p90 53s, slowest observed 192s across 745 runs).
security-mr-review needed a more careful argument than an earlier revision of
this section gave it. That revision said cancellation between the curl and the
AppSec flow applying its label produces the duplicate @mention the file's
retry: ban exists to prevent. That reasoning runs backwards. The POST is
the last statement in script:, so canceling the job can only drop an @mention,
never add one; a retry re-sends it, which is what makes the retry: ban right
and makes borrowing it here argue the opposite of what was needed. The duplicate
comes from the label gate in rules: instead: two pipelines seconds apart both
see no label yet and both post, canceled or not.
The exclusion stays, for the reason now written on the job: a cancel landing
mid-curl can leave the note created while the job records no success, so nothing
downstream can tell whether the flow was triggered. In practice it is close to a
no-op, and deliberately so. Across 686 superseded pipelines in the sampled
window, security-mr-review appeared in 162 and had already finished before the
supersede instant in all 162 (needs: [], median duration 15s).
The criterion is therefore "does the job write state outside the runner",
not "is it manual with an environment:". The conclusion happened to hold for
the two sync jobs, but by luck of the workflow rules rather than by the stated
test, and the test as written would mislead the next person to apply it.
For the record, the count in the earlier revision was also wrong: .gitlab-ci.yml
declares 53 visible job keys, four of which use parallel:matrix and expand to
61 concrete jobs, not 45.
Component jobs, and why the blanket default: is the right reach
A default: block applies to every job in the fully merged configuration, not
only to jobs in the file that declares it, and this is the pipeline's only
default:: none of common-ci-tasks@v5.2 (setup-mise, standard-build,
golang-build, release-platform, danger), runwayctl, or fairway declares one.
That reach is the point, because go_unittests, golangci_lint, gitleaks,
danger and the rest cannot otherwise be marked without naming each of them.
The only pre-existing interruptible: in the include graph is on the
modular-feature-testing/go-test jobs. There are four such entries, not two
as an earlier revision said: test:go:unit, test:go:integration,
test:go:lint, and the hidden .test:go:integration. And
.gitlab/ci/common-ci-tasks-patches.yml disables one of them, not both;
test:go:unit and test:go:lint are inert through the component's own
rules: [{if: "false == true"}]. None materializes either way, so nothing
behaves differently, but this sentence is the blast-radius argument and it
should be countable.
The Runway production deploy is not at risk, and not because of the scoping: it
is a trigger: bridge into a different project, and the docs are explicit that a
multi-project downstream pipeline is never affected by the trigger job's
interruptible setting.
The build-jobs child pipeline is a different case, and it is not reaped.
The docs split the two: a child pipeline "is automatically canceled if the
pipeline is configured with interruptible", while only multi-project ones are
exempt. But the config resolve-build-streams generates carries no workflow:
and no default: of its own, so the child runs under conservative and stops
being cancelable once its first job starts, which happens within a second of
the bridge. The result: the bridge flips to canceled while the child keeps
building and pushing images for the superseded commit.
Measured over the same window, that is about 1,200 job-minutes across 190
superseded child pipelines, roughly 15% on top of what this MR does reclaim, and
it contains the two longest jobs in the run. Its images are per-job-id staging
tags, so nothing is left half-written and there is no safety hazard, but the
compute is not recovered and a canceled bridge does not mean the build stopped.
Not fixed here: the child's config is generated by the component, so the fix
belongs upstream or in a tags:-style override, and the job comment now says so
rather than leaving the totals to imply otherwise.
Two things a reviewer should know before merging
It takes effect one pipeline late on any given ref. GitLab reads the cancel
mode off the superseded pipeline, whose metadata was frozen when it was
created. The first pipeline after this merges still carries the old
conservative value, so reaping starts on the second push to a branch. Do not
read the first push as a failed rollout.
One behavior on main genuinely changes, in the safe direction. Today
conservative does cancel a superseded main pipeline in the narrow window
before its first job starts. none removes even that, so two pushes to main
seconds apart will both run their pipelines to completion. That costs a little
compute and buys a guaranteed release attempt per commit, which is the right
trade for a main pipeline that runs semantic_release and pushes a Helm chart.
Calling it out rather than claiming main is untouched.
The same loss applies in principle to feature-branch pipelines with no open MR,
which also move from conservative to none. In practice it measures at
nothing: of the 300 most recent push-source pipelines, 2 were canceled, and
both were on main.
Merge trains are in scope, and that is deliberate
*if-mr tests $CI_PIPELINE_SOURCE == "merge_request_event" alone, which
matches detached, merged-result and merge-train event types.
.gitlab/ci/security-mr-review.yml's own rule comment already records that
distinction. Merge trains are enabled on this project
(merge_trains_enabled: true), and a third of recent merge-request pipelines
ran on a /train ref (334 of the last 1,000, against 623 /merge and 43
/head), so train pipelines are reaped under this rule too.
That is the intent rather than an oversight: a train rebuild supersedes the pipeline it replaces, and the jobs excluded above stay excluded on a train exactly as they do on a detached run. Written down because a change whose whole justification is careful scope analysis should not leave a third of its own scope unmentioned.
What this deliberately does not do
Add on_job_failure: all. The rule would accept it, but on a validate fan-out
of 61 concrete jobs it means one fast lint failure kills most of the in-flight
test jobs and you get one red job instead of the whole failure picture. You
would re-push to learn what else was broken, spending back more than it saved.
Worth considering separately, with data.
Conflicts
Rebased onto main at 934df003 on 2026-08-27, and the reason is the one a
reviewer raised: main had already moved inside this MR's own edit region,
adding - <<: *if-e2e-caproni to the same workflow:rules list, so the earlier
"different regions" reasoning covered the other open MRs but not main itself.
The rebase absorbs that rule, and the GET /ci/lint?sha= readback in Testing
was re-run against the post-rebase configuration. *if-e2e-caproni is a
schedule-source rule, so it inherits the workflow-level none, which is the
safe direction and consistent with how the comment above the block treats
schedule pipelines. GitLab reports has_conflicts: false and
can_be_merged at 8b425400.
Post-rebase hunk locations: .gitlab-ci.yml lines 256 (default:), 318 and 351
(workflow:) and 2227 (cache:go-mod-warm); .gitlab/ci/docs.gitlab-ci.yml
line 113; .gitlab/ci/security-mr-review.yml line 34.
Open MRs touching the same three files, re-listed on 2026-08-27 from each MR's own diff:
.gitlab-ci.yml: !1781 (the sharding MR in this same series), !1662, !1910, !2010 (merged), !2000 (merged), !1937, !1930, !1921 (merged), !1916, !1667, !1080, !924, !203. All but one sit elsewhere in the file, so those are textual rebases rather than semantic conflicts. !1080 is the exception, and an earlier revision of this section was wrong to claim every overlap was in a different region: that draft adds- <<: *if-divergence-mineto this sameworkflow:ruleslist, one line away from this MR's edit. It is a schedule-source rule, so it inherits the workflow-levelnoneexactly as*if-e2e-capronidoes, and whichever lands second resolves as a textual rebase..gitlab/ci/docs.gitlab-ci.yml: !1778, the lychee-cache MR in this same series, editsdocs:linksaround line 40, and !2007 (merged) changes the shared.docs-lint-imagetag around line 8. This edits.review-docsaround line 113..gitlab/ci/security-mr-review.yml: no other open MR touches it.
glab mr list --search 'auto_cancel' returns nothing but this MR, and no issue
other than #751 mentions either keyword. --search 'interruptible' also returns
!1781 and !1778, the two sibling MRs in this series; both mention the keyword in
their descriptions only, and neither diff sets interruptible: anywhere.
Testing
CI plumbing with no Go surface, so no unit test applies. Verified by behavior rather than by a job:
- The merged configuration was read back from
GET /projects/:id/ci/lint?sha=<head>(?ref=silently lints the default branch, so it has to be?sha=). Re-taken after the rebase, at8b4254006d554f476cb96128d7e631e5747a488c, so the evidence covers the shape that will merge. It reportsvalid: true, 0 errors and 0 warnings, exactly onedefault:and thatdefault:carryinginterruptible: true,auto_cancel: {on_new_commit: none}at theworkflow:level, and nineworkflow:rulesof which only themerge_request_eventrule carriesauto_cancel: {on_new_commit: interruptible}.interruptible: falseresolves onto all four excluded jobs, which are five keys in the merged configuration:security-mr-review,cache:go-mod-warm,.review-docs, and throughextendsontodocs:review-deployanddocs:review-cleanup. The earlier readback was taken against eight workflow rules, beforemainadded- <<: *if-e2e-caproni, so it was not evidence for the post-rebase shape; that is what this re-run replaces. - Push twice to this branch in quick succession. Because of the
one-pipeline-late effect above, the reaping shows up from the second push
onward: the older pipeline should move to
canceledwhile the newer one runs. - Confirm no job on a
mainor tag pipeline is ever canceled.
Related to #751