diff --git a/requirements-release.txt b/requirements-release.txt index 0a80b61..add15a6 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -10,12 +10,9 @@ govoplan-admin @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git@v0. govoplan-policy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git@v0.1.8 govoplan-audit @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git@v0.1.8 govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.8 -govoplan-addresses @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-addresses.git@v0.1.8 govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.8 govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.8 govoplan-campaign @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git@v0.1.8 govoplan-calendar @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git@v0.1.8 -govoplan-scheduling @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-scheduling.git@v0.1.8 -govoplan-notifications @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-notifications.git@v0.1.8 govoplan-docs @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git@v0.1.8 govoplan-ops @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git@v0.1.8 diff --git a/tests/test_version_alignment.py b/tests/test_version_alignment.py index 221f996..caae2c3 100644 --- a/tests/test_version_alignment.py +++ b/tests/test_version_alignment.py @@ -2,6 +2,7 @@ from __future__ import annotations import json from pathlib import Path +import subprocess import sys import tempfile import unittest @@ -179,6 +180,46 @@ class VersionAlignmentTests(unittest.TestCase): self.assertEqual("1.2.3", issues[0].expected) self.assertEqual("1.2.4", issues[0].actual) + def test_release_refs_must_point_at_installable_versioned_artifacts(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + meta = workspace / "govoplan" + core = workspace / "govoplan-core" + module = workspace / "govoplan-example" + meta.mkdir() + (core / "webui").mkdir(parents=True) + module.mkdir() + (meta / "requirements-release.txt").write_text( + "govoplan-example @ git+ssh://git@example.test/acme/govoplan-example.git@v1.2.3\n" + ) + (core / "webui" / "package.release.json").write_text( + json.dumps( + { + "dependencies": { + "@govoplan/example-webui": ( + "git+ssh://git@example.test/acme/govoplan-example.git#v1.2.3" + ) + } + } + ) + ) + subprocess.run(["git", "init", "-q", str(module)], check=True) + subprocess.run(["git", "-C", str(module), "config", "user.email", "test@example.test"], check=True) + subprocess.run(["git", "-C", str(module), "config", "user.name", "Test"], check=True) + (module / "README.md").write_text("uninstallable release\n") + subprocess.run(["git", "-C", str(module), "add", "README.md"], check=True) + subprocess.run(["git", "-C", str(module), "commit", "-qm", "release"], check=True) + subprocess.run(["git", "-C", str(module), "tag", "v1.2.3"], check=True) + + issues = release_composition_issues(meta, core_root=core) + + self.assertEqual(2, len(issues)) + self.assertEqual( + {"v1.2.3:pyproject.toml", "v1.2.3:webui/package.json"}, + {issue.source for issue in issues}, + ) + self.assertTrue(all("must contain" in issue.message for issue in issues)) + if __name__ == "__main__": unittest.main() diff --git a/tools/release/govoplan_release/version_alignment.py b/tools/release/govoplan_release/version_alignment.py index c3dcbe2..fe3fb6b 100644 --- a/tools/release/govoplan_release/version_alignment.py +++ b/tools/release/govoplan_release/version_alignment.py @@ -7,6 +7,7 @@ import json from pathlib import Path import re import subprocess +import tomllib from .git_state import collect_versions from .workspace import load_repository_specs, resolve_repo_path @@ -164,7 +165,7 @@ def selected_repository_version_issues( def release_composition_issues(meta_root: Path, *, core_root: Path) -> tuple[VersionAlignmentIssue, ...]: - """Require backend and WebUI release references for a module to agree.""" + """Require release references to agree and point at installable artifacts.""" python_refs = _python_release_refs(meta_root / "requirements-release.txt") webui_refs = _webui_release_refs(core_root / "webui" / "package.release.json") @@ -184,9 +185,74 @@ def release_composition_issues(meta_root: Path, *, core_root: Path) -> tuple[Ver message="frontend release ref must match the backend release ref", ) ) + workspace = meta_root.parent + for repo, version in sorted(python_refs.items()): + repo_path = workspace / repo + if repo_path.exists(): + issues.extend( + _release_artifact_issues( + repo=repo, + repo_path=repo_path, + version=version, + artifact="pyproject.toml", + ) + ) + for repo, version in sorted(webui_refs.items()): + repo_path = workspace / repo + if repo_path.exists(): + issues.extend( + _release_artifact_issues( + repo=repo, + repo_path=repo_path, + version=version, + artifact="webui/package.json", + ) + ) return tuple(issues) +def _release_artifact_issues( + *, + repo: str, + repo_path: Path, + version: str, + artifact: str, +) -> list[VersionAlignmentIssue]: + tag = f"v{version.removeprefix('v')}" + payload = _git_tag_file(repo_path, tag, artifact) + if payload is None: + return [ + VersionAlignmentIssue( + repo=repo, + source=f"{tag}:{artifact}", + expected="installable package metadata", + actual="missing or unreadable", + message="release tag must contain its declared package artifact", + ) + ] + try: + if artifact == "pyproject.toml": + parsed = tomllib.loads(payload) + project = parsed.get("project") + artifact_version = project.get("version") if isinstance(project, dict) else None + else: + parsed = json.loads(payload) + artifact_version = parsed.get("version") if isinstance(parsed, dict) else None + except (json.JSONDecodeError, tomllib.TOMLDecodeError): + artifact_version = None + if artifact_version == version.removeprefix("v"): + return [] + return [ + VersionAlignmentIssue( + repo=repo, + source=f"{tag}:{artifact}", + expected=version.removeprefix("v"), + actual=str(artifact_version or ""), + message="release artifact version must match its immutable tag", + ) + ] + + def candidate_catalog_version_issues(payload: object) -> tuple[VersionAlignmentIssue, ...]: """Validate versions, refs, and selected-unit provenance inside a catalog.""" @@ -514,6 +580,20 @@ def _git_tag_commit(repo_path: Path, tag: str) -> str | None: return value if result.returncode == 0 and value else None +def _git_tag_file(repo_path: Path, tag: str, relative_path: str) -> str | None: + if not (repo_path / ".git").exists(): + return None + result = subprocess.run( + ["git", "-C", str(repo_path), "show", f"{tag}:{relative_path}"], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + ) + return result.stdout if result.returncode == 0 else None + + def _json_version(path: Path) -> str | None: value = _payload_version(_json_object(path)) return value