Files
computer-playbook/scripts/github/cancel/pull_request_runs.sh
kevinveenbirkenbachandClaude Opus 5 fb54aa1451 fix(ci): force-cancel superseded runs that ignore the cancel
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>
2026-08-25 18:07:00 +02:00

113 lines
3.8 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:-}"
FORCE_CANCEL_AFTER_SECONDS="${FORCE_CANCEL_AFTER_SECONDS:-}"
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
validate_force_cancel_after
# 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}"