Files
iptvnator/.github/workflows/cleanup-pr-draft.yml
T
4grayandClaude Opus 5 a8a3f77c27 ci(release): sweep PR draft releases the close event never reaches
`cleanup-pr-draft.yml` deletes a PR's rolling `test-pr-<n>` draft on
`pull_request: closed`. GitHub does not run that 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 (close + branch delete in one operation), so
those drafts were never collected: 15 orphaned drafts had accumulated, 13 of
them Dependabot's.

Add a daily `schedule` (plus `workflow_dispatch`) sweep job to the same
workflow. It lists every draft whose `tag_name` matches `^test-pr-[0-9]+$`,
asks GitHub for that PR's live state, and deletes the draft only when the PR
reports `closed`. It fails closed: a lookup error leaves `state` empty and
the draft untouched, and a failed listing fails the job rather than sweeping
a short list. `test-<branch>` drafts and every other release are outside the
tag shape.

The event-driven job stays the fast path, now gated on
`github.event_name == 'pull_request'`. Workflow-level permissions drop to
`contents: read`; the event job takes `actions: write` (cancelling the closed
PR's build) and `contents: write`, the sweep only `contents: write`.

Verified with a dry run of the sweep script against the live repository: of
the 19 `test-pr-<n>` drafts it would delete the 15 closed-PR ones and keep
the 4 open-PR ones (#1638, #1637, #1501, #1215). actionlint 1.7.12 with
shellcheck at warning severity passes on all workflows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:32:23 +02:00

178 lines
8.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}."
elif ! gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" >/dev/null 2>&1; then
echo "Draft ${tag} (release ${release_id}) was already gone."
else
echo "::error::Failed to delete draft ${tag} (release ${release_id})."
failed=1
fi
done <<< "${drafts}"
exit "${failed}"