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

@@ -55,7 +55,8 @@ class ReleaseEntrypointGateTests(unittest.TestCase):
self.assertIn("trusted_keys_from_keyring(\n target_keyring_payload", publisher)
self.assertIn("trusted_keys=publication_trust", publisher)
self.assertNotIn("trusted_keys=candidate_keys", publisher)
self.assertIn("write_module_directory(", publisher)
self.assertIn("module_directory_payloads(", publisher)
self.assertIn("verify_committed_publication(", publisher)
self.assertNotIn("shutil.copytree(candidate_modules", publisher)
def test_release_integration_honors_independent_module_versions(self) -> None:

View File

@@ -230,10 +230,14 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
for repository in (self.repo, self.remote, campaign, campaign_remote):
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
def test_api_requires_explicit_confirmation_and_ui_exposes_source_release(self) -> None:
with TestClient(create_app(workspace_root=self.workspace)) as client:
def test_api_keeps_legacy_source_release_preview_only(self) -> None:
with TestClient(
create_app(workspace_root=self.workspace, token="token")
) as client:
headers = {"X-Release-Console-Token": "token"}
rejected = client.post(
"/api/repositories/tag",
headers=headers,
json={
"repos": ["govoplan-core"],
"repo_versions": {"govoplan-core": "0.1.10"},
@@ -244,6 +248,7 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
)
preview = client.post(
"/api/repositories/tag",
headers=headers,
json={
"repos": ["govoplan-core"],
"repo_versions": {"govoplan-core": "0.1.10"},
@@ -251,14 +256,47 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
"push": True,
},
)
legacy_push = client.post(
"/api/repositories/push",
headers=headers,
json={
"repos": ["govoplan-core"],
"apply": True,
"confirm": "PUSH",
},
)
legacy_sync = client.post(
"/api/repositories/sync",
headers=headers,
json={
"repos": ["govoplan-core"],
"apply": True,
"confirm": "SYNC",
},
)
ui = client.get("/")
self.assertEqual(rejected.status_code, 400)
self.assertEqual(rejected.status_code, 409)
self.assertIn("durable release run", rejected.json()["detail"])
self.assertEqual(preview.status_code, 200)
self.assertEqual(409, legacy_push.status_code)
self.assertEqual(409, legacy_sync.status_code)
self.assertIn("durable", legacy_push.json()["detail"])
self.assertIn("durable", legacy_sync.json()["detail"])
self.assertEqual(preview.json()["repositories"][0]["repo"], "govoplan-core")
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
self.assertIn('id="publishReleaseTags"', ui.text)
self.assertIn(
'id="publishReleaseTags" data-release-disabled="true" disabled',
ui.text,
)
self.assertIn('postJson("/api/repositories/tag"', ui.text)
self.assertIn(
'id="syncRepos" disabled data-release-disabled="true"', ui.text
)
self.assertIn(
'id="pushRepos" disabled data-release-disabled="true"', ui.text
)
self.assertIn("Signed Website Catalog", ui.text)
self.assertIn("Apply + Website Tag", ui.text)

View File

@@ -32,6 +32,12 @@ from govoplan_release.release_run import ( # noqa: E402
ReleaseRunStore,
_record_digest,
)
from govoplan_release.candidate_artifact import ( # noqa: E402
CandidateArtifactReceipt,
candidate_output_path,
harden_private_candidate_tree,
issue_candidate_receipt,
)
from server.app import ( # noqa: E402
create_app,
default_release_run_root,
@@ -51,8 +57,24 @@ class ReleaseRunStoreTests(unittest.TestCase):
self.root,
expected_workspace_fingerprint=WORKSPACE_FINGERPRINT,
)
self.runtime_trust = patch(
"server.app.require_trusted_release_runtime"
)
self.runtime_binding = patch(
"server.app.verify_release_runtime_binding"
)
self.source_bindings = patch(
"server.app.bind_plan_source_states",
side_effect=bind_test_plan_source_states,
)
self.runtime_trust.start()
self.runtime_binding.start()
self.source_bindings.start()
def tearDown(self) -> None:
self.source_bindings.stop()
self.runtime_binding.stop()
self.runtime_trust.stop()
self.temporary.cleanup()
def test_create_persists_private_immutable_plan_and_ordered_steps(self) -> None:
@@ -68,7 +90,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
record_path = self.root / f"{run['run_id']}.json"
self.assertEqual("govoplan.release-run", reopened["schema"])
self.assertEqual(3, reopened["schema_version"])
self.assertEqual(4, reopened["schema_version"])
self.assertEqual(
{"govoplan-core": "1.2.3", "govoplan-files": "1.2.4"},
reopened["immutable"]["input"]["repo_versions"],
@@ -82,6 +104,119 @@ class ReleaseRunStoreTests(unittest.TestCase):
self.assertNotIn("processed_requests", reopened["state"])
self.assertNotIn("attempt_fingerprint", reopened["state"]["steps"][0])
def test_catalog_receipt_is_bounded_persisted_and_idempotent(self) -> None:
run_id = create_run(
self.store,
plan_snapshot=mutating_first_plan(),
request_id="receipt-create-request-0001",
)["run_id"]
attempt_id = "receipt-attempt-request-0001"
self.store.start_step(run_id, "core:tag", attempt_id=attempt_id)
receipt = {
"kind": "catalog_candidate",
"candidate_id": "candidate-" + "a" * 32,
"catalog_sha256": "b" * 64,
}
finished = self.store.finish_step(
run_id,
"core:tag",
attempt_id=attempt_id,
succeeded=True,
result_code="catalog_candidate_ready",
result_receipt=receipt,
)
replayed = self.store.finish_step(
run_id,
"core:tag",
attempt_id=attempt_id,
succeeded=True,
result_code="catalog_candidate_ready",
result_receipt=receipt,
)
self.assertEqual(receipt, finished["state"]["steps"][0]["result_receipt"])
self.assertEqual(finished, replayed)
with self.assertRaisesRegex(ReleaseRunConflict, "different recorded outcome"):
self.store.finish_step(
run_id,
"core:tag",
attempt_id=attempt_id,
succeeded=True,
result_code="catalog_candidate_ready",
result_receipt=receipt | {"catalog_sha256": "c" * 64},
)
def test_catalog_publication_receipt_binds_candidate_origin_commit_and_tag(
self,
) -> None:
run_id = create_run(
self.store,
plan_snapshot=mutating_first_plan(),
request_id="publication-receipt-create-0001",
)["run_id"]
attempt_id = "publication-receipt-attempt-0001"
self.store.start_step(run_id, "core:tag", attempt_id=attempt_id)
receipt = {
"kind": "catalog_publication",
"candidate_id": "candidate-" + "a" * 32,
"catalog_sha256": "b" * 64,
"keyring_sha256": "c" * 64,
"publication_commit_sha": "d" * 40,
"publication_tag_object_sha": "e" * 40,
"publication_tag_commit_sha": "d" * 40,
"branch": "main",
"tag_name": "catalog-stable-1",
"remote": "origin",
"remote_sha256": "f" * 64,
}
finished = self.store.finish_step(
run_id,
"core:tag",
attempt_id=attempt_id,
succeeded=True,
result_code="catalog_published",
result_receipt=receipt,
)
self.assertEqual(receipt, finished["state"]["steps"][0]["result_receipt"])
with self.assertRaisesRegex(
ReleaseRunConflict, "publication receipt identity"
):
self.store.finish_step(
run_id,
"core:tag",
attempt_id=attempt_id,
succeeded=True,
result_code="catalog_published",
result_receipt=receipt
| {"publication_tag_commit_sha": "0" * 40},
)
def test_schema_three_record_is_verified_then_migrated_privately(self) -> None:
run_id = create_run(
self.store,
request_id="legacy-schema-create-request-0001",
)["run_id"]
path = self.root / f"{run_id}.json"
payload = json.loads(path.read_text(encoding="utf-8"))
payload["schema_version"] = 3
for step in payload["state"]["steps"]:
step.pop("result_receipt")
payload["record_digest"] = _record_digest(payload)
path.write_text(json.dumps(payload), encoding="utf-8")
path.chmod(0o600)
migrated = self.store.get(run_id)
persisted = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(4, migrated["schema_version"])
self.assertEqual(4, persisted["schema_version"])
self.assertTrue(
all(step["result_receipt"] is None for step in persisted["state"]["steps"])
)
def test_zero_step_plan_is_rejected(self) -> None:
plan = release_plan()
plan["dry_run_steps"] = []
@@ -293,7 +428,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
self.assertEqual(0, untouched["state"]["steps"][0]["attempt_count"])
accepted_plan = mutating_first_plan()
accepted_plan["notes"] = ["x" * (MAX_RECORD_BYTES - 3_000)]
accepted_plan["notes"] = ["x" * (MAX_RECORD_BYTES - 4_300)]
accepted_run_id = create_run(
self.store,
request_id="byte-capacity-accepted-create-0001",
@@ -334,7 +469,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
) -> None:
plan = mutating_first_plan()
plan["dry_run_steps"][0]["mutating"] = False # type: ignore[index]
plan["notes"] = ["x" * (MAX_RECORD_BYTES - 3_000)]
plan["notes"] = ["x" * (MAX_RECORD_BYTES - 5_500)]
run_id = create_run(
self.store,
request_id="byte-capacity-read-only-create-0001",
@@ -747,7 +882,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
listed = self.store.list(limit=1)
self.assertEqual([local_run["run_id"]], [item["run_id"] for item in listed])
def test_list_orders_verified_runs_by_updated_timestamp_before_unavailable(
def test_list_orders_verified_runs_by_creation_timestamp_before_unavailable(
self,
) -> None:
with patch(
@@ -786,11 +921,11 @@ class ReleaseRunStoreTests(unittest.TestCase):
listed = self.store.list(limit=3)
self.assertEqual(
[older["run_id"], newer["run_id"], corrupt["run_id"]],
[newer["run_id"], older["run_id"], corrupt["run_id"]],
[item["run_id"] for item in listed],
)
self.assertEqual("unavailable", listed[-1]["status"])
self.assertEqual(older["run_id"], self.store.list(limit=1)[0]["run_id"])
self.assertEqual(newer["run_id"], self.store.list(limit=1)[0]["run_id"])
def test_cursor_pagination_is_stable_and_keeps_unavailable_records_visible(
self,
@@ -822,6 +957,43 @@ class ReleaseRunStoreTests(unittest.TestCase):
with self.assertRaisesRegex(ReleaseRunConflict, "cursor is invalid"):
self.store.list_page(limit=1, cursor=f"{first['next_cursor']}tampered")
def test_cursor_does_not_skip_or_repeat_when_an_older_run_is_updated(
self,
) -> None:
created: list[dict[str, object]] = []
for index in range(3):
with patch(
"govoplan_release.release_run._timestamp",
return_value=f"2026-07-22T0{index}:00:00Z",
):
created.append(
create_run(
self.store,
request_id=f"cursor-update-create-request-{index:04d}",
)
)
first = self.store.list_page(limit=1)
with patch(
"govoplan_release.release_run._timestamp",
return_value="2026-07-22T12:00:00Z",
):
self.store.start_step(
created[0]["run_id"],
"core:preflight",
attempt_id="cursor-update-attempt-request-0001",
)
second = self.store.list_page(limit=1, cursor=first["next_cursor"])
third = self.store.list_page(limit=1, cursor=second["next_cursor"])
self.assertEqual(
[item["run_id"] for item in reversed(created)],
[
first["runs"][0]["run_id"],
second["runs"][0]["run_id"],
third["runs"][0]["run_id"],
],
)
def test_retention_prunes_only_oldest_completed_records_and_fails_closed(
self,
) -> None:
@@ -963,6 +1135,22 @@ class ReleaseRunStoreTests(unittest.TestCase):
class ReleaseRunApiTests(unittest.TestCase):
def setUp(self) -> None:
self.runtime_trust = patch("server.app.require_trusted_release_runtime")
self.runtime_binding = patch("server.app.verify_release_runtime_binding")
self.source_bindings = patch(
"server.app.bind_plan_source_states",
side_effect=bind_test_plan_source_states,
)
self.runtime_trust.start()
self.runtime_binding.start()
self.source_bindings.start()
def tearDown(self) -> None:
self.source_bindings.stop()
self.runtime_binding.stop()
self.runtime_trust.stop()
def test_api_exposes_and_validates_cursor_pagination(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
app = create_app(
@@ -1008,6 +1196,338 @@ class ReleaseRunApiTests(unittest.TestCase):
self.assertIsNone(second["next_cursor"])
self.assertEqual(409, invalid.status_code)
def test_executor_claims_before_effect_and_replays_lost_success_response(
self,
) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
app = create_app(
workspace_root=Path(temp_dir),
run_state_root=Path(temp_dir) / "runs",
candidate_root=Path(temp_dir) / "candidates",
token="token",
)
headers = {"X-Release-Console-Token": "token"}
with (
patch("server.app.build_dashboard", return_value=object()),
patch(
"server.app.build_selective_release_plan",
return_value=release_plan(),
),
patch(
"server.app.bind_plan_source_states",
side_effect=bind_test_plan_source_states,
),
patch(
"server.app.verify_repository_preflight_binding",
return_value=repository_receipt(),
),
patch(
"server.app.execute_repository_step",
return_value=({"status": "inspected"}, repository_receipt()),
) as executor,
TestClient(app) as client,
):
created = client.post(
"/api/release-runs",
headers=headers,
json=api_run_input(request_id="api-executor-create-0001"),
).json()
run_id = created["run_id"]
self.assertTrue(created["state"]["steps"][0]["executor"]["available"])
self.assertEqual(
"preflight", created["state"]["steps"][0]["executor"]["kind"]
)
payload = {"request_id": "api-executor-attempt-0001"}
executed = client.post(
f"/api/release-runs/{run_id}/steps/core:preflight/execute",
headers=headers,
json=payload,
)
replayed = client.post(
f"/api/release-runs/{run_id}/steps/core:preflight/execute",
headers=headers,
json=payload,
)
self.assertEqual(200, executed.status_code)
self.assertEqual("succeeded", executed.json()["state"]["steps"][0]["state"])
self.assertEqual("replayed", replayed.json()["execution_result"]["status"])
executor.assert_called_once()
def test_lost_finish_write_interrupts_without_reexecuting_effect(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
app = create_app(
workspace_root=Path(temp_dir),
run_state_root=Path(temp_dir) / "runs",
token="token",
)
headers = {"X-Release-Console-Token": "token"}
with (
patch("server.app.build_dashboard", return_value=object()),
patch(
"server.app.build_selective_release_plan",
return_value=release_plan(),
),
patch(
"server.app.bind_plan_source_states",
side_effect=bind_test_plan_source_states,
),
patch(
"server.app.verify_repository_preflight_binding",
return_value=repository_receipt(),
),
patch(
"server.app.execute_repository_step",
return_value=({"status": "inspected"}, repository_receipt()),
) as executor,
TestClient(app) as client,
):
run_id = client.post(
"/api/release-runs",
headers=headers,
json=api_run_input(request_id="api-lost-finish-create-0001"),
).json()["run_id"]
with patch.object(
app.state.release_runs,
"finish_step",
side_effect=ReleaseRunCorrupt("simulated durable write failure"),
):
first = client.post(
f"/api/release-runs/{run_id}/steps/core:preflight/execute",
headers=headers,
json={"request_id": "api-lost-finish-attempt-0001"},
)
replay = client.post(
f"/api/release-runs/{run_id}/steps/core:preflight/execute",
headers=headers,
json={"request_id": "api-lost-finish-attempt-0001"},
)
loaded = client.get(
f"/api/release-runs/{run_id}", headers=headers
).json()
self.assertEqual(409, first.status_code)
self.assertEqual(409, replay.status_code)
self.assertEqual("interrupted", loaded["state"]["steps"][0]["state"])
executor.assert_called_once()
def test_executor_exception_is_interrupted_and_never_retried_implicitly(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
app = create_app(
workspace_root=Path(temp_dir),
run_state_root=Path(temp_dir) / "runs",
token="token",
)
headers = {"X-Release-Console-Token": "token"}
with (
patch("server.app.build_dashboard", return_value=object()),
patch(
"server.app.build_selective_release_plan",
return_value=mutating_first_plan(),
),
patch(
"server.app.bind_plan_source_states",
side_effect=bind_test_plan_source_states,
),
patch("server.app.verify_repository_step_precondition"),
patch(
"server.app.execute_repository_step",
side_effect=RuntimeError("connection lost after push"),
) as executor,
TestClient(app) as client,
):
run_id = client.post(
"/api/release-runs",
headers=headers,
json=api_run_input(request_id="api-ambiguous-create-0001"),
).json()["run_id"]
payload = {
"request_id": "api-ambiguous-attempt-0001",
"confirm": "TAG",
}
first = client.post(
f"/api/release-runs/{run_id}/steps/core:tag/execute",
headers=headers,
json=payload,
)
second = client.post(
f"/api/release-runs/{run_id}/steps/core:tag/execute",
headers=headers,
json=payload,
)
loaded = client.get(
f"/api/release-runs/{run_id}", headers=headers
).json()
self.assertEqual(409, first.status_code)
self.assertEqual(409, second.status_code)
self.assertEqual("interrupted", loaded["state"]["steps"][0]["state"])
self.assertEqual(
"executor_outcome_unknown",
loaded["state"]["steps"][0]["result_code"],
)
executor.assert_called_once()
def test_catalog_execution_persists_and_consumes_only_issued_receipt(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
candidate_root = Path(temp_dir) / "candidates"
app = create_app(
workspace_root=Path(temp_dir),
run_state_root=Path(temp_dir) / "runs",
candidate_root=candidate_root,
token="token",
)
headers = {"X-Release-Console-Token": "token"}
def generated(**kwargs: object) -> tuple[dict[str, object], CandidateArtifactReceipt]:
candidate_id = str(kwargs["candidate_id"])
candidate = candidate_output_path(candidate_root, candidate_id)
channels = candidate / "channels"
channels.mkdir(parents=True)
(channels / "stable.json").write_text(
json.dumps({"channel": "stable", "signatures": [{}]}),
encoding="utf-8",
)
harden_private_candidate_tree(candidate)
return (
{"status": "ready"},
issue_candidate_receipt(
root=candidate_root,
candidate_id=candidate_id,
channel="stable",
),
)
with (
patch("server.app.build_dashboard", return_value=object()),
patch(
"server.app.build_selective_release_plan",
return_value=catalog_release_plan(),
),
patch(
"server.app.bind_plan_source_states",
side_effect=bind_test_plan_source_states,
),
patch(
"server.app.verify_catalog_publication_precondition",
return_value=repository_receipt(
repo="addideas-govoplan-website", target_tag=""
),
),
patch("server.app.default_signing_keys", return_value=("key=/private",)),
patch(
"server.app.generate_catalog_candidate", side_effect=generated
) as generator,
patch(
"server.app.publish_received_candidate",
side_effect=published_candidate_result,
) as publisher,
TestClient(app) as client,
):
run_id = client.post(
"/api/release-runs",
headers=headers,
json=api_run_input(request_id="api-candidate-create-0001"),
).json()["run_id"]
generated_response = client.post(
f"/api/release-runs/{run_id}/steps/catalog:selective-generator/execute",
headers=headers,
json={
"request_id": "api-candidate-generate-0001",
"confirm": "GENERATE",
},
)
with patch("server.app.default_signing_keys", return_value=()):
replayed_generation = client.post(
f"/api/release-runs/{run_id}/steps/catalog:selective-generator/execute",
headers=headers,
json={
"request_id": "api-candidate-generate-0001",
"confirm": "GENERATE",
},
)
published = client.post(
f"/api/release-runs/{run_id}/steps/catalog:validate-sign-publish/execute",
headers=headers,
json={
"request_id": "api-candidate-publish-0001",
"confirm": "PUSH",
},
)
receipt = generated_response.json()["state"]["steps"][0]["result_receipt"]
self.assertRegex(receipt["candidate_id"], r"^candidate-[0-9a-f]{32}$")
self.assertEqual(64, len(receipt["catalog_sha256"]))
self.assertEqual(200, replayed_generation.status_code)
self.assertEqual(
"replayed",
replayed_generation.json()["execution_result"]["status"],
)
generator.assert_called_once()
self.assertEqual(200, published.status_code)
self.assertEqual("completed", published.json()["state"]["status"])
publisher.assert_called_once_with(
candidate_path=candidate_root / receipt["candidate_id"],
candidate_receipt=receipt,
channel="stable",
workspace_root=Path(temp_dir),
remote="origin",
expected_website_receipt=repository_receipt(
repo="addideas-govoplan-website", target_tag=""
),
)
def test_legacy_catalog_endpoint_is_preview_only(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
app = create_app(workspace_root=Path(temp_dir), token="token")
with TestClient(app) as client:
response = client.post(
"/api/catalog-candidates/publish",
headers={"X-Release-Console-Token": "token"},
json={
"candidate_dir": "/tmp/untrusted",
"apply": True,
"push": True,
"confirm": "PUSH",
},
)
self.assertEqual(409, response.status_code)
self.assertIn("durable release run", response.json()["detail"])
def test_release_channels_and_durable_remote_are_strictly_validated(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
app = create_app(workspace_root=Path(temp_dir), token="token")
headers = {"X-Release-Console-Token": "token"}
with (
patch("server.app.build_dashboard") as dashboard,
TestClient(app) as client,
):
query = client.get(
"/api/dashboard", headers=headers, params={"channel": "../stable"}
)
created = client.post(
"/api/release-runs",
headers=headers,
json=api_run_input(
request_id="api-invalid-channel-create-0001",
channel="/absolute",
),
)
execute = client.post(
"/api/release-runs/not-a-run/steps/core:tag/execute",
headers=headers,
json={
"request_id": "api-invalid-remote-attempt-0001",
"confirm": "TAG",
"remote": "upstream",
},
)
self.assertEqual(422, query.status_code)
self.assertEqual(422, created.status_code)
self.assertEqual(422, execute.status_code)
dashboard.assert_not_called()
def test_token_guarded_create_list_read_resume_and_retry(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir) / "runs"
@@ -1205,6 +1725,10 @@ class ReleaseRunApiTests(unittest.TestCase):
"server.app.build_selective_release_plan",
return_value=mutating_first_plan(),
),
patch(
"server.app.reconciled_repository_receipt",
return_value=repository_receipt(),
),
TestClient(app) as client,
):
created = client.post(
@@ -1372,12 +1896,16 @@ class ReleaseRunApiTests(unittest.TestCase):
)
self.assertIn("Durable Run State", webui)
self.assertIn("State tracking only.", webui)
self.assertIn("Durable execution.", webui)
self.assertIn("Create Run from Selection", webui)
self.assertIn("Prepare Retry", webui)
self.assertIn("step.retry_available", webui)
self.assertIn("Record Reconciliation", webui)
self.assertIn("effect_succeeded", webui)
self.assertNotIn(
'step.id === "catalog:validate-sign-publish" ? "disabled"',
webui,
)
self.assertIn("renderReleaseRunStoreUnavailable", webui)
self.assertIn('const runsRequest = api("/api/release-runs")', webui)
self.assertIn("pendingReleaseRunPayload", webui)
@@ -1385,13 +1913,30 @@ class ReleaseRunApiTests(unittest.TestCase):
self.assertIn("recoverPendingRunCommands", webui)
self.assertIn("inFlightRunCommandId", webui)
self.assertIn("Run saved; list refresh unavailable", webui)
self.assertIn("This foundation tracks state only.", runbook)
self.assertIn("The run record is execution evidence only", runbook)
self.assertIn("isolated checkout", runbook)
self.assertIn("effect_absent", runbook)
self.assertIn("$XDG_STATE_HOME", runbook)
self.assertIn("same-directory", runbook)
self.assertIn("caller-generated `request_id`", runbook)
self.assertIn("reserves the two command-ledger", runbook)
def test_entire_release_console_script_is_valid_javascript(self) -> None:
node = shutil.which("node")
if node is None:
self.skipTest("Node.js is required for the Release Console UI check")
webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
match = re.search(r"<script>(?P<script>.*?)</script>", webui, re.DOTALL)
self.assertIsNotNone(match, "release console script element is missing")
result = subprocess.run( # noqa: S603
[node, "--check", "-"],
input=match.group("script") if match is not None else "",
check=False,
capture_output=True,
text=True,
)
self.assertEqual(0, result.returncode, result.stderr)
def test_ui_reuses_uncertain_ids_and_separates_saved_run_list_failure(
self,
) -> None:
@@ -1493,6 +2038,16 @@ async function api(_path) {
const reconcileRequest = pendingRunCommandRequest("reconcile:rr-known:core:tag:effect_succeeded", "reconcile-request");
assert(reconcileRequest.path.endsWith("/steps/core%3Atag/reconcile"), "reconciliation recovery path was not executable");
assert(reconcileRequest.body.confirm === "RECONCILE" && reconcileRequest.body.outcome === "effect_succeeded", "reconciliation recovery body changed");
const executeKey = "execute:rr-known:catalog:selective-generator:GENERATE";
const executeId = inFlightRunCommandId(executeKey, "execute", { signing_keys: ["release-key=/private/key"] });
assert(!values.get(pendingRunCommandsKey).includes("signing_keys"), "signing key path was persisted in session storage");
const executeRequest = pendingRunCommandRequest(executeKey, executeId);
assert(!Object.hasOwn(executeRequest.body, "signing_keys"), "execute replay reconstructed signing material");
values.set(pendingRunCommandsKey, JSON.stringify({ [commandKey]: commandOne, [executeKey]: { request_id: executeId, body: { signing_keys: ["legacy-secret"] } } }));
const migratedCommands = readInFlightRunCommands();
assert(migratedCommands[executeKey] === executeId, "legacy pending execute request ID was lost");
assert(!values.get(pendingRunCommandsKey).includes("legacy-secret"), "legacy persisted signing material was not purged");
clearInFlightRunCommand(executeKey, executeId);
failCreate = true;
const uncertainCommand = await recoverPendingRunCommands();
@@ -1591,6 +2146,64 @@ def run_input(
}
def repository_receipt(
*, repo: str = "govoplan-core", target_tag: str = "v1.2.3"
) -> dict[str, object]:
return {
"kind": "repository_state",
"repo": repo,
"head": "a" * 40,
"branch": "main",
"remote": "origin",
"remote_sha256": "b" * 64,
"worktree_clean": True,
"target_tag": target_tag,
"tag_object": None,
}
def published_candidate_result(
**kwargs: object,
) -> tuple[dict[str, object], dict[str, object]]:
candidate = kwargs["candidate_receipt"]
website = kwargs["expected_website_receipt"]
if not isinstance(candidate, dict) or not isinstance(website, dict):
raise AssertionError("publication test receipts are unavailable")
receipt = {
"kind": "catalog_publication",
"candidate_id": candidate["candidate_id"],
"catalog_sha256": candidate["catalog_sha256"],
"keyring_sha256": "c" * 64,
"publication_commit_sha": "d" * 40,
"publication_tag_object_sha": "e" * 40,
"publication_tag_commit_sha": "d" * 40,
"branch": website["branch"],
"tag_name": "catalog-stable-7",
"remote": "origin",
"remote_sha256": website["remote_sha256"],
}
return {"status": "published"}, receipt
def bind_test_plan_source_states(**kwargs: object) -> dict[str, object]:
plan = kwargs["plan"]
if not isinstance(plan, dict):
raise AssertionError("test plan is not an object")
steps = plan.get("dry_run_steps")
if not isinstance(steps, list):
raise AssertionError("test plan has no steps")
for step in steps:
if not isinstance(step, dict):
continue
if isinstance(step.get("repo"), str):
step["source_binding"] = repository_receipt()
elif step.get("id") == "catalog:validate-sign-publish":
step["source_binding"] = repository_receipt(
repo="addideas-govoplan-website", target_tag=""
)
return plan
def release_plan() -> dict[str, object]:
return {
"generated_at": "2026-07-22T00:00:00Z",
@@ -1641,5 +2254,32 @@ def mutating_first_plan() -> dict[str, object]:
return plan
def catalog_release_plan() -> dict[str, object]:
plan = release_plan()
plan["dry_run_steps"] = [
{
"id": "catalog:selective-generator",
"title": "Generate candidate",
"detail": "Build artifacts and sign a candidate.",
"command": "release-catalog selective",
"cwd": "/workspace/govoplan",
"mutating": True,
"repo": None,
"status": "planned",
},
{
"id": "catalog:validate-sign-publish",
"title": "Publish candidate",
"detail": "Publish only the receipt-bound candidate.",
"command": "release-catalog publish-candidate",
"cwd": "/workspace/govoplan",
"mutating": True,
"repo": None,
"status": "planned",
},
]
return plan
if __name__ == "__main__":
unittest.main()

View File

@@ -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

View File

@@ -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",
)
)

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]] = []

View File

@@ -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";
}
}