Handle publication race during draft release notes patch

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.
This commit is contained in:
Jinjing
2026-09-05 22:19:09 -07:00
parent 21d9dce303
commit 0748bdd20b
2 changed files with 59 additions and 3 deletions
+20 -1
View File
@@ -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
+39 -2
View File
@@ -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 () => {