From 5447299289a112cf6b8c2ca7d03658ef0b6efe6d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 10:37:19 +0200 Subject: [PATCH] Block mismatched selective WebUI releases --- docs/RELEASE_CONSOLE.md | 6 + tests/test_release_repository_tag.py | 73 ++++++++ tests/test_version_alignment.py | 174 +++++++++++++++++- .../govoplan_release/repository_tag.py | 20 +- .../govoplan_release/selective_catalog.py | 20 +- .../govoplan_release/selective_planner.py | 29 +++ .../govoplan_release/version_alignment.py | 115 ++++++++++++ 7 files changed, 429 insertions(+), 8 deletions(-) diff --git a/docs/RELEASE_CONSOLE.md b/docs/RELEASE_CONSOLE.md index 8daa2cd..752966b 100644 --- a/docs/RELEASE_CONSOLE.md +++ b/docs/RELEASE_CONSOLE.md @@ -193,6 +193,12 @@ forcing every repository to the same tag, but channel publication must preserve the unchanged package versions, validate interface compatibility, sign the updated catalog, and keep the published keyring healthy. +When a selected module exposes a WebUI package, its requested version must also +match Core's `webui/package.release.json` input and the resolved +`package-lock.release.json` entry. The source-tag preflight, selective plan, and +catalog-candidate writer all enforce this composition boundary. Pins for modules +that are not part of the selective release remain unchanged. + Release integration also enforces repository and composition version alignment and generates a CycloneDX SBOM from the resolved Python environment and the release WebUI lockfile. Catalog publication should attach that immutable SBOM diff --git a/tests/test_release_repository_tag.py b/tests/test_release_repository_tag.py index 6e2958c..d12bc77 100644 --- a/tests/test_release_repository_tag.py +++ b/tests/test_release_repository_tag.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import subprocess import sys import tempfile @@ -155,6 +156,78 @@ class ReleaseRepositoryTagTests(unittest.TestCase): for repository in (self.repo, self.remote, access, access_remote): self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10")) + def test_batch_preflight_blocks_selected_webui_version_missing_from_core_bundle(self) -> None: + campaign, campaign_remote = create_release_repo( + root=self.root, + workspace=self.workspace, + name="govoplan-campaign", + version="0.1.10", + ) + campaign_webui = campaign / "webui" + campaign_webui.mkdir() + (campaign_webui / "package.json").write_text( + '{"name":"@govoplan/campaign-webui","version":"0.1.10"}\n', + encoding="utf-8", + ) + git(campaign, "add", "webui/package.json") + git(campaign, "commit", "-m", "Add Campaign WebUI package") + git(campaign, "tag", "-a", "v0.1.9", "-m", "Prior Campaign release") + git(campaign, "push", "origin", "main", "refs/tags/v0.1.9") + campaign_head = git_text(campaign, "rev-parse", "HEAD") + + core_webui = self.repo / "webui" + core_webui.mkdir() + old_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v0.1.9" + (core_webui / "package.release.json").write_text( + json.dumps( + { + "name": "@govoplan/core-webui", + "version": "0.1.10", + "dependencies": {"@govoplan/campaign-webui": old_ref}, + } + ) + + "\n", + encoding="utf-8", + ) + (core_webui / "package-lock.release.json").write_text( + json.dumps( + { + "packages": { + "": { + "name": "@govoplan/core-webui", + "version": "0.1.10", + "dependencies": {"@govoplan/campaign-webui": old_ref}, + }, + "node_modules/@govoplan/campaign-webui": { + "version": "0.1.9", + "resolved": f"git+ssh://git@example.test/acme/govoplan-campaign.git#{campaign_head}", + }, + } + } + ) + + "\n", + encoding="utf-8", + ) + git(self.repo, "add", "webui/package.release.json", "webui/package-lock.release.json") + git(self.repo, "commit", "-m", "Add release WebUI composition") + git(self.repo, "push", "origin", "main") + + result = tag_repositories( + repos=("govoplan-core", "govoplan-campaign"), + repo_versions={"govoplan-core": "0.1.10", "govoplan-campaign": "0.1.10"}, + workspace_root=self.workspace, + apply=True, + push=True, + ) + + self.assertEqual("blocked", result["status"]) + self.assertIn("no selected repository was mutated", result["detail"]) + self.assertEqual("skipped", result["repositories"][0]["status"]) + self.assertEqual("blocked", result["repositories"][1]["status"]) + self.assertIn("release WebUI composition gate failed", result["repositories"][1]["detail"]) + for repository in (self.repo, self.remote, campaign, campaign_remote): + self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10")) + def test_api_requires_explicit_confirmation_and_ui_exposes_source_release(self) -> None: with TestClient(create_app(workspace_root=self.workspace)) as client: rejected = client.post( diff --git a/tests/test_version_alignment.py b/tests/test_version_alignment.py index caae2c3..457981d 100644 --- a/tests/test_version_alignment.py +++ b/tests/test_version_alignment.py @@ -16,11 +16,19 @@ if str(RELEASE_TOOLS_ROOT) not in sys.path: from govoplan_release.version_alignment import ( # noqa: E402 candidate_catalog_version_issues, release_composition_issues, + selected_release_webui_bundle_issues, repository_version_issues, selected_repository_version_issues, ) -from govoplan_release.model import RepositorySnapshot, RepositorySpec, VersionSnapshot # noqa: E402 -from govoplan_release.selective_planner import build_unit # noqa: E402 +from govoplan_release.model import ( # noqa: E402 + CatalogSnapshot, + DashboardSummary, + ReleaseDashboard, + RepositorySnapshot, + RepositorySpec, + VersionSnapshot, +) +from govoplan_release.selective_planner import build_selective_release_plan, build_unit # noqa: E402 from govoplan_release.selective_catalog import enforce_selected_version_alignment # noqa: E402 @@ -82,6 +90,168 @@ class VersionAlignmentTests(unittest.TestCase): workspace=workspace, ) + def test_selected_webui_release_must_match_core_bundle_input_and_lock(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + core_webui = workspace / "govoplan-core" / "webui" + campaign_webui = workspace / "govoplan-campaign" / "webui" + core_webui.mkdir(parents=True) + campaign_webui.mkdir(parents=True) + dependency = "@govoplan/campaign-webui" + old_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v1.2.2" + (campaign_webui / "package.json").write_text( + json.dumps({"name": dependency, "version": "1.2.3"}) + ) + (core_webui / "package.release.json").write_text( + json.dumps({"dependencies": {dependency: old_ref}}) + ) + (core_webui / "package-lock.release.json").write_text( + json.dumps( + { + "packages": { + "": {"dependencies": {dependency: old_ref}}, + f"node_modules/{dependency}": {"version": "1.2.2"}, + } + } + ) + ) + + issues = selected_release_webui_bundle_issues( + repo_versions={"govoplan-core": "2.0.0", "govoplan-campaign": "1.2.3"}, + workspace=workspace, + ) + with self.assertRaisesRegex(ValueError, "Core release WebUI dependency"): + enforce_selected_version_alignment( + repo_versions={"govoplan-campaign": "1.2.3"}, + workspace=workspace, + ) + + self.assertEqual(2, len(issues)) + self.assertTrue(all(issue.repo == "govoplan-campaign" for issue in issues)) + self.assertEqual( + { + "Core release WebUI dependency must match the selected module version", + "Core release lock must resolve the selected module version", + }, + {issue.message for issue in issues}, + ) + + def test_selected_webui_gate_ignores_unselected_module_pins(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + core_webui = workspace / "govoplan-core" / "webui" + campaign_webui = workspace / "govoplan-campaign" / "webui" + core_webui.mkdir(parents=True) + campaign_webui.mkdir(parents=True) + campaign_package = "@govoplan/campaign-webui" + files_package = "@govoplan/files-webui" + campaign_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v1.2.3" + files_ref = "git+ssh://git@example.test/acme/govoplan-files.git#v0.9.0" + (campaign_webui / "package.json").write_text( + json.dumps({"name": campaign_package, "version": "1.2.3"}) + ) + dependencies = {campaign_package: campaign_ref, files_package: files_ref} + (core_webui / "package.release.json").write_text(json.dumps({"dependencies": dependencies})) + (core_webui / "package-lock.release.json").write_text( + json.dumps( + { + "packages": { + "": {"dependencies": dependencies}, + f"node_modules/{campaign_package}": {"version": "1.2.3"}, + f"node_modules/{files_package}": {"version": "0.9.0"}, + } + } + ) + ) + + issues = selected_release_webui_bundle_issues( + repo_versions={"govoplan-campaign": "1.2.3"}, + workspace=workspace, + ) + + self.assertEqual((), issues) + + def test_selective_plan_blocks_mismatched_selected_webui_bundle(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + core_webui = workspace / "govoplan-core" / "webui" + campaign_webui = workspace / "govoplan-campaign" / "webui" + core_webui.mkdir(parents=True) + campaign_webui.mkdir(parents=True) + package_name = "@govoplan/campaign-webui" + old_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v1.2.2" + (campaign_webui / "package.json").write_text( + json.dumps({"name": package_name, "version": "1.2.3"}) + ) + (core_webui / "package.release.json").write_text( + json.dumps({"dependencies": {package_name: old_ref}}) + ) + (core_webui / "package-lock.release.json").write_text( + json.dumps( + { + "packages": { + "": {"dependencies": {package_name: old_ref}}, + f"node_modules/{package_name}": {"version": "1.2.2"}, + } + } + ) + ) + campaign = RepositorySnapshot( + spec=RepositorySpec( + name="govoplan-campaign", + category="module", + subtype="domain", + remote="git@example.test:acme/govoplan-campaign.git", + path="govoplan-campaign", + ), + absolute_path=str(workspace / "govoplan-campaign"), + exists=True, + is_git=True, + has_head=True, + branch="main", + versions=VersionSnapshot(pyproject="1.2.3", webui_package="1.2.3"), + ) + dashboard = ReleaseDashboard( + generated_at="2026-01-01T00:00:00Z", + meta_root=str(workspace / "govoplan"), + workspace_root=str(workspace), + target_version=None, + target_tag=None, + online=False, + include_migrations=False, + summary=DashboardSummary( + repository_count=1, + missing_count=0, + dirty_count=0, + ahead_count=0, + behind_count=0, + no_head_count=0, + error_count=0, + safe_directory_count=0, + local_target_tag_missing_count=0, + status="ready", + ), + repositories=(campaign,), + catalog=CatalogSnapshot( + channel="stable", + website_path="", + catalog_path="", + catalog_exists=False, + keyring_path="", + keyring_exists=False, + ), + ) + + plan = build_selective_release_plan( + dashboard, + selected_repos=("govoplan-campaign",), + repo_versions={"govoplan-campaign": "1.2.3"}, + ) + + self.assertEqual("blocked", plan.status) + self.assertEqual("blocked", plan.units[0].status) + self.assertTrue(any("Core release WebUI dependency" in item for item in plan.units[0].blockers)) + def test_selected_repository_gate_reports_missing_version_metadata(self) -> None: with tempfile.TemporaryDirectory() as tmp: workspace = Path(tmp) diff --git a/tools/release/govoplan_release/repository_tag.py b/tools/release/govoplan_release/repository_tag.py index 680fa6b..9091923 100644 --- a/tools/release/govoplan_release/repository_tag.py +++ b/tools/release/govoplan_release/repository_tag.py @@ -12,7 +12,7 @@ import sys from .git_state import collect_repository_snapshot, git_text from .model import RepositorySnapshot from .repository_push import command_text, compact_output -from .version_alignment import repository_version_issues +from .version_alignment import repository_version_issues, selected_release_webui_bundle_issues from .workspace import META_ROOT, load_repository_specs, resolve_repo_path, resolve_workspace_root @@ -41,6 +41,14 @@ def tag_repositories( specs = {spec.name: spec for spec in load_repository_specs(include_website=False)} selected = tuple(dict.fromkeys(repos)) results: list[dict[str, object]] = [] + bundle_issues_by_repo: dict[str, list[str]] = {} + for issue in selected_release_webui_bundle_issues( + repo_versions={repo: repo_versions.get(repo, "") for repo in selected}, + workspace=workspace, + ): + bundle_issues_by_repo.setdefault(issue.repo, []).append( + f"{issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})" + ) if apply: preflight = tag_repositories( @@ -147,6 +155,16 @@ def tag_repositories( ) results.append({**row, "status": "blocked", "detail": f"version alignment gate failed: {detail}"}) continue + bundle_alignment = bundle_issues_by_repo.get(repo, ()) + if bundle_alignment: + results.append( + { + **row, + "status": "blocked", + "detail": "release WebUI composition gate failed: " + "; ".join(bundle_alignment), + } + ) + continue head_commit = git_text(path, "rev-parse", "HEAD") local_commit = ref_commit(path, f"refs/tags/{tag}") diff --git a/tools/release/govoplan_release/selective_catalog.py b/tools/release/govoplan_release/selective_catalog.py index 5031435..4f3896a 100644 --- a/tools/release/govoplan_release/selective_catalog.py +++ b/tools/release/govoplan_release/selective_catalog.py @@ -28,7 +28,10 @@ from .source_provenance import ( selected_source_provenance, source_tag_provenance_issues, ) -from .version_alignment import selected_repository_version_issues +from .version_alignment import ( + selected_release_webui_bundle_issues, + selected_repository_version_issues, +) from .workspace import load_repository_specs, resolve_workspace_root, website_root GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas" @@ -197,12 +200,19 @@ def build_selective_catalog_candidate( def enforce_selected_version_alignment(*, repo_versions: dict[str, str], workspace: Path) -> None: - failures = [ - f"{issue.repo}: {issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})" - for issue in selected_repository_version_issues( + issues = ( + *selected_repository_version_issues( repo_versions=repo_versions, workspace=workspace, - ) + ), + *selected_release_webui_bundle_issues( + repo_versions=repo_versions, + workspace=workspace, + ), + ) + failures = [ + f"{issue.repo}: {issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})" + for issue in issues ] if failures: raise ValueError("Version alignment gate failed: " + "; ".join(failures)) diff --git a/tools/release/govoplan_release/selective_planner.py b/tools/release/govoplan_release/selective_planner.py index c80206d..4109c56 100644 --- a/tools/release/govoplan_release/selective_planner.py +++ b/tools/release/govoplan_release/selective_planner.py @@ -2,7 +2,9 @@ from __future__ import annotations +from dataclasses import replace from datetime import UTC, datetime +from pathlib import Path import shlex from .contracts import validate_contracts @@ -17,6 +19,7 @@ from .model import ( SelectiveReleasePlan, ModuleContractSnapshot, ) +from .version_alignment import selected_release_webui_bundle_issues def build_selective_release_plan( @@ -34,6 +37,7 @@ def build_selective_release_plan( build_unit(repo, target_version=repo_versions.get(repo.spec.name) or target_version, contracts=contracts_by_repo.get(repo.spec.name)) for repo in repositories ) + units = apply_release_webui_bundle_gate(units, workspace=Path(dashboard.workspace_root)) compatibility = compatibility_issues(dashboard) steps = dry_run_steps(units=units, dashboard=dashboard, channel=channel) notes = release_notes(dashboard) @@ -49,6 +53,31 @@ def build_selective_release_plan( ) +def apply_release_webui_bundle_gate( + units: tuple[ReleasePlanUnit, ...], + *, + workspace: Path, +) -> tuple[ReleasePlanUnit, ...]: + issues_by_repo: dict[str, list[str]] = {} + for issue in selected_release_webui_bundle_issues( + repo_versions={unit.repo: unit.target_version for unit in units}, + workspace=workspace, + ): + issues_by_repo.setdefault(issue.repo, []).append( + f"{issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})" + ) + return tuple( + replace( + unit, + status="blocked", + blockers=(*unit.blockers, *issues_by_repo[unit.repo]), + ) + if unit.repo in issues_by_repo + else unit + for unit in units + ) + + def selected_repositories(dashboard: ReleaseDashboard, *, selected_repos: tuple[str, ...]) -> tuple[RepositorySnapshot, ...]: if selected_repos: wanted = set(selected_repos) diff --git a/tools/release/govoplan_release/version_alignment.py b/tools/release/govoplan_release/version_alignment.py index fe3fb6b..bb34168 100644 --- a/tools/release/govoplan_release/version_alignment.py +++ b/tools/release/govoplan_release/version_alignment.py @@ -164,6 +164,121 @@ def selected_repository_version_issues( return tuple(issues) +def selected_release_webui_bundle_issues( + *, + repo_versions: dict[str, str], + workspace: Path, +) -> tuple[VersionAlignmentIssue, ...]: + """Require selected WebUI modules to match Core's pinned release bundle. + + Selective releases leave unselected module pins untouched. A selected module + that exposes a WebUI package, however, must be the version that Core's + immutable release package input and lockfile will actually install. + """ + + core_webui = workspace / "govoplan-core" / "webui" + release_package_path = core_webui / "package.release.json" + release_lock_path = core_webui / "package-lock.release.json" + release_package = _json_object(release_package_path) if release_package_path.exists() else {} + release_dependencies = release_package.get("dependencies") + if not isinstance(release_dependencies, dict): + release_dependencies = {} + + release_lock = _json_object(release_lock_path) if release_lock_path.exists() else {} + lock_packages = release_lock.get("packages") + if not isinstance(lock_packages, dict): + lock_packages = {} + lock_root = lock_packages.get("") + lock_root_dependencies = lock_root.get("dependencies") if isinstance(lock_root, dict) else None + if not isinstance(lock_root_dependencies, dict): + lock_root_dependencies = {} + + issues: list[VersionAlignmentIssue] = [] + for repo, requested_version in sorted(repo_versions.items()): + if repo == "govoplan-core": + continue + module_package_path = workspace / repo / "webui" / "package.json" + if not module_package_path.exists(): + continue + module_package = _json_object(module_package_path) + package_name = module_package.get("name") + if not isinstance(package_name, str) or not package_name: + issues.append( + VersionAlignmentIssue( + repo=repo, + source="webui/package.json:name", + expected="WebUI package name", + actual="", + message="selected WebUI module must declare its package name", + ) + ) + continue + + version = requested_version.removeprefix("v") + release_ref = release_dependencies.get(package_name) + release_match = _CATALOG_WEBUI_REF.search(release_ref) if isinstance(release_ref, str) else None + actual_release = ( + f"{release_match.group('repo')}@v{release_match.group('version').removeprefix('v')}" + if release_match is not None + else str(release_ref or "") + ) + expected_release = f"{repo}@v{version}" + if ( + release_match is None + or release_match.group("repo") != repo + or release_match.group("version").removeprefix("v") != version + ): + issues.append( + VersionAlignmentIssue( + repo=repo, + source=f"govoplan-core/webui/package.release.json:dependencies.{package_name}", + expected=expected_release, + actual=actual_release, + message="Core release WebUI dependency must match the selected module version", + ) + ) + + locked_root_ref = lock_root_dependencies.get(package_name) + if locked_root_ref != release_ref: + issues.append( + VersionAlignmentIssue( + repo=repo, + source=f"govoplan-core/webui/package-lock.release.json:dependencies.{package_name}", + expected=str(release_ref or ""), + actual=str(locked_root_ref or ""), + message="Core release lock root dependency must match package.release.json", + ) + ) + + locked_package = lock_packages.get(f"node_modules/{package_name}") + locked_version = locked_package.get("version") if isinstance(locked_package, dict) else None + if locked_version != version: + issues.append( + VersionAlignmentIssue( + repo=repo, + source=f"govoplan-core/webui/package-lock.release.json:{package_name}", + expected=version, + actual=str(locked_version or ""), + message="Core release lock must resolve the selected module version", + ) + ) + + expected_commit = _git_tag_commit(workspace / repo, f"v{version}") + resolved = locked_package.get("resolved") if isinstance(locked_package, dict) else None + resolved_commit = resolved.rsplit("#", 1)[-1] if isinstance(resolved, str) and "#" in resolved else None + if expected_commit is not None and resolved_commit != expected_commit: + issues.append( + VersionAlignmentIssue( + repo=repo, + source=f"govoplan-core/webui/package-lock.release.json:{package_name}:resolved", + expected=expected_commit, + actual=resolved_commit or "", + message="Core release lock commit must match the selected module tag", + ) + ) + return tuple(issues) + + def release_composition_issues(meta_root: Path, *, core_root: Path) -> tuple[VersionAlignmentIssue, ...]: """Require release references to agree and point at installable artifacts."""