diff --git a/docs/RELEASE_CONSOLE.md b/docs/RELEASE_CONSOLE.md index 752966b..3766677 100644 --- a/docs/RELEASE_CONSOLE.md +++ b/docs/RELEASE_CONSOLE.md @@ -39,6 +39,16 @@ shows the dry-run commands for the selected rows, and `Generate Candidate` creates a signed catalog candidate that advances only selected repositories that already have a catalog entry. +`Build Plan` also returns structured release-gate findings for each selected +repository. The plan names the recommended next action and gives an explicit +remediation for source-version, lockfile, Core WebUI composition, Git state, and +worktree findings. A target version that has not yet been applied consistently +to the selected source tree is therefore explained before the source-tag +preflight rather than appearing only as an error after the operator tries to +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. + 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_plan_guidance.py b/tests/test_release_plan_guidance.py new file mode 100644 index 0000000..3ce6625 --- /dev/null +++ b/tests/test_release_plan_guidance.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + + +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 govoplan_release.model import ( # noqa: E402 + CatalogSnapshot, + DashboardSummary, + ReleaseDashboard, + RepositorySnapshot, + RepositorySpec, + VersionSnapshot, +) +from govoplan_release.selective_planner import build_selective_release_plan # noqa: E402 + + +class ReleasePlanGuidanceTests(unittest.TestCase): + def test_unprepared_target_has_structured_remediation_and_recommendation( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + repo_path = workspace / "govoplan-files" + repo_path.mkdir() + (repo_path / "pyproject.toml").write_text( + '[project]\nname = "govoplan-files"\nversion = "1.2.3"\n', + encoding="utf-8", + ) + plan = build_selective_release_plan( + dashboard(workspace=workspace, version="1.2.3"), + selected_repos=("govoplan-files",), + repo_versions={"govoplan-files": "1.2.4"}, + ) + + self.assertEqual("blocked", plan.status) + self.assertFalse(plan.source_preflight_ready) + finding = next( + item + for item in plan.gate_findings + if item.code == "repository_version_alignment" + ) + self.assertEqual("govoplan-files", finding.repo) + self.assertEqual("pyproject.toml", finding.source) + self.assertEqual("1.2.4", finding.expected) + self.assertEqual("1.2.3", finding.actual) + self.assertIn("Regenerate lockfiles", finding.remediation) + self.assertEqual("resolve_release_gate", plan.recommended_action.id) + self.assertEqual("govoplan-files", plan.recommended_action.repo) + + def test_aligned_target_recommends_non_mutating_tag_preview(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + repo_path = workspace / "govoplan-files" + repo_path.mkdir() + (repo_path / "pyproject.toml").write_text( + '[project]\nname = "govoplan-files"\nversion = "1.2.4"\n', + encoding="utf-8", + ) + plan = build_selective_release_plan( + dashboard(workspace=workspace, version="1.2.4"), + selected_repos=("govoplan-files",), + repo_versions={"govoplan-files": "1.2.4"}, + ) + + self.assertTrue(plan.source_preflight_ready) + self.assertEqual("preview_source_release", plan.recommended_action.id) + self.assertIn("Preview Tag + Publish", plan.recommended_action.remediation) + + def test_webui_renders_recommendation_and_remediation(self) -> None: + webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8") + + self.assertIn("plan.gate_findings", webui) + self.assertIn("plan.recommended_action", webui) + self.assertIn("recommended next", webui) + self.assertIn('plan.status === "blocked" ? "block"', webui) + self.assertIn('pill("recommended next", recommendationKind)', webui) + self.assertIn("Remediation:", webui) + + +def dashboard(*, workspace: Path, version: str) -> ReleaseDashboard: + repo = RepositorySnapshot( + spec=RepositorySpec( + name="govoplan-files", + category="module", + subtype="infrastructure", + remote="git@example.test:add-ideas/govoplan-files.git", + path="govoplan-files", + ), + absolute_path=str(workspace / "govoplan-files"), + exists=True, + is_git=True, + has_head=True, + branch="main", + versions=VersionSnapshot(pyproject=version), + local_target_tag_exists=False, + ) + return ReleaseDashboard( + generated_at="2026-07-22T00: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=(repo,), + catalog=CatalogSnapshot( + channel="stable", + website_path="", + catalog_path="", + catalog_exists=False, + keyring_path="", + keyring_exists=False, + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_version_alignment.py b/tests/test_version_alignment.py index 457981d..f1b4e30 100644 --- a/tests/test_version_alignment.py +++ b/tests/test_version_alignment.py @@ -251,6 +251,14 @@ class VersionAlignmentTests(unittest.TestCase): 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)) + finding = next( + item + for item in plan.gate_findings + if item.code == "release_webui_composition_alignment" + ) + self.assertIn("package.release.json", finding.source) + self.assertIn("regenerate webui/package-lock.release.json", finding.remediation) + self.assertEqual("resolve_release_gate", plan.recommended_action.id) def test_selected_repository_gate_reports_missing_version_metadata(self) -> None: with tempfile.TemporaryDirectory() as tmp: diff --git a/tools/release/govoplan_release/model.py b/tools/release/govoplan_release/model.py index e5f655a..9f92f7d 100644 --- a/tools/release/govoplan_release/model.py +++ b/tools/release/govoplan_release/model.py @@ -186,6 +186,18 @@ class ModuleContractSnapshot: requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = () +@dataclass(frozen=True, slots=True) +class ReleaseGateFinding: + code: str + severity: str + message: str + remediation: str + repo: str | None = None + source: str | None = None + expected: str | None = None + actual: str | None = None + + @dataclass(frozen=True, slots=True) class ReleasePlanUnit: repo: str @@ -200,6 +212,8 @@ class ReleasePlanUnit: warnings: tuple[str, ...] = () provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = () requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = () + gate_findings: tuple[ReleaseGateFinding, ...] = () + source_preflight_ready: bool = False @dataclass(frozen=True, slots=True) @@ -224,6 +238,15 @@ class ReleasePlanStep: status: str = "planned" +@dataclass(frozen=True, slots=True) +class ReleaseRecommendedAction: + id: str + title: str + detail: str + remediation: str + repo: str | None = None + + @dataclass(frozen=True, slots=True) class SelectiveReleasePlan: generated_at: str @@ -233,6 +256,9 @@ class SelectiveReleasePlan: compatibility: tuple[CompatibilityIssue, ...] dry_run_steps: tuple[ReleasePlanStep, ...] notes: tuple[str, ...] = () + gate_findings: tuple[ReleaseGateFinding, ...] = () + recommended_action: ReleaseRecommendedAction | None = None + source_preflight_ready: bool = False @dataclass(frozen=True, slots=True) diff --git a/tools/release/govoplan_release/selective_planner.py b/tools/release/govoplan_release/selective_planner.py index 4109c56..c4b7749 100644 --- a/tools/release/govoplan_release/selective_planner.py +++ b/tools/release/govoplan_release/selective_planner.py @@ -12,14 +12,19 @@ from .model import ( CompatibilityIssue, InterfaceProviderSnapshot, InterfaceRequirementSnapshot, + ReleaseGateFinding, ReleaseDashboard, ReleasePlanStep, ReleasePlanUnit, + ReleaseRecommendedAction, RepositorySnapshot, SelectiveReleasePlan, ModuleContractSnapshot, ) -from .version_alignment import selected_release_webui_bundle_issues +from .version_alignment import ( + selected_release_webui_bundle_issues, + selected_repository_version_issues, +) def build_selective_release_plan( @@ -37,11 +42,13 @@ 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_repository_version_gate(units, workspace=Path(dashboard.workspace_root)) 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) status = plan_status(units=units, compatibility=compatibility) + findings = tuple(finding for unit in units for finding in unit.gate_findings) return SelectiveReleasePlan( generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"), target_channel=channel, @@ -50,6 +57,59 @@ def build_selective_release_plan( compatibility=compatibility, dry_run_steps=steps, notes=notes, + gate_findings=findings, + recommended_action=recommended_action( + units=units, + findings=findings, + compatibility=compatibility, + ), + source_preflight_ready=bool(units) and all(unit.source_preflight_ready for unit in units), + ) + + +def apply_repository_version_gate( + units: tuple[ReleasePlanUnit, ...], + *, + workspace: Path, +) -> tuple[ReleasePlanUnit, ...]: + issues_by_repo: dict[str, list[ReleaseGateFinding]] = {} + for issue in selected_repository_version_issues( + repo_versions={unit.repo: unit.target_version for unit in units}, + workspace=workspace, + ): + issues_by_repo.setdefault(issue.repo, []).append( + ReleaseGateFinding( + code="repository_version_alignment", + severity="blocker", + message=issue.message, + remediation=( + f"Align {issue.source} and every other version-bearing source declaration to " + f"{issue.expected!r}. Regenerate lockfiles instead of editing resolved entries by hand, " + "commit the reviewed changes, then rebuild this plan." + ), + repo=issue.repo, + source=issue.source, + expected=issue.expected, + actual=issue.actual, + ) + ) + return tuple( + replace( + unit, + status="blocked", + blockers=( + *unit.blockers, + *( + f"{finding.source}={finding.actual!r}, expected {finding.expected!r} ({finding.message})" + for finding in issues_by_repo[unit.repo] + ), + ), + gate_findings=(*unit.gate_findings, *issues_by_repo[unit.repo]), + source_preflight_ready=False, + ) + if unit.repo in issues_by_repo + else unit + for unit in units ) @@ -58,19 +118,39 @@ def apply_release_webui_bundle_gate( *, workspace: Path, ) -> tuple[ReleasePlanUnit, ...]: - issues_by_repo: dict[str, list[str]] = {} + issues_by_repo: dict[str, list[ReleaseGateFinding]] = {} 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})" + ReleaseGateFinding( + code="release_webui_composition_alignment", + severity="blocker", + message=issue.message, + remediation=( + "Update Core's webui/package.release.json dependency to the selected module tag, " + "regenerate webui/package-lock.release.json, commit both files, then rebuild this plan." + ), + repo=issue.repo, + source=issue.source, + expected=issue.expected, + actual=issue.actual, + ) ) return tuple( replace( unit, status="blocked", - blockers=(*unit.blockers, *issues_by_repo[unit.repo]), + blockers=( + *unit.blockers, + *( + f"{finding.source}={finding.actual!r}, expected {finding.expected!r} ({finding.message})" + for finding in issues_by_repo[unit.repo] + ), + ), + gate_findings=(*unit.gate_findings, *issues_by_repo[unit.repo]), + source_preflight_ready=False, ) if unit.repo in issues_by_repo else unit @@ -95,16 +175,57 @@ def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contract target_tag = f"v{resolved_target}" blockers: list[str] = [] warnings: list[str] = [] + findings: list[ReleaseGateFinding] = [] if not repo.exists: blockers.append("repository is missing locally") + findings.append( + gate_finding( + repo=repo.spec.name, + code="repository_missing", + message="Repository is missing locally.", + remediation="Clone or bootstrap the registered repository, then refresh the dashboard.", + ) + ) elif not repo.is_git: blockers.append("path is not a Git repository") + findings.append( + gate_finding( + repo=repo.spec.name, + code="repository_not_git", + message="Registered path is not a Git repository.", + remediation="Restore the registered checkout as a Git repository, then refresh the dashboard.", + ) + ) elif repo.safe_directory_required: blockers.append("Git safe.directory approval is required") + findings.append( + gate_finding( + repo=repo.spec.name, + code="git_safe_directory", + message="Git safe.directory approval is required.", + remediation="Review checkout ownership and apply Git safe.directory approval only if this path is trusted.", + ) + ) elif repo.behind: blockers.append(f"repository is behind {repo.upstream}") + findings.append( + gate_finding( + repo=repo.spec.name, + code="repository_behind", + message=f"Repository is behind {repo.upstream}.", + remediation="Fetch and integrate the upstream changes, then rebuild the plan from the reviewed HEAD.", + ) + ) if repo.exists and repo.is_git and not repo.has_head: blockers.append("repository has no initial commit") + findings.append( + gate_finding( + repo=repo.spec.name, + code="repository_no_head", + message="Repository has no initial commit.", + remediation="Create and publish a reviewed initial commit before planning its first release.", + ) + ) local_versions = tuple( value for value in ( @@ -118,12 +239,57 @@ def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contract ) if len(set(local_versions)) > 1: blockers.append("backend, manifest, and frontend version metadata is not aligned") + findings.append( + gate_finding( + repo=repo.spec.name, + code="repository_version_alignment", + message="Backend, manifest, and frontend version metadata is not aligned.", + remediation=f"Align every version-bearing source declaration to {resolved_target!r}, regenerate lockfiles, and rebuild the plan.", + ) + ) + if repo.exists and repo.is_git and repo.has_head and not repo.branch: + blockers.append("repository is not on a named branch") + findings.append( + gate_finding( + repo=repo.spec.name, + code="repository_detached_head", + message="Repository is not on a named branch.", + remediation="Check out the intended release branch, then rebuild the plan.", + ) + ) if repo.dirty: warnings.append("uncommitted changes will need review before release") + findings.append( + gate_finding( + repo=repo.spec.name, + code="worktree_dirty", + severity="warning", + message="Uncommitted changes prevent source tagging.", + remediation="Preview Prepare Changes, review the exact paths, then commit or discard them before rebuilding the plan.", + ) + ) if repo.ahead: warnings.append("unpushed commits should be pushed before catalog publication") + findings.append( + gate_finding( + repo=repo.spec.name, + code="repository_ahead", + severity="warning", + message="The branch contains unpushed commits.", + remediation="Preview the source release; guarded publication can push the branch and annotated tag atomically.", + ) + ) if current_version is None: warnings.append("no local version metadata found; initial release needs version metadata and catalog entry synthesis") + findings.append( + gate_finding( + repo=repo.spec.name, + code="version_metadata_missing", + severity="warning", + message="No local version metadata was discovered.", + remediation=f"Add the required package and manifest version declarations for {resolved_target!r}, then rebuild the plan.", + ) + ) if current_version and current_version == resolved_target and repo.local_target_tag_exists: warnings.append(f"local tag {target_tag} already exists") @@ -146,6 +312,85 @@ def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contract warnings=tuple(warnings), provides_interfaces=provides, requires_interfaces=requires, + gate_findings=tuple(findings), + source_preflight_ready=( + not blockers + and not repo.dirty + and repo.has_head + and bool(repo.branch) + and current_version == resolved_target + ), + ) + + +def gate_finding( + *, + repo: str, + code: str, + message: str, + remediation: str, + severity: str = "blocker", +) -> ReleaseGateFinding: + return ReleaseGateFinding( + code=code, + severity=severity, + message=message, + remediation=remediation, + repo=repo, + ) + + +def recommended_action( + *, + units: tuple[ReleasePlanUnit, ...], + findings: tuple[ReleaseGateFinding, ...], + compatibility: tuple[CompatibilityIssue, ...], +) -> ReleaseRecommendedAction: + blocker = next( + (finding for finding in findings if finding.severity == "blocker"), None + ) + if blocker is not None: + return ReleaseRecommendedAction( + id="resolve_release_gate", + title=f"Resolve {blocker.repo or 'release'} gate", + detail=blocker.message, + remediation=blocker.remediation, + repo=blocker.repo, + ) + contract_blocker = next( + (issue for issue in compatibility if issue.severity == "blocker"), None + ) + if contract_blocker is not None: + return ReleaseRecommendedAction( + id="resolve_compatibility_gate", + title="Resolve interface compatibility gate", + detail=contract_blocker.message, + remediation="Align the selected provider and consumer interface contracts, then rebuild the plan before publication.", + repo=contract_blocker.repo, + ) + worktree = next( + (finding for finding in findings if finding.code == "worktree_dirty"), None + ) + if worktree is not None: + return ReleaseRecommendedAction( + id="prepare_changes", + title=f"Prepare {worktree.repo} changes", + detail=worktree.message, + remediation=worktree.remediation, + repo=worktree.repo, + ) + if not units: + return ReleaseRecommendedAction( + id="select_release_targets", + title="Select release targets", + detail="No repositories are selected for this release plan.", + remediation="Select one or more repositories, confirm their target versions, then build the plan again.", + ) + return ReleaseRecommendedAction( + id="preview_source_release", + title="Preview source release tags", + detail="All plan-visible source preflight gates pass for the selected repositories.", + remediation="Run Preview Tag + Publish to verify manifest scope, configured remotes, and immutable local/remote tag state before mutation.", ) diff --git a/tools/release/webui/index.html b/tools/release/webui/index.html index c3bbde5..2afcd2a 100644 --- a/tools/release/webui/index.html +++ b/tools/release/webui/index.html @@ -409,6 +409,11 @@ padding: 10px; } + .action.recommended { + border-left: 4px solid var(--accent); + background: var(--panel-alt); + } + .action h3 { margin: 0 0 5px; font-size: 13px; @@ -1409,10 +1414,28 @@ const units = plan.units || []; const compatibility = plan.compatibility || []; const steps = plan.dry_run_steps || []; - if (!units.length && !compatibility.length && !steps.length) { + const findings = plan.gate_findings || []; + const recommendation = plan.recommended_action || null; + if (!units.length && !compatibility.length && !steps.length && !recommendation) { elements.actions.innerHTML = `
${escapeHtml(recommendation.detail)}
+ +${escapeHtml(finding.message)}
+ ${source ? `` : ""} + +${escapeHtml(unit.blockers.join("; "))}
` : ""; const warnings = unit.warnings.length ? `${escapeHtml(unit.warnings.join("; "))}
` : ""; @@ -1439,6 +1462,8 @@ `; }).join(""); elements.actions.innerHTML = ` + ${recommendationHtml} + ${findingHtml} ${unitHtml} ${compatibilityHtml} ${stepHtml}