fix: bound release notes by published releases (#7311)

This commit is contained in:
Jinjing 2026-07-03 20:53:16 -07:00 committed by GitHub
parent c29ee51a0a
commit b2fcbdbc73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 93 additions and 44 deletions

View File

@ -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`,

View File

@ -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)