diff --git a/config/scripts/create-draft-release.mjs b/config/scripts/create-draft-release.mjs index 8064af7f6..3412a1184 100644 --- a/config/scripts/create-draft-release.mjs +++ b/config/scripts/create-draft-release.mjs @@ -39,17 +39,21 @@ function compareDesktopReleaseTags(a, b) { return a.rc - b.rc } -export function latestPreviousDesktopReleaseTag(tags, tag) { +export function latestPreviousPublishedDesktopReleaseTag(releases, tag) { const current = parseDesktopReleaseTag(tag) if (!current) { return '' } - const previousTags = tags - .map((candidate) => parseDesktopReleaseTag(candidate)) + const previousReleases = releases + .filter((release) => release?.draft === false && typeof release.tag_name === 'string') + .map((release) => parseDesktopReleaseTag(release.tag_name)) .filter((candidate) => candidate && candidate.tag !== current.tag) .filter((candidate) => compareDesktopReleaseTags(candidate, current) < 0) + // Why: public changelogs should be bounded by releases users could see; + // stable releases summarize since the prior stable, not the latest RC. + .filter((candidate) => current.rc !== null || candidate.rc === null) .sort(compareDesktopReleaseTags) - return previousTags.at(-1)?.tag ?? '' + return previousReleases.at(-1)?.tag ?? '' } function githubHeaders(token) { @@ -75,27 +79,23 @@ async function githubJson(fetchImpl, url, token, options = {}) { return res.json() } -async function fetchRepoTags(repo, token, fetchImpl) { - const tags = [] +async function fetchRepoReleases(repo, token, fetchImpl) { + const releases = [] for (let page = 1; ; page += 1) { - const pageTags = await githubJson( + const pageReleases = await githubJson( fetchImpl, - `https://api.github.com/repos/${repo}/tags?per_page=100&page=${page}`, + `https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`, token ) - if (!Array.isArray(pageTags)) { - throw new Error(`GitHub tags response page ${page} for ${repo} was not an array`) + if (!Array.isArray(pageReleases)) { + throw new Error(`GitHub releases response page ${page} for ${repo} was not an array`) } - for (const tag of pageTags) { - if (typeof tag?.name === 'string') { - tags.push(tag.name) - } - } - if (pageTags.length < 100) { + releases.push(...pageReleases) + if (pageReleases.length < 100) { break } } - return tags + return releases } export function truncateReleaseBody(body, maxLength = MAX_RELEASE_BODY_LENGTH) { @@ -128,8 +128,8 @@ export async function createDraftRelease({ throw new Error('token is required') } - const previousTag = latestPreviousDesktopReleaseTag( - await fetchRepoTags(repo, token, fetchImpl), + const previousTag = latestPreviousPublishedDesktopReleaseTag( + await fetchRepoReleases(repo, token, fetchImpl), tag ) const generateNotesBody = { @@ -138,9 +138,8 @@ export async function createDraftRelease({ ...(previousTag ? { previous_tag_name: previousTag } : {}) } - // Why: draft releases are invisible to GitHub's generate-notes baseline. - // Passing the previous desktop tag keeps each release from accumulating - // notes from older drafts. + // Why: GitHub's generate-notes baseline ignores draft releases, so pass the + // previous public changelog boundary explicitly. const releaseNotes = await githubJson( fetchImpl, `https://api.github.com/repos/${repo}/releases/generate-notes`, diff --git a/config/scripts/create-draft-release.test.mjs b/config/scripts/create-draft-release.test.mjs index 985224b2b..b330ccb94 100644 --- a/config/scripts/create-draft-release.test.mjs +++ b/config/scripts/create-draft-release.test.mjs @@ -1,11 +1,19 @@ import { describe, expect, it, vi } from 'vitest' import { createDraftRelease, - latestPreviousDesktopReleaseTag, + latestPreviousPublishedDesktopReleaseTag, parseDesktopReleaseTag, truncateReleaseBody } from './create-draft-release.mjs' +function release(tag, options = {}) { + return { + draft: false, + tag_name: tag, + ...options + } +} + function jsonResponse(body, init = {}) { return { ok: init.ok ?? true, @@ -49,32 +57,74 @@ describe('parseDesktopReleaseTag', () => { }) }) -describe('latestPreviousDesktopReleaseTag', () => { - it('bounds stable notes to the previous rc when one exists', () => { - expect(latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36-rc.0', 'v1.4.36'], 'v1.4.36')).toBe( - 'v1.4.36-rc.0' - ) +describe('latestPreviousPublishedDesktopReleaseTag', () => { + it('bounds stable notes to the previous stable release when rcs exist', () => { + expect( + latestPreviousPublishedDesktopReleaseTag( + [release('v1.4.35'), release('v1.4.36-rc.0'), release('v1.4.36')], + 'v1.4.36' + ) + ).toBe('v1.4.35') + }) + + it('does not collapse a stable changelog to its rc-to-stable version bump', () => { + expect( + latestPreviousPublishedDesktopReleaseTag( + [ + release('v1.4.120'), + release('v1.4.121-rc.0'), + release('v1.4.121-rc.6'), + release('v1.4.121') + ], + 'v1.4.121' + ) + ).toBe('v1.4.120') }) it('bounds the first rc notes to the previous stable release', () => { expect( - latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36-rc.0', 'mobile-v0.0.12'], 'v1.4.36-rc.0') + latestPreviousPublishedDesktopReleaseTag( + [release('v1.4.35'), release('v1.4.36-rc.0'), release('mobile-v0.0.12')], + 'v1.4.36-rc.0' + ) ).toBe('v1.4.35') }) it('bounds later rc notes to the prior rc', () => { - expect(latestPreviousDesktopReleaseTag(['v1.4.36-rc.0', 'v1.4.36-rc.1'], 'v1.4.36-rc.1')).toBe( - 'v1.4.36-rc.0' - ) + expect( + latestPreviousPublishedDesktopReleaseTag( + [release('v1.4.36-rc.0'), release('v1.4.36-rc.1')], + 'v1.4.36-rc.1' + ) + ).toBe('v1.4.36-rc.0') + }) + + it('ignores draft releases as public changelog boundaries', () => { + expect( + latestPreviousPublishedDesktopReleaseTag( + [release('v1.4.35'), release('v1.4.36-rc.0', { draft: true }), release('v1.4.36-rc.1')], + 'v1.4.36-rc.1' + ) + ).toBe('v1.4.35') }) it('returns empty string for the first desktop release when no earlier tag exists', () => { - expect(latestPreviousDesktopReleaseTag(['v1.4.36', 'mobile-v0.0.12'], 'v1.4.36')).toBe('') - expect(latestPreviousDesktopReleaseTag([], 'v1.4.36')).toBe('') + expect( + latestPreviousPublishedDesktopReleaseTag( + [release('v1.4.36'), release('mobile-v0.0.12')], + 'v1.4.36' + ) + ).toBe('') + expect(latestPreviousPublishedDesktopReleaseTag([], 'v1.4.36')).toBe('') }) it('returns empty string when the current tag is not a desktop release tag', () => { - expect(latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36'], 'mobile-v0.0.12')).toBe('') + expect( + latestPreviousPublishedDesktopReleaseTag( + [release('v1.4.35'), release('v1.4.36')], + 'mobile-v0.0.12' + ) + ).toBe('') }) }) @@ -82,7 +132,7 @@ describe('createDraftRelease', () => { it('creates a draft release with bounded generated notes', async () => { const fetchImpl = vi .fn() - .mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.35' }, { name: 'v1.4.36' }])) + .mockResolvedValueOnce(jsonResponse([release('v1.4.35'), release('v1.4.36')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) @@ -96,7 +146,7 @@ describe('createDraftRelease', () => { expect(fetchImpl).toHaveBeenNthCalledWith( 1, - 'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=1', + 'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=1', expect.any(Object) ) expect(fetchImpl).toHaveBeenNthCalledWith( @@ -134,7 +184,7 @@ describe('createDraftRelease', () => { it('marks rc tags as prereleases', async () => { const fetchImpl = vi .fn() - .mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.36' }, { name: 'v1.4.36-rc.1' }])) + .mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('v1.4.36-rc.1')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true })) @@ -153,7 +203,7 @@ describe('createDraftRelease', () => { 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([{ name: 'v1.4.36' }, { name: 'mobile-v0.0.12' }])) + .mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('mobile-v0.0.12')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) @@ -170,12 +220,12 @@ describe('createDraftRelease', () => { expect(generateNotesBody).not.toHaveProperty('previous_tag_name') }) - it('paginates through every tag page before choosing the previous tag', async () => { - const firstPage = Array.from({ length: 100 }, (_, index) => ({ name: `mobile-v0.0.${index}` })) + it('paginates through every release page before choosing the previous release', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => release(`mobile-v0.0.${index}`)) const fetchImpl = vi .fn() .mockResolvedValueOnce(jsonResponse(firstPage)) - .mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.35' }])) + .mockResolvedValueOnce(jsonResponse([release('v1.4.35')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) @@ -189,12 +239,12 @@ describe('createDraftRelease', () => { expect(fetchImpl).toHaveBeenNthCalledWith( 1, - 'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=1', + 'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=1', expect.any(Object) ) expect(fetchImpl).toHaveBeenNthCalledWith( 2, - 'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=2', + 'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=2', expect.any(Object) ) const generateNotesBody = JSON.parse(fetchImpl.mock.calls[2][1].body)