From ee89b0c8a9099ab15ceec64b854c23cbd2242e7f Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 6 Jun 2026 01:07:47 -0700 Subject: [PATCH] feat(release): add contributor attribution to release notes (#114) --- .../2026-06-06-release-notes-contributors.md | 4 + docs/RELEASING.md | 1 + scripts/build-changelog.test.ts | 79 ++++++++ scripts/build-changelog.ts | 172 +++++++++++++++++- 4 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 changes/2026-06-06-release-notes-contributors.md diff --git a/changes/2026-06-06-release-notes-contributors.md b/changes/2026-06-06-release-notes-contributors.md new file mode 100644 index 00000000..3f632fd9 --- /dev/null +++ b/changes/2026-06-06-release-notes-contributors.md @@ -0,0 +1,4 @@ +type: added +area: release + +- Release notes now credit contributors with a `What's Changed` list (`by @author in #pr`) and a `New Contributors` section for first-time authors, resolved from changelog fragments via git and the GitHub API. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 95aed8a3..40ed7b3c 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -77,6 +77,7 @@ Notes: - `changelog:check` now rejects tag/package version mismatches. - `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over. - `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `
Internal changes` collapse; the release notes drop them entirely. +- `release/release-notes.md` (and `release/prerelease-notes.md`) end with GitHub-style attribution: a `## What’s Changed` list crediting each released fragment as `by @ in #`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits//pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free. - The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version ` locally, commit the polished output, then tag. - Do not tag while `changes/*.md` fragments still exist. - Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts. diff --git a/scripts/build-changelog.test.ts b/scripts/build-changelog.test.ts index 995ed157..fa7359ca 100644 --- a/scripts/build-changelog.test.ts +++ b/scripts/build-changelog.test.ts @@ -1065,6 +1065,85 @@ test('writeChangelogArtifacts filters internal fragments from the release-notes } }); +test('writeChangelogArtifacts appends contributor attribution and a new-contributors section to release notes', async () => { + const { writeChangelogArtifacts } = await loadModule(); + const workspace = createWorkspace('release-notes-contributors'); + const projectRoot = path.join(workspace, 'SubMiner'); + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: added', 'area: overlay', '', '- Added a feature.'].join('\n'), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', '002.md'), + ['type: fixed', 'area: jellyfin', '', '- Fixed a bug.'].join('\n'), + 'utf8', + ); + + try { + const stub = defaultStubClaude(); + const resolveContributionsCalls: string[][] = []; + writeChangelogArtifacts({ + cwd: projectRoot, + version: '0.6.0', + date: '2026-05-06', + deps: { + runClaude: stub.runClaude, + resolveContributions: (fragmentPaths) => { + resolveContributionsCalls.push(fragmentPaths); + return [ + { + prNumber: 110, + login: 'ksyasuda', + title: 'feat(overlay): add a feature', + isFirstContribution: false, + }, + { + prNumber: 112, + login: 'bee-san', + title: 'fix(jellyfin): restart remote session', + isFirstContribution: true, + }, + ]; + }, + }, + }); + + assert.equal(resolveContributionsCalls.length, 1, 'resolves contributions once per release'); + assert.deepEqual(resolveContributionsCalls[0], [ + path.join(projectRoot, 'changes', '001.md'), + path.join(projectRoot, 'changes', '002.md'), + ]); + + const releaseNotes = fs.readFileSync( + path.join(projectRoot, 'release', 'release-notes.md'), + 'utf8', + ); + assert.match(releaseNotes, /## What’s Changed\n\n/); + assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/); + assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/); + assert.match( + releaseNotes, + /## New Contributors\n\n- @bee-san made their first contribution in #112/, + ); + assert.doesNotMatch( + releaseNotes, + /ksyasuda made their first contribution/, + 'returning contributors are not listed under New Contributors', + ); + + // Attribution is a release-notes concern only; the CHANGELOG stays clean. + const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8'); + assert.doesNotMatch(changelog, /What’s Changed/); + assert.doesNotMatch(changelog, /New Contributors/); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + test('writeChangelogArtifacts strips
blocks from release notes when reusing an existing CHANGELOG section', async () => { const { writeChangelogArtifacts } = await loadModule(); const workspace = createWorkspace('reuse-existing-section'); diff --git a/scripts/build-changelog.ts b/scripts/build-changelog.ts index 97b5a246..47dd800b 100644 --- a/scripts/build-changelog.ts +++ b/scripts/build-changelog.ts @@ -4,6 +4,20 @@ import { execFileSync } from 'node:child_process'; type RunClaude = (input: string, args: string[]) => string; +// A single PR's contribution, resolved from the fragment files released in this +// cycle. Used to append GitHub-style attribution to the release notes. +type Contribution = { + prNumber: number; + login: string; + title: string; + isFirstContribution: boolean; +}; + +// Resolves the contributions behind a set of changelog fragment paths. Injected +// in tests so we never hit git/gh; the default implementation walks git history +// and the GitHub API. +type ResolveContributions = (fragmentPaths: string[], cwd: string) => Contribution[]; + type ChangelogFsDeps = { existsSync?: (candidate: string) => boolean; mkdirSync?: (candidate: string, options: { recursive: true }) => void; @@ -13,6 +27,7 @@ type ChangelogFsDeps = { writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void; log?: (message: string) => void; runClaude?: RunClaude; + resolveContributions?: ResolveContributions; }; type PolishMode = 'changelog' | 'release-notes'; @@ -296,6 +311,152 @@ function defaultRunClaude(input: string, args: string[]): string { } } +function resolveFragmentRelativePath(fragmentPath: string, cwd: string): string { + return path.relative(cwd, fragmentPath).split(path.sep).join('/'); +} + +// Walks git history + the GitHub API to attribute each released fragment to the +// PR (and author) that introduced it. One git call and one gh call per fragment, +// plus one gh call per unique author for the first-contribution check. Best +// effort: if gh is unavailable/unauthenticated or any lookup fails, we warn and +// drop attribution rather than failing the release. +function defaultResolveContributions(fragmentPaths: string[], cwd: string): Contribution[] { + if (fragmentPaths.length === 0) { + return []; + } + + try { + const slug = execFileSync( + 'gh', + ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], + { + cwd, + encoding: 'utf8', + }, + ).trim(); + if (!slug) { + return []; + } + + const byPr = new Map(); + for (const fragmentPath of fragmentPaths) { + const relativePath = resolveFragmentRelativePath(fragmentPath, cwd); + // git log lists newest first, so the commit that *added* the file is the + // last line of the --diff-filter=A history. + const addingSha = execFileSync( + 'git', + ['log', '--diff-filter=A', '--follow', '--format=%H', '--', relativePath], + { cwd, encoding: 'utf8' }, + ) + .trim() + .split(/\r?\n/) + .filter(Boolean) + .pop(); + if (!addingSha) { + continue; + } + + const prRaw = execFileSync( + 'gh', + [ + 'api', + `repos/${slug}/commits/${addingSha}/pulls`, + '--jq', + '.[0] // empty | {number, login: .user.login, title}', + ], + { cwd, encoding: 'utf8' }, + ).trim(); + if (!prRaw) { + continue; + } + + const pr = JSON.parse(prRaw) as { number?: number; login?: string; title?: string }; + if (typeof pr.number !== 'number' || !pr.login || !pr.title) { + continue; + } + if (!byPr.has(pr.number)) { + byPr.set(pr.number, { + prNumber: pr.number, + login: pr.login, + title: pr.title, + isFirstContribution: false, + }); + } + } + + const firstPrByAuthor = new Map(); + for (const contribution of byPr.values()) { + if (!firstPrByAuthor.has(contribution.login)) { + const firstRaw = execFileSync( + 'gh', + [ + 'api', + '-X', + 'GET', + 'search/issues', + '-f', + `q=repo:${slug} is:pr is:merged author:${contribution.login}`, + '-f', + 'sort=created', + '-f', + 'order=asc', + '-f', + 'per_page=1', + '--jq', + '.items[0].number // empty', + ], + { cwd, encoding: 'utf8' }, + ).trim(); + firstPrByAuthor.set(contribution.login, firstRaw ? Number.parseInt(firstRaw, 10) : null); + } + const firstPr = firstPrByAuthor.get(contribution.login) ?? null; + contribution.isFirstContribution = firstPr !== null && firstPr === contribution.prNumber; + } + + return [...byPr.values()].sort((a, b) => a.prNumber - b.prNumber); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Skipping contributor attribution: ${message}`); + return []; + } +} + +function resolveContributionsForFragments( + fragments: ChangeFragment[], + cwd: string, + deps?: ChangelogFsDeps, +): Contribution[] { + const resolve = deps?.resolveContributions ?? defaultResolveContributions; + return resolve( + fragments.filter((fragment) => fragment.type !== 'internal').map((fragment) => fragment.path), + cwd, + ); +} + +function renderContributorsSections(contributions: Contribution[]): string[] { + if (contributions.length === 0) { + return []; + } + + const lines: string[] = ['## What’s Changed', '']; + for (const contribution of contributions) { + lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`); + } + + const firstTimers = contributions.filter((contribution) => contribution.isFirstContribution); + if (firstTimers.length > 0) { + lines.push('', '## New Contributors', ''); + for (const contribution of firstTimers) { + lines.push( + `- @${contribution.login} made their first contribution in #${contribution.prNumber}`, + ); + } + } + + lines.push(''); + return lines; +} + function serializeFragmentsForPrompt( fragments: ChangeFragment[], mode: PolishMode, @@ -473,6 +634,7 @@ function renderReleaseNotes( changes: string, options?: { disclaimer?: string; + contributions?: Contribution[]; }, ): string { const prefix = options?.disclaimer ? [options.disclaimer, ''] : []; @@ -494,6 +656,7 @@ function renderReleaseNotes( '', 'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.', '', + ...renderContributorsSections(options?.contributions ?? []), ].join('\n'); } @@ -504,6 +667,7 @@ function writeReleaseNotesFile( options?: { disclaimer?: string; outputPath?: string; + contributions?: Contribution[]; }, ): string { const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; @@ -530,6 +694,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): { const version = resolveVersion(options ?? {}); const date = resolveDate(options?.date); const fragments = readChangeFragments(cwd, options?.deps); + const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps); const existingChangelogPath = path.join(cwd, 'CHANGELOG.md'); const existingChangelog = existsSync(existingChangelogPath) ? readFileSync(existingChangelogPath, 'utf8') @@ -547,6 +712,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): { cwd, stripDetailsBlocks(existingReleaseSection), options?.deps, + { contributions }, ); log(`Generated ${releaseNotesPath}`); @@ -572,7 +738,9 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): { date, deps: options?.deps, }); - const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps); + const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps, { + contributions, + }); log(`Generated ${releaseNotesPath}`); for (const fragment of fragments) { @@ -833,10 +1001,12 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri existingReleaseNotes, deps: options?.deps, }); + const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps); return writeReleaseNotesFile(cwd, changes, options?.deps, { disclaimer: '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', outputPath: PRERELEASE_NOTES_PATH, + contributions, }); }