Skip to content

Commit 0207895

Browse files
silverwindclaude
andcommitted
read CHANGELOG.md for commit/tag/release body
If a CHANGELOG.md is present with a heading for the new version, use its body instead of a `git log` summary. Heading matching is lenient. If the heading has no date or a placeholder (YYYY-MM-DD, xxxx-xx-xx, etc.), rewrite it to today's date and include the file in the commit. Closes #48 Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
1 parent af05a3a commit 0207895

3 files changed

Lines changed: 219 additions & 16 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ To automatically sign commits and tags created by `versions` with GPG add this t
6161

6262
By default, `versions` pushes the commit and tag to `origin` after creating them. Pass `--no-push` to skip the push and keep changes local. Use `--remote` and `--branch` to override the target remote and branch.
6363

64+
## Changelog
65+
66+
If a `CHANGELOG.md` is present at the project root with a heading for the new version, its body is used as the commit message, tag annotation, and release body. Heading matching is lenient — `# 1.2.3`, `## v1.2.3`, `## [1.2.3]`, `## [1.2.3] - 2024-01-15`, `## 1.2.3 (YYYY-MM-DD)` all work. If the heading has no date or a placeholder (`YYYY-MM-DD`, `xxxx-xx-xx`, etc.), it gets rewritten to today's date and included in the commit. With no matching entry, the tool falls back to a `git log` summary.
67+
6468
## Creating releases
6569

6670
When using the `--release` option, `versions` will automatically create a GitHub or Gitea release after pushing the tag. The release body will contain the same changelog as the commit message. `--release` requires the push and is incompatible with `--no-push`.

index.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
readVersionFromPackageJson, readVersionFromPyprojectToml,
99
removeIgnoredFiles, getGithubTokens, getGiteaTokens,
1010
getRepoInfo, writeResult, createForgeRelease,
11+
readChangelogEntry, updateChangelogHeadingDate,
1112
type RepoInfo,
1213
} from "./index.ts";
1314
import {exec, tomlGetString, SubprocessError} from "./utils.ts";
@@ -374,6 +375,71 @@ snapshots:
374375
expect(await readFile(join(tmpDir, "pnpm-lock.yaml"), "utf8")).toEqual(lockContent);
375376
}));
376377

