Files
4grayandClaude Opus 5 1471e7af36 ci(release): sweep PR draft releases the close event never reaches (#1641)
The `pull_request: closed` cleanup is the fast path, not a guarantee: GitHub
does not run that workflow when the head ref is already gone at event time,
which is what Dependabot does when it supersedes one of its own PRs. 15
`test-pr-<n>` drafts had been orphaned that way, 13 of them Dependabot's.

Add a daily scheduled (and manually dispatchable) sweep to the same workflow.
It lists every draft tagged `^test-pr-[0-9]+$`, asks GitHub for that PR's live
state, and deletes only when the PR reports closed. It fails closed: a PR
lookup error leaves the draft untouched, a failed release listing fails the job
rather than sweeping a short list, and only a confirmed HTTP 404 excuses a
failed delete — `gh api` exits 1 for every failure alike, so the re-check reads
the response status instead of the exit code.

Workflow permissions drop to `contents: read`; the event job keeps
`actions: write` + `contents: write`, the sweep takes only `contents: write`.
No new actions. Docs: new "Rolling test drafts" section in
docs/architecture/release-pipeline.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 13:28:30 +02:00

195 lines
9.6 KiB
YAML

name: Cleanup PR Draft Release
on:
pull_request:
types: [closed]
# The event above is the fast path, not a guarantee: GitHub does not run a
# `pull_request: closed` workflow when the head ref is already gone at
# event time, which is exactly what Dependabot does when it supersedes one
# of its own PRs (closes it and deletes the branch in a single operation).
# Those drafts — and any the event path missed for other reasons — are
# collected by the scheduled sweep below.
schedule:
- cron: '17 4 * * *'
workflow_dispatch:
# contents: write — delete the draft release. The `actions: write` needed to
# cancel a closed PR's still-running build is scoped to the event job.
permissions:
contents: read
jobs:
delete-draft:
name: Delete PR draft release
# Fork PRs never get a draft (the release job skips them) and their
# GITHUB_TOKEN is read-only regardless of the permissions block, so
# there is nothing to cancel or delete.
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
# A closed PR can be reopened while this job is still queued or
# waiting; a reopened PR's fresh build must not be cancelled and
# its draft must not be deleted. Check the live state up front
# (and again right before deleting below).
- name: Check PR is still closed
id: pr-state
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
echo "state=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.state')" >> "${GITHUB_OUTPUT}"
# 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
if: steps.pr-state.outputs.state == 'closed'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
set -euo pipefail
# --event pull_request: a manually dispatched build on the
# same branch is not this PR's work and must not be cancelled.
list_active_runs() {
gh run list --repo "${GITHUB_REPOSITORY}" \
--workflow 'Build and Make Electron App' \
--branch "${HEAD_BRANCH}" \
--event pull_request \
--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/<tag> returns 404. List releases and match the
# draft by its stored tag_name (test-pr-<n>) instead. The PR state
# is re-checked one last time right before deleting, in case the
# PR was reopened during the cancellation wait above.
- name: Delete draft release for closed PR
if: steps.pr-state.outputs.state == 'closed'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [ "$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.state')" != "closed" ]; then
echo "PR #${PR_NUMBER} was reopened; keeping its draft."
exit 0
fi
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate \
--jq ".[] | select(.draft and .tag_name == \"test-pr-${PR_NUMBER}\") | .id" |
xargs -r -n1 -I{} gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/{}"
sweep-drafts:
name: Sweep orphaned PR draft releases
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
# Two sweeps must not race each other into a double delete; a manual
# dispatch during the nightly cron would otherwise produce a spurious
# failure on an already-deleted draft.
concurrency:
group: cleanup-pr-draft-sweep
cancel-in-progress: false
steps:
# Unlike the event job this one does not cancel in-progress builds:
# the build's draft steps are themselves gated on the live PR state
# being `open` ("Check PR is still open" in build-and-make.yaml), so
# a run that outlives the close cannot recreate what was swept. A
# build that passed that check just before the PR closed is caught
# by the next sweep.
#
# Draft releases have no real git tag, so they are invisible to
# `gh release view <tag>` and to the tags API. Enumerate releases
# and match on the stored tag_name, exactly as the event job does.
# The `test-<branch>` drafts and every other release are left
# alone by the ^test-pr-<n>$ shape.
- name: Delete drafts whose PR is closed
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Assigned rather than piped: a failed or partially paginated
# listing must fail the job instead of silently sweeping a
# short list.
drafts="$(
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate \
--jq '.[] | select(.draft and (.tag_name | test("^test-pr-[0-9]+$"))) | "\(.id) \(.tag_name)"'
)"
if [ -z "${drafts}" ]; then
echo "No test-pr-<n> drafts found."
exit 0
fi
failed=0
while IFS=' ' read -r release_id tag; do
pr_number="${tag#test-pr-}"
# Fail closed: a lookup error (404, rate limit, outage)
# leaves `state` empty and the draft untouched. Only a
# PR GitHub currently reports as closed loses its draft,
# so a reopened PR and an open PR mid-build keep theirs.
# gh's own stderr is left visible on purpose — a draft
# kept as `unknown` should say why in the job log.
state="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" --jq '.state' || true)"
if [ "${state}" != "closed" ]; then
echo "Keeping ${tag}: PR #${pr_number} is ${state:-unknown}."
continue
fi
if gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/${release_id}" >/dev/null; then
echo "Deleted ${tag} (release ${release_id}) for closed PR #${pr_number}."
continue
fi
# The delete failed. Only a release GitHub confirms is
# gone (404) excuses that — the event job racing us to
# the same draft. `gh api` exits 1 for every failure
# alike, so a rate limit or outage hitting both calls
# would otherwise read as "already deleted" and leave a
# green sweep behind an undeleted draft. Read the status
# line instead: `-i` prints it even on an error status,
# and a request that never got a response leaves it
# empty, which is not 404 and so stays a failure.
recheck_status="$(
gh api -i "repos/${GITHUB_REPOSITORY}/releases/${release_id}" 2>/dev/null |
sed -n '1s#^HTTP/[0-9.]* \([0-9]\{3\}\).*#\1#p' || true
)"
if [ "${recheck_status}" = "404" ]; then
echo "Draft ${tag} (release ${release_id}) was already gone."
else
echo "::error::Failed to delete draft ${tag} (release ${release_id}); re-check returned ${recheck_status:-no HTTP status}."
failed=1
fi
done <<< "${drafts}"
exit "${failed}"