410 lines
18 KiB
Python
410 lines
18 KiB
Python
"""Publish reviewed release catalog candidates into the website repository."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
import json
|
|
from pathlib import Path
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
|
|
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
|
|
|
from .catalog import canonical_hash
|
|
from .model import CatalogPublishResult, CatalogPublishStep
|
|
from .selective_catalog import trusted_keys_from_keyring
|
|
from .workspace import DEFAULT_WORKSPACE_ROOT, resolve_workspace_root, website_root
|
|
|
|
|
|
def publish_catalog_candidate(
|
|
*,
|
|
candidate_dir: Path | str,
|
|
workspace_root: Path | str | None = None,
|
|
web_root: Path | str | None = None,
|
|
channel: str = "stable",
|
|
apply: bool = False, # noqa: A002 - CLI uses --apply.
|
|
build_web: bool = False,
|
|
commit: bool = False,
|
|
tag: bool = False,
|
|
push: bool = False,
|
|
remote: str = "origin",
|
|
branch: str | None = None,
|
|
npm: str = "npm",
|
|
tag_name: str | None = None,
|
|
allow_dirty_website: bool = False,
|
|
) -> CatalogPublishResult:
|
|
workspace = resolve_workspace_root(workspace_root or DEFAULT_WORKSPACE_ROOT)
|
|
candidate_root = Path(candidate_dir).expanduser()
|
|
if not candidate_root.is_absolute():
|
|
candidate_root = Path.cwd() / candidate_root
|
|
resolved_web_root = Path(web_root).expanduser().resolve() if web_root is not None else website_root(workspace)
|
|
candidate_catalog = candidate_root / "channels" / f"{channel}.json"
|
|
candidate_keyring = candidate_root / "keyring.json"
|
|
candidate_modules = candidate_root / "modules"
|
|
target_catalog = resolved_web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
|
|
target_keyring = resolved_web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
|
target_modules = resolved_web_root / "public" / "catalogs" / "v1" / "modules"
|
|
|
|
steps: list[CatalogPublishStep] = []
|
|
notes: list[str] = []
|
|
blockers: list[str] = []
|
|
|
|
if not candidate_catalog.exists():
|
|
blockers.append(f"candidate catalog is missing: {candidate_catalog}")
|
|
if not candidate_keyring.exists():
|
|
blockers.append(f"candidate keyring is missing: {candidate_keyring}")
|
|
if not resolved_web_root.exists():
|
|
blockers.append(f"website root is missing: {resolved_web_root}")
|
|
if push:
|
|
commit = True
|
|
if tag:
|
|
commit = True
|
|
|
|
candidate_payload = read_json(candidate_catalog) if candidate_catalog.exists() else None
|
|
keyring_payload = read_json(candidate_keyring) if candidate_keyring.exists() else None
|
|
candidate_catalog_hash = canonical_hash(candidate_payload) if candidate_payload is not None else None
|
|
candidate_keyring_hash = canonical_hash(keyring_payload) if keyring_payload is not None else None
|
|
target_catalog_hash_before = file_json_hash(target_catalog)
|
|
target_keyring_hash_before = file_json_hash(target_keyring)
|
|
validation_valid = False
|
|
validation_error: str | None = None
|
|
validation_warnings: tuple[str, ...] = ()
|
|
|
|
if candidate_payload is not None and keyring_payload is not None:
|
|
validation = validate_module_package_catalog(
|
|
candidate_catalog,
|
|
require_trusted=True,
|
|
approved_channels=(channel,),
|
|
trusted_keys=trusted_keys_from_keyring(keyring_payload if isinstance(keyring_payload, dict) else {}),
|
|
)
|
|
validation_valid = validation.get("valid") is True
|
|
validation_error = str(validation["error"]) if validation.get("error") else None
|
|
validation_warnings = tuple(str(item) for item in validation.get("warnings") or ())
|
|
if not validation_valid:
|
|
blockers.append(validation_error or "candidate catalog validation failed")
|
|
|
|
catalog_changed = candidate_catalog_hash != target_catalog_hash_before
|
|
keyring_changed = candidate_keyring_hash != target_keyring_hash_before
|
|
if not catalog_changed and not keyring_changed:
|
|
notes.append("Candidate catalog and keyring already match the website repository.")
|
|
|
|
if apply or commit or tag or push:
|
|
if not (resolved_web_root / ".git").exists():
|
|
blockers.append(f"website root is not a Git repository: {resolved_web_root}")
|
|
if not allow_dirty_website and website_dirty(resolved_web_root):
|
|
blockers.append("website repository has uncommitted changes")
|
|
|
|
effective_branch = branch or git_text(resolved_web_root, "branch", "--show-current") or None
|
|
effective_tag_name = tag_name or default_tag_name(candidate_payload, channel=channel)
|
|
if (tag or push) and not effective_tag_name:
|
|
blockers.append("could not infer catalog tag name; pass --tag-name")
|
|
if (commit or push) and not effective_branch:
|
|
blockers.append("could not infer website branch; pass --branch")
|
|
if tag and effective_tag_name and git_success(resolved_web_root, "rev-parse", "--quiet", "--verify", f"refs/tags/{effective_tag_name}"):
|
|
blockers.append(f"website tag already exists: {effective_tag_name}")
|
|
|
|
steps.append(
|
|
CatalogPublishStep(
|
|
id="validate",
|
|
title="Validate candidate catalog",
|
|
detail="Validate signature, approved channel, freshness, interface closure, and keyring trust.",
|
|
status="done" if validation_valid else "blocked",
|
|
)
|
|
)
|
|
steps.append(
|
|
CatalogPublishStep(
|
|
id="copy",
|
|
title="Copy candidate catalog, keyring, and module directory",
|
|
detail=f"{candidate_root} -> {resolved_web_root / 'public' / 'catalogs' / 'v1'}",
|
|
mutating=True,
|
|
status="planned" if not apply else "blocked" if blockers else "done",
|
|
)
|
|
)
|
|
if not candidate_modules.exists():
|
|
notes.append("Candidate has no module-directory tree; only catalog and keyring will be copied.")
|
|
if build_web:
|
|
steps.append(
|
|
CatalogPublishStep(
|
|
id="build-web",
|
|
title="Build website",
|
|
detail="Run the website build after copying catalog files.",
|
|
command=shlex.join([npm, "--prefix", str(resolved_web_root), "run", "build"]),
|
|
mutating=True,
|
|
status="planned" if not apply else "blocked" if blockers else "pending",
|
|
)
|
|
)
|
|
if commit:
|
|
steps.append(
|
|
CatalogPublishStep(
|
|
id="commit",
|
|
title="Commit website catalog update",
|
|
detail="Commit generated catalog/keyring files in the website repository.",
|
|
command=shlex.join(["git", "-C", str(resolved_web_root), "commit", "-m", commit_message(candidate_payload, channel=channel)]),
|
|
mutating=True,
|
|
status="planned" if not apply else "blocked" if blockers else "pending",
|
|
)
|
|
)
|
|
if tag:
|
|
steps.append(
|
|
CatalogPublishStep(
|
|
id="tag",
|
|
title=f"Tag website catalog publication {effective_tag_name}",
|
|
detail="Create an annotated website publication tag.",
|
|
command=shlex.join(["git", "-C", str(resolved_web_root), "tag", "-a", effective_tag_name, "-m", commit_message(candidate_payload, channel=channel)]),
|
|
mutating=True,
|
|
status="planned" if not apply else "blocked" if blockers else "pending",
|
|
)
|
|
)
|
|
if push:
|
|
steps.append(
|
|
CatalogPublishStep(
|
|
id="push",
|
|
title="Push website catalog publication",
|
|
detail="Push website branch and tag if a tag was requested.",
|
|
command=push_command(resolved_web_root, remote=remote, branch=effective_branch, tag_name=effective_tag_name if tag else None),
|
|
mutating=True,
|
|
status="planned" if not apply else "blocked" if blockers else "pending",
|
|
)
|
|
)
|
|
|
|
if blockers:
|
|
return result(
|
|
status="blocked",
|
|
applied=False,
|
|
candidate_root=candidate_root,
|
|
candidate_catalog=candidate_catalog,
|
|
candidate_keyring=candidate_keyring,
|
|
resolved_web_root=resolved_web_root,
|
|
target_catalog=target_catalog,
|
|
target_keyring=target_keyring,
|
|
channel=channel,
|
|
branch=effective_branch,
|
|
tag_name=effective_tag_name,
|
|
validation_valid=validation_valid,
|
|
validation_error=validation_error,
|
|
validation_warnings=validation_warnings,
|
|
candidate_catalog_hash=candidate_catalog_hash,
|
|
candidate_keyring_hash=candidate_keyring_hash,
|
|
target_catalog_hash_before=target_catalog_hash_before,
|
|
target_keyring_hash_before=target_keyring_hash_before,
|
|
catalog_changed=catalog_changed,
|
|
keyring_changed=keyring_changed,
|
|
steps=tuple(steps),
|
|
notes=tuple([*notes, *blockers]),
|
|
)
|
|
|
|
if not apply:
|
|
notes.append("Dry run only. Pass --apply to copy files; pass --commit/--tag/--push for Git publication steps.")
|
|
return result(
|
|
status="ready",
|
|
applied=False,
|
|
candidate_root=candidate_root,
|
|
candidate_catalog=candidate_catalog,
|
|
candidate_keyring=candidate_keyring,
|
|
resolved_web_root=resolved_web_root,
|
|
target_catalog=target_catalog,
|
|
target_keyring=target_keyring,
|
|
channel=channel,
|
|
branch=effective_branch,
|
|
tag_name=effective_tag_name,
|
|
validation_valid=validation_valid,
|
|
validation_error=validation_error,
|
|
validation_warnings=validation_warnings,
|
|
candidate_catalog_hash=candidate_catalog_hash,
|
|
candidate_keyring_hash=candidate_keyring_hash,
|
|
target_catalog_hash_before=target_catalog_hash_before,
|
|
target_keyring_hash_before=target_keyring_hash_before,
|
|
catalog_changed=catalog_changed,
|
|
keyring_changed=keyring_changed,
|
|
steps=tuple(steps),
|
|
notes=tuple(notes),
|
|
)
|
|
|
|
target_catalog.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(candidate_catalog, target_catalog)
|
|
shutil.copy2(candidate_keyring, target_keyring)
|
|
if candidate_modules.exists():
|
|
if target_modules.exists():
|
|
shutil.rmtree(target_modules)
|
|
shutil.copytree(candidate_modules, target_modules)
|
|
|
|
completed_steps = [replace_status(step, "done") if step.id == "copy" else step for step in steps]
|
|
if build_web:
|
|
run_checked([npm, "--prefix", str(resolved_web_root), "run", "build"], cwd=resolved_web_root)
|
|
completed_steps = [replace_status(step, "done") if step.id == "build-web" else step for step in completed_steps]
|
|
if commit:
|
|
add_paths = ["git", "-C", str(resolved_web_root), "add", str(target_catalog), str(target_keyring)]
|
|
if target_modules.exists():
|
|
add_paths.append(str(target_modules))
|
|
run_checked(add_paths, cwd=resolved_web_root)
|
|
if git_success(resolved_web_root, "diff", "--cached", "--quiet"):
|
|
notes.append("No website catalog changes were staged for commit.")
|
|
completed_steps = [replace_status(step, "skipped") if step.id == "commit" else step for step in completed_steps]
|
|
else:
|
|
run_checked(["git", "-C", str(resolved_web_root), "commit", "-m", commit_message(candidate_payload, channel=channel)], cwd=resolved_web_root)
|
|
completed_steps = [replace_status(step, "done") if step.id == "commit" else step for step in completed_steps]
|
|
if tag and effective_tag_name:
|
|
run_checked(["git", "-C", str(resolved_web_root), "tag", "-a", effective_tag_name, "-m", commit_message(candidate_payload, channel=channel)], cwd=resolved_web_root)
|
|
completed_steps = [replace_status(step, "done") if step.id == "tag" else step for step in completed_steps]
|
|
if push and effective_branch:
|
|
push_args = ["git", "-C", str(resolved_web_root), "push"]
|
|
if tag and effective_tag_name:
|
|
push_args.extend(["--atomic", remote, f"HEAD:refs/heads/{effective_branch}", f"refs/tags/{effective_tag_name}"])
|
|
else:
|
|
push_args.extend([remote, f"HEAD:refs/heads/{effective_branch}"])
|
|
run_checked(push_args, cwd=resolved_web_root)
|
|
completed_steps = [replace_status(step, "done") if step.id == "push" else step for step in completed_steps]
|
|
|
|
return result(
|
|
status="published" if push else "applied",
|
|
applied=True,
|
|
candidate_root=candidate_root,
|
|
candidate_catalog=candidate_catalog,
|
|
candidate_keyring=candidate_keyring,
|
|
resolved_web_root=resolved_web_root,
|
|
target_catalog=target_catalog,
|
|
target_keyring=target_keyring,
|
|
channel=channel,
|
|
branch=effective_branch,
|
|
tag_name=effective_tag_name,
|
|
validation_valid=validation_valid,
|
|
validation_error=validation_error,
|
|
validation_warnings=validation_warnings,
|
|
candidate_catalog_hash=candidate_catalog_hash,
|
|
candidate_keyring_hash=candidate_keyring_hash,
|
|
target_catalog_hash_before=target_catalog_hash_before,
|
|
target_keyring_hash_before=target_keyring_hash_before,
|
|
catalog_changed=catalog_changed,
|
|
keyring_changed=keyring_changed,
|
|
steps=tuple(completed_steps),
|
|
notes=tuple(notes),
|
|
)
|
|
|
|
|
|
def result(
|
|
*,
|
|
status: str,
|
|
applied: bool,
|
|
candidate_root: Path,
|
|
candidate_catalog: Path,
|
|
candidate_keyring: Path,
|
|
resolved_web_root: Path,
|
|
target_catalog: Path,
|
|
target_keyring: Path,
|
|
channel: str,
|
|
branch: str | None,
|
|
tag_name: str | None,
|
|
validation_valid: bool,
|
|
validation_error: str | None,
|
|
validation_warnings: tuple[str, ...],
|
|
candidate_catalog_hash: str | None,
|
|
candidate_keyring_hash: str | None,
|
|
target_catalog_hash_before: str | None,
|
|
target_keyring_hash_before: str | None,
|
|
catalog_changed: bool,
|
|
keyring_changed: bool,
|
|
steps: tuple[CatalogPublishStep, ...],
|
|
notes: tuple[str, ...],
|
|
) -> CatalogPublishResult:
|
|
return CatalogPublishResult(
|
|
generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
channel=channel,
|
|
status=status,
|
|
applied=applied,
|
|
candidate_dir=str(candidate_root),
|
|
candidate_catalog_path=str(candidate_catalog),
|
|
candidate_keyring_path=str(candidate_keyring),
|
|
web_root=str(resolved_web_root),
|
|
target_catalog_path=str(target_catalog),
|
|
target_keyring_path=str(target_keyring),
|
|
branch=branch,
|
|
tag_name=tag_name,
|
|
validation_valid=validation_valid,
|
|
validation_error=validation_error,
|
|
validation_warnings=validation_warnings,
|
|
candidate_catalog_hash=candidate_catalog_hash,
|
|
candidate_keyring_hash=candidate_keyring_hash,
|
|
target_catalog_hash_before=target_catalog_hash_before,
|
|
target_keyring_hash_before=target_keyring_hash_before,
|
|
catalog_changed=catalog_changed,
|
|
keyring_changed=keyring_changed,
|
|
steps=steps,
|
|
notes=notes,
|
|
)
|
|
|
|
|
|
def read_json(path: Path) -> object:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def file_json_hash(path: Path) -> str | None:
|
|
if not path.exists():
|
|
return None
|
|
return canonical_hash(read_json(path))
|
|
|
|
|
|
def website_dirty(path: Path) -> bool:
|
|
if not (path / ".git").exists():
|
|
return False
|
|
return bool(git_text(path, "status", "--porcelain"))
|
|
|
|
|
|
def default_tag_name(payload: object, *, channel: str) -> str | None:
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
sequence = payload.get("sequence")
|
|
release = payload.get("release")
|
|
selected_units = release.get("selected_units") if isinstance(release, dict) else None
|
|
if isinstance(selected_units, list) and len(selected_units) == 1 and isinstance(selected_units[0], dict):
|
|
repo = str(selected_units[0].get("repo") or "catalog")
|
|
version = str(selected_units[0].get("version") or "").removeprefix("v")
|
|
if version:
|
|
return f"catalog-{channel}-{repo}-v{version}"
|
|
return f"catalog-{channel}-{sequence}" if sequence is not None else None
|
|
|
|
|
|
def commit_message(payload: object, *, channel: str) -> str:
|
|
if isinstance(payload, dict):
|
|
release = payload.get("release")
|
|
selected_units = release.get("selected_units") if isinstance(release, dict) else None
|
|
if isinstance(selected_units, list) and selected_units:
|
|
units = ", ".join(
|
|
f"{item.get('repo')} v{str(item.get('version') or '').removeprefix('v')}"
|
|
for item in selected_units
|
|
if isinstance(item, dict)
|
|
)
|
|
if units:
|
|
return f"Publish {channel} catalog for {units}"
|
|
return f"Publish {channel} catalog"
|
|
|
|
|
|
def push_command(web_root: Path, *, remote: str, branch: str | None, tag_name: str | None) -> str:
|
|
if tag_name:
|
|
return shlex.join(["git", "-C", str(web_root), "push", "--atomic", remote, f"HEAD:refs/heads/{branch or '<branch>'}", f"refs/tags/{tag_name}"])
|
|
return shlex.join(["git", "-C", str(web_root), "push", remote, f"HEAD:refs/heads/{branch or '<branch>'}"])
|
|
|
|
|
|
def replace_status(step: CatalogPublishStep, status: str) -> CatalogPublishStep:
|
|
return CatalogPublishStep(
|
|
id=step.id,
|
|
title=step.title,
|
|
detail=step.detail,
|
|
command=step.command,
|
|
mutating=step.mutating,
|
|
status=status,
|
|
)
|
|
|
|
|
|
def git_text(path: Path, *args: str) -> str:
|
|
result = subprocess.run(["git", *args], cwd=path, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
return result.stdout.strip() if result.returncode == 0 else ""
|
|
|
|
|
|
def git_success(path: Path, *args: str) -> bool:
|
|
return subprocess.run(["git", *args], cwd=path, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0
|
|
|
|
|
|
def run_checked(args: list[str], *, cwd: Path) -> None:
|
|
subprocess.run(args, cwd=cwd, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|