Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
141 changes: 141 additions & 0 deletions scripts/generate-release-notes.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
}
129 changes: 129 additions & 0 deletions scripts/generate-release-notes.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>>
}
}

type ChangelogFactory = (
options: Record<string, unknown>,
context: Record<string, string>,
commits: Record<string, string>,
) => 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<string> {
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))
}
Loading