fix(release): harden review gate diagnostics

This commit is contained in:
2026-07-22 16:52:27 +02:00
parent 36f662c56f
commit 72f697c341
4 changed files with 409 additions and 52 deletions

View File

@@ -39,18 +39,29 @@ def build_selective_release_plan(
repositories = selected_repositories(dashboard, selected_repos=selected_repos)
contracts_by_repo = {contract.repo: contract for contract in dashboard.contracts}
units = tuple(
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
)
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_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"),
generated_at=datetime.now(tz=UTC)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z"),
target_channel=channel,
status=status,
units=units,
@@ -63,7 +74,8 @@ def build_selective_release_plan(
findings=findings,
compatibility=compatibility,
),
source_preflight_ready=bool(units) and all(unit.source_preflight_ready for unit in units),
source_preflight_ready=bool(units)
and all(unit.source_preflight_ready for unit in units),
)
@@ -73,26 +85,49 @@ def apply_repository_version_gate(
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,
for unit in units:
try:
issues = selected_repository_version_issues(
repo_versions={unit.repo: unit.target_version},
workspace=workspace,
)
except Exception as exc: # noqa: BLE001 - source errors become release findings.
issues_by_repo.setdefault(unit.repo, []).append(
ReleaseGateFinding(
code="repository_version_metadata_unreadable",
severity="blocker",
message=(
"Version-bearing source metadata could not be read or parsed "
f"({type(exc).__name__})."
),
remediation=(
"Repair the malformed TOML or JSON version metadata, refresh the "
"repository dashboard, then rebuild this plan."
),
repo=unit.repo,
source="repository version metadata",
expected=unit.target_version,
actual="unreadable",
)
)
continue
for issue in issues:
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,
@@ -158,18 +193,31 @@ def apply_release_webui_bundle_gate(
)
def selected_repositories(dashboard: ReleaseDashboard, *, selected_repos: tuple[str, ...]) -> tuple[RepositorySnapshot, ...]:
def selected_repositories(
dashboard: ReleaseDashboard, *, selected_repos: tuple[str, ...]
) -> tuple[RepositorySnapshot, ...]:
if selected_repos:
wanted = set(selected_repos)
return tuple(repo for repo in dashboard.repositories if repo.spec.name in wanted)
return tuple(
repo for repo in dashboard.repositories if repo.spec.name in wanted
)
return tuple(repo for repo in dashboard.repositories if release_candidate(repo))
def release_candidate(repo: RepositorySnapshot) -> bool:
return repo.dirty or bool(repo.ahead) or (repo.exists and repo.is_git and not repo.has_head)
return (
repo.dirty
or bool(repo.ahead)
or (repo.exists and repo.is_git and not repo.has_head)
)
def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contracts: ModuleContractSnapshot | None) -> ReleasePlanUnit:
def build_unit(
repo: RepositorySnapshot,
*,
target_version: str | None,
contracts: ModuleContractSnapshot | None,
) -> ReleasePlanUnit:
current_version = repo.versions.primary
resolved_target = (target_version or current_version or "0.1.0").removeprefix("v")
target_tag = f"v{resolved_target}"
@@ -238,7 +286,9 @@ def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contract
if value
)
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,
@@ -280,7 +330,9 @@ def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contract
)
)
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,
@@ -290,7 +342,11 @@ def build_unit(repo: RepositorySnapshot, *, target_version: str | None, contract
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")
provides: tuple[InterfaceProviderSnapshot, ...] = ()
@@ -398,7 +454,9 @@ def compatibility_issues(dashboard: ReleaseDashboard) -> tuple[CompatibilityIssu
return validate_contracts(dashboard.contracts)
def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashboard, channel: str) -> tuple[ReleasePlanStep, ...]:
def dry_run_steps(
*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashboard, channel: str
) -> tuple[ReleasePlanStep, ...]:
steps: list[ReleasePlanStep] = []
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
for unit in units:
@@ -407,7 +465,8 @@ def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashb
id=f"{unit.repo}:preflight",
title=f"Inspect {unit.repo}",
detail="Verify local branch, dirty state, current version, and target tag before release.",
command="git status --short --branch && git tag --list " + shlex.quote(unit.target_tag),
command="git status --short --branch && git tag --list "
+ shlex.quote(unit.target_tag),
cwd=f"{dashboard.workspace_root}/{unit.repo}",
repo=unit.repo,
)
@@ -426,7 +485,9 @@ def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashb
)
)
snapshot = snapshots.get(unit.repo)
if unit.current_version != unit.target_version or (snapshot is not None and snapshot.dirty):
if unit.current_version != unit.target_version or (
snapshot is not None and snapshot.dirty
):
steps.append(
ReleasePlanStep(
id=f"{unit.repo}:commit",
@@ -492,10 +553,10 @@ def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashb
"and updates only selected release units."
),
command=(
"KEY_DIR=\"$HOME/.config/govoplan/release-keys\" "
'KEY_DIR="$HOME/.config/govoplan/release-keys" '
f"tools/release/release-catalog.py selective --channel {shlex.quote(channel)} "
f"{repo_version_args} "
"--catalog-signing-key \"release-key-1=$KEY_DIR/release-key-1.pem\""
'--catalog-signing-key "release-key-1=$KEY_DIR/release-key-1.pem"'
),
cwd=dashboard.meta_root,
mutating=True,
@@ -512,7 +573,7 @@ def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashb
),
command=(
"tools/release/release-catalog.py publish-candidate "
f"--candidate-dir \"$CANDIDATE_DIR\" --channel {shlex.quote(channel)}"
f'--candidate-dir "$CANDIDATE_DIR" --channel {shlex.quote(channel)}'
),
cwd=dashboard.meta_root,
status="planned",
@@ -528,12 +589,18 @@ def release_notes(dashboard: ReleaseDashboard) -> tuple[str, ...]:
"Compatibility is checked from currently discovered manifest interface providers and requirements.",
]
if dashboard.catalog.public_checked:
notes.append("Local catalog/keyring hashes are compared with the published channel and keyring.")
notes.append(
"Local catalog/keyring hashes are compared with the published channel and keyring."
)
return tuple(notes)
def plan_status(*, units: tuple[ReleasePlanUnit, ...], compatibility: tuple[CompatibilityIssue, ...]) -> str:
if any(unit.blockers for unit in units) or any(issue.severity == "blocker" for issue in compatibility):
def plan_status(
*, units: tuple[ReleasePlanUnit, ...], compatibility: tuple[CompatibilityIssue, ...]
) -> str:
if any(unit.blockers for unit in units) or any(
issue.severity == "blocker" for issue in compatibility
):
return "blocked"
if units or compatibility:
return "attention"
@@ -544,9 +611,17 @@ def version_satisfies(version: str, requirement: InterfaceRequirementSnapshot) -
parsed = parse_version(version)
if parsed is None:
return False
if requirement.version_min and (minimum := parse_version(requirement.version_min)) is not None and parsed < minimum:
if (
requirement.version_min
and (minimum := parse_version(requirement.version_min)) is not None
and parsed < minimum
):
return False
if requirement.version_max_exclusive and (maximum := parse_version(requirement.version_max_exclusive)) is not None and parsed >= maximum:
if (
requirement.version_max_exclusive
and (maximum := parse_version(requirement.version_max_exclusive)) is not None
and parsed >= maximum
):
return False
return True