Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 54 additions & 17 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ 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. 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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
permissions:
contents: write
# Required by attest-build-provenance to mint the signing certificate.
Expand Down Expand Up @@ -76,41 +83,71 @@ 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/<tag>` 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: |
# 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"

if lookup=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" 2>&1); then
if [ "$(jq -r .draft <<<"$lookup")" != "true" ]; then
# 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}')
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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
# tag_name must be restated: a PATCH without it silently renames
# a draft's tag to `untagged-<hash>`, detaching it from the tag.
gh api -X PATCH "$api/$id" -f tag_name="$tag" -F body=@release-notes.md >/dev/null
fi
gh release upload "$tag" "${assets[@]}" --clobber
# By id, not `gh release upload <tag>`: 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 --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
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
Loading