diff --git a/.github/workflows/cancel-closed-pr-ci.yml b/.github/workflows/cancel-closed-pr-ci.yml new file mode 100644 index 000000000..e52227fcc --- /dev/null +++ b/.github/workflows/cancel-closed-pr-ci.yml @@ -0,0 +1,69 @@ +name: Cancel CI for closed PRs + +# A PR's concurrency group only cancels its in-flight runs when a newer run of +# the same PR starts. Merging or closing starts none, so without this the runs +# keep holding the GPU runners until they finish. +# +# Why a sweep rather than reacting to the close event: +# - pull_request: closed sets GITHUB_REF to refs/heads/main on merge, so the +# workflows' cancel-in-progress is false in exactly the case that matters, +# and a fork contributor's run of it waits for maintainer approval. +# - pull_request_target is never held for approval, but it is a HIGH +# dangerous-triggers finding in the security scan. +# A scheduled run comes from main, is never held for approval, and needs no +# exception. The cost is up to one interval before a closed PR's runs stop. +on: + schedule: + - cron: "7-59/10 * * * *" + workflow_dispatch: + +permissions: + actions: write + pull-requests: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + cancel: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Cancel in-flight runs of closed PRs + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + inflight=$(for status in queued in_progress waiting pending; do + gh api --paginate "repos/$REPO/actions/runs?event=pull_request&status=$status&per_page=100" \ + --jq '.workflow_runs[] | [.id, .name, .head_repository.full_name, .head_branch, .head_sha] | @tsv' + done) + if [ -z "$inflight" ]; then + echo "No in-flight PR runs" + exit 0 + fi + + # Branch names come from PR authors: they are only ever data here, + # URL-encoded for the query and compared, never evaluated. + declare -A pr_state + while IFS=$'\t' read -r id name head_repo branch sha; do + key="$head_repo:$branch:$sha" + if [ -z "${pr_state[$key]+set}" ]; then + head=$(printf '%s:%s' "${head_repo%%/*}" "$branch" | jq -sRr @uri) + # Fork runs carry no PR number, so find the PR by its head branch. + # Only a PR whose head is this commit counts, and any open one wins. + pr_state[$key]=$(SHA="$sha" gh api "repos/$REPO/pulls?state=all&head=$head&per_page=100" \ + --jq '[.[] | select(.head.sha == env.SHA) | .state] | if index("open") then "open" elif length > 0 then "closed" else "none" end') + fi + if [ "${pr_state[$key]}" != "closed" ]; then + continue + fi + # A run can finish between listing and cancelling; that's fine. + if gh api -X POST "repos/$REPO/actions/runs/$id/cancel" > /dev/null 2>&1; then + echo "Cancelled $name ($id) from closed PR $head_repo:$branch" + else + echo "Could not cancel $name ($id); it has probably just finished" + fi + done <<< "$inflight" diff --git a/.github/workflows/iris-external-validation-test.yml b/.github/workflows/iris-external-validation-test.yml index aa12811e1..81a9b22ac 100644 --- a/.github/workflows/iris-external-validation-test.yml +++ b/.github/workflows/iris-external-validation-test.yml @@ -8,7 +8,8 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + # PR number, not head branch: two forks' "patch-1" branches must not cancel each other. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: diff --git a/.github/workflows/iris-performance-regression-test.yml b/.github/workflows/iris-performance-regression-test.yml index 96e694493..d760065dd 100644 --- a/.github/workflows/iris-performance-regression-test.yml +++ b/.github/workflows/iris-performance-regression-test.yml @@ -8,7 +8,8 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + # PR number, not head branch: two forks' "patch-1" branches must not cancel each other. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: diff --git a/.github/workflows/iris-tests.yml b/.github/workflows/iris-tests.yml index c635b986a..7ea57ef58 100644 --- a/.github/workflows/iris-tests.yml +++ b/.github/workflows/iris-tests.yml @@ -8,7 +8,8 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + # PR number, not head branch: two forks' "patch-1" branches must not cancel each other. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: