From ccff8457169e305f94dd443180c1270788b0f166 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 19 Aug 2026 17:31:08 -0400 Subject: [PATCH 1/2] Cancel a pull request's in-flight runs when it closes Once a pull request is merged or closed its running jobs can no longer tell anyone anything, but they hold their runners until they finish. That is ordinarily just waste; with the queue hundreds of jobs deep it delays every other pull request, because those runners are precisely what the queue is waiting for. `concurrency` does not cover this. It supersedes a run only when a newer run starts in the same group, and closing a pull request starts nothing. Fork pull requests are skipped, since their token is read-only and the cancel calls would fail. This run excludes itself from the runs it cancels, being one of them. Co-Authored-By: Claude Opus 5 --- .github/workflows/cancel-pr-runs-on-close.yml | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/cancel-pr-runs-on-close.yml diff --git a/.github/workflows/cancel-pr-runs-on-close.yml b/.github/workflows/cancel-pr-runs-on-close.yml new file mode 100644 index 00000000000..a6851fcd56c --- /dev/null +++ b/.github/workflows/cancel-pr-runs-on-close.yml @@ -0,0 +1,55 @@ +name: Cancel PR runs on close + +# A merged or closed pull request's in-flight runs can no longer tell anyone +# anything, but they keep their runners until they finish. That is ordinarily +# just waste; when the queue is hundreds of jobs deep it actively delays every +# other pull request, since those runners are what the queue is waiting for. +# +# GitHub cancels superseded runs within a branch through `concurrency`, but that +# only fires when a newer run starts in the same group — closing a pull request +# starts nothing, so nothing is cancelled. + +on: + pull_request: + types: [closed] + +permissions: + actions: write + contents: read + +jobs: + cancel: + name: Cancel in-flight runs for this branch + runs-on: ubuntu-latest + timeout-minutes: 5 + # A pull request from a fork gets a read-only token, so the cancel calls + # would fail; such runs are left for GitHub to reap. + if: github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Cancel + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.event.pull_request.head.ref }} + SELF: ${{ github.run_id }} + run: | + # Excludes this run: it is itself a run on the branch being cleaned up, + # and cancelling it would stop the loop partway through. + ids=$(gh api "repos/${REPO}/actions/runs?branch=${BRANCH}&per_page=100" \ + --jq ".workflow_runs[] + | select(.status == \"queued\" or .status == \"in_progress\") + | select(.id != ${SELF}) + | .id") + if [ -z "$ids" ]; then + echo "No in-flight runs on ${BRANCH}." + exit 0 + fi + for id in $ids; do + if gh api -X POST "repos/${REPO}/actions/runs/${id}/cancel" >/dev/null 2>&1; then + echo "cancelled ${id}" + else + # A run that completed between listing and cancelling reports an + # error; that is the desired end state either way. + echo "could not cancel ${id} (likely already finished)" + fi + done From c295c2f23493bb45378215810f4d4fd3b3d9945a Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 19 Aug 2026 20:34:43 -0400 Subject: [PATCH 2/2] Spare the runs a pull request's own closure starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing a pull request can start work as well as end it. observability-preview tears down the PR's Grafana preview from a `closed` trigger, so cancelling every run on the branch would cancel that teardown too, stranding the preview and its sticky comment until the fallback sweep notices. Those runs are exactly the ones created at or after the close, so comparing against the pull request's closed_at separates them without naming individual workflows — a later teardown added elsewhere is covered by the same rule. When closed_at cannot be read, nothing is cancelled at all. The listing is also paginated now, and filtered by status server-side so paginating cannot walk a long-lived branch's entire run history to find the few runs that can still be cancelled. Co-Authored-By: Claude Opus 5 --- .github/workflows/cancel-pr-runs-on-close.yml | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cancel-pr-runs-on-close.yml b/.github/workflows/cancel-pr-runs-on-close.yml index a6851fcd56c..75f41ecdecb 100644 --- a/.github/workflows/cancel-pr-runs-on-close.yml +++ b/.github/workflows/cancel-pr-runs-on-close.yml @@ -30,16 +30,32 @@ jobs: env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} BRANCH: ${{ github.event.pull_request.head.ref }} SELF: ${{ github.run_id }} run: | - # Excludes this run: it is itself a run on the branch being cleaned up, - # and cancelling it would stop the loop partway through. - ids=$(gh api "repos/${REPO}/actions/runs?branch=${BRANCH}&per_page=100" \ - --jq ".workflow_runs[] - | select(.status == \"queued\" or .status == \"in_progress\") - | select(.id != ${SELF}) - | .id") + # Runs started *by* this close event have to survive: this one, and any + # teardown a workflow performs on close — observability-preview.yml's + # `preview-cleanup` removes the PR's Grafana preview that way, and + # cancelling it would strand the preview and its sticky comment. Those + # runs are exactly the ones created at or after the close, so the + # timestamp separates them without naming individual workflows. + CLOSED_AT=$(gh api "repos/${REPO}/pulls/${PR}" --jq '.closed_at') + if [ -z "$CLOSED_AT" ] || [ "$CLOSED_AT" = "null" ]; then + echo "No closed_at on PR ${PR}; refusing to cancel anything." + exit 0 + fi + + # Filtering by status server-side keeps this to the runs that can + # actually be cancelled, so paginating cannot walk a long-lived + # branch's entire history. + ids=$(for run_status in queued in_progress; do + gh api --paginate "repos/${REPO}/actions/runs?branch=${BRANCH}&status=${run_status}&per_page=100" \ + --jq ".workflow_runs[] + | select(.id != ${SELF}) + | select(.created_at < \"${CLOSED_AT}\") + | .id" + done) if [ -z "$ids" ]; then echo "No in-flight runs on ${BRANCH}." exit 0