mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
ci(release): make test draft releases traceable and self-cleaning (#1202)
* ci(release): make test draft releases traceable and self-cleaning Every PR and master build created a draft named "Release v<version>" with tag test-<github.sha>, so 70+ identical drafts piled up and PR drafts were untraceable (for pull_request events github.sha is the ephemeral merge-commit SHA that resolves to nothing in the repo). - Title test drafts as "v<ver> — PR #<n> @ <sha> [test]" / "v<ver> — master @ <sha> [test]"; tag releases keep "Release v<ver>" - Prepend a context header (PR, head commit, workflow run links) to the auto-generated release notes - Use the PR head SHA and pass target_commitish so generated notes actually cover the PR commits - Use stable tags (test-pr-<n>, test-master) so action-gh-release updates one rolling draft in place instead of creating a new one per push - Mark all non-tag drafts as prerelease - Cancel superseded in-progress PR builds via a concurrency group - Delete a PR's rolling draft when the PR closes (new workflow) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * ci(release): grant actions:write so PR-close cleanup can cancel builds gh run cancel needs the actions scope; with only contents: write the cancellation 403s silently and the settle-poll burns its full window. Also skip the cleanup job for fork PRs entirely: they never get a draft and their token is read-only regardless of the permissions block. Addresses Greptile P1 / Codex P2 follow-up on #1202. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(release): re-assert rolling draft title after asset upload action-gh-release@v2 updates name/body/target_commitish on the normal draft-reuse path, but in a rare race (release listing transiently missing the draft) it uploads assets to the canonical oldest draft without refreshing its metadata. PATCH the title and commitish on the release id the action actually used, so the draft title always names the current head SHA; the body is left alone to preserve generated notes. Addresses Codex round-2 feedback on #1202. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(release): prune stale assets before updating a rolling draft The release action only replaces same-name assets, so a PR that bumps the app version would leave old-version installers beside the new set in its rolling draft. Delete all existing assets of the matched draft before the upload; the action re-uploads the full current set right after. Published releases are never touched. Addresses Codex round-3 feedback on #1202. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(release): rebuild full draft metadata after asset upload Extend the post-upload metadata step to also rebuild the body (context header + notes from the same generate-notes API the action uses), not just title/commitish. The rolling draft now ends up with correct metadata regardless of which internal action-gh-release path ran, including the rare canonicalize-duplicate fallback. If notes generation fails, the body is left as the action set it. Addresses Codex round-4 feedback on #1202. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(release): only cancel pull_request runs when cleaning up a closed PR A manually dispatched build on the same head branch is not the PR's work; filter the cancellation list by event so PR-close cleanup cannot abort it. Addresses Codex round-5 feedback on #1202. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(release): guard PR-close cleanup against close-reopen races Re-check the live PR state at the start of the cleanup job and again right before deleting the draft, so a PR that is reopened while the cleanup is queued or waiting keeps its rolling draft and its fresh reopened-run builds are not cancelled. Addresses Codex round-6 feedback on #1202. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(release): keep tag_name when patching rolling draft metadata PATCHing a draft release without tag_name makes GitHub drop the pending tag (the draft turns into untagged-<hash>), so the next run cannot find the rolling draft by tag and creates a duplicate — observed live on this PR's own drafts. Include tag_name in both PATCH payloads of the metadata step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b719ed23cd
commit
29e624b0dd
2 files changed
+270
-3
No files matched your search
@@ -16,6 +16,13 @@ jobs:
|
||||
name: Build pinned Linux Embedded MPV runtime
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 120
|
||||
# Concurrency lives on the build 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 only serializes and is never cancelled.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-build-linux-runtime-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -304,6 +311,9 @@ jobs:
|
||||
name: Build on ${{ matrix.os }} ${{ matrix.arch }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.arch }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1200,6 +1210,9 @@ jobs:
|
||||
needs: linux-embedded-mpv-runtime
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.linux_profile }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1235,6 +1248,9 @@ jobs:
|
||||
- build-linux
|
||||
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
|
||||
|
||||
@@ -1367,13 +1383,117 @@ jobs:
|
||||
id: package-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
# Test drafts (PR/master) get a self-describing title plus a context
|
||||
# header linking the PR, real head commit, and workflow run. A stable
|
||||
# tag per PR (test-pr-<n>) / branch (test-master) makes the action
|
||||
# update one rolling draft in place instead of piling up a new draft
|
||||
# for every push. PR builds must not use github.sha here: that is the
|
||||
# ephemeral merge-commit SHA, which resolves to nothing in the repo.
|
||||
- name: Compose release metadata
|
||||
id: release-meta
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ steps.package-version.outputs.version }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
IS_TAG_BUILD: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
SHORT_SHA="${HEAD_SHA:0:7}"
|
||||
SAFE_BRANCH="${SOURCE_BRANCH//\//-}"
|
||||
|
||||
if [ "${IS_TAG_BUILD}" = "true" ]; then
|
||||
NAME="Release v${VERSION}"
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
BODY=""
|
||||
elif [ "${EVENT_NAME}" = "pull_request" ]; then
|
||||
NAME="v${VERSION} — PR #${PR_NUMBER} @ ${SHORT_SHA} [test]"
|
||||
TAG="test-pr-${PR_NUMBER}"
|
||||
BODY="$(printf '🧪 Test build for PR [#%s](%s) — %s\n\nCommit [`%s`](%s/commit/%s) · branch `%s` · [workflow run](%s)' \
|
||||
"${PR_NUMBER}" "${PR_URL}" "${PR_TITLE}" \
|
||||
"${SHORT_SHA}" "${REPO_URL}" "${HEAD_SHA}" "${SOURCE_BRANCH}" "${RUN_URL}")"
|
||||
else
|
||||
NAME="v${VERSION} — ${SAFE_BRANCH} @ ${SHORT_SHA} [test]"
|
||||
TAG="test-${SAFE_BRANCH}"
|
||||
BODY="$(printf '🧪 Test build from `%s` — commit [`%s`](%s/commit/%s) · [workflow run](%s)' \
|
||||
"${SOURCE_BRANCH}" "${SHORT_SHA}" "${REPO_URL}" "${HEAD_SHA}" "${RUN_URL}")"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "name=${NAME}"
|
||||
echo "tag=${TAG}"
|
||||
echo "commitish=${HEAD_SHA}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [ -n "${BODY}" ]; then
|
||||
{
|
||||
echo "body<<RELEASE_BODY_EOF"
|
||||
echo "${BODY}"
|
||||
echo "RELEASE_BODY_EOF"
|
||||
} >> "${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}"
|
||||
|
||||
# The rolling draft keeps assets across runs and the release action
|
||||
# only replaces same-name files. If the app version changes between
|
||||
# pushes, old-version installers would linger beside the new set,
|
||||
# so drop every existing asset first — the action re-uploads the
|
||||
# full current set right after. Only drafts are pruned; published
|
||||
# releases are never touched.
|
||||
- name: Prune stale draft assets
|
||||
if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ steps.release-meta.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate |
|
||||
jq -s --arg tag "${RELEASE_TAG}" \
|
||||
'add | [.[] | select(.draft and .tag_name == $tag)][0].id // empty')"
|
||||
|
||||
if [ -z "${release_id}" ]; then
|
||||
echo "No existing draft for ${RELEASE_TAG}; nothing to prune."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" --paginate --jq '.[].id' |
|
||||
xargs -r -n1 -I{} gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/{}"
|
||||
|
||||
echo "Pruned assets from draft ${release_id} (${RELEASE_TAG})."
|
||||
|
||||
- name: Create Draft Release
|
||||
id: draft-release
|
||||
if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ github.event_name == 'pull_request' }}
|
||||
name: Release v${{ steps.package-version.outputs.version }}
|
||||
tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('test-{0}', github.sha) }}
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
name: ${{ steps.release-meta.outputs.name }}
|
||||
tag_name: ${{ steps.release-meta.outputs.tag }}
|
||||
target_commitish: ${{ steps.release-meta.outputs.commitish }}
|
||||
body: ${{ steps.release-meta.outputs.body }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/macos-x64-artifacts/*-x64.dmg
|
||||
@@ -1400,3 +1520,58 @@ jobs:
|
||||
artifacts/windows-artifacts/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Rare action-gh-release path: when the release listing transiently
|
||||
# misses the rolling draft, the action creates a duplicate, deletes
|
||||
# it in favor of the canonical (oldest) draft, and uploads assets
|
||||
# there WITHOUT refreshing that draft's metadata. Rebuild the full
|
||||
# metadata (title, commitish, and body = context header + notes
|
||||
# from the same generate-notes API the action uses) on the release
|
||||
# id the action actually used, so the draft ends up correct no
|
||||
# matter which internal path ran. If notes generation fails, the
|
||||
# body is left as the action set it and only title/commitish are
|
||||
# re-asserted.
|
||||
- name: Ensure draft metadata is current
|
||||
if: steps.draft-release.outputs.id != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
RELEASE_TAG: ${{ steps.release-meta.outputs.tag }}
|
||||
RELEASE_NAME: ${{ steps.release-meta.outputs.name }}
|
||||
RELEASE_COMMITISH: ${{ steps.release-meta.outputs.commitish }}
|
||||
RELEASE_BODY: ${{ steps.release-meta.outputs.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
GENERATED_NOTES="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
|
||||
-f tag_name="${RELEASE_TAG}" \
|
||||
-f target_commitish="${RELEASE_COMMITISH}" \
|
||||
--jq '.body' || true)"
|
||||
|
||||
# tag_name MUST be included in every PATCH: updating a draft
|
||||
# without it makes GitHub drop the pending tag (the draft
|
||||
# becomes "untagged-<hash>"), which breaks the rolling-draft
|
||||
# lookup on the next run.
|
||||
if [ -z "${GENERATED_NOTES}" ]; then
|
||||
jq -n \
|
||||
--arg tag "${RELEASE_TAG}" \
|
||||
--arg name "${RELEASE_NAME}" \
|
||||
--arg commitish "${RELEASE_COMMITISH}" \
|
||||
'{tag_name: $tag, name: $name, target_commitish: $commitish}' |
|
||||
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "${RELEASE_BODY}" ]; then
|
||||
FULL_BODY="$(printf '%s\n\n%s' "${RELEASE_BODY}" "${GENERATED_NOTES}")"
|
||||
else
|
||||
FULL_BODY="${GENERATED_NOTES}"
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg tag "${RELEASE_TAG}" \
|
||||
--arg name "${RELEASE_NAME}" \
|
||||
--arg commitish "${RELEASE_COMMITISH}" \
|
||||
--arg body "${FULL_BODY}" \
|
||||
'{tag_name: $tag, name: $name, target_commitish: $commitish, body: ($body | .[0:120000])}' |
|
||||
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null
|
||||
Reference in new issue
Block a user