Skip to content

Commit e693f9d

Browse files
authored
ci: generate complete GitHub release notes (#428)
1 parent 9dae708 commit e693f9d

4 files changed

Lines changed: 278 additions & 9 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ jobs:
9696
}
9797
NODE
9898
99+
- name: Generate release notes
100+
run: node scripts/generate-release-notes.ts "$RELEASE_VERSION" "$RUNNER_TEMP/release-notes.md"
101+
env:
102+
GH_TOKEN: ${{ github.token }}
103+
RELEASE_VERSION: ${{ inputs.version }}
104+
99105
- name: Publish prepared release
100106
run: ./scripts/release.sh --publish-prepared "$RELEASE_VERSION"
101107
env:
@@ -110,14 +116,7 @@ jobs:
110116
if [[ "${version%%.*}" == 9 ]]; then
111117
latest=false
112118
fi
113-
notes="docs/releases/v${version}.md"
114-
args=("v${version}" --target "$(git rev-parse HEAD)" --title "NitroSQLite ${version}" --latest="$latest")
115-
if [ -f "$notes" ]; then
116-
args+=(--notes-file "$notes")
117-
else
118-
args+=(--generate-notes)
119-
fi
120-
gh release create "${args[@]}"
119+
gh release create "v${version}" --target "$(git rev-parse HEAD)" --title "NitroSQLite ${version}" --latest="$latest" --notes-file "$RUNNER_TEMP/release-notes.md"
121120
env:
122121
GH_TOKEN: ${{ github.token }}
123122
RELEASE_VERSION: ${{ inputs.version }}

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"clean": "rm -rf **/.tsbuild **/tsconfig.tsbuildinfo node_modules packages/react-native-nitro-sqlite/node_module packages/react-native-nitro-sqlite/lib",
2929
"check:lockfile": "bun scripts/check-bun-lockfile.ts",
3030
"sync:lockfile": "bun scripts/sync-bun-lockfile.ts",
31-
"test:release": "bun test scripts/sync-bun-lockfile.test.ts && node --test scripts/release.test.ts",
31+
"test:release": "bun test scripts/sync-bun-lockfile.test.ts && node --test scripts/release.test.ts scripts/generate-release-notes.test.ts",
3232
"release": "./scripts/release.sh",
3333
"sqlite": "bun --cwd packages/react-native-nitro-sqlite",
3434
"example": "bun --cwd example",
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import assert from 'node:assert/strict'
2+
import { execFileSync } from 'node:child_process'
3+
import {
4+
mkdtempSync,
5+
mkdirSync,
6+
readFileSync,
7+
rmSync,
8+
writeFileSync,
9+
} from 'node:fs'
10+
import { tmpdir } from 'node:os'
11+
import { join } from 'node:path'
12+
import { test } from 'node:test'
13+
import { fileURLToPath } from 'node:url'
14+
15+
type Configuration = {
16+
'repository': { type: string; url: string }
17+
'release-it': {
18+
plugins: {
19+
'@release-it/conventional-changelog': {
20+
preset: { types: { type: string; section: string }[] }
21+
}
22+
}
23+
}
24+
}
25+
26+
const script = fileURLToPath(
27+
new URL('./generate-release-notes.ts', import.meta.url),
28+
)
29+
const configuration = JSON.parse(
30+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
31+
) as Configuration
32+
33+
test('combines the announcement, configured changelog, and new contributors from the selected release branch', () => {
34+
const githubNotes =
35+
"## 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"
36+
withFixture(githubNotes, (directory) => {
37+
const notes = generate(directory)
38+
assert.match(notes, /^Custom announcement\.\n\n---\n\n/)
39+
const fixSection = configuration['release-it'].plugins[
40+
'@release-it/conventional-changelog'
41+
].preset.types.find((entry) => entry.type === 'fix')?.section
42+
assert.ok(fixSection)
43+
assert.ok(notes.includes(fixSection))
44+
assert.match(notes, /repair runtime connections/)
45+
assert.match(notes, /v1\.0\.0\.\.\.v1\.0\.1/)
46+
assert.match(
47+
notes,
48+
/## New Contributors\n\* @newcomer made their first contribution in https:\/\/github\.com\/margelo\/react-native-nitro-sqlite\/pull\/1/,
49+
)
50+
assert.doesNotMatch(
51+
notes,
52+
/main only feature|initial release fixture|unrelated generated list|contributor-section-end/,
53+
)
54+
})
55+
})
56+
57+
test('regenerates a published release from its previous tag and states when no new contributors are detected', () => {
58+
withFixture("## What's Changed\n* existing contributor", (directory) => {
59+
git(directory, 'tag', 'v1.0.1')
60+
const notes = generate(directory)
61+
assert.match(notes, /repair runtime connections/)
62+
assert.match(notes, /v1\.0\.0\.\.\.v1\.0\.1/)
63+
assert.match(
64+
notes,
65+
/## New Contributors\n\nNo new contributors were detected for this release\./,
66+
)
67+
})
68+
})
69+
70+
test('rejects a release increment before generating notes', () => {
71+
withFixture('', (directory) => {
72+
assert.throws(
73+
() => generate(directory, 'patch'),
74+
/An exact stable release version is required/,
75+
)
76+
})
77+
})
78+
79+
function generate(directory: string, version = '1.0.1'): string {
80+
const destination = join(directory, 'notes.md')
81+
execFileSync(process.execPath, [script, version, destination], {
82+
cwd: directory,
83+
env: {
84+
...process.env,
85+
PATH: `${join(directory, 'bin')}:${process.env.PATH}`,
86+
},
87+
stdio: 'pipe',
88+
})
89+
return readFileSync(destination, 'utf8')
90+
}
91+
92+
function withFixture(body: string, run: (directory: string) => void): void {
93+
const directory = mkdtempSync(join(tmpdir(), 'nitrosqlite-release-notes-'))
94+
try {
95+
git(directory, 'init', '-b', 'main')
96+
git(directory, 'config', 'user.name', 'Christoph Pader')
97+
git(directory, 'config', 'user.email', 'release-test@example.invalid')
98+
// Fixture commits stay inside this temporary repository and need no signing key in CI.
99+
git(directory, 'config', 'commit.gpgsign', 'false')
100+
writeFileSync(
101+
join(directory, 'package.json'),
102+
JSON.stringify({
103+
'name': 'release-notes-fixture',
104+
'version': '1.0.1',
105+
'repository': configuration.repository,
106+
'release-it': configuration['release-it'],
107+
}),
108+
)
109+
mkdirSync(join(directory, 'docs/releases'), { recursive: true })
110+
writeFileSync(
111+
join(directory, 'docs/releases/v1.0.1.md'),
112+
'Custom announcement.\n',
113+
)
114+
git(directory, 'add', '.')
115+
git(directory, 'commit', '-m', 'chore: initial release fixture')
116+
git(directory, 'tag', 'v1.0.0')
117+
git(directory, 'branch', 'release/1.0')
118+
git(directory, 'commit', '--allow-empty', '-m', 'feat: main only feature')
119+
git(directory, 'switch', 'release/1.0')
120+
git(
121+
directory,
122+
'commit',
123+
'--allow-empty',
124+
'-m',
125+
'fix: repair runtime connections',
126+
)
127+
mkdirSync(join(directory, 'bin'))
128+
writeFileSync(
129+
join(directory, 'bin/gh'),
130+
`#!/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`,
131+
{ mode: 0o755 },
132+
)
133+
run(directory)
134+
} finally {
135+
rmSync(directory, { recursive: true, force: true })
136+
}
137+
}
138+
139+
function git(directory: string, ...args: string[]): void {
140+
execFileSync('git', args, { cwd: directory, stdio: 'pipe' })
141+
}

