feat(release): guide selective gate remediation
This commit is contained in:
@@ -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.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user