Skip to content

ci(release): address the draft by the id its create call returns - #6

Merged
ks98 merged 1 commit into
mainfrom
fix/release-draft-race
Sep 23, 2026
Merged

ks98 merged 1 commit into
mainfrom
fix/release-draft-race

Conversation

@ks98

@ks98 ks98 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

What

The draft-first release step no longer looks the draft up by tag right after creating it. It creates the draft with POST /repos/{owner}/{repo}/releases and from then on addresses it only by the id in that response: uploads go to uploads.github.com/repos/{owner}/{repo}/releases/<id>/assets (curl), the digest check reads releases/<id>/assets, and publishing is PATCH releases/<id> with draft=false.

Why

The first real draft-first release, lmn-gpo v7.3.4 (run 35865217323, attempt 1), created the draft at 13:12:18.67. The lookup 0.7 s later got nothing back: expected exactly one draft for v7.3.4, got: . The release list lags behind a create. I reproduced this live four more times: 0 hits right after the POST each time, and the draft showed up within about 2 s. GET releases/tags/{tag} never returns drafts, and gh release upload/edit <tag> also resolve a draft by its tag.

Details

  • The release itself is unchanged: same title, generated notes, prerelease for -rc tags.
  • Re-run path kept. A draft that a failed run left behind (minutes old) is still found through the list, now with --paginate and a bounded retry (3 attempts, API errors only; an empty answer is final). An already published release is refused and never touched. More than one draft for the tag is an error.
  • The tag must exist before a draft is created or completed. This replaces --verify-tag, because publishing a draft would otherwise create the tag at the default branch.
  • Idempotent uploads. An asset with the same name already on the draft stays if its sha256 matches. If not, it is deleted and uploaded again, and only after re-checking that the release is still a draft.
  • The final check compares (GitHub sha256, name) of every asset with the build, as a set. Names are compared in normalized form because GitHub renames ~ to . (seen live; + stays).
  • The asset list block of this repo is unchanged.

How tested

  • actionlint 1.7.12 with shellcheck 0.11.0: no new findings compared with main (the existing warnings in other jobs are unchanged). The YAML parses, and shellcheck of the extracted step is clean.

  • Live in faircomp/linuxmuster-gpo-template, running the exact run: blocks extracted from the gpo copy of this step (identical to this one apart from the asset list and the notes body). They ran against throwaway drafts v0.0.0-racetest-1790169896 and v0.0.0-rc1-racetest-1790169990, whose tags never existed. A PATH shim answered the tag check for exactly those tags and blocked the publish PATCH. Results:

    • fresh create, 2 uploads, digest check passed; the release list showed the new draft 0 times at that moment, and the step never asked it again
    • re-run, identical files: both skipped
    • re-run after a rebuild: both replaced
    • half-finished upload: only the missing file uploaded
    • stray foreign asset: publishing refused
    • -rc tag: prerelease=true
    • missing tag: refused before any write
    • published v7.3.4 (read-only): refused after one list call

    Both drafts were deleted afterwards: GET by id returns 404, there are 22 releases before and after, and no v0.0.0* tag exists.

Changelog

No entry. The unreleased top block already says that releases are created as drafts, get their assets, are checked and only then published. That stays true; this fixes how the workflow does it, and nothing changes in the package or for admins.

The release job created the draft with `gh release create` and then looked
it up in the release list to get its id. Right after a create the list does
not show the new draft yet, so the first real draft-first release (lmn-gpo
v7.3.4, run 35865217323 in faircomp/linuxmuster-gpo-template) failed with
"expected exactly one draft"; a re-run completed it.

The draft is now created with POST /releases (same title, generated
notes, prerelease for -rc tags) and its id is taken from the response. Everything after that uses the id: uploads go to
uploads.github.com/.../releases/<id>/assets, the digest check reads
releases/<id>/assets, publishing is PATCH releases/<id> draft=false. The
list is only used to find a draft an earlier run left behind, with a
bounded retry against API errors. The tag must exist before a draft is
created or completed (what --verify-tag checked).

A re-run is idempotent: an asset of the same name that is already on the
draft stays if its sha256 matches and is replaced if not, and assets are
only deleted while the release is still a draft. The final check compares
name and sha256 of every asset with the build, as a set. Tested live
against throwaway drafts (never published, deleted afterwards).
@ks98
ks98 merged commit 2a5b1a2 into main Sep 23, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant