Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 71 additions & 9 deletions .github/workflows/reusable-prepare-release-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
209 changes: 209 additions & 0 deletions tests/test_prepare_release_pr.py
Original file line number Diff line number Diff line change
@@ -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()
Loading