Block mismatched selective WebUI releases
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 15s
Security Audit / security-audit (push) Failing after 13s

This commit is contained in:
2026-07-22 10:37:19 +02:00
parent 568ea6059a
commit 5447299289
7 changed files with 429 additions and 8 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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))

View File

@@ -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)

View File

@@ -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."""