"""Release-note changelog extraction tests (DC-6, DC-7).""" import importlib.util import json import subprocess import sys from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parent.parent EXTRACTOR_PATH = REPO_ROOT / "scripts" / "extract_changelog.py" RELEASE_REQUEST_PATH = REPO_ROOT / "scripts" / "release_request.py" CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md" def _extractor_module(): spec = importlib.util.spec_from_file_location("extract_changelog", EXTRACTOR_PATH) assert spec and spec.loader module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module def test_extracts_the_requested_version_section_verbatim(): extractor = _extractor_module() changelog = """# Changelog ## [Unreleased] ## [1.4.0] - 2026-09-10 ### Added - Show a release summary to consumers. ## [1.3.0] - 2026-09-01 ### Fixed - Preserve the observation history during upgrades. """ expected = """## [1.4.0] - 2026-09-10 ### Added - Show a release summary to consumers. """ assert extractor.extract_version_section(changelog, "1.4.0") == expected def test_checked_in_changelog_keeps_unreleased_first_and_categories_limited(): lines = CHANGELOG_PATH.read_text(encoding="utf-8").splitlines() unreleased = lines.index("## [Unreleased]") version_headings = [ index for index, line in enumerate(lines) if line.startswith("## [") and line != "## [Unreleased]" ] first_version = version_headings[0] if version_headings else len(lines) categories = [ line.removeprefix("### ") for line in lines[unreleased + 1:first_version] if line.startswith("### ") ] assert unreleased < first_version assert set(categories) <= {"Added", "Changed", "Fixed"} def test_release_footer_verifies_the_clearsigned_checksum_asset(): footer = (REPO_ROOT / "packaging" / "release-footer.md").read_text( encoding="utf-8" ) assert "gpg --output SHA256SUMS --decrypt SHA256SUMS.asc" in footer assert "sha256sum -c SHA256SUMS" in footer @pytest.mark.parametrize( ("changelog", "expected_error"), [ ("# Changelog\n\n## [Unreleased]\n", "missing"), ( "# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n", "empty", ), ( "# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-02-30\n\n- Add a note.\n", "malformed release date", ), ], ) def test_fails_closed_for_missing_empty_or_malformed_sections( changelog, expected_error ): extractor = _extractor_module() with pytest.raises(extractor.ChangelogError, match=expected_error): extractor.extract_version_section(changelog, "1.4.0") def test_command_emits_a_workflow_error_and_nonzero_status(tmp_path): changelog = tmp_path / "CHANGELOG.md" changelog.write_text("# Changelog\n\n## [Unreleased]\n", encoding="utf-8") result = subprocess.run( [sys.executable, str(EXTRACTOR_PATH), str(changelog), "1.4.0"], capture_output=True, text=True, check=False, ) assert result.returncode != 0 assert result.stderr.startswith("::error::") assert "missing" in result.stderr def test_assembles_a_release_body_without_changing_the_section(): extractor = _extractor_module() section = "## [1.4.0] - 2026-09-10\n\n### Added\n\n- Show a release summary.\n" footer = "## Install\n\nUse the package channel.\n" assert extractor.assemble_release_body(section, footer) == ( section + "\n" + footer ) def test_command_can_write_the_complete_release_body(tmp_path): changelog = tmp_path / "CHANGELOG.md" changelog.write_text( "# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n" "### Added\n\n- Show a release summary.\n", encoding="utf-8", ) footer = tmp_path / "footer.md" footer.write_text("## Install\n\nUse the package channel.\n", encoding="utf-8") result = subprocess.run( [ sys.executable, str(EXTRACTOR_PATH), str(changelog), "1.4.0", "--footer", str(footer), ], capture_output=True, text=True, check=False, ) assert result.returncode == 0 assert result.stdout == ( "## [1.4.0] - 2026-09-10\n\n### Added\n\n- Show a release summary.\n\n" "## Install\n\nUse the package channel.\n" ) def test_release_request_command_reports_create_or_patch_decisions(tmp_path): body = tmp_path / "release-body.md" body.write_text("## [1.4.0] - 2026-09-10\n", encoding="utf-8") create = subprocess.run( [ sys.executable, str(RELEASE_REQUEST_PATH), "--version", "1.4.0", "--body-file", str(body), ], capture_output=True, text=True, check=False, ) existing = tmp_path / "existing-release.json" existing.write_text('{"id": 17, "assets": []}', encoding="utf-8") patch = subprocess.run( [ sys.executable, str(RELEASE_REQUEST_PATH), "--version", "1.4.0", "--body-file", str(body), "--existing-release", str(existing), ], capture_output=True, text=True, check=False, ) assert create.returncode == patch.returncode == 0 assert json.loads(create.stdout) == { "method": "POST", "path": "/releases", "payload": { "tag_name": "v1.4.0", "name": "v1.4.0", "body": "## [1.4.0] - 2026-09-10\n", }, } assert json.loads(patch.stdout) == { "method": "PATCH", "path": "/releases/17", "payload": {"body": "## [1.4.0] - 2026-09-10\n"}, }