Fix release note body limit (#4378)
This commit is contained in:
parent
25360c4ce6
commit
6bd6c6fda0
|
|
@ -447,7 +447,7 @@ jobs:
|
|||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Create draft release with auto-generated notes
|
||||
- name: Create draft release with bounded generated notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
|
|
@ -457,15 +457,7 @@ jobs:
|
|||
exit 0
|
||||
fi
|
||||
|
||||
is_rc=false
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
is_rc=true
|
||||
fi
|
||||
|
||||
gh release create "$TAG" \
|
||||
--draft \
|
||||
--generate-notes \
|
||||
--prerelease="$is_rc"
|
||||
node config/scripts/create-draft-release.mjs "$TAG"
|
||||
|
||||
# Why: tag-scoped E2E gives release visibility, but the suite is flaky enough
|
||||
# that publish-release must not depend on it.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const API_VERSION = '2022-11-28'
|
||||
const MAX_RELEASE_BODY_LENGTH = 120_000
|
||||
const TRUNCATION_NOTICE =
|
||||
'\n\n---\nRelease notes were truncated because GitHub release bodies are limited to 125,000 characters.'
|
||||
|
||||
function githubHeaders(token) {
|
||||
return {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-GitHub-Api-Version': API_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
async function githubJson(fetchImpl, url, token, options = {}) {
|
||||
const res = await fetchImpl(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...githubHeaders(token),
|
||||
...options.headers
|
||||
}
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${body.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function truncateReleaseBody(body, maxLength = MAX_RELEASE_BODY_LENGTH) {
|
||||
if (body.length <= maxLength) {
|
||||
return body
|
||||
}
|
||||
|
||||
const availableLength = maxLength - TRUNCATION_NOTICE.length
|
||||
if (availableLength <= 0) {
|
||||
throw new Error('Release truncation notice is longer than the maximum release body length')
|
||||
}
|
||||
|
||||
return `${body.slice(0, availableLength).trimEnd()}${TRUNCATION_NOTICE}`
|
||||
}
|
||||
|
||||
export async function createDraftRelease({
|
||||
repo,
|
||||
tag,
|
||||
token,
|
||||
fetchImpl = fetch,
|
||||
log = console.log
|
||||
}) {
|
||||
if (!repo) {
|
||||
throw new Error('repo is required')
|
||||
}
|
||||
if (!tag) {
|
||||
throw new Error('tag is required')
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error('token is required')
|
||||
}
|
||||
|
||||
const releaseNotes = await githubJson(
|
||||
fetchImpl,
|
||||
`https://api.github.com/repos/${repo}/releases/generate-notes`,
|
||||
token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
target_commitish: tag
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const generatedBody = typeof releaseNotes.body === 'string' ? releaseNotes.body : ''
|
||||
const body = truncateReleaseBody(generatedBody)
|
||||
const name =
|
||||
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 generated body instead of --generate-notes.
|
||||
await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases`, token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
target_commitish: tag,
|
||||
name,
|
||||
body,
|
||||
draft: true,
|
||||
prerelease
|
||||
})
|
||||
})
|
||||
|
||||
if (generatedBody.length !== body.length) {
|
||||
log(`Created draft release ${tag} with truncated generated notes (${body.length} chars).`)
|
||||
} else {
|
||||
log(`Created draft release ${tag} with generated notes (${body.length} chars).`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tag = process.argv[2]
|
||||
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
|
||||
const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca'
|
||||
await createDraftRelease({ repo, tag, token })
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createDraftRelease, truncateReleaseBody } from './create-draft-release.mjs'
|
||||
|
||||
function jsonResponse(body, init = {}) {
|
||||
return {
|
||||
ok: init.ok ?? true,
|
||||
status: init.status ?? 200,
|
||||
statusText: init.statusText ?? 'OK',
|
||||
json: vi.fn(async () => body),
|
||||
text: vi.fn(async () => (typeof body === 'string' ? body : JSON.stringify(body)))
|
||||
}
|
||||
}
|
||||
|
||||
describe('truncateReleaseBody', () => {
|
||||
it('leaves short release notes unchanged', () => {
|
||||
expect(truncateReleaseBody('short notes', 120_000)).toBe('short notes')
|
||||
})
|
||||
|
||||
it('caps long release notes and appends an explanation', () => {
|
||||
const body = truncateReleaseBody('a'.repeat(130_000), 1_000)
|
||||
|
||||
expect(body).toHaveLength(1_000)
|
||||
expect(body).toContain('Release notes were truncated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDraftRelease', () => {
|
||||
it('creates a draft release with bounded generated notes', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
|
||||
|
||||
await createDraftRelease({
|
||||
repo: 'stablyai/orca',
|
||||
tag: 'v1.4.36',
|
||||
token: 'token',
|
||||
fetchImpl,
|
||||
log: vi.fn()
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://api.github.com/repos/stablyai/orca/releases/generate-notes',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tag_name: 'v1.4.36',
|
||||
target_commitish: 'v1.4.36'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://api.github.com/repos/stablyai/orca/releases',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.any(String)
|
||||
})
|
||||
)
|
||||
|
||||
const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
|
||||
expect(createBody).toMatchObject({
|
||||
tag_name: 'v1.4.36',
|
||||
target_commitish: 'v1.4.36',
|
||||
name: 'v1.4.36',
|
||||
draft: true,
|
||||
prerelease: false
|
||||
})
|
||||
expect(createBody.body).toHaveLength(120_000)
|
||||
expect(createBody.body).toContain('Release notes were truncated')
|
||||
})
|
||||
|
||||
it('marks rc tags as prereleases', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true }))
|
||||
|
||||
await createDraftRelease({
|
||||
repo: 'stablyai/orca',
|
||||
tag: 'v1.4.36-rc.1',
|
||||
token: 'token',
|
||||
fetchImpl,
|
||||
log: vi.fn()
|
||||
})
|
||||
|
||||
const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
|
||||
expect(createBody.prerelease).toBe(true)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue