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
38 changes: 35 additions & 3 deletions scripts/release_notes.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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__":
Expand Down
74 changes: 69 additions & 5 deletions tests/test_release_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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](<guides/recovery (advanced).md#resume>)\n"
"[Query](guides/recovery.md?plain=1#resume)\n"
"[Project](../README.md)\n"
Expand All @@ -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](<https://github.com/acme/project/blob/v1.2.3/docs/guides/recovery%20%28advanced%29.md#resume>)\n"
"[Query](https://github.com/acme/project/blob/v1.2.3/docs/guides/recovery.md?plain=1#resume)\n"
Expand All @@ -55,15 +55,19 @@ 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())
with tempfile.TemporaryDirectory() as directory:
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)
Expand All @@ -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__":
Expand Down
Loading