Execute receipt-bound durable release runs
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
"""Durable state for guided, explicitly executed release runs.
|
||||
"""Durable state and bounded receipts for explicitly executed release runs.
|
||||
|
||||
The run store records an immutable selective-plan snapshot and a small mutable
|
||||
state machine. It does not execute release commands. Existing release
|
||||
executors remain responsible for their narrow confirmations and will be wired
|
||||
to this state in a later slice.
|
||||
The store freezes a selective plan, claims each supported attempt before its
|
||||
executor is called, and records only bounded outcomes. Narrow executors remain
|
||||
separate from this persistence module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,6 +10,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import binascii
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
@@ -24,9 +24,12 @@ from typing import Any
|
||||
|
||||
from filelock import FileLock, Timeout as FileLockTimeout
|
||||
|
||||
from .candidate_artifact import CandidateArtifactError, validate_release_channel
|
||||
|
||||
|
||||
SCHEMA = "govoplan.release-run"
|
||||
SCHEMA_VERSION = 3
|
||||
SCHEMA_VERSION = 4
|
||||
LEGACY_SCHEMA_VERSIONS = {3}
|
||||
MAX_RECORD_BYTES = 2 * 1024 * 1024
|
||||
MAX_PLAN_STEPS = 512
|
||||
MAX_REPOSITORY_VERSIONS = 128
|
||||
@@ -40,7 +43,6 @@ RUN_ID_PATTERN = re.compile(
|
||||
)
|
||||
STEP_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$")
|
||||
REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{7,127}$")
|
||||
CHANNEL_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
WORKSPACE_FINGERPRINT_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||
VERSION_PATTERN = re.compile(
|
||||
@@ -65,6 +67,13 @@ RECONCILIATION_CONFIRMATION = "RECONCILE"
|
||||
START_RECOVERY_RESERVE = 2
|
||||
PROJECTION_TIMESTAMP = "9999-12-31T23:59:59Z"
|
||||
PROJECTION_RESULT_CODE = "r" * 64
|
||||
RECEIPT_KINDS = {
|
||||
"catalog_candidate",
|
||||
"catalog_publication",
|
||||
"repository_state",
|
||||
}
|
||||
RECEIPT_ID_PATTERN = re.compile(r"^candidate-[0-9a-f]{32}$")
|
||||
GIT_OBJECT_PATTERN = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
|
||||
|
||||
|
||||
class ReleaseRunError(RuntimeError):
|
||||
@@ -87,6 +96,17 @@ class ReleaseRunCorrupt(ReleaseRunError):
|
||||
"""Raised when a persisted record fails its integrity or schema checks."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReleaseStepClaim:
|
||||
"""Private executor claim result; attempt identifiers remain fingerprinted."""
|
||||
|
||||
run: dict[str, Any]
|
||||
claimed: bool
|
||||
outcome: str
|
||||
result_code: str | None
|
||||
result_receipt: dict[str, Any] | None
|
||||
|
||||
|
||||
class ReleaseRunStore:
|
||||
"""Atomic, bounded local JSON store for release-run state."""
|
||||
|
||||
@@ -135,6 +155,7 @@ class ReleaseRunStore:
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"result_code": None,
|
||||
"result_receipt": None,
|
||||
}
|
||||
for item in immutable_plan["dry_run_steps"]
|
||||
]
|
||||
@@ -251,7 +272,7 @@ class ReleaseRunStore:
|
||||
(
|
||||
(
|
||||
1,
|
||||
view["updated_at"],
|
||||
view["created_at"],
|
||||
view["created_at"],
|
||||
view["run_id"],
|
||||
),
|
||||
@@ -364,6 +385,7 @@ class ReleaseRunStore:
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"result_code": None,
|
||||
"result_receipt": None,
|
||||
}
|
||||
)
|
||||
_remember_request(
|
||||
@@ -391,6 +413,7 @@ class ReleaseRunStore:
|
||||
request_id: str,
|
||||
outcome: str,
|
||||
confirmation: str,
|
||||
result_receipt: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Record an operator-observed outcome for an interrupted mutation."""
|
||||
|
||||
@@ -401,12 +424,22 @@ class ReleaseRunStore:
|
||||
raise ReleaseRunConflict(
|
||||
f"Type {RECONCILIATION_CONFIRMATION} to record reconciliation."
|
||||
)
|
||||
receipt = _validated_result_receipt(result_receipt)
|
||||
if receipt is not None and outcome != "effect_succeeded":
|
||||
raise ReleaseRunConflict(
|
||||
"Only a reconciled successful effect can carry a result receipt."
|
||||
)
|
||||
request_fingerprint = _request_fingerprint(request_id)
|
||||
with self._locked():
|
||||
record = self._read(run_id)
|
||||
if _request_seen(
|
||||
record, request_fingerprint, "reconcile", step_id, outcome
|
||||
):
|
||||
step, _plan_step = _step_pair(record, step_id)
|
||||
if step.get("result_receipt") != receipt:
|
||||
raise ReleaseRunConflict(
|
||||
"The reconciliation request already has a different result receipt."
|
||||
)
|
||||
return _view(record)
|
||||
step, plan_step = _step_pair(record, step_id)
|
||||
if step["state"] != "interrupted" or not bool(
|
||||
@@ -438,6 +471,7 @@ class ReleaseRunStore:
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"result_code": None,
|
||||
"result_receipt": None,
|
||||
}
|
||||
)
|
||||
elif outcome == "effect_succeeded":
|
||||
@@ -446,6 +480,7 @@ class ReleaseRunStore:
|
||||
"state": "succeeded",
|
||||
"finished_at": _timestamp(),
|
||||
"result_code": "reconciled_effect_succeeded",
|
||||
"result_receipt": receipt,
|
||||
}
|
||||
)
|
||||
_remember_request(
|
||||
@@ -472,10 +507,20 @@ class ReleaseRunStore:
|
||||
*,
|
||||
attempt_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Claim one step for a future confirmed executor.
|
||||
"""Claim one step while preserving the original store API."""
|
||||
|
||||
This is deliberately not an HTTP endpoint in the foundation slice.
|
||||
"""
|
||||
return self.claim_step(
|
||||
run_id, step_id, attempt_id=attempt_id
|
||||
).run
|
||||
|
||||
def claim_step(
|
||||
self,
|
||||
run_id: str,
|
||||
step_id: str,
|
||||
*,
|
||||
attempt_id: str,
|
||||
) -> ReleaseStepClaim:
|
||||
"""Durably claim a step and distinguish a delayed attempt replay."""
|
||||
|
||||
_validate_step_id(step_id)
|
||||
attempt_fingerprint = _request_fingerprint(attempt_id)
|
||||
@@ -487,7 +532,14 @@ class ReleaseRunStore:
|
||||
raise ReleaseRunConflict(
|
||||
"The attempt identifier was already used for another release step."
|
||||
)
|
||||
return _view(record)
|
||||
step, _plan_step = _step_pair(record, step_id)
|
||||
return ReleaseStepClaim(
|
||||
run=_view(record),
|
||||
claimed=False,
|
||||
outcome=existing_attempt["outcome"],
|
||||
result_code=existing_attempt["result_code"],
|
||||
result_receipt=deepcopy(step.get("result_receipt")),
|
||||
)
|
||||
step, plan_step = _step_pair(record, step_id)
|
||||
available, reason = _step_available(record, step_id)
|
||||
if not available:
|
||||
@@ -502,6 +554,7 @@ class ReleaseRunStore:
|
||||
"started_at": _timestamp(),
|
||||
"finished_at": None,
|
||||
"result_code": None,
|
||||
"result_receipt": None,
|
||||
}
|
||||
)
|
||||
record["state"]["processed_attempts"].append(
|
||||
@@ -519,7 +572,13 @@ class ReleaseRunStore:
|
||||
mutating=bool(plan_step.get("mutating")),
|
||||
)
|
||||
self._persist_update(record)
|
||||
return _view(record)
|
||||
return ReleaseStepClaim(
|
||||
run=_view(record),
|
||||
claimed=True,
|
||||
outcome="running",
|
||||
result_code=None,
|
||||
result_receipt=None,
|
||||
)
|
||||
|
||||
def finish_step(
|
||||
self,
|
||||
@@ -529,12 +588,16 @@ class ReleaseRunStore:
|
||||
attempt_id: str,
|
||||
succeeded: bool,
|
||||
result_code: str,
|
||||
result_receipt: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Record a future executor outcome without persisting its output."""
|
||||
"""Record a bounded executor outcome without persisting process output."""
|
||||
|
||||
_validate_step_id(step_id)
|
||||
attempt_fingerprint = _request_fingerprint(attempt_id)
|
||||
result_code = _safe_code(result_code)
|
||||
receipt = _validated_result_receipt(result_receipt)
|
||||
if receipt is not None and not succeeded:
|
||||
raise ReleaseRunConflict("A failed release step cannot carry a result receipt.")
|
||||
target_state = "succeeded" if succeeded else "failed"
|
||||
with self._locked():
|
||||
record = self._read(run_id)
|
||||
@@ -548,6 +611,7 @@ class ReleaseRunStore:
|
||||
if (
|
||||
attempt["outcome"] == target_state
|
||||
and attempt["result_code"] == result_code
|
||||
and step.get("result_receipt") == receipt
|
||||
):
|
||||
return _view(record)
|
||||
raise ReleaseRunConflict(
|
||||
@@ -565,6 +629,7 @@ class ReleaseRunStore:
|
||||
"state": target_state,
|
||||
"finished_at": _timestamp(),
|
||||
"result_code": result_code,
|
||||
"result_receipt": receipt,
|
||||
}
|
||||
)
|
||||
attempt["outcome"] = target_state
|
||||
@@ -578,6 +643,76 @@ class ReleaseRunStore:
|
||||
self._persist_update(record)
|
||||
return _view(record)
|
||||
|
||||
def interrupt_step(
|
||||
self,
|
||||
run_id: str,
|
||||
step_id: str,
|
||||
*,
|
||||
attempt_id: str,
|
||||
result_code: str = "executor_outcome_unknown",
|
||||
) -> dict[str, Any]:
|
||||
"""Freeze an executor exception as an ambiguous, reconcilable attempt."""
|
||||
|
||||
_validate_step_id(step_id)
|
||||
attempt_fingerprint = _request_fingerprint(attempt_id)
|
||||
result_code = _safe_code(result_code)
|
||||
if result_code != "executor_outcome_unknown":
|
||||
raise ReleaseRunConflict("Executor interruption result code is invalid.")
|
||||
with self._locked():
|
||||
record = self._read(run_id)
|
||||
step, _plan_step = _step_pair(record, step_id)
|
||||
attempt = _find_attempt(record, attempt_fingerprint)
|
||||
if attempt is None or attempt["step_id"] != step_id:
|
||||
raise ReleaseRunConflict(
|
||||
"The release attempt identifier was not claimed for this step."
|
||||
)
|
||||
if attempt["outcome"] == "interrupted":
|
||||
if attempt["result_code"] == result_code:
|
||||
return _view(record)
|
||||
raise ReleaseRunConflict(
|
||||
"The release attempt already has a different interruption outcome."
|
||||
)
|
||||
if (
|
||||
attempt["outcome"] != "running"
|
||||
or step["state"] != "running"
|
||||
or step["attempt_fingerprint"] != attempt_fingerprint
|
||||
):
|
||||
raise ReleaseRunConflict(
|
||||
"The release step is not running with this attempt identifier."
|
||||
)
|
||||
step.update(
|
||||
{
|
||||
"state": "interrupted",
|
||||
"finished_at": _timestamp(),
|
||||
"result_code": result_code,
|
||||
"result_receipt": None,
|
||||
}
|
||||
)
|
||||
attempt["outcome"] = "interrupted"
|
||||
attempt["result_code"] = result_code
|
||||
_append_event(
|
||||
record,
|
||||
event_type="step_interrupted",
|
||||
step_id=step_id,
|
||||
result_code=result_code,
|
||||
)
|
||||
self._persist_update(record)
|
||||
return _view(record)
|
||||
|
||||
def current_attempt_fingerprint(self, run_id: str, step_id: str) -> str:
|
||||
"""Return the opaque current fingerprint for server-side artifact recovery."""
|
||||
|
||||
_validate_step_id(step_id)
|
||||
with self._locked():
|
||||
record = self._read(run_id)
|
||||
step, _plan_step = _step_pair(record, step_id)
|
||||
fingerprint = step.get("attempt_fingerprint")
|
||||
if not isinstance(fingerprint, str):
|
||||
raise ReleaseRunConflict(
|
||||
"Release step has no current attempt to reconcile."
|
||||
)
|
||||
return fingerprint
|
||||
|
||||
def _persist_update(self, record: dict[str, Any]) -> None:
|
||||
self._assert_workspace(record)
|
||||
record["updated_at"] = _timestamp()
|
||||
@@ -638,6 +773,9 @@ class ReleaseRunStore:
|
||||
) from exc
|
||||
_validate_record(payload, expected_run_id=run_id)
|
||||
self._assert_workspace(payload)
|
||||
if payload["schema_version"] in LEGACY_SCHEMA_VERSIONS:
|
||||
payload = _upgrade_record(payload)
|
||||
self._atomic_write(path, payload)
|
||||
return payload
|
||||
|
||||
def _read_optional(self, run_id: str) -> dict[str, Any] | None:
|
||||
@@ -888,7 +1026,9 @@ def _validated_input(value: dict[str, Any]) -> dict[str, Any]:
|
||||
raise ReleaseRunConflict("Release run input fields are invalid.")
|
||||
channel = value.get("channel")
|
||||
repo_versions = value.get("repo_versions")
|
||||
if not isinstance(channel, str) or not CHANNEL_PATTERN.fullmatch(channel):
|
||||
try:
|
||||
channel = validate_release_channel(channel)
|
||||
except CandidateArtifactError:
|
||||
raise ReleaseRunConflict("Release channel is invalid.")
|
||||
if (
|
||||
not isinstance(repo_versions, dict)
|
||||
@@ -924,7 +1064,7 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise ReleaseRunConflict("Release plan snapshot must be an object.")
|
||||
copied = deepcopy(value)
|
||||
if set(copied) != {
|
||||
base_fields = {
|
||||
"generated_at",
|
||||
"target_channel",
|
||||
"status",
|
||||
@@ -935,15 +1075,32 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]:
|
||||
"gate_findings",
|
||||
"recommended_action",
|
||||
"source_preflight_ready",
|
||||
}:
|
||||
}
|
||||
if set(copied) not in (base_fields, base_fields | {"runtime_binding"}):
|
||||
raise ReleaseRunConflict("Release plan snapshot fields are invalid.")
|
||||
if "runtime_binding" in copied:
|
||||
runtime_binding = copied["runtime_binding"]
|
||||
validated_runtime = _validated_result_receipt(runtime_binding)
|
||||
if (
|
||||
validated_runtime != runtime_binding
|
||||
or runtime_binding.get("kind") != "repository_state"
|
||||
or runtime_binding.get("repo") != "govoplan"
|
||||
or runtime_binding.get("target_tag") != ""
|
||||
or runtime_binding.get("tag_object") is not None
|
||||
or runtime_binding.get("worktree_clean") is not True
|
||||
):
|
||||
raise ReleaseRunConflict(
|
||||
"Release plan runtime binding is invalid."
|
||||
)
|
||||
if not _is_timestamp(copied.get("generated_at")):
|
||||
raise ReleaseRunConflict("Release plan timestamp is invalid.")
|
||||
if copied.get("status") not in PLAN_STATUSES:
|
||||
raise ReleaseRunConflict("Release plan status is invalid.")
|
||||
if not isinstance(
|
||||
copied.get("target_channel"), str
|
||||
) or not CHANNEL_PATTERN.fullmatch(copied["target_channel"]):
|
||||
try:
|
||||
target_channel = validate_release_channel(copied.get("target_channel"))
|
||||
except CandidateArtifactError:
|
||||
raise ReleaseRunConflict("Release plan channel is invalid.")
|
||||
if target_channel != copied["target_channel"]:
|
||||
raise ReleaseRunConflict("Release plan channel is invalid.")
|
||||
if not isinstance(copied.get("units"), list):
|
||||
raise ReleaseRunConflict("Release plan units are invalid.")
|
||||
@@ -969,7 +1126,7 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]:
|
||||
raise ReleaseRunConflict("Release plan step collection is invalid.")
|
||||
seen: set[str] = set()
|
||||
for step in steps:
|
||||
if not isinstance(step, dict) or set(step) != {
|
||||
if not isinstance(step, dict) or set(step) not in ({
|
||||
"id",
|
||||
"title",
|
||||
"detail",
|
||||
@@ -978,7 +1135,17 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]:
|
||||
"mutating",
|
||||
"repo",
|
||||
"status",
|
||||
}:
|
||||
}, {
|
||||
"id",
|
||||
"title",
|
||||
"detail",
|
||||
"command",
|
||||
"cwd",
|
||||
"mutating",
|
||||
"repo",
|
||||
"status",
|
||||
"source_binding",
|
||||
}):
|
||||
raise ReleaseRunConflict("Release plan step is invalid.")
|
||||
step_id = step.get("id")
|
||||
_validate_step_id(step_id)
|
||||
@@ -997,6 +1164,21 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]:
|
||||
not isinstance(step[field], str) or len(step[field]) > 8_192
|
||||
):
|
||||
raise ReleaseRunConflict("Release plan step metadata is invalid.")
|
||||
if "source_binding" in step:
|
||||
binding = step["source_binding"]
|
||||
if binding is not None:
|
||||
validated_binding = _validated_result_receipt(binding)
|
||||
if (
|
||||
validated_binding != binding
|
||||
or binding.get("kind") != "repository_state"
|
||||
or (
|
||||
step.get("repo") is not None
|
||||
and binding.get("repo") != step.get("repo")
|
||||
)
|
||||
):
|
||||
raise ReleaseRunConflict(
|
||||
"Release plan source binding is invalid."
|
||||
)
|
||||
encoded = _canonical_json(copied)
|
||||
if len(encoded) > MAX_RECORD_BYTES:
|
||||
raise ReleaseRunConflict("Release plan snapshot exceeds its size limit.")
|
||||
@@ -1022,9 +1204,11 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None:
|
||||
if (
|
||||
value.get("schema") != SCHEMA
|
||||
or type(value.get("schema_version")) is not int
|
||||
or value["schema_version"] != SCHEMA_VERSION
|
||||
or value["schema_version"]
|
||||
not in {SCHEMA_VERSION, *LEGACY_SCHEMA_VERSIONS}
|
||||
):
|
||||
raise ValueError
|
||||
schema_version = value["schema_version"]
|
||||
run_id = value.get("run_id")
|
||||
if not isinstance(run_id, str) or not RUN_ID_PATTERN.fullmatch(run_id):
|
||||
raise ValueError
|
||||
@@ -1084,18 +1268,20 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None:
|
||||
if not isinstance(steps, list) or len(steps) != len(plan_steps):
|
||||
raise ValueError
|
||||
for step, plan_step in zip(steps, plan_steps, strict=True):
|
||||
expected_step_fields = {
|
||||
"id",
|
||||
"state",
|
||||
"attempt_count",
|
||||
"attempt_fingerprint",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"result_code",
|
||||
}
|
||||
if schema_version >= 4:
|
||||
expected_step_fields.add("result_receipt")
|
||||
if (
|
||||
not isinstance(step, dict)
|
||||
or set(step)
|
||||
!= {
|
||||
"id",
|
||||
"state",
|
||||
"attempt_count",
|
||||
"attempt_fingerprint",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"result_code",
|
||||
}
|
||||
or set(step) != expected_step_fields
|
||||
or step.get("id") != plan_step["id"]
|
||||
):
|
||||
raise ValueError
|
||||
@@ -1114,6 +1300,9 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None:
|
||||
result_code = step.get("result_code")
|
||||
if result_code is not None:
|
||||
_safe_code(result_code)
|
||||
receipt = _validated_result_receipt(step.get("result_receipt"))
|
||||
if receipt != step.get("result_receipt"):
|
||||
raise ValueError
|
||||
_validate_step_state_fields(step)
|
||||
for timestamp in (step.get("started_at"), step.get("finished_at")):
|
||||
if timestamp is not None and not (
|
||||
@@ -1221,7 +1410,8 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None:
|
||||
_safe_code(attempt_result)
|
||||
if (
|
||||
attempt["outcome"] == "interrupted"
|
||||
and attempt_result != "process_interrupted"
|
||||
and attempt_result
|
||||
not in {"process_interrupted", "executor_outcome_unknown"}
|
||||
):
|
||||
raise ValueError
|
||||
if len({attempt["fingerprint"] for attempt in attempts}) != len(attempts):
|
||||
@@ -1550,6 +1740,7 @@ def _project_finish(
|
||||
"state": target_state,
|
||||
"finished_at": PROJECTION_TIMESTAMP,
|
||||
"result_code": PROJECTION_RESULT_CODE,
|
||||
"result_receipt": _projection_receipt() if succeeded else None,
|
||||
}
|
||||
)
|
||||
attempt["outcome"] = target_state
|
||||
@@ -1565,6 +1756,22 @@ def _project_finish(
|
||||
return projected
|
||||
|
||||
|
||||
def _projection_receipt() -> dict[str, Any]:
|
||||
return {
|
||||
"kind": "catalog_publication",
|
||||
"candidate_id": "candidate-" + "c" * 32,
|
||||
"catalog_sha256": "c" * 64,
|
||||
"keyring_sha256": "c" * 64,
|
||||
"publication_commit_sha": "c" * 64,
|
||||
"publication_tag_object_sha": "c" * 64,
|
||||
"publication_tag_commit_sha": "c" * 64,
|
||||
"branch": "b" * 255,
|
||||
"tag_name": "t" * 160,
|
||||
"remote": "origin",
|
||||
"remote_sha256": "c" * 64,
|
||||
}
|
||||
|
||||
|
||||
def _project_resume(record: dict[str, Any]) -> dict[str, Any]:
|
||||
projected = deepcopy(record)
|
||||
running_steps = [
|
||||
@@ -1615,6 +1822,7 @@ def _project_retry(record: dict[str, Any], step_id: str) -> dict[str, Any]:
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"result_code": None,
|
||||
"result_receipt": None,
|
||||
}
|
||||
)
|
||||
_remember_request(
|
||||
@@ -1650,6 +1858,7 @@ def _project_reconcile(
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"result_code": None,
|
||||
"result_receipt": None,
|
||||
}
|
||||
)
|
||||
elif outcome == "effect_succeeded":
|
||||
@@ -1658,6 +1867,7 @@ def _project_reconcile(
|
||||
"state": "succeeded",
|
||||
"finished_at": PROJECTION_TIMESTAMP,
|
||||
"result_code": "reconciled_effect_succeeded",
|
||||
"result_receipt": _projection_receipt(),
|
||||
}
|
||||
)
|
||||
_remember_request(
|
||||
@@ -1944,6 +2154,118 @@ def _safe_code(value: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _validated_result_receipt(
|
||||
value: dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, dict) or value.get("kind") not in RECEIPT_KINDS:
|
||||
raise ReleaseRunConflict("Release result receipt is invalid.")
|
||||
if value["kind"] == "catalog_candidate":
|
||||
if set(value) != {"kind", "candidate_id", "catalog_sha256"}:
|
||||
raise ReleaseRunConflict("Release result receipt is invalid.")
|
||||
candidate_id = value.get("candidate_id")
|
||||
digest = value.get("catalog_sha256")
|
||||
if (
|
||||
not isinstance(candidate_id, str)
|
||||
or not RECEIPT_ID_PATTERN.fullmatch(candidate_id)
|
||||
or not _is_sha256(digest)
|
||||
):
|
||||
raise ReleaseRunConflict("Release result receipt identity is invalid.")
|
||||
return dict(value)
|
||||
if value["kind"] == "catalog_publication":
|
||||
if set(value) != {
|
||||
"kind",
|
||||
"candidate_id",
|
||||
"catalog_sha256",
|
||||
"keyring_sha256",
|
||||
"publication_commit_sha",
|
||||
"publication_tag_object_sha",
|
||||
"publication_tag_commit_sha",
|
||||
"branch",
|
||||
"tag_name",
|
||||
"remote",
|
||||
"remote_sha256",
|
||||
}:
|
||||
raise ReleaseRunConflict("Catalog publication receipt is invalid.")
|
||||
candidate_id = value.get("candidate_id")
|
||||
commit = value.get("publication_commit_sha")
|
||||
tagged_commit = value.get("publication_tag_commit_sha")
|
||||
if (
|
||||
not isinstance(candidate_id, str)
|
||||
or not RECEIPT_ID_PATTERN.fullmatch(candidate_id)
|
||||
or not _is_sha256(value.get("catalog_sha256"))
|
||||
or not _is_sha256(value.get("keyring_sha256"))
|
||||
or not isinstance(commit, str)
|
||||
or not GIT_OBJECT_PATTERN.fullmatch(commit)
|
||||
or not isinstance(value.get("publication_tag_object_sha"), str)
|
||||
or not GIT_OBJECT_PATTERN.fullmatch(
|
||||
value["publication_tag_object_sha"]
|
||||
)
|
||||
or not isinstance(tagged_commit, str)
|
||||
or not GIT_OBJECT_PATTERN.fullmatch(tagged_commit)
|
||||
or tagged_commit != commit
|
||||
or not isinstance(value.get("branch"), str)
|
||||
or not value["branch"]
|
||||
or len(value["branch"]) > 255
|
||||
or not isinstance(value.get("tag_name"), str)
|
||||
or not value["tag_name"]
|
||||
or len(value["tag_name"]) > 160
|
||||
or value.get("remote") != "origin"
|
||||
or not _is_sha256(value.get("remote_sha256"))
|
||||
):
|
||||
raise ReleaseRunConflict(
|
||||
"Catalog publication receipt identity is invalid."
|
||||
)
|
||||
return dict(value)
|
||||
if set(value) != {
|
||||
"kind",
|
||||
"repo",
|
||||
"head",
|
||||
"branch",
|
||||
"remote",
|
||||
"remote_sha256",
|
||||
"worktree_clean",
|
||||
"target_tag",
|
||||
"tag_object",
|
||||
}:
|
||||
raise ReleaseRunConflict("Repository result receipt is invalid.")
|
||||
if (
|
||||
not isinstance(value.get("repo"), str)
|
||||
or not REPOSITORY_PATTERN.fullmatch(value["repo"])
|
||||
or not isinstance(value.get("head"), str)
|
||||
or not GIT_OBJECT_PATTERN.fullmatch(value["head"])
|
||||
or not isinstance(value.get("branch"), str)
|
||||
or not value["branch"]
|
||||
or len(value["branch"]) > 255
|
||||
or value.get("remote") != "origin"
|
||||
or not _is_sha256(value.get("remote_sha256"))
|
||||
or type(value.get("worktree_clean")) is not bool
|
||||
or not isinstance(value.get("target_tag"), str)
|
||||
or len(value["target_tag"]) > 160
|
||||
or (
|
||||
value.get("tag_object") is not None
|
||||
and (
|
||||
not isinstance(value["tag_object"], str)
|
||||
or not GIT_OBJECT_PATTERN.fullmatch(value["tag_object"])
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ReleaseRunConflict("Repository result receipt identity is invalid.")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _upgrade_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||
upgraded = deepcopy(record)
|
||||
if upgraded.get("schema_version") == 3:
|
||||
for step in upgraded["state"]["steps"]:
|
||||
step["result_receipt"] = None
|
||||
upgraded["schema_version"] = SCHEMA_VERSION
|
||||
upgraded["record_digest"] = _record_digest(upgraded)
|
||||
_validate_record(upgraded)
|
||||
return upgraded
|
||||
|
||||
|
||||
def _is_sha256(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(re.fullmatch(r"[0-9a-f]{64}", value))
|
||||
|
||||
@@ -1967,6 +2289,7 @@ def _validate_step_state_fields(step: dict[str, Any]) -> None:
|
||||
started_at = step["started_at"]
|
||||
finished_at = step["finished_at"]
|
||||
result_code = step["result_code"]
|
||||
result_receipt = step.get("result_receipt")
|
||||
if started_at is not None and not _is_timestamp(started_at):
|
||||
raise ValueError
|
||||
if finished_at is not None and not _is_timestamp(finished_at):
|
||||
@@ -1976,19 +2299,34 @@ def _validate_step_state_fields(step: dict[str, Any]) -> None:
|
||||
if state == "pending":
|
||||
if any(
|
||||
value is not None
|
||||
for value in (fingerprint, started_at, finished_at, result_code)
|
||||
for value in (
|
||||
fingerprint,
|
||||
started_at,
|
||||
finished_at,
|
||||
result_code,
|
||||
result_receipt,
|
||||
)
|
||||
):
|
||||
raise ValueError
|
||||
return
|
||||
if attempt_count < 1 or fingerprint is None or started_at is None:
|
||||
raise ValueError
|
||||
if state == "running":
|
||||
if finished_at is not None or result_code is not None:
|
||||
if (
|
||||
finished_at is not None
|
||||
or result_code is not None
|
||||
or result_receipt is not None
|
||||
):
|
||||
raise ValueError
|
||||
return
|
||||
if finished_at is None or result_code is None:
|
||||
raise ValueError
|
||||
if state == "interrupted" and result_code != "process_interrupted":
|
||||
if state != "succeeded" and result_receipt is not None:
|
||||
raise ValueError
|
||||
if state == "interrupted" and result_code not in {
|
||||
"process_interrupted",
|
||||
"executor_outcome_unknown",
|
||||
}:
|
||||
raise ValueError
|
||||
|
||||
|
||||
@@ -2014,7 +2352,8 @@ def _validate_event_fields(event: dict[str, Any]) -> None:
|
||||
raise ValueError
|
||||
if (
|
||||
event_type == "step_interrupted"
|
||||
and event["result_code"] != "process_interrupted"
|
||||
and event["result_code"]
|
||||
not in {"process_interrupted", "executor_outcome_unknown"}
|
||||
):
|
||||
raise ValueError
|
||||
|
||||
|
||||
@@ -501,6 +501,24 @@ def dry_run_steps(
|
||||
) -> tuple[ReleasePlanStep, ...]:
|
||||
steps: list[ReleasePlanStep] = []
|
||||
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
|
||||
selected_names = {unit.repo for unit in units}
|
||||
if "govoplan-core" in selected_names and len(selected_names) > 1:
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
id="release:cross-repository-ordering",
|
||||
title="Resolve module/Core release ordering",
|
||||
detail=(
|
||||
"A combined Core/module release needs a dependency-aware DAG: "
|
||||
"local module tags, Core release-lock regeneration and commit, "
|
||||
"Core tag, full alignment, then atomic pushes. The current linear "
|
||||
"executor must not publish a module before that lock is committed."
|
||||
),
|
||||
command=None,
|
||||
cwd=dashboard.meta_root,
|
||||
mutating=True,
|
||||
status="needs-executor",
|
||||
)
|
||||
)
|
||||
for unit in units:
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
@@ -618,6 +636,7 @@ def dry_run_steps(
|
||||
f'--candidate-dir "$CANDIDATE_DIR" --channel {shlex.quote(channel)}'
|
||||
),
|
||||
cwd=dashboard.meta_root,
|
||||
mutating=True,
|
||||
status="planned",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
@@ -608,7 +608,7 @@
|
||||
</div>
|
||||
<div class="units-toolbar">
|
||||
<button id="buildSelectedPlan">Build Plan</button>
|
||||
<button id="generateSelectedCandidate">Generate Candidate</button>
|
||||
<button id="generateSelectedCandidate" disabled data-release-disabled="true" title="Use the receipt-bound generator in Durable Run State.">Generate Candidate</button>
|
||||
<button class="secondary" id="selectUnpushedUnits" title="Select repositories with committed branch changes to push.">Select Unpushed</button>
|
||||
<button class="secondary" id="selectChangedUnits" title="Select repositories with dirty/untracked paths, missing HEAD, or unpushed commits.">Select Dirty/Changed</button>
|
||||
<button class="secondary" id="selectUnreleasedUnits">Select Unreleased</button>
|
||||
@@ -642,7 +642,7 @@
|
||||
<small id="runStatus">no run selected</small>
|
||||
</div>
|
||||
<div class="details">
|
||||
<p class="hint"><strong>State tracking only.</strong> A run freezes the selected inputs and plan, guides recovery, and records bounded state events. It does not execute or confirm tags, signing, publication, or catalog changes.</p>
|
||||
<p class="hint"><strong>Durable execution.</strong> A run freezes the selected inputs and plan, claims each supported executor before its effect, and keeps explicit confirmation and reconciliation boundaries. Unsupported dependency-ordering or version-metadata steps stay visible and disabled.</p>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="releaseRun">Saved run</label>
|
||||
@@ -678,7 +678,7 @@
|
||||
<small id="pushStatus"></small>
|
||||
</div>
|
||||
<div class="details">
|
||||
<p class="hint">Sync selected fetches remote refs and tags only. It does not merge, rebase, commit, tag, or push.</p>
|
||||
<p class="hint">Repository sync and push are preview-only until they have a receipt-bound durable maintenance executor.</p>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="pushRemote">Remote</label>
|
||||
@@ -691,9 +691,9 @@
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="secondary" id="previewRepoSync">Preview Sync</button>
|
||||
<button id="syncRepos">Sync Selected</button>
|
||||
<button id="syncRepos" disabled data-release-disabled="true" title="Durable maintenance executor required">Sync Selected</button>
|
||||
<button class="secondary" id="previewRepoPush">Preview Push</button>
|
||||
<button class="danger" id="pushRepos">Push Selected</button>
|
||||
<button class="danger" id="pushRepos" disabled data-release-disabled="true" title="Durable maintenance executor required">Push Selected</button>
|
||||
</div>
|
||||
<div class="actions" id="pushOutput"></div>
|
||||
</div>
|
||||
@@ -705,7 +705,7 @@
|
||||
<small id="prepareStatus"></small>
|
||||
</div>
|
||||
<div class="details">
|
||||
<p class="hint">Prepare commits selected dirty worktrees only. It does not tag or push.</p>
|
||||
<p class="hint">Preview preparation only. Commit mutation is disabled outside a content-bound durable executor; commit and review dirty or version-changing work outside this console, then build a new run.</p>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="prepareMessage">Commit message</label>
|
||||
@@ -713,12 +713,12 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="prepareConfirmText">Confirm</label>
|
||||
<input id="prepareConfirmText" type="text" placeholder="COMMIT" />
|
||||
<input id="prepareConfirmText" type="text" placeholder="durable commit executor pending" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="secondary" id="previewPrepare">Preview Prepare</button>
|
||||
<button class="danger" id="commitPrepare">Commit Selected</button>
|
||||
<button class="danger" id="commitPrepare" data-release-disabled="true" disabled title="No content-bound durable commit executor is available yet.">Commit Selected</button>
|
||||
</div>
|
||||
<div class="actions" id="prepareOutput"></div>
|
||||
</div>
|
||||
@@ -730,7 +730,7 @@
|
||||
<small><span id="tagStatus">idle</span> · plan <span id="planStatus">idle</span></small>
|
||||
</div>
|
||||
<div class="details">
|
||||
<p class="hint">Creates an annotated source tag at the selected clean HEAD. Publish pushes the branch and tag atomically; existing release tags are never moved.</p>
|
||||
<p class="hint">Preview is available here. Tag creation and publication use the matching durable run steps, bound to the frozen HEAD, branch, clean worktree, and registered origin.</p>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="tagMessage">Tag message</label>
|
||||
@@ -738,13 +738,13 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="tagConfirmText">Confirm</label>
|
||||
<input id="tagConfirmText" type="text" placeholder="TAG or PUBLISH" />
|
||||
<input id="tagConfirmText" type="text" placeholder="use durable run controls" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="secondary" id="previewReleaseTags">Preview Tag + Publish</button>
|
||||
<button id="createReleaseTags">Create Tags</button>
|
||||
<button class="danger" id="publishReleaseTags">Publish Tags</button>
|
||||
<button id="createReleaseTags" data-release-disabled="true" disabled title="Use the receipt-bound tag step in Durable Run State.">Create Tags</button>
|
||||
<button class="danger" id="publishReleaseTags" data-release-disabled="true" disabled title="Use the receipt-bound push step in Durable Run State.">Publish Tags</button>
|
||||
</div>
|
||||
<div class="actions" id="tagOutput"></div>
|
||||
</div>
|
||||
@@ -764,18 +764,18 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="candidateDir">Candidate dir</label>
|
||||
<input id="candidateDir" type="text" placeholder="runtime/release-candidates/..." />
|
||||
<input id="candidateDir" type="text" placeholder="private state/release-candidates/candidate-..." />
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirmText">Confirm</label>
|
||||
<input id="confirmText" type="text" placeholder="APPLY or PUSH" />
|
||||
<input id="confirmText" type="text" placeholder="use durable run controls" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="generateCandidate">Generate</button>
|
||||
<button id="generateCandidate" disabled data-release-disabled="true" title="Use the receipt-bound generator in Durable Run State.">Generate</button>
|
||||
<button class="secondary" id="previewPublish">Preview</button>
|
||||
<button class="secondary" id="applyPublish">Apply + Website Tag</button>
|
||||
<button class="danger" id="pushPublish">Push Website Release</button>
|
||||
<button class="secondary" id="applyPublish" data-release-disabled="true" disabled title="Use the receipt-bound publication step in Durable Run State.">Apply + Website Tag</button>
|
||||
<button class="danger" id="pushPublish" data-release-disabled="true" disabled title="Use the receipt-bound publication step in Durable Run State.">Push Website Release</button>
|
||||
</div>
|
||||
<div class="actions" id="workflowOutput"></div>
|
||||
</div>
|
||||
@@ -1047,23 +1047,23 @@
|
||||
renderCatalog(data.catalog, data.target_version);
|
||||
}
|
||||
|
||||
function renderReleaseRunList(runs, options = {}) {
|
||||
function renderReleaseRunList(runs, renderOptions = {}) {
|
||||
releaseState.runStoreAvailable = true;
|
||||
const incoming = Array.isArray(runs) ? runs : [];
|
||||
releaseState.runs = options.append
|
||||
releaseState.runs = renderOptions.append
|
||||
? [...new Map([...releaseState.runs, ...incoming].map((run) => [run.run_id, run])).values()]
|
||||
: incoming;
|
||||
releaseState.runNextCursor = options.nextCursor || null;
|
||||
releaseState.runNextCursor = renderOptions.nextCursor || null;
|
||||
elements.releaseRun.disabled = false;
|
||||
elements.createReleaseRun.disabled = false;
|
||||
elements.loadOlderReleaseRuns.disabled = !releaseState.runNextCursor;
|
||||
const selectedId = releaseState.currentRun?.run_id || "";
|
||||
const options = releaseState.runs.map((run) => {
|
||||
const runOptions = releaseState.runs.map((run) => {
|
||||
const repositories = run.repo_versions ? Object.keys(run.repo_versions).length : 0;
|
||||
const label = `${run.run_id} · ${run.status || "unknown"} · ${repositories} repos`;
|
||||
return `<option value="${escapeAttr(run.run_id)}" ${run.run_id === selectedId ? "selected" : ""}>${escapeHtml(label)}</option>`;
|
||||
}).join("");
|
||||
elements.releaseRun.innerHTML = `<option value="">No saved run selected</option>${options}`;
|
||||
elements.releaseRun.innerHTML = `<option value="">No saved run selected</option>${runOptions}`;
|
||||
if (selectedId && releaseState.runs.some((run) => run.run_id === selectedId)) {
|
||||
elements.releaseRun.value = selectedId;
|
||||
}
|
||||
@@ -1252,8 +1252,15 @@
|
||||
const stepHtml = steps.map((step) => {
|
||||
const kind = step.state === "succeeded" ? "ok" : ["failed", "interrupted"].includes(step.state) ? "block" : step.available ? "ok" : "warn";
|
||||
const retryReason = step.retry_available ? "Prepare this known failed or read-only interrupted step for an explicit retry." : (step.disabled_reason || "Retry is unavailable for this state.");
|
||||
const executor = step.executor || {};
|
||||
const confirmation = executor.confirmation || "";
|
||||
const executeReason = executor.available ? (step.disabled_reason || "Execute this frozen plan step.") : "No bounded executor is available for this frozen step.";
|
||||
const executorHtml = executor.available ? `<div class="form-grid" data-run-execute-step="${escapeAttr(step.id)}" data-run-execute-confirmation="${escapeAttr(confirmation)}" data-run-execute-available="${step.available ? "true" : "false"}">
|
||||
${confirmation ? `<div><label>Confirmation</label><input type="text" data-run-execute-confirm placeholder="Type ${escapeAttr(confirmation)}" autocomplete="off" /></div>` : ""}
|
||||
<div class="button-row">${executor.kind === "catalog_publish" ? `<button class="secondary" data-run-preview-submit ${step.available ? "" : "disabled"}>Preview receipt-bound candidate</button>` : ""}<button data-run-execute-submit ${step.available && !confirmation ? "" : "disabled"} title="${escapeAttr(executeReason)}">Execute Step</button></div>
|
||||
</div>` : `<p class="action-meta">No durable executor: ${escapeHtml(step.status || "unsupported")}</p>`;
|
||||
const reconciliationHtml = step.reconciliation_required ? `<div class="form-grid" data-run-reconcile-step="${escapeAttr(step.id)}">
|
||||
<div><label>Observed external effect</label><select data-run-reconcile-outcome><option value="">Choose verified outcome</option><option value="effect_absent">Effect is absent — retry is safe</option><option value="effect_succeeded">Effect succeeded — advance the run</option><option value="unresolved">Still unresolved — keep blocked</option></select></div>
|
||||
<div><label>Observed external effect</label><select data-run-reconcile-outcome><option value="">Choose verified outcome</option><option value="effect_absent">Effect is absent — retry is safe</option><option value="effect_succeeded">Effect succeeded — advance the run after server verification</option><option value="unresolved">Still unresolved — keep blocked</option></select></div>
|
||||
<div><label>Confirmation</label><input type="text" data-run-reconcile-confirm placeholder="Type RECONCILE" autocomplete="off" /></div>
|
||||
<div class="button-row"><button class="secondary" data-run-reconcile-submit disabled title="Verify local and remote state independently, choose the observed outcome, and type RECONCILE.">Record Reconciliation</button></div>
|
||||
</div>` : "";
|
||||
@@ -1261,6 +1268,8 @@
|
||||
<div class="stage-title"><h3>${escapeHtml(`${step.order}. ${step.title || step.id}`)}</h3>${pill(step.state, kind)}</div>
|
||||
<p>${escapeHtml(step.detail || "")}</p>
|
||||
${step.result_code ? `<p class="action-meta">Result code: ${escapeHtml(step.result_code)}</p>` : ""}
|
||||
${releaseReceiptHtml(step.result_receipt)}
|
||||
${executorHtml}
|
||||
<div class="button-row"><button class="secondary" data-run-retry-step="${escapeAttr(step.id)}" ${step.retry_available ? "" : "disabled"} title="${escapeAttr(retryReason)}">Prepare Retry</button></div>
|
||||
${reconciliationHtml}
|
||||
</div>`;
|
||||
@@ -1268,7 +1277,7 @@
|
||||
const eventHtml = events.length ? `<div class="action"><h3>Recent bounded state events</h3>${events.map((event) => `<p class="action-meta">${escapeHtml(`${event.at} · ${event.type}${event.step_id ? ` · ${event.step_id}` : ""}${event.result_code ? ` · ${event.result_code}` : ""}`)}</p>`).join("")}</div>` : "";
|
||||
elements.runStatus.textContent = state.status || "unknown";
|
||||
elements.resumeReleaseRun.disabled = state.status !== "running";
|
||||
elements.runOutput.innerHTML = `<div class="action"><h3>${pill(state.status || "unknown", state.status === "blocked" ? "block" : state.status === "completed" ? "ok" : "warn")} ${escapeHtml(run.run_id)}</h3><p>Frozen plan ${escapeHtml((run.immutable?.digest || "").slice(0, 16))}… · updated ${escapeHtml(run.updated_at || "-")}</p><p class="action-meta">Executor controls remain separate and keep their existing confirmations.</p></div>${recommendationHtml}${stepHtml}${eventHtml}`;
|
||||
elements.runOutput.innerHTML = `<div class="action"><h3>${pill(state.status || "unknown", state.status === "blocked" ? "block" : state.status === "completed" ? "ok" : "warn")} ${escapeHtml(run.run_id)}</h3><p>Frozen plan ${escapeHtml((run.immutable?.digest || "").slice(0, 16))}… · updated ${escapeHtml(run.updated_at || "-")}</p><p class="action-meta">Every supported mutation is claimed durably before the existing narrow executor is called.</p></div>${recommendationHtml}${stepHtml}${eventHtml}`;
|
||||
for (const button of elements.runOutput.querySelectorAll("[data-run-retry-step]")) {
|
||||
button.addEventListener("click", () => retryReleaseRunStep(button.dataset.runRetryStep));
|
||||
}
|
||||
@@ -1283,6 +1292,78 @@
|
||||
confirmation.addEventListener("input", refresh);
|
||||
submit.addEventListener("click", () => reconcileReleaseRunStep(controls.dataset.runReconcileStep, outcome.value, confirmation.value.trim(), submit));
|
||||
}
|
||||
for (const controls of elements.runOutput.querySelectorAll("[data-run-execute-step]")) {
|
||||
const required = controls.dataset.runExecuteConfirmation || "";
|
||||
const available = controls.dataset.runExecuteAvailable === "true";
|
||||
const input = controls.querySelector("[data-run-execute-confirm]");
|
||||
const submit = controls.querySelector("[data-run-execute-submit]");
|
||||
const preview = controls.querySelector("[data-run-preview-submit]");
|
||||
if (input) input.addEventListener("input", () => {
|
||||
submit.disabled = !available || input.value.trim() !== required;
|
||||
});
|
||||
submit.addEventListener("click", () => executeReleaseRunStep(controls.dataset.runExecuteStep, required, submit));
|
||||
if (preview) preview.addEventListener("click", () => previewReleaseRunStep(controls.dataset.runExecuteStep, preview));
|
||||
}
|
||||
}
|
||||
|
||||
async function executeReleaseRunStep(stepId, confirmation, button) {
|
||||
const run = releaseState.currentRun;
|
||||
if (!run || !stepId) return;
|
||||
const commandKey = `execute:${run.run_id}:${stepId}:${confirmation || "NONE"}`;
|
||||
const commandBody = {
|
||||
confirm: confirmation,
|
||||
remote: "origin",
|
||||
signing_keys: elements.signingKey.value.trim() ? [elements.signingKey.value.trim()] : [],
|
||||
};
|
||||
const commandRequestId = inFlightRunCommandId(commandKey, "execute");
|
||||
setBusy([button], true);
|
||||
try {
|
||||
const executed = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/steps/${encodeURIComponent(stepId)}/execute`, {
|
||||
request_id: commandRequestId,
|
||||
...commandBody,
|
||||
});
|
||||
clearInFlightRunCommand(commandKey, commandRequestId);
|
||||
releaseState.currentRun = executed;
|
||||
renderReleaseRun(executed);
|
||||
const result = executed.execution_result || {};
|
||||
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill(result.status || "recorded", result.status === "failed" ? "block" : "ok")} Executor result</h3><p>${escapeHtml(result.detail || result.result_code || "The bounded result was recorded.")}</p></div>`);
|
||||
} catch (error) {
|
||||
if (deterministicRunCommandError(error)) clearInFlightRunCommand(commandKey, commandRequestId);
|
||||
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("reconcile", "block")} Executor did not establish a safe result</h3><p>${escapeHtml(error.message)}</p></div>`);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function previewReleaseRunStep(stepId, button) {
|
||||
const run = releaseState.currentRun;
|
||||
if (!run || !stepId) return;
|
||||
setBusy([button], true);
|
||||
try {
|
||||
const result = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/steps/${encodeURIComponent(stepId)}/preview`, {
|
||||
remote: "origin",
|
||||
});
|
||||
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill(result.status || "preview", result.status === "blocked" ? "block" : "ok")} Receipt-bound publication preview</h3><p>${escapeHtml((result.notes || []).join("; ") || "Preview completed without changing run state.")}</p></div>`);
|
||||
} catch (error) {
|
||||
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("blocked", "block")} Preview failed</h3><p>${escapeHtml(error.message)}</p></div>`);
|
||||
} finally {
|
||||
setBusy([button], false);
|
||||
}
|
||||
}
|
||||
|
||||
function releaseReceiptHtml(receipt) {
|
||||
if (!receipt || typeof receipt !== "object") return "";
|
||||
if (receipt.kind === "catalog_candidate") {
|
||||
return `<p class="action-meta">Candidate ${escapeHtml(receipt.candidate_id || "-")} · ${escapeHtml((receipt.catalog_sha256 || "").slice(0, 16))}…</p>`;
|
||||
}
|
||||
if (receipt.kind === "catalog_publication") {
|
||||
return `<p class="action-meta">Published ${escapeHtml(receipt.candidate_id || "-")} · commit ${escapeHtml((receipt.publication_commit_sha || "").slice(0, 12))} · tag ${escapeHtml(receipt.tag_name || "-")} (${escapeHtml((receipt.publication_tag_object_sha || "").slice(0, 12))}) · ${escapeHtml(receipt.remote || "origin")}/${escapeHtml(receipt.branch || "-")}</p>`;
|
||||
}
|
||||
if (receipt.kind === "repository_state") {
|
||||
const tag = receipt.tag_object ? ` · tag ${(receipt.tag_object || "").slice(0, 12)}` : "";
|
||||
return `<p class="action-meta">Bound ${escapeHtml(receipt.repo || "-")} · HEAD ${escapeHtml((receipt.head || "").slice(0, 12))}${escapeHtml(tag)} · ${receipt.worktree_clean ? "clean" : "dirty"}</p>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function resumeReleaseRun() {
|
||||
@@ -1346,7 +1427,17 @@
|
||||
function readInFlightRunCommands() {
|
||||
try {
|
||||
const commands = JSON.parse(sessionStorage.getItem(pendingRunCommandsKey) || "{}");
|
||||
return commands && typeof commands === "object" && !Array.isArray(commands) ? commands : {};
|
||||
if (!commands || typeof commands !== "object" || Array.isArray(commands)) return {};
|
||||
const normalized = {};
|
||||
for (const [commandKey, savedCommand] of Object.entries(commands)) {
|
||||
const requestIdToReplay = typeof savedCommand === "string" ? savedCommand : savedCommand?.request_id;
|
||||
if (typeof requestIdToReplay === "string") normalized[commandKey] = requestIdToReplay;
|
||||
}
|
||||
if (JSON.stringify(normalized) !== JSON.stringify(commands)) {
|
||||
if (Object.keys(normalized).length) sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(normalized));
|
||||
else sessionStorage.removeItem(pendingRunCommandsKey);
|
||||
}
|
||||
return normalized;
|
||||
} catch (_error) {
|
||||
sessionStorage.removeItem(pendingRunCommandsKey);
|
||||
return {};
|
||||
@@ -1387,6 +1478,19 @@
|
||||
body: { request_id: requestIdToReplay, outcome, confirm: "RECONCILE" },
|
||||
};
|
||||
}
|
||||
if (commandKey.startsWith("execute:")) {
|
||||
const remainder = commandKey.slice("execute:".length);
|
||||
const runSeparator = remainder.indexOf(":");
|
||||
const confirmationSeparator = remainder.lastIndexOf(":");
|
||||
const runId = remainder.slice(0, runSeparator);
|
||||
const stepId = remainder.slice(runSeparator + 1, confirmationSeparator);
|
||||
const confirmation = remainder.slice(confirmationSeparator + 1);
|
||||
if (runSeparator < 1 || confirmationSeparator <= runSeparator + 1 || !stepId) return null;
|
||||
return {
|
||||
path: `/api/release-runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepId)}/execute`,
|
||||
body: { request_id: requestIdToReplay, confirm: confirmation === "NONE" ? "" : confirmation },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1398,7 +1502,12 @@
|
||||
let recoveredRun = null;
|
||||
let pendingError = null;
|
||||
let rejectedError = null;
|
||||
for (const [commandKey, requestIdToReplay] of entries) {
|
||||
for (const [commandKey, savedCommand] of entries) {
|
||||
const requestIdToReplay = typeof savedCommand === "string" ? savedCommand : savedCommand?.request_id;
|
||||
if (typeof requestIdToReplay !== "string") {
|
||||
delete commands[commandKey];
|
||||
continue;
|
||||
}
|
||||
const command = pendingRunCommandRequest(commandKey, requestIdToReplay);
|
||||
if (!command) {
|
||||
clearInFlightRunCommand(commandKey, requestIdToReplay);
|
||||
@@ -1438,6 +1547,7 @@
|
||||
function inFlightRunCommandId(commandKey, prefix) {
|
||||
const commands = readInFlightRunCommands();
|
||||
if (typeof commands[commandKey] === "string") return commands[commandKey];
|
||||
if (typeof commands[commandKey]?.request_id === "string") return commands[commandKey].request_id;
|
||||
const identifier = requestId(prefix);
|
||||
commands[commandKey] = identifier;
|
||||
sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands));
|
||||
@@ -1446,7 +1556,8 @@
|
||||
|
||||
function clearInFlightRunCommand(commandKey, requestIdToClear) {
|
||||
const commands = readInFlightRunCommands();
|
||||
if (commands[commandKey] !== requestIdToClear) return;
|
||||
const storedRequestId = typeof commands[commandKey] === "string" ? commands[commandKey] : commands[commandKey]?.request_id;
|
||||
if (storedRequestId !== requestIdToClear) return;
|
||||
delete commands[commandKey];
|
||||
if (Object.keys(commands).length) sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands));
|
||||
else sessionStorage.removeItem(pendingRunCommandsKey);
|
||||
@@ -1493,14 +1604,14 @@
|
||||
tagStatus,
|
||||
releaseBlockers ? "block" : selected ? "ok" : "warn",
|
||||
selected
|
||||
? `${selected} selected; use Preview Tag + Publish, then Create Tags or Publish Tags after reviewing the source-release preflight.`
|
||||
? `${selected} selected; preview here, then create a durable run and execute its enabled tag/push steps.`
|
||||
: "Select repositories, set target versions, then preview the source release tags."
|
||||
),
|
||||
workflowStage(
|
||||
"4. Sign and publish website catalog",
|
||||
signedStatus,
|
||||
hasCandidate ? "ok" : "warn",
|
||||
hasCandidate ? `Candidate: ${elements.candidateDir.value.trim()}` : "After every selected source tag is remotely published, generate a signed candidate, preview it, then apply/tag/push the website publication."
|
||||
hasCandidate ? `Preview candidate: ${elements.candidateDir.value.trim()}` : "After every selected source tag is remotely published, use a durable run to generate and publish its receipt-bound candidate."
|
||||
),
|
||||
workflowStage(
|
||||
"5. Maintain install catalog",
|
||||
@@ -2348,7 +2459,7 @@
|
||||
|
||||
function setBusy(buttons, busy) {
|
||||
for (const button of buttons) {
|
||||
button.disabled = busy;
|
||||
button.disabled = busy || button.dataset.releaseDisabled === "true";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user