diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43b377e1..7abc3a5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 permissions: contents: write # Required by attest-build-provenance to mint the signing certificate. @@ -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/` 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}') + 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-`, 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 `: 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