Skip to content

Commit bebc54d

Browse files
Leo310claude
andauthored
fix(release): find an existing draft by listing, upload to it by id (#535)
* fix(release): find an existing draft by listing, upload to it by id GitHub's releases/tags/<tag> 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 <tag>` misses drafts the same way, so assets are replaced through the uploads API. Co-Authored-By: Claude <noreply@anthropic.com> * 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-<hash> (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-<hash>. Co-Authored-By: Claude <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3e414ca commit bebc54d

1 file changed

Lines changed: 54 additions & 17 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ jobs:
1313
# (2.1.1). Those pushes are never a release; skip them outright.
1414
if: ${{ !startsWith(github.ref_name, 'untagged-') }}
1515
runs-on: ubuntu-latest
16+
# One run per tag at a time: two pushes of the same tag could otherwise both
17+
# find no release and each create a draft. GitHub keeps only the newest
18+
# pending run per group and doesn't promise push order, so the upload step
19+
# also checks that the tag still points at the commit this run built.
20+
concurrency:
21+
group: release-${{ github.ref }}
22+
cancel-in-progress: false
1623
permissions:
1724
contents: write
1825
# Required by attest-build-provenance to mint the signing certificate.
@@ -76,41 +83,71 @@ jobs:
7683
# up via the API. A PUBLISHED release is never touched — users may
7784
# already have installed its bundle — so that case fails loudly, and
7885
# a draft that gets published mid-upload is detected afterwards.
79-
# Only a 404 from the lookup means "no release yet"; any other lookup
80-
# failure (auth, rate limit, outage) also fails rather than creating.
86+
# GitHub's `releases/tags/<tag>` endpoint never returns DRAFTS, so the
87+
# release is found by listing and then addressed by id (2.3.0: a
88+
# re-pointed tag minted a second draft because the tag lookup 404'd). A failed
89+
# listing (auth, rate limit, outage) fails the run rather than creating.
8190
- name: Create or update draft release
8291
env:
8392
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
8493
run: |
94+
# Default run steps are `bash -e` without pipefail; a failed listing
95+
# piped into jq must fail the step, not read as "no release".
96+
set -o pipefail
8597
tag="${GITHUB_REF#refs/tags/}"
8698
assets=(build/prod/main.js manifest.json build/prod/styles.css)
99+
api="repos/${GITHUB_REPOSITORY}/releases"
87100
88-
if lookup=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" 2>&1); then
89-
if [ "$(jq -r .draft <<<"$lookup")" != "true" ]; then
101+
# The tag was re-pointed after this run started: a newer run owns the
102+
# draft, so don't overwrite it with this (older) build. The peeled
103+
# `^{}` line, when present, is an annotated tag's commit.
104+
current=$(git ls-remote origin "refs/tags/$tag" "refs/tags/$tag^{}" | tail -n 1 | cut -f 1)
105+
if [ "$current" != "$GITHUB_SHA" ]; then
106+
echo "::notice::Tag $tag now points at ${current:-nothing}, not $GITHUB_SHA; skipping the upload."
107+
exit 0
108+
fi
109+
110+
# The tag goes to jq as data (--arg), never into the filter text.
111+
matches=$(gh api --paginate "$api" | jq -c --arg tag "$tag" '.[] | select(.tag_name == $tag) | {id, draft}')
112+
count=$(jq -s length <<<"$matches")
113+
114+
if [ "$count" -eq 0 ]; then
115+
gh release create "$tag" \
116+
--title="$tag" \
117+
--draft \
118+
--notes-file release-notes.md \
119+
"${assets[@]}"
120+
elif [ "$count" -gt 1 ]; then
121+
echo "::error::$count releases exist for $tag; delete the stray ones and re-run."
122+
exit 1
123+
else
124+
id=$(jq -r .id <<<"$matches")
125+
if [ "$(jq -r .draft <<<"$matches")" != "true" ]; then
90126
echo "::error::Release $tag is already published; refusing to replace its assets."
91127
exit 1
92128
fi
93-
echo "Draft release $tag exists; replacing its assets and notes."
129+
echo "Draft release $tag ($id) exists; replacing its assets and notes."
94130
if [ -s release-notes.md ]; then
95-
gh release edit "$tag" --notes-file release-notes.md
131+
# tag_name must be restated: a PATCH without it silently renames
132+
# a draft's tag to `untagged-<hash>`, detaching it from the tag.
133+
gh api -X PATCH "$api/$id" -f tag_name="$tag" -F body=@release-notes.md >/dev/null
96134
fi
97-
gh release upload "$tag" "${assets[@]}" --clobber
135+
# By id, not `gh release upload <tag>`: gh's tag lookup misses
136+
# drafts too. Delete a same-named asset, then upload its build.
137+
for file in "${assets[@]}"; do
138+
name=$(basename "$file")
139+
old=$(gh api --paginate "$api/$id/assets" | jq --arg name "$name" '.[] | select(.name == $name) | .id')
140+
if [ -n "$old" ]; then gh api -X DELETE "$api/assets/$old"; fi
141+
gh api -X POST "https://uploads.github.com/$api/$id/assets?name=$name" \
142+
-H "Content-Type: application/octet-stream" --input "$file" >/dev/null
143+
done
98144
# GitHub has no conditional upload, so a maintainer publishing
99145
# the draft during the seconds between the check above and the
100146
# upload cannot be prevented — only detected. Re-check and fail
101147
# loudly so a published release with swapped assets is never a
102148
# silent green run.
103-
if [ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" --jq .draft)" != "true" ]; then
149+
if [ "$(gh api "$api/$id" --jq .draft)" != "true" ]; then
104150
echo "::error::Release $tag was published while its assets were being replaced; verify the published bundle."
105151
exit 1
106152
fi
107-
elif grep -q "HTTP 404" <<<"$lookup"; then
108-
gh release create "$tag" \
109-
--title="$tag" \
110-
--draft \
111-
--notes-file release-notes.md \
112-
"${assets[@]}"
113-
else
114-
echo "::error::Could not look up release $tag: $lookup"
115-
exit 1
116153
fi

0 commit comments

Comments
 (0)