feat(release): guide selective gate remediation
This commit is contained in:
@@ -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
|
creates a signed catalog candidate that advances only selected repositories that
|
||||||
already have a catalog entry.
|
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
|
The release-control area above the repository table is read-only and is meant
|
||||||
to become the central release cockpit. It shows:
|
to become the central release cockpit. It shows:
|
||||||
|
|
||||||
|
|||||||
138
tests/test_release_plan_guidance.py
Normal file
138
tests/test_release_plan_guidance.py
Normal file
@@ -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("<strong>Remediation:</strong>", 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()
|
||||||
@@ -251,6 +251,14 @@ class VersionAlignmentTests(unittest.TestCase):
|
|||||||
self.assertEqual("blocked", plan.status)
|
self.assertEqual("blocked", plan.status)
|
||||||
self.assertEqual("blocked", plan.units[0].status)
|
self.assertEqual("blocked", plan.units[0].status)
|
||||||
self.assertTrue(any("Core release WebUI dependency" in item for item in plan.units[0].blockers))
|
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:
|
def test_selected_repository_gate_reports_missing_version_metadata(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
|||||||
@@ -186,6 +186,18 @@ class ModuleContractSnapshot:
|
|||||||
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ReleasePlanUnit:
|
class ReleasePlanUnit:
|
||||||
repo: str
|
repo: str
|
||||||
@@ -200,6 +212,8 @@ class ReleasePlanUnit:
|
|||||||
warnings: tuple[str, ...] = ()
|
warnings: tuple[str, ...] = ()
|
||||||
provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = ()
|
provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = ()
|
||||||
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
|
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
|
||||||
|
gate_findings: tuple[ReleaseGateFinding, ...] = ()
|
||||||
|
source_preflight_ready: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -224,6 +238,15 @@ class ReleasePlanStep:
|
|||||||
status: str = "planned"
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SelectiveReleasePlan:
|
class SelectiveReleasePlan:
|
||||||
generated_at: str
|
generated_at: str
|
||||||
@@ -233,6 +256,9 @@ class SelectiveReleasePlan:
|
|||||||
compatibility: tuple[CompatibilityIssue, ...]
|
compatibility: tuple[CompatibilityIssue, ...]
|
||||||
dry_run_steps: tuple[ReleasePlanStep, ...]
|
dry_run_steps: tuple[ReleasePlanStep, ...]
|
||||||
notes: tuple[str, ...] = ()
|
notes: tuple[str, ...] = ()
|
||||||
|
gate_findings: tuple[ReleaseGateFinding, ...] = ()
|
||||||
|
recommended_action: ReleaseRecommendedAction | None = None
|
||||||
|
source_preflight_ready: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -12,14 +12,19 @@ from .model import (
|
|||||||
CompatibilityIssue,
|
CompatibilityIssue,
|
||||||
InterfaceProviderSnapshot,
|
InterfaceProviderSnapshot,
|
||||||
InterfaceRequirementSnapshot,
|
InterfaceRequirementSnapshot,
|
||||||
|
ReleaseGateFinding,
|
||||||
ReleaseDashboard,
|
ReleaseDashboard,
|
||||||
ReleasePlanStep,
|
ReleasePlanStep,
|
||||||
ReleasePlanUnit,
|
ReleasePlanUnit,
|
||||||
|
ReleaseRecommendedAction,
|
||||||
RepositorySnapshot,
|
RepositorySnapshot,
|
||||||
SelectiveReleasePlan,
|
SelectiveReleasePlan,
|
||||||
ModuleContractSnapshot,
|
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(
|
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))
|
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_repository_version_gate(units, workspace=Path(dashboard.workspace_root))
|
||||||
units = apply_release_webui_bundle_gate(units, workspace=Path(dashboard.workspace_root))
|
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)
|
||||||
status = plan_status(units=units, compatibility=compatibility)
|
status = plan_status(units=units, compatibility=compatibility)
|
||||||
|
findings = tuple(finding for unit in units for finding in unit.gate_findings)
|
||||||
return SelectiveReleasePlan(
|
return SelectiveReleasePlan(
|
||||||
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||||
target_channel=channel,
|
target_channel=channel,
|
||||||
@@ -50,6 +57,59 @@ def build_selective_release_plan(
|
|||||||
compatibility=compatibility,
|
compatibility=compatibility,
|
||||||
dry_run_steps=steps,
|
dry_run_steps=steps,
|
||||||
notes=notes,
|
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,
|
workspace: Path,
|
||||||
) -> tuple[ReleasePlanUnit, ...]:
|
) -> tuple[ReleasePlanUnit, ...]:
|
||||||
issues_by_repo: dict[str, list[str]] = {}
|
issues_by_repo: dict[str, list[ReleaseGateFinding]] = {}
|
||||||
for issue in selected_release_webui_bundle_issues(
|
for issue in selected_release_webui_bundle_issues(
|
||||||
repo_versions={unit.repo: unit.target_version for unit in units},
|
repo_versions={unit.repo: unit.target_version for unit in units},
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
):
|
):
|
||||||
issues_by_repo.setdefault(issue.repo, []).append(
|
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(
|
return tuple(
|
||||||
replace(
|
replace(
|
||||||
unit,
|
unit,
|
||||||
status="blocked",
|
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
|
if unit.repo in issues_by_repo
|
||||||
else unit
|
else unit
|
||||||
@@ -95,16 +175,57 @@ def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contract
|
|||||||
target_tag = f"v{resolved_target}"
|
target_tag = f"v{resolved_target}"
|
||||||
blockers: list[str] = []
|
blockers: list[str] = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
findings: list[ReleaseGateFinding] = []
|
||||||
if not repo.exists:
|
if not repo.exists:
|
||||||
blockers.append("repository is missing locally")
|
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:
|
elif not repo.is_git:
|
||||||
blockers.append("path is not a Git repository")
|
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:
|
elif repo.safe_directory_required:
|
||||||
blockers.append("Git safe.directory approval is 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:
|
elif repo.behind:
|
||||||
blockers.append(f"repository is behind {repo.upstream}")
|
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:
|
if repo.exists and repo.is_git and not repo.has_head:
|
||||||
blockers.append("repository has no initial commit")
|
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(
|
local_versions = tuple(
|
||||||
value
|
value
|
||||||
for value in (
|
for value in (
|
||||||
@@ -118,12 +239,57 @@ def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contract
|
|||||||
)
|
)
|
||||||
if len(set(local_versions)) > 1:
|
if len(set(local_versions)) > 1:
|
||||||
blockers.append("backend, manifest, and frontend version metadata is not aligned")
|
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:
|
if repo.dirty:
|
||||||
warnings.append("uncommitted changes will need review before release")
|
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:
|
if repo.ahead:
|
||||||
warnings.append("unpushed commits should be pushed before catalog publication")
|
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:
|
if current_version is None:
|
||||||
warnings.append("no local version metadata found; initial release needs version metadata and catalog entry synthesis")
|
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:
|
if current_version and current_version == resolved_target and repo.local_target_tag_exists:
|
||||||
warnings.append(f"local tag {target_tag} already 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),
|
warnings=tuple(warnings),
|
||||||
provides_interfaces=provides,
|
provides_interfaces=provides,
|
||||||
requires_interfaces=requires,
|
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.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -409,6 +409,11 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action.recommended {
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
background: var(--panel-alt);
|
||||||
|
}
|
||||||
|
|
||||||
.action h3 {
|
.action h3 {
|
||||||
margin: 0 0 5px;
|
margin: 0 0 5px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -1409,10 +1414,28 @@
|
|||||||
const units = plan.units || [];
|
const units = plan.units || [];
|
||||||
const compatibility = plan.compatibility || [];
|
const compatibility = plan.compatibility || [];
|
||||||
const steps = plan.dry_run_steps || [];
|
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 = `<div class="muted">No selective release candidates. Select repositories above to plan a release.</div>`;
|
elements.actions.innerHTML = `<div class="muted">No selective release candidates. Select repositories above to plan a release.</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const recommendationKind = plan.status === "blocked" ? "block" : plan.source_preflight_ready ? "ok" : "warn";
|
||||||
|
const recommendationHtml = recommendation ? `<div class="action recommended">
|
||||||
|
<h3>${pill("recommended next", recommendationKind)} ${escapeHtml(recommendation.title)}</h3>
|
||||||
|
<p>${escapeHtml(recommendation.detail)}</p>
|
||||||
|
<p class="action-meta"><strong>How to continue:</strong> ${escapeHtml(recommendation.remediation)}</p>
|
||||||
|
</div>` : "";
|
||||||
|
const findingHtml = findings.map((finding) => {
|
||||||
|
const kind = finding.severity === "blocker" ? "block" : finding.severity === "warning" ? "warn" : "ok";
|
||||||
|
const source = finding.source ? `${finding.source}: ${finding.actual || "-"} -> ${finding.expected || "-"}` : "";
|
||||||
|
return `<div class="action">
|
||||||
|
<h3>${pill(finding.severity, kind)} ${escapeHtml(finding.repo || "release")} · ${escapeHtml(finding.code)}</h3>
|
||||||
|
<p>${escapeHtml(finding.message)}</p>
|
||||||
|
${source ? `<p class="action-meta">${escapeHtml(source)}</p>` : ""}
|
||||||
|
<p class="action-meta"><strong>Remediation:</strong> ${escapeHtml(finding.remediation)}</p>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
const unitHtml = units.map((unit) => {
|
const unitHtml = units.map((unit) => {
|
||||||
const blockers = unit.blockers.length ? `<p>${escapeHtml(unit.blockers.join("; "))}</p>` : "";
|
const blockers = unit.blockers.length ? `<p>${escapeHtml(unit.blockers.join("; "))}</p>` : "";
|
||||||
const warnings = unit.warnings.length ? `<p>${escapeHtml(unit.warnings.join("; "))}</p>` : "";
|
const warnings = unit.warnings.length ? `<p>${escapeHtml(unit.warnings.join("; "))}</p>` : "";
|
||||||
@@ -1439,6 +1462,8 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
elements.actions.innerHTML = `
|
elements.actions.innerHTML = `
|
||||||
|
${recommendationHtml}
|
||||||
|
${findingHtml}
|
||||||
${unitHtml}
|
${unitHtml}
|
||||||
${compatibilityHtml}
|
${compatibilityHtml}
|
||||||
${stepHtml}
|
${stepHtml}
|
||||||
|
|||||||
Reference in New Issue
Block a user