378+
test("readChangelogEntry", () => {
379+
const md = `# Changelog
380+
381+
## [Unreleased]
382+
383+
## [1.2.3] - 2024-01-15
384+
### Added
385+
- new thing
386+
387+
### Fixed
388+
- broken thing
389+
390+
## 1.2.2 (2024-01-01)
391+
- prior
392+
393+
## v1.2.1
394+
old
395+
`;
396+
expect(readChangelogEntry(md, "1.2.3")).toEqual("### Added\n- new thing\n\n### Fixed\n- broken thing");
397+
expect(readChangelogEntry(md, "1.2.2")).toEqual("- prior");
398+
expect(readChangelogEntry(md, "1.2.1")).toEqual("old");
399+
expect(readChangelogEntry(md, "v1.2.3")).toEqual("### Added\n- new thing\n\n### Fixed\n- broken thing");
400+
expect(readChangelogEntry(md, "9.9.9")).toBeNull();
401+
402+
expect(readChangelogEntry("# 1.0.0\n\nbody\n", "1.0.0")).toEqual("body");
403+
expect(readChangelogEntry("# 1.0.0\n\nbody\n", "1.0.10")).toBeNull();
404+
expect(readChangelogEntry("## 1.0.0\n## 1.0.1\nb\n", "1.0.0")).toBeNull();
405+
expect(readChangelogEntry("## 1.0.0-rc.1\n\nrc\n## 1.0.0\n\nrelease\n", "1.0.0")).toEqual("release");
406+
expect(readChangelogEntry("## 1.0.0\n\nrelease\n## 1.0.0-rc.1\n\nrc\n", "1.0.0-rc.1")).toEqual("rc");
407+
expect(readChangelogEntry("# Changelog\n\n## 1.0.0\nbody\n", "1.0.0")).toEqual("body");
408+
expect(readChangelogEntry("", "1.0.0")).toBeNull();
409+
});
410+
411+
test("updateChangelogHeadingDate", () => {
412+
const today = "2026-04-30";
413+
414+
expect(updateChangelogHeadingDate("## 1.2.3\n\nbody\n", "1.2.3", today))
415+
.toEqual("## 1.2.3 - 2026-04-30\n\nbody\n");
416+
417+
expect(updateChangelogHeadingDate("## [1.2.3]\n\nbody\n", "1.2.3", today))
418+
.toEqual("## [1.2.3] - 2026-04-30\n\nbody\n");
419+
420+
expect(updateChangelogHeadingDate("## 1.2.3 - YYYY-MM-DD\n\nbody\n", "1.2.3", today))
421+
.toEqual("## 1.2.3 - 2026-04-30\n\nbody\n");
422+
423+
expect(updateChangelogHeadingDate("## [1.2.3] (yyyy-mm-dd)\n\nbody\n", "1.2.3", today))
424+
.toEqual("## [1.2.3] (2026-04-30)\n\nbody\n");
425+
426+
expect(updateChangelogHeadingDate("## 1.2.3 - xxxx-xx-xx\n\nbody\n", "1.2.3", today))
427+
.toEqual("## 1.2.3 - 2026-04-30\n\nbody\n");
428+
429+
expect(updateChangelogHeadingDate("## 1.2.3 - XXXX-XX-XX\n\nbody\n", "1.2.3", today))
430+
.toEqual("## 1.2.3 - 2026-04-30\n\nbody\n");
431+
432+
expect(updateChangelogHeadingDate("## 1.2.3 - DD-MM-YYYY\n\nbody\n", "1.2.3", today))
433+
.toEqual("## 1.2.3 - 2026-04-30\n\nbody\n");
434+
435+
expect(updateChangelogHeadingDate("## 1.2.3 - ????-??-??\n\nbody\n", "1.2.3", today))
436+
.toEqual("## 1.2.3 - 2026-04-30\n\nbody\n");
437+
438+
expect(updateChangelogHeadingDate("## [1.2.3] - 2024-01-15\n\nbody\n", "1.2.3", today)).toBeNull();
439+
440+
expect(updateChangelogHeadingDate("## 1.0.0\nbody\n", "9.9.9", today)).toBeNull();
441+
});
442+
377443
test("createForgeRelease github success", async () => {
378444
const mock = vi.fn(() => Promise.resolve(Response.json({html_url: "https://github.com/o/r/releases/tag/1.0.1"}, {status: 201})));
379445
stubGlobal("fetch", mock);
@@ -1042,6 +1108,62 @@ test("incrementSemver unknown level throws", () => {
10421108
expect(() => incrementSemver("1.0.0", "unknown")).toThrow("Invalid semver level");
10431109
});
10441110

1111+
test("CHANGELOG.md drives commit body and gets dated heading", () => withTmpDir(async (tmpDir) => {
1112+
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));
1113+
await writeFile(join(tmpDir, "CHANGELOG.md"), `# Changelog\n\n## [1.0.1]\n- Fixed thing X\n- Added thing Y\n\n## 1.0.0\nold stuff\n`);
1114+
1115+
const {env} = await setupReleaseRepo(tmpDir);
1116+
const opts = {cwd: tmpDir, env: {...process.env, ...env}};
1117+
1118+
await exec("node", [distPath, "--no-push", "patch", "package.json"], opts);
1119+
1120+
const today = new Date().toISOString().substring(0, 10);
1121+
const changelogAfter = await readFile(join(tmpDir, "CHANGELOG.md"), "utf8");
1122+
expect(changelogAfter).toContain(`## [1.0.1] - ${today}`);
1123+
1124+
const {stdout: msg} = await exec("git", ["log", "-1", "--pretty=%B"], opts);
1125+
expect(msg).toContain("- Fixed thing X");
1126+
expect(msg).toContain("- Added thing Y");
1127+
// git log fallback (commit subjects) must not leak in
1128+
expect(msg).not.toContain("Initial commit");
1129+
}));
1130+
1131+
test("CHANGELOG.md without entry falls back to git log", () => withTmpDir(async (tmpDir) => {
1132+
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));
1133+
await writeFile(join(tmpDir, "CHANGELOG.md"), `# Changelog\n\n## 1.0.0\nold\n`);
1134+
1135+
const {env} = await setupReleaseRepo(tmpDir);
1136+
const opts = {cwd: tmpDir, env: {...process.env, ...env}};
1137+
1138+
// commit between the tag and HEAD so the git log fallback has something to report
1139+
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0", changed: true}, null, 2));
1140+
await exec("git", ["commit", "-am", "tweak something"], opts);
1141+
1142+
await exec("node", [distPath, "--no-push", "patch", "package.json"], opts);
1143+
1144+
// unchanged because no entry for 1.0.1
1145+
expect(await readFile(join(tmpDir, "CHANGELOG.md"), "utf8")).toEqual(`# Changelog\n\n## 1.0.0\nold\n`);
1146+
1147+
const {stdout: msg} = await exec("git", ["log", "-1", "--pretty=%B"], opts);
1148+
expect(msg).toContain("tweak something");
1149+
}));
1150+
1151+
test("CHANGELOG.md with existing date is left alone", () => withTmpDir(async (tmpDir) => {
1152+
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));
1153+
const original = `# Changelog\n\n## [1.0.1] - 2024-01-15\n- existing entry\n`;
1154+
await writeFile(join(tmpDir, "CHANGELOG.md"), original);
1155+
1156+
const {env} = await setupReleaseRepo(tmpDir);
1157+
const opts = {cwd: tmpDir, env: {...process.env, ...env}};
1158+
1159+
await exec("node", [distPath, "--no-push", "patch", "package.json"], opts);
1160+
1161+
expect(await readFile(join(tmpDir, "CHANGELOG.md"), "utf8")).toEqual(original);
1162+
1163+
const {stdout: msg} = await exec("git", ["log", "-1", "--pretty=%B"], opts);
1164+
expect(msg).toContain("- existing entry");
1165+
}));
1166+
10451167
test("readVersionFromPackageJson", () => withTmpDir(async (tmpDir) => {
10461168
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test", version: "3.2.1"}, null, 2));
10471169
expect(readVersionFromPackageJson(tmpDir)).toEqual("3.2.1");

index.ts

Lines changed: 93 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const reSemver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d
1414
const rePrereleaseVersion = /^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*))?/;
1515
const rePrereleaseIdNum = /^([a-zA-Z0-9-]+)\.(\d+)$/;
1616
const reDatePattern = /(?<=[^0-9]|^)[0-9]{4}-[0-9]{2}-[0-9]{2}(?=[^0-9]|$)/g;
17+
const reDate = new RegExp(reDatePattern.source);
1718
const reReplaceString = /^s#([^#]+)#([^#]+)#(.*)$/;
1819

1920
function stripV(str: string): string {
@@ -93,6 +94,56 @@ export function readVersionFromPyprojectToml(projectRoot: string): string | null
9394
});
9495
}
9596
97+
const reHeading = /^(#+)\s+(.*?)\s*$/;
98+
// Three groups of 2-4 chars from Y/M/D/X/? separated by `-`, `/`, `.`, or whitespace.
99+
// Covers YYYY-MM-DD, xxxx-xx-xx, ????-??-??, DD-MM-YYYY, YYYY/MM/DD etc.
100+
const rePlaceholderDate = /[YMDX?]{2,4}[-/. ][YMDX?]{2,4}[-/. ][YMDX?]{2,4}/i;
101+
102+
function findVersionHeading(lines: string[], version: string): {index: number, level: number} | null {
103+
// Non-version-char boundaries so "1.2.3" doesn't match "1.2.30" or "1.2.3-rc.1".
104+
const re = new RegExp(`(?:^|[^\\d.\\-])v?${esc(stripV(version))}(?:[^\\d.\\-]|$)`, "i");
105+
for (let i = 0; i < lines.length; i++) {
106+
const m = reHeading.exec(lines[i]);
107+
if (m && re.test(m[2])) return {index: i, level: m[1].length};
108+
}
109+
return null;
110+
}
111+
112+
// Lenient about heading shape: matches "# 1.2.3", "## v1.2.3", "## [1.2.3]",
113+
// "## [1.2.3] - 2024-01-15", "## 1.2.3 (2024-01-15)", etc.
114+
export function readChangelogEntry(content: string, version: string): string | null {
115+
const lines = content.split(reNewline);
116+
const head = findVersionHeading(lines, version);
117+
if (!head) return null;
118+
119+
let end = lines.length;
120+
for (let i = head.index + 1; i < lines.length; i++) {
121+
const m = reHeading.exec(lines[i]);
122+
if (m && m[1].length <= head.level) {
123+
end = i;
124+
break;
125+
}
126+
}
127+
128+
return lines.slice(head.index + 1, end).join("\n").trim() || null;
129+
}
130+
131+
export function updateChangelogHeadingDate(content: string, version: string, date: string): string | null {
132+
const lines = content.split(reNewline);
133+
const head = findVersionHeading(lines, version);
134+
if (!head) return null;
135+
136+
const heading = lines[head.index];
137+
if (rePlaceholderDate.test(heading)) {
138+
lines[head.index] = heading.replace(rePlaceholderDate, date);
139+
} else if (reDate.test(heading)) {
140+
return null;
141+
} else {
142+
lines[head.index] = `${heading.trimEnd()} - ${date}`;
143+
}
144+
return lines.join("\n");
145+
}
146+
96147
export async function removeIgnoredFiles(files: Array<string>, cwd?: string): Promise<Array<string>> {
97148
let result: Result;
98149
try {
@@ -358,7 +409,8 @@ async function main(): Promise<void> {
358409
end();
359410
}
360411

361-
const date = args.date ? new Date().toISOString().substring(0, 10) : "";
412+
const today = new Date().toISOString().substring(0, 10);
413+
const date = args.date ? today : "";
362414

363415
const pwd = cwd();
364416
const gitDir = findUp(".git", pwd);
@@ -465,8 +517,28 @@ async function main(): Promise<void> {
465517
const msgs = (args.message || []).filter(msg => typeof msg === "string");
466518
const tagName = args["prefix"] ? `v${newVersion}` : newVersion;
467519

468-
const filesToAddPromise = (!args.gitless && !args.all && files.length) ? removeIgnoredFiles(files) : null;
520+
const changelogInfo = (() => {
521+
const path = findUp("CHANGELOG.md", projectRoot);
522+
if (!path) return null;
523+
let original: string;
524+
try {
525+
original = readFileSync(path, "utf8");
526+
} catch {
527+
return null;
528+
}
529+
const entry = readChangelogEntry(original, newVersion);
530+
if (!entry) return null;
531+
return {path, entry, original, updated: updateChangelogHeadingDate(original, newVersion, today)};
532+
})();
533+
534+
const allFiles = changelogInfo?.updated ? [...files, relative(pwd, changelogInfo.path)] : files;
535+
const filesToAddPromise = (!args.gitless && !args.all && allFiles.length) ? removeIgnoredFiles(allFiles) : null;
469536
const changelogPromise = (!args.gitless && !args.dry) ? (async () => {
537+
if (changelogInfo) {
538+
logVerbose(`using changelog entry from ${changelogInfo.path}`);
539+
return changelogInfo.entry;
540+
}
541+
470542
let range = "";
471543
const tagExists = await exec("git", ["rev-parse", "--verify", `refs/tags/${tagName}`]).then(() => true, () => false);
472544
if (tagExists) {
@@ -508,23 +580,28 @@ async function main(): Promise<void> {
508580
const rollbacks: Array<() => Promise<void> | void> = [];
509581

510582
try {
511-
if (files.length) {
512-
const originals = new Map<string, string>();
513-
rollbacks.push(() => {
514-
for (const [file, content] of originals) write(file, content);
515-
});
516-
for (const file of files) {
517-
const [filePath, newData, oldData] = getFileChanges({file, baseVersion, newVersion, replacements, date});
518-
if (newData !== null) {
519-
if (!originals.has(filePath)) originals.set(filePath, oldData!);
520-
logVerbose(`writing ${filePath}`);
521-
write(filePath, newData);
522-
} else {
523-
logVerbose(`skipping ${file} (unhandled lockfile)`);
524-
}
583+
const originals = new Map<string, string>();
584+
rollbacks.push(() => {
585+
for (const [file, content] of originals) write(file, content);
586+
});
587+
for (const file of files) {
588+
const [filePath, newData, oldData] = getFileChanges({file, baseVersion, newVersion, replacements, date});
589+
if (newData !== null) {
590+
if (!originals.has(filePath)) originals.set(filePath, oldData!);
591+
logVerbose(`writing ${filePath}`);
592+
write(filePath, newData);
593+
} else {
594+
logVerbose(`skipping ${file} (unhandled lockfile)`);
525595
}
526596
}
527597

598+
if (changelogInfo?.updated) {
599+
const {path, original, updated} = changelogInfo;
600+
if (!originals.has(path)) originals.set(path, original);
601+
logVerbose(`updating heading date in ${path}`);
602+
write(path, updated);
603+
}
604+
528605
if (typeof args.command === "string") {
529606
logVerbose(`running command: ${args.command}`);
530607
writeResult(await exec(args.command, [], {shell: true}));

0 commit comments

Comments
 (0)