anthodev/lapin-futepublic Fork
⑂ 9e36c16
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/lapin-fute.git
git clone ssh://git@rickub.com/anthodev/lapin-fute.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

fix(release): handle squash merges when generating release notes

Cedric ANTHONY committed 2026-09-13T18:06:11+02:00 Browse files
9e36c16 parent: 932a713
modified .github/workflows/deploy-bunny.yml +2 -54
@@ -377,60 +377,8 @@ jobs:
377377 name: lapin-fute-${{ github.ref_name }}
378378 path: release-assets
379379
380- - name: Generate categorized release notes
381- shell: bash
382- run: |
383- set -euo pipefail
384- previous_tag="$(git describe --tags --match 'v[0-9]*' --abbrev=0 "$GITHUB_SHA^" 2>/dev/null || true)"
385-
386- if [[ -n "$previous_tag" ]]; then
387- range="$previous_tag..$GITHUB_SHA"
388- changelog_url="https://github.com/$GITHUB_REPOSITORY/compare/$previous_tag...$GITHUB_REF_NAME"
389- else
390- range="$GITHUB_SHA"
391- changelog_url="https://github.com/$GITHUB_REPOSITORY/commits/$GITHUB_REF_NAME"
392- fi
393-
394- RANGE="$range" CHANGELOG_URL="$changelog_url" node --input-type=module <<'EOF'
395- import { execFileSync } from "node:child_process";
396- import { writeFileSync } from "node:fs";
397-
398- const groups = [
399- { key: "feat", title: "Features", commits: [] },
400- { key: "change", title: "Changes", commits: [] },
401- { key: "fix", title: "Fixes", commits: [] },
402- { key: "ci", title: "CI", commits: [] },
403- ];
404- const byKey = new Map(groups.map((group) => [group.key, group]));
405- const fields = execFileSync(
406- "git",
407- ["log", "--first-parent", "-z", "--format=%s%x00%h", process.env.RANGE],
408- { encoding: "utf8" },
409- ).split("\0").filter(Boolean);
410-
411- for (let index = 0; index < fields.length; index += 2) {
412- const subject = fields[index].trim();
413- const hash = fields[index + 1].trim();
414- const type = subject.match(/^([a-z]+)(?:\([^)]*\))?!?:\s+/i)?.[1].toLowerCase();
415- const key = type === "feat"
416- ? "feat"
417- : type === "fix"
418- ? "fix"
419- : type === "ci"
420- ? "ci"
421- : "change";
422-
423- byKey.get(key).commits.push(`- ${subject} (\`${hash}\`)`);
424- }
425-
426- const notes = [];
427- for (const group of groups) {
428- if (group.commits.length === 0) continue;
429- notes.push(`## ${group.title}`, "", ...group.commits, "");
430- }
431- notes.push(`**Full changelog**: ${process.env.CHANGELOG_URL}`, "");
432- writeFileSync("release-notes.md", notes.join("\n"));
433- EOF
380+ - name: Generate release notes
381+ run: node scripts/generate-release-notes.mjs
434382
435383 - name: Create release with categorized notes
436384 env:
@@ -377,60 +377,8 @@ jobs:
377 name: lapin-fute-${{ github.ref_name }}377 name: lapin-fute-${{ github.ref_name }}
378 path: release-assets378 path: release-assets
379 379
380- - name: Generate categorized release notes380+ - name: Generate release notes
381- shell: bash381+ run: node scripts/generate-release-notes.mjs
382- run: |
383- set -euo pipefail
384- previous_tag="$(git describe --tags --match 'v[0-9]*' --abbrev=0 "$GITHUB_SHA^" 2>/dev/null || true)"
385-
386- if [[ -n "$previous_tag" ]]; then
387- range="$previous_tag..$GITHUB_SHA"
388- changelog_url="https://github.com/$GITHUB_REPOSITORY/compare/$previous_tag...$GITHUB_REF_NAME"
389- else
390- range="$GITHUB_SHA"
391- changelog_url="https://github.com/$GITHUB_REPOSITORY/commits/$GITHUB_REF_NAME"
392- fi
393-
394- RANGE="$range" CHANGELOG_URL="$changelog_url" node --input-type=module <<'EOF'
395- import { execFileSync } from "node:child_process";
396- import { writeFileSync } from "node:fs";
397-
398- const groups = [
399- { key: "feat", title: "Features", commits: [] },
400- { key: "change", title: "Changes", commits: [] },
401- { key: "fix", title: "Fixes", commits: [] },
402- { key: "ci", title: "CI", commits: [] },
403- ];
404- const byKey = new Map(groups.map((group) => [group.key, group]));
405- const fields = execFileSync(
406- "git",
407- ["log", "--first-parent", "-z", "--format=%s%x00%h", process.env.RANGE],
408- { encoding: "utf8" },
409- ).split("\0").filter(Boolean);
410-
411- for (let index = 0; index < fields.length; index += 2) {
412- const subject = fields[index].trim();
413- const hash = fields[index + 1].trim();
414- const type = subject.match(/^([a-z]+)(?:\([^)]*\))?!?:\s+/i)?.[1].toLowerCase();
415- const key = type === "feat"
416- ? "feat"
417- : type === "fix"
418- ? "fix"
419- : type === "ci"
420- ? "ci"
421- : "change";
422-
423- byKey.get(key).commits.push(`- ${subject} (\`${hash}\`)`);
424- }
425-
426- const notes = [];
427- for (const group of groups) {
428- if (group.commits.length === 0) continue;
429- notes.push(`## ${group.title}`, "", ...group.commits, "");
430- }
431- notes.push(`**Full changelog**: ${process.env.CHANGELOG_URL}`, "");
432- writeFileSync("release-notes.md", notes.join("\n"));
433- EOF
434 382
435 - name: Create release with categorized notes383 - name: Create release with categorized notes
436 env:384 env:
added packages/config-page/test/generate-release-notes.test.js +104 -0
new file mode 100644
@@ -0,0 +1,104 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { execFileSync } from "node:child_process";
4+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5+import { tmpdir } from "node:os";
6+import { join } from "node:path";
7+import { generateReleaseNotes } from "../../../scripts/generate-release-notes.mjs";
8+
9+function fixture(t) {
10+ const cwd = mkdtempSync(join(tmpdir(), "lapin-fute-release-notes-"));
11+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
12+ const git = (...args) => execFileSync("git", args, {
13+ cwd, encoding: "utf8",
14+ env: { ...process.env, GIT_AUTHOR_NAME: "Fixture", GIT_AUTHOR_EMAIL: "fixture@example.invalid", GIT_COMMITTER_NAME: "Fixture", GIT_COMMITTER_EMAIL: "fixture@example.invalid" },
15+ }).trim();
16+ git("init", "--quiet");
17+ function commit(subject, content, parents = [], filename = "app.txt") {
18+ writeFileSync(join(cwd, filename), content);
19+ git("add", "--", filename);
20+ const tree = git("write-tree");
21+ return git("commit-tree", tree, ...parents.flatMap((parent) => ["-p", parent]), "-m", subject);
22+ }
23+ const root = commit("chore: initialize", "root");
24+ return {
25+ git, commit, root,
26+ notes: (tag, revision = tag) => generateReleaseNotes({ cwd, tag, revision, repository: "example/app" }),
27+ };
28+}
29+
30+test("empty commit subjects do not consume the following categorized commit", (t) => {
31+ const f = fixture(t);
32+ f.git("tag", "v1.0.0", f.root);
33+ const fix = f.commit("fix: preserve this change", "fixed", [f.root]);
34+ f.git("update-ref", "HEAD", fix);
35+ f.git("-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null",
36+ "commit", "--quiet", "--allow-empty", "--allow-empty-message", "-m", "");
37+ const empty = f.git("rev-parse", "HEAD");
38+ f.git("tag", "v1.0.1", empty);
39+ const notes = f.notes("v1.0.1");
40+ assert.ok(notes.includes(`## Changes\n\n- (no commit subject) (\`${f.git("rev-parse", "--short", empty)}\`)`));
41+ assert.ok(notes.includes(`## Fixes\n\n- fix: preserve this change (\`${f.git("rev-parse", "--short", fix)}\`)`));
42+ assert.doesNotMatch(notes, /initialize/u);
43+});
44+
45+test("numeric stable predecessor excludes future, prerelease, build and malformed tags", (t) => {
46+ const f = fixture(t);
47+ const old = f.commit("feat: already shipped", "old", [f.root]);
48+ for (const tag of ["v1.9.9", "v1.10.0"]) f.git("tag", tag, old);
49+ const misleading = f.commit("fix: belongs in these notes", "intermediate", [old]);
50+ for (const tag of ["v1.10.1-rc.1", "v1.10.1+build", "v01.10.1", "v1.10.1junk", "v1.11.0", "v2.0.0"]) f.git("tag", tag, misleading);
51+ const current = f.commit("ci: release", "current", [misleading]);
52+ f.git("tag", "v1.10.1", current);
53+ const notes = f.notes("v1.10.1");
54+ assert.match(notes, /compare\/v1\.10\.0\.\.\.v1\.10\.1/u);
55+ assert.match(notes, /## Fixes\n\n- fix: belongs in these notes/u);
56+ assert.match(notes, /## CI\n\n- ci: release/u);
57+ assert.doesNotMatch(notes, /already shipped|initialize/u);
58+});
59+
60+test("stable version comparison retains precision beyond Number safe integers", (t) => {
61+ const f = fixture(t);
62+ f.git("tag", "v9007199254740992.0.0", f.root);
63+ f.git("tag", "v9007199254740993.0.0", f.root);
64+ const current = f.commit("fix: new", "new", [f.root]);
65+ f.git("tag", "v9007199254740994.0.0", current);
66+ assert.match(f.notes("v9007199254740994.0.0"), /compare\/v9007199254740993\.0\.0\.\.\.v9007199254740994\.0\.0/u);
67+});
68+
69+test("divergent release uses net trees without replaying historical or squash commits", (t) => {
70+ const f = fixture(t);
71+ const previous = f.commit("feat: huge old feature", "released", [f.root]);
72+ f.git("tag", "v1.0.0", previous);
73+ const squash = f.commit("feat: ship all old features", "released", [f.root]);
74+ const current = f.commit("fix: recent change", "new", [squash]);
75+ f.git("tag", "v1.0.1", current);
76+ const notes = f.notes("v1.0.1");
77+ assert.match(notes, /## Net file changes/u);
78+ assert.match(notes, /0 files added, 1 modified, 0 deleted, 0 changed type/u);
79+ assert.match(notes, /compare\/v1\.0\.0\.\.v1\.0\.1\n/u);
80+ assert.doesNotMatch(notes, /huge old feature|ship all old features|recent change|## Features/u);
81+});
82+
83+test("net counts preserve unusual filenames without injecting Markdown", (t) => {
84+ const f = fixture(t);
85+ const previous = f.commit("feat: old", "old", [f.root]);
86+ f.git("tag", "v1.0.0", previous);
87+ const current = f.commit("feat: squash", "new", [f.root], "odd\n`[filename].txt");
88+ f.git("tag", "v1.0.1", current);
89+ const notes = f.notes("v1.0.1");
90+ assert.match(notes, /1 files added, 0 modified, 0 deleted, 0 changed type/u);
91+ assert.doesNotMatch(notes, /filename/u);
92+});
93+
94+test("initial release alone includes full history and rejects invalid current releases", (t) => {
95+ const f = fixture(t);
96+ f.git("tag", "v1.0.0", f.root);
97+ assert.match(f.notes("v1.0.0"), /chore: initialize/u);
98+ assert.match(f.notes("v1.0.0"), /\/commits\/v1\.0\.0/u);
99+ for (const tag of [undefined, "v1.0", "v1.0.0-rc.1", "v01.0.0"]) {
100+ assert.throws(() => f.notes(tag), /strict stable version/u);
101+ }
102+ const other = f.commit("fix: untagged", "other", [f.root]);
103+ assert.throws(() => f.notes("v1.0.0", other), /does not match its tag/u);
104+});
new file mode 100644
@@ -0,0 +1,104 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { execFileSync } from "node:child_process";
4+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5+import { tmpdir } from "node:os";
6+import { join } from "node:path";
7+import { generateReleaseNotes } from "../../../scripts/generate-release-notes.mjs";
8+
9+function fixture(t) {
10+ const cwd = mkdtempSync(join(tmpdir(), "lapin-fute-release-notes-"));
11+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
12+ const git = (...args) => execFileSync("git", args, {
13+ cwd, encoding: "utf8",
14+ env: { ...process.env, GIT_AUTHOR_NAME: "Fixture", GIT_AUTHOR_EMAIL: "fixture@example.invalid", GIT_COMMITTER_NAME: "Fixture", GIT_COMMITTER_EMAIL: "fixture@example.invalid" },
15+ }).trim();
16+ git("init", "--quiet");
17+ function commit(subject, content, parents = [], filename = "app.txt") {
18+ writeFileSync(join(cwd, filename), content);
19+ git("add", "--", filename);
20+ const tree = git("write-tree");
21+ return git("commit-tree", tree, ...parents.flatMap((parent) => ["-p", parent]), "-m", subject);
22+ }
23+ const root = commit("chore: initialize", "root");
24+ return {
25+ git, commit, root,
26+ notes: (tag, revision = tag) => generateReleaseNotes({ cwd, tag, revision, repository: "example/app" }),
27+ };
28+}
29+
30+test("empty commit subjects do not consume the following categorized commit", (t) => {
31+ const f = fixture(t);
32+ f.git("tag", "v1.0.0", f.root);
33+ const fix = f.commit("fix: preserve this change", "fixed", [f.root]);
34+ f.git("update-ref", "HEAD", fix);
35+ f.git("-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null",
36+ "commit", "--quiet", "--allow-empty", "--allow-empty-message", "-m", "");
37+ const empty = f.git("rev-parse", "HEAD");
38+ f.git("tag", "v1.0.1", empty);
39+ const notes = f.notes("v1.0.1");
40+ assert.ok(notes.includes(`## Changes\n\n- (no commit subject) (\`${f.git("rev-parse", "--short", empty)}\`)`));
41+ assert.ok(notes.includes(`## Fixes\n\n- fix: preserve this change (\`${f.git("rev-parse", "--short", fix)}\`)`));
42+ assert.doesNotMatch(notes, /initialize/u);
43+});
44+
45+test("numeric stable predecessor excludes future, prerelease, build and malformed tags", (t) => {
46+ const f = fixture(t);
47+ const old = f.commit("feat: already shipped", "old", [f.root]);
48+ for (const tag of ["v1.9.9", "v1.10.0"]) f.git("tag", tag, old);
49+ const misleading = f.commit("fix: belongs in these notes", "intermediate", [old]);
50+ for (const tag of ["v1.10.1-rc.1", "v1.10.1+build", "v01.10.1", "v1.10.1junk", "v1.11.0", "v2.0.0"]) f.git("tag", tag, misleading);
51+ const current = f.commit("ci: release", "current", [misleading]);
52+ f.git("tag", "v1.10.1", current);
53+ const notes = f.notes("v1.10.1");
54+ assert.match(notes, /compare\/v1\.10\.0\.\.\.v1\.10\.1/u);
55+ assert.match(notes, /## Fixes\n\n- fix: belongs in these notes/u);
56+ assert.match(notes, /## CI\n\n- ci: release/u);
57+ assert.doesNotMatch(notes, /already shipped|initialize/u);
58+});
59+
60+test("stable version comparison retains precision beyond Number safe integers", (t) => {
61+ const f = fixture(t);
62+ f.git("tag", "v9007199254740992.0.0", f.root);
63+ f.git("tag", "v9007199254740993.0.0", f.root);
64+ const current = f.commit("fix: new", "new", [f.root]);
65+ f.git("tag", "v9007199254740994.0.0", current);
66+ assert.match(f.notes("v9007199254740994.0.0"), /compare\/v9007199254740993\.0\.0\.\.\.v9007199254740994\.0\.0/u);
67+});
68+
69+test("divergent release uses net trees without replaying historical or squash commits", (t) => {
70+ const f = fixture(t);
71+ const previous = f.commit("feat: huge old feature", "released", [f.root]);
72+ f.git("tag", "v1.0.0", previous);
73+ const squash = f.commit("feat: ship all old features", "released", [f.root]);
74+ const current = f.commit("fix: recent change", "new", [squash]);
75+ f.git("tag", "v1.0.1", current);
76+ const notes = f.notes("v1.0.1");
77+ assert.match(notes, /## Net file changes/u);
78+ assert.match(notes, /0 files added, 1 modified, 0 deleted, 0 changed type/u);
79+ assert.match(notes, /compare\/v1\.0\.0\.\.v1\.0\.1\n/u);
80+ assert.doesNotMatch(notes, /huge old feature|ship all old features|recent change|## Features/u);
81+});
82+
83+test("net counts preserve unusual filenames without injecting Markdown", (t) => {
84+ const f = fixture(t);
85+ const previous = f.commit("feat: old", "old", [f.root]);
86+ f.git("tag", "v1.0.0", previous);
87+ const current = f.commit("feat: squash", "new", [f.root], "odd\n`[filename].txt");
88+ f.git("tag", "v1.0.1", current);
89+ const notes = f.notes("v1.0.1");
90+ assert.match(notes, /1 files added, 0 modified, 0 deleted, 0 changed type/u);
91+ assert.doesNotMatch(notes, /filename/u);
92+});
93+
94+test("initial release alone includes full history and rejects invalid current releases", (t) => {
95+ const f = fixture(t);
96+ f.git("tag", "v1.0.0", f.root);
97+ assert.match(f.notes("v1.0.0"), /chore: initialize/u);
98+ assert.match(f.notes("v1.0.0"), /\/commits\/v1\.0\.0/u);
99+ for (const tag of [undefined, "v1.0", "v1.0.0-rc.1", "v01.0.0"]) {
100+ assert.throws(() => f.notes(tag), /strict stable version/u);
101+ }
102+ const other = f.commit("fix: untagged", "other", [f.root]);
103+ assert.throws(() => f.notes("v1.0.0", other), /does not match its tag/u);
104+});
added scripts/generate-release-notes.mjs +100 -0
new file mode 100644
@@ -0,0 +1,100 @@
1+import { execFileSync } from "node:child_process";
2+import { writeFileSync } from "node:fs";
3+import { fileURLToPath } from "node:url";
4+
5+const STABLE_TAG = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
6+
7+function compareVersions(left, right) {
8+ for (let index = 0; index < 3; index += 1) {
9+ if (left[index] < right[index]) return -1;
10+ if (left[index] > right[index]) return 1;
11+ }
12+ return 0;
13+}
14+
15+function previousStableTag(tags, currentTag) {
16+ const current = currentTag?.match(STABLE_TAG)?.slice(1).map(BigInt);
17+ if (!current) throw new Error("Release tag must be a strict stable version: vMAJOR.MINOR.PATCH");
18+ let previous = null;
19+ for (const tag of tags) {
20+ const version = tag.match(STABLE_TAG)?.slice(1).map(BigInt);
21+ if (!version || compareVersions(version, current) >= 0) continue;
22+ if (!previous || compareVersions(version, previous.version) > 0) previous = { tag, version };
23+ }
24+ return previous?.tag ?? null;
25+}
26+
27+export function generateReleaseNotes({ tag, revision = tag, repository, cwd = process.cwd() }) {
28+ const git = (...args) => execFileSync("git", args, { cwd, encoding: "utf8" });
29+ if (!repository || !/^[\w.-]+\/[\w.-]+$/u.test(repository)) throw new Error("A GitHub owner/repository is required");
30+ const previous = previousStableTag(git("tag", "--list").trim().split("\n"), tag);
31+ const currentCommit = git("rev-parse", "--verify", "--end-of-options", `${revision}^{commit}`).trim();
32+ const taggedCommit = git("rev-parse", "--verify", `refs/tags/${tag}^{commit}`).trim();
33+ if (currentCommit !== taggedCommit) throw new Error("Release revision does not match its tag");
34+ const baseUrl = `https://github.com/${repository}`;
35+ const notes = [];
36+ let baseline = null;
37+ let divergent = false;
38+ if (previous) {
39+ baseline = git("rev-parse", "--verify", `refs/tags/${previous}^{commit}`).trim();
40+ try {
41+ git("merge-base", "--is-ancestor", baseline, currentCommit);
42+ } catch (error) {
43+ if (error.status !== 1) throw error;
44+ divergent = true;
45+ baseline = null;
46+ }
47+ }
48+
49+ if (divergent) {
50+ // Two endpoints, not a merge-base diff: old feature commits must not look new.
51+ const statuses = git("diff", "--no-renames", "--name-status", "-z", `refs/tags/${previous}`, currentCommit, "--")
52+ .split("\0");
53+ const counts = { A: 0, M: 0, D: 0, T: 0 };
54+ for (let index = 0; index < statuses.length - 1; index += 2) counts[statuses[index]] += 1;
55+ notes.push(
56+ "## Net file changes",
57+ "",
58+ `The history of ${previous} is not an ancestor of ${tag}. This summary compares the two release trees directly; historical commit titles are omitted to avoid presenting previously shipped work as new.`,
59+ "",
60+ `- ${counts.A} files added, ${counts.M} modified, ${counts.D} deleted, ${counts.T} changed type.`,
61+ "",
62+ );
63+ } else {
64+ const groups = [
65+ { key: "feat", title: "Features", commits: [] },
66+ { key: "change", title: "Changes", commits: [] },
67+ { key: "fix", title: "Fixes", commits: [] },
68+ { key: "ci", title: "CI", commits: [] },
69+ ];
70+ const byKey = new Map(groups.map((group) => [group.key, group]));
71+ const fields = git("log", "--first-parent", "-z", "--format=%s%x00%h", baseline ? `${baseline}..${currentCommit}` : currentCommit, "--")
72+ .split("\0");
73+ fields.pop(); // Remove only the record terminator; empty subjects are valid fields.
74+ for (let index = 0; index < fields.length; index += 2) {
75+ const subject = fields[index].trim() || "(no commit subject)";
76+ const hash = fields[index + 1].trim();
77+ const type = subject.match(/^([a-z]+)(?:\([^)]*\))?!?:\s+/i)?.[1].toLowerCase();
78+ const key = ["feat", "fix", "ci"].includes(type) ? type : "change";
79+ byKey.get(key).commits.push(`- ${subject} (\`${hash}\`)`);
80+ }
81+ for (const group of groups) {
82+ if (group.commits.length === 0) continue;
83+ notes.push(`## ${group.title}`, "", ...group.commits, "");
84+ }
85+ }
86+ // GitHub's two-dot comparison shows the endpoint trees even across divergent histories.
87+ const changelogUrl = previous
88+ ? `${baseUrl}/compare/${previous}${divergent ? ".." : "..."}${tag}`
89+ : `${baseUrl}/commits/${tag}`;
90+ notes.push(`**${divergent ? "Release tree diff" : "Full changelog"}**: ${changelogUrl}`, "");
91+ return notes.join("\n");
92+}
93+
94+if (process.argv[1] === fileURLToPath(import.meta.url)) {
95+ writeFileSync("release-notes.md", generateReleaseNotes({
96+ tag: process.env.GITHUB_REF_NAME,
97+ revision: process.env.GITHUB_SHA,
98+ repository: process.env.GITHUB_REPOSITORY,
99+ }));
100+}
new file mode 100644
@@ -0,0 +1,100 @@
1+import { execFileSync } from "node:child_process";
2+import { writeFileSync } from "node:fs";
3+import { fileURLToPath } from "node:url";
4+
5+const STABLE_TAG = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
6+
7+function compareVersions(left, right) {
8+ for (let index = 0; index < 3; index += 1) {
9+ if (left[index] < right[index]) return -1;
10+ if (left[index] > right[index]) return 1;
11+ }
12+ return 0;
13+}
14+
15+function previousStableTag(tags, currentTag) {
16+ const current = currentTag?.match(STABLE_TAG)?.slice(1).map(BigInt);
17+ if (!current) throw new Error("Release tag must be a strict stable version: vMAJOR.MINOR.PATCH");
18+ let previous = null;
19+ for (const tag of tags) {
20+ const version = tag.match(STABLE_TAG)?.slice(1).map(BigInt);
21+ if (!version || compareVersions(version, current) >= 0) continue;
22+ if (!previous || compareVersions(version, previous.version) > 0) previous = { tag, version };
23+ }
24+ return previous?.tag ?? null;
25+}
26+
27+export function generateReleaseNotes({ tag, revision = tag, repository, cwd = process.cwd() }) {
28+ const git = (...args) => execFileSync("git", args, { cwd, encoding: "utf8" });
29+ if (!repository || !/^[\w.-]+\/[\w.-]+$/u.test(repository)) throw new Error("A GitHub owner/repository is required");
30+ const previous = previousStableTag(git("tag", "--list").trim().split("\n"), tag);
31+ const currentCommit = git("rev-parse", "--verify", "--end-of-options", `${revision}^{commit}`).trim();
32+ const taggedCommit = git("rev-parse", "--verify", `refs/tags/${tag}^{commit}`).trim();
33+ if (currentCommit !== taggedCommit) throw new Error("Release revision does not match its tag");
34+ const baseUrl = `https://github.com/${repository}`;
35+ const notes = [];
36+ let baseline = null;
37+ let divergent = false;
38+ if (previous) {
39+ baseline = git("rev-parse", "--verify", `refs/tags/${previous}^{commit}`).trim();
40+ try {
41+ git("merge-base", "--is-ancestor", baseline, currentCommit);
42+ } catch (error) {
43+ if (error.status !== 1) throw error;
44+ divergent = true;
45+ baseline = null;
46+ }
47+ }
48+
49+ if (divergent) {
50+ // Two endpoints, not a merge-base diff: old feature commits must not look new.
51+ const statuses = git("diff", "--no-renames", "--name-status", "-z", `refs/tags/${previous}`, currentCommit, "--")
52+ .split("\0");
53+ const counts = { A: 0, M: 0, D: 0, T: 0 };
54+ for (let index = 0; index < statuses.length - 1; index += 2) counts[statuses[index]] += 1;
55+ notes.push(
56+ "## Net file changes",
57+ "",
58+ `The history of ${previous} is not an ancestor of ${tag}. This summary compares the two release trees directly; historical commit titles are omitted to avoid presenting previously shipped work as new.`,
59+ "",
60+ `- ${counts.A} files added, ${counts.M} modified, ${counts.D} deleted, ${counts.T} changed type.`,
61+ "",
62+ );
63+ } else {
64+ const groups = [
65+ { key: "feat", title: "Features", commits: [] },
66+ { key: "change", title: "Changes", commits: [] },
67+ { key: "fix", title: "Fixes", commits: [] },
68+ { key: "ci", title: "CI", commits: [] },
69+ ];
70+ const byKey = new Map(groups.map((group) => [group.key, group]));
71+ const fields = git("log", "--first-parent", "-z", "--format=%s%x00%h", baseline ? `${baseline}..${currentCommit}` : currentCommit, "--")
72+ .split("\0");
73+ fields.pop(); // Remove only the record terminator; empty subjects are valid fields.
74+ for (let index = 0; index < fields.length; index += 2) {
75+ const subject = fields[index].trim() || "(no commit subject)";
76+ const hash = fields[index + 1].trim();
77+ const type = subject.match(/^([a-z]+)(?:\([^)]*\))?!?:\s+/i)?.[1].toLowerCase();
78+ const key = ["feat", "fix", "ci"].includes(type) ? type : "change";
79+ byKey.get(key).commits.push(`- ${subject} (\`${hash}\`)`);
80+ }
81+ for (const group of groups) {
82+ if (group.commits.length === 0) continue;
83+ notes.push(`## ${group.title}`, "", ...group.commits, "");
84+ }
85+ }
86+ // GitHub's two-dot comparison shows the endpoint trees even across divergent histories.
87+ const changelogUrl = previous
88+ ? `${baseUrl}/compare/${previous}${divergent ? ".." : "..."}${tag}`
89+ : `${baseUrl}/commits/${tag}`;
90+ notes.push(`**${divergent ? "Release tree diff" : "Full changelog"}**: ${changelogUrl}`, "");
91+ return notes.join("\n");
92+}
93+
94+if (process.argv[1] === fileURLToPath(import.meta.url)) {
95+ writeFileSync("release-notes.md", generateReleaseNotes({
96+ tag: process.env.GITHUB_REF_NAME,
97+ revision: process.env.GITHUB_SHA,
98+ repository: process.env.GITHUB_REPOSITORY,
99+ }));
100+}