Support updating existing draft releases when regenerating notes (#19014)

Move release existence check into create-draft-release.mjs. Draft releases
are updated via PATCH, published releases are skipped, making the
release-cut workflow idempotent.
This commit is contained in:
Jinjing
2026-09-05 21:52:54 -07:00
committed by GitHub
parent 8f97048d60
commit bdad20b4c1
3 changed files with 83 additions and 28 deletions
+1 -7
View File
@@ -809,13 +809,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.cut.outputs.tag }}
run: |
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $TAG already exists."
exit 0
fi
node config/scripts/create-draft-release.mjs "$TAG"
run: node config/scripts/create-draft-release.mjs "$TAG"
terminal-rendering-golden:
needs: cut
+41 -18
View File
@@ -128,10 +128,14 @@ export async function createDraftRelease({
throw new Error('token is required')
}
const previousTag = latestPreviousPublishedDesktopReleaseTag(
await fetchRepoReleases(repo, token, fetchImpl),
tag
)
const releases = await fetchRepoReleases(repo, token, fetchImpl)
const existingRelease = releases.find((release) => release?.tag_name === tag)
if (existingRelease && existingRelease.draft !== true) {
log(`Release ${tag} already exists and is published.`)
return
}
const previousTag = latestPreviousPublishedDesktopReleaseTag(releases, tag)
const generateNotesBody = {
tag_name: tag,
target_commitish: tag,
@@ -156,24 +160,43 @@ export async function createDraftRelease({
typeof releaseNotes.name === 'string' && releaseNotes.name.length > 0 ? releaseNotes.name : tag
const prerelease = tag.includes('-rc.')
// Why: GitHub's generated release notes can exceed the release body API
// limit, so create with a bounded body. Omit target_commitish because the
// release-cut tag already exists and GitHub rejects the tag name there.
await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases`, token, {
method: 'POST',
body: JSON.stringify({
tag_name: tag,
name,
body,
draft: true,
prerelease
if (existingRelease) {
if (!Number.isInteger(existingRelease.id)) {
throw new Error(`Draft release ${tag} is missing a GitHub release id`)
}
await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases/${existingRelease.id}`,
token,
{
method: 'PATCH',
body: JSON.stringify({ body })
}
)
} 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
// release-cut tag already exists and GitHub rejects the tag name there.
await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases`, token, {
method: 'POST',
body: JSON.stringify({
tag_name: tag,
name,
body,
draft: true,
prerelease
})
})
})
}
if (generatedBody.length !== body.length) {
log(`Created draft release ${tag} with truncated generated notes (${body.length} chars).`)
log(
`${existingRelease ? 'Updated' : 'Created'} draft release ${tag} with truncated generated notes (${body.length} chars).`
)
} else {
log(`Created draft release ${tag} with generated notes (${body.length} chars).`)
log(
`${existingRelease ? 'Updated' : 'Created'} draft release ${tag} with generated notes (${body.length} chars).`
)
}
}
+41 -3
View File
@@ -132,7 +132,7 @@ describe('createDraftRelease', () => {
it('creates a draft release with bounded generated notes', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse([release('v1.4.35'), release('v1.4.36')]))
.mockResolvedValueOnce(jsonResponse([release('v1.4.35')]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
@@ -184,7 +184,7 @@ describe('createDraftRelease', () => {
it('marks rc tags as prereleases', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('v1.4.36-rc.1')]))
.mockResolvedValueOnce(jsonResponse([release('v1.4.36')]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true }))
@@ -200,10 +200,48 @@ describe('createDraftRelease', () => {
expect(createBody.prerelease).toBe(true)
})
it('regenerates notes for an existing draft release', async () => {
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, body: 'notes' }))
await createDraftRelease({
repo: 'stablyai/orca',
tag: 'v1.4.36',
token: 'token',
fetchImpl,
log: vi.fn()
})
expect(fetchImpl).toHaveBeenNthCalledWith(
3,
'https://api.github.com/repos/stablyai/orca/releases/42',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ body: 'notes' }) })
)
})
it('preserves notes on an existing published release', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse([release('v1.4.36', { id: 42 })]))
await createDraftRelease({
repo: 'stablyai/orca',
tag: 'v1.4.36',
token: 'token',
fetchImpl,
log: vi.fn()
})
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('omits previous_tag_name for the first desktop release so notes fall back to the GitHub default', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('mobile-v0.0.12')]))
.mockResolvedValueOnce(jsonResponse([release('mobile-v0.0.12')]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))