intermediate commit
This commit is contained in:
176
tools/release/govoplan_release/planner.py
Normal file
176
tools/release/govoplan_release/planner.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Read-only release plan suggestions.
|
||||
|
||||
This is deliberately not an executor yet. The first console slice should make
|
||||
state and next actions explicit before any commit, tag, signing, or publish
|
||||
operation becomes clickable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import shlex
|
||||
|
||||
from .model import PlanAction, ReleaseDashboard, ReleasePlan
|
||||
|
||||
|
||||
def build_release_plan(dashboard: ReleaseDashboard) -> ReleasePlan:
|
||||
actions: list[PlanAction] = []
|
||||
target_version = dashboard.target_version
|
||||
target_tag = dashboard.target_tag
|
||||
|
||||
for repo in dashboard.repositories:
|
||||
if not repo.exists:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
id=f"{repo.spec.name}:missing",
|
||||
title=f"{repo.spec.name} is missing locally",
|
||||
detail="Clone or bootstrap the repository before planning a release.",
|
||||
command=f"tools/repo/bootstrap-repositories.py --repo {shlex.quote(repo.spec.name)}",
|
||||
cwd=dashboard.meta_root,
|
||||
mutating=True,
|
||||
severity="blocker",
|
||||
repo=repo.spec.name,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not repo.is_git:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
id=f"{repo.spec.name}:not-git",
|
||||
title=f"{repo.spec.name} is not a Git repository",
|
||||
detail=repo.absolute_path,
|
||||
severity="blocker",
|
||||
repo=repo.spec.name,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if repo.safe_directory_required:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
id=f"{repo.spec.name}:safe-directory",
|
||||
title=f"{repo.spec.name} requires Git safe.directory approval",
|
||||
detail="Git refused to inspect this checkout until it is trusted for the current user.",
|
||||
command=repo.safe_directory_command,
|
||||
cwd=repo.absolute_path,
|
||||
mutating=True,
|
||||
severity="blocker",
|
||||
repo=repo.spec.name,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not repo.has_head:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
id=f"{repo.spec.name}:initial-commit",
|
||||
title=f"{repo.spec.name} needs an initial commit",
|
||||
detail="The repository exists but has no HEAD yet, so it cannot be tagged or released.",
|
||||
command="git add -A && git commit -m \"Initialize repository\" && git push -u origin HEAD",
|
||||
cwd=repo.absolute_path,
|
||||
mutating=True,
|
||||
severity="blocker",
|
||||
repo=repo.spec.name,
|
||||
)
|
||||
)
|
||||
if repo.dirty:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
id=f"{repo.spec.name}:dirty",
|
||||
title=f"{repo.spec.name} has uncommitted changes",
|
||||
detail=f"{len(repo.dirty_entries)} changed path(s) need review before release.",
|
||||
command="git status --short && git diff --stat",
|
||||
cwd=repo.absolute_path,
|
||||
severity="blocker",
|
||||
repo=repo.spec.name,
|
||||
)
|
||||
)
|
||||
if repo.behind:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
id=f"{repo.spec.name}:behind",
|
||||
title=f"{repo.spec.name} is behind {repo.upstream}",
|
||||
detail=f"Behind by {repo.behind} commit(s). Release from an up-to-date branch.",
|
||||
command="git fetch --all --tags --prune",
|
||||
cwd=repo.absolute_path,
|
||||
mutating=True,
|
||||
severity="blocker",
|
||||
repo=repo.spec.name,
|
||||
)
|
||||
)
|
||||
if repo.ahead:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
id=f"{repo.spec.name}:ahead",
|
||||
title=f"{repo.spec.name} has unpushed commits",
|
||||
detail=f"Ahead by {repo.ahead} commit(s). Tags and catalogs should point at pushed commits.",
|
||||
command="git push",
|
||||
cwd=repo.absolute_path,
|
||||
mutating=True,
|
||||
severity="warning",
|
||||
repo=repo.spec.name,
|
||||
)
|
||||
)
|
||||
if target_tag and repo.versions.primary == target_version and repo.local_target_tag_exists is False:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
id=f"{repo.spec.name}:tag-missing",
|
||||
title=f"{repo.spec.name} has no local {target_tag} tag",
|
||||
detail="The version file matches the target release, but the release tag is missing locally.",
|
||||
command=f"git tag -a {shlex.quote(target_tag)} -m \"Release {shlex.quote(target_tag)}\"",
|
||||
cwd=repo.absolute_path,
|
||||
mutating=True,
|
||||
severity="info",
|
||||
repo=repo.spec.name,
|
||||
)
|
||||
)
|
||||
|
||||
catalog = dashboard.catalog
|
||||
if target_version and catalog.core_release_version != target_version:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
id="catalog:core-version",
|
||||
title="Stable catalog does not point at the target core release",
|
||||
detail=f"catalog core={catalog.core_release_version or '-'}, target={target_version}",
|
||||
command=(
|
||||
"KEY_DIR=\"$HOME/.config/govoplan/release-keys\" "
|
||||
f"tools/release/publish-release-catalog.sh --version {shlex.quote(target_version)} "
|
||||
"--catalog-signing-key \"release-key-1=$KEY_DIR/release-key-1.pem\" --build-web --dry-run"
|
||||
),
|
||||
cwd=dashboard.meta_root,
|
||||
severity="warning",
|
||||
)
|
||||
)
|
||||
if catalog.catalog_exists and catalog.signature_count == 0:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
id="catalog:unsigned",
|
||||
title="Stable catalog is unsigned",
|
||||
detail="Release catalogs must be signed before operators can trust them.",
|
||||
cwd=dashboard.meta_root,
|
||||
severity="blocker",
|
||||
)
|
||||
)
|
||||
if not catalog.keyring_exists or catalog.key_count == 0:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
id="catalog:keyring",
|
||||
title="Catalog keyring is missing or empty",
|
||||
detail="Generate or publish a public keyring before enforcing signed catalogs.",
|
||||
command=(
|
||||
"tools/release/generate-catalog-keypair.py --key-id release-key-1 "
|
||||
"--private-key \"$HOME/.config/govoplan/release-keys/release-key-1.pem\" "
|
||||
"--keyring \"$HOME/.config/govoplan/release-keys/keyring.json\""
|
||||
),
|
||||
cwd=dashboard.meta_root,
|
||||
mutating=True,
|
||||
severity="warning",
|
||||
)
|
||||
)
|
||||
|
||||
status = "blocked" if any(action.severity == "blocker" for action in actions) else "attention" if actions else "ready"
|
||||
return ReleasePlan(
|
||||
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
target_version=target_version,
|
||||
target_tag=target_tag,
|
||||
status=status,
|
||||
actions=tuple(actions),
|
||||
)
|
||||
Reference in New Issue
Block a user