"""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 ''}", f"refs/tags/{tag_name}"]) return shlex.join(["git", "-C", str(web_root), "push", remote, f"HEAD:refs/heads/{branch or ''}"]) 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)