From f757dcfb8111ec73651f2c6511cfcec0f58eb141 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 7 Jul 2026 16:30:19 +0900 Subject: [PATCH] Cancel PR workflows when pull requests close --- .github/workflows/close-empty-pr.yml | 9 +++++- .github/workflows/codeql-pr.yml | 10 +++++- .github/workflows/noema-review.yml | 9 +++++- .github/workflows/opencode-review.yml | 19 +++++++++-- .github/workflows/osv-scanner-pr.yml | 8 +++++ .../workflows/pr-review-merge-scheduler.yml | 20 +++++++++--- .github/workflows/scorecard-pr.yml | 8 +++++ .github/workflows/security-scan.yml | 11 +++++++ .github/workflows/strix.yml | 16 +++++++--- .../test_required_workflow_queue_contract.py | 32 +++++++++++++++++++ 10 files changed, 129 insertions(+), 13 deletions(-) diff --git a/.github/workflows/close-empty-pr.yml b/.github/workflows/close-empty-pr.yml index 0a3c7b01..95611051 100644 --- a/.github/workflows/close-empty-pr.yml +++ b/.github/workflows/close-empty-pr.yml @@ -10,7 +10,7 @@ name: Close Empty PR on: pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, closed] concurrency: group: close-empty-pr-${{ github.event.pull_request.base.repo.full_name || github.repository }}-${{ github.event.pull_request.number }} @@ -21,7 +21,14 @@ permissions: contents: read jobs: + cancel-closed-pr-runs: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - run: echo "PR closed; this run only cancels older runs through workflow concurrency." + close-empty: + if: github.event.action != 'closed' runs-on: ubuntu-latest steps: - name: Close PR when it has no net changes diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index 74023d03..04ffcd6f 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -7,6 +7,7 @@ name: CodeQL PR on: pull_request: + types: [opened, synchronize, reopened, ready_for_review, closed] branches: [main, master, develop] concurrency: @@ -17,8 +18,15 @@ permissions: contents: read jobs: + cancel-closed-pr-runs: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - run: echo "PR closed; this run only cancels older runs through workflow concurrency." + detect-languages: name: Detect CodeQL languages + if: github.event.action != 'closed' runs-on: ubuntu-latest outputs: matrix: ${{ steps.detect.outputs.matrix }} @@ -92,7 +100,7 @@ jobs: analyze-merge: name: CodeQL merge preview (${{ matrix.language }}) needs: detect-languages - if: github.event.pull_request.merge_commit_sha != '' + if: github.event.action != 'closed' && github.event.pull_request.merge_commit_sha != '' runs-on: ubuntu-latest permissions: actions: read diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index b8ff3113..e8ce207f 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -2,7 +2,7 @@ name: Required Noema Review on: pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, closed] workflow_run: workflows: ["Required OpenCode Review", "Strix Security Scan"] types: [completed] @@ -39,6 +39,12 @@ permissions: id-token: write jobs: + cancel-closed-pr-runs: + if: github.event_name == 'pull_request_target' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - run: echo "PR closed; this run only cancels older runs through workflow concurrency." + noema-review: name: noema-review runs-on: ubuntu-latest @@ -50,6 +56,7 @@ jobs: ) || ( github.event_name == 'pull_request_target' + && github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository ) env: diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 3ebb73c4..b0f38796 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -2,7 +2,7 @@ name: Required OpenCode Review on: pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, closed] workflow_dispatch: inputs: pr_number: @@ -45,12 +45,19 @@ permissions: contents: read jobs: + cancel-closed-pr-runs: + if: github.event_name == 'pull_request_target' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - run: echo "PR closed; this run only cancels older runs through workflow concurrency." + coverage-evidence: name: coverage-evidence if: >- github.event_name == 'workflow_dispatch' || ( github.event_name == 'pull_request_target' + && github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository ) runs-on: ubuntu-latest @@ -825,7 +832,15 @@ jobs: opencode-review-target: name: opencode-review needs: [coverage-evidence] - if: always() && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request_target') + if: >- + always() + && ( + github.event_name == 'workflow_dispatch' + || ( + github.event_name == 'pull_request_target' + && github.event.action != 'closed' + ) + ) runs-on: ubuntu-latest timeout-minutes: 360 permissions: diff --git a/.github/workflows/osv-scanner-pr.yml b/.github/workflows/osv-scanner-pr.yml index 0523887f..4d5c4475 100644 --- a/.github/workflows/osv-scanner-pr.yml +++ b/.github/workflows/osv-scanner-pr.yml @@ -6,6 +6,7 @@ name: OSV-Scanner PR on: pull_request: + types: [opened, synchronize, reopened, ready_for_review, closed] branches: [main, master, develop] concurrency: @@ -19,7 +20,14 @@ permissions: security-events: write jobs: + cancel-closed-pr-runs: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - run: echo "PR closed; this run only cancels older runs through workflow concurrency." + osv-scan: + if: github.event.action != 'closed' # ponytail: use upstream reusable PR workflow, don't hand-roll the diff scan uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 permissions: diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index b11e2cd5..a86d0149 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -4,7 +4,7 @@ on: push: branches: [main, develop, master] pull_request_target: - types: [opened, synchronize, reopened, ready_for_review, auto_merge_enabled] + types: [opened, synchronize, reopened, ready_for_review, auto_merge_enabled, closed] workflow_run: workflows: ["Required OpenCode Review", "Strix Security Scan"] types: [completed] @@ -128,14 +128,26 @@ concurrency: cancel-in-progress: true jobs: + cancel-closed-pr-runs: + if: github.event_name == 'pull_request_target' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - run: echo "PR closed; this run only cancels older runs through workflow concurrency." + scan-pr-queue: # workflow_dispatch review runs do not reliably carry pull_requests metadata. # Without this guard, one completed central review can wake a repo-wide scan. if: >- - github.event_name != 'workflow_run' || ( - github.event.workflow_run.conclusion != 'cancelled' && - github.event.workflow_run.pull_requests[0].number + github.event_name != 'pull_request_target' || + github.event.action != 'closed' + ) && + ( + github.event_name != 'workflow_run' || + ( + github.event.workflow_run.conclusion != 'cancelled' && + github.event.workflow_run.pull_requests[0].number + ) ) runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/scorecard-pr.yml b/.github/workflows/scorecard-pr.yml index ad154f84..f4512596 100644 --- a/.github/workflows/scorecard-pr.yml +++ b/.github/workflows/scorecard-pr.yml @@ -13,6 +13,7 @@ name: Scorecard PR on: pull_request: + types: [opened, synchronize, reopened, ready_for_review, closed] branches: [main, master, develop] concurrency: @@ -23,8 +24,15 @@ permissions: contents: read jobs: + cancel-closed-pr-runs: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - run: echo "PR closed; this run only cancels older runs through workflow concurrency." + analysis: name: Scorecard + if: github.event.action != 'closed' runs-on: ubuntu-latest permissions: security-events: write diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 52545708..7a077b05 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -28,6 +28,7 @@ name: Security Scan on: pull_request: + types: [opened, synchronize, reopened, ready_for_review, closed] branches: [main, master, develop] concurrency: @@ -40,7 +41,14 @@ permissions: security-events: write jobs: + cancel-closed-pr-runs: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - run: echo "PR closed; this run only cancels older runs through workflow concurrency." + osv-scan: + if: github.event.action != 'closed' # ponytail: reuse upstream diff-scoped PR scanner instead of hand-rolling it uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 permissions: @@ -51,6 +59,7 @@ jobs: fail-on-vuln: true dependency-review: + if: github.event.action != 'closed' runs-on: ubuntu-latest permissions: contents: read @@ -103,6 +112,7 @@ jobs: comment-summary-in-pr: on-failure trivy-fs: + if: github.event.action != 'closed' runs-on: ubuntu-latest permissions: contents: read @@ -132,6 +142,7 @@ jobs: category: trivy-fs scorecard: + if: github.event.action != 'closed' runs-on: ubuntu-latest # SOFT: posture findings are unrelated to the PR diff, so never block merge. continue-on-error: true diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 8fcf7c84..9214c44f 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -4,6 +4,7 @@ on: push: branches: [main, develop, master] pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, closed] schedule: # Weekly scan on protected branches (Mondays at 03:00 UTC). - cron: '0 3 * * 1' @@ -39,10 +40,10 @@ concurrency: github.event.inputs.pr_number != '' && github.event.inputs.pr_head_sha != '' && format('pr-{0}-{1}', github.event.inputs.pr_number, github.event.inputs.pr_head_sha) || github.event.inputs.pr_number != '' && format('pr-{0}', github.event.inputs.pr_number) || github.ref }} - # cancel-in-progress deliberately disabled: an attacker could force-push - # a benign commit to cancel an in-progress scan of a malicious commit. The - # head SHA in PR groups prevents stale scans from serializing newer evidence. - cancel-in-progress: false + # cancel-in-progress stays disabled for normal PR updates: an attacker could + # force-push a benign commit to cancel an in-progress scan of a malicious + # commit. Closed PR events only cancel older runs for the same PR/head group. + cancel-in-progress: ${{ github.event_name == 'pull_request_target' && github.event.action == 'closed' }} permissions: actions: read @@ -52,7 +53,14 @@ permissions: statuses: write jobs: + cancel-closed-pr-runs: + if: github.event_name == 'pull_request_target' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - run: echo "PR closed; this run only cancels older runs through workflow concurrency." + strix: + if: github.event_name != 'pull_request_target' || github.event.action != 'closed' timeout-minutes: 120 runs-on: ubuntu-latest env: diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index cc12c00f..aa8cd925 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -31,6 +31,38 @@ def test_required_pull_request_workflows_cancel_superseded_runs() -> None: assert "cancel-in-progress: true" in workflow +def test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs() -> None: + workflows = ( + "close-empty-pr.yml", + "codeql-pr.yml", + "noema-review.yml", + "opencode-review.yml", + "osv-scanner-pr.yml", + "pr-review-merge-scheduler.yml", + "scorecard-pr.yml", + "security-scan.yml", + "strix.yml", + ) + + for filename in workflows: + workflow = workflow_text(filename) + + assert "closed" in workflow + assert "cancel-closed-pr-runs:" in workflow + assert ( + 'PR closed; this run only cancels older runs through workflow concurrency.' + in workflow + ) + assert "github.event.action != 'closed'" in workflow + + strix = workflow_text("strix.yml") + + assert ( + "cancel-in-progress: ${{ github.event_name == 'pull_request_target' && " + "github.event.action == 'closed' }}" + ) in strix + + def test_cancelled_review_workflow_runs_do_not_spawn_more_queue_work() -> None: for filename in ("noema-review.yml", "pr-review-merge-scheduler.yml"): workflow = workflow_text(filename)