Execute receipt-bound durable release runs

This commit is contained in:
2026-07-22 21:37:18 +02:00
parent 7ecf1f17b0
commit 5381e37a9e
7 changed files with 1961 additions and 141 deletions

View File

@@ -9,13 +9,12 @@ from typing import Literal
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from govoplan_release import (
build_dashboard,
build_release_intelligence,
build_release_plan,
build_selective_catalog_candidate,
build_selective_release_plan,
publish_catalog_candidate,
prepare_repositories,
@@ -24,13 +23,37 @@ from govoplan_release import (
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
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT, website_root
class CatalogCandidateRequest(BaseModel):
@@ -41,6 +64,7 @@ class CatalogCandidateRequest(BaseModel):
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"
@@ -48,6 +72,11 @@ class CatalogCandidateRequest(BaseModel):
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
@@ -66,17 +95,22 @@ class PublishCandidateRequest(BaseModel):
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: str = "origin"
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: str = "origin"
remote: Literal["origin"] = "origin"
apply: bool = False # noqa: A003 - API field mirrors CLI.
confirm: str = ""
@@ -108,6 +142,11 @@ class ReleaseRunCreateRequest(BaseModel):
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)
@@ -119,11 +158,23 @@ class ReleaseRunReconcileRequest(BaseModel):
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
@@ -140,13 +191,31 @@ def create_app(
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 token and request.url.path.startswith("/api/"):
provided = request.headers.get("x-release-console-token")
if provided != token:
return JSONResponse({"detail": "release console token required"}, status_code=401)
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)
@@ -174,6 +243,7 @@ def create_app(
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,
@@ -195,6 +265,7 @@ def create_app(
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,
@@ -212,6 +283,7 @@ def create_app(
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,
@@ -230,6 +302,7 @@ def create_app(
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,
@@ -280,7 +353,13 @@ def create_app(
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if existing is not None:
return existing
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,
@@ -319,18 +398,29 @@ def create_app(
),
)
try:
return app.state.release_runs.create(
request_id=request.request_id,
input_snapshot=input_snapshot,
plan_snapshot=plan_payload,
plan_payload = bind_plan_source_states(
plan=plan_payload,
repo_versions=dict(request.repo_versions),
workspace_root=app.state.workspace_root,
)
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
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 app.state.release_runs.get(run_id)
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:
@@ -341,7 +431,11 @@ def create_app(
run_id: str, request: ReleaseRunCommandRequest
) -> dict[str, object]:
try:
return app.state.release_runs.resume(run_id, request_id=request.request_id)
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:
@@ -352,10 +446,12 @@ def create_app(
run_id: str, step_id: str, request: ReleaseRunCommandRequest
) -> dict[str, object]:
try:
return app.state.release_runs.retry_step(
run_id,
step_id,
request_id=request.request_id,
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
@@ -367,53 +463,442 @@ def create_app(
run_id: str, step_id: str, request: ReleaseRunReconcileRequest
) -> dict[str, object]:
try:
return app.state.release_runs.reconcile_step(
run_id,
step_id,
request_id=request.request_id,
outcome=request.outcome,
confirmation=request.confirm,
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 (ReleaseRunConflict, ReleaseRunCorrupt) as 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()}
return {
"candidates": list_catalog_candidates(app.state.release_candidate_root)
}
@app.post("/api/catalog-candidates")
def catalog_candidate(request: CatalogCandidateRequest) -> dict[str, object]:
repo_versions = dict(request.repo_versions)
if not repo_versions and request.repos and request.target_version:
repo_versions = {repo: request.target_version for repo in request.repos}
if not repo_versions:
raise HTTPException(status_code=400, detail="repo_versions or repos with target_version is required")
signing_keys = tuple(request.signing_keys or default_signing_keys())
if not signing_keys:
raise HTTPException(status_code=400, detail="No signing key supplied and default release key was not found")
try:
candidate = build_selective_catalog_candidate(
repo_versions=repo_versions,
channel=request.channel,
workspace_root=app.state.workspace_root,
output_dir=request.output_dir,
base_catalog=request.base_catalog,
signing_keys=signing_keys,
public_base_url=request.public_base_url,
repository_base=request.repository_base,
source_remote=request.source_remote,
expires_days=request.expires_days,
sequence=request.sequence,
check_public=request.check_public,
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return to_jsonable(candidate)
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":
@@ -442,8 +927,14 @@ def create_app(
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 and request.confirm != "PUSH":
raise HTTPException(status_code=400, detail="Repository push requires confirm=PUSH")
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)
@@ -452,8 +943,14 @@ def create_app(
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 and request.confirm != "SYNC":
raise HTTPException(status_code=400, detail="Repository sync requires confirm=SYNC")
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)
@@ -462,6 +959,15 @@ def create_app(
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(
@@ -478,6 +984,14 @@ def create_app(
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":
@@ -496,6 +1010,164 @@ def create_app(
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()
@@ -552,8 +1224,8 @@ def default_signing_keys() -> tuple[str, ...]:
return (f"release-key-1={key_path}",)
def list_catalog_candidates() -> list[dict[str, object]]:
root = META_ROOT / "runtime" / "release-candidates"
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]] = []