diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc4a6728..d89ee9f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,6 +96,12 @@ jobs: } NODE + - name: Generate release notes + run: node scripts/generate-release-notes.ts "$RELEASE_VERSION" "$RUNNER_TEMP/release-notes.md" + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + - name: Publish prepared release run: ./scripts/release.sh --publish-prepared "$RELEASE_VERSION" env: @@ -110,14 +116,7 @@ jobs: if [[ "${version%%.*}" == 9 ]]; then latest=false fi - notes="docs/releases/v${version}.md" - args=("v${version}" --target "$(git rev-parse HEAD)" --title "NitroSQLite ${version}" --latest="$latest") - if [ -f "$notes" ]; then - args+=(--notes-file "$notes") - else - args+=(--generate-notes) - fi - gh release create "${args[@]}" + gh release create "v${version}" --target "$(git rev-parse HEAD)" --title "NitroSQLite ${version}" --latest="$latest" --notes-file "$RUNNER_TEMP/release-notes.md" env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} diff --git a/package.json b/package.json index 9a256a75..d30c39bf 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "clean": "rm -rf **/.tsbuild **/tsconfig.tsbuildinfo node_modules packages/react-native-nitro-sqlite/node_module packages/react-native-nitro-sqlite/lib", "check:lockfile": "bun scripts/check-bun-lockfile.ts", "sync:lockfile": "bun scripts/sync-bun-lockfile.ts", - "test:release": "bun test scripts/sync-bun-lockfile.test.ts && node --test scripts/release.test.ts", + "test:release": "bun test scripts/sync-bun-lockfile.test.ts && node --test scripts/release.test.ts scripts/generate-release-notes.test.ts", "release": "./scripts/release.sh", "sqlite": "bun --cwd packages/react-native-nitro-sqlite", "example": "bun --cwd example", diff --git a/scripts/generate-release-notes.test.ts b/scripts/generate-release-notes.test.ts new file mode 100644 index 00000000..1f4782c3 --- /dev/null +++ b/scripts/generate-release-notes.test.ts @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +type Configuration = { + 'repository': { type: string; url: string } + 'release-it': { + plugins: { + '@release-it/conventional-changelog': { + preset: { types: { type: string; section: string }[] } + } + } + } +} + +const script = fileURLToPath( + new URL('./generate-release-notes.ts', import.meta.url), +) +const configuration = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +) as Configuration + +test('combines the announcement, configured changelog, and new contributors from the selected release branch', () => { + const githubNotes = + "## What's Changed\n* unrelated generated list\n\n## New Contributors\n* @newcomer made their first contribution in https://github.com/margelo/react-native-nitro-sqlite/pull/1\n\n**Full Changelog**: contributor-section-end" + withFixture(githubNotes, (directory) => { + const notes = generate(directory) + assert.match(notes, /^Custom announcement\.\n\n---\n\n/) + const fixSection = configuration['release-it'].plugins[ + '@release-it/conventional-changelog' + ].preset.types.find((entry) => entry.type === 'fix')?.section + assert.ok(fixSection) + assert.ok(notes.includes(fixSection)) + assert.match(notes, /repair runtime connections/) + assert.match(notes, /v1\.0\.0\.\.\.v1\.0\.1/) + assert.match( + notes, + /## New Contributors\n\* @newcomer made their first contribution in https:\/\/github\.com\/margelo\/react-native-nitro-sqlite\/pull\/1/, + ) + assert.doesNotMatch( + notes, + /main only feature|initial release fixture|unrelated generated list|contributor-section-end/, + ) + }) +}) + +test('regenerates a published release from its previous tag and states when no new contributors are detected', () => { + withFixture("## What's Changed\n* existing contributor", (directory) => { + git(directory, 'tag', 'v1.0.1') + const notes = generate(directory) + assert.match(notes, /repair runtime connections/) + assert.match(notes, /v1\.0\.0\.\.\.v1\.0\.1/) + assert.match( + notes, + /## New Contributors\n\nNo new contributors were detected for this release\./, + ) + }) +}) + +test('rejects a release increment before generating notes', () => { + withFixture('', (directory) => { + assert.throws( + () => generate(directory, 'patch'), + /An exact stable release version is required/, + ) + }) +}) + +function generate(directory: string, version = '1.0.1'): string { + const destination = join(directory, 'notes.md') + execFileSync(process.execPath, [script, version, destination], { + cwd: directory, + env: { + ...process.env, + PATH: `${join(directory, 'bin')}:${process.env.PATH}`, + }, + stdio: 'pipe', + }) + return readFileSync(destination, 'utf8') +} + +function withFixture(body: string, run: (directory: string) => void): void { + const directory = mkdtempSync(join(tmpdir(), 'nitrosqlite-release-notes-')) + try { + git(directory, 'init', '-b', 'main') + git(directory, 'config', 'user.name', 'Christoph Pader') + git(directory, 'config', 'user.email', 'release-test@example.invalid') + // Fixture commits stay inside this temporary repository and need no signing key in CI. + git(directory, 'config', 'commit.gpgsign', 'false') + writeFileSync( + join(directory, 'package.json'), + JSON.stringify({ + 'name': 'release-notes-fixture', + 'version': '1.0.1', + 'repository': configuration.repository, + 'release-it': configuration['release-it'], + }), + ) + mkdirSync(join(directory, 'docs/releases'), { recursive: true }) + writeFileSync( + join(directory, 'docs/releases/v1.0.1.md'), + 'Custom announcement.\n', + ) + git(directory, 'add', '.') + git(directory, 'commit', '-m', 'chore: initial release fixture') + git(directory, 'tag', 'v1.0.0') + git(directory, 'branch', 'release/1.0') + git(directory, 'commit', '--allow-empty', '-m', 'feat: main only feature') + git(directory, 'switch', 'release/1.0') + git( + directory, + 'commit', + '--allow-empty', + '-m', + 'fix: repair runtime connections', + ) + mkdirSync(join(directory, 'bin')) + writeFileSync( + join(directory, 'bin/gh'), + `#!/usr/bin/env node\nconst args = process.argv.slice(2);\nif (!args.includes('previous_tag_name=v1.0.0') || !args.includes('tag_name=v1.0.1')) throw new Error('Incorrect GitHub release range');\nprocess.stdout.write(${JSON.stringify(JSON.stringify({ body }))});\n`, + { mode: 0o755 }, + ) + run(directory) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + +function git(directory: string, ...args: string[]): void { + execFileSync('git', args, { cwd: directory, stdio: 'pipe' }) +} diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts new file mode 100644 index 00000000..dab54df1 --- /dev/null +++ b/scripts/generate-release-notes.ts @@ -0,0 +1,129 @@ +import { execFileSync } from 'node:child_process' +import { readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import type { Readable } from 'node:stream' +import { fileURLToPath } from 'node:url' + +type RootPackage = { + 'release-it'?: { + plugins?: Record> + } +} + +type ChangelogFactory = ( + options: Record, + context: Record, + commits: Record, +) => Readable + +/** Build announcement text, the configured commit changelog, and contributor acknowledgements. + * The range ends at the selected commit and starts at its previous reachable release tag. + * Throws if custom notes, the changelog configuration, or GitHub contributor detection are unavailable. + * @param version Exact stable version being released. + * @returns Complete Markdown announcement, changelog, and new contributor section. + */ +export async function generateReleaseNotes(version: string): Promise { + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error('An exact stable release version is required.') + } + + const releaseCommit = git('rev-parse', 'HEAD') + const previousTag = git( + 'describe', + '--tags', + '--match', + 'v[0-9]*', + '--abbrev=0', + `${releaseCommit}^`, + ) + const currentTag = `v${version}` + const announcement = ( + await readFile(`docs/releases/${currentTag}.md`, 'utf8') + ).trim() + if (!announcement) throw new Error('Custom release notes must not be empty.') + + const metadata = JSON.parse( + await readFile('package.json', 'utf8'), + ) as RootPackage + const options = + metadata['release-it']?.plugins?.['@release-it/conventional-changelog'] + if (!options?.preset) { + throw new Error('The release pipeline must configure a changelog preset.') + } + + const require = createRequire(import.meta.url) + const pluginRequire = createRequire( + require.resolve('@release-it/conventional-changelog/package.json'), + ) + const changelogFactory = pluginRequire( + 'conventional-changelog', + ) as ChangelogFactory + const stream = changelogFactory( + { ...options, releaseCount: 1, tagPrefix: 'v' }, + { version, previousTag, currentTag }, + { from: previousTag, to: releaseCommit }, + ) + let changelog = '' + for await (const chunk of stream) changelog += String(chunk) + if (!changelog.trim()) + throw new Error('The release changelog must not be empty.') + + const githubNotes = JSON.parse( + execFileSync( + 'gh', + [ + 'api', + 'repos/{owner}/{repo}/releases/generate-notes', + '--method', + 'POST', + '-f', + `tag_name=${currentTag}`, + '-f', + `target_commitish=${releaseCommit}`, + '-f', + `previous_tag_name=${previousTag}`, + ], + { encoding: 'utf8' }, + ), + ) as { body?: string } + if (typeof githubNotes.body !== 'string') { + throw new Error('GitHub did not return generated release notes.') + } + + return `${announcement}\n\n---\n\n${changelog.trim()}\n\n${contributorsSection(githubNotes.body)}\n` +} + +function contributorsSection(notes: string): string { + const lines = notes.split('\n') + const start = lines.findIndex((line) => /^## New Contributors\s*$/.test(line)) + if (start === -1) { + return '## New Contributors\n\nNo new contributors were detected for this release.' + } + + const remaining = lines.slice(start + 1) + const end = remaining.findIndex((line) => + /^#{1,2} |^\*\*Full Changelog\*\*/.test(line), + ) + return [ + '## New Contributors', + ...remaining.slice(0, end === -1 ? undefined : end), + ] + .join('\n') + .trim() +} + +function git(...args: string[]): string { + return execFileSync('git', args, { encoding: 'utf8' }).trim() +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const [version, destination] = process.argv.slice(2) + if (!version || !destination) { + throw new Error('Provide the release version and output file.') + } + await writeFile(destination, await generateReleaseNotes(version)) +}