diff --git a/tests/test_release_execution.py b/tests/test_release_execution.py index 573b89c..7b0955a 100644 --- a/tests/test_release_execution.py +++ b/tests/test_release_execution.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path import os import subprocess @@ -762,6 +763,141 @@ class ReleaseExecutionTests(unittest.TestCase): _git_output(cloned["govoplan-core"], "rev-parse", "--abbrev-ref", "HEAD"), ) + def test_catalog_synthesis_adds_exact_core_bundle_dependency(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + idm_repo, idm_remote = _tagged_repository( + workspace, + repo="govoplan-idm", + bare="idm.git", + version="0.1.8", + ) + idm_commit = _git_output(idm_repo, "rev-parse", "v0.1.8^{commit}") + (idm_repo / "pyproject.toml").write_text( + '[project]\nname = "govoplan-idm"\nversion = "0.1.9"\n', + encoding="utf-8", + ) + _git(idm_repo, "add", "pyproject.toml") + _git(idm_repo, "commit", "-m", "Next release") + _git(idm_repo, "tag", "-a", "v0.1.9", "-m", "Next release") + _git(idm_repo, "push", "origin", "main", "refs/tags/v0.1.9") + catalog_commit = _git_output(idm_repo, "rev-parse", "v0.1.9^{commit}") + core_repo = workspace / "govoplan-core" + core_remote = workspace / "core.git" + _git(workspace, "init", "--bare", str(core_remote)) + _git(workspace, "init", "-b", "main", str(core_repo)) + _git(core_repo, "config", "user.name", "Release Test") + _git(core_repo, "config", "user.email", "release@example.test") + (core_repo / "webui").mkdir() + (core_repo / "pyproject.toml").write_text( + '[project]\nname = "govoplan-core"\nversion = "1.0.0"\n', + encoding="utf-8", + ) + reference = ( + "git+ssh://git@example.test/add-ideas/" + "govoplan-idm.git#v0.1.8" + ) + (core_repo / "webui/package.release.json").write_text( + json.dumps( + { + "version": "1.0.0", + "dependencies": {"@govoplan/idm-webui": reference}, + } + ), + encoding="utf-8", + ) + (core_repo / "webui/package-lock.release.json").write_text( + json.dumps( + { + "packages": { + "": { + "version": "1.0.0", + "dependencies": { + "@govoplan/idm-webui": reference, + }, + }, + "node_modules/@govoplan/idm-webui": { + "version": "0.1.8", + "resolved": f"{reference.rsplit('#', 1)[0]}#{idm_commit}", + }, + } + } + ), + encoding="utf-8", + ) + _git(core_repo, "add", ".") + _git(core_repo, "commit", "-m", "Release") + _git(core_repo, "tag", "-a", "v1.0.0", "-m", "Release") + _git(core_repo, "remote", "add", "origin", str(core_remote)) + _git(core_repo, "push", "-u", "origin", "main", "refs/tags/v1.0.0") + specs = ( + RepositorySpec( + name="govoplan-core", + category="system", + subtype="kernel", + remote=str(core_remote), + path="govoplan-core", + ), + RepositorySpec( + name="govoplan-idm", + category="module", + subtype="platform", + remote=str(idm_remote), + path="govoplan-idm", + ), + ) + + with patch( + "govoplan_release.release_execution.load_repository_specs", + return_value=specs, + ): + synthesis = workspace / "synthesis" + synthesis.mkdir() + cloned = _clone_catalog_sources( + source_versions={"govoplan-core": "1.0.0"}, + repo_versions={}, + source_receipts={}, + workspace_root=workspace, + destination=synthesis, + ) + newer_synthesis = workspace / "newer-synthesis" + newer_synthesis.mkdir() + cloned_with_newer_catalog_source = _clone_catalog_sources( + source_versions={ + "govoplan-core": "1.0.0", + "govoplan-idm": "0.1.9", + }, + repo_versions={}, + source_receipts={}, + workspace_root=workspace, + destination=newer_synthesis, + ) + + self.assertEqual( + {"govoplan-core", "govoplan-idm"}, + set(cloned), + ) + self.assertEqual( + idm_commit, + _git_output(cloned["govoplan-idm"], "rev-parse", "HEAD"), + ) + self.assertEqual( + catalog_commit, + _git_output( + cloned_with_newer_catalog_source["govoplan-idm"], + "rev-parse", + "HEAD", + ), + ) + self.assertEqual( + idm_commit, + _git_output( + cloned_with_newer_catalog_source["govoplan-idm"], + "rev-parse", + "v0.1.8^{commit}", + ), + ) + def _repository_spec(remote: Path) -> RepositorySpec: return RepositorySpec( diff --git a/tools/release/govoplan_release/release_execution.py b/tools/release/govoplan_release/release_execution.py index 39ee8de..4bac04a 100644 --- a/tools/release/govoplan_release/release_execution.py +++ b/tools/release/govoplan_release/release_execution.py @@ -7,6 +7,7 @@ import hashlib import json import os from pathlib import Path +import re import shutil import signal import stat @@ -76,6 +77,9 @@ DURABLE_REMOTE = "origin" FLATPAK_BUILDER_PROXY = Path("/usr/bin/flatpak-spawn") HOST_BUBBLEWRAP = Path("/usr/bin/bwrap") FLATPAK_HOST_BUS = Path("/run/flatpak/bus") +_WEBUI_RELEASE_SOURCE = re.compile( + r"/(?Pgovoplan-[a-z0-9-]+)\.git#v(?P[0-9]+\.[0-9]+\.[0-9]+)$" +) class ReleaseExecutionError(RuntimeError): @@ -1631,9 +1635,122 @@ def _clone_catalog_sources( tag=f"v{version.removeprefix('v')}", source_root=destination, ) + _clone_release_bundle_sources( + result=result, + specs=specs, + source_root=destination, + ) return result +def _clone_release_bundle_sources( + *, + result: dict[str, Path], + specs: dict[str, Any], + source_root: Path, +) -> None: + """Add exact Core bundle dependencies needed only for alignment checks.""" + + core = result.get("govoplan-core") + if core is None: + raise ReleaseExecutionBlocked( + "Authenticated catalog composition has no Core source." + ) + package_path = core / "webui" / "package.release.json" + lock_path = core / "webui" / "package-lock.release.json" + if not package_path.exists() and not lock_path.exists(): + return + package = _read_bounded_release_json( + package_path, + label="Core release WebUI package", + ) + lock = _read_bounded_release_json( + lock_path, + label="Core release WebUI lock", + ) + dependencies = package.get("dependencies") + lock_packages = lock.get("packages") + if not isinstance(dependencies, dict) or not isinstance(lock_packages, dict): + raise ReleaseExecutionBlocked( + "Core release WebUI dependency metadata is incomplete." + ) + if len(dependencies) > MAX_CATALOG_SOURCE_REPOSITORIES: + raise ReleaseExecutionBlocked( + "Core release WebUI dependency set exceeds its bound." + ) + for package_name, reference in sorted(dependencies.items()): + if not isinstance(package_name, str) or not package_name.startswith( + "@govoplan/" + ): + continue + match = ( + _WEBUI_RELEASE_SOURCE.search(reference) + if isinstance(reference, str) + else None + ) + if match is None: + raise ReleaseExecutionBlocked( + f"Core release dependency {package_name} has no immutable source tag." + ) + repo = match.group("repo") + version = match.group("version") + repository = specs.get(repo) + if repository is None: + raise ReleaseExecutionBlocked( + f"Core release dependency {repo} is not registered." + ) + locked = lock_packages.get(f"node_modules/{package_name}") + resolved = locked.get("resolved") if isinstance(locked, dict) else None + locked_version = locked.get("version") if isinstance(locked, dict) else None + commit = ( + resolved.rsplit("#", 1)[-1] + if isinstance(resolved, str) and "#" in resolved + else "" + ) + if ( + locked_version != version + or not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", commit) + ): + raise ReleaseExecutionBlocked( + f"Core release dependency {repo} has no exact lock identity." + ) + if repo not in result: + if len(result) >= MAX_CATALOG_SOURCE_REPOSITORIES: + raise ReleaseExecutionBlocked( + "Release composition source set exceeds its bound." + ) + result[repo] = _checkout_registered_source_tag( + repo=repo, + source_remote=repository.remote, + tag=f"v{version}", + source_root=source_root, + ) + observed_type = git_text( + result[repo], "cat-file", "-t", f"refs/tags/v{version}" + ) + observed = ref_commit(result[repo], f"refs/tags/v{version}") + if observed_type != "tag" or observed != commit: + raise ReleaseExecutionBlocked( + f"Core release dependency {repo} lock does not match its origin tag." + ) + + +def _read_bounded_release_json(path: Path, *, label: str) -> dict[str, Any]: + try: + encoded = path.read_bytes() + except OSError as exc: + raise ReleaseExecutionBlocked(f"{label} cannot be read.") from exc + if len(encoded) > 4 * 1024 * 1024: + raise ReleaseExecutionBlocked(f"{label} exceeds its size limit.") + try: + payload = json.loads(encoded) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ReleaseExecutionBlocked(f"{label} is malformed.") from exc + if not isinstance(payload, dict): + raise ReleaseExecutionBlocked(f"{label} must be a JSON object.") + return payload + + def verify_frozen_repository_tag_receipt( *, receipt: dict[str, Any] | None, workspace_root: Path ) -> dict[str, Any]: