From 1c284cd4ef7a55887c63fafb4e45514c893e204e Mon Sep 17 00:00:00 2001 From: "immich-push-o-matic[bot]" <179150890+immich-push-o-matic[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:29:42 +0200 Subject: [PATCH] feat: custom release notes (#32191) feat: custom release notes (#32172) (cherry picked from commit b620efba388bf4a85081e1d07fc86f80d4d4b83c) Co-authored-by: Jason Rasmussen --- .github/workflows/draft-release.yml | 24 +- mise.toml | 7 + packages/scripts/package.json | 3 +- packages/scripts/src/cli.ts | 30 ++ packages/scripts/src/commands/notes.spec.ts | 243 +++++++++++++++ packages/scripts/src/commands/notes.ts | 326 ++++++++++++++++++++ packages/scripts/src/types.ts | 2 + pnpm-lock.yaml | 3 + 8 files changed, 631 insertions(+), 7 deletions(-) create mode 100644 packages/scripts/src/commands/notes.spec.ts create mode 100644 packages/scripts/src/commands/notes.ts diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml index a1193ad97..4158875e2 100644 --- a/.github/workflows/draft-release.yml +++ b/.github/workflows/draft-release.yml @@ -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 \ diff --git a/mise.toml b/mise.toml index a52006fe7..131df71e4 100644 --- a/mise.toml +++ b/mise.toml @@ -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", diff --git a/packages/scripts/package.json b/packages/scripts/package.json index a92375b38..ea428ae05 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -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", diff --git a/packages/scripts/src/cli.ts b/packages/scripts/src/cli.ts index 95e50e977..b8e71d7f9 100644 --- a/packages/scripts/src/cli.ts +++ b/packages/scripts/src/cli.ts @@ -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 ', 'the tag being released') + .requiredOption('-p, --previous ', 'the previously released tag') + .addOption( + new Option('--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); }; diff --git a/packages/scripts/src/commands/notes.spec.ts b/packages/scripts/src/commands/notes.spec.ts new file mode 100644 index 000000000..fdc9b2c1d --- /dev/null +++ b/packages/scripts/src/commands/notes.spec.ts @@ -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 & { + 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>; + let github: Mocked>; + let sut: NotesService; + + const setup = (fixtures: Fixture[], firstTimers: Contributor[] = []) => { + const pulls = new Map(); + 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(); + 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 + " + `); + }); +}); diff --git a/packages/scripts/src/commands/notes.ts b/packages/scripts/src/commands/notes.ts new file mode 100644 index 000000000..dfc7e6fd7 --- /dev/null +++ b/packages/scripts/src/commands/notes.ts @@ -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 = { + 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(); + 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; + }>(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>( + `query { ${fields} }`, + ); + + for (const [index, candidate] of batch.entries()) { + if (data[`c_${index}`].issueCount === 0) { + firstTimers.push(candidate); + } + } + } + + return firstTimers; + } + + private chunk(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(query: string, variables?: Record) { + 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; + if (!data) { + throw new Error( + `GitHub GraphQL request failed: ${errors?.map(({ message }) => message).join(', ')}`, + ); + } + + return data; + } +} + +export class NotesService { + constructor( + private git: Public, + private github: Public, + ) {} + + 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) { + 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(); + + 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(items: T[]) { + return [...new Set(items)]; + } +} diff --git a/packages/scripts/src/types.ts b/packages/scripts/src/types.ts index 586fdb876..88866a86f 100644 --- a/packages/scripts/src/types.ts +++ b/packages/scripts/src/types.ts @@ -41,6 +41,8 @@ export class JsonFile { } } +export type Public = Pick; + export class ReleaseInputError extends Error {} export class ReleaseError extends Error { version: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 352165b55..d9589de5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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