fix(scripts): added changelog baseline pinning for recover runs

- Added a `refs/clog` baseline and updated the fixer to prefer it over older `v*` tags when resolving the changelog diff floor.
- Constrained recovery scans to tags containing the baseline and added `--pin` to advance that baseline to `HEAD` after authoritative rewrites.
- Updated release tagging/tag lookup behavior and added a regression test covering recovered released bullets not being re-promoted.
This commit is contained in:
can1357
2026-06-15 04:35:12 +02:00
parent a655953e7a
commit 36991a38e7
3 changed files with 183 additions and 15 deletions
+78 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "bun:test";
import { collectPromotableAddedItemLines, fixChangelogContent } from "./fix-changelogs";
import { $ } from "bun";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { collectPromotableAddedItemLines, fixChangelogContent, runChangelogFixer } from "./fix-changelogs";
describe("collectPromotableAddedItemLines", () => {
it("keeps new changelog item additions while ignoring moves and edits", () => {
@@ -246,3 +250,76 @@ describe("fixChangelogContent", () => {
expect(result.content).toBe(content);
});
});
const RELEASED_ONLY = `# Changelog
## [Unreleased]
## [1.0.0] - 2025-01-01
### Fixed
- Old released bullet.
`;
const RELEASED_PLUS_RECOVERED = `# Changelog
## [Unreleased]
## [1.0.0] - 2025-01-01
### Fixed
- Old released bullet.
- Recovered bullet.
`;
describe("runChangelogFixer baseline pin", () => {
it("uses the clog baseline ref as the diff floor so a recovered released bullet is not re-promoted", async () => {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "clog-fix-"));
const git = (...args: string[]) =>
$`git ${args}`
.cwd(repoRoot)
.quiet()
.env({
...process.env,
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_SYSTEM: "/dev/null",
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@t",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@t",
});
try {
const changelogPath = path.join(repoRoot, "packages/foo/CHANGELOG.md");
await git("init", "-b", "main");
await Bun.write(changelogPath, RELEASED_ONLY);
await git("add", "-A");
await git("commit", "-m", "release 1.0.0");
await git("tag", "v1.0.0");
// Simulate a `--recover` restoring a historically released bullet that the
// v1.0.0 snapshot no longer carries.
await Bun.write(changelogPath, RELEASED_PLUS_RECOVERED);
await git("add", "-A");
await git("commit", "-m", "recover dropped bullet");
// No baseline tag: the floor is the latest version tag, which predates the
// recovery, so the restored released bullet reads as added-in-a-released
// section and is wrongly promoted back into [Unreleased].
const withoutPin = await runChangelogFixer({ repoRoot, write: false });
expect(withoutPin.since).toBe("v1.0.0");
const promoted = withoutPin.changedFiles.find(file => file.path === "packages/foo/CHANGELOG.md");
expect(promoted?.promotedItems).toBe(1);
// Pin `clog` (a custom ref, not a tag — see resolveSince) to the recovery
// commit: the plain run now diffs against it and leaves the bullet untouched.
await git("update-ref", "refs/clog", "HEAD");
const withPin = await runChangelogFixer({ repoRoot, write: false });
expect(withPin.since).toBe("refs/clog");
expect(withPin.changedFiles).toHaveLength(0);
} finally {
await fs.rm(repoRoot, { recursive: true, force: true });
}
});
});
+100 -12
View File
@@ -5,6 +5,8 @@ import * as path from "node:path";
const CHANGELOG_GLOB = "packages/*/CHANGELOG.md";
const ORDERED_SECTION_TITLES = ["Breaking Changes", "Added", "Changed", "Fixed", "Removed"] as const;
const CHANGELOG_BASELINE_REF = "refs/clog";
const CHANGELOG_BASELINE_NAME = "clog";
interface NumberedLine {
text: string;
@@ -86,6 +88,7 @@ interface CliOptions {
repoRoot?: string;
since?: string;
recover: boolean;
pin: boolean;
help: boolean;
}
@@ -723,16 +726,60 @@ async function resolveRepoRoot(repoRoot: string | undefined): Promise<string> {
}
async function latestTag(repoRoot: string): Promise<string> {
return (await git(["describe", "--tags", "--abbrev=0"], repoRoot)).trim();
return ((await gitMaybe(["describe", "--tags", "--abbrev=0", "--match", "v*"], repoRoot)) ?? "").trim();
}
async function allTags(repoRoot: string): Promise<string[]> {
return (await git(["tag", "--sort=v:refname"], repoRoot))
async function changelogBaselineCommit(repoRoot: string): Promise<string | undefined> {
return (await gitMaybe(["rev-parse", "--verify", "--quiet", CHANGELOG_BASELINE_REF], repoRoot))?.trim() || undefined;
}
/**
* The diff/scan floor for both operations. Prefer the `clog` baseline (the last
* authoritative changelog rewrite) over the latest version tag whenever the
* baseline is newer — i.e. a `--recover` landed after the last release. Once the
* next release tags a commit that descends from the baseline, the version tag
* wins again, so the pin self-expires without manual cleanup.
*
* The baseline lives in a custom ref outside `refs/tags/`, not a tag: this repo
* runs background `git maintenance` with `fetch.pruneTags=true`, which deletes
* any local tag not on the remote — a lightweight `clog` tag would vanish. A
* non-tag ref is never touched by tag pruning and stays invisible to
* `git describe --tags`.
*/
async function resolveSince(repoRoot: string, since: string | undefined): Promise<string> {
if (since) return since;
const versionTag = await latestTag(repoRoot);
const baseline = await changelogBaselineCommit(repoRoot);
if (!baseline) return versionTag;
if (!versionTag) return CHANGELOG_BASELINE_REF;
const versionTagIsNewer =
(await gitMaybe(["merge-base", "--is-ancestor", baseline, versionTag], repoRoot)) !== undefined;
return versionTagIsNewer ? versionTag : CHANGELOG_BASELINE_REF;
}
/**
* Tags whose released bullets `--recover` treats as authoritative. Bounded to
* the commits at or after the `clog` baseline so a recovery never resurrects a
* bullet that was intentionally dropped before the last authoritative rewrite;
* without a baseline it falls back to every tag (legacy behavior).
*/
async function recoveryTags(repoRoot: string): Promise<string[]> {
const baseline = await changelogBaselineCommit(repoRoot);
const listArgs = baseline
? ["tag", "--contains", baseline, "--sort=v:refname"]
: ["tag", "--sort=v:refname"];
return (await git(listArgs, repoRoot))
.split("\n")
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
}
async function pinChangelogBaseline(repoRoot: string): Promise<string> {
const head = (await git(["rev-parse", "HEAD"], repoRoot)).trim();
await git(["update-ref", CHANGELOG_BASELINE_REF, head], repoRoot);
return head;
}
async function gitMaybe(args: readonly string[], cwd: string): Promise<string | undefined> {
const result = await $`git -c core.fsmonitor=false -c core.untrackedCache=false -c fetch.pruneTags=false ${args}`
.cwd(cwd)
@@ -746,7 +793,7 @@ async function collectHistoricalReleaseRecovery(
repoRoot: string,
paths: readonly string[],
): Promise<Map<string, HistoricalReleaseRecovery>> {
const tags = await allTags(repoRoot);
const tags = await recoveryTags(repoRoot);
const recoveryByPath = new Map<string, HistoricalReleaseRecovery>();
for (const tag of tags) {
@@ -797,7 +844,7 @@ async function changelogDiff(repoRoot: string, since: string, paths: readonly st
export async function runChangelogFixer(options: RunChangelogFixerOptions = {}): Promise<RunChangelogFixerResult> {
const repoRoot = await resolveRepoRoot(options.repoRoot);
const since = options.since ?? (await latestTag(repoRoot));
const since = await resolveSince(repoRoot, options.since);
const paths = await changelogPaths(repoRoot);
const addedItemLines = options.recover
? new Map<string, Set<number>>()
@@ -838,8 +885,17 @@ export async function runChangelogFixer(options: RunChangelogFixerOptions = {}):
return { since, changedFiles };
}
async function dirtyChangelogs(repoRoot: string): Promise<string[]> {
const paths = await changelogPaths(repoRoot);
if (paths.length === 0) return [];
return (await git(["status", "--porcelain", "--", ...paths], repoRoot))
.split("\n")
.map(line => line.trim())
.filter(line => line.length > 0);
}
function parseCliArgs(args: readonly string[]): CliOptions {
const options: CliOptions = { mode: "write", recover: false, help: false };
const options: CliOptions = { mode: "write", recover: false, pin: false, help: false };
for (let index = 0; index < args.length; index++) {
const arg = args[index];
switch (arg) {
@@ -852,6 +908,9 @@ function parseCliArgs(args: readonly string[]): CliOptions {
case "--recover":
options.recover = true;
break;
case "--pin":
options.pin = true;
break;
case "--since": {
const value = args[index + 1];
if (!value) throw new Error("--since requires a tag or commit");
@@ -879,23 +938,30 @@ function parseCliArgs(args: readonly string[]): CliOptions {
function usage(): string {
return [
"Usage: bun scripts/fix-changelogs.ts [--dry-run|--check] [--since <tag>] [--recover]",
"Usage: bun scripts/fix-changelogs.ts [--dry-run|--check] [--since <tag>] [--recover] [--pin]",
"",
"Moves changelog items added since the latest tag from released sections into [Unreleased],",
"Moves changelog items added since the baseline from released sections into [Unreleased],",
"drops [Unreleased] items that already appear verbatim in a released section, removes",
"blank separators between adjacent bullet items, then removes duplicate or empty",
"### category headings.",
"",
"With --recover, the fixer also scans every tagged changelog snapshot in version order",
`The baseline defaults to the '${CHANGELOG_BASELINE_NAME}' ref (the last authoritative rewrite)`,
"when it is newer than the latest version tag, otherwise the latest version tag — so a",
"--recover is not undone by a later plain run.",
"",
"With --recover, the fixer scans every tagged changelog snapshot from the baseline forward",
"and treats every historically released bullet as authoritative, so stale [Unreleased]",
"items copied forward by past bad releases are pruned even if the current file no longer",
"contains a matching released copy.",
"contains a matching released copy. After committing a recovery, run --pin to mark it.",
"",
`With --pin, move the '${CHANGELOG_BASELINE_NAME}' baseline ref to HEAD and exit without fixing.`,
"",
"Options:",
" --dry-run Print what would change without writing files.",
" --check Exit 1 if any changelog would change.",
" --since <tag> Compare changelog additions against this tag/commit instead of latest tag.",
" --recover Rebuild against the union of historically released bullets from all tags.",
" --since <tag> Compare changelog additions against this tag/commit instead of the baseline.",
" --recover Rebuild against historically released bullets from the baseline forward.",
` --pin Move the '${CHANGELOG_BASELINE_NAME}' baseline ref to HEAD, then exit.`,
" --repo-root <dir> Run against an explicit repository root.",
].join("\n");
}
@@ -927,6 +993,22 @@ async function main(): Promise<void> {
return;
}
if (cliOptions.pin) {
const repoRoot = await resolveRepoRoot(cliOptions.repoRoot);
const dirty = await dirtyChangelogs(repoRoot);
if (dirty.length > 0) {
console.warn(
`Warning: ${dirty.length} changelog file(s) have uncommitted changes; the pinned commit ` +
"will not include them. Commit first, then re-run --pin.",
);
}
const head = await pinChangelogBaseline(repoRoot);
console.log(
`Pinned changelog baseline '${CHANGELOG_BASELINE_NAME}' (${CHANGELOG_BASELINE_REF}) to ${head.slice(0, 12)}.`,
);
return;
}
const result = await runChangelogFixer({
repoRoot: cliOptions.repoRoot,
since: cliOptions.since,
@@ -934,6 +1016,12 @@ async function main(): Promise<void> {
recover: cliOptions.recover,
});
printSummary(result, cliOptions.mode);
if (cliOptions.recover && cliOptions.mode === "write" && result.changedFiles.length > 0) {
console.log(
`\nAuthoritative rewrite written. Commit the changelog changes, then run ` +
`'bun scripts/fix-changelogs.ts --pin' to move the '${CHANGELOG_BASELINE_NAME}' baseline ref.`,
);
}
if (cliOptions.mode === "check" && result.changedFiles.length > 0) {
process.exit(1);
}
+5 -2
View File
@@ -205,7 +205,7 @@ async function cmdRelease(version: string): Promise<void> {
}
console.log(" Working directory clean");
const latestTag = (await git(["describe", "--tags", "--abbrev=0"]).text()).trim();
const latestTag = (await git(["describe", "--tags", "--abbrev=0", "--match", "v*"]).text()).trim();
if (compareVersions(version, latestTag) <= 0) {
console.error(`Error: Version ${version} must be greater than latest tag ${latestTag}`);
process.exit(1);
@@ -306,7 +306,10 @@ async function cmdRelease(version: string): Promise<void> {
// 5. Update changelogs
console.log("Updating CHANGELOGs...");
const fixResult = await runChangelogFixer({ since: latestTag });
// Omit `since` so the fixer resolves its own baseline: the `clog` tag (last
// authoritative rewrite) when newer than `latestTag`, else `latestTag`. This
// keeps a release run from re-promoting bullets a prior `--recover` restored.
const fixResult = await runChangelogFixer({});
for (const fixed of fixResult.changedFiles) {
console.log(
` Fixed ${fixed.path}: ${fixed.promotedItems} promoted, ` +