diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index c35999c7786..c2124d12990 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -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 diff --git a/config/scripts/create-draft-release.mjs b/config/scripts/create-draft-release.mjs index 3412a118491..b4e3f3e0933 100644 --- a/config/scripts/create-draft-release.mjs +++ b/config/scripts/create-draft-release.mjs @@ -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).` + ) } } diff --git a/config/scripts/create-draft-release.test.mjs b/config/scripts/create-draft-release.test.mjs index b330ccb9423..dadc6111ac3 100644 --- a/config/scripts/create-draft-release.test.mjs +++ b/config/scripts/create-draft-release.test.mjs @@ -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 }))