feat(release): guide selective gate remediation

This commit is contained in:
2026-07-22 16:27:03 +02:00
parent 99534c0251
commit bcab7095c3
6 changed files with 457 additions and 5 deletions

View File

@@ -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)

View File

@@ -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.",
)