from __future__ import annotations
import hashlib
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import generate_notes as notes
def run_git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(repo), *args],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
).stdout
class ReleaseNotesTest(unittest.TestCase):
def create_repo(self) -> Path:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
repo = Path(temporary.name)
run_git(repo, "init", "-b", "main")
run_git(repo, "config", "user.email", "release-test@example.invalid")
run_git(repo, "config", "user.name", "Release Test")
return repo
def commit(self, repo: Path, subject: str, body: str = "") -> str:
arguments = ["commit", "--allow-empty", "-m", subject]
if body:
arguments.extend(["-m", body])
run_git(repo, *arguments)
return run_git(repo, "rev-parse", "HEAD").strip()
def test_real_git_history_groups_gitcode_merge_metadata(self) -> None:
repo = self.create_repo()
self.commit(repo, "Initial release")
run_git(repo, "tag", "v0.1.0")
self.commit(
repo,
"!12 merge topic into main",
"\n".join(
(
"Support inline replies",
"",
"Created-by: example",
"Description: Closes #7, #8 and #9",
"Labels: mcp, enhancement",
"BREAKING CHANGE: update the client configuration",
)
),
)
direct_sha = self.commit(repo, "Stabilize parser errors")
run_git(repo, "tag", "v0.2.0")
result = notes.generate(
git=notes.GitRepository(repo),
tag="v0.2.0",
previous_tag="",
preview=False,
web_base_url="https://gitcode.com/example/project",
supplement="Maintainer-authored context.",
assets=(
notes.Asset("z.zip", "https://example.invalid/z.zip"),
notes.Asset("a.tar.gz", "https://example.invalid/a.tar.gz"),
),
highlight_count=3,
)
self.assertEqual(result.previous_tag, "v0.1.0")
self.assertEqual(len(result.entries), 2)
direct_entry, merge_entry = result.entries
self.assertEqual(merge_entry.title, "Support inline replies")
self.assertEqual(merge_entry.merge_request, 12)
self.assertEqual(merge_entry.issues, (7, 8, 9))
self.assertEqual(merge_entry.category, "added")
self.assertTrue(merge_entry.breaking)
self.assertEqual(direct_entry.category, "fixed")
self.assertIn(
"[!12](https://gitcode.com/example/project/merge_requests/12)",
result.content,
)
self.assertIn(
"[#9](https://gitcode.com/example/project/issues/9)",
result.content,
)
self.assertIn(
f"[{direct_sha[:8]}](https://gitcode.com/example/project/commit/{direct_sha})",
result.content,
)
self.assertIn("## Maintainer notes", result.content)
self.assertIn("Maintainer-authored context.", result.content)
self.assertLess(result.content.index("a.tar.gz"), result.content.index("z.zip"))
self.assertEqual(
result.fingerprint,
hashlib.sha256(result.content.encode("utf-8")).hexdigest(),
)
repeated = notes.generate(
git=notes.GitRepository(repo),
tag="v0.2.0",
previous_tag="",
preview=False,
web_base_url="https://gitcode.com/example/project",
supplement="Maintainer-authored context.",
assets=(
notes.Asset("z.zip", "https://example.invalid/z.zip"),
notes.Asset("a.tar.gz", "https://example.invalid/a.tar.gz"),
),
highlight_count=3,
)
self.assertEqual(repeated.content, result.content)
self.assertEqual(repeated.fingerprint, result.fingerprint)
def test_migration_word_is_not_implicitly_breaking(self) -> None:
raw = (
"a" * 40
+ "\x1f!2 merge schema into main"
+ "\x1fAdd cache migration coverage\n\n"
+ "Description: Fixes #1\n"
+ "Verification includes schema migration tests.\x1e"
)
entries = notes.parse_entries(raw)
self.assertEqual(len(entries), 1)
self.assertFalse(entries[0].breaking)
def test_preview_uses_head_and_latest_first_parent_tag(self) -> None:
repo = self.create_repo()
self.commit(repo, "Initial release")
run_git(repo, "tag", "v0.1.0")
self.commit(repo, "Add planned feature")
result = notes.generate(
git=notes.GitRepository(repo),
tag="v0.2.0",
previous_tag="",
preview=True,
web_base_url="https://gitcode.com/example/project",
supplement="",
assets=(),
highlight_count=1,
)
self.assertTrue(result.preview)
self.assertEqual(result.target, "HEAD")
self.assertEqual(result.previous_tag, "v0.1.0")
self.assertIn("pre-tag preview generated from `HEAD`", result.content)
def test_explicit_previous_tag_must_be_on_first_parent_history(self) -> None:
repo = self.create_repo()
self.commit(repo, "Initial release")
run_git(repo, "tag", "v0.1.0")
run_git(repo, "checkout", "-b", "side")
self.commit(repo, "Side release")
run_git(repo, "tag", "v9.9.9")
run_git(repo, "checkout", "main")
self.commit(repo, "Main release")
run_git(repo, "tag", "v0.2.0")
with self.assertRaisesRegex(
notes.ReleaseNotesError, "not on the first-parent history"
):
notes.generate(
git=notes.GitRepository(repo),
tag="v0.2.0",
previous_tag="v9.9.9",
preview=False,
web_base_url="https://gitcode.com/example/project",
supplement="",
assets=(),
highlight_count=1,
)
def test_automatic_previous_tag_ignores_tagged_merged_branch(self) -> None:
repo = self.create_repo()
self.commit(repo, "Initial release")
run_git(repo, "tag", "v0.1.0")
run_git(repo, "checkout", "-b", "side")
self.commit(repo, "Side branch work")
run_git(repo, "tag", "v9.9.9")
run_git(repo, "checkout", "main")
run_git(
repo,
"merge",
"--no-ff",
"side",
"-m",
"!2 merge side into main",
"-m",
"Merge side branch",
)
run_git(repo, "tag", "v0.2.0")
result = notes.generate(
git=notes.GitRepository(repo),
tag="v0.2.0",
previous_tag="",
preview=False,
web_base_url="https://gitcode.com/example/project",
supplement="",
assets=(),
highlight_count=1,
)
self.assertEqual(result.previous_tag, "v0.1.0")
def test_invalid_tag_asset_and_empty_range_fail(self) -> None:
with self.assertRaisesRegex(notes.ReleaseNotesError, "v-prefixed SemVer"):
notes.validate_tag("release-1")
with self.assertRaisesRegex(notes.ReleaseNotesError, "absolute HTTPS"):
notes.parse_asset("binary=http://example.invalid/binary")
repo = self.create_repo()
self.commit(repo, "Initial release")
run_git(repo, "tag", "v0.1.0")
run_git(repo, "tag", "v0.1.1")
with self.assertRaisesRegex(notes.ReleaseNotesError, "no delivered changes"):
notes.generate(
git=notes.GitRepository(repo),
tag="v0.1.1",
previous_tag="v0.1.0",
preview=False,
web_base_url="https://gitcode.com/example/project",
supplement="",
assets=(),
highlight_count=1,
)
def test_cli_writes_output_and_reports_fingerprint(self) -> None:
repo = self.create_repo()
self.commit(repo, "Initial release")
run_git(repo, "tag", "v0.1.0")
self.commit(repo, "Add release automation")
output = repo / "dist" / "release-notes.md"
completed = subprocess.run(
[
sys.executable,
str(SCRIPT_DIR / "generate_notes.py"),
"--repo-dir",
str(repo),
"--tag",
"v0.2.0",
"--preview",
"--previous-tag",
"v0.1.0",
"--gitcode-web-base-url",
"https://gitcode.com/example/project",
"--asset",
"binary.tar.gz=https://example.invalid/binary.tar.gz",
"--output",
str(output),
],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertTrue(output.is_file())
self.assertIn("fingerprint=", completed.stdout)
self.assertIn("## Assets", output.read_text(encoding="utf-8"))
def test_release_workflow_reuses_one_notes_file(self) -> None:
repository_root = SCRIPT_DIR.parents[1]
workflow = (
repository_root / ".github" / "workflows" / "release.yml"
).read_text(encoding="utf-8")
self.assertIn('notes_path="dist/release-notes.md"', workflow)
self.assertIn("--notes-file dist/release-notes.md", workflow)
self.assertIn('--input "$notes_path"', workflow)
self.assertLess(
workflow.index("Wait for GitCode tag mirror"),
workflow.index("Publish GitCode release"),
)
self.assertIn("scripts/release/wait_gitcode_tag.py", workflow)
self.assertIn('--expected-commit "$(git rev-parse HEAD)"', workflow)
self.assertNotIn(
"release artifacts and SHA256 checksums.", workflow
)
if __name__ == "__main__":
unittest.main()