feat: custom release notes (#32172)

This commit is contained in:
Jason Rasmussen
2026-10-07 13:27:29 -04:00
committed by GitHub
parent 26b8e3d400
commit b620efba38
8 changed files with 631 additions and 7 deletions
+18 -6
View File
@@ -136,13 +136,16 @@ jobs:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.generate-token.outputs.token }}
- name: Generate release notes
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
REPO: ${{ github.repository }}
TAG: ${{ needs.tag.outputs.version }}
PRERELEASE: ${{ needs.tag.outputs.rc }}
BRANCH: ${{ github.ref_name }}
run: |
tags=$(git -c versionsort.suffix=-rc tag --list 'v[0-9]*' --sort=-v:refname)
if [[ "${PRERELEASE}" != 'true' ]]; then
@@ -151,15 +154,24 @@ jobs:
previous=$(grep -A1 -xF "${TAG}" <<< "${tags}" | tail -1)
echo "generating notes from ${previous}"
mise //:notes --tag "${TAG}" --previous "${previous}" > "${RUNNER_TEMP}/notes.md"
cat "${RUNNER_TEMP}/notes.md"
- name: Create draft release
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
REPO: ${{ github.repository }}
TAG: ${{ needs.tag.outputs.version }}
PRERELEASE: ${{ needs.tag.outputs.rc }}
BRANCH: ${{ github.ref_name }}
run: |
gh release create "${TAG}" \
--repo "${REPO}" \
--target "${BRANCH}" \
--draft \
--verify-tag \
--prerelease="${PRERELEASE}" \
--notes-file misc/release/notes.tmpl \
--generate-notes \
--notes-start-tag "${previous}" \
--notes-file "${RUNNER_TEMP}/notes.md" \
docker/docker-compose.yml \
docker/docker-compose.rootless.yml \
docker/example.env \
+7
View File
@@ -47,6 +47,13 @@ run = [
{ task = "//:open-api" },
]
[tasks.notes]
run = [
"pnpm --filter @immich/scripts install --frozen-lockfile",
"pnpm --filter @immich/scripts build",
"pnpm --filter @immich/scripts cli notes {{arg(name='args', var=true)}}",
]
[tasks.plugins]
run = [
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
+2 -1
View File
@@ -24,7 +24,8 @@
},
"dependencies": {
"commander": "^15.0.0",
"semver": "^7.8.1"
"semver": "^7.8.1",
"yaml": "^2.9.1"
},
"devDependencies": {
"@types/node": "^24.13.6",
+30
View File
@@ -1,4 +1,10 @@
import { Command, Option } from 'commander';
import {
GitClient,
GitHubClient,
type NotesOptions,
NotesService,
} from './commands/notes';
import { handleRelease } from './commands/release';
import {
RELEASE_TYPES,
@@ -43,5 +49,29 @@ export const cli = (argv: string[]) => {
}
});
program
.command('notes')
.description('generate release notes for a tag')
.requiredOption('-t, --tag <tag>', 'the tag being released')
.requiredOption('-p, --previous <tag>', 'the previously released tag')
.addOption(
new Option('--token <token>', 'a GitHub token')
.env('GH_TOKEN')
.default(process.env.GITHUB_TOKEN, 'GITHUB_TOKEN')
.makeOptionMandatory(),
)
.action(async ({ token, ...options }: NotesOptions & { token: string }) => {
try {
const service = new NotesService(
new GitClient(),
new GitHubClient(token),
);
process.stdout.write(await service.handleNotes(options));
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
return program.parse(argv);
};
+243
View File
@@ -0,0 +1,243 @@
import type { Public } from 'src/types.js';
import { beforeEach, describe, expect, it, type Mocked, vi } from 'vitest';
import {
type Contributor,
type GitClient,
type GitHubClient,
NotesService,
type PullRequest,
} from './notes';
type Fixture = Partial<PullRequest> & {
number: number;
original?: number;
published?: boolean;
commit?: boolean;
known?: boolean;
};
const alice = { login: 'alice', bot: false };
const bob = { login: 'bob', bot: false };
const bot = { login: 'immich-push-o-matic', bot: true };
const link = (number: number) =>
`https://github.com/immich-app/immich/pull/${number}`;
const fix = (number: number, author = alice) =>
`* fix: change ${number} by @${author.login}${author.bot ? '[bot]' : ''} in ${link(number)}`;
const changelog = (...lines: string[]) =>
[
"## What's Changed",
...lines,
'',
'**Full Changelog**: https://github.com/immich-app/immich/compare/v3.2.4...v3.3.0',
'',
].join('\n');
const body = (notes: string) => notes.slice(notes.indexOf("## What's Changed"));
describe(NotesService.name, () => {
let git: Mocked<Public<GitClient>>;
let github: Mocked<Public<GitHubClient>>;
let sut: NotesService;
const setup = (fixtures: Fixture[], firstTimers: Contributor[] = []) => {
const pulls = new Map<number, PullRequest>();
const log = ['> chore: version v3.3.0'];
for (const {
original,
published,
commit = true,
known = true,
...overrides
} of fixtures) {
const pull: PullRequest = {
title: `fix: change ${original ?? overrides.number}`,
body: original ? `Backport of #${original} to \`release/v3.2\`.` : null,
mergedAt: `2026-01-${String(overrides.number).padStart(2, '0')}T00:00:00Z`,
author: original ? bot : alice,
labels: original
? ['changelog:bugfix', 'backport']
: ['changelog:bugfix'],
...overrides,
};
if (known) {
pulls.set(pull.number, pull);
}
if (commit) {
log.push(`${published ? '<' : '>'} ${pull.title} (#${pull.number})`);
}
}
log.push('< chore: version v3.2.4');
git.log.mockReturnValue(log.join('\n') + '\n');
github.getPullRequests.mockImplementation(async (numbers) => {
const found = new Map<number, PullRequest>();
for (const number of numbers) {
const pull = pulls.get(number);
if (pull) {
found.set(number, pull);
}
}
return found;
});
github.getFirstTimers.mockResolvedValue(firstTimers);
};
const run = () => sut.handleNotes({ tag: 'v3.3.0', previous: 'v3.2.4' });
beforeEach(() => {
git = { log: vi.fn() };
github = { getPullRequests: vi.fn(), getFirstTimers: vi.fn() };
sut = new NotesService(git, github);
});
it('should compare the previous tag with the new tag', async () => {
setup([{ number: 1 }]);
await run();
expect(git.log).toHaveBeenCalledWith('v3.2.4', 'v3.3.0');
});
it('should start with the template and end with the full changelog', async () => {
setup([{ number: 1 }]);
const notes = await run();
expect(notes.startsWith('## Highlights')).toBe(true);
expect(body(notes)).toMatchInlineSnapshot(`
"## What's Changed
### 🐛 Bug fixes
* fix: change 1 by @alice in https://github.com/immich-app/immich/pull/1
**Full Changelog**: https://github.com/immich-app/immich/compare/v3.2.4...v3.3.0
"
`);
});
it('should attribute a backport to the original pull request', async () => {
setup([
{ number: 2, original: 1 },
{ number: 1, commit: false },
]);
const notes = await run();
expect(github.getPullRequests).toHaveBeenNthCalledWith(1, [2]);
expect(github.getPullRequests).toHaveBeenNthCalledWith(2, [1]);
expect(body(notes)).toBe(changelog('### 🐛 Bug fixes', fix(1)));
});
it('should fall back to the backport when the body lacks a reference', async () => {
setup([
{ number: 2, original: 1, body: '* BACKPORT-CONFLICT' },
{ number: 1, commit: false },
]);
const notes = await run();
expect(body(notes)).toBe(
changelog(
'### 🐛 Bug fixes',
`* fix: change 1 by @immich-push-o-matic[bot] in ${link(2)}`,
),
);
});
it('should drop main changes already published through a backport on the previous line', async () => {
setup([
{ number: 3 },
{ number: 1 },
{ number: 2, original: 1, published: true },
]);
const notes = await run();
expect(body(notes)).toBe(changelog('### 🐛 Bug fixes', fix(3)));
});
it('should drop changes backported to both lines when the previous line shipped first', async () => {
setup([
{ number: 3, original: 1 },
{ number: 4 },
{ number: 2, original: 1, published: true },
{ number: 1, commit: false },
]);
const notes = await run();
expect(body(notes)).toBe(changelog('### 🐛 Bug fixes', fix(4)));
});
it('should list each original once in merge order', async () => {
setup([{ number: 6, original: 1 }, { number: 5 }, { number: 1 }]);
const notes = await run();
expect(body(notes)).toBe(changelog('### 🐛 Bug fixes', fix(1), fix(5)));
});
it('should ignore commits without a known pull request', async () => {
setup([{ number: 1 }, { number: 99, known: false }]);
const notes = await run();
expect(body(notes)).toBe(changelog('### 🐛 Bug fixes', fix(1)));
});
it('should group by changelog label and omit unlisted labels', async () => {
setup([
{ number: 3, labels: ['🗄️server', 'changelog:bugfix'] },
{ number: 2, labels: ['changelog:skip'] },
{ number: 1, labels: ['changelog:feature'] },
]);
const notes = await run();
expect(body(notes)).toBe(
changelog('### 🚀 Features', fix(1), '### 🐛 Bug fixes', fix(3)),
);
});
it('should look up first contributions using the earliest change per human author', async () => {
setup([
{ number: 4, author: bob },
{ number: 3 },
{ number: 2 },
{ number: 1, author: bot },
]);
await run();
expect(github.getFirstTimers).toHaveBeenCalledWith([
{ author: alice, number: 2, mergedAt: '2026-01-02T00:00:00Z' },
{ author: bob, number: 4, mergedAt: '2026-01-04T00:00:00Z' },
]);
});
it('should render new contributors', async () => {
setup(
[{ number: 1 }],
[{ author: alice, number: 1, mergedAt: '2026-01-01T00:00:00Z' }],
);
const notes = await run();
expect(body(notes)).toMatchInlineSnapshot(`
"## What's Changed
### 🐛 Bug fixes
* fix: change 1 by @alice in https://github.com/immich-app/immich/pull/1
## New Contributors
* @alice made their first contribution in https://github.com/immich-app/immich/pull/1
**Full Changelog**: https://github.com/immich-app/immich/compare/v3.2.4...v3.3.0
"
`);
});
});
+326
View File
@@ -0,0 +1,326 @@
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Public } from 'src/types.js';
import { parse } from 'yaml';
const findRoot = (dir: string): string =>
existsSync(join(dir, 'pnpm-workspace.yaml')) ? dir : findRoot(dirname(dir));
const root = findRoot(dirname(fileURLToPath(import.meta.url)));
const REPO = 'immich-app/immich';
const Files = {
Config: join(root, '.github/release.yml'),
Template: join(root, 'misc/release/notes.tmpl'),
};
export type NotesOptions = {
tag: string;
previous: string;
};
type Side = 'left' | 'right';
type Commit = { side: Side; number: number };
type Author = { login: string; bot: boolean };
export type PullRequest = {
number: number;
title: string;
body: string | null;
mergedAt: string;
author: Author;
labels: string[];
};
type Category = { title: string; labels: string[] };
export type Contributor = { author: Author; number: number; mergedAt: string };
type GraphQlResponse<T> = {
data?: T;
errors?: Array<{ message: string }>;
};
type PullRequestNode = {
number: number;
title: string;
body: string | null;
mergedAt: string;
author: { __typename: string; login: string } | null;
labels: { nodes: Array<{ name: string }> };
};
const PULL_REQUEST_BATCH = 50;
const SEARCH_BATCH = 20;
export class GitClient {
log(previous: string, tag: string) {
return execFileSync(
'git',
[
'log',
'--left-right',
'--no-merges',
'--format=%m %s',
`${previous}...${tag}`,
],
{ cwd: root, encoding: 'utf8' },
);
}
}
export class GitHubClient {
constructor(private token: string) {}
async getPullRequests(numbers: number[]) {
const pulls = new Map<number, PullRequest>();
const [owner, name] = REPO.split('/');
for (const batch of this.chunk(numbers, PULL_REQUEST_BATCH)) {
const fields = batch
.map(
(number) =>
`pr_${number}: pullRequest(number: ${number}) { ...PullRequestFields }`,
)
.join('\n');
const query = `
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) { ${fields} }
}
fragment PullRequestFields on PullRequest {
number
title
body
mergedAt
author { __typename login }
labels(first: 100) { nodes { name } }
}`;
const data = await this.graphql<{
repository: Record<string, PullRequestNode | null>;
}>(query, { owner, name });
for (const node of Object.values(data.repository)) {
if (!node?.mergedAt) {
continue;
}
pulls.set(node.number, {
number: node.number,
title: node.title,
body: node.body,
mergedAt: node.mergedAt,
author: {
login: node.author?.login ?? 'ghost',
bot: node.author?.__typename === 'Bot',
},
labels: node.labels.nodes.map(({ name }) => name),
});
}
}
return pulls;
}
async getFirstTimers(candidates: Contributor[]) {
const firstTimers: Contributor[] = [];
for (const batch of this.chunk(candidates, SEARCH_BATCH)) {
const fields = batch
.map(({ author, mergedAt }, index) => {
const search = JSON.stringify(
`repo:${REPO} is:pr is:merged author:${author.login} merged:<${mergedAt}`,
);
return `c_${index}: search(query: ${search}, type: ISSUE, first: 1) { issueCount }`;
})
.join('\n');
const data = await this.graphql<Record<string, { issueCount: number }>>(
`query { ${fields} }`,
);
for (const [index, candidate] of batch.entries()) {
if (data[`c_${index}`].issueCount === 0) {
firstTimers.push(candidate);
}
}
}
return firstTimers;
}
private chunk<T>(items: T[], size: number) {
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
}
private async graphql<T>(query: string, variables?: Record<string, string>) {
const response = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(
`GitHub GraphQL request failed: ${response.status} ${response.statusText}`,
);
}
const { data, errors } = (await response.json()) as GraphQlResponse<T>;
if (!data) {
throw new Error(
`GitHub GraphQL request failed: ${errors?.map(({ message }) => message).join(', ')}`,
);
}
return data;
}
}
export class NotesService {
constructor(
private git: Public<GitClient>,
private github: Public<GitHubClient>,
) {}
async handleNotes({ tag, previous }: NotesOptions) {
const commits = this.parseCommits(this.git.log(previous, tag));
const pulls = await this.getPullRequests(commits);
const changes = this.selectChanges(commits, pulls);
const firstTimers = await this.getFirstTimers(changes);
return this.render({ tag, previous, changes, firstTimers });
}
private parseCommits(log: string): Commit[] {
return log.split('\n').flatMap((line) => {
const match = line.match(/^([<>]) .*\(#(\d+)\)$/);
return match
? {
side: match[1] === '<' ? 'left' : 'right',
number: Number(match[2]),
}
: [];
});
}
private async getPullRequests(commits: Commit[]) {
const pulls = await this.github.getPullRequests(
this.unique(commits.map(({ number }) => number)),
);
const originals = this.unique(
[...pulls.values()].map((pull) => this.toOriginal(pull)),
).filter((number) => !pulls.has(number));
for (const [number, pull] of await this.github.getPullRequests(originals)) {
pulls.set(number, pull);
}
return pulls;
}
private toOriginal(pull: PullRequest) {
const match = pull.body?.match(/^Backport of #(\d+) to `release\//);
return match ? Number(match[1]) : pull.number;
}
private selectChanges(commits: Commit[], pulls: Map<number, PullRequest>) {
const originals = (side: Side) =>
commits
.filter((commit) => commit.side === side)
.flatMap((commit) => pulls.get(commit.number) ?? [])
.map((pull) => this.toOriginal(pull));
const published = new Set(originals('left'));
return this.unique(originals('right'))
.filter((number) => !published.has(number))
.flatMap((number) => pulls.get(number) ?? [])
.sort((a, b) => a.mergedAt.localeCompare(b.mergedAt));
}
private getFirstTimers(changes: PullRequest[]) {
const candidates = new Map<string, Contributor>();
for (const { author, number, mergedAt } of changes) {
if (!author.bot && !candidates.has(author.login)) {
candidates.set(author.login, { author, number, mergedAt });
}
}
return this.github.getFirstTimers([...candidates.values()]);
}
private render({
tag,
previous,
changes,
firstTimers,
}: NotesOptions & { changes: PullRequest[]; firstTimers: Contributor[] }) {
const { changelog } = parse(readFileSync(Files.Config, 'utf8')) as {
changelog: { categories: Category[] };
};
const sections = [
readFileSync(Files.Template, 'utf8').trim(),
[
"## What's Changed",
...changelog.categories.flatMap(({ title, labels }) => {
const matches = changes.filter((pull) =>
pull.labels.some((label) => labels.includes(label)),
);
return matches.length === 0
? []
: [
`### ${title}`,
...matches.map(
({ title, author, number }) =>
`* ${title} by ${this.mention(author)} in ${this.link(number)}`,
),
];
}),
].join('\n'),
];
if (firstTimers.length > 0) {
sections.push(
[
'## New Contributors',
...firstTimers.map(
({ author, number }) =>
`* ${this.mention(author)} made their first contribution in ${this.link(number)}`,
),
].join('\n'),
);
}
sections.push(
`**Full Changelog**: https://github.com/${REPO}/compare/${previous}...${tag}`,
);
return sections.join('\n\n') + '\n';
}
private link(number: number) {
return `https://github.com/${REPO}/pull/${number}`;
}
private mention({ login, bot }: Author) {
return `@${login}${bot ? '[bot]' : ''}`;
}
private unique<T>(items: T[]) {
return [...new Set(items)];
}
}
+2
View File
@@ -41,6 +41,8 @@ export class JsonFile<T extends object> {
}
}
export type Public<T extends object> = Pick<T, keyof T>;
export class ReleaseInputError extends Error {}
export class ReleaseError extends Error {
version: string;
+3
View File
@@ -372,6 +372,9 @@ importers:
semver:
specifier: ^7.8.1
version: 7.8.5
yaml:
specifier: ^2.9.1
version: 2.9.1
devDependencies:
'@types/node':
specifier: ^24.13.6