From b50b51d8bceb5c8dc8e9f76a298881954dafda96 Mon Sep 17 00:00:00 2001 From: Leo310 Date: Mon, 28 Sep 2026 08:45:07 +0200 Subject: [PATCH 1/3] fix(release): find an existing draft by listing, upload to it by id GitHub's releases/tags/ endpoint never returns drafts, so re-pointing an unpublished tag took the "no release yet" path and created a second 2.3.0 draft instead of updating the first. The workflow now lists releases to find the tag's release, fails if more than one exists, and updates a draft's notes and assets by release id; `gh release upload ` misses drafts the same way, so assets are replaced through the uploads API. Co-Authored-By: Claude --- .github/workflows/release.yml | 49 +++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43b377e1..b047e17a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,41 +76,56 @@ jobs: # up via the API. A PUBLISHED release is never touched — users may # already have installed its bundle — so that case fails loudly, and # a draft that gets published mid-upload is detected afterwards. - # Only a 404 from the lookup means "no release yet"; any other lookup - # failure (auth, rate limit, outage) also fails rather than creating. + # GitHub's `releases/tags/` endpoint never returns DRAFTS, so the + # release is found by listing and then addressed by id (2.3.0: a + # re-pointed tag minted a second draft because the tag lookup 404'd). A failed + # listing (auth, rate limit, outage) fails the run rather than creating. - name: Create or update draft release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | tag="${GITHUB_REF#refs/tags/}" assets=(build/prod/main.js manifest.json build/prod/styles.css) + api="repos/${GITHUB_REPOSITORY}/releases" - if lookup=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" 2>&1); then - if [ "$(jq -r .draft <<<"$lookup")" != "true" ]; then + matches=$(gh api --paginate "$api" --jq ".[] | select(.tag_name == \"$tag\") | {id, draft}") + count=$(jq -s length <<<"$matches") + + if [ "$count" -eq 0 ]; then + gh release create "$tag" \ + --title="$tag" \ + --draft \ + --notes-file release-notes.md \ + "${assets[@]}" + elif [ "$count" -gt 1 ]; then + echo "::error::$count releases exist for $tag; delete the stray ones and re-run." + exit 1 + else + id=$(jq -r .id <<<"$matches") + if [ "$(jq -r .draft <<<"$matches")" != "true" ]; then echo "::error::Release $tag is already published; refusing to replace its assets." exit 1 fi - echo "Draft release $tag exists; replacing its assets and notes." + echo "Draft release $tag ($id) exists; replacing its assets and notes." if [ -s release-notes.md ]; then - gh release edit "$tag" --notes-file release-notes.md + gh api -X PATCH "$api/$id" -F body=@release-notes.md >/dev/null fi - gh release upload "$tag" "${assets[@]}" --clobber + # By id, not `gh release upload `: gh's tag lookup misses + # drafts too. Delete a same-named asset, then upload its build. + for file in "${assets[@]}"; do + name=$(basename "$file") + old=$(gh api "$api/$id/assets" --jq ".[] | select(.name == \"$name\") | .id") + if [ -n "$old" ]; then gh api -X DELETE "$api/assets/$old"; fi + gh api -X POST "https://uploads.github.com/$api/$id/assets?name=$name" \ + -H "Content-Type: application/octet-stream" --input "$file" >/dev/null + done # GitHub has no conditional upload, so a maintainer publishing # the draft during the seconds between the check above and the # upload cannot be prevented — only detected. Re-check and fail # loudly so a published release with swapped assets is never a # silent green run. - if [ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" --jq .draft)" != "true" ]; then + if [ "$(gh api "$api/$id" --jq .draft)" != "true" ]; then echo "::error::Release $tag was published while its assets were being replaced; verify the published bundle." exit 1 fi - elif grep -q "HTTP 404" <<<"$lookup"; then - gh release create "$tag" \ - --title="$tag" \ - --draft \ - --notes-file release-notes.md \ - "${assets[@]}" - else - echo "::error::Could not look up release $tag: $lookup" - exit 1 fi From 27a74d208fbfebac5ff55d6437ed4a3e23cbf65f Mon Sep 17 00:00:00 2001 From: Leo310 Date: Mon, 28 Sep 2026 08:52:13 +0200 Subject: [PATCH 2/3] fix(release): tag as jq data, per-tag concurrency, keep the draft's tag - Pass the tag (and asset names) to jq with --arg instead of splicing them into the filter text. - Serialize runs per tag, queued rather than cancelled, so two pushes of the same tag can't both find no release and each create a draft. - Paginate the asset lookup. - Restate tag_name when PATCHing the draft's notes: a PATCH without it renames a draft's tag to untagged- (verified against a draft on an existing tag), which would detach the draft from its tag and send the next run down the create path. Likely also how 2.1.1 was published as untagged-. Co-Authored-By: Claude --- .github/workflows/release.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b047e17a..a196d166 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,12 @@ jobs: # (2.1.1). Those pushes are never a release; skip them outright. if: ${{ !startsWith(github.ref_name, 'untagged-') }} runs-on: ubuntu-latest + # One run per tag at a time: two pushes of the same tag could otherwise both + # find no release and each create a draft. Queued, never cancelled, so the + # last push's build is the one that ends up on the draft. + concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false permissions: contents: write # Required by attest-build-provenance to mint the signing certificate. @@ -88,7 +94,8 @@ jobs: assets=(build/prod/main.js manifest.json build/prod/styles.css) api="repos/${GITHUB_REPOSITORY}/releases" - matches=$(gh api --paginate "$api" --jq ".[] | select(.tag_name == \"$tag\") | {id, draft}") + # The tag goes to jq as data (--arg), never into the filter text. + matches=$(gh api --paginate "$api" | jq -c --arg tag "$tag" '.[] | select(.tag_name == $tag) | {id, draft}') count=$(jq -s length <<<"$matches") if [ "$count" -eq 0 ]; then @@ -108,13 +115,15 @@ jobs: fi echo "Draft release $tag ($id) exists; replacing its assets and notes." if [ -s release-notes.md ]; then - gh api -X PATCH "$api/$id" -F body=@release-notes.md >/dev/null + # tag_name must be restated: a PATCH without it silently renames + # a draft's tag to `untagged-`, detaching it from the tag. + gh api -X PATCH "$api/$id" -f tag_name="$tag" -F body=@release-notes.md >/dev/null fi # By id, not `gh release upload `: gh's tag lookup misses # drafts too. Delete a same-named asset, then upload its build. for file in "${assets[@]}"; do name=$(basename "$file") - old=$(gh api "$api/$id/assets" --jq ".[] | select(.name == \"$name\") | .id") + old=$(gh api --paginate "$api/$id/assets" | jq --arg name "$name" '.[] | select(.name == $name) | .id') if [ -n "$old" ]; then gh api -X DELETE "$api/assets/$old"; fi gh api -X POST "https://uploads.github.com/$api/$id/assets?name=$name" \ -H "Content-Type: application/octet-stream" --input "$file" >/dev/null From 29a981184650b84fd579b565c697f9f53fdd21d0 Mon Sep 17 00:00:00 2001 From: Leo310 Date: Mon, 28 Sep 2026 08:56:51 +0200 Subject: [PATCH 3/3] fix(release): pipefail on the release lookup; only the tag's current build uploads - set -o pipefail in the create/update step (run steps default to bash -e without it), so a failed release listing fails the step instead of reading as "no release" and creating a duplicate draft. - Before touching the release, check that the tag still points at the commit this run built; if it was re-pointed since, exit without uploading. GitHub keeps only the newest pending run per concurrency group and doesn't promise push order, so this, not the queue, is what keeps an older build off the draft. Co-Authored-By: Claude --- .github/workflows/release.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a196d166..7abc3a5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,9 @@ jobs: if: ${{ !startsWith(github.ref_name, 'untagged-') }} runs-on: ubuntu-latest # One run per tag at a time: two pushes of the same tag could otherwise both - # find no release and each create a draft. Queued, never cancelled, so the - # last push's build is the one that ends up on the draft. + # find no release and each create a draft. GitHub keeps only the newest + # pending run per group and doesn't promise push order, so the upload step + # also checks that the tag still points at the commit this run built. concurrency: group: release-${{ github.ref }} cancel-in-progress: false @@ -90,10 +91,22 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + # Default run steps are `bash -e` without pipefail; a failed listing + # piped into jq must fail the step, not read as "no release". + set -o pipefail tag="${GITHUB_REF#refs/tags/}" assets=(build/prod/main.js manifest.json build/prod/styles.css) api="repos/${GITHUB_REPOSITORY}/releases" + # The tag was re-pointed after this run started: a newer run owns the + # draft, so don't overwrite it with this (older) build. The peeled + # `^{}` line, when present, is an annotated tag's commit. + current=$(git ls-remote origin "refs/tags/$tag" "refs/tags/$tag^{}" | tail -n 1 | cut -f 1) + if [ "$current" != "$GITHUB_SHA" ]; then + echo "::notice::Tag $tag now points at ${current:-nothing}, not $GITHUB_SHA; skipping the upload." + exit 0 + fi + # The tag goes to jq as data (--arg), never into the filter text. matches=$(gh api --paginate "$api" | jq -c --arg tag "$tag" '.[] | select(.tag_name == $tag) | {id, draft}') count=$(jq -s length <<<"$matches")