From 04e34a3f4335bda670baf4ee038248883d519b5b Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:01:20 -0500 Subject: [PATCH] The release PR converts an Unreleased heading instead of orphaning it A repository that accumulates notes under `## Unreleased` between releases means that block: the release being cut IS those notes. The step prepended a new `## vX.Y.Z` section above it instead, which left `## Unreleased` stranded below a released version and filed the release's own notes under "Unreleased" in the published CHANGELOG.md. audiodsp had two such headings by the time v0.5.1 was cut. Now the first level-2 heading decides. If it is `## Unreleased`, that heading becomes the version and the generated git log joins the section under the notes already written. Otherwise the new section is inserted above the first level-2 heading, which also stops a `# Changelog` title and its preamble -- audiocomponents' shape -- being pushed below the release. The logic has to live in the workflow, because a reusable workflow runs against the caller's checkout and has no copy of this repository's scripts/. So tests/test_prepare_release_pr.py lifts the heredoc out of the YAML and runs it over fixture changelogs, the same way the mip synchronize tests do. Closes #40 --- .../workflows/reusable-prepare-release-pr.yml | 80 ++++++- tests/test_prepare_release_pr.py | 209 ++++++++++++++++++ 2 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 tests/test_prepare_release_pr.py diff --git a/.github/workflows/reusable-prepare-release-pr.yml b/.github/workflows/reusable-prepare-release-pr.yml index 07fc09c..c6575af 100644 --- a/.github/workflows/reusable-prepare-release-pr.yml +++ b/.github/workflows/reusable-prepare-release-pr.yml @@ -60,20 +60,82 @@ jobs: id: prep env: V: ${{ steps.version.outputs.version }} + CHANGELOG: CHANGELOG.md + BULLETS: /tmp/bullets.md run: | printf '%s\n' "$V" > VERSION LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true) RANGE=() [[ -n "$LAST_TAG" ]] && RANGE=("$LAST_TAG..HEAD") - { - echo "## v$V ($(date -u +%Y-%m-%d))" - echo - git log --no-merges --format='- %s' "${RANGE[@]}" - echo - } > /tmp/section.md - touch CHANGELOG.md - cat /tmp/section.md CHANGELOG.md > /tmp/changelog.md - mv /tmp/changelog.md CHANGELOG.md + git log --no-merges --format='- %s' "${RANGE[@]}" > "$BULLETS" + RELEASE_DATE="$(date -u +%Y-%m-%d)" python3 - <<'PY' + import os + + # Where the release section goes depends on what the changelog already + # says. A repository that accumulates notes under "## Unreleased" + # between releases means that block: the release being cut IS those + # notes, so the heading becomes the version and the git log joins the + # section. Inserting above it instead would strand the block below a + # released version and file this release's own notes under + # "Unreleased" in the published artifact (.github#40). + version = os.environ["V"] + date = os.environ["RELEASE_DATE"] + changelog = os.environ["CHANGELOG"] + heading = f"## v{version} ({date})" + + try: + with open(changelog, encoding="utf-8") as handle: + lines = handle.read().splitlines() + except FileNotFoundError: + lines = [] + + with open(os.environ["BULLETS"], encoding="utf-8") as handle: + bullets = [line for line in handle.read().splitlines() if line.strip()] + + # The first level-2 heading is where the released history starts; + # anything above it is the file's title and preamble and stays put. + first = next( + (i for i, line in enumerate(lines) if line.startswith("## ")), + len(lines), + ) + unreleased = ( + first < len(lines) + and lines[first][3:].strip().lower().rstrip(":").startswith("unreleased") + ) + + if unreleased: + # Convert the block: its heading becomes this version, and the + # generated bullets are appended after the notes already written. + end = next( + ( + i + for i in range(first + 1, len(lines)) + if lines[i].startswith("## ") + ), + len(lines), + ) + body = lines[first + 1 : end] + while body and not body[0].strip(): + body.pop(0) + while body and not body[-1].strip(): + body.pop() + section = [heading, ""] + body + if bullets: + if body: + section.append("") + section.extend(bullets) + lines[first:end] = section + [""] + else: + section = [heading, ""] + bullets + [""] + lines[first:first] = section + + # A preamble must keep its blank line before the section that follows. + if first and lines[first - 1].strip(): + lines.insert(first, "") + + with open(changelog, "w", encoding="utf-8") as handle: + handle.write("\n".join(lines).rstrip("\n") + "\n") + PY - name: Open the release PR env: diff --git a/tests/test_prepare_release_pr.py b/tests/test_prepare_release_pr.py new file mode 100644 index 0000000..9b6e4e1 --- /dev/null +++ b/tests/test_prepare_release_pr.py @@ -0,0 +1,209 @@ +"""Tests for .github/workflows/reusable-prepare-release-pr.yml. + +The changelog logic lives inline in the workflow, because the reusable +workflow runs against the *caller's* checkout and has no copy of this +repository's scripts/. So these tests lift the exact heredoc out of the YAML +and run it, rather than a transcription of it that could drift. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +PREPARE_WORKFLOW = REPO / ".github/workflows/reusable-prepare-release-pr.yml" + +VERSION = "0.5.2" +DATE = "2026-09-22" +HEADING = f"## v{VERSION} ({DATE})" + + +def changelog_python() -> str: + """The `python3 - <<'PY'` block from the Write VERSION and CHANGELOG step.""" + text = PREPARE_WORKFLOW.read_text(encoding="utf-8") + start = text.index("- name: Write VERSION and CHANGELOG") + block = text[start:] + begin = block.index("python3 - <<'PY'\n") + len("python3 - <<'PY'\n") + end = block.index("\n PY\n", begin) + return textwrap.dedent(block[begin:end]) + + +def run_changelog_step( + existing: str | None, + *, + bullets: str = "- Second commit\n- First commit\n", +) -> str: + """Run the workflow's Python over a fixture changelog, return the result.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + script = root / "write_changelog.py" + script.write_text(changelog_python(), encoding="utf-8") + + changelog = root / "CHANGELOG.md" + if existing is not None: + changelog.write_text(existing, encoding="utf-8") + + bullets_file = root / "bullets.md" + bullets_file.write_text(bullets, encoding="utf-8") + + env = os.environ.copy() + env.update( + { + "V": VERSION, + "RELEASE_DATE": DATE, + "CHANGELOG": str(changelog), + "BULLETS": str(bullets_file), + } + ) + subprocess.run( + [sys.executable, str(script)], + env=env, + check=True, + capture_output=True, + text=True, + ) + return changelog.read_text(encoding="utf-8") + + +class WriteChangelogTests(unittest.TestCase): + def test_no_changelog_yet_writes_one_section(self): + self.assertEqual( + run_changelog_step(None), + f"{HEADING}\n\n- Second commit\n- First commit\n", + ) + + def test_unreleased_heading_becomes_the_version(self): + """The bug in .github#40: the block must not be left stranded.""" + result = run_changelog_step( + "## Unreleased\n" + "\n" + "- A note written between releases\n" + "\n" + "## v0.5.1 (2026-09-21)\n" + "\n" + "- Something released\n" + ) + self.assertEqual( + result, + f"{HEADING}\n" + "\n" + "- A note written between releases\n" + "\n" + "- Second commit\n" + "- First commit\n" + "\n" + "## v0.5.1 (2026-09-21)\n" + "\n" + "- Something released\n", + ) + self.assertNotIn("## Unreleased", result) + # The released history still starts below this release, not above it. + self.assertLess(result.index(HEADING), result.index("## v0.5.1")) + + def test_unreleased_under_a_title_and_preamble(self): + """audiocomponents' shape: a `# Changelog` title and prose first.""" + result = run_changelog_step( + "# Changelog\n" + "\n" + "All notable changes are recorded here.\n" + "\n" + "## Unreleased\n" + "\n" + "### Added\n" + "\n" + "- A hand-written note\n" + "\n" + "## v0.1.1 (2026-08-01)\n" + ) + self.assertEqual( + result, + "# Changelog\n" + "\n" + "All notable changes are recorded here.\n" + "\n" + f"{HEADING}\n" + "\n" + "### Added\n" + "\n" + "- A hand-written note\n" + "\n" + "- Second commit\n" + "- First commit\n" + "\n" + "## v0.1.1 (2026-08-01)\n", + ) + + def test_no_unreleased_block_keeps_the_old_behaviour(self): + result = run_changelog_step( + "## v0.5.1 (2026-09-21)\n\n- Something released\n" + ) + self.assertEqual( + result, + f"{HEADING}\n" + "\n" + "- Second commit\n" + "- First commit\n" + "\n" + "## v0.5.1 (2026-09-21)\n" + "\n" + "- Something released\n", + ) + + def test_a_title_keeps_the_release_below_it(self): + """Without an Unreleased block the section still goes under the title.""" + result = run_changelog_step("# Changelog\n\n## v0.1.0 (2026-01-01)\n") + self.assertEqual( + result, + "# Changelog\n" + "\n" + f"{HEADING}\n" + "\n" + "- Second commit\n" + "- First commit\n" + "\n" + "## v0.1.0 (2026-01-01)\n", + ) + + def test_empty_unreleased_block_takes_only_the_log(self): + result = run_changelog_step("## Unreleased\n\n## v0.1.0 (2026-01-01)\n") + self.assertEqual( + result, + f"{HEADING}\n" + "\n" + "- Second commit\n" + "- First commit\n" + "\n" + "## v0.1.0 (2026-01-01)\n", + ) + + def test_no_commits_since_the_tag_keeps_the_written_notes(self): + result = run_changelog_step( + "## Unreleased\n\n- A note\n\n## v0.1.0 (2026-01-01)\n", + bullets="", + ) + self.assertEqual( + result, + f"{HEADING}\n" + "\n" + "- A note\n" + "\n" + "## v0.1.0 (2026-01-01)\n", + ) + + def test_an_unreleased_heading_below_a_release_is_left_alone(self): + """Only the *first* level-2 heading is the block being released.""" + result = run_changelog_step( + "## v0.5.1 (2026-09-21)\n\n- Released\n\n## Unreleased\n\n- Orphan\n" + ) + self.assertTrue(result.startswith(f"{HEADING}\n")) + self.assertIn("## Unreleased", result) + + +if __name__ == "__main__": + unittest.main()