Skip to content

Serialise publishing against release-drafter - #394

Merged
fstubner merged 1 commit into
mainfrom
fix/publish-drafter-race
Sep 11, 2026
Merged

Serialise publishing against release-drafter#394
fstubner merged 1 commit into
mainfrom
fix/publish-drafter-race

Conversation

@fstubner

Copy link
Copy Markdown
Owner

The race

release-drafter rewrites its draft by listing every release, taking the first one whose draft is true, and then PATCHing that release by id. Between the read and the write it re-checks nothing, and the payload it sends always carries draft: true. Publish inside that gap and the PATCH lands on a release that is no longer a draft.

Source, at the SHA already pinned in release-drafter.yml:

  • find-previous-releases.tsdraftReleases = filteredReleases.filter((r) => r.draft), then draftReleases[0].
  • update-release.tsoctokit.rest.repos.updateRelease({ release_id: draftRelease.id, draft: releasePayload.draft, ... }), with no re-read.

Measured, from drafter run 34597984827:

12:15:46.9585962Z Release payload built successfully
  tag:    v0.3.1
  draft:  true
12:15:46.9593580Z Updating existing release...
12:15:47.9599265Z Release updated!

v0.3.1's publishedAt is 12:15:47Z — one second inside that window. The release came out of it with its tag replaced by untagged-67369151a7d123887d23 and was repaired by hand. Nothing failed and no workflow reported anything wrong.

The collision is structural, not unlucky. "Merge the changelog, then publish" puts a push to main and a publish back to back, and the push is what starts a drafter run.

The fix

The gap is inside the action, so it can't be closed from outside — the only real prevention is to stop a publish and a draft rewrite from overlapping.

  • New publish-release.ymlworkflow_dispatch with a tag input. Validates the tag shape, refuses a tag with no ref, refuses a release that doesn't exist or is already published, flips the draft, then reads it back to confirm the release is public and still carries its tag.
  • Shared release-draft concurrency group across publish-release.yml and release-drafter.yml. release-drafter was the only workflow in the repo without a concurrency group, so this also stops two drafter runs racing when several PRs merge together.
  • cancel-in-progress: false on both sides, which is load-bearing rather than cautious: the setting is read from the workflow of the run joining the group, so a cancelling drafter would cancel an in-progress publish and strand a public release with no pipeline started.

publish-release.yml dispatches release.yml and publish.yml explicitly, because GitHub suppresses release: published for anything done with GITHUB_TOKENworkflow_dispatch is one of its two documented exceptions. No new credential is needed, and it removes an existing race where both workflows fired on the same event simultaneously.

The procedure changes

gh workflow run publish-release.yml -f tag=vX.Y.Z

instead of gh release edit --draft=false or the web UI's publish button. Those two hold no lock and stay exposed, so docs/RELEASE.md and docs/PUBLISHING.md now point at the workflow, and publish.yml's trigger comment no longer calls the event path canonical.

Verification

  • Both workflows parse; the shared group is byte-identical on each side (release-draft / cancel-in-progress: false).
  • Tag regex exercised against 7 inputs: accepts v0.3.1, v1.2.3-rc.1, v10.20.30; rejects 0.3.1, v0.3, vX.Y.Z, and v0.3.1; rm -rf /.
  • actionlint is not installed here, so it did not run.

What CI cannot prove: neither workflow is exercised by CI. The real proof is a dispatch, and the first one will be the next release.

Unverified

Why the clobber replaced the tag specifically. The PATCH demonstrably sent tag_name: v0.3.1, and GitHub doesn't expose release edit history, so the untagged-… placeholder is a consequence I couldn't reconstruct. What's established is that the write landed on a release that was no longer a draft. The comments claim only that.

release-drafter rewrites its draft by listing every release, taking the
first one whose `draft` is true, then PATCHing that release by id. It
re-checks nothing between the read and the write, and the payload it sends
always carries `draft: true`. A publish landing in that gap is overwritten.

Run 34597984827 built its payload at 12:15:46.96 and logged "Release
updated!" at 12:15:47.96. v0.3.1 was published at 12:15:47Z, one second
inside the window. The release came back with its tag replaced by an
`untagged-...` placeholder and had to be repaired by hand. Nothing failed,
and no workflow reported anything wrong -- it was found by chance, on a
release that was already public.

The collision is structural rather than unlucky: "merge the changelog, then
publish" puts a push to main and a publish back to back, and the push is
exactly what starts a drafter run.

The gap is inside the action, so it cannot be closed from outside.
Serialise instead:

- Add publish-release.yml, a workflow_dispatch that validates the tag shape,
  refuses a missing or already-published release, flips the draft, then reads
  it back to confirm the release is public and still carries its tag.
- Give it and release-drafter.yml a shared `release-draft` concurrency group,
  so a publish and a draft rewrite can never overlap. release-drafter was the
  only workflow in the repo without a concurrency group, so this also stops
  two drafter runs racing when several PRs merge together.
- `cancel-in-progress: false` on both sides, which is load-bearing: the
  setting is read from the workflow of the run joining the group, so a
  cancelling drafter would kill an in-progress publish and strand a public
  release with no pipeline started.

publish-release.yml dispatches release.yml and publish.yml explicitly,
because GitHub suppresses `release: published` for anything done with
GITHUB_TOKEN and `workflow_dispatch` is one of its two documented
exceptions. That also removes an existing race, where release.yml and
publish.yml both fired on the same event at the same instant.

This only covers publishes that go through the workflow. `gh release edit
--draft=false` and the web UI's publish button hold no lock, so
docs/RELEASE.md and docs/PUBLISHING.md now point at the workflow, and
publish.yml's trigger comment no longer calls the event path canonical.

Unverified, and left unclaimed in the comments: why the clobber replaced the
tag specifically. The PATCH demonstrably sent `tag_name: v0.3.1`, and GitHub
does not expose release edit history, so the placeholder is a consequence I
could not reconstruct. What is established is that the write landed on a
release that was no longer a draft.
@fstubner
fstubner merged commit ea7b480 into main Sep 11, 2026
15 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