From a8a3f77c274fcd234a1de588ffe236c61f854b5d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 19 Sep 2026 19:31:00 +0200 Subject: [PATCH] ci(release): sweep PR draft releases the close event never reaches `cleanup-pr-draft.yml` deletes a PR's rolling `test-pr-` 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-` 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-` 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 --- .github/workflows/cleanup-pr-draft.yml | 95 ++++++++++++++++++++++++-- docs/architecture/release-pipeline.md | 18 +++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cleanup-pr-draft.yml b/.github/workflows/cleanup-pr-draft.yml index 695685255..9fdfc1fe7 100644 --- a/.github/workflows/cleanup-pr-draft.yml +++ b/.github/workflows/cleanup-pr-draft.yml @@ -3,12 +3,20 @@ 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; actions: write — cancel the -# closed PR's still-running build workflow before deleting. +# 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: - actions: write - contents: write + contents: read jobs: delete-draft: @@ -16,8 +24,11 @@ jobs: # 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.pull_request.head.repo.full_name == github.repository + 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 @@ -90,3 +101,77 @@ jobs: 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 ` and to the tags API. Enumerate releases + # and match on the stored tag_name, exactly as the event job does. + # The `test-` drafts and every other release are left + # alone by the ^test-pr-$ 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- 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}" diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md index 89140fe05..58354bc01 100644 --- a/docs/architecture/release-pipeline.md +++ b/docs/architecture/release-pipeline.md @@ -276,6 +276,24 @@ References: [AppImage desktop keys](https://docs.appimage.org/reference/desktop- [AppManager desktop parser](https://github.com/kem-a/AppManager/blob/v3.8.0/src/core/desktop_entry.vala), [AppManager updater](https://github.com/kem-a/AppManager/blob/v3.8.0/src/core/updater.vala). +## Rolling test drafts + +Every non-fork PR build publishes its artifacts to a rolling **draft** release +tagged `test-pr-`; a non-PR, non-tag build (a dispatch on a branch) uses +`test-`, the shape master pushes used before the nightly channel took +over. The tag is stable per PR, so the draft is updated in place and a PR has +at most one. + +`cleanup-pr-draft.yml` deletes a PR's draft when the PR closes. That event 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 what +Dependabot does when it supersedes one of its own PRs — 15 drafts were +orphaned that way before this was noticed. A daily scheduled sweep in the same +workflow (also runnable with `gh workflow run cleanup-pr-draft.yml`) therefore +lists every `test-pr-` draft, asks GitHub for that PR's live state, and +deletes the draft only when the PR is closed; anything else — an open PR, a +lookup failure, a `test-` draft — is left alone. + ## Nightly channel Every push to `master` of `4gray/iptvnator` is also a nightly. The same