"""FastAPI app for the local GovOPlaN release console.""" from __future__ import annotations import hashlib import os from pathlib import Path from typing import Literal from fastapi import FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel, Field, field_validator from govoplan_release import ( build_dashboard, build_release_intelligence, build_release_plan, build_selective_release_plan, publish_catalog_candidate, prepare_repositories, push_repositories, sync_repositories, tag_repositories, ) from govoplan_release.model import to_jsonable from govoplan_release.candidate_artifact import ( CandidateArtifactError, issue_candidate_id, validate_release_channel, verify_candidate_receipt, ) from govoplan_release.release_execution import ( DURABLE_REMOTE, ReleaseExecutionAmbiguous, ReleaseExecutionBlocked, bind_plan_source_states, execute_repository_step, executor_spec, generate_catalog_candidate, publish_received_candidate, require_trusted_release_runtime, reconciled_catalog_publication_receipt, reconciled_repository_receipt, validated_candidate_receipt, verify_catalog_publication_precondition, verify_repository_preflight_binding, verify_repository_step_precondition, verify_release_runtime_binding, ) from govoplan_release.release_run import ( ReleaseRunConflict, ReleaseRunCorrupt, ReleaseRunNotFound, ReleaseRunStore, ) from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT, website_root class CatalogCandidateRequest(BaseModel): repo_versions: dict[str, str] = Field(default_factory=dict) repos: list[str] = Field(default_factory=list) target_version: str | None = None channel: str = "stable" signing_keys: list[str] = Field(default_factory=list) output_dir: str | None = None base_catalog: str | None = None base_keyring: str | None = None expires_days: int = 90 sequence: int | None = None public_base_url: str = "https://govoplan.add-ideas.de" repository_base: str = "git+ssh://git@git.add-ideas.de/add-ideas" source_remote: str = "origin" check_public: bool = True @field_validator("channel") @classmethod def validate_channel(cls, value: str) -> str: return validate_release_channel(value) class PublishCandidateRequest(BaseModel): candidate_dir: str channel: str = "stable" web_root: str | None = None apply: bool = False # noqa: A003 - API field mirrors CLI. build_web: bool = False commit: bool = False tag: bool = False push: bool = False remote: str = "origin" source_remote: str = "origin" branch: str | None = None tag_name: str | None = None npm: str = "npm" allow_dirty_website: bool = False confirm: str = "" @field_validator("channel") @classmethod def validate_channel(cls, value: str) -> str: return validate_release_channel(value) class RepositoryPushRequest(BaseModel): repos: list[str] = Field(default_factory=list) remote: Literal["origin"] = "origin" apply: bool = False # noqa: A003 - API field mirrors CLI. confirm: str = "" class RepositorySyncRequest(BaseModel): repos: list[str] = Field(default_factory=list) remote: Literal["origin"] = "origin" apply: bool = False # noqa: A003 - API field mirrors CLI. confirm: str = "" class RepositoryPrepareRequest(BaseModel): repos: list[str] = Field(default_factory=list) repo_versions: dict[str, str] = Field(default_factory=dict) message: str | None = None apply: bool = False # noqa: A003 - API field mirrors CLI. confirm: str = "" class RepositoryTagRequest(BaseModel): repos: list[str] = Field(default_factory=list) repo_versions: dict[str, str] = Field(default_factory=dict) remote: str = "origin" message: str | None = None apply: bool = False # noqa: A003 - API field mirrors CLI. push: bool = False confirm: str = "" class ReleaseRunCreateRequest(BaseModel): request_id: str = Field(min_length=8, max_length=128) repo_versions: dict[str, str] = Field(default_factory=dict) channel: str = Field(default="stable", min_length=1, max_length=64) online: bool = False remote_tags: bool = False public_catalog: bool = True include_migrations: bool = False @field_validator("channel") @classmethod def validate_channel(cls, value: str) -> str: return validate_release_channel(value) class ReleaseRunCommandRequest(BaseModel): request_id: str = Field(min_length=8, max_length=128) class ReleaseRunReconcileRequest(BaseModel): request_id: str = Field(min_length=8, max_length=128) outcome: Literal["effect_absent", "effect_succeeded", "unresolved"] confirm: str = Field(default="", max_length=32) class ReleaseRunExecuteRequest(BaseModel): request_id: str = Field(min_length=8, max_length=128) confirm: str = Field(default="", max_length=32) remote: Literal["origin"] = "origin" signing_keys: list[str] = Field(default_factory=list, max_length=8) class ReleaseRunPreviewRequest(BaseModel): remote: Literal["origin"] = "origin" def create_app( *, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | None = None, run_state_root: Path | None = None, candidate_root: Path | None = None, ) -> FastAPI: app = FastAPI(title="GovOPlaN Release Console", version="0.1.0") app.state.workspace_root = workspace_root app.state.token = token app.state.workspace_fingerprint = release_workspace_fingerprint(workspace_root) release_run_root = ( workspace_release_run_root( run_state_root, app.state.workspace_fingerprint ) if run_state_root is not None else default_release_run_root(workspace_root) ) app.state.release_runs = ReleaseRunStore( release_run_root, expected_workspace_fingerprint=app.state.workspace_fingerprint, ) app.state.release_candidate_root = ( Path(os.path.abspath(os.fspath(candidate_root.expanduser()))) if candidate_root is not None else release_run_root.parent / "release-candidates" ) @app.middleware("http") async def require_token(request: Request, call_next): # type: ignore[no-untyped-def] if request.url.path.startswith("/api/"): if token: provided = request.headers.get("x-release-console-token") if provided != token: return JSONResponse( {"detail": "release console token required"}, status_code=401, ) elif request.method not in {"GET", "HEAD", "OPTIONS"}: return JSONResponse( { "detail": ( "release console is read-only without an API token" ) }, status_code=403, ) return await call_next(request) @app.get("/", response_class=HTMLResponse) def index() -> HTMLResponse: index_path = Path(__file__).resolve().parents[1] / "webui" / "index.html" if not index_path.exists(): raise HTTPException(status_code=500, detail="release console UI is missing") return HTMLResponse(index_path.read_text(encoding="utf-8"), headers={"Cache-Control": "no-store"}) @app.get("/api/health") def health() -> dict[str, object]: return { "ok": True, "workspace_root": str(app.state.workspace_root), "token_required": bool(app.state.token), } @app.get("/api/dashboard") def dashboard( target_version: str | None = None, online: bool = False, remote_tags: bool = False, public_catalog: bool = True, include_migrations: bool = False, include_website: bool = False, channel: str = "stable", ) -> dict[str, object]: channel = http_release_channel(channel) snapshot = build_dashboard( workspace_root=app.state.workspace_root, target_version=target_version, online=online, check_remote_tags=online or remote_tags, check_public_catalog=public_catalog, include_migrations=include_migrations, include_website=include_website, channel=channel, ) return to_jsonable(snapshot) @app.get("/api/plan") def plan( target_version: str | None = None, online: bool = False, remote_tags: bool = False, public_catalog: bool = True, include_migrations: bool = False, channel: str = "stable", ) -> dict[str, object]: channel = http_release_channel(channel) snapshot = build_dashboard( workspace_root=app.state.workspace_root, target_version=target_version, online=online, check_remote_tags=online or remote_tags, check_public_catalog=public_catalog, include_migrations=include_migrations, channel=channel, ) release_plan = build_release_plan(snapshot) return to_jsonable(release_plan) @app.get("/api/release-intelligence") def release_intelligence( channel: str = "stable", public_catalog: bool = True, ) -> dict[str, object]: channel = http_release_channel(channel) intelligence = build_release_intelligence( workspace_root=app.state.workspace_root, channel=channel, prefer_public=public_catalog, ) return to_jsonable(intelligence) @app.get("/api/selective-plan") def selective_plan( repos: str | None = None, target_version: str | None = None, repo_versions: str | None = None, online: bool = False, remote_tags: bool = False, public_catalog: bool = True, include_migrations: bool = False, channel: str = "stable", ) -> dict[str, object]: channel = http_release_channel(channel) snapshot = build_dashboard( workspace_root=app.state.workspace_root, target_version=target_version, online=online, check_remote_tags=online or remote_tags, check_public_catalog=public_catalog, include_migrations=include_migrations, channel=channel, ) release_plan = build_selective_release_plan( snapshot, selected_repos=parse_csv(repos), target_version=target_version, repo_versions=parse_repo_versions(repo_versions), channel=channel, ) return to_jsonable(release_plan) @app.get("/api/release-runs") def release_runs( limit: int = 20, cursor: str | None = None ) -> dict[str, object]: try: return app.state.release_runs.list_page(limit=limit, cursor=cursor) except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.post("/api/release-runs") def create_release_run(request: ReleaseRunCreateRequest) -> dict[str, object]: if not request.repo_versions: raise HTTPException( status_code=400, detail="At least one repository version is required" ) input_snapshot = { "channel": request.channel, "repo_versions": dict(request.repo_versions), "online": request.online, "remote_tags": request.remote_tags, "public_catalog": request.public_catalog, "include_migrations": request.include_migrations, "workspace_fingerprint": app.state.workspace_fingerprint, } try: existing = app.state.release_runs.find_created_run( request_id=request.request_id, input_snapshot=input_snapshot, ) except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc if existing is not None: return release_run_view(existing) try: require_trusted_release_runtime( workspace_root=app.state.workspace_root ) except ReleaseExecutionBlocked as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc snapshot = build_dashboard( workspace_root=app.state.workspace_root, online=request.online, check_remote_tags=request.online or request.remote_tags, check_public_catalog=request.public_catalog, include_migrations=request.include_migrations, channel=request.channel, ) plan = build_selective_release_plan( snapshot, selected_repos=tuple(request.repo_versions), repo_versions=dict(request.repo_versions), channel=request.channel, ) plan_payload = to_jsonable(plan) planned_units = plan_payload.get("units") planned_versions = ( { unit.get("repo"): unit.get("target_version") for unit in planned_units if isinstance(unit, dict) and isinstance(unit.get("repo"), str) and isinstance(unit.get("target_version"), str) } if isinstance(planned_units, list) else {} ) if planned_versions != dict(request.repo_versions) or len( planned_versions ) != len(planned_units or []): raise HTTPException( status_code=409, detail=( "The release plan did not resolve exactly the requested repository " "versions; refresh the dashboard and select known release units." ), ) try: plan_payload = bind_plan_source_states( plan=plan_payload, repo_versions=dict(request.repo_versions), workspace_root=app.state.workspace_root, ) return release_run_view( app.state.release_runs.create( request_id=request.request_id, input_snapshot=input_snapshot, plan_snapshot=plan_payload, ) ) except ( ReleaseExecutionBlocked, ReleaseRunConflict, ReleaseRunCorrupt, ) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.get("/api/release-runs/{run_id}") def release_run(run_id: str) -> dict[str, object]: try: return release_run_view(app.state.release_runs.get(run_id)) except ReleaseRunNotFound as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.post("/api/release-runs/{run_id}/resume") def resume_release_run( run_id: str, request: ReleaseRunCommandRequest ) -> dict[str, object]: try: return release_run_view( app.state.release_runs.resume( run_id, request_id=request.request_id ) ) except ReleaseRunNotFound as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.post("/api/release-runs/{run_id}/steps/{step_id}/retry") def retry_release_run_step( run_id: str, step_id: str, request: ReleaseRunCommandRequest ) -> dict[str, object]: try: return release_run_view( app.state.release_runs.retry_step( run_id, step_id, request_id=request.request_id, ) ) except ReleaseRunNotFound as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.post("/api/release-runs/{run_id}/steps/{step_id}/reconcile") def reconcile_release_run_step( run_id: str, step_id: str, request: ReleaseRunReconcileRequest ) -> dict[str, object]: try: receipt = None run = app.state.release_runs.get(run_id) state_step = release_run_state_step(run, step_id) immutable = run.get("immutable") frozen_plan = ( immutable.get("plan") if isinstance(immutable, dict) else None ) verify_release_runtime_binding( expected=( frozen_plan.get("runtime_binding") if isinstance(frozen_plan, dict) else None ), workspace_root=app.state.workspace_root, ) if ( request.outcome == "effect_succeeded" and state_step.get("state") == "succeeded" and state_step.get("result_code") == "reconciled_effect_succeeded" ): receipt = state_step.get("result_receipt") if ( request.outcome == "effect_succeeded" and step_id == "catalog:selective-generator" and state_step.get("state") == "interrupted" ): attempt_fingerprint = ( app.state.release_runs.current_attempt_fingerprint( run_id, step_id ) ) candidate_id = issue_candidate_id( run_id, step_id, attempt_fingerprint ) candidate_receipt = validated_candidate_receipt( candidate_root=app.state.release_candidate_root, candidate_id=candidate_id, channel=app.state.release_runs.get(run_id)["immutable"]["input"][ "channel" ], ) receipt = { "kind": "catalog_candidate", "candidate_id": candidate_receipt.candidate_id, "catalog_sha256": candidate_receipt.catalog_sha256, } elif ( request.outcome == "effect_succeeded" and step_id == "catalog:validate-sign-publish" and state_step.get("state") == "interrupted" ): plan_step = release_run_plan_step(run, step_id) candidate_receipt = release_run_candidate_receipt(run) receipt = reconciled_catalog_publication_receipt( candidate_path=verified_run_candidate( run, candidate_root=app.state.release_candidate_root, ), candidate_receipt=candidate_receipt, channel=run["immutable"]["input"]["channel"], workspace_root=app.state.workspace_root, remote=DURABLE_REMOTE, expected_website_receipt=plan_step.get("source_binding"), ) elif request.outcome == "effect_succeeded" and state_step.get( "state" ) == "interrupted": plan_step = release_run_plan_step(run, step_id) spec = executor_spec(plan_step) if spec is not None and spec.kind in {"tag", "push"}: repo = str(plan_step["repo"]) receipt = reconciled_repository_receipt( spec=spec, plan_step=plan_step, version=run["immutable"]["input"]["repo_versions"][repo], workspace_root=app.state.workspace_root, expected_receipt=preceding_repository_receipt( run, step_id=step_id, repo=repo ), ) return release_run_view( app.state.release_runs.reconcile_step( run_id, step_id, request_id=request.request_id, outcome=request.outcome, confirmation=request.confirm, result_receipt=receipt, ) ) except ReleaseRunNotFound as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ( CandidateArtifactError, ReleaseExecutionBlocked, ReleaseRunConflict, ReleaseRunCorrupt, ) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.post("/api/release-runs/{run_id}/steps/{step_id}/execute") def execute_release_run_step( run_id: str, step_id: str, request: ReleaseRunExecuteRequest ) -> dict[str, object]: claim_acquired = False try: run = app.state.release_runs.get(run_id) plan_step = release_run_plan_step(run, step_id) immutable = run.get("immutable") frozen_plan = ( immutable.get("plan") if isinstance(immutable, dict) else None ) verify_release_runtime_binding( expected=( frozen_plan.get("runtime_binding") if isinstance(frozen_plan, dict) else None ), workspace_root=app.state.workspace_root, ) spec = executor_spec(plan_step) if spec is None: raise ReleaseRunConflict( "This frozen plan step has no bounded durable executor." ) if request.confirm != spec.confirmation: required = spec.confirmation or "an empty confirmation" raise ReleaseRunConflict( f"Release step requires exactly {required}." ) expected_repository_receipt = None claim = app.state.release_runs.claim_step( run_id, step_id, attempt_id=request.request_id, ) if not claim.claimed: if claim.outcome == "succeeded": replay = release_run_view(claim.run) replay["execution_result"] = { "status": "replayed", "result_code": claim.result_code, "result_receipt": claim.result_receipt, } return replay if claim.outcome == "running": raise ReleaseRunConflict( "This exact executor attempt is still running or its response " "is uncertain; resume and reconcile it before retrying." ) raise ReleaseRunConflict( "This exact executor attempt already ended; use the run's explicit " "retry or reconciliation action." ) claim_acquired = True signing_keys = tuple(request.signing_keys or default_signing_keys()) if spec.kind == "catalog_generate" and not signing_keys: raise ReleaseExecutionBlocked( "Durable catalog generation requires a configured signing key." ) candidate_path = None candidate_receipt = None website_receipt = None if spec.kind == "catalog_publish": candidate_path = verified_run_candidate( run, candidate_root=app.state.release_candidate_root, ) candidate_receipt = release_run_candidate_receipt(run) website_receipt = verify_catalog_publication_precondition( plan_step=plan_step, workspace_root=app.state.workspace_root, ) if spec.kind in {"preflight", "tag", "push"}: repo = str(plan_step["repo"]) version = run["immutable"]["input"]["repo_versions"][repo] expected_repository_receipt = preceding_repository_receipt( run, step_id=step_id, repo=repo ) if spec.kind == "preflight": verify_repository_preflight_binding( plan_step=plan_step, version=version, workspace_root=app.state.workspace_root, ) else: verify_repository_step_precondition( spec=spec, plan_step=plan_step, version=version, workspace_root=app.state.workspace_root, expected_receipt=expected_repository_receipt, ) receipt = None if spec.kind in {"preflight", "tag", "push"}: result, receipt = execute_repository_step( spec=spec, plan_step=plan_step, repo_versions=run["immutable"]["input"]["repo_versions"], workspace_root=app.state.workspace_root, remote=DURABLE_REMOTE, expected_receipt=expected_repository_receipt, ) elif spec.kind == "catalog_generate": attempt_fingerprint = ( app.state.release_runs.current_attempt_fingerprint( run_id, step_id ) ) candidate_id = issue_candidate_id( run_id, step_id, attempt_fingerprint ) base_catalog, base_keyring = release_catalog_base_paths( app.state.workspace_root, channel=run["immutable"]["input"]["channel"], ) result, candidate_receipt = generate_catalog_candidate( candidate_root=app.state.release_candidate_root, candidate_id=candidate_id, channel=run["immutable"]["input"]["channel"], repo_versions=run["immutable"]["input"]["repo_versions"], workspace_root=app.state.workspace_root, signing_keys=signing_keys, remote=DURABLE_REMOTE, check_public=run["immutable"]["input"]["public_catalog"], source_receipts={ repo: receipt for repo in run["immutable"]["input"]["repo_versions"] if isinstance( ( receipt := preceding_repository_receipt( run, step_id=step_id, repo=repo ) ), dict, ) }, base_catalog=base_catalog, base_keyring=base_keyring, ) receipt = { "kind": "catalog_candidate", "candidate_id": candidate_receipt.candidate_id, "catalog_sha256": candidate_receipt.catalog_sha256, } elif ( spec.kind == "catalog_publish" and candidate_path is not None and candidate_receipt is not None and website_receipt is not None ): candidate_path = verified_run_candidate( run, candidate_root=app.state.release_candidate_root, ) result, receipt = publish_received_candidate( candidate_path=candidate_path, candidate_receipt=candidate_receipt, channel=run["immutable"]["input"]["channel"], workspace_root=app.state.workspace_root, remote=DURABLE_REMOTE, expected_website_receipt=website_receipt, ) try: verified_run_candidate( run, candidate_root=app.state.release_candidate_root, ) except CandidateArtifactError as exc: raise ReleaseExecutionAmbiguous( "Published candidate changed while its effect was in flight; " "reconcile the immutable website commit and remote tag." ) from exc else: raise ReleaseRunConflict("Release executor mapping is incomplete.") finished = app.state.release_runs.finish_step( run_id, step_id, attempt_id=request.request_id, succeeded=True, result_code=spec.result_code, result_receipt=receipt, ) response = release_run_view(finished) response["execution_result"] = { "status": "succeeded", "result_code": spec.result_code, "result_receipt": receipt, "output": result, } return response except ReleaseExecutionBlocked as exc: if not claim_acquired: raise HTTPException(status_code=409, detail=str(exc)[:4000]) from exc failed = app.state.release_runs.finish_step( run_id, step_id, attempt_id=request.request_id, succeeded=False, result_code="executor_blocked", ) response = release_run_view(failed) response["execution_result"] = { "status": "failed", "result_code": "executor_blocked", "detail": str(exc)[:4000], } return response except ReleaseExecutionAmbiguous as exc: app.state.release_runs.interrupt_step( run_id, step_id, attempt_id=request.request_id, ) raise HTTPException(status_code=409, detail=str(exc)[:4000]) from exc except ReleaseRunNotFound as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except CandidateArtifactError as exc: if claim_acquired: failed = app.state.release_runs.finish_step( run_id, step_id, attempt_id=request.request_id, succeeded=False, result_code="candidate_invalid", ) response = release_run_view(failed) response["execution_result"] = { "status": "failed", "result_code": "candidate_invalid", "detail": str(exc)[:4000], } return response raise HTTPException(status_code=409, detail=str(exc)) from exc except ReleaseRunCorrupt as exc: if claim_acquired: try: app.state.release_runs.interrupt_step( run_id, step_id, attempt_id=request.request_id, ) except (ReleaseRunConflict, ReleaseRunCorrupt, ReleaseRunNotFound): pass raise HTTPException( status_code=409, detail=( "The executor outcome could not be persisted safely; resume " "and reconcile this attempt before retrying." ), ) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc except ReleaseRunConflict as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except Exception as exc: # noqa: BLE001 - never retry an uncertain effect. try: app.state.release_runs.interrupt_step( run_id, step_id, attempt_id=request.request_id, ) except (ReleaseRunConflict, ReleaseRunCorrupt, ReleaseRunNotFound): pass raise HTTPException( status_code=409, detail=( f"Executor raised {type(exc).__name__}; its effect is unknown. " "Resume and reconcile before retry." ), ) from exc @app.post("/api/release-runs/{run_id}/steps/{step_id}/preview") def preview_release_run_step( run_id: str, step_id: str, request: ReleaseRunPreviewRequest ) -> dict[str, object]: try: run = app.state.release_runs.get(run_id) plan_step = release_run_plan_step(run, step_id) spec = executor_spec(plan_step) if spec is None or spec.kind != "catalog_publish": raise ReleaseRunConflict( "Only the receipt-bound catalog publication step has this preview." ) candidate = verified_run_candidate( run, candidate_root=app.state.release_candidate_root, ) return to_jsonable( publish_catalog_candidate( candidate_dir=candidate, workspace_root=app.state.workspace_root, channel=run["immutable"]["input"]["channel"], remote=DURABLE_REMOTE, source_remote=DURABLE_REMOTE, apply=False, ) ) except ReleaseRunNotFound as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ( CandidateArtifactError, ReleaseRunConflict, ReleaseRunCorrupt, ) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.get("/api/catalog-candidates") def catalog_candidates() -> dict[str, object]: return { "candidates": list_catalog_candidates(app.state.release_candidate_root) } @app.post("/api/catalog-candidates") def catalog_candidate(request: CatalogCandidateRequest) -> dict[str, object]: del request raise HTTPException( status_code=409, detail=( "Signed catalog generation is available only through a durable " "release run with a trusted runtime and receipt-bound source tags." ), ) @app.post("/api/catalog-candidates/publish") def publish_candidate(request: PublishCandidateRequest) -> dict[str, object]: if request.apply or request.commit or request.tag or request.push or request.build_web: raise HTTPException( status_code=409, detail=( "Catalog mutation is available only through a durable release run " "with a server-issued candidate receipt." ), ) if request.push and request.confirm != "PUSH": raise HTTPException(status_code=400, detail="Push requires confirm=PUSH") if (request.apply or request.commit or request.tag or request.build_web) and not request.push and request.confirm != "APPLY": raise HTTPException(status_code=400, detail="Apply/commit/tag/build requires confirm=APPLY") result = publish_catalog_candidate( candidate_dir=request.candidate_dir, workspace_root=app.state.workspace_root, web_root=request.web_root, channel=request.channel, apply=request.apply, build_web=request.build_web, commit=request.commit, tag=request.tag, push=request.push, remote=request.remote, source_remote=request.source_remote, branch=request.branch, tag_name=request.tag_name, npm=request.npm, allow_dirty_website=request.allow_dirty_website, ) return to_jsonable(result) @app.post("/api/repositories/push") def repository_push(request: RepositoryPushRequest) -> dict[str, object]: repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip())) if not repos: raise HTTPException(status_code=400, detail="At least one repository must be selected") if request.apply: raise HTTPException( status_code=409, detail=( "Repository push mutation is disabled outside a durable " "receipt-bound maintenance run." ), ) result = push_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply) return to_jsonable(result) @app.post("/api/repositories/sync") def repository_sync(request: RepositorySyncRequest) -> dict[str, object]: repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip())) if not repos: raise HTTPException(status_code=400, detail="At least one repository must be selected") if request.apply: raise HTTPException( status_code=409, detail=( "Repository sync mutation is disabled outside a durable " "receipt-bound maintenance run." ), ) result = sync_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply) return to_jsonable(result) @app.post("/api/repositories/prepare") def repository_prepare(request: RepositoryPrepareRequest) -> dict[str, object]: repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip())) if not repos: raise HTTPException(status_code=400, detail="At least one repository must be selected") if request.apply: raise HTTPException( status_code=409, detail=( "Release preparation mutation is disabled outside a durable " "release executor; use preview and commit reviewed changes " "outside this console for now." ), ) if request.apply and request.confirm != "COMMIT": raise HTTPException(status_code=400, detail="Repository prepare requires confirm=COMMIT") result = prepare_repositories( repos=repos, repo_versions=request.repo_versions, message=request.message, workspace_root=app.state.workspace_root, apply=request.apply, ) return to_jsonable(result) @app.post("/api/repositories/tag") def repository_tag(request: RepositoryTagRequest) -> dict[str, object]: repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip())) if not repos: raise HTTPException(status_code=400, detail="At least one repository must be selected") if request.apply: raise HTTPException( status_code=409, detail=( "Release tag mutation is available only through a durable " "release run with creation-time repository bindings." ), ) if request.apply and request.push and request.confirm != "PUBLISH": raise HTTPException(status_code=400, detail="Release tag publication requires confirm=PUBLISH") if request.apply and not request.push and request.confirm != "TAG": raise HTTPException(status_code=400, detail="Release tag creation requires confirm=TAG") result = tag_repositories( repos=repos, repo_versions=request.repo_versions, message=request.message, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply, push=request.push, ) return to_jsonable(result) return app def release_run_view(run: dict[str, object]) -> dict[str, object]: state = run.get("state") immutable = run.get("immutable") plan = immutable.get("plan") if isinstance(immutable, dict) else None steps = state.get("steps", []) if isinstance(state, dict) else [] plan_steps = plan.get("dry_run_steps", []) if isinstance(plan, dict) else [] if isinstance(steps, list) and isinstance(plan_steps, list): plan_by_id = { step.get("id"): step for step in plan_steps if isinstance(step, dict) } for step in steps: if not isinstance(step, dict): continue plan_step = plan_by_id.get(step.get("id")) spec = executor_spec(plan_step) if isinstance(plan_step, dict) else None step["executor"] = { "available": spec is not None, "kind": spec.kind if spec is not None else None, "confirmation": spec.confirmation if spec is not None else None, } return run def release_run_plan_step( run: dict[str, object], step_id: str ) -> dict[str, object]: immutable = run.get("immutable") plan = immutable.get("plan") if isinstance(immutable, dict) else None steps = plan.get("dry_run_steps") if isinstance(plan, dict) else None if not isinstance(steps, list): raise ReleaseRunCorrupt("Release run plan steps are unavailable.") for step in steps: if isinstance(step, dict) and step.get("id") == step_id: return step raise ReleaseRunNotFound("Release run step was not found.") def release_run_state_step( run: dict[str, object], step_id: str ) -> dict[str, object]: state = run.get("state") steps = state.get("steps") if isinstance(state, dict) else None if not isinstance(steps, list): raise ReleaseRunCorrupt("Release run state steps are unavailable.") for step in steps: if isinstance(step, dict) and step.get("id") == step_id: return step raise ReleaseRunNotFound("Release run step was not found.") def preceding_repository_receipt( run: dict[str, object], *, step_id: str, repo: str ) -> dict[str, object] | None: immutable = run.get("immutable") plan = immutable.get("plan") if isinstance(immutable, dict) else None plan_steps = plan.get("dry_run_steps") if isinstance(plan, dict) else None state = run.get("state") state_steps = state.get("steps") if isinstance(state, dict) else None if not isinstance(plan_steps, list) or not isinstance(state_steps, list): raise ReleaseRunCorrupt("Release run repository receipt chain is unavailable.") pairs = list(zip(plan_steps, state_steps, strict=True)) current_index = next( ( index for index, (planned, _mutable) in enumerate(pairs) if isinstance(planned, dict) and planned.get("id") == step_id ), None, ) if current_index is None: raise ReleaseRunNotFound("Release run step was not found.") for planned, mutable in reversed(pairs[:current_index]): if not isinstance(planned, dict) or planned.get("repo") != repo: continue if not isinstance(mutable, dict) or mutable.get("state") != "succeeded": return None receipt = mutable.get("result_receipt") return ( receipt if isinstance(receipt, dict) and receipt.get("kind") == "repository_state" and receipt.get("repo") == repo else None ) return None def verified_run_candidate( run: dict[str, object], *, candidate_root: Path ) -> Path: receipt, channel = release_run_candidate_receipt(run, include_channel=True) return verify_candidate_receipt( root=candidate_root, candidate_id=str(receipt.get("candidate_id") or ""), catalog_sha256=str(receipt.get("catalog_sha256") or ""), channel=channel, ) def release_run_candidate_receipt( run: dict[str, object], *, include_channel: bool = False ) -> dict[str, object] | tuple[dict[str, object], str]: state = run.get("state") steps = state.get("steps") if isinstance(state, dict) else None input_snapshot = ( run.get("immutable", {}).get("input", {}) if isinstance(run.get("immutable"), dict) else {} ) channel = input_snapshot.get("channel") if isinstance(input_snapshot, dict) else None if not isinstance(steps, list) or not isinstance(channel, str): raise ReleaseRunCorrupt("Release run candidate dependency is unavailable.") generator = next( ( step for step in steps if isinstance(step, dict) and step.get("id") == "catalog:selective-generator" ), None, ) receipt = generator.get("result_receipt") if isinstance(generator, dict) else None if ( not isinstance(generator, dict) or generator.get("state") != "succeeded" or not isinstance(receipt, dict) or receipt.get("kind") != "catalog_candidate" ): raise ReleaseRunConflict( "Catalog publication requires the successful generator's durable receipt." ) copied = dict(receipt) return (copied, channel) if include_channel else copied def default_release_candidate_root(workspace_root: Path) -> Path: return default_release_run_root(workspace_root).parent / "release-candidates" def release_catalog_base_paths( workspace_root: Path, *, channel: str ) -> tuple[Path, Path]: """Resolve durable generation trust material only from the website checkout.""" catalog_root = website_root(workspace_root) / "public" / "catalogs" / "v1" return ( catalog_root / "channels" / f"{validate_release_channel(channel)}.json", catalog_root / "keyring.json", ) def http_release_channel(value: str) -> str: try: return validate_release_channel(value) except CandidateArtifactError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc def default_release_run_root(workspace_root: Path) -> Path: configured_state_home = os.environ.get("XDG_STATE_HOME", "").strip() state_home = Path(configured_state_home).expanduser() if not configured_state_home or not state_home.is_absolute(): state_home = Path.home() / ".local" / "state" fingerprint = release_workspace_fingerprint(workspace_root) return ( state_home / "govoplan" / "release-console" / f"workspace-{fingerprint}" / "release-runs" ) def workspace_release_run_root(state_root: Path, workspace_fingerprint: str) -> Path: return ( state_root.expanduser() / f"workspace-{workspace_fingerprint}" / "release-runs" ) def release_workspace_fingerprint(workspace_root: Path) -> str: canonical = str(workspace_root.expanduser().resolve()).encode("utf-8") return hashlib.sha256(canonical).hexdigest() def parse_csv(value: str | None) -> tuple[str, ...]: if not value: return () return tuple(item.strip() for item in value.split(",") if item.strip()) def parse_repo_versions(value: str | None) -> dict[str, str]: if not value: return {} result: dict[str, str] = {} for item in value.split(","): if ":" not in item: continue repo, version = item.split(":", 1) repo = repo.strip() version = version.strip() if repo and version: result[repo] = version return result def default_signing_keys() -> tuple[str, ...]: key_path = Path.home() / ".config" / "govoplan" / "release-keys" / "release-key-1.pem" if not key_path.exists(): return () return (f"release-key-1={key_path}",) def list_catalog_candidates(root: Path | None = None) -> list[dict[str, object]]: root = root or (META_ROOT / "runtime" / "release-candidates") if not root.exists(): return [] candidates: list[dict[str, object]] = [] for path in sorted((item for item in root.iterdir() if item.is_dir()), reverse=True): summary = path / "summary.json" if summary.exists(): try: import json payload = json.loads(summary.read_text(encoding="utf-8")) if isinstance(payload, dict): payload.setdefault("candidate_dir", str(path)) candidates.append(payload) continue except json.JSONDecodeError: pass candidates.append({"candidate_dir": str(path), "status": "unknown"}) return candidates