Skip to content

CI cancellation race — narrate's own commit kills in-flight pipelines

Status: open bug. Observed repeatedly in production; costs builds silently.

Symptom

A push produces no image. No job fails, no notification arrives, and the pipeline is simply gone. Waiting longer never helps — the build was cancelled, not slowed.

Observed on HomeLabHD/LinkStack, 2026-09-14, twice in one afternoon:

time event
13:35:10 7c34b90 pushed (application change)
13:36:21 1416edf "docs: refresh generated badges" pushed by the previous pipeline's narrate stage
— no dev-7c34b90 image ever published

and again:

time event
14:59:23 dev-a21f9d5 image published
14:59:54 e8d4daf pushed and e40e8a1 narrate badge commit pushed
— no dev-e8d4daf image; recovered only by a manual pipeline re-run

The earlier build in each pair succeeded in roughly five minutes, so the silence is not slowness.

Cause

The emitter renders both of these (src/ci/render/gitlab/emitter.go):

workflow:
  auto_cancel:
    on_new_commit: interruptible   # PipelineDefaults.CancelSuperseded
default:
  interruptible: true              # PipelineDefaults.Interruptible

Narrate pushes its generated-docs commit back to the branch that triggered the pipeline, so the pipeline races itself. When that commit lands while a newer push is still building, GitLab treats it as a new commit on the ref and cancels the in-flight pipeline.

The existing self-skip rule does not prevent this:

- if: '$CI_COMMIT_MESSAGE =~ /(?m)^Generated-By: StageFreight/ && $CI_PIPELINE_SOURCE == "push"'
  when: never

That rule is one-sided. It suppresses pipeline creation for narrate's commit, which is what it was written for, but auto_cancel.on_new_commit acts on the arrival of a commit on the ref and is indifferent to whether that commit created a pipeline. So the badge commit starts nothing and cancels everything.

Because interruptible: true is a pipeline-wide default with no per-job override, every stage is cancellable — including publish, which is the one stage whose cancellation loses an artifact rather than just time.

Add a per-job interruptible override and render publish (at minimum) as interruptible: false.

GitLab only auto-cancels jobs marked interruptible, and once a job with interruptible: false has started, the pipeline stops being auto-cancellable at all. That yields exactly the wanted property: cancellation stays available for the cheap early stages where superseding is a genuine saving, and becomes impossible from the moment a pipeline starts producing something.

This needs Interruptible on the job model rather than only on PipelineDefaults, plus golden-file and fleet regeneration.

Alternatives considered

  • Drop auto_cancel. Fixes the race, but forfeits superseding entirely — every abandoned push then builds to completion.
  • Narrate pushes elsewhere (orphan ref, or the artifact store). Removes the self-race at its root and is arguably the cleaner long-term shape, but it changes where generated docs live, which is a much larger contract change.
  • [skip ci] on narrate's commit. Rejected previously for a documented reason — it is context-blind and also suppresses tags.

Operator workaround until fixed

If no dev-<sha> appears in roughly twice the usual build time, check whether a badge commit landed just after the push. If so the build was cancelled, not failed, and no amount of waiting will produce it. Re-run the pipeline once the branch is quiet — noting that a re-run builds the branch tip, which will be the badge commit, not necessarily the commit intended.