From 0748bdd20b2d6fb6bbbc5c160088416c7f8decff Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:19:09 -0700 Subject: [PATCH] Handle publication race during draft release notes patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Between the draft status check and the PATCH request, a release can be published. The PATCH succeeds but now modifies published content. Check the PATCH response—if draft=false, publication won; restore the published body and leave generated notes unapplied. --- config/scripts/create-draft-release.mjs | 21 +++++++++- config/scripts/create-draft-release.test.mjs | 41 +++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/config/scripts/create-draft-release.mjs b/config/scripts/create-draft-release.mjs index 6aceadf7383..779cec3f465 100644 --- a/config/scripts/create-draft-release.mjs +++ b/config/scripts/create-draft-release.mjs @@ -175,7 +175,10 @@ export async function createDraftRelease({ log(`Release ${tag} was published while notes were generated; leaving it unchanged.`) return } - await githubJson( + // Why: the PATCH endpoint supports no conditional/versioned update, so the + // GET above cannot close the window. The PATCH response reports the state we + // actually wrote to; if publication won, put the published body back. + const patchedRelease = await githubJson( fetchImpl, `https://api.github.com/repos/${repo}/releases/${existingRelease.id}`, token, @@ -184,6 +187,22 @@ export async function createDraftRelease({ body: JSON.stringify({ body }) } ) + if (patchedRelease?.draft !== true) { + const publishedBody = typeof currentRelease.body === 'string' ? currentRelease.body : '' + await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases/${existingRelease.id}`, + token, + { + method: 'PATCH', + body: JSON.stringify({ body: publishedBody }) + } + ) + log( + `Release ${tag} was published while notes were patched; restored its published body and left the generated notes unapplied.` + ) + return + } } else { // Why: GitHub's generated release notes can exceed the release body API // limit, so create with a bounded body. Omit target_commitish because the diff --git a/config/scripts/create-draft-release.test.mjs b/config/scripts/create-draft-release.test.mjs index 766553e5f87..13541f30387 100644 --- a/config/scripts/create-draft-release.test.mjs +++ b/config/scripts/create-draft-release.test.mjs @@ -207,8 +207,8 @@ describe('createDraftRelease', () => { jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) ) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) - .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true })) - .mockResolvedValueOnce(jsonResponse({ id: 42, body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'stale' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'notes' })) await createDraftRelease({ repo: 'stablyai/orca', @@ -248,6 +248,43 @@ describe('createDraftRelease', () => { }) expect(fetchImpl).toHaveBeenCalledTimes(3) + expect(fetchImpl).toHaveBeenNthCalledWith( + 3, + 'https://api.github.com/repos/stablyai/orca/releases/42', + expect.not.objectContaining({ method: expect.anything() }) + ) + }) + + it('restores the published body when publication lands between the check and the patch', async () => { + const log = vi.fn() + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) + ) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'hand-written notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'hand-written notes' })) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log + }) + + expect(fetchImpl).toHaveBeenCalledTimes(5) + expect(fetchImpl).toHaveBeenNthCalledWith( + 5, + 'https://api.github.com/repos/stablyai/orca/releases/42', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ body: 'hand-written notes' }) + }) + ) + expect(log).toHaveBeenCalledWith(expect.stringContaining('restored its published body')) }) it('preserves notes on an existing published release', async () => {