"""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"