diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 35672c015..6eb98d9ff 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -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. diff --git a/config/scripts/create-draft-release.mjs b/config/scripts/create-draft-release.mjs new file mode 100644 index 000000000..3ac223b85 --- /dev/null +++ b/config/scripts/create-draft-release.mjs @@ -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) + }) +} diff --git a/config/scripts/create-draft-release.test.mjs b/config/scripts/create-draft-release.test.mjs new file mode 100644 index 000000000..95d61ea09 --- /dev/null +++ b/config/scripts/create-draft-release.test.mjs @@ -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) + }) +})