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/ returns 404. List releases and match the # draft by its stored tag_name (test-pr-) 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 ` 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}." 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}"