Serialise publishing against release-drafter - #394
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The race
release-drafterrewrites its draft by listing every release, taking the first one whosedraftis true, and then PATCHing that release by id. Between the read and the write it re-checks nothing, and the payload it sends always carriesdraft: 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.ts—draftReleases = filteredReleases.filter((r) => r.draft), thendraftReleases[0].update-release.ts—octokit.rest.repos.updateRelease({ release_id: draftRelease.id, draft: releasePayload.draft, ... }), with no re-read.Measured, from drafter run
34597984827:v0.3.1'spublishedAtis12:15:47Z— one second inside that window. The release came out of it with its tag replaced byuntagged-67369151a7d123887d23and 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
mainand 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.
publish-release.yml—workflow_dispatchwith ataginput. 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.release-draftconcurrency group acrosspublish-release.ymlandrelease-drafter.yml.release-drafterwas 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: falseon 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.ymldispatchesrelease.ymlandpublish.ymlexplicitly, because GitHub suppressesrelease: publishedfor anything done withGITHUB_TOKEN—workflow_dispatchis 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
instead of
gh release edit --draft=falseor the web UI's publish button. Those two hold no lock and stay exposed, sodocs/RELEASE.mdanddocs/PUBLISHING.mdnow point at the workflow, andpublish.yml's trigger comment no longer calls the event path canonical.Verification
release-draft/cancel-in-progress: false).v0.3.1,v1.2.3-rc.1,v10.20.30; rejects0.3.1,v0.3,vX.Y.Z, andv0.3.1; rm -rf /.actionlintis 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 theuntagged-…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.