Block mismatched selective WebUI releases
This commit is contained in:
@@ -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
|
the unchanged package versions, validate interface compatibility, sign the
|
||||||
updated catalog, and keep the published keyring healthy.
|
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
|
Release integration also enforces repository and composition version alignment
|
||||||
and generates a CycloneDX SBOM from the resolved Python environment and the
|
and generates a CycloneDX SBOM from the resolved Python environment and the
|
||||||
release WebUI lockfile. Catalog publication should attach that immutable SBOM
|
release WebUI lockfile. Catalog publication should attach that immutable SBOM
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -155,6 +156,78 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
|||||||
for repository in (self.repo, self.remote, access, access_remote):
|
for repository in (self.repo, self.remote, access, access_remote):
|
||||||
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
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:
|
def test_api_requires_explicit_confirmation_and_ui_exposes_source_release(self) -> None:
|
||||||
with TestClient(create_app(workspace_root=self.workspace)) as client:
|
with TestClient(create_app(workspace_root=self.workspace)) as client:
|
||||||
rejected = client.post(
|
rejected = client.post(
|
||||||
|
|||||||
@@ -16,11 +16,19 @@ if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
|||||||
from govoplan_release.version_alignment import ( # noqa: E402
|
from govoplan_release.version_alignment import ( # noqa: E402
|
||||||
candidate_catalog_version_issues,
|
candidate_catalog_version_issues,
|
||||||
release_composition_issues,
|
release_composition_issues,
|
||||||
|
selected_release_webui_bundle_issues,
|
||||||
repository_version_issues,
|
repository_version_issues,
|
||||||
selected_repository_version_issues,
|
selected_repository_version_issues,
|
||||||
)
|
)
|
||||||
from govoplan_release.model import RepositorySnapshot, RepositorySpec, VersionSnapshot # noqa: E402
|
from govoplan_release.model import ( # noqa: E402
|
||||||
from govoplan_release.selective_planner import build_unit # 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
|
from govoplan_release.selective_catalog import enforce_selected_version_alignment # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@@ -82,6 +90,168 @@ class VersionAlignmentTests(unittest.TestCase):
|
|||||||
workspace=workspace,
|
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:
|
def test_selected_repository_gate_reports_missing_version_metadata(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
workspace = Path(tmp)
|
workspace = Path(tmp)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import sys
|
|||||||
from .git_state import collect_repository_snapshot, git_text
|
from .git_state import collect_repository_snapshot, git_text
|
||||||
from .model import RepositorySnapshot
|
from .model import RepositorySnapshot
|
||||||
from .repository_push import command_text, compact_output
|
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
|
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)}
|
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
||||||
selected = tuple(dict.fromkeys(repos))
|
selected = tuple(dict.fromkeys(repos))
|
||||||
results: list[dict[str, object]] = []
|
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:
|
if apply:
|
||||||
preflight = tag_repositories(
|
preflight = tag_repositories(
|
||||||
@@ -147,6 +155,16 @@ def tag_repositories(
|
|||||||
)
|
)
|
||||||
results.append({**row, "status": "blocked", "detail": f"version alignment gate failed: {detail}"})
|
results.append({**row, "status": "blocked", "detail": f"version alignment gate failed: {detail}"})
|
||||||
continue
|
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")
|
head_commit = git_text(path, "rev-parse", "HEAD")
|
||||||
local_commit = ref_commit(path, f"refs/tags/{tag}")
|
local_commit = ref_commit(path, f"refs/tags/{tag}")
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ from .source_provenance import (
|
|||||||
selected_source_provenance,
|
selected_source_provenance,
|
||||||
source_tag_provenance_issues,
|
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
|
from .workspace import load_repository_specs, resolve_workspace_root, website_root
|
||||||
|
|
||||||
GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
|
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:
|
def enforce_selected_version_alignment(*, repo_versions: dict[str, str], workspace: Path) -> None:
|
||||||
failures = [
|
issues = (
|
||||||
f"{issue.repo}: {issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})"
|
*selected_repository_version_issues(
|
||||||
for issue in selected_repository_version_issues(
|
|
||||||
repo_versions=repo_versions,
|
repo_versions=repo_versions,
|
||||||
workspace=workspace,
|
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:
|
if failures:
|
||||||
raise ValueError("Version alignment gate failed: " + "; ".join(failures))
|
raise ValueError("Version alignment gate failed: " + "; ".join(failures))
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
from .contracts import validate_contracts
|
from .contracts import validate_contracts
|
||||||
@@ -17,6 +19,7 @@ from .model import (
|
|||||||
SelectiveReleasePlan,
|
SelectiveReleasePlan,
|
||||||
ModuleContractSnapshot,
|
ModuleContractSnapshot,
|
||||||
)
|
)
|
||||||
|
from .version_alignment import selected_release_webui_bundle_issues
|
||||||
|
|
||||||
|
|
||||||
def build_selective_release_plan(
|
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))
|
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
|
for repo in repositories
|
||||||
)
|
)
|
||||||
|
units = apply_release_webui_bundle_gate(units, workspace=Path(dashboard.workspace_root))
|
||||||
compatibility = compatibility_issues(dashboard)
|
compatibility = compatibility_issues(dashboard)
|
||||||
steps = dry_run_steps(units=units, dashboard=dashboard, channel=channel)
|
steps = dry_run_steps(units=units, dashboard=dashboard, channel=channel)
|
||||||
notes = release_notes(dashboard)
|
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, ...]:
|
def selected_repositories(dashboard: ReleaseDashboard, *, selected_repos: tuple[str, ...]) -> tuple[RepositorySnapshot, ...]:
|
||||||
if selected_repos:
|
if selected_repos:
|
||||||
wanted = set(selected_repos)
|
wanted = set(selected_repos)
|
||||||
|
|||||||
@@ -164,6 +164,121 @@ def selected_repository_version_issues(
|
|||||||
return tuple(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, ...]:
|
def release_composition_issues(meta_root: Path, *, core_root: Path) -> tuple[VersionAlignmentIssue, ...]:
|
||||||
"""Require release references to agree and point at installable artifacts."""
|
"""Require release references to agree and point at installable artifacts."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user