Files
computer-playbook/scripts/github/cancel/pull_request_runs.sh
T
kevinveenbirkenbachandClaude Opus 5 474aaf7bfe fix(ci): cancel superseded runs the group leaves behind
A long orchestrator sweep sometimes survives its concurrency group: the occupant sits on queued while its matrix waits for runners, cancel-in-progress does not reap it, and the newer run parks on pending until someone cancels the sweep by hand. PR #380 sat that way for nine hours behind a run from the previous commit.

entry-cancel-superseded.yml cancels those leftovers through the API. The group stays primary and untouched: it reaps within seconds and needs no runner, so the fallback normally arrives to find nothing left to do. It carries no concurrency group of its own, because the run holding a group cannot be the run that frees it.

Selection mirrors what the group would have done: cancel every matched run except the newest in the group instance, keyed the way the group is keyed. Not by head SHA, which goes stale while the job waits for a runner and would cancel the newest commit's CI while sparing the stale one. INCLUDE_PATHS keeps the sweep inside the group's own members, so a push to main cannot take the cron workflows with it.

deleted_branch_runs.sh becomes branch_runs.sh, since it now also serves live branches, and the shared parts move to lib.sh. A rejected cancel is logged and the remaining runs are still attempted; only a denied token fails the job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 16:29:11 +02:00

111 lines
3.7 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
: "${PR_NUMBER:?Missing PR_NUMBER}"
: "${GH_TOKEN:?Missing GH_TOKEN}"
: "${REPOSITORY:?Missing REPOSITORY}"
PR_HEAD_REF="${PR_HEAD_REF:-}"
PR_HEAD_SHA="${PR_HEAD_SHA:-}"
PR_HEAD_REPOSITORY="${PR_HEAD_REPOSITORY:-}"
: "${CURRENT_RUN_ID:?Missing CURRENT_RUN_ID}"
INCLUDE_PATHS="${INCLUDE_PATHS:-}"
KEEP_NEWEST_PER="${KEEP_NEWEST_PER:-}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/github/cancel/lib.sh
source "${SCRIPT_DIR}/lib.sh"
require_tools
validate_include_paths
validate_keep_newest_per
# A fork PR's runs carry an empty `pull_requests` array, so the branch matcher
# is all that is left — and it cannot tell two pull requests apart that share a
# head repository and head branch. Rather than cancel the wrong one, drop the
# matcher for as long as the head is ambiguous.
disable_ambiguous_branch_fallback() {
local rivals
local -a query
if [[ -z "${PR_HEAD_REF}" ]]; then
return 0
fi
query=(-f state=open -f per_page=100)
if [[ -n "${PR_HEAD_REPOSITORY}" ]]; then
query+=(-f "head=${PR_HEAD_REPOSITORY%%/*}:${PR_HEAD_REF}")
fi
if ! rivals="$(
gh api --paginate \
-H "Accept: application/vnd.github+json" \
-X GET "/repos/${REPOSITORY}/pulls" \
"${query[@]}" |
jq -s \
--argjson pr_number "${PR_NUMBER}" \
--arg head_ref "${PR_HEAD_REF}" \
'[.[][] | select(.number != $pr_number and (.head.ref // "") == $head_ref)] | length'
)"; then
echo "WARNING: could not list open pull requests for the head; keeping the branch fallback" >&2
return 0
fi
if [[ "${rivals}" != "0" ]]; then
echo "Branch fallback disabled: ${rivals} other open pull request(s) share ${PR_HEAD_REPOSITORY%%/*}:${PR_HEAD_REF}"
PR_HEAD_REF=""
fi
}
echo "Searching active workflow runs for PR #${PR_NUMBER}"
if [[ -n "${PR_HEAD_SHA}${PR_HEAD_REF}${PR_HEAD_REPOSITORY}" ]]; then
echo "Fallback matching enabled for head.sha=${PR_HEAD_SHA:-<empty>} head.ref=${PR_HEAD_REF:-<empty>} head.repo=${PR_HEAD_REPOSITORY:-<empty>}"
fi
disable_ambiguous_branch_fallback
runs="$(collect_runs)"
run_ids="$(
printf '%s\n' "${runs}" | jq -s -r \
--argjson pr_number "${PR_NUMBER}" \
--arg current_run_id "${CURRENT_RUN_ID}" \
--arg pr_head_ref "${PR_HEAD_REF}" \
--arg pr_head_sha "${PR_HEAD_SHA}" \
--arg pr_head_repository "${PR_HEAD_REPOSITORY}" \
--arg include_paths "${INCLUDE_PATHS}" \
--arg keep_newest "${KEEP_NEWEST_PER}" '
def drop_newest: sort_by([(.run_started_at // .created_at), .id]) | .[:-1];
def allowlist:
$include_paths | split("\n") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0));
allowlist as $paths
| unique_by(.id)
| map(
select($current_run_id == "" or (.id | tostring) != $current_run_id)
| select($include_paths == "" or ((.path // "") | IN($paths[])))
| select(.event == "pull_request" or .event == "pull_request_target")
| select(
any(.pull_requests[]?; (.number // -1) == $pr_number)
or ($pr_head_sha != "" and (.head_sha // "") == $pr_head_sha)
or (
$pr_head_ref != ""
and all(.pull_requests[]?; (.number // $pr_number) == $pr_number)
and (.head_branch // "") == $pr_head_ref
and (
$pr_head_repository == ""
or (.head_repository.full_name // "") == $pr_head_repository
or (.head_repository.full_name // "") == ""
)
)
)
)
| (if $keep_newest == "all" then drop_newest
elif $keep_newest == "event" then
group_by(.event // "") | map(drop_newest) | flatten
else . end)
| .[].id
'
)"
cancel_all "${run_ids}"