feat(release): add contributor attribution to release notes (#114)

This commit is contained in:
2026-06-06 01:07:47 -07:00
committed by GitHub
parent f2fd58cd2b
commit ee89b0c8a9
4 changed files with 255 additions and 1 deletions
@@ -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.
+1
View File
@@ -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 `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- `release/release-notes.md` (and `release/prerelease-notes.md`) end with GitHub-style attribution: a `## Whats Changed` list crediting each released fragment as `by @<author> in #<pr>`, 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/<sha>/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 <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.
+79
View File
@@ -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, /## Whats 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, /Whats Changed/);
assert.doesNotMatch(changelog, /New Contributors/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('reuse-existing-section');
+171 -1
View File
@@ -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<number, Contribution>();
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<string, number | null>();
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[] = ['## Whats 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,
});
}