From 4d793485a75cc08acb0cf5fdf3cd09b53724c2a5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 18 Jul 2026 16:21:32 +0200 Subject: [PATCH] ci(release): close review-bot race windows in draft release flow - Move concurrency from workflow level to job level: cancelling a whole run could interrupt action-gh-release mid-asset-replacement and leave the rolling draft incomplete. Build slots still cancel superseded PR work (matrix-aware groups); the release job gets its own serializing, never-cancelling group. - Re-check the live PR state in the release job right before touching the draft, so a build that outlives its PR cannot recreate the draft after cleanup deleted it. - In the cleanup workflow, cancel still-running builds of the closed PR (dead work anyway) and wait for them to settle before deleting. - Emit an explicit empty `body=` output for tag builds instead of a blank-line heredoc. Addresses Codex and Greptile review feedback on #1202. Co-Authored-By: Claude Fable 5 --- .github/workflows/build-and-make.yaml | 41 +++++++++++++++++++++----- .github/workflows/cleanup-pr-draft.yml | 33 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index dce6e1c83..7597a5b55 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -11,15 +11,18 @@ on: - master workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - jobs: build: name: Build on ${{ matrix.os }} ${{ matrix.arch }} runs-on: ${{ matrix.runner }} timeout-minutes: 120 + # Concurrency lives on the jobs, not the workflow: cancelling a whole + # run could interrupt action-gh-release mid-update and leave the + # rolling draft with missing assets. Build slots cancel superseded PR + # work; the release job below only serializes and is never cancelled. + concurrency: + group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.linux_profile }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: matrix: include: @@ -669,6 +672,9 @@ jobs: needs: build if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-release-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false permissions: contents: write @@ -848,12 +854,33 @@ jobs: echo "name=${NAME}" echo "tag=${TAG}" echo "commitish=${HEAD_SHA}" - echo "body<> "${GITHUB_OUTPUT}" + if [ -n "${BODY}" ]; then + { + echo "body<> "${GITHUB_OUTPUT}" + else + echo "body=" >> "${GITHUB_OUTPUT}" + fi + + # A PR can be closed while this workflow is still running; the + # cleanup workflow deletes the PR draft on close. Re-check the live + # PR state right before touching the draft so a late-finishing run + # cannot recreate a draft for a closed PR. + - name: Check PR is still open + if: github.event_name == 'pull_request' + id: pr-state + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + echo "state=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}" --jq '.state')" >> "${GITHUB_OUTPUT}" + - name: Create Draft Release + if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open' uses: softprops/action-gh-release@v2 with: draft: true diff --git a/.github/workflows/cleanup-pr-draft.yml b/.github/workflows/cleanup-pr-draft.yml index 661892ec3..aca2db6f6 100644 --- a/.github/workflows/cleanup-pr-draft.yml +++ b/.github/workflows/cleanup-pr-draft.yml @@ -12,6 +12,39 @@ jobs: name: Delete PR draft release runs-on: ubuntu-latest steps: + # A build for this PR may still be running and would recreate the + # rolling draft after we delete it. Cancel those runs (dead work + # for a closed PR anyway) and wait for them to wind down. The + # release job additionally re-checks the live PR state, so this + # wait is defense in depth, not the only guard. + - name: Cancel in-progress builds for the closed PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + + list_active_runs() { + gh run list --repo "${GITHUB_REPOSITORY}" \ + --workflow 'Build and Make Electron App' \ + --branch "${HEAD_BRANCH}" \ + --json databaseId,status \ + --jq '.[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "requested" or .status == "pending") | .databaseId' + } + + for run_id in $(list_active_runs); do + echo "Cancelling run ${run_id}" + gh run cancel "${run_id}" --repo "${GITHUB_REPOSITORY}" || true + done + + # Cancellation is asynchronous; poll until the runs settle. + for _ in $(seq 1 18); do + if [ -z "$(list_active_runs)" ]; then + break + fi + sleep 10 + done + # Draft releases have no real git tag, so a lookup via # releases/tags/ returns 404. List releases and match the # draft by its stored tag_name (test-pr-) instead.