Files
govoplan/tools/release/release-doctor.py
Albrecht Degering 342582645b
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 1m14s
Security Audit / security-audit (push) Failing after 31s
Harden audit tooling and release helpers
2026-07-11 18:49:36 +02:00

1224 lines
50 KiB
Python

#!/usr/bin/env python3
"""Inspect GovOPlaN release state and suggest the next release commands."""
from __future__ import annotations
import argparse
import ast
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tomllib
from typing import Any
from urllib.error import URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
META_ROOT = Path(__file__).resolve().parents[2]
ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve()
DEFAULT_WORKSPACE_ROOT = ROOT.parent
DEFAULT_PUBLIC_CATALOG_URL = "https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json"
DEFAULT_PUBLIC_KEYRING_URL = "https://govoplan.add-ideas.de/catalogs/v1/keyring.json"
SEVERITY_ORDER = {"blocker": 3, "warning": 2, "info": 1, "ok": 0}
@dataclass(frozen=True, slots=True)
class SuggestedCommand:
title: str
command: str
cwd: str
argv: tuple[str, ...] = ()
mutating: bool = False
reason: str = ""
@dataclass(frozen=True, slots=True)
class Finding:
check_id: str
severity: str
title: str
detail: str
commands: tuple[SuggestedCommand, ...] = ()
@dataclass(frozen=True, slots=True)
class RepoState:
name: str
path: str
exists: bool
branch: str | None = None
upstream: str | None = None
ahead: int | None = None
behind: int | None = None
dirty_entries: tuple[str, ...] = ()
pyproject_version: str | None = None
package_version: str | None = None
webui_package_version: str | None = None
local_tag_exists: bool | None = None
remote_tag_exists: bool | None = None
remote_checked: bool = False
safe_directory_required: bool = False
safe_directory_command: str | None = None
errors: tuple[str, ...] = ()
@property
def dirty(self) -> bool:
return bool(self.dirty_entries)
@dataclass(frozen=True, slots=True)
class DoctorReport:
generated_at: str
workspace_root: str
target_version: str | None
target_tag: str | None
online: bool
overall_status: str
repos: tuple[RepoState, ...]
findings: tuple[Finding, ...]
migration_audits: dict[str, dict[str, Any]] = field(default_factory=dict)
catalog: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CommandResult:
returncode: int
stdout: str
stderr: str
class GitCommandError(RuntimeError):
def __init__(self, message: str, *, result: CommandResult) -> None:
super().__init__(message)
self.result = result
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"command",
nargs="?",
choices=("status", "next", "interactive"),
default="status",
help="status prints the report, next prints only guidance, interactive lets you choose commands to run.",
)
parser.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACE_ROOT)
parser.add_argument("--target-version", help="Release version to evaluate, without leading v. Defaults to core pyproject version.")
parser.add_argument("--repo", action="append", default=[], help="Limit checks to a repository name; may be repeated.")
parser.add_argument("--online", action="store_true", help="Check remote tags and public catalog/keyring availability.")
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
parser.add_argument("--details", action="store_true", help="Show full finding details in status output.")
parser.add_argument("--no-migration-audit", action="store_true", help="Skip release/dev migration audit subprocesses.")
parser.add_argument("--yes", action="store_true", help="In interactive mode, skip the first yes/no prompt for non-mutating commands.")
args = parser.parse_args(argv)
workspace_root = args.workspace_root.expanduser().resolve()
target_version = normalize_version(args.target_version) if args.target_version else read_pyproject_version(ROOT)
report = build_report(
workspace_root=workspace_root,
target_version=target_version,
repo_filter=tuple(args.repo),
online=args.online,
run_migration_audit=not args.no_migration_audit,
)
if args.json:
print(json.dumps(report_to_dict(report), indent=2, sort_keys=True))
elif args.command == "next":
print_next_commands(report)
elif args.command == "interactive":
return run_interactive(
report,
assume_yes=args.yes,
workspace_root=workspace_root,
target_version=target_version,
repo_filter=tuple(args.repo),
online=args.online,
run_migration_audit=not args.no_migration_audit,
)
else:
print_text_report(report, detailed=args.details)
return 1 if report.overall_status == "blocked" else 0
def build_report(
*,
workspace_root: Path,
target_version: str | None,
repo_filter: tuple[str, ...] = (),
online: bool = False,
run_migration_audit: bool = True,
) -> DoctorReport:
repo_names = release_repo_names(workspace_root)
if repo_filter:
wanted = set(repo_filter)
repo_names = tuple(name for name in repo_names if name in wanted)
missing_filters = tuple(sorted(wanted - set(repo_names)))
else:
missing_filters = ()
target_tag = f"v{target_version}" if target_version else None
repos = tuple(collect_repo_state(workspace_root / name, target_tag=target_tag, online=online) for name in repo_names)
migration_audits = collect_migration_audits(run_migration_audit=run_migration_audit)
catalog = collect_catalog_state(workspace_root=workspace_root, target_version=target_version, online=online)
findings: list[Finding] = []
for name in missing_filters:
findings.append(
Finding(
check_id="repo-filter",
severity="blocker",
title=f"Requested repository {name} is not part of the release repo set",
detail="Check the repository name or add it to the release catalog module list first.",
)
)
findings.extend(repo_findings(repos, target_version=target_version, target_tag=target_tag))
findings.extend(migration_findings(migration_audits, target_version=target_version))
findings.extend(catalog_findings(catalog, target_version=target_version, online=online))
overall_status = status_from_findings(findings)
return DoctorReport(
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
workspace_root=str(workspace_root),
target_version=target_version,
target_tag=target_tag,
online=online,
overall_status=overall_status,
repos=repos,
findings=tuple(findings),
migration_audits=migration_audits,
catalog=catalog,
)
def release_repo_names(workspace_root: Path) -> tuple[str, ...]:
names = {"govoplan-core"}
catalog_script = META_ROOT / "tools" / "release" / "generate-release-catalog.py"
if catalog_script.exists():
tree = ast.parse(catalog_script.read_text(encoding="utf-8"), filename=str(catalog_script))
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if not isinstance(node.func, ast.Name) or node.func.id != "CatalogModule":
continue
for keyword in node.keywords:
if keyword.arg == "repo" and isinstance(keyword.value, ast.Constant) and isinstance(keyword.value.value, str):
names.add(keyword.value.value)
baseline_file = ROOT / "docs" / "migration-release-baselines.json"
if baseline_file.exists():
try:
baseline = json.loads(baseline_file.read_text(encoding="utf-8"))
releases = baseline.get("releases") if isinstance(baseline, dict) else None
latest = releases[-1] if isinstance(releases, list) and releases else None
owner_heads = latest.get("owner_heads") if isinstance(latest, dict) else None
if isinstance(owner_heads, list):
for item in owner_heads:
owner = item.get("owner") if isinstance(item, dict) else None
if isinstance(owner, str) and owner.startswith("govoplan-"):
names.add(owner)
except Exception:
pass
if len(names) <= 2:
names.update(path.name for path in workspace_root.glob("govoplan-*") if (path / ".git").is_dir())
return tuple(sorted(names))
def collect_repo_state(path: Path, *, target_tag: str | None, online: bool) -> RepoState:
if not path.exists():
return RepoState(name=path.name, path=str(path), exists=False, errors=("repository directory is missing",))
if not (path / ".git").exists():
return RepoState(name=path.name, path=str(path), exists=False, errors=("not a git repository",))
errors: list[str] = []
try:
branch = git_text(path, "rev-parse", "--abbrev-ref", "HEAD")
status = git_text(path, "status", "--porcelain")
except GitCommandError as exc:
message = git_error_text(exc.result)
if is_dubious_ownership_error(message):
return RepoState(
name=path.name,
path=str(path),
exists=True,
safe_directory_required=True,
safe_directory_command=safe_directory_command(path),
errors=(compact_git_error(message),),
)
return RepoState(name=path.name, path=str(path), exists=True, errors=(compact_git_error(message),))
upstream = git_text(path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}", check=False)
ahead: int | None = None
behind: int | None = None
if upstream:
counts = git_text(path, "rev-list", "--left-right", "--count", "@{upstream}...HEAD", check=False)
parts = counts.split()
if len(parts) == 2 and all(part.isdigit() for part in parts):
behind = int(parts[0])
ahead = int(parts[1])
local_tag_exists: bool | None = None
remote_tag_exists: bool | None = None
if target_tag:
local_tag_exists = git_success(path, "rev-parse", "-q", "--verify", f"refs/tags/{target_tag}")
if online:
remote_tag_exists = git_success(path, "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{target_tag}", timeout=8)
try:
pyproject_version = read_pyproject_version(path)
package_version = read_json_version(path / "package.json")
webui_package_version = read_json_version(path / "webui" / "package.json")
except Exception as exc: # noqa: BLE001 - surfaced as a doctor finding.
errors.append(str(exc))
pyproject_version = None
package_version = None
webui_package_version = None
return RepoState(
name=path.name,
path=str(path),
exists=True,
branch=branch or None,
upstream=upstream or None,
ahead=ahead,
behind=behind,
dirty_entries=tuple(line for line in status.splitlines() if line.strip()),
pyproject_version=pyproject_version,
package_version=package_version,
webui_package_version=webui_package_version,
local_tag_exists=local_tag_exists,
remote_tag_exists=remote_tag_exists,
remote_checked=online,
errors=tuple(errors),
)
def repo_findings(repos: tuple[RepoState, ...], *, target_version: str | None, target_tag: str | None) -> tuple[Finding, ...]:
findings: list[Finding] = []
for repo in repos:
repo_path = Path(repo.path)
if not repo.exists:
findings.append(
Finding(
check_id="repo-exists",
severity="blocker",
title=f"{repo.name} is missing",
detail="The release doctor cannot inspect this repository.",
)
)
continue
if repo.errors:
if repo.safe_directory_required:
findings.append(
Finding(
check_id="repo-safe-directory",
severity="blocker",
title=f"{repo.name} is blocked by Git dubious-ownership protection",
detail="Git refuses to inspect this repository until it is explicitly marked as safe.",
commands=(
command(
Path(repo.path),
f"Trust {repo.name} for this user",
repo.safe_directory_command or safe_directory_command(Path(repo.path)),
mutating=True,
),
),
)
)
continue
findings.append(
Finding(
check_id="repo-read",
severity="warning",
title=f"{repo.name} has unreadable metadata",
detail="; ".join(repo.errors),
commands=(command(repo_path, f"Inspect {repo.name}", "git status --short"),),
)
)
if repo.dirty:
preview = "\n".join(repo.dirty_entries[:8])
if len(repo.dirty_entries) > 8:
preview += f"\n... {len(repo.dirty_entries) - 8} more"
findings.append(
Finding(
check_id="repo-dirty",
severity="blocker",
title=f"{repo.name} has uncommitted changes",
detail=preview,
commands=(
command(repo_path, f"Show {repo.name} status", "git status --short"),
command(repo_path, f"Show {repo.name} diff summary", "git diff --stat"),
),
)
)
if repo.behind:
findings.append(
Finding(
check_id="repo-behind",
severity="blocker",
title=f"{repo.name} is behind {repo.upstream}",
detail=f"Behind by {repo.behind} commit(s). Release from an up-to-date branch.",
commands=(command(repo_path, f"Fetch {repo.name}", "git fetch --all --tags --prune"),),
)
)
if repo.ahead:
findings.append(
Finding(
check_id="repo-ahead",
severity="warning",
title=f"{repo.name} has commits not pushed to {repo.upstream}",
detail=f"Ahead by {repo.ahead} commit(s). Tags/catalogs should point to pushed commits.",
commands=(command(repo_path, f"Push {repo.name}", "git push", mutating=True),),
)
)
versions = tuple(
value
for value in (repo.pyproject_version, repo.package_version, repo.webui_package_version)
if value
)
if len(set(versions)) > 1:
findings.append(
Finding(
check_id="repo-version-mismatch",
severity="warning",
title=f"{repo.name} has inconsistent local version files",
detail=", ".join(
value
for value in (
f"pyproject={repo.pyproject_version}" if repo.pyproject_version else "",
f"package={repo.package_version}" if repo.package_version else "",
f"webui={repo.webui_package_version}" if repo.webui_package_version else "",
)
if value
),
)
)
if target_version and repo.name == "govoplan-core" and repo.pyproject_version and repo.pyproject_version != target_version:
findings.append(
Finding(
check_id="core-target-version",
severity="warning",
title="Core pyproject version differs from the target release",
detail=f"core={repo.pyproject_version}, target={target_version}",
commands=(command(META_ROOT, "Prepare release version", f"tools/release/push-release-tag.sh --version {shlex.quote(target_version)} --strict-migration-audit", mutating=True),),
)
)
elif target_version and repo.pyproject_version and repo.pyproject_version != target_version:
findings.append(
Finding(
check_id="module-target-version",
severity="info",
title=f"{repo.name} is not currently at target version {target_version}",
detail=f"local pyproject version is {repo.pyproject_version}. This is fine if the module is not part of this release.",
)
)
if target_tag and repo.local_tag_exists is False and repo.pyproject_version == target_version:
findings.append(
Finding(
check_id="tag-local-missing",
severity="info",
title=f"{repo.name} has no local {target_tag} tag",
detail="The release tag is still missing locally.",
commands=(command(META_ROOT, "Run release tagging workflow", f"tools/release/push-release-tag.sh --version {shlex.quote(target_version or '')} --strict-migration-audit", mutating=True),),
)
)
if target_tag and repo.remote_checked and repo.remote_tag_exists is False and repo.pyproject_version == target_version:
findings.append(
Finding(
check_id="tag-remote-missing",
severity="warning",
title=f"{repo.name} has no remote {target_tag} tag",
detail="The public release tag is missing from origin.",
commands=(command(repo_path, f"Push {repo.name} tags", f"git push origin {shlex.quote(target_tag)}", mutating=True),),
)
)
return tuple(findings)
def collect_migration_audits(*, run_migration_audit: bool) -> dict[str, dict[str, Any]]:
if not run_migration_audit:
return {}
result: dict[str, dict[str, Any]] = {}
for track in ("release", "dev"):
audit = run_subprocess(
[sys.executable, str(META_ROOT / "tools" / "release" / "release-migration-audit.py"), "--track", track, "--json"],
cwd=META_ROOT,
timeout=30,
)
if audit.returncode != 0:
result[track] = {
"ok": False,
"error": (audit.stderr or audit.stdout).strip(),
}
continue
try:
payload = json.loads(audit.stdout)
except json.JSONDecodeError as exc:
result[track] = {"ok": False, "error": f"Could not parse audit JSON: {exc}"}
continue
payload["ok"] = not payload.get("graph_errors")
result[track] = payload
return result
def migration_findings(migration_audits: dict[str, dict[str, Any]], *, target_version: str | None) -> tuple[Finding, ...]:
if not migration_audits:
return (
Finding(
check_id="migration-audit-skipped",
severity="info",
title="Migration audits were skipped",
detail="Run without --no-migration-audit for release/dev track checks.",
),
)
findings: list[Finding] = []
for track in ("release", "dev"):
audit = migration_audits.get(track)
if not audit:
findings.append(
Finding(
check_id=f"migration-{track}-missing",
severity="warning",
title=f"{track} migration audit did not run",
detail="No audit result is available.",
commands=(migration_audit_command(track),),
)
)
continue
if not audit.get("ok", True):
findings.append(
Finding(
check_id=f"migration-{track}-graph",
severity="blocker",
title=f"{track} migration graph has errors",
detail="\n".join(audit.get("graph_errors") or [audit.get("error", "unknown audit error")]),
commands=(migration_audit_command(track),),
)
)
strict_errors = tuple(audit.get("strict_errors") or ())
if track == "release" and strict_errors:
commands = [migration_audit_command("release", strict=True)]
if target_version:
commands.append(
command(
META_ROOT,
"Record reviewed release migration baseline",
f"{python_bin()} tools/release/release-migration-audit.py --track release --record-release {shlex.quote(target_version)}",
mutating=True,
)
)
findings.append(
Finding(
check_id="migration-release-baseline",
severity="blocker",
title="Release migration heads are not fully recorded",
detail="\n".join(strict_errors),
commands=tuple(commands),
)
)
return tuple(findings)
def collect_catalog_state(*, workspace_root: Path, target_version: str | None, online: bool) -> dict[str, Any]:
web_root = workspace_root / "addideas-govoplan-website"
catalog_path = web_root / "public" / "catalogs" / "v1" / "channels" / "stable.json"
keyring_path = web_root / "public" / "catalogs" / "v1" / "keyring.json"
state: dict[str, Any] = {
"catalog_path": str(catalog_path),
"catalog_exists": catalog_path.exists(),
"keyring_path": str(keyring_path),
"keyring_exists": keyring_path.exists(),
"catalog_version": None,
"core_release_version": None,
"module_versions": {},
"signature_count": 0,
"key_count": 0,
"public_catalog": None,
"public_keyring": None,
}
if catalog_path.exists():
try:
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
state["catalog_version"] = catalog.get("catalog_version")
core_release = catalog.get("core_release") if isinstance(catalog.get("core_release"), dict) else {}
state["core_release_version"] = core_release.get("version")
modules = catalog.get("modules") if isinstance(catalog.get("modules"), list) else []
state["module_versions"] = {
item.get("module_id"): item.get("version")
for item in modules
if isinstance(item, dict) and item.get("module_id")
}
signatures = catalog.get("signatures")
state["signature_count"] = len(signatures) if isinstance(signatures, list) else 0
except Exception as exc: # noqa: BLE001 - surfaced as a doctor finding.
state["catalog_error"] = str(exc)
if keyring_path.exists():
try:
keyring = json.loads(keyring_path.read_text(encoding="utf-8"))
keys = keyring.get("keys")
state["key_count"] = len(keys) if isinstance(keys, list) else 0
except Exception as exc: # noqa: BLE001 - surfaced as a doctor finding.
state["keyring_error"] = str(exc)
release_requirements = META_ROOT / "requirements-release.txt"
if release_requirements.exists() and target_version:
text = release_requirements.read_text(encoding="utf-8")
state["requirements_release_mentions_target"] = f"@v{target_version}" in text
release_package = ROOT / "webui" / "package.release.json"
if release_package.exists() and target_version:
text = release_package.read_text(encoding="utf-8")
state["webui_release_mentions_target"] = f"#v{target_version}" in text
if online:
state["public_catalog"] = check_url(DEFAULT_PUBLIC_CATALOG_URL)
state["public_keyring"] = check_url(DEFAULT_PUBLIC_KEYRING_URL)
return state
def catalog_findings(catalog: dict[str, Any], *, target_version: str | None, online: bool) -> tuple[Finding, ...]:
findings: list[Finding] = []
if not catalog.get("catalog_exists"):
findings.append(
Finding(
check_id="catalog-missing",
severity="warning",
title="Stable release catalog is missing locally",
detail=str(catalog.get("catalog_path")),
commands=(publish_catalog_command(target_version),),
)
)
elif catalog.get("catalog_error"):
findings.append(
Finding(
check_id="catalog-invalid",
severity="blocker",
title="Stable release catalog cannot be parsed",
detail=str(catalog["catalog_error"]),
)
)
elif target_version and catalog.get("core_release_version") != target_version:
findings.append(
Finding(
check_id="catalog-version",
severity="warning",
title="Stable catalog does not point at the target core release",
detail=f"catalog core={catalog.get('core_release_version')}, target={target_version}",
commands=(publish_catalog_command(target_version),),
)
)
if catalog.get("catalog_exists") and catalog.get("signature_count", 0) == 0:
findings.append(
Finding(
check_id="catalog-signatures",
severity="blocker",
title="Stable catalog has no signatures",
detail="Operators requiring signed catalogs cannot trust this catalog.",
commands=(publish_catalog_command(target_version),),
)
)
if not catalog.get("keyring_exists"):
findings.append(
Finding(
check_id="keyring-missing",
severity="warning",
title="Stable catalog keyring is missing locally",
detail=str(catalog.get("keyring_path")),
commands=(publish_catalog_command(target_version),),
)
)
elif catalog.get("keyring_error"):
findings.append(
Finding(
check_id="keyring-invalid",
severity="blocker",
title="Stable catalog keyring cannot be parsed",
detail=str(catalog["keyring_error"]),
)
)
elif catalog.get("key_count", 0) == 0:
findings.append(
Finding(
check_id="keyring-empty",
severity="blocker",
title="Stable catalog keyring has no active keys",
detail="Publish or copy the public keyring before release consumers enforce signatures.",
commands=(publish_catalog_command(target_version),),
)
)
if target_version and catalog.get("requirements_release_mentions_target") is False:
findings.append(
Finding(
check_id="release-requirements-stale",
severity="warning",
title="requirements-release.txt does not reference the target tag",
detail=f"Expected @v{target_version} in GovOPlaN release package refs.",
commands=(command(META_ROOT, "Update release refs through release tag workflow", f"tools/release/push-release-tag.sh --version {shlex.quote(target_version)} --strict-migration-audit", mutating=True),),
)
)
if target_version and catalog.get("webui_release_mentions_target") is False:
findings.append(
Finding(
check_id="webui-release-package-stale",
severity="warning",
title="webui/package.release.json does not reference the target tag",
detail=f"Expected #v{target_version} in GovOPlaN WebUI release package refs.",
commands=(command(META_ROOT, "Update WebUI release refs through release tag workflow", f"tools/release/push-release-tag.sh --version {shlex.quote(target_version)} --strict-migration-audit", mutating=True),),
)
)
if online:
for check_id, label, value in (
("public-catalog", "public stable catalog", catalog.get("public_catalog")),
("public-keyring", "public stable keyring", catalog.get("public_keyring")),
):
if isinstance(value, dict) and not value.get("ok"):
findings.append(
Finding(
check_id=check_id,
severity="warning",
title=f"The {label} is not reachable",
detail=str(value.get("error") or value.get("status")),
)
)
return tuple(findings)
def status_from_findings(findings: list[Finding] | tuple[Finding, ...]) -> str:
if any(item.severity == "blocker" for item in findings):
return "blocked"
if any(item.severity == "warning" for item in findings):
return "warning"
return "ready"
def print_text_report(report: DoctorReport, *, detailed: bool = False) -> None:
print(render_text_report(report, detailed=detailed, include_commands=False))
def render_text_report(report: DoctorReport, *, detailed: bool, include_commands: bool) -> str:
lines: list[str] = [
"Release Doctor",
f" workspace: {report.workspace_root}",
f" target: {report.target_version or '-'}",
f" status: {report.overall_status.upper()}",
f" online checks: {'yes' if report.online else 'no'}",
"",
]
lines.extend(repo_summary_lines(report, detailed=detailed))
lines.append("")
lines.extend(finding_lines(report.findings, detailed=detailed))
if include_commands:
lines.append("")
lines.extend(next_command_lines(report))
elif not detailed:
lines.append("")
lines.append("Use --details for full findings, `next` for suggested commands, or `interactive` for guided workflow.")
return "\n".join(lines)
def repo_summary_lines(report: DoctorReport, *, detailed: bool) -> list[str]:
dirty = [repo.name for repo in report.repos if repo.dirty]
missing = [repo.name for repo in report.repos if not repo.exists]
safe_needed = [repo.name for repo in report.repos if repo.safe_directory_required]
ahead = [repo.name for repo in report.repos if repo.ahead]
behind = [repo.name for repo in report.repos if repo.behind]
lines = [
"Repositories:",
f" checked={len(report.repos)} dirty={len(dirty)} missing={len(missing)} ahead={len(ahead)} behind={len(behind)} safe-directory={len(safe_needed)}",
]
for label, values in (
("dirty", dirty),
("missing", missing),
("ahead", ahead),
("behind", behind),
("safe-directory", safe_needed),
):
if values:
lines.append(f" {label}: {', '.join(values)}")
if detailed:
lines.append(" repositories:")
for repo in report.repos:
marker = "!" if repo.dirty or not repo.exists or repo.safe_directory_required else "-"
version = repo.pyproject_version or repo.package_version or repo.webui_package_version or "no version"
branch = repo.branch or "unknown"
lines.append(f" {marker} {repo.name}: {branch}, {version}")
return lines
def print_findings(findings: tuple[Finding, ...], *, detailed: bool = True) -> None:
print("\n".join(finding_lines(findings, detailed=detailed)))
def finding_lines(findings: tuple[Finding, ...], *, detailed: bool) -> list[str]:
if not findings:
return ["Findings: none"]
counts = {
severity: sum(1 for item in findings if item.severity == severity)
for severity in ("blocker", "warning", "info")
}
lines = [
"Findings:",
f" blockers={counts['blocker']} warnings={counts['warning']} info={counts['info']}",
]
for item in sorted(findings, key=lambda finding: (-SEVERITY_ORDER.get(finding.severity, 0), finding.check_id, finding.title)):
lines.append(f" [{item.severity.upper()}] {item.title}")
if detailed and item.detail:
for line in item.detail.splitlines():
lines.append(f" {line}")
return lines
def print_next_commands(report: DoctorReport) -> None:
print("\n".join(next_command_lines(report)))
def next_command_lines(report: DoctorReport) -> list[str]:
commands = collect_suggested_commands(report)
lines = ["Next Commands:"]
if not commands:
lines.append(" No next command suggested.")
return lines
for index, item in enumerate(commands, start=1):
mutating = "mutating" if item.mutating else "safe"
lines.append(f" {index}. {item.title} ({mutating})")
lines.append(f" cd {item.cwd}")
lines.append(f" {item.command}")
return lines
def run_interactive(
report: DoctorReport,
*,
assume_yes: bool = False,
workspace_root: Path,
target_version: str | None,
repo_filter: tuple[str, ...],
online: bool,
run_migration_audit: bool,
) -> int:
while True:
print()
print_interactive_summary(report)
actions = interactive_actions(report)
print()
print("Choose an action:")
for index, (key, title) in enumerate(actions, start=1):
print(f" {index}. {title}")
print(" q. Quit")
choice = input("> ").strip().lower()
if choice in {"q", "quit", "exit"}:
return 0 if report.overall_status != "blocked" else 1
if not choice.isdigit() or not (1 <= int(choice) <= len(actions)):
print("Please enter an action number or q.")
continue
action = actions[int(choice) - 1][0]
if action == "summary":
print("\n".join(finding_lines(report.findings, detailed=False)))
elif action == "details":
page_text(render_text_report(report, detailed=True, include_commands=True))
elif action == "commands":
run_suggested_command_menu(report, assume_yes=assume_yes)
elif action == "release-tags":
run_release_tag_workflow(report)
elif action == "safe-directory":
run_safe_directory_workflow(report)
elif action == "push-ahead":
push_all_ahead_repositories(report)
elif action == "commit-push-dirty":
commit_and_push_dirty_repositories(report)
elif action == "refresh":
report = build_report(
workspace_root=workspace_root,
target_version=target_version,
repo_filter=repo_filter,
online=online,
run_migration_audit=run_migration_audit,
)
def print_interactive_summary(report: DoctorReport) -> None:
dirty = sum(1 for repo in report.repos if repo.dirty)
safe_needed = sum(1 for repo in report.repos if repo.safe_directory_required)
ahead = sum(1 for repo in report.repos if repo.ahead)
blockers = sum(1 for item in report.findings if item.severity == "blocker")
warnings = sum(1 for item in report.findings if item.severity == "warning")
print(f"Release Doctor: target={report.target_version or '-'} status={report.overall_status.upper()}")
print(f"Repos: checked={len(report.repos)} dirty={dirty} ahead={ahead} safe-directory={safe_needed}")
print(f"Findings: blockers={blockers} warnings={warnings}")
def interactive_actions(report: DoctorReport) -> list[tuple[str, str]]:
actions = [
("summary", "Show concise findings"),
("details", "Open full report/details"),
("commands", "Choose one suggested command"),
]
if report.target_version:
actions.append(("release-tags", f"Run release tag workflow for v{report.target_version}"))
if any(repo.safe_directory_required for repo in report.repos):
actions.append(("safe-directory", "Mark dubious-ownership repositories as safe"))
if any(repo.ahead and not repo.dirty for repo in report.repos):
actions.append(("push-ahead", "Push all clean repositories with unpushed commits"))
if any(repo.dirty for repo in report.repos):
actions.append(("commit-push-dirty", "Commit and push all dirty repositories"))
actions.append(("refresh", "Refresh release doctor state"))
return actions
def run_suggested_command_menu(report: DoctorReport, *, assume_yes: bool = False) -> None:
commands = collect_suggested_commands(report)
if not commands:
print("No suggested commands to run.")
return
while True:
print()
print("Suggested commands:")
for index, item in enumerate(commands, start=1):
marker = "!" if item.mutating else "-"
print(f" {index}. {marker} {item.title}")
print(" b. Back")
choice = input("> ").strip().lower()
if choice in {"b", "back", "q", "quit"}:
return
if not choice.isdigit() or not (1 <= int(choice) <= len(commands)):
print("Please enter a command number or b.")
continue
run_suggested_command(commands[int(choice) - 1], assume_yes=assume_yes)
def run_suggested_command(item: SuggestedCommand, *, assume_yes: bool = False) -> int | None:
print(f"cwd: {item.cwd}")
print(f"cmd: {item.command}")
if item.mutating:
confirmation = input("This command may modify repositories or release state. Type RUN to execute: ").strip()
if confirmation != "RUN":
print("Skipped.")
return None
elif not assume_yes:
confirmation = input("Run this command? [y/N] ").strip().lower()
if confirmation not in {"y", "yes"}:
print("Skipped.")
return None
try:
argv = command_argv(item)
except ValueError as exc:
print(f"Cannot run command safely: {exc}")
return 2
result = subprocess.run(argv, cwd=item.cwd, check=False)
print(f"Command exited with {result.returncode}.")
return result.returncode
def run_release_tag_workflow(report: DoctorReport) -> None:
if not report.target_version:
print("No target version is set. Re-run with --target-version <x.y.z>.")
return
print(f"Release tag workflow for v{report.target_version}")
print(" 1. Dry run: print the intended commit/tag/push plan")
print(" 2. Real run: commit, tag, and push according to the release script")
print(" b. Back")
choice = input("> ").strip().lower()
if choice in {"b", "back", "q", "quit"}:
return
if choice == "1":
run_suggested_command(release_tag_command(report, dry_run=True), assume_yes=False)
elif choice == "2":
run_suggested_command(release_tag_command(report, dry_run=False), assume_yes=False)
else:
print("Please enter 1, 2, or b.")
def collect_suggested_commands(report: DoctorReport) -> tuple[SuggestedCommand, ...]:
seen: set[tuple[str, str]] = set()
commands: list[SuggestedCommand] = []
for finding in sorted(report.findings, key=lambda item: (-SEVERITY_ORDER.get(item.severity, 0), item.check_id, item.title)):
for item in finding.commands:
key = (item.cwd, item.command)
if key in seen:
continue
seen.add(key)
commands.append(item)
return tuple(commands)
def run_safe_directory_workflow(report: DoctorReport) -> None:
repos = tuple(repo for repo in report.repos if repo.safe_directory_required and repo.safe_directory_command)
if not repos:
print("No dubious-ownership repositories detected.")
return
print("Git refused to inspect these repositories because of dubious ownership:")
for repo in repos:
print(f" - {repo.name}: {repo.path}")
print()
print("The doctor can run the corresponding safe.directory commands for the current user.")
confirmation = input("Type SAFE to run them: ").strip()
if confirmation != "SAFE":
print("Skipped.")
return
for repo in repos:
assert repo.safe_directory_command is not None
result = subprocess.run(safe_directory_argv(Path(repo.path)), cwd=ROOT, check=False)
print(f"{repo.name}: command exited with {result.returncode}.")
def push_all_ahead_repositories(report: DoctorReport) -> None:
repos = tuple(repo for repo in report.repos if repo.exists and repo.ahead and not repo.dirty and not repo.safe_directory_required)
if not repos:
print("No clean repositories with unpushed commits were detected.")
return
print("These clean repositories have local commits to push:")
for repo in repos:
print(f" - {repo.name}: ahead={repo.ahead}")
confirmation = input("Type PUSH to run git push in all of them: ").strip()
if confirmation != "PUSH":
print("Skipped.")
return
for repo in repos:
result = subprocess.run(["git", "push"], cwd=repo.path, check=False)
print(f"{repo.name}: git push exited with {result.returncode}.")
def commit_and_push_dirty_repositories(report: DoctorReport) -> None:
repos = tuple(
repo
for repo in report.repos
if repo.exists and repo.dirty and not repo.safe_directory_required
)
if not repos:
print("No dirty repositories were detected.")
return
blocked = tuple(repo for repo in repos if repo.behind)
eligible = tuple(repo for repo in repos if not repo.behind)
if blocked:
print("These dirty repositories are behind their upstream and will be skipped:")
for repo in blocked:
print(f" - {repo.name}: behind={repo.behind}")
print()
if not eligible:
print("No dirty repositories are eligible for bulk commit/push.")
return
print("This action will run `git add -A`, `git commit`, and `git push` in these repositories:")
for repo in eligible:
print(f" - {repo.name}: {len(repo.dirty_entries)} dirty entr{'y' if len(repo.dirty_entries) == 1 else 'ies'}")
print()
print("Review the detailed report first if you are not certain every dirty file belongs in one commit per repository.")
message = input("Commit message [Release preparation updates]: ").strip() or "Release preparation updates"
confirmation = input("Type COMMIT AND PUSH to continue: ").strip()
if confirmation != "COMMIT AND PUSH":
print("Skipped.")
return
for repo in eligible:
repo_path = Path(repo.path)
print(f"\n== {repo.name} ==")
add_result = subprocess.run(["git", "add", "-A"], cwd=repo_path, check=False)
if add_result.returncode != 0:
print(f"git add failed with {add_result.returncode}; skipping push.")
continue
if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=repo_path, check=False).returncode == 0:
print("No staged changes after git add; skipping.")
continue
commit_result = subprocess.run(["git", "commit", "-m", message], cwd=repo_path, check=False)
if commit_result.returncode != 0:
print(f"git commit failed with {commit_result.returncode}; skipping push.")
continue
push_result = subprocess.run(["git", "push"], cwd=repo_path, check=False)
print(f"git push exited with {push_result.returncode}.")
def page_text(text: str) -> None:
if sys.stdout.isatty():
try:
result = subprocess.run(["less", "-R"], input=text, text=True, check=False)
if result.returncode != 127:
return
except OSError:
pass
print(text)
def report_to_dict(report: DoctorReport) -> dict[str, Any]:
return asdict(report)
def command(
cwd: Path,
title: str,
value: str,
*,
mutating: bool = False,
reason: str = "",
argv: tuple[str, ...] = (),
) -> SuggestedCommand:
return SuggestedCommand(title=title, command=value, cwd=str(cwd), argv=argv, mutating=mutating, reason=reason)
def command_argv(item: SuggestedCommand) -> tuple[str, ...]:
if item.argv:
return item.argv
argv = tuple(shlex.split(item.command))
if not argv:
raise ValueError("empty command")
if any(_looks_like_shell_syntax(part) for part in argv):
raise ValueError("command contains shell syntax and needs an explicit argv definition")
return argv
def _looks_like_shell_syntax(value: str) -> bool:
return any(marker in value for marker in ("|", "&&", "||", ";", "$(", "`", ">", "<"))
def release_tag_command(report: DoctorReport, *, dry_run: bool) -> SuggestedCommand:
assert report.target_version is not None
suffix = " --dry-run" if dry_run else ""
argv = (
"tools/release/push-release-tag.sh",
"--version",
report.target_version,
"--strict-migration-audit",
) + (("--dry-run",) if dry_run else ())
return command(
META_ROOT,
"Preview release tagging workflow" if dry_run else "Run release tagging workflow",
f"tools/release/push-release-tag.sh --version {shlex.quote(report.target_version)} --strict-migration-audit{suffix}",
mutating=not dry_run,
argv=argv,
)
def migration_audit_command(track: str, *, strict: bool = False) -> SuggestedCommand:
extra = " --strict" if strict else ""
argv = (python_bin_path(), "tools/release/release-migration-audit.py", "--track", track) + (("--strict",) if strict else ())
return command(META_ROOT, f"Run {track} migration audit", f"{python_bin()} tools/release/release-migration-audit.py --track {track}{extra}", argv=argv)
def publish_catalog_command(target_version: str | None) -> SuggestedCommand:
version = shlex.quote(target_version or "<x.y.z>")
display_version = target_version or "<x.y.z>"
key_dir = Path.home() / ".config" / "govoplan" / "release-keys"
return command(
META_ROOT,
"Publish signed release catalog",
"KEY_DIR=\"$HOME/.config/govoplan/release-keys\" "
f"tools/release/publish-release-catalog.sh --version {version} "
"--catalog-signing-key \"release-key-1=$KEY_DIR/release-key-1.pem\" --build-web",
mutating=True,
argv=(
"tools/release/publish-release-catalog.sh",
"--version",
display_version,
"--catalog-signing-key",
f"release-key-1={key_dir / 'release-key-1.pem'}",
"--build-web",
),
)
def python_bin() -> str:
return shlex.quote(python_bin_path())
def python_bin_path() -> str:
local = META_ROOT / ".venv" / "bin" / "python"
return str(local if local.exists() else Path(sys.executable))
def normalize_version(value: str) -> str:
return value.removeprefix("v").strip()
def read_pyproject_version(path: Path) -> str | None:
pyproject = path / "pyproject.toml"
if not pyproject.exists():
return None
with pyproject.open("rb") as handle:
payload = tomllib.load(handle)
project = payload.get("project")
if isinstance(project, dict) and isinstance(project.get("version"), str):
return project["version"]
return None
def read_json_version(path: Path) -> str | None:
if not path.exists():
return None
payload = json.loads(path.read_text(encoding="utf-8"))
value = payload.get("version") if isinstance(payload, dict) else None
return value if isinstance(value, str) else None
def git_text(repo: Path, *args: str, check: bool = True) -> str:
result = run_subprocess(["git", "-C", str(repo), *args], cwd=repo, timeout=15)
if check and result.returncode != 0:
raise GitCommandError(
result.stderr.strip() or result.stdout.strip() or f"git {' '.join(args)} failed",
result=result,
)
if result.returncode != 0:
return ""
return result.stdout.strip()
def git_success(repo: Path, *args: str, timeout: int = 15) -> bool:
return run_subprocess(["git", "-C", str(repo), *args], cwd=repo, timeout=timeout).returncode == 0
def run_subprocess(argv: list[str], *, cwd: Path, timeout: int) -> CommandResult:
try:
result = subprocess.run(
argv,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired as exc:
return CommandResult(
returncode=124,
stdout=exc.stdout if isinstance(exc.stdout, str) else "",
stderr=f"Timed out after {timeout}s: {' '.join(argv)}",
)
except OSError as exc:
return CommandResult(returncode=127, stdout="", stderr=str(exc))
return CommandResult(returncode=result.returncode, stdout=result.stdout, stderr=result.stderr)
def git_error_text(result: CommandResult) -> str:
return "\n".join(part for part in (result.stderr.strip(), result.stdout.strip()) if part)
def compact_git_error(text: str) -> str:
lines = [line.strip() for line in text.splitlines() if line.strip()]
if not lines:
return "git command failed"
return " ".join(lines[:4])
def is_dubious_ownership_error(text: str) -> bool:
lowered = text.lower()
return "detected dubious ownership" in lowered and "safe.directory" in lowered
def safe_directory_command(path: Path) -> str:
return "git config --global --add safe.directory " + shlex.quote(str(path.resolve()))
def safe_directory_argv(path: Path) -> list[str]:
return ["git", "config", "--global", "--add", "safe.directory", str(path.resolve())]
def check_url(url: str) -> dict[str, Any]:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return {"ok": False, "error": "URL must use http:// or https:// with a hostname", "url": url}
request = Request(url, method="HEAD")
try:
with urlopen(request, timeout=8) as response: # noqa: S310 - validated operator-requested release availability check. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
return {"ok": 200 <= response.status < 400, "status": response.status, "url": url}
except URLError as exc:
return {"ok": False, "error": str(exc), "url": url}
if __name__ == "__main__":
raise SystemExit(main())