From 1c4661e0cc2bce6f75560274bde84552a0d78fed Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:18:57 -0400 Subject: [PATCH 01/12] ci(release): stop CHANGELOG.md gaining a blank line at every release update-changelog.sh inserted each entry with a leading newline below a header that already ended in a blank line, so every release added one more blank line under the title. It now keeps exactly one blank line on each side of the new entry, and the existing blank lines are removed. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/update-changelog.sh | 10 ++- CHANGELOG.md | 121 ---------------------------- 2 files changed, 8 insertions(+), 123 deletions(-) diff --git a/.github/scripts/update-changelog.sh b/.github/scripts/update-changelog.sh index 6fe24f8ac..e22e205e9 100755 --- a/.github/scripts/update-changelog.sh +++ b/.github/scripts/update-changelog.sh @@ -35,8 +35,14 @@ if os.path.exists(changelog_path): (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) + # Exactly one blank line on each side of the new entry, so blank lines + # under the header can't pile up across releases. + header = '\n'.join(lines[:insert_at]).rstrip() + earlier_entries = '\n'.join(lines[insert_at:]) + if earlier_entries: + text = f"{header}\n\n{entry}\n\n{earlier_entries}" + else: + text = f"{header}\n\n{entry}\n" else: text = f"# Changelog\n\n{entry}\n" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d470d029..95e9631c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,126 +1,5 @@ # Changelog - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ## [0.54.16] — 2026-10-20 ### Features From f80b14e708cec55ddf70001e683c556822730d20 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:42:08 -0400 Subject: [PATCH 02/12] ci(release): insert a release that replaces [Unreleased] the same way as any other The [Unreleased] branch passed the release notes to re.sub as its replacement, so a backslash in the notes was read as an escape (an unknown one aborted the update), and it left no blank line before the next entry. The section is now removed and the entry goes through the same insert as every release, with one blank line on each side. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/update-changelog.sh | 44 ++++++++++++++--------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/scripts/update-changelog.sh b/.github/scripts/update-changelog.sh index e22e205e9..fa4e119c0 100755 --- a/.github/scripts/update-changelog.sh +++ b/.github/scripts/update-changelog.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Update CHANGELOG.md 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 @@ -21,28 +22,27 @@ 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 - ) + # Drop an [Unreleased] section; the new entry takes its place below. Never + # pass the notes to re.sub as the replacement: it reads backslashes as escapes. + text = re.sub( + r'^## \[Unreleased\].*?(?=^## \[|\Z)', + '', + text, count=1, flags=re.DOTALL | re.MULTILINE + ) + # 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) + ) + # Exactly one blank line on each side of the new entry, so blank lines + # under the header can't pile up across releases. + header = '\n'.join(lines[:insert_at]).rstrip() + earlier_entries = '\n'.join(lines[insert_at:]) + if earlier_entries: + text = f"{header}\n\n{entry}\n\n{earlier_entries}" 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) - ) - # Exactly one blank line on each side of the new entry, so blank lines - # under the header can't pile up across releases. - header = '\n'.join(lines[:insert_at]).rstrip() - earlier_entries = '\n'.join(lines[insert_at:]) - if earlier_entries: - text = f"{header}\n\n{entry}\n\n{earlier_entries}" - else: - text = f"{header}\n\n{entry}\n" + text = f"{header}\n\n{entry}\n" else: text = f"# Changelog\n\n{entry}\n" From 068d350c557978104bc13b1eb35856d7eff949e7 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:01:28 -0400 Subject: [PATCH 03/12] docs: collapse the double blank lines between older CHANGELOG.md entries Entries from 0.13.0 down to 0.1.1 were separated by two blank lines, left by an earlier version of the changelog script. Each run is now one blank line; no entry text changes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95e9631c9..b2060f7e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2673,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 @@ -2691,7 +2689,6 @@ - Remove unneeded type - ## [0.11.3] — 2026-05-17 ### Features @@ -2706,7 +2703,6 @@ - Add gitleaks secret scanning (#38) - ## [0.11.2] — 2026-05-16 ### Documentation @@ -2718,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 @@ -2737,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 @@ -2749,7 +2742,6 @@ - Update CHANGELOG.md for v0.9.0 - ## [0.9.0] — 2026-05-16 ### Bug Fixes @@ -2765,7 +2757,6 @@ - **deps:** Update all dependencies, Node 24, TypeScript 6 (#27) - ## [0.8.1] — 2026-05-15 ### Documentation @@ -2773,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 @@ -2785,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 @@ -2801,7 +2789,6 @@ No notable changes. - Update CHANGELOG.md for v0.6.3 - ## [0.6.3] — 2026-05-14 ### Refactoring @@ -2813,7 +2800,6 @@ No notable changes. - Update CHANGELOG.md for v0.6.2 - ## [0.6.2] — 2026-05-14 ### Bug Fixes @@ -2824,7 +2810,6 @@ No notable changes. - Update CHANGELOG.md for v0.6.1 - ## [0.6.1] — 2026-05-14 ### Bug Fixes @@ -2839,7 +2824,6 @@ No notable changes. - Update .gitignore - ## [0.6.0] — 2026-05-13 ### Features @@ -2850,7 +2834,6 @@ No notable changes. - Update CHANGELOG.md for v0.5.0 - ## [0.5.0] — 2026-05-13 ### Features @@ -2873,7 +2856,6 @@ No notable changes. - **vault-mcp:** Cover server.ts helpers and mcp-router.ts (#14) - ## [0.4.0] — 2026-05-13 ### Features @@ -2888,7 +2870,6 @@ No notable changes. - Update CHANGELOG.md for v0.3.0 - ## [0.3.0] — 2026-05-12 ### Features @@ -2899,7 +2880,6 @@ No notable changes. - Update CHANGELOG.md for v0.2.0 - ## [0.2.0] — 2026-05-11 ### Features @@ -2917,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. From 4b4bbb1ae8e2fc8d1a4fd095a3d27385e9fe7692 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:15:36 -0400 Subject: [PATCH 04/12] ci(release): give an empty CHANGELOG.md the same title as a missing one A CHANGELOG.md that existed but held only whitespace got a release entry with no '# Changelog' title. A missing file is now read as empty, and an empty header falls back to the title, so both get the same first lines. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/update-changelog.sh | 55 ++++++++++++++--------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/.github/scripts/update-changelog.sh b/.github/scripts/update-changelog.sh index fa4e119c0..a187363bf 100755 --- a/.github/scripts/update-changelog.sh +++ b/.github/scripts/update-changelog.sh @@ -13,39 +13,36 @@ NOTES=$(cat "$NOTES_FILE") DATE=$(date +%Y-%m-%d) python3 - "$VERSION" "$DATE" "$NOTES" << 'PYTHON' -import sys, re, os +import sys, re +from pathlib import Path 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() - # Drop an [Unreleased] section; the new entry takes its place below. Never - # pass the notes to re.sub as the replacement: it reads backslashes as escapes. - text = re.sub( - r'^## \[Unreleased\].*?(?=^## \[|\Z)', - '', - text, count=1, flags=re.DOTALL | re.MULTILINE - ) - # 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) - ) - # Exactly one blank line on each side of the new entry, so blank lines - # under the header can't pile up across releases. - header = '\n'.join(lines[:insert_at]).rstrip() - earlier_entries = '\n'.join(lines[insert_at:]) - if earlier_entries: - text = f"{header}\n\n{entry}\n\n{earlier_entries}" - else: - text = f"{header}\n\n{entry}\n" +changelog = Path("CHANGELOG.md") +text = changelog.read_text() if changelog.exists() else "" +# Drop an [Unreleased] section; the new entry takes its place below. Never +# pass the notes to re.sub as the replacement: it reads backslashes as escapes. +text = re.sub( + r'^## \[Unreleased\].*?(?=^## \[|\Z)', + '', + text, count=1, flags=re.DOTALL | re.MULTILINE +) +# 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) +) +# Exactly one blank line on each side of the new entry, so blank lines +# under the header can't pile up across releases. A missing or empty file +# has no header, so it gets the title. +header = '\n'.join(lines[:insert_at]).rstrip() or "# Changelog" +earlier_entries = '\n'.join(lines[insert_at:]) +if earlier_entries: + text = f"{header}\n\n{entry}\n\n{earlier_entries}" else: - text = f"# Changelog\n\n{entry}\n" + text = f"{header}\n\n{entry}\n" -with open(changelog_path, 'w') as f: - f.write(text) +changelog.write_text(text) PYTHON From b15b6dd25781d168e27797d982c0521858c3abdc Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:37:16 -0400 Subject: [PATCH 05/12] ci(release): keep one blank line around an entry with empty notes With an empty notes file the entry ended in the heading's own blank line, so the separators added around it made extra blank lines. The assembled entry is now stripped of trailing whitespace. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/update-changelog.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/scripts/update-changelog.sh b/.github/scripts/update-changelog.sh index a187363bf..02ab88ced 100755 --- a/.github/scripts/update-changelog.sh +++ b/.github/scripts/update-changelog.sh @@ -17,7 +17,9 @@ import sys, re from pathlib import Path version, date, notes = sys.argv[1], sys.argv[2], sys.argv[3] -entry = f"## [{version}] — {date}\n\n{notes}" +# rstrip: empty notes would otherwise leave the heading's own blank line +# beside the separators added below. +entry = f"## [{version}] — {date}\n\n{notes}".rstrip() changelog = Path("CHANGELOG.md") text = changelog.read_text() if changelog.exists() else "" From 28b467d28e2be801746d72a8bbf55a6c41194886 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:54:28 -0400 Subject: [PATCH 06/12] fix(review): end CHANGELOG.md with one newline after removing a trailing [Unreleased] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An [Unreleased] section below the newest entry left its separating blank line at the end of the file once removed; the earlier entries are now stripped and the file ends with a single newline. Ship-Check: pr-review · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/update-changelog.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/scripts/update-changelog.sh b/.github/scripts/update-changelog.sh index 02ab88ced..b15c5b50d 100755 --- a/.github/scripts/update-changelog.sh +++ b/.github/scripts/update-changelog.sh @@ -40,9 +40,11 @@ insert_at = next( # under the header can't pile up across releases. A missing or empty file # has no header, so it gets the title. header = '\n'.join(lines[:insert_at]).rstrip() or "# Changelog" -earlier_entries = '\n'.join(lines[insert_at:]) +# rstrip: an [Unreleased] section removed from the end of the file leaves +# its separating blank line behind. +earlier_entries = '\n'.join(lines[insert_at:]).rstrip() if earlier_entries: - text = f"{header}\n\n{entry}\n\n{earlier_entries}" + text = f"{header}\n\n{entry}\n\n{earlier_entries}\n" else: text = f"{header}\n\n{entry}\n" From f2106eaf9f2c03adad370a11eadfde7ba2430e6c Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:00:59 -0400 Subject: [PATCH 07/12] style(release): gather update-changelog.sh's blank-line handling in one join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the notes file in Python instead of passing its contents as an argument, name the two regexes, split the file at the first entry heading with re.search, and build the output from one join of the stripped parts. Comments now match what the code does. Output is byte-identical. Ship-Check: code-quality · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/update-changelog.sh | 62 ++++++++++++++--------------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/.github/scripts/update-changelog.sh b/.github/scripts/update-changelog.sh index b15c5b50d..420082772 100755 --- a/.github/scripts/update-changelog.sh +++ b/.github/scripts/update-changelog.sh @@ -1,5 +1,6 @@ #!/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 # The entry goes above the newest version entry, replacing an [Unreleased] # section if there is one. @@ -9,44 +10,39 @@ 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' +python3 - "$VERSION" "$DATE" "$NOTES_FILE" << 'PYTHON' import sys, re from pathlib import Path -version, date, notes = sys.argv[1], sys.argv[2], sys.argv[3] -# rstrip: empty notes would otherwise leave the heading's own blank line -# beside the separators added below. -entry = f"## [{version}] — {date}\n\n{notes}".rstrip() +# 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() changelog = Path("CHANGELOG.md") text = changelog.read_text() if changelog.exists() else "" -# Drop an [Unreleased] section; the new entry takes its place below. Never -# pass the notes to re.sub as the replacement: it reads backslashes as escapes. -text = re.sub( - r'^## \[Unreleased\].*?(?=^## \[|\Z)', - '', - text, count=1, flags=re.DOTALL | re.MULTILINE -) -# 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) -) -# Exactly one blank line on each side of the new entry, so blank lines -# under the header can't pile up across releases. A missing or empty file -# has no header, so it gets the title. -header = '\n'.join(lines[:insert_at]).rstrip() or "# Changelog" -# rstrip: an [Unreleased] section removed from the end of the file leaves -# its separating blank line behind. -earlier_entries = '\n'.join(lines[insert_at:]).rstrip() -if earlier_entries: - text = f"{header}\n\n{entry}\n\n{earlier_entries}\n" -else: - text = f"{header}\n\n{entry}\n" - -changelog.write_text(text) +# Remove an [Unreleased] section, and insert the entry at that spot separately: +# 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 title becomes the header. +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") PYTHON From e5cc501f7342dd5a9b8e844b55c362a4bf810570 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:02:01 -0400 Subject: [PATCH 08/12] ci(release): read and write the changelog as UTF-8 whatever the locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry heading holds an em dash, so writing it under a non-UTF-8 locale failed. The notes and CHANGELOG.md are now read and written with an explicit UTF-8 encoding. Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/update-changelog.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/scripts/update-changelog.sh b/.github/scripts/update-changelog.sh index 420082772..7f856485f 100755 --- a/.github/scripts/update-changelog.sh +++ b/.github/scripts/update-changelog.sh @@ -25,10 +25,10 @@ UNRELEASED_SECTION = re.compile( ENTRY_HEADING = re.compile(r'^## \[', re.MULTILINE) version, date, notes_file = sys.argv[1:] -notes = Path(notes_file).read_text() +notes = Path(notes_file).read_text(encoding="utf-8") changelog = Path("CHANGELOG.md") -text = changelog.read_text() if changelog.exists() else "" +text = changelog.read_text(encoding="utf-8") if changelog.exists() else "" # Remove an [Unreleased] section, and insert the entry at that spot separately: # re.sub reads backslashes in a replacement string as escapes. text = UNRELEASED_SECTION.sub('', text, count=1) @@ -44,5 +44,5 @@ 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") +changelog.write_text("\n\n".join(part for part in parts if part) + "\n", encoding="utf-8") PYTHON From 0d38547bbdd6c56d5b287decafb2f02d06ac38d6 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:09:14 -0400 Subject: [PATCH 09/12] test(release): cover update-changelog.sh's entry placement and blank lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the real script under bash in a temporary directory per test, with a stub date on PATH, and asserts the whole CHANGELOG.md it writes: the title for missing, empty, whitespace-only and title-less files; one blank line around the entry across repeated releases; [Unreleased] replaced above an entry, as the last section, or opening the file; backslashes in the notes written as-is; empty notes; UTF-8 under an ASCII locale; and a missing notes file leaving CHANGELOG.md untouched. Ship-Check: test-audit · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- src/__tests__/update-changelog.test.ts | 318 +++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 src/__tests__/update-changelog.test.ts diff --git a/src/__tests__/update-changelog.test.ts b/src/__tests__/update-changelog.test.ts new file mode 100644 index 000000000..70100e29d --- /dev/null +++ b/src/__tests__/update-changelog.test.ts @@ -0,0 +1,318 @@ +import { spawnSync } from "node:child_process" +import { + chmodSync, + mkdirSync, + mkdtempSync, + 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. */ + runScript: (params: { + version: string + notesPath: string + extraEnv?: Record + }) => 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 }) => { + const result = spawnSync("bash", [SCRIPT_PATH, version, notesPath], { + cwd: changelogDir, + env: { PATH: `${stubDir}:${runnerPath()}`, HOME: tempDir, ...extraEnv }, + encoding: "utf8", + }) + 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("ends the file with a single newline when the changelog ends in blank lines", () => { + const releaseDir = createReleaseDir(`# Changelog\n\n${EARLIER_ENTRIES}\n\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}\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("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, + }) + }) +}) From 117d471152a6e19d5f90a0ba12cc8c1dc829770c Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:19:50 -0400 Subject: [PATCH 10/12] ci(release): let update-changelog.sh read its notes from /dev/stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python program came in on stdin through a heredoc, so a notes file of /dev/stdin read the already-consumed heredoc and the entry was written with no notes, without an error. The program now comes in on fd 3, leaving stdin to the caller. Two comments are reworded to match the code: the [Unreleased] comment no longer says the entry goes where the section was (it goes above the newest entry), and the header comment names the # Changelog title it supplies. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/update-changelog.sh | 8 +++++--- src/__tests__/update-changelog.test.ts | 22 ++++++++++++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/scripts/update-changelog.sh b/.github/scripts/update-changelog.sh index 7f856485f..50ef7c2ee 100755 --- a/.github/scripts/update-changelog.sh +++ b/.github/scripts/update-changelog.sh @@ -12,7 +12,9 @@ NOTES_FILE="${2:?Usage: update-changelog.sh }" DATE=$(date +%Y-%m-%d) -python3 - "$VERSION" "$DATE" "$NOTES_FILE" << 'PYTHON' +# 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 @@ -29,14 +31,14 @@ 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, and insert the entry at that spot separately: +# 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 title becomes the header. +# 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() diff --git a/src/__tests__/update-changelog.test.ts b/src/__tests__/update-changelog.test.ts index 70100e29d..d47b36d70 100644 --- a/src/__tests__/update-changelog.test.ts +++ b/src/__tests__/update-changelog.test.ts @@ -55,11 +55,13 @@ const runnerPath = (): string => { type ScriptRun = { status: number | null; stdout: string; stderr: string } type ReleaseDir = { - /** Runs the script for the version with the notes file at notesPath. */ + /** Runs the script for the version with the notes file at notesPath, + * writing stdin, when given, to the script's standard input. */ runScript: (params: { version: string notesPath: string extraEnv?: Record + stdin?: 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. */ @@ -88,11 +90,12 @@ const createReleaseDir = (changelog: string | null): ReleaseDir => { const notesPathFor = (version: string): string => join(tempDir, `notes-${version}.md`) - const runScript: ReleaseDir["runScript"] = ({ version, notesPath, extraEnv }) => { + const runScript: ReleaseDir["runScript"] = ({ version, notesPath, extraEnv, stdin }) => { const result = spawnSync("bash", [SCRIPT_PATH, version, notesPath], { cwd: changelogDir, env: { PATH: `${stubDir}:${runnerPath()}`, HOME: tempDir, ...extraEnv }, encoding: "utf8", + input: stdin, }) return { status: result.status, stdout: result.stdout, stderr: result.stderr } } @@ -299,6 +302,21 @@ describe("update-changelog.sh", () => { ) }) + it("reads notes piped to /dev/stdin", () => { + const releaseDir = createReleaseDir(`# Changelog\n\n${EARLIER_ENTRIES}`) + + const scriptRun = releaseDir.runScript({ + version: "0.2.0", + notesPath: "/dev/stdin", + stdin: NOTES, + }) + + 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) From 3a9f5a27d80542af5a76af96920811c9b415ea03 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:37:56 -0400 Subject: [PATCH 11/12] test(release): feed /dev/stdin from a file so the test holds on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spawnSync's input reaches the child through a socket on Linux, and a socket cannot be opened as /dev/stdin, so the test failed in CI while passing on macOS. Standard input now comes from the notes file's descriptor, as a shell redirect gives it. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- src/__tests__/update-changelog.test.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/__tests__/update-changelog.test.ts b/src/__tests__/update-changelog.test.ts index d47b36d70..a825848fe 100644 --- a/src/__tests__/update-changelog.test.ts +++ b/src/__tests__/update-changelog.test.ts @@ -1,8 +1,10 @@ import { spawnSync } from "node:child_process" import { chmodSync, + closeSync, mkdirSync, mkdtempSync, + openSync, readFileSync, realpathSync, rmSync, @@ -55,13 +57,13 @@ const runnerPath = (): string => { type ScriptRun = { status: number | null; stdout: string; stderr: string } type ReleaseDir = { - /** Runs the script for the version with the notes file at notesPath, - * writing stdin, when given, to the script's standard input. */ + /** 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 - stdin?: string + 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. */ @@ -90,12 +92,19 @@ const createReleaseDir = (changelog: string | null): ReleaseDir => { const notesPathFor = (version: string): string => join(tempDir, `notes-${version}.md`) - const runScript: ReleaseDir["runScript"] = ({ version, notesPath, extraEnv, stdin }) => { + 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", - input: stdin, + stdio: [stdinFd ?? "ignore", "pipe", "pipe"], }) return { status: result.status, stdout: result.stdout, stderr: result.stderr } } @@ -302,13 +311,15 @@ describe("update-changelog.sh", () => { ) }) - it("reads notes piped to /dev/stdin", () => { + 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", - stdin: NOTES, + stdinPath: notesPath, }) expect({ scriptRun, changelog: releaseDir.readChangelog() }).toEqual({ From 3e8200276941777202b9183dbc87893a680ebd91 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:43:49 -0400 Subject: [PATCH 12/12] test(release): cover a changelog that has no final newline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- src/__tests__/update-changelog.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/__tests__/update-changelog.test.ts b/src/__tests__/update-changelog.test.ts index a825848fe..0edd02dd4 100644 --- a/src/__tests__/update-changelog.test.ts +++ b/src/__tests__/update-changelog.test.ts @@ -181,8 +181,11 @@ describe("update-changelog.sh", () => { ) }) - it("ends the file with a single newline when the changelog ends in blank lines", () => { - const releaseDir = createReleaseDir(`# Changelog\n\n${EARLIER_ENTRIES}\n\n\n`) + 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 })