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:

  1. It cancels the whole superseded pipeline, including jobs marked interruptible: false. Per-job exclusions are not honored in this mode.
  2. Its only protection is that the pipeline stops being cancelable once an interruptible: false job has started (running, success, or failed). A job still sitting in pending protects nothing, and manual is 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 main while the previous main pipeline's semantic_release or fairway's Push Helm chart was queued or running would kill it, and that commit's release attempt would simply not happen.
  • A schedule pipeline shares the ref main with branch-push pipelines, so a push to main could kill a running renovate_bot, sync:adrs, or sync: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-mine to this same workflow:rules list, one line away from this MR's edit. It is a schedule-source rule, so it inherits the workflow-level none exactly as *if-e2e-caproni does, and whichever lands second resolves as a textual rebase.
  • .gitlab/ci/docs.gitlab-ci.yml: !1778, the lychee-cache MR in this same series, edits docs:links around line 40, and !2007 (merged) changes the shared .docs-lint-image tag around line 8. This edits .review-docs around 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:

  1. 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, at 8b4254006d554f476cb96128d7e631e5747a488c, so the evidence covers the shape that will merge. It reports valid: true, 0 errors and 0 warnings, exactly one default: and that default: carrying interruptible: true, auto_cancel: {on_new_commit: none} at the workflow: level, and nine workflow:rules of which only the merge_request_event rule carries auto_cancel: {on_new_commit: interruptible}. interruptible: false resolves onto all four excluded jobs, which are five keys in the merged configuration: security-mr-review, cache:go-mod-warm, .review-docs, and through extends onto docs:review-deploy and docs:review-cleanup. The earlier readback was taken against eight workflow rules, before main added - <<: *if-e2e-caproni, so it was not evidence for the post-rebase shape; that is what this re-run replaces.
  2. 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 canceled while the newer one runs.
  3. Confirm no job on a main or tag pipeline is ever canceled.

Related to #751

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading