From 24aaefb548434aa31e4f962f6c4ef4a60597636f Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Fri, 30 Oct 2026 18:22:01 -0700 Subject: [PATCH] Publish only the tagged release section --- scripts/release_notes.py | 38 +++++++++++++++++-- tests/test_release_notes.py | 74 ++++++++++++++++++++++++++++++++++--- 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/scripts/release_notes.py b/scripts/release_notes.py index fcb43dd85..482a06ec9 100644 --- a/scripts/release_notes.py +++ b/scripts/release_notes.py @@ -1,5 +1,5 @@ # Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 -"""Bind release-note links to files under the publishing repository and tag.""" +"""Render one release section with documentation links bound to its repository and tag.""" import argparse import re @@ -14,6 +14,34 @@ ) +def release_section(text: str, tag: str) -> str: + """Select an exact version heading, excluding headings inside Markdown fences.""" + headings: list[tuple[str, int]] = [] + fence: tuple[str, int] | None = None + offset = 0 + for line in text.splitlines(keepends=True): + marker = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line.rstrip("\r\n")) + if fence is not None: + if marker and marker[1][0] == fence[0] and len(marker[1]) >= fence[1] and not marker[2].strip(): + fence = None + elif marker: + fence = (marker[1][0], len(marker[1])) + else: + heading = re.match(r"^##[ \t]+(\S+)(?:[ \t].*)?$", line.rstrip("\r\n")) + if heading: + headings.append((heading[1], offset)) + offset += len(line) + if fence is not None: + raise ValueError("Release notes contain an unclosed Markdown fence") + selected = [index for index, (version, _) in enumerate(headings) if version == tag.removeprefix("v")] + if len(selected) != 1: + raise ValueError("Expected exactly one release section for " + tag) + index = selected[0] + start = headings[index][1] + end = headings[index + 1][1] if index + 1 < len(headings) else len(text) + return text[start:end].rstrip("\r\n") + + def render_notes(source: Path, root: Path, repository: str, tag: str) -> str: root, source = root.resolve(), source.resolve() source.relative_to(root) @@ -41,8 +69,12 @@ def replace(match: re.Match[str]) -> str: start, end = match.span("destination") return match[0][: start - match.start()] + normalized + match[0][end - match.start() :] - parts = CODE.split(source.read_text(encoding="utf-8")) - return "".join(part if index % 2 else LINK.sub(replace, part) for index, part in enumerate(parts)) + parts = CODE.split(release_section(source.read_text(encoding="utf-8"), tag)) + notes = "".join(part if index % 2 else LINK.sub(replace, part) for index, part in enumerate(parts)) + history = f"https://github.com/{repository}/blob/{quote(tag, safe='')}/" + quote( + source.relative_to(root).as_posix(), safe="/" + ) + return notes + f"\n\n[Full release history]({history})\n" if __name__ == "__main__": diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py index 46d9c1c8b..f7351311e 100644 --- a/tests/test_release_notes.py +++ b/tests/test_release_notes.py @@ -20,7 +20,7 @@ def test_cli_preserves_prose_while_binding_nested_links_to_the_tag(self): (root / "README.md").write_text("# Project\n") source = root / "docs/release-notes.md" source.write_text( - '# Notes\n\n[Read [nested] guide](guides/recovery.md#resume "Recovery")\n' + '## 1.2.3\n\n[Read [nested] guide](guides/recovery.md#resume "Recovery")\n' "[Spaced]()\n" "[Query](guides/recovery.md?plain=1#resume)\n" "[Project](../README.md)\n" @@ -29,7 +29,7 @@ def test_cli_preserves_prose_while_binding_nested_links_to_the_tag(self): "`[Literal](missing.md)`\n\n```md\n[Example](missing.md)\n```\n" ) expected = ( - "# Notes\n\n[Read [nested] guide](https://github.com/acme/project/blob/v1.2.3/docs/" + "## 1.2.3\n\n[Read [nested] guide](https://github.com/acme/project/blob/v1.2.3/docs/" 'guides/recovery.md#resume "Recovery")\n' "[Spaced]()\n" "[Query](https://github.com/acme/project/blob/v1.2.3/docs/guides/recovery.md?plain=1#resume)\n" @@ -55,7 +55,11 @@ def test_cli_preserves_prose_while_binding_nested_links_to_the_tag(self): text=True, ) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(output.read_text(), expected) + self.assertEqual( + output.read_text(), + expected + + "\n[Full release history](https://github.com/acme/project/blob/v1.2.3/docs/release-notes.md)\n", + ) def test_cli_corrects_every_real_v023_documentation_target(self): expected = json.loads((ROOT / "tests/fixtures/release-note-links-v023.json").read_text()) @@ -63,7 +67,7 @@ def test_cli_corrects_every_real_v023_documentation_target(self): root = Path(directory) (root / "docs").mkdir() source = root / "docs/release-notes.md" - source.write_text("\n".join(f"[Guide]({target})" for target in expected) + "\n") + source.write_text("## 0.2.23\n\n" + "\n".join(f"[Guide]({target})" for target in expected) + "\n") for target in expected: name = target.split("#", 1)[0] self.assertTrue((ROOT / "docs" / name).is_file(), name) @@ -85,7 +89,67 @@ def test_cli_corrects_every_real_v023_documentation_target(self): text=True, ) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(output.read_text(), "\n".join(f"[Guide]({target})" for target in expected.values()) + "\n") + self.assertEqual( + output.read_text(), + "## 0.2.23\n\n" + + "\n".join(f"[Guide]({target})" for target in expected.values()) + + "\n\n[Full release history](https://github.com/fireflyframework/firefly-forge/" + "blob/v0.2.23/docs/release-notes.md)\n", + ) + + +class CurrentReleaseSectionTests(unittest.TestCase): + def render(self, root, text, tag="v1.2.3"): + source, output = root / "release-notes.md", root / "published.md" + source.write_text(text) + result = subprocess.run( + [sys.executable, str(SCRIPT), str(source), str(output), "--repository", "acme/project", "--tag", tag], + cwd=root, + capture_output=True, + text=True, + ) + return result, output + + def test_large_history_is_not_a_release_body_and_fenced_headings_stay_verbatim(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + current = ( + "## 1.2.3 — Current release\n\nComplete current prose.\n\n### Details\n\n" + "```md\n## 1.2.3\n[Literal](missing.md)\n```\n\n" + "~~~~text\n## 9.9.9\n~~~\n## 8.8.8\n~~~~\n\nFinal relevant paragraph.\n" + ) + result, output = self.render( + root, "# History\n\n## 1.2.4\nNewer.\n\n" + current + "\n## 1.2.2\n" + "Old history.\n" * 20000 + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + output.read_text(), + current + "\n[Full release history](https://github.com/acme/project/blob/v1.2.3/release-notes.md)\n", + ) + + def test_current_section_is_complete_and_old_broken_links_are_not_rewritten(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + current = "## 1.2.3\n\n" + "Complete current paragraph.\n\n" * 500 + "Last authored sentence.\n" + result, output = self.render(root, current + "\n## 1.2.2\n[Old deleted guide](missing.md)\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + output.read_text(), + current + "\n[Full release history](https://github.com/acme/project/blob/v1.2.3/release-notes.md)\n", + ) + + def test_missing_or_duplicate_version_refuses_before_creating_output(self): + examples = ( + "## 1.2.30\nOther version.\n", + "```md\n## 1.2.3\n```\n## 1.2.2\nOlder.\n", + "## 1.2.3\nFirst.\n\n## 1.2.3 — Duplicate\nSecond.\n", + ) + for text in examples: + with self.subTest(text=text), tempfile.TemporaryDirectory() as directory: + result, output = self.render(Path(directory), text) + self.assertNotEqual(result.returncode, 0) + self.assertIn("exactly one release section", result.stderr) + self.assertFalse(output.exists()) if __name__ == "__main__":