272 lines
12 KiB
Python
272 lines
12 KiB
Python
"""Selective release planning for independently versioned packages."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
import shlex
|
|
|
|
from .contracts import validate_contracts
|
|
from .model import (
|
|
CompatibilityIssue,
|
|
InterfaceProviderSnapshot,
|
|
InterfaceRequirementSnapshot,
|
|
ReleaseDashboard,
|
|
ReleasePlanStep,
|
|
ReleasePlanUnit,
|
|
RepositorySnapshot,
|
|
SelectiveReleasePlan,
|
|
ModuleContractSnapshot,
|
|
)
|
|
|
|
|
|
def build_selective_release_plan(
|
|
dashboard: ReleaseDashboard,
|
|
*,
|
|
selected_repos: tuple[str, ...] = (),
|
|
target_version: str | None = None,
|
|
repo_versions: dict[str, str] | None = None,
|
|
channel: str = "stable",
|
|
) -> SelectiveReleasePlan:
|
|
repo_versions = repo_versions or {}
|
|
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))
|
|
for repo in repositories
|
|
)
|
|
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)
|
|
return SelectiveReleasePlan(
|
|
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
target_channel=channel,
|
|
status=status,
|
|
units=units,
|
|
compatibility=compatibility,
|
|
dry_run_steps=steps,
|
|
notes=notes,
|
|
)
|
|
|
|
|
|
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 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)
|
|
|
|
|
|
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}"
|
|
blockers: list[str] = []
|
|
warnings: list[str] = []
|
|
if not repo.exists:
|
|
blockers.append("repository is missing locally")
|
|
elif not repo.is_git:
|
|
blockers.append("path is not a Git repository")
|
|
elif repo.safe_directory_required:
|
|
blockers.append("Git safe.directory approval is required")
|
|
elif repo.behind:
|
|
blockers.append(f"repository is behind {repo.upstream}")
|
|
if repo.exists and repo.is_git and not repo.has_head:
|
|
blockers.append("repository has no initial commit")
|
|
if repo.dirty:
|
|
warnings.append("uncommitted changes will need review before release")
|
|
if repo.ahead:
|
|
warnings.append("unpushed commits should be pushed before catalog publication")
|
|
if current_version is None:
|
|
warnings.append("no local version metadata found; initial release needs version metadata and catalog entry synthesis")
|
|
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, ...] = ()
|
|
requires: tuple[InterfaceRequirementSnapshot, ...] = ()
|
|
if contracts is not None:
|
|
provides = contracts.provides_interfaces
|
|
requires = contracts.requires_interfaces
|
|
|
|
return ReleasePlanUnit(
|
|
repo=repo.spec.name,
|
|
category=repo.spec.category,
|
|
subtype=repo.spec.subtype,
|
|
branch=repo.branch,
|
|
current_version=current_version,
|
|
target_version=resolved_target,
|
|
target_tag=target_tag,
|
|
status="blocked" if blockers else "attention" if warnings else "ready",
|
|
blockers=tuple(blockers),
|
|
warnings=tuple(warnings),
|
|
provides_interfaces=provides,
|
|
requires_interfaces=requires,
|
|
)
|
|
|
|
|
|
def compatibility_issues(dashboard: ReleaseDashboard) -> tuple[CompatibilityIssue, ...]:
|
|
return validate_contracts(dashboard.contracts)
|
|
|
|
|
|
def dry_run_steps(*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashboard, channel: str) -> tuple[ReleasePlanStep, ...]:
|
|
steps: list[ReleasePlanStep] = []
|
|
for unit in units:
|
|
steps.append(
|
|
ReleasePlanStep(
|
|
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),
|
|
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
|
repo=unit.repo,
|
|
)
|
|
)
|
|
if unit.current_version != unit.target_version:
|
|
steps.append(
|
|
ReleasePlanStep(
|
|
id=f"{unit.repo}:version",
|
|
title=f"Update {unit.repo} version metadata",
|
|
detail=f"Prepare version files and manifest metadata for {unit.target_version}.",
|
|
command=None,
|
|
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
|
mutating=True,
|
|
repo=unit.repo,
|
|
status="needs-executor",
|
|
)
|
|
)
|
|
steps.append(
|
|
ReleasePlanStep(
|
|
id=f"{unit.repo}:commit",
|
|
title=f"Commit {unit.repo} release changes",
|
|
detail="Commit reviewed changes for this release unit only.",
|
|
command=f"git add -A && git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}",
|
|
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
|
mutating=True,
|
|
repo=unit.repo,
|
|
)
|
|
)
|
|
steps.append(
|
|
ReleasePlanStep(
|
|
id=f"{unit.repo}:tag",
|
|
title=f"Create {unit.repo} tag {unit.target_tag}",
|
|
detail="Create an annotated tag for this package release.",
|
|
command=f"git tag -a {shlex.quote(unit.target_tag)} -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}",
|
|
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
|
mutating=True,
|
|
repo=unit.repo,
|
|
)
|
|
)
|
|
steps.append(
|
|
ReleasePlanStep(
|
|
id=f"{unit.repo}:push",
|
|
title=f"Push {unit.repo} release",
|
|
detail="Push the branch and release tag for this package only.",
|
|
command=f"git push --atomic origin HEAD:refs/heads/{shlex.quote(unit.branch or 'main')} refs/tags/{shlex.quote(unit.target_tag)}",
|
|
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
|
mutating=True,
|
|
repo=unit.repo,
|
|
)
|
|
)
|
|
|
|
if units:
|
|
initial_units = tuple(unit for unit in units if unit.current_version is None)
|
|
repo_version_args = " ".join(
|
|
f"--repo-version {shlex.quote(unit.repo + '=' + unit.target_version)}"
|
|
for unit in units
|
|
)
|
|
if initial_units:
|
|
steps.append(
|
|
ReleasePlanStep(
|
|
id="catalog:initial-entry-synthesis",
|
|
title=f"Prepare initial {channel} catalog entries",
|
|
detail=(
|
|
"Selected initial-release repositories are not guaranteed to exist in the channel catalog yet: "
|
|
+ ", ".join(unit.repo for unit in initial_units)
|
|
+ ". The current selective catalog writer updates existing entries only."
|
|
),
|
|
cwd=dashboard.meta_root,
|
|
mutating=True,
|
|
status="needs-catalog-entry-synthesis",
|
|
)
|
|
)
|
|
else:
|
|
steps.append(
|
|
ReleasePlanStep(
|
|
id="catalog:selective-generator",
|
|
title=f"Update {channel} release channel catalog",
|
|
detail=(
|
|
"Generate a signed candidate catalog that preserves unchanged package versions "
|
|
"and updates only selected release units."
|
|
),
|
|
command=(
|
|
"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\""
|
|
),
|
|
cwd=dashboard.meta_root,
|
|
mutating=True,
|
|
status="planned",
|
|
)
|
|
)
|
|
steps.append(
|
|
ReleasePlanStep(
|
|
id="catalog:validate-sign-publish",
|
|
title=f"Validate, sign, and publish {channel}",
|
|
detail="The candidate writer validates and signs locally. Publishing to the website repo remains a separate apply step.",
|
|
command="Review the candidate under runtime/release-candidates before publishing it to the website repository.",
|
|
cwd=dashboard.meta_root,
|
|
status="needs-publish-executor",
|
|
)
|
|
)
|
|
return tuple(steps)
|
|
|
|
|
|
def release_notes(dashboard: ReleaseDashboard) -> tuple[str, ...]:
|
|
notes = [
|
|
"Release units are independent; selecting one package should not force every repository to a shared tag.",
|
|
"Channel publication must preserve unchanged package versions and only advance selected release units.",
|
|
"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.")
|
|
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):
|
|
return "blocked"
|
|
if units or compatibility:
|
|
return "attention"
|
|
return "ready"
|
|
|
|
|
|
def version_satisfies(version: str, requirement: InterfaceRequirementSnapshot) -> bool:
|
|
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:
|
|
return False
|
|
if requirement.version_max_exclusive and (maximum := parse_version(requirement.version_max_exclusive)) is not None and parsed >= maximum:
|
|
return False
|
|
return True
|
|
|
|
|
|
def parse_version(value: str) -> tuple[int, ...] | None:
|
|
parts = value.split(".")
|
|
if not parts or any(not part.isdigit() for part in parts):
|
|
return None
|
|
return tuple(int(part) for part in parts)
|
|
|
|
|
|
def format_requirement(requirement: InterfaceRequirementSnapshot) -> str:
|
|
parts: list[str] = []
|
|
if requirement.version_min:
|
|
parts.append(f">={requirement.version_min}")
|
|
if requirement.version_max_exclusive:
|
|
parts.append(f"<{requirement.version_max_exclusive}")
|
|
return ", ".join(parts) if parts else "any version"
|