A cancel request does not stop a running step. The runner asks it to end, and a step that ignores the signal keeps going, bounded only by its own timeout-minutes -- 350 for a deploy. Meanwhile the concurrency group holds the newer run on pending until the occupant is terminal, not until it is cancel-requested. Measured on run 32859801382: the cancel was accepted at 15:27:45 and thirteen minutes later ten deploy jobs were still running and the newer run still had not started. The sweep now waits FORCE_CANCEL_AFTER_SECONDS and force-cancels whatever is still not completed. Cleanup steps are skipped for those, which is the price: the artefacts of an already superseded run help nobody. Only entry-cancel-superseded.yml sets the variable -- a closed pull request and a deleted branch have nothing waiting on the group, so they keep cancelling and walking away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6.9 KiB
Run Cancellation 🚫
Shell helpers that cancel GitHub Actions workflow runs whose original trigger is no longer relevant.
Scope 📋
This directory contains helpers invoked from workflows that listen on pull_request_target, push, and delete. The helpers call the GitHub Actions API to cancel runs made obsolete by a closed PR, a deleted branch, or a newer commit on the same PR or branch. Helpers MUST NOT be called from workflows that have not received one of those events, because cancelling unrelated runs would mask real failures and waste accumulated work.
Superseding runs 🔁
On a long orchestrator sweep the concurrency group sometimes fails to reap its occupant: the occupant's run-level status sits on queued — the normal state of a run whose matrix jobs are still waiting for runners — and the newer run parks on pending behind it until someone cancels the sweep by hand. Whether queued is the cause or another symptom cannot be decided from outside GitHub; what is observable is that the group does not always reap. The API cancel does not depend on any of that, so entry-cancel-superseded.yml calls these helpers on every new commit as well. That workflow deliberately declares no concurrency group: the run holding a group cannot be the run that frees it.
The group stays primary. It reaps within seconds of a run being created and needs no runner, while the fallback first has to win a runner slot — from the very pool the superseded sweep is saturating, so it is slowest exactly when it is needed. Measured waits in this repository range from under a minute to about twenty. That is the cost of the fallback, and the reason it is a fallback rather than the mechanism.
Three variables keep the fallback inside the group's bounds:
-
KEEP_NEWEST_PER— keeps the newest run of each group instance alive and cancels only the older ones, which is the rule the concurrency group itself applies. It is what makes the fallback safe against races: the head SHA from the event payload goes stale while the job waits for a runner, and a[skip ci]orGITHUB_TOKENpush moves the branch tip without creating a run at all. Selecting by "not the current head" would then cancel the newest commit's CI and spare the stale one, or leave the branch with no CI at all. Selecting by "not the newest run" cannot, in any ordering.The value must mirror how the backed-up group is keyed.
alltreats every matched run as one instance — correct forglobal-ci-<repo>-<ref_name>, which spans bothentry-push-latest.ymlandentry-manual-steer.yml, so a push must be able to reap a manual sweep.eventkeeps one instance per event name — correct forpr-ci-<repo>-<event_name>-pr-<number>. Grouping any finer than the real key spares a run the group would have reaped: per-workflow grouping leaves the manual sweep alive, because it is the newest run of its own workflow. -
CURRENT_RUN_ID— keeps the calling run itself alive. -
INCLUDE_PATHS— newline-separated workflow paths, the members of the concurrency group being backed up. Without it the helpers would also cancel runs the group never governed, such as thecron-*workflows that listen onpush. Derive the list from theconcurrency:blocks, not from what looks related:entry-delete-branch.ymlkeys its group on the deleted ref, so it is not a member of a push's group, and listing it would make a push tomaincancel an unrelated branch cleanup. A value that lists no path at all aborts rather than silently matching nothing.
Leave KEEP_NEWEST_PER and INCLUDE_PATHS unset to cancel every matching run, as the lifecycle events do — a closed PR and a deleted branch have no run left that deserves to survive. A fallback also mirrors whatever gates its group: global-ci-* cancels on every ref except main, so the push job carries the same github.ref_name != 'main' guard, while pr-ci-* is declared cancel-in-progress: true unconditionally, so the pull request job is ungated too.
FORCE_CANCEL_AFTER_SECONDS turns the cancel into an escalation. A cancel request does not stop a running step: the runner asks it to end, and a step that ignores the signal keeps going until its own timeout-minutes, which is 350 for a deploy. The concurrency group holds the newer run on pending until the occupant is terminal, not until it is cancel-requested, so a superseded sweep can block its branch for hours after being told to stop -- measured at eight minutes and counting, with ten deploy jobs untouched. After the wait, every run that is still not completed gets force-cancel, which bypasses the conditions that keep it alive. The skipped cleanup steps are the price, and for a run that has already been superseded its artefacts are of no use to anyone. Leave the variable unset to cancel and walk away, which is what a closed pull request and a deleted branch want.
Every selected run is attempted before anything is reported; abandoning the rest on the first rejection would leave them burning runners. HTTP 409 means the run already finished and is not a rejection at all. Any other rejection is logged as an ERROR line, but only a denied token fails the job — a rate limit or a run that vanished between listing and cancel is transient, and a red check for those is a red check nobody reads.
"Newest" means the run that entered the group last, so the helpers sort on run_started_at and fall back to created_at. A re-run keeps the created_at of its first attempt while re-entering the group as the newest member, and sorting on created_at would cancel it as the oldest.
Two open pull requests that share a head repository and a head branch, to different bases, cannot be told apart from the run payload: the runs of a fork PR carry an empty pull_requests array, the same head_branch and the same head_sha. The helper therefore asks the API whether any other open pull request shares that head, and drops the branch matcher while one does — cancelling nothing beats cancelling the wrong pull request. If that lookup itself fails the matcher stays on, because a failed lookup is far more likely than a shared head.
The branch-name matcher normally also requires the run's head repository to be the pull request's own. Two cases relax that: a run whose head_repository the API omits, and a pull request whose head fork has been deleted, where the event payload carries no head repository either. Both are matched on the branch name alone, because otherwise their runs would keep going forever — and neither field is something a contributor can influence. The ambiguity check above is what makes that safe: it runs whenever a head branch is known, with or without a repository, so a shared branch name still disables the matcher rather than cancelling the wrong pull request.
For the workflow catalog that drives these calls see workflows.md.