diff --git a/docs/RELEASE_CONSOLE.md b/docs/RELEASE_CONSOLE.md index 3766677..4bbdbc5 100644 --- a/docs/RELEASE_CONSOLE.md +++ b/docs/RELEASE_CONSOLE.md @@ -49,6 +49,12 @@ tag. `source_preflight_ready` means that the plan-visible source gates pass; the non-mutating `Preview Tag + Publish` remains mandatory for remote, manifest, and immutable-tag checks. +Dashboard collection is also fail closed. Unreadable Core version metadata or a +malformed module contract is returned as a bounded `collection_errors` entry +with a remediation, marks the dashboard blocked, and becomes a structured +blocker in both full and selective release plans. The console does not silently +omit a contract or turn these source errors into an HTTP 500. + The release-control area above the repository table is read-only and is meant to become the central release cockpit. It shows: diff --git a/tests/test_release_dashboard_diagnostics.py b/tests/test_release_dashboard_diagnostics.py new file mode 100644 index 0000000..39d3949 --- /dev/null +++ b/tests/test_release_dashboard_diagnostics.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from fastapi.testclient import TestClient + + +META_ROOT = Path(__file__).resolve().parents[1] +RELEASE_ROOT = META_ROOT / "tools" / "release" +if str(RELEASE_ROOT) not in sys.path: + sys.path.insert(0, str(RELEASE_ROOT)) + +from server.app import create_app # noqa: E402 + + +class ReleaseDashboardDiagnosticsTests(unittest.TestCase): + def test_malformed_core_version_is_bounded_and_blocks_api_plans(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + core = workspace / "govoplan-core" + core.mkdir() + (core / "pyproject.toml").write_text("[project\n", encoding="utf-8") + + with TestClient(create_app(workspace_root=workspace)) as client: + dashboard_response = client.get( + "/api/dashboard", params={"public_catalog": "false"} + ) + selective_response = client.get( + "/api/selective-plan", + params={ + "repos": "govoplan-core", + "repo_versions": "govoplan-core:1.2.4", + "public_catalog": "false", + }, + ) + full_plan_response = client.get( + "/api/plan", params={"public_catalog": "false"} + ) + + self.assertEqual(200, dashboard_response.status_code) + dashboard = dashboard_response.json() + self.assertEqual("blocked", dashboard["summary"]["status"]) + error = next( + item + for item in dashboard["collection_errors"] + if item["code"] == "core_version_metadata_unreadable" + ) + self.assertEqual("govoplan-core/pyproject.toml", error["source"]) + self.assertIn("TOMLDecodeError", error["message"]) + self.assertIn("Repair govoplan-core/pyproject.toml", error["remediation"]) + self.assertLess(len(error["message"]), 200) + self.assertNotIn(str(workspace), error["message"]) + + self.assertEqual(200, selective_response.status_code) + selective_plan = selective_response.json() + self.assertEqual("blocked", selective_plan["status"]) + self.assertFalse(selective_plan["source_preflight_ready"]) + finding = next( + item + for item in selective_plan["gate_findings"] + if item["code"] == "core_version_metadata_unreadable" + ) + self.assertEqual(error["remediation"], finding["remediation"]) + self.assertEqual( + "resolve_release_gate", selective_plan["recommended_action"]["id"] + ) + + self.assertEqual(200, full_plan_response.status_code) + full_plan = full_plan_response.json() + self.assertEqual("blocked", full_plan["status"]) + self.assertTrue( + any( + action["id"].startswith("dashboard:core_version_metadata_unreadable:") + for action in full_plan["actions"] + ) + ) + + def test_malformed_manifest_is_not_silently_omitted_from_selected_plan( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + core = workspace / "govoplan-core" + core.mkdir() + (core / "pyproject.toml").write_text( + '[project]\nname = "govoplan-core"\nversion = "0.1.13"\n', + encoding="utf-8", + ) + files = workspace / "govoplan-files" + manifest = files / "src" / "govoplan_files" / "backend" / "manifest.py" + manifest.parent.mkdir(parents=True) + (files / "pyproject.toml").write_text( + '[project]\nname = "govoplan-files"\nversion = "1.2.4"\n', + encoding="utf-8", + ) + manifest.write_text("def broken(:\n", encoding="utf-8") + initialize_repository(files) + + with TestClient(create_app(workspace_root=workspace)) as client: + dashboard_response = client.get( + "/api/dashboard", params={"public_catalog": "false"} + ) + selective_response = client.get( + "/api/selective-plan", + params={ + "repos": "govoplan-files", + "repo_versions": "govoplan-files:1.2.4", + "public_catalog": "false", + }, + ) + + self.assertEqual(200, dashboard_response.status_code) + dashboard = dashboard_response.json() + error = next( + item + for item in dashboard["collection_errors"] + if item["code"] == "module_contract_unreadable" + ) + self.assertEqual("govoplan-files", error["repo"]) + self.assertEqual( + "govoplan-files/src/govoplan_files/backend/manifest.py", + error["source"], + ) + self.assertIn("SyntaxError", error["message"]) + self.assertIn("Repair the manifest", error["remediation"]) + + self.assertEqual(200, selective_response.status_code) + plan = selective_response.json() + self.assertEqual("blocked", plan["status"]) + self.assertFalse(plan["source_preflight_ready"]) + self.assertIn( + "module_contract_unreadable", + {finding["code"] for finding in plan["gate_findings"]}, + ) + self.assertEqual("resolve_release_gate", plan["recommended_action"]["id"]) + + +def initialize_repository(path: Path) -> None: + subprocess.run( + ["git", "init", "--initial-branch=main", str(path)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.run( + ["git", "-C", str(path), "add", "."], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.run( + [ + "git", + "-C", + str(path), + "-c", + "user.name=Release Test", + "-c", + "user.email=release@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "Initial fixture", + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release/govoplan_release/contracts.py b/tools/release/govoplan_release/contracts.py index 35ec568..ed75d60 100644 --- a/tools/release/govoplan_release/contracts.py +++ b/tools/release/govoplan_release/contracts.py @@ -13,6 +13,7 @@ from pathlib import Path from .model import ( CompatibilityIssue, + DashboardCollectionError, InterfaceProviderSnapshot, InterfaceRequirementSnapshot, ModuleContractSnapshot, @@ -33,6 +34,43 @@ def collect_contracts(repositories: tuple[RepositorySnapshot, ...]) -> tuple[Mod return tuple(contracts) +def collect_contracts_with_diagnostics( + repositories: tuple[RepositorySnapshot, ...], +) -> tuple[tuple[ModuleContractSnapshot, ...], tuple[DashboardCollectionError, ...]]: + """Collect readable contracts and retain bounded parse diagnostics for the UI.""" + + contracts: list[ModuleContractSnapshot] = [] + diagnostics: list[DashboardCollectionError] = [] + for repo in repositories: + if not repo.exists or not repo.is_git: + continue + root = Path(repo.absolute_path) + for manifest in sorted(root.glob("src/**/backend/manifest.py")): + try: + contract = parse_manifest_contract(manifest, repo_name=repo.spec.name) + except Exception as exc: # noqa: BLE001 - fail-closed UI diagnostic. + relative_source = manifest.relative_to(root).as_posix() + diagnostics.append( + DashboardCollectionError( + code="module_contract_unreadable", + message=( + "Module contract metadata could not be read or parsed " + f"({type(exc).__name__})." + ), + remediation=( + "Repair the manifest's Python syntax or read permissions, " + "then refresh the dashboard before planning a release." + ), + repo=repo.spec.name, + source=f"{repo.spec.name}/{relative_source}", + ) + ) + continue + if contract is not None: + contracts.append(contract) + return tuple(contracts), tuple(diagnostics) + + def validate_contracts(contracts: tuple[ModuleContractSnapshot, ...]) -> tuple[CompatibilityIssue, ...]: """Validate the statically collected module interface graph. diff --git a/tools/release/govoplan_release/dashboard.py b/tools/release/govoplan_release/dashboard.py index 0e3355b..d6e9e9d 100644 --- a/tools/release/govoplan_release/dashboard.py +++ b/tools/release/govoplan_release/dashboard.py @@ -6,10 +6,10 @@ from datetime import UTC, datetime from pathlib import Path from .catalog import collect_catalog_snapshot -from .contracts import collect_contracts +from .contracts import collect_contracts_with_diagnostics from .git_state import collect_repository_snapshot, read_pyproject_version from .migrations import collect_migration_snapshots -from .model import DashboardSummary, ReleaseDashboard, RepositorySnapshot +from .model import DashboardCollectionError, DashboardSummary, ReleaseDashboard, RepositorySnapshot from .workspace import META_ROOT, core_root, load_repository_specs, resolve_workspace_root @@ -26,10 +26,25 @@ def build_dashboard( channel: str = "stable", ) -> ReleaseDashboard: resolved_workspace = resolve_workspace_root(workspace_root) + collection_errors: list[DashboardCollectionError] = [] if check_public_catalog is None: check_public_catalog = online if target_version is None: - target_version = read_pyproject_version(core_root(resolved_workspace)) + try: + target_version = read_pyproject_version(core_root(resolved_workspace)) + except Exception as exc: # noqa: BLE001 - bounded fail-closed diagnostic. + collection_errors.append( + DashboardCollectionError( + code="core_version_metadata_unreadable", + message=f"Core version metadata could not be read or parsed ({type(exc).__name__}).", + remediation=( + "Repair govoplan-core/pyproject.toml, refresh the dashboard, " + "then rebuild the release plan." + ), + repo="govoplan-core", + source="govoplan-core/pyproject.toml", + ) + ) target_tag = f"v{target_version}" if target_version else None repositories = tuple( @@ -38,7 +53,11 @@ def build_dashboard( ) catalog = collect_catalog_snapshot(workspace_root=resolved_workspace, channel=channel, check_public=check_public_catalog) migrations = collect_migration_snapshots() if include_migrations else () - contracts = collect_contracts(repositories) if include_contracts else () + if include_contracts: + contracts, contract_errors = collect_contracts_with_diagnostics(repositories) + collection_errors.extend(contract_errors) + else: + contracts = () return ReleaseDashboard( generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"), meta_root=str(META_ROOT), @@ -47,28 +66,34 @@ def build_dashboard( target_tag=target_tag, online=online or check_remote_tags or check_public_catalog, include_migrations=include_migrations, - summary=summarize(repositories, target_tag=target_tag), + summary=summarize(repositories, target_tag=target_tag, collection_errors=tuple(collection_errors)), repositories=repositories, catalog=catalog, migrations=migrations, contracts=contracts, + collection_errors=tuple(collection_errors), ) -def summarize(repositories: tuple[RepositorySnapshot, ...], *, target_tag: str | None) -> DashboardSummary: +def summarize( + repositories: tuple[RepositorySnapshot, ...], + *, + target_tag: str | None, + collection_errors: tuple[DashboardCollectionError, ...] = (), +) -> DashboardSummary: missing_count = sum(1 for repo in repositories if not repo.exists or not repo.is_git) dirty_count = sum(1 for repo in repositories if repo.dirty) ahead_count = sum(1 for repo in repositories if repo.ahead) behind_count = sum(1 for repo in repositories if repo.behind) no_head_count = sum(1 for repo in repositories if repo.exists and repo.is_git and not repo.has_head) - error_count = sum(1 for repo in repositories if repo.errors) + error_count = sum(1 for repo in repositories if repo.errors) + len(collection_errors) safe_directory_count = sum(1 for repo in repositories if repo.safe_directory_required) local_target_tag_missing_count = ( sum(1 for repo in repositories if repo.local_target_tag_exists is False and repo.versions.primary == target_tag.removeprefix("v")) if target_tag else 0 ) - if missing_count or behind_count or safe_directory_count: + if missing_count or behind_count or safe_directory_count or collection_errors: status = "blocked" elif dirty_count or ahead_count or no_head_count or error_count or local_target_tag_missing_count: status = "attention" diff --git a/tools/release/govoplan_release/git_state.py b/tools/release/govoplan_release/git_state.py index 337752c..029d9a2 100644 --- a/tools/release/govoplan_release/git_state.py +++ b/tools/release/govoplan_release/git_state.py @@ -70,7 +70,7 @@ def collect_repository_snapshot( try: versions = collect_versions(path) except Exception as exc: # noqa: BLE001 - surfaced in the dashboard. - errors.append(str(exc)) + errors.append(f"version metadata could not be read or parsed ({type(exc).__name__})") versions = VersionSnapshot() return RepositorySnapshot( diff --git a/tools/release/govoplan_release/model.py b/tools/release/govoplan_release/model.py index 9f92f7d..3d4182a 100644 --- a/tools/release/govoplan_release/model.py +++ b/tools/release/govoplan_release/model.py @@ -111,6 +111,17 @@ class MigrationTrackSnapshot: error: str | None = None +@dataclass(frozen=True, slots=True) +class DashboardCollectionError: + """Bounded, actionable failure from read-only dashboard collection.""" + + code: str + message: str + remediation: str + repo: str | None = None + source: str | None = None + + @dataclass(frozen=True, slots=True) class DashboardSummary: repository_count: int @@ -139,6 +150,7 @@ class ReleaseDashboard: catalog: CatalogSnapshot migrations: tuple[MigrationTrackSnapshot, ...] = () contracts: tuple[ModuleContractSnapshot, ...] = () + collection_errors: tuple[DashboardCollectionError, ...] = () @dataclass(frozen=True, slots=True) diff --git a/tools/release/govoplan_release/planner.py b/tools/release/govoplan_release/planner.py index 078c8ef..1843e76 100644 --- a/tools/release/govoplan_release/planner.py +++ b/tools/release/govoplan_release/planner.py @@ -18,6 +18,17 @@ def build_release_plan(dashboard: ReleaseDashboard) -> ReleasePlan: target_version = dashboard.target_version target_tag = dashboard.target_tag + for index, error in enumerate(dashboard.collection_errors, start=1): + actions.append( + PlanAction( + id=f"dashboard:{error.code}:{index}", + title=f"Release metadata is unreadable in {error.repo or 'the workspace'}", + detail=f"{error.message} {error.remediation}", + severity="blocker", + repo=error.repo, + ) + ) + for repo in dashboard.repositories: if not repo.exists: actions.append( diff --git a/tools/release/govoplan_release/selective_planner.py b/tools/release/govoplan_release/selective_planner.py index fd79ce3..772883d 100644 --- a/tools/release/govoplan_release/selective_planner.py +++ b/tools/release/govoplan_release/selective_planner.py @@ -55,8 +55,9 @@ def build_selective_release_plan( compatibility = compatibility_issues(dashboard) steps = dry_run_steps(units=units, dashboard=dashboard, channel=channel) notes = release_notes(dashboard) - status = plan_status(units=units, compatibility=compatibility) - findings = tuple(finding for unit in units for finding in unit.gate_findings) + collection_findings = dashboard_collection_findings(dashboard) + findings = (*collection_findings, *(finding for unit in units for finding in unit.gate_findings)) + status = plan_status(units=units, compatibility=compatibility, findings=findings) return SelectiveReleasePlan( generated_at=datetime.now(tz=UTC) .replace(microsecond=0) @@ -74,11 +75,28 @@ def build_selective_release_plan( findings=findings, compatibility=compatibility, ), - source_preflight_ready=bool(units) + source_preflight_ready=not collection_findings + and bool(units) and all(unit.source_preflight_ready for unit in units), ) +def dashboard_collection_findings(dashboard: ReleaseDashboard) -> tuple[ReleaseGateFinding, ...]: + return tuple( + ReleaseGateFinding( + code=error.code, + severity="blocker", + message=error.message, + remediation=error.remediation, + repo=error.repo, + source=error.source, + expected="readable release metadata", + actual="unreadable", + ) + for error in dashboard.collection_errors + ) + + def apply_repository_version_gate( units: tuple[ReleasePlanUnit, ...], *, @@ -620,9 +638,12 @@ def release_notes(dashboard: ReleaseDashboard) -> tuple[str, ...]: def plan_status( - *, units: tuple[ReleasePlanUnit, ...], compatibility: tuple[CompatibilityIssue, ...] + *, + units: tuple[ReleasePlanUnit, ...], + compatibility: tuple[CompatibilityIssue, ...], + findings: tuple[ReleaseGateFinding, ...] = (), ) -> str: - if any(unit.blockers for unit in units) or any( + if any(finding.severity == "blocker" for finding in findings) or any(unit.blockers for unit in units) or any( issue.severity == "blocker" for issue in compatibility ): return "blocked" diff --git a/tools/release/webui/index.html b/tools/release/webui/index.html index 2afcd2a..dcb9a8e 100644 --- a/tools/release/webui/index.html +++ b/tools/release/webui/index.html @@ -968,6 +968,7 @@ metric("Ahead", s.ahead_count), metric("Behind", s.behind_count), metric("No HEAD", s.no_head_count), + metric("Errors", s.error_count), metric("Contracts", data.contracts.length), ].join(""); releaseState.dashboard = data; @@ -981,7 +982,7 @@ const selected = elements.releaseUnits ? elements.releaseUnits.querySelectorAll("[data-release-check]:checked").length : 0; const gitBlockers = (s.missing_count || 0) + (s.safe_directory_count || 0); const branchDrift = (s.ahead_count || 0) + (s.behind_count || 0); - const releaseBlockers = gitBlockers + (s.behind_count || 0) + (s.no_head_count || 0) + (s.dirty_count || 0); + const releaseBlockers = gitBlockers + (s.behind_count || 0) + (s.no_head_count || 0) + (s.dirty_count || 0) + (s.error_count || 0); const catalog = data.catalog || {}; const hasCandidate = Boolean(elements.candidateDir?.value.trim()); const catalogPublished = catalog.catalog_matches_public === true && catalog.keyring_matches_public === true;