‎scripts/generate-release-notes.ts‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { execFileSync } from 'node:child_process'
2+
import { readFile, writeFile } from 'node:fs/promises'
3+
import { createRequire } from 'node:module'
4+
import { resolve } from 'node:path'
5+
import type { Readable } from 'node:stream'
6+
import { fileURLToPath } from 'node:url'
7+
8+
type RootPackage = {
9+
'release-it'?: {
10+
plugins?: Record<string, Record<string, unknown>>
11+
}
12+
}
13+
14+
type ChangelogFactory = (
15+
options: Record<string, unknown>,
16+
context: Record<string, string>,
17+
commits: Record<string, string>,
18+
) => Readable
19+
20+
/** Build announcement text, the configured commit changelog, and contributor acknowledgements.
21+
* The range ends at the selected commit and starts at its previous reachable release tag.
22+
* Throws if custom notes, the changelog configuration, or GitHub contributor detection are unavailable.
23+
* @param version Exact stable version being released.
24+
* @returns Complete Markdown announcement, changelog, and new contributor section.
25+
*/
26+
export async function generateReleaseNotes(version: string): Promise<string> {
27+
if (!/^\d+\.\d+\.\d+$/.test(version)) {
28+
throw new Error('An exact stable release version is required.')
29+
}
30+
31+
const releaseCommit = git('rev-parse', 'HEAD')
32+
const previousTag = git(
33+
'describe',
34+
'--tags',
35+
'--match',
36+
'v[0-9]*',
37+
'--abbrev=0',
38+
`${releaseCommit}^`,
39+
)
40+
const currentTag = `v${version}`
41+
const announcement = (
42+
await readFile(`docs/releases/${currentTag}.md`, 'utf8')
43+
).trim()
44+
if (!announcement) throw new Error('Custom release notes must not be empty.')
45+
46+
const metadata = JSON.parse(
47+
await readFile('package.json', 'utf8'),
48+
) as RootPackage
49+
const options =
50+
metadata['release-it']?.plugins?.['@release-it/conventional-changelog']
51+
if (!options?.preset) {
52+
throw new Error('The release pipeline must configure a changelog preset.')
53+
}
54+
55+
const require = createRequire(import.meta.url)
56+
const pluginRequire = createRequire(
57+
require.resolve('@release-it/conventional-changelog/package.json'),
58+
)
59+
const changelogFactory = pluginRequire(
60+
'conventional-changelog',
61+
) as ChangelogFactory
62+
const stream = changelogFactory(
63+
{ ...options, releaseCount: 1, tagPrefix: 'v' },
64+
{ version, previousTag, currentTag },
65+
{ from: previousTag, to: releaseCommit },
66+
)
67+
let changelog = ''
68+
for await (const chunk of stream) changelog += String(chunk)
69+
if (!changelog.trim())
70+
throw new Error('The release changelog must not be empty.')
71+
72+
const githubNotes = JSON.parse(
73+
execFileSync(
74+
'gh',
75+
[
76+
'api',
77+
'repos/{owner}/{repo}/releases/generate-notes',
78+
'--method',
79+
'POST',
80+
'-f',
81+
`tag_name=${currentTag}`,
82+
'-f',
83+
`target_commitish=${releaseCommit}`,
84+
'-f',
85+
`previous_tag_name=${previousTag}`,
86+
],
87+
{ encoding: 'utf8' },
88+
),
89+
) as { body?: string }
90+
if (typeof githubNotes.body !== 'string') {
91+
throw new Error('GitHub did not return generated release notes.')
92+
}
93+
94+
return `${announcement}\n\n---\n\n${changelog.trim()}\n\n${contributorsSection(githubNotes.body)}\n`
95+
}
96+
97+
function contributorsSection(notes: string): string {
98+
const lines = notes.split('\n')
99+
const start = lines.findIndex((line) => /^## New Contributors\s*$/.test(line))
100+
if (start === -1) {
101+
return '## New Contributors\n\nNo new contributors were detected for this release.'
102+
}
103+
104+
const remaining = lines.slice(start + 1)
105+
const end = remaining.findIndex((line) =>
106+
/^#{1,2} |^\*\*Full Changelog\*\*/.test(line),
107+
)
108+
return [
109+
'## New Contributors',
110+
...remaining.slice(0, end === -1 ? undefined : end),
111+
]
112+
.join('\n')
113+
.trim()
114+
}
115+
116+
function git(...args: string[]): string {
117+
return execFileSync('git', args, { encoding: 'utf8' }).trim()
118+
}
119+
120+
if (
121+
process.argv[1] &&
122+
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
123+
) {
124+
const [version, destination] = process.argv.slice(2)
125+
if (!version || !destination) {
126+
throw new Error('Provide the release version and output file.')
127+
}
128+
await writeFile(destination, await generateReleaseNotes(version))
129+
}

0 commit comments

Comments
 (0)