diff --git a/.github/scripts/update-changelog.sh b/.github/scripts/update-changelog.sh index 6fe24f8ac..50ef7c2ee 100755 --- a/.github/scripts/update-changelog.sh +++ b/.github/scripts/update-changelog.sh @@ -1,45 +1,50 @@ #!/usr/bin/env bash -# Update CHANGELOG.md with release notes for a given version. +# Update CHANGELOG.md in the current directory with release notes for a +# given version. # Usage: update-changelog.sh -# Replaces [Unreleased] section if present, otherwise inserts after header. +# The entry goes above the newest version entry, replacing an [Unreleased] +# section if there is one. set -euo pipefail VERSION="${1:?Usage: update-changelog.sh }" NOTES_FILE="${2:?Usage: update-changelog.sh }" -NOTES=$(cat "$NOTES_FILE") DATE=$(date +%Y-%m-%d) -python3 - "$VERSION" "$DATE" "$NOTES" << 'PYTHON' -import sys, re, os - -version, date, notes = sys.argv[1], sys.argv[2], sys.argv[3] -entry = f"## [{version}] — {date}\n\n{notes}" - -changelog_path = "CHANGELOG.md" -if os.path.exists(changelog_path): - with open(changelog_path) as f: - text = f.read() - # Replace [Unreleased] section if present - if re.search(r'^## \[Unreleased\]', text, re.MULTILINE): - text = re.sub( - r'## \[Unreleased\].*?(?=\n## \[|$)', - entry, - text, count=1, flags=re.DOTALL - ) - else: - # Insert before the first version entry (## [x.y.z]) - lines = text.split('\n') - insert_at = next( - (i for i in range(len(lines)) if lines[i].startswith('## [')), - len(lines) - ) - lines.insert(insert_at, f"\n{entry}\n") - text = '\n'.join(lines) -else: - text = f"# Changelog\n\n{entry}\n" - -with open(changelog_path, 'w') as f: - f.write(text) +# Python reads this program from fd 3, not stdin, so a notes file of +# /dev/stdin still reads the caller's stdin. +python3 /dev/fd/3 "$VERSION" "$DATE" "$NOTES_FILE" 3<< 'PYTHON' +import sys, re +from pathlib import Path + +# From an "## [Unreleased]" heading up to the next "## [" heading or the end +# of the file, the section's trailing blank lines included. +UNRELEASED_SECTION = re.compile( + r'^## \[Unreleased\].*?(?=^## \[|\Z)', re.DOTALL | re.MULTILINE +) +# An entry heading: any line starting "## [". +ENTRY_HEADING = re.compile(r'^## \[', re.MULTILINE) + +version, date, notes_file = sys.argv[1:] +notes = Path(notes_file).read_text(encoding="utf-8") + +changelog = Path("CHANGELOG.md") +text = changelog.read_text(encoding="utf-8") if changelog.exists() else "" +# Remove an [Unreleased] section rather than replace it with the entry: +# re.sub reads backslashes in a replacement string as escapes. +text = UNRELEASED_SECTION.sub('', text, count=1) + +first_entry = ENTRY_HEADING.search(text) +header_end = first_entry.start() if first_entry else len(text) + +# With no text above the first entry, the header is a "# Changelog" title. +header = text[:header_end].rstrip() or "# Changelog" +entry = f"## [{version}] — {date}\n\n{notes}".rstrip() +earlier_entries = text[header_end:].rstrip() + +# Every part is rstripped, so the join leaves exactly one blank line between +# parts and a single newline at the end of the file. +parts = [header, entry, earlier_entries] +changelog.write_text("\n\n".join(part for part in parts if part) + "\n", encoding="utf-8") PYTHON diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d470d029..b2060f7e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,126 +1,5 @@ # Changelog - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ## [0.54.16] — 2026-10-20 ### Features @@ -2794,14 +2673,12 @@ - Update CHANGELOG.md for v0.12.1 - ## [0.12.1] — 2026-05-18 ### Bug Fixes - Prevent port 8000 wipe by avoiding ForceNew on InstancePublicPorts (#42) - ## [0.12.0] — 2026-05-18 ### Features @@ -2812,7 +2689,6 @@ - Remove unneeded type - ## [0.11.3] — 2026-05-17 ### Features @@ -2827,7 +2703,6 @@ - Add gitleaks secret scanning (#38) - ## [0.11.2] — 2026-05-16 ### Documentation @@ -2839,14 +2714,12 @@ - Align Node references on Node 24 and fix doc inaccuracies (#37) - ## [0.11.1] — 2026-05-16 ### Bug Fixes - Env-var import — default import, not namespace (#35) - ## [0.11.0] — 2026-05-16 ### Bug Fixes @@ -2858,7 +2731,6 @@ - README rewrite and deployment docs for open-source (#33) - Update CHANGELOG.md for v0.10.0 - ## [0.10.0] — 2026-05-16 ### Features @@ -2870,7 +2742,6 @@ - Update CHANGELOG.md for v0.9.0 - ## [0.9.0] — 2026-05-16 ### Bug Fixes @@ -2886,7 +2757,6 @@ - **deps:** Update all dependencies, Node 24, TypeScript 6 (#27) - ## [0.8.1] — 2026-05-15 ### Documentation @@ -2894,7 +2764,6 @@ - Improve tool descriptions for structural edit safety (#23) - Update CHANGELOG.md for v0.8.0 - ## [0.8.0] — 2026-05-15 ### Features @@ -2906,12 +2775,10 @@ - Document sort order in search-by-tag/folder/property tool descriptions - Update CHANGELOG.md for v0.7.0 - ## [0.7.0] — 2026-05-14 No notable changes. - ## [0.6.4] — 2026-05-14 ### Features @@ -2922,7 +2789,6 @@ No notable changes. - Update CHANGELOG.md for v0.6.3 - ## [0.6.3] — 2026-05-14 ### Refactoring @@ -2934,7 +2800,6 @@ No notable changes. - Update CHANGELOG.md for v0.6.2 - ## [0.6.2] — 2026-05-14 ### Bug Fixes @@ -2945,7 +2810,6 @@ No notable changes. - Update CHANGELOG.md for v0.6.1 - ## [0.6.1] — 2026-05-14 ### Bug Fixes @@ -2960,7 +2824,6 @@ No notable changes. - Update .gitignore - ## [0.6.0] — 2026-05-13 ### Features @@ -2971,7 +2834,6 @@ No notable changes. - Update CHANGELOG.md for v0.5.0 - ## [0.5.0] — 2026-05-13 ### Features @@ -2994,7 +2856,6 @@ No notable changes. - **vault-mcp:** Cover server.ts helpers and mcp-router.ts (#14) - ## [0.4.0] — 2026-05-13 ### Features @@ -3009,7 +2870,6 @@ No notable changes. - Update CHANGELOG.md for v0.3.0 - ## [0.3.0] — 2026-05-12 ### Features @@ -3020,7 +2880,6 @@ No notable changes. - Update CHANGELOG.md for v0.2.0 - ## [0.2.0] — 2026-05-11 ### Features @@ -3038,7 +2897,6 @@ No notable changes. - **ci:** Inline deploy + release in manual_release so the chain fires (#6) - ## [0.1.1] — 2026-05-11 Initial tagged release — Phase 1 scaffold and core implementation. diff --git a/src/__tests__/update-changelog.test.ts b/src/__tests__/update-changelog.test.ts new file mode 100644 index 000000000..0edd02dd4 --- /dev/null +++ b/src/__tests__/update-changelog.test.ts @@ -0,0 +1,350 @@ +import { spawnSync } from "node:child_process" +import { + chmodSync, + closeSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" + +import { describe, expect, it, onTestFinished } from "vitest" + +/** + * Behavioral spec for the changelog updater + * (.github/scripts/update-changelog.sh): where a release entry goes in + * CHANGELOG.md, and the blank lines around it. The real script runs under + * `bash` with real python3, in a fresh directory per test, with a stub `date` + * first on PATH so every entry carries RELEASE_DATE. + */ + +const SCRIPT_PATH = resolve(import.meta.dirname, "../../.github/scripts/update-changelog.sh") + +const RELEASE_DATE = "2026-07-04" + +/** Stub `date`: prints RELEASE_DATE for the format the script asks for, and + * fails on any other call. */ +const DATE_STUB = `#!/bin/sh +if [ "$*" = "+%Y-%m-%d" ]; then + echo ${RELEASE_DATE} + exit 0 +fi +echo "date stub: unexpected arguments: $*" >&2 +exit 1 +` + +const NOTES = "### Bug Fixes\n\n- Handle a missing note (#2)\n" + +const NEWER_NOTES = "### Features\n\n- Add search (#3)\n" + +/** Two released entries in the shape the script writes them. */ +const EARLIER_ENTRIES = + "## [0.1.1] — 2026-01-02\n\n### Bug Fixes\n\n- Fix a thing (#1)\n\n" + + "## [0.1.0] — 2026-01-01\n\n- First release\n" + +const runnerPath = (): string => { + const path = process.env.PATH + + if (!path) throw new Error("the changelog tests need a PATH") + return path +} + +type ScriptRun = { status: number | null; stdout: string; stderr: string } + +type ReleaseDir = { + /** Runs the script for the version with the notes file at notesPath. With + * stdinPath, standard input comes from that file, like a shell's `<`. */ + runScript: (params: { + version: string + notesPath: string + extraEnv?: Record + stdinPath?: string + }) => ScriptRun + /** Writes the notes to a file outside the changelog's directory and runs + * the script with it; throws unless the run succeeds without output. */ + release: (params: { version: string; notes: string; extraEnv?: Record }) => void + readChangelog: () => string + notesPathFor: (version: string) => string +} + +/** A directory holding the given CHANGELOG.md, or none when changelog is null. */ +const createReleaseDir = (changelog: string | null): ReleaseDir => { + // realpath: macOS's tmpdir is a symlink. + const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "update-changelog-"))) + onTestFinished(() => rmSync(tempDir, { recursive: true, force: true })) + const stubDir = join(tempDir, "bin") + const changelogDir = join(tempDir, "repo") + const changelogPath = join(changelogDir, "CHANGELOG.md") + + mkdirSync(stubDir) + writeFileSync(join(stubDir, "date"), DATE_STUB) + chmodSync(join(stubDir, "date"), 0o755) + mkdirSync(changelogDir) + + if (changelog !== null) { + writeFileSync(changelogPath, changelog) + } + + const notesPathFor = (version: string): string => join(tempDir, `notes-${version}.md`) + + const runScript: ReleaseDir["runScript"] = ({ version, notesPath, extraEnv, stdinPath }) => { + // A file descriptor, not spawnSync's `input`: on Linux that input arrives + // on a socket, which cannot be opened as /dev/stdin. + const stdinFd = stdinPath ? openSync(stdinPath, "r") : undefined + + // 0 is a valid file descriptor, so compare with undefined. + if (stdinFd !== undefined) onTestFinished(() => closeSync(stdinFd)) + + const result = spawnSync("bash", [SCRIPT_PATH, version, notesPath], { + cwd: changelogDir, + env: { PATH: `${stubDir}:${runnerPath()}`, HOME: tempDir, ...extraEnv }, + encoding: "utf8", + stdio: [stdinFd ?? "ignore", "pipe", "pipe"], + }) + return { status: result.status, stdout: result.stdout, stderr: result.stderr } + } + + const release: ReleaseDir["release"] = ({ version, notes, extraEnv = {} }) => { + const notesPath = notesPathFor(version) + writeFileSync(notesPath, notes) + const scriptRun = runScript({ version, notesPath, extraEnv }) + + if (scriptRun.status !== 0 || scriptRun.stdout || scriptRun.stderr) { + throw new Error(`update-changelog.sh failed: ${JSON.stringify(scriptRun)}`) + } + } + + const readChangelog = (): string => readFileSync(changelogPath, "utf8") + + return { runScript, release, readChangelog, notesPathFor } +} + +describe("update-changelog.sh", () => { + describe("placing the entry", () => { + it("puts the entry under the title with one blank line on each side", () => { + const releaseDir = createReleaseDir(`# Changelog\n\n${EARLIER_ENTRIES}`) + + releaseDir.release({ version: "0.2.0", notes: NOTES }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${NOTES}\n${EARLIER_ENTRIES}`, + ) + }) + + it("leaves earlier entries byte-identical and adds no blank lines over repeated releases", () => { + const releaseDir = createReleaseDir(`# Changelog\n\n${EARLIER_ENTRIES}`) + + releaseDir.release({ version: "0.2.0", notes: NOTES }) + releaseDir.release({ version: "0.3.0", notes: NEWER_NOTES }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\n## [0.3.0] — ${RELEASE_DATE}\n\n${NEWER_NOTES}\n` + + `## [0.2.0] — ${RELEASE_DATE}\n\n${NOTES}\n${EARLIER_ENTRIES}`, + ) + }) + + it("keeps the text between the title and the first entry, then one blank line", () => { + const releaseDir = createReleaseDir( + `# Changelog\n\nAll notable changes.\n\n\n\n${EARLIER_ENTRIES}`, + ) + + releaseDir.release({ version: "0.2.0", notes: NOTES }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\nAll notable changes.\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${NOTES}\n` + + EARLIER_ENTRIES, + ) + }) + + it("keeps one blank line below the entry when the notes end in blank lines", () => { + const releaseDir = createReleaseDir(`# Changelog\n\n${EARLIER_ENTRIES}`) + + releaseDir.release({ version: "0.2.0", notes: `${NOTES}\n\n\n` }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${NOTES}\n${EARLIER_ENTRIES}`, + ) + }) + + it("keeps one blank line below the entry when the notes have no final newline", () => { + const releaseDir = createReleaseDir(`# Changelog\n\n${EARLIER_ENTRIES}`) + + releaseDir.release({ version: "0.2.0", notes: NOTES.trimEnd() }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${NOTES}\n${EARLIER_ENTRIES}`, + ) + }) + + it.each([ + { label: "ends in blank lines", earlierEntries: `${EARLIER_ENTRIES}\n\n\n` }, + { label: "has no final newline", earlierEntries: EARLIER_ENTRIES.trimEnd() }, + ])("ends the file with a single newline when the changelog $label", ({ earlierEntries }) => { + const releaseDir = createReleaseDir(`# Changelog\n\n${earlierEntries}`) + + releaseDir.release({ version: "0.2.0", notes: NOTES }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${NOTES}\n${EARLIER_ENTRIES}`, + ) + }) + }) + + describe("the title", () => { + it.each([ + { label: "missing", changelog: null }, + { label: "empty", changelog: "" }, + { label: "whitespace only", changelog: "\n\n \n" }, + ])("writes a # Changelog title when the file is $label", ({ changelog }) => { + const releaseDir = createReleaseDir(changelog) + + releaseDir.release({ version: "0.2.0", notes: NOTES }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${NOTES}`, + ) + }) + + it("adds a # Changelog title above entries that have none", () => { + const releaseDir = createReleaseDir(EARLIER_ENTRIES) + + releaseDir.release({ version: "0.2.0", notes: NOTES }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${NOTES}\n${EARLIER_ENTRIES}`, + ) + }) + + it("keeps the file's own title when no entries follow it", () => { + const releaseDir = createReleaseDir("# Release history\n") + + releaseDir.release({ version: "0.2.0", notes: NOTES }) + + expect(releaseDir.readChangelog()).toBe( + `# Release history\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${NOTES}`, + ) + }) + }) + + describe("an [Unreleased] section", () => { + it("is replaced when it sits above the newest entry", () => { + const releaseDir = createReleaseDir( + `# Changelog\n\n## [Unreleased]\n\n- pending item\n\n\n${EARLIER_ENTRIES}`, + ) + + releaseDir.release({ version: "0.2.0", notes: NOTES }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${NOTES}\n${EARLIER_ENTRIES}`, + ) + }) + + it("is replaced when it is the last section, leaving a single final newline", () => { + const releaseDir = createReleaseDir("# Changelog\n\n## [Unreleased]\n\n- pending item\n\n") + + releaseDir.release({ version: "0.2.0", notes: NOTES }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${NOTES}`, + ) + }) + + it("is replaced, and a # Changelog title added, when it opens the file", () => { + const releaseDir = createReleaseDir(`## [Unreleased]\n\n- pending item\n\n${EARLIER_ENTRIES}`) + + releaseDir.release({ version: "0.2.0", notes: NOTES }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${NOTES}\n${EARLIER_ENTRIES}`, + ) + }) + + it("is replaced by notes whose backslash sequences are written as-is", () => { + const releaseDir = createReleaseDir( + `# Changelog\n\n## [Unreleased]\n\n- pending item\n\n${EARLIER_ENTRIES}`, + ) + const backslashNotes = "### Bug Fixes\n\n- Match \\d and \\n in C:\\temp, then \\1 (#2)\n" + + releaseDir.release({ version: "0.2.0", notes: backslashNotes }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${backslashNotes}\n${EARLIER_ENTRIES}`, + ) + }) + }) + + describe("empty notes", () => { + it("writes the heading alone, with one blank line on each side", () => { + const releaseDir = createReleaseDir(`# Changelog\n\n${EARLIER_ENTRIES}`) + + releaseDir.release({ version: "0.2.0", notes: "" }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${EARLIER_ENTRIES}`, + ) + }) + + it("ends the file with a single newline after the heading when no entries follow", () => { + const releaseDir = createReleaseDir("# Changelog\n") + + releaseDir.release({ version: "0.2.0", notes: "" }) + + expect(releaseDir.readChangelog()).toBe(`# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n`) + }) + }) + + it("reads and writes UTF-8 under a locale whose encoding is ASCII", () => { + const releaseDir = createReleaseDir(`# Changelog\n\n${EARLIER_ENTRIES}`) + const nonAsciiNotes = "### ⚠ BREAKING CHANGES\n\n- Rename the café tool (#4)\n" + + releaseDir.release({ + version: "0.2.0", + notes: nonAsciiNotes, + // Python otherwise switches a C locale to UTF-8 (PEP 538, PEP 540). + extraEnv: { LC_ALL: "C", PYTHONUTF8: "0", PYTHONCOERCECLOCALE: "0" }, + }) + + expect(releaseDir.readChangelog()).toBe( + `# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${nonAsciiNotes}\n${EARLIER_ENTRIES}`, + ) + }) + + it("reads notes passed as /dev/stdin", () => { + const releaseDir = createReleaseDir(`# Changelog\n\n${EARLIER_ENTRIES}`) + const notesPath = releaseDir.notesPathFor("0.2.0") + writeFileSync(notesPath, NOTES) + + const scriptRun = releaseDir.runScript({ + version: "0.2.0", + notesPath: "/dev/stdin", + stdinPath: notesPath, + }) + + expect({ scriptRun, changelog: releaseDir.readChangelog() }).toEqual({ + scriptRun: { status: 0, stdout: "", stderr: "" }, + changelog: `# Changelog\n\n## [0.2.0] — ${RELEASE_DATE}\n\n${NOTES}\n${EARLIER_ENTRIES}`, + }) + }) + + it("fails without changing CHANGELOG.md when the notes file is missing", () => { + const changelog = `# Changelog\n\n${EARLIER_ENTRIES}` + const releaseDir = createReleaseDir(changelog) + const notesPath = releaseDir.notesPathFor("0.2.0") + + const { status, stdout, stderr } = releaseDir.runScript({ version: "0.2.0", notesPath }) + // Python's traceback ends with the error line, then a newline. + const stderrErrorLine = stderr.split("\n").at(-2) + + expect({ status, stdout, stderrErrorLine, changelog: releaseDir.readChangelog() }).toEqual({ + status: 1, + stdout: "", + stderrErrorLine: `FileNotFoundError: [Errno 2] No such file or directory: '${notesPath}'`, + changelog, + }) + }) +})