From 36991a38e7ba9d4602e1a389b80fd51a10f7e88a Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 15 Jun 2026 04:35:12 +0200 Subject: [PATCH] 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. --- scripts/fix-changelogs.test.ts | 79 ++++++++++++++++++++++- scripts/fix-changelogs.ts | 112 +++++++++++++++++++++++++++++---- scripts/release.ts | 7 ++- 3 files changed, 183 insertions(+), 15 deletions(-) diff --git a/scripts/fix-changelogs.test.ts b/scripts/fix-changelogs.test.ts index 4da0b0f66..5a2fb181e 100644 --- a/scripts/fix-changelogs.test.ts +++ b/scripts/fix-changelogs.test.ts @@ -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 }); + } + }); +}); diff --git a/scripts/fix-changelogs.ts b/scripts/fix-changelogs.ts index 24ace2440..282ffec5e 100755 --- a/scripts/fix-changelogs.ts +++ b/scripts/fix-changelogs.ts @@ -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 { } async function latestTag(repoRoot: string): Promise { - 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 { - return (await git(["tag", "--sort=v:refname"], repoRoot)) +async function changelogBaselineCommit(repoRoot: string): Promise { + 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 { + 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 { + 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 { + 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 { 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> { - const tags = await allTags(repoRoot); + const tags = await recoveryTags(repoRoot); const recoveryByPath = new Map(); 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 { 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>() @@ -838,8 +885,17 @@ export async function runChangelogFixer(options: RunChangelogFixerOptions = {}): return { since, changedFiles }; } +async function dirtyChangelogs(repoRoot: string): Promise { + 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 ] [--recover]", + "Usage: bun scripts/fix-changelogs.ts [--dry-run|--check] [--since ] [--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 Compare changelog additions against this tag/commit instead of latest tag.", - " --recover Rebuild against the union of historically released bullets from all tags.", + " --since 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 Run against an explicit repository root.", ].join("\n"); } @@ -927,6 +993,22 @@ async function main(): Promise { 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 { 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); } diff --git a/scripts/release.ts b/scripts/release.ts index 273b7e914..c1f3f57ba 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -205,7 +205,7 @@ async function cmdRelease(version: string): Promise { } 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 { // 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, ` +