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_from_keyring(\n target_keyring_payload", publisher)
self.assertIn("trusted_keys=publication_trust", publisher) self.assertIn("trusted_keys=publication_trust", publisher)
self.assertNotIn("trusted_keys=candidate_keys", 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) self.assertNotIn("shutil.copytree(candidate_modules", publisher)
def test_release_integration_honors_independent_module_versions(self) -> None: 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): for repository in (self.repo, self.remote, campaign, campaign_remote):
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10")) self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
def test_api_requires_explicit_confirmation_and_ui_exposes_source_release(self) -> None: def test_api_keeps_legacy_source_release_preview_only(self) -> None:
with TestClient(create_app(workspace_root=self.workspace)) as client: with TestClient(
create_app(workspace_root=self.workspace, token="token")
) as client:
headers = {"X-Release-Console-Token": "token"}
rejected = client.post( rejected = client.post(
"/api/repositories/tag", "/api/repositories/tag",
headers=headers,
json={ json={
"repos": ["govoplan-core"], "repos": ["govoplan-core"],
"repo_versions": {"govoplan-core": "0.1.10"}, "repo_versions": {"govoplan-core": "0.1.10"},
@@ -244,6 +248,7 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
) )
preview = client.post( preview = client.post(
"/api/repositories/tag", "/api/repositories/tag",
headers=headers,
json={ json={
"repos": ["govoplan-core"], "repos": ["govoplan-core"],
"repo_versions": {"govoplan-core": "0.1.10"}, "repo_versions": {"govoplan-core": "0.1.10"},
@@ -251,14 +256,47 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
"push": True, "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("/") 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(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.assertEqual(preview.json()["repositories"][0]["repo"], "govoplan-core")
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10")) self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
self.assertIn('id="publishReleaseTags"', ui.text) 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('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("Signed Website Catalog", ui.text)
self.assertIn("Apply + Website Tag", ui.text) self.assertIn("Apply + Website Tag", ui.text)

View File

@@ -32,6 +32,12 @@ from govoplan_release.release_run import ( # noqa: E402
ReleaseRunStore, ReleaseRunStore,
_record_digest, _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 from server.app import ( # noqa: E402
create_app, create_app,
default_release_run_root, default_release_run_root,
@@ -51,8 +57,24 @@ class ReleaseRunStoreTests(unittest.TestCase):
self.root, self.root,
expected_workspace_fingerprint=WORKSPACE_FINGERPRINT, 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: def tearDown(self) -> None:
self.source_bindings.stop()
self.runtime_binding.stop()
self.runtime_trust.stop()
self.temporary.cleanup() self.temporary.cleanup()
def test_create_persists_private_immutable_plan_and_ordered_steps(self) -> None: 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" record_path = self.root / f"{run['run_id']}.json"
self.assertEqual("govoplan.release-run", reopened["schema"]) self.assertEqual("govoplan.release-run", reopened["schema"])
self.assertEqual(3, reopened["schema_version"]) self.assertEqual(4, reopened["schema_version"])
self.assertEqual( self.assertEqual(
{"govoplan-core": "1.2.3", "govoplan-files": "1.2.4"}, {"govoplan-core": "1.2.3", "govoplan-files": "1.2.4"},
reopened["immutable"]["input"]["repo_versions"], reopened["immutable"]["input"]["repo_versions"],
@@ -82,6 +104,119 @@ class ReleaseRunStoreTests(unittest.TestCase):
self.assertNotIn("processed_requests", reopened["state"]) self.assertNotIn("processed_requests", reopened["state"])
self.assertNotIn("attempt_fingerprint", reopened["state"]["steps"][0]) 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: def test_zero_step_plan_is_rejected(self) -> None:
plan = release_plan() plan = release_plan()
plan["dry_run_steps"] = [] plan["dry_run_steps"] = []
@@ -293,7 +428,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
self.assertEqual(0, untouched["state"]["steps"][0]["attempt_count"]) self.assertEqual(0, untouched["state"]["steps"][0]["attempt_count"])
accepted_plan = mutating_first_plan() 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( accepted_run_id = create_run(
self.store, self.store,
request_id="byte-capacity-accepted-create-0001", request_id="byte-capacity-accepted-create-0001",
@@ -334,7 +469,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
) -> None: ) -> None:
plan = mutating_first_plan() plan = mutating_first_plan()
plan["dry_run_steps"][0]["mutating"] = False # type: ignore[index] 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( run_id = create_run(
self.store, self.store,
request_id="byte-capacity-read-only-create-0001", request_id="byte-capacity-read-only-create-0001",
@@ -747,7 +882,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
listed = self.store.list(limit=1) listed = self.store.list(limit=1)
self.assertEqual([local_run["run_id"]], [item["run_id"] for item in listed]) 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, self,
) -> None: ) -> None:
with patch( with patch(
@@ -786,11 +921,11 @@ class ReleaseRunStoreTests(unittest.TestCase):
listed = self.store.list(limit=3) listed = self.store.list(limit=3)
self.assertEqual( 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], [item["run_id"] for item in listed],
) )
self.assertEqual("unavailable", listed[-1]["status"]) 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( def test_cursor_pagination_is_stable_and_keeps_unavailable_records_visible(
self, self,
@@ -822,6 +957,43 @@ class ReleaseRunStoreTests(unittest.TestCase):
with self.assertRaisesRegex(ReleaseRunConflict, "cursor is invalid"): with self.assertRaisesRegex(ReleaseRunConflict, "cursor is invalid"):
self.store.list_page(limit=1, cursor=f"{first['next_cursor']}tampered") 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( def test_retention_prunes_only_oldest_completed_records_and_fails_closed(
self, self,
) -> None: ) -> None:
@@ -963,6 +1135,22 @@ class ReleaseRunStoreTests(unittest.TestCase):
class ReleaseRunApiTests(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: def test_api_exposes_and_validates_cursor_pagination(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
app = create_app( app = create_app(
@@ -1008,6 +1196,338 @@ class ReleaseRunApiTests(unittest.TestCase):
self.assertIsNone(second["next_cursor"]) self.assertIsNone(second["next_cursor"])
self.assertEqual(409, invalid.status_code) 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: def test_token_guarded_create_list_read_resume_and_retry(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir) / "runs" root = Path(temp_dir) / "runs"
@@ -1205,6 +1725,10 @@ class ReleaseRunApiTests(unittest.TestCase):
"server.app.build_selective_release_plan", "server.app.build_selective_release_plan",
return_value=mutating_first_plan(), return_value=mutating_first_plan(),
), ),
patch(
"server.app.reconciled_repository_receipt",
return_value=repository_receipt(),
),
TestClient(app) as client, TestClient(app) as client,
): ):
created = client.post( created = client.post(
@@ -1372,12 +1896,16 @@ class ReleaseRunApiTests(unittest.TestCase):
) )
self.assertIn("Durable Run State", webui) 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("Create Run from Selection", webui)
self.assertIn("Prepare Retry", webui) self.assertIn("Prepare Retry", webui)
self.assertIn("step.retry_available", webui) self.assertIn("step.retry_available", webui)
self.assertIn("Record Reconciliation", webui) self.assertIn("Record Reconciliation", webui)
self.assertIn("effect_succeeded", webui) self.assertIn("effect_succeeded", webui)
self.assertNotIn(
'step.id === "catalog:validate-sign-publish" ? "disabled"',
webui,
)
self.assertIn("renderReleaseRunStoreUnavailable", webui) self.assertIn("renderReleaseRunStoreUnavailable", webui)
self.assertIn('const runsRequest = api("/api/release-runs")', webui) self.assertIn('const runsRequest = api("/api/release-runs")', webui)
self.assertIn("pendingReleaseRunPayload", webui) self.assertIn("pendingReleaseRunPayload", webui)
@@ -1385,13 +1913,30 @@ class ReleaseRunApiTests(unittest.TestCase):
self.assertIn("recoverPendingRunCommands", webui) self.assertIn("recoverPendingRunCommands", webui)
self.assertIn("inFlightRunCommandId", webui) self.assertIn("inFlightRunCommandId", webui)
self.assertIn("Run saved; list refresh unavailable", 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("effect_absent", runbook)
self.assertIn("$XDG_STATE_HOME", runbook) self.assertIn("$XDG_STATE_HOME", runbook)
self.assertIn("same-directory", runbook) self.assertIn("same-directory", runbook)
self.assertIn("caller-generated `request_id`", runbook) self.assertIn("caller-generated `request_id`", runbook)
self.assertIn("reserves the two command-ledger", 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( def test_ui_reuses_uncertain_ids_and_separates_saved_run_list_failure(
self, self,
) -> None: ) -> None:
@@ -1493,6 +2038,16 @@ async function api(_path) {
const reconcileRequest = pendingRunCommandRequest("reconcile:rr-known:core:tag:effect_succeeded", "reconcile-request"); 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.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"); 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; failCreate = true;
const uncertainCommand = await recoverPendingRunCommands(); 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]: def release_plan() -> dict[str, object]:
return { return {
"generated_at": "2026-07-22T00:00:00Z", "generated_at": "2026-07-22T00:00:00Z",
@@ -1641,5 +2254,32 @@ def mutating_first_plan() -> dict[str, object]:
return plan 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__": if __name__ == "__main__":
unittest.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 The store freezes a selective plan, claims each supported attempt before its
state machine. It does not execute release commands. Existing release executor is called, and records only bounded outcomes. Narrow executors remain
executors remain responsible for their narrow confirmations and will be wired separate from this persistence module.
to this state in a later slice.
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,6 +10,7 @@ from __future__ import annotations
import base64 import base64
import binascii import binascii
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
import hashlib import hashlib
import json import json
@@ -24,9 +24,12 @@ from typing import Any
from filelock import FileLock, Timeout as FileLockTimeout from filelock import FileLock, Timeout as FileLockTimeout
from .candidate_artifact import CandidateArtifactError, validate_release_channel
SCHEMA = "govoplan.release-run" SCHEMA = "govoplan.release-run"
SCHEMA_VERSION = 3 SCHEMA_VERSION = 4
LEGACY_SCHEMA_VERSIONS = {3}
MAX_RECORD_BYTES = 2 * 1024 * 1024 MAX_RECORD_BYTES = 2 * 1024 * 1024
MAX_PLAN_STEPS = 512 MAX_PLAN_STEPS = 512
MAX_REPOSITORY_VERSIONS = 128 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}$") 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}$") 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}$") 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}$") WORKSPACE_FINGERPRINT_PATTERN = re.compile(r"^[0-9a-f]{64}$")
VERSION_PATTERN = re.compile( VERSION_PATTERN = re.compile(
@@ -65,6 +67,13 @@ RECONCILIATION_CONFIRMATION = "RECONCILE"
START_RECOVERY_RESERVE = 2 START_RECOVERY_RESERVE = 2
PROJECTION_TIMESTAMP = "9999-12-31T23:59:59Z" PROJECTION_TIMESTAMP = "9999-12-31T23:59:59Z"
PROJECTION_RESULT_CODE = "r" * 64 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): class ReleaseRunError(RuntimeError):
@@ -87,6 +96,17 @@ class ReleaseRunCorrupt(ReleaseRunError):
"""Raised when a persisted record fails its integrity or schema checks.""" """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: class ReleaseRunStore:
"""Atomic, bounded local JSON store for release-run state.""" """Atomic, bounded local JSON store for release-run state."""
@@ -135,6 +155,7 @@ class ReleaseRunStore:
"started_at": None, "started_at": None,
"finished_at": None, "finished_at": None,
"result_code": None, "result_code": None,
"result_receipt": None,
} }
for item in immutable_plan["dry_run_steps"] for item in immutable_plan["dry_run_steps"]
] ]
@@ -251,7 +272,7 @@ class ReleaseRunStore:
( (
( (
1, 1,
view["updated_at"], view["created_at"],
view["created_at"], view["created_at"],
view["run_id"], view["run_id"],
), ),
@@ -364,6 +385,7 @@ class ReleaseRunStore:
"started_at": None, "started_at": None,
"finished_at": None, "finished_at": None,
"result_code": None, "result_code": None,
"result_receipt": None,
} }
) )
_remember_request( _remember_request(
@@ -391,6 +413,7 @@ class ReleaseRunStore:
request_id: str, request_id: str,
outcome: str, outcome: str,
confirmation: str, confirmation: str,
result_receipt: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Record an operator-observed outcome for an interrupted mutation.""" """Record an operator-observed outcome for an interrupted mutation."""
@@ -401,12 +424,22 @@ class ReleaseRunStore:
raise ReleaseRunConflict( raise ReleaseRunConflict(
f"Type {RECONCILIATION_CONFIRMATION} to record reconciliation." 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) request_fingerprint = _request_fingerprint(request_id)
with self._locked(): with self._locked():
record = self._read(run_id) record = self._read(run_id)
if _request_seen( if _request_seen(
record, request_fingerprint, "reconcile", step_id, outcome 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) return _view(record)
step, plan_step = _step_pair(record, step_id) step, plan_step = _step_pair(record, step_id)
if step["state"] != "interrupted" or not bool( if step["state"] != "interrupted" or not bool(
@@ -438,6 +471,7 @@ class ReleaseRunStore:
"started_at": None, "started_at": None,
"finished_at": None, "finished_at": None,
"result_code": None, "result_code": None,
"result_receipt": None,
} }
) )
elif outcome == "effect_succeeded": elif outcome == "effect_succeeded":
@@ -446,6 +480,7 @@ class ReleaseRunStore:
"state": "succeeded", "state": "succeeded",
"finished_at": _timestamp(), "finished_at": _timestamp(),
"result_code": "reconciled_effect_succeeded", "result_code": "reconciled_effect_succeeded",
"result_receipt": receipt,
} }
) )
_remember_request( _remember_request(
@@ -472,10 +507,20 @@ class ReleaseRunStore:
*, *,
attempt_id: str, attempt_id: str,
) -> dict[str, Any]: ) -> 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) _validate_step_id(step_id)
attempt_fingerprint = _request_fingerprint(attempt_id) attempt_fingerprint = _request_fingerprint(attempt_id)
@@ -487,7 +532,14 @@ class ReleaseRunStore:
raise ReleaseRunConflict( raise ReleaseRunConflict(
"The attempt identifier was already used for another release step." "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) step, plan_step = _step_pair(record, step_id)
available, reason = _step_available(record, step_id) available, reason = _step_available(record, step_id)
if not available: if not available:
@@ -502,6 +554,7 @@ class ReleaseRunStore:
"started_at": _timestamp(), "started_at": _timestamp(),
"finished_at": None, "finished_at": None,
"result_code": None, "result_code": None,
"result_receipt": None,
} }
) )
record["state"]["processed_attempts"].append( record["state"]["processed_attempts"].append(
@@ -519,7 +572,13 @@ class ReleaseRunStore:
mutating=bool(plan_step.get("mutating")), mutating=bool(plan_step.get("mutating")),
) )
self._persist_update(record) 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( def finish_step(
self, self,
@@ -529,12 +588,16 @@ class ReleaseRunStore:
attempt_id: str, attempt_id: str,
succeeded: bool, succeeded: bool,
result_code: str, result_code: str,
result_receipt: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> 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) _validate_step_id(step_id)
attempt_fingerprint = _request_fingerprint(attempt_id) attempt_fingerprint = _request_fingerprint(attempt_id)
result_code = _safe_code(result_code) 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" target_state = "succeeded" if succeeded else "failed"
with self._locked(): with self._locked():
record = self._read(run_id) record = self._read(run_id)
@@ -548,6 +611,7 @@ class ReleaseRunStore:
if ( if (
attempt["outcome"] == target_state attempt["outcome"] == target_state
and attempt["result_code"] == result_code and attempt["result_code"] == result_code
and step.get("result_receipt") == receipt
): ):
return _view(record) return _view(record)
raise ReleaseRunConflict( raise ReleaseRunConflict(
@@ -565,6 +629,7 @@ class ReleaseRunStore:
"state": target_state, "state": target_state,
"finished_at": _timestamp(), "finished_at": _timestamp(),
"result_code": result_code, "result_code": result_code,
"result_receipt": receipt,
} }
) )
attempt["outcome"] = target_state attempt["outcome"] = target_state
@@ -578,6 +643,76 @@ class ReleaseRunStore:
self._persist_update(record) self._persist_update(record)
return _view(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: def _persist_update(self, record: dict[str, Any]) -> None:
self._assert_workspace(record) self._assert_workspace(record)
record["updated_at"] = _timestamp() record["updated_at"] = _timestamp()
@@ -638,6 +773,9 @@ class ReleaseRunStore:
) from exc ) from exc
_validate_record(payload, expected_run_id=run_id) _validate_record(payload, expected_run_id=run_id)
self._assert_workspace(payload) self._assert_workspace(payload)
if payload["schema_version"] in LEGACY_SCHEMA_VERSIONS:
payload = _upgrade_record(payload)
self._atomic_write(path, payload)
return payload return payload
def _read_optional(self, run_id: str) -> dict[str, Any] | None: 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.") raise ReleaseRunConflict("Release run input fields are invalid.")
channel = value.get("channel") channel = value.get("channel")
repo_versions = value.get("repo_versions") 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.") raise ReleaseRunConflict("Release channel is invalid.")
if ( if (
not isinstance(repo_versions, dict) not isinstance(repo_versions, dict)
@@ -924,7 +1064,7 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]:
if not isinstance(value, dict): if not isinstance(value, dict):
raise ReleaseRunConflict("Release plan snapshot must be an object.") raise ReleaseRunConflict("Release plan snapshot must be an object.")
copied = deepcopy(value) copied = deepcopy(value)
if set(copied) != { base_fields = {
"generated_at", "generated_at",
"target_channel", "target_channel",
"status", "status",
@@ -935,15 +1075,32 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]:
"gate_findings", "gate_findings",
"recommended_action", "recommended_action",
"source_preflight_ready", "source_preflight_ready",
}: }
if set(copied) not in (base_fields, base_fields | {"runtime_binding"}):
raise ReleaseRunConflict("Release plan snapshot fields are invalid.") 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")): if not _is_timestamp(copied.get("generated_at")):
raise ReleaseRunConflict("Release plan timestamp is invalid.") raise ReleaseRunConflict("Release plan timestamp is invalid.")
if copied.get("status") not in PLAN_STATUSES: if copied.get("status") not in PLAN_STATUSES:
raise ReleaseRunConflict("Release plan status is invalid.") raise ReleaseRunConflict("Release plan status is invalid.")
if not isinstance( try:
copied.get("target_channel"), str target_channel = validate_release_channel(copied.get("target_channel"))
) or not CHANNEL_PATTERN.fullmatch(copied["target_channel"]): except CandidateArtifactError:
raise ReleaseRunConflict("Release plan channel is invalid.")
if target_channel != copied["target_channel"]:
raise ReleaseRunConflict("Release plan channel is invalid.") raise ReleaseRunConflict("Release plan channel is invalid.")
if not isinstance(copied.get("units"), list): if not isinstance(copied.get("units"), list):
raise ReleaseRunConflict("Release plan units are invalid.") 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.") raise ReleaseRunConflict("Release plan step collection is invalid.")
seen: set[str] = set() seen: set[str] = set()
for step in steps: for step in steps:
if not isinstance(step, dict) or set(step) != { if not isinstance(step, dict) or set(step) not in ({
"id", "id",
"title", "title",
"detail", "detail",
@@ -978,7 +1135,17 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]:
"mutating", "mutating",
"repo", "repo",
"status", "status",
}: }, {
"id",
"title",
"detail",
"command",
"cwd",
"mutating",
"repo",
"status",
"source_binding",
}):
raise ReleaseRunConflict("Release plan step is invalid.") raise ReleaseRunConflict("Release plan step is invalid.")
step_id = step.get("id") step_id = step.get("id")
_validate_step_id(step_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 not isinstance(step[field], str) or len(step[field]) > 8_192
): ):
raise ReleaseRunConflict("Release plan step metadata is invalid.") 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) encoded = _canonical_json(copied)
if len(encoded) > MAX_RECORD_BYTES: if len(encoded) > MAX_RECORD_BYTES:
raise ReleaseRunConflict("Release plan snapshot exceeds its size limit.") 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 ( if (
value.get("schema") != SCHEMA value.get("schema") != SCHEMA
or type(value.get("schema_version")) is not int 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 raise ValueError
schema_version = value["schema_version"]
run_id = value.get("run_id") run_id = value.get("run_id")
if not isinstance(run_id, str) or not RUN_ID_PATTERN.fullmatch(run_id): if not isinstance(run_id, str) or not RUN_ID_PATTERN.fullmatch(run_id):
raise ValueError raise ValueError
@@ -1084,10 +1268,7 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None:
if not isinstance(steps, list) or len(steps) != len(plan_steps): if not isinstance(steps, list) or len(steps) != len(plan_steps):
raise ValueError raise ValueError
for step, plan_step in zip(steps, plan_steps, strict=True): for step, plan_step in zip(steps, plan_steps, strict=True):
if ( expected_step_fields = {
not isinstance(step, dict)
or set(step)
!= {
"id", "id",
"state", "state",
"attempt_count", "attempt_count",
@@ -1096,6 +1277,11 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None:
"finished_at", "finished_at",
"result_code", "result_code",
} }
if schema_version >= 4:
expected_step_fields.add("result_receipt")
if (
not isinstance(step, dict)
or set(step) != expected_step_fields
or step.get("id") != plan_step["id"] or step.get("id") != plan_step["id"]
): ):
raise ValueError raise ValueError
@@ -1114,6 +1300,9 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None:
result_code = step.get("result_code") result_code = step.get("result_code")
if result_code is not None: if result_code is not None:
_safe_code(result_code) _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) _validate_step_state_fields(step)
for timestamp in (step.get("started_at"), step.get("finished_at")): for timestamp in (step.get("started_at"), step.get("finished_at")):
if timestamp is not None and not ( 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) _safe_code(attempt_result)
if ( if (
attempt["outcome"] == "interrupted" attempt["outcome"] == "interrupted"
and attempt_result != "process_interrupted" and attempt_result
not in {"process_interrupted", "executor_outcome_unknown"}
): ):
raise ValueError raise ValueError
if len({attempt["fingerprint"] for attempt in attempts}) != len(attempts): if len({attempt["fingerprint"] for attempt in attempts}) != len(attempts):
@@ -1550,6 +1740,7 @@ def _project_finish(
"state": target_state, "state": target_state,
"finished_at": PROJECTION_TIMESTAMP, "finished_at": PROJECTION_TIMESTAMP,
"result_code": PROJECTION_RESULT_CODE, "result_code": PROJECTION_RESULT_CODE,
"result_receipt": _projection_receipt() if succeeded else None,
} }
) )
attempt["outcome"] = target_state attempt["outcome"] = target_state
@@ -1565,6 +1756,22 @@ def _project_finish(
return projected 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]: def _project_resume(record: dict[str, Any]) -> dict[str, Any]:
projected = deepcopy(record) projected = deepcopy(record)
running_steps = [ running_steps = [
@@ -1615,6 +1822,7 @@ def _project_retry(record: dict[str, Any], step_id: str) -> dict[str, Any]:
"started_at": None, "started_at": None,
"finished_at": None, "finished_at": None,
"result_code": None, "result_code": None,
"result_receipt": None,
} }
) )
_remember_request( _remember_request(
@@ -1650,6 +1858,7 @@ def _project_reconcile(
"started_at": None, "started_at": None,
"finished_at": None, "finished_at": None,
"result_code": None, "result_code": None,
"result_receipt": None,
} }
) )
elif outcome == "effect_succeeded": elif outcome == "effect_succeeded":
@@ -1658,6 +1867,7 @@ def _project_reconcile(
"state": "succeeded", "state": "succeeded",
"finished_at": PROJECTION_TIMESTAMP, "finished_at": PROJECTION_TIMESTAMP,
"result_code": "reconciled_effect_succeeded", "result_code": "reconciled_effect_succeeded",
"result_receipt": _projection_receipt(),
} }
) )
_remember_request( _remember_request(
@@ -1944,6 +2154,118 @@ def _safe_code(value: str) -> str:
return value 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: def _is_sha256(value: Any) -> bool:
return isinstance(value, str) and bool(re.fullmatch(r"[0-9a-f]{64}", value)) 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"] started_at = step["started_at"]
finished_at = step["finished_at"] finished_at = step["finished_at"]
result_code = step["result_code"] result_code = step["result_code"]
result_receipt = step.get("result_receipt")
if started_at is not None and not _is_timestamp(started_at): if started_at is not None and not _is_timestamp(started_at):
raise ValueError raise ValueError
if finished_at is not None and not _is_timestamp(finished_at): 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 state == "pending":
if any( if any(
value is not None 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 raise ValueError
return return
if attempt_count < 1 or fingerprint is None or started_at is None: if attempt_count < 1 or fingerprint is None or started_at is None:
raise ValueError raise ValueError
if state == "running": 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 raise ValueError
return return
if finished_at is None or result_code is None: if finished_at is None or result_code is None:
raise ValueError 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 raise ValueError
@@ -2014,7 +2352,8 @@ def _validate_event_fields(event: dict[str, Any]) -> None:
raise ValueError raise ValueError
if ( if (
event_type == "step_interrupted" event_type == "step_interrupted"
and event["result_code"] != "process_interrupted" and event["result_code"]
not in {"process_interrupted", "executor_outcome_unknown"}
): ):
raise ValueError raise ValueError

View File

@@ -501,6 +501,24 @@ def dry_run_steps(
) -> tuple[ReleasePlanStep, ...]: ) -> tuple[ReleasePlanStep, ...]:
steps: list[ReleasePlanStep] = [] steps: list[ReleasePlanStep] = []
snapshots = {repo.spec.name: repo for repo in dashboard.repositories} 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: for unit in units:
steps.append( steps.append(
ReleasePlanStep( ReleasePlanStep(
@@ -618,6 +636,7 @@ def dry_run_steps(
f'--candidate-dir "$CANDIDATE_DIR" --channel {shlex.quote(channel)}' f'--candidate-dir "$CANDIDATE_DIR" --channel {shlex.quote(channel)}'
), ),
cwd=dashboard.meta_root, cwd=dashboard.meta_root,
mutating=True,
status="planned", status="planned",
) )
) )

View File

@@ -9,13 +9,12 @@ from typing import Literal
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
from govoplan_release import ( from govoplan_release import (
build_dashboard, build_dashboard,
build_release_intelligence, build_release_intelligence,
build_release_plan, build_release_plan,
build_selective_catalog_candidate,
build_selective_release_plan, build_selective_release_plan,
publish_catalog_candidate, publish_catalog_candidate,
prepare_repositories, prepare_repositories,
@@ -24,13 +23,37 @@ from govoplan_release import (
tag_repositories, tag_repositories,
) )
from govoplan_release.model import to_jsonable 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 ( from govoplan_release.release_run import (
ReleaseRunConflict, ReleaseRunConflict,
ReleaseRunCorrupt, ReleaseRunCorrupt,
ReleaseRunNotFound, ReleaseRunNotFound,
ReleaseRunStore, 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): class CatalogCandidateRequest(BaseModel):
@@ -41,6 +64,7 @@ class CatalogCandidateRequest(BaseModel):
signing_keys: list[str] = Field(default_factory=list) signing_keys: list[str] = Field(default_factory=list)
output_dir: str | None = None output_dir: str | None = None
base_catalog: str | None = None base_catalog: str | None = None
base_keyring: str | None = None
expires_days: int = 90 expires_days: int = 90
sequence: int | None = None sequence: int | None = None
public_base_url: str = "https://govoplan.add-ideas.de" public_base_url: str = "https://govoplan.add-ideas.de"
@@ -48,6 +72,11 @@ class CatalogCandidateRequest(BaseModel):
source_remote: str = "origin" source_remote: str = "origin"
check_public: bool = True check_public: bool = True
@field_validator("channel")
@classmethod
def validate_channel(cls, value: str) -> str:
return validate_release_channel(value)
class PublishCandidateRequest(BaseModel): class PublishCandidateRequest(BaseModel):
candidate_dir: str candidate_dir: str
@@ -66,17 +95,22 @@ class PublishCandidateRequest(BaseModel):
allow_dirty_website: bool = False allow_dirty_website: bool = False
confirm: str = "" confirm: str = ""
@field_validator("channel")
@classmethod
def validate_channel(cls, value: str) -> str:
return validate_release_channel(value)
class RepositoryPushRequest(BaseModel): class RepositoryPushRequest(BaseModel):
repos: list[str] = Field(default_factory=list) repos: list[str] = Field(default_factory=list)
remote: str = "origin" remote: Literal["origin"] = "origin"
apply: bool = False # noqa: A003 - API field mirrors CLI. apply: bool = False # noqa: A003 - API field mirrors CLI.
confirm: str = "" confirm: str = ""
class RepositorySyncRequest(BaseModel): class RepositorySyncRequest(BaseModel):
repos: list[str] = Field(default_factory=list) repos: list[str] = Field(default_factory=list)
remote: str = "origin" remote: Literal["origin"] = "origin"
apply: bool = False # noqa: A003 - API field mirrors CLI. apply: bool = False # noqa: A003 - API field mirrors CLI.
confirm: str = "" confirm: str = ""
@@ -108,6 +142,11 @@ class ReleaseRunCreateRequest(BaseModel):
public_catalog: bool = True public_catalog: bool = True
include_migrations: bool = False include_migrations: bool = False
@field_validator("channel")
@classmethod
def validate_channel(cls, value: str) -> str:
return validate_release_channel(value)
class ReleaseRunCommandRequest(BaseModel): class ReleaseRunCommandRequest(BaseModel):
request_id: str = Field(min_length=8, max_length=128) request_id: str = Field(min_length=8, max_length=128)
@@ -119,11 +158,23 @@ class ReleaseRunReconcileRequest(BaseModel):
confirm: str = Field(default="", max_length=32) 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( def create_app(
*, *,
workspace_root: Path = DEFAULT_WORKSPACE_ROOT, workspace_root: Path = DEFAULT_WORKSPACE_ROOT,
token: str | None = None, token: str | None = None,
run_state_root: Path | None = None, run_state_root: Path | None = None,
candidate_root: Path | None = None,
) -> FastAPI: ) -> FastAPI:
app = FastAPI(title="GovOPlaN Release Console", version="0.1.0") app = FastAPI(title="GovOPlaN Release Console", version="0.1.0")
app.state.workspace_root = workspace_root app.state.workspace_root = workspace_root
@@ -140,13 +191,31 @@ def create_app(
release_run_root, release_run_root,
expected_workspace_fingerprint=app.state.workspace_fingerprint, 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") @app.middleware("http")
async def require_token(request: Request, call_next): # type: ignore[no-untyped-def] async def require_token(request: Request, call_next): # type: ignore[no-untyped-def]
if token and request.url.path.startswith("/api/"): if request.url.path.startswith("/api/"):
if token:
provided = request.headers.get("x-release-console-token") provided = request.headers.get("x-release-console-token")
if provided != token: if provided != token:
return JSONResponse({"detail": "release console token required"}, status_code=401) 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) return await call_next(request)
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
@@ -174,6 +243,7 @@ def create_app(
include_website: bool = False, include_website: bool = False,
channel: str = "stable", channel: str = "stable",
) -> dict[str, object]: ) -> dict[str, object]:
channel = http_release_channel(channel)
snapshot = build_dashboard( snapshot = build_dashboard(
workspace_root=app.state.workspace_root, workspace_root=app.state.workspace_root,
target_version=target_version, target_version=target_version,
@@ -195,6 +265,7 @@ def create_app(
include_migrations: bool = False, include_migrations: bool = False,
channel: str = "stable", channel: str = "stable",
) -> dict[str, object]: ) -> dict[str, object]:
channel = http_release_channel(channel)
snapshot = build_dashboard( snapshot = build_dashboard(
workspace_root=app.state.workspace_root, workspace_root=app.state.workspace_root,
target_version=target_version, target_version=target_version,
@@ -212,6 +283,7 @@ def create_app(
channel: str = "stable", channel: str = "stable",
public_catalog: bool = True, public_catalog: bool = True,
) -> dict[str, object]: ) -> dict[str, object]:
channel = http_release_channel(channel)
intelligence = build_release_intelligence( intelligence = build_release_intelligence(
workspace_root=app.state.workspace_root, workspace_root=app.state.workspace_root,
channel=channel, channel=channel,
@@ -230,6 +302,7 @@ def create_app(
include_migrations: bool = False, include_migrations: bool = False,
channel: str = "stable", channel: str = "stable",
) -> dict[str, object]: ) -> dict[str, object]:
channel = http_release_channel(channel)
snapshot = build_dashboard( snapshot = build_dashboard(
workspace_root=app.state.workspace_root, workspace_root=app.state.workspace_root,
target_version=target_version, target_version=target_version,
@@ -280,7 +353,13 @@ def create_app(
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
if existing is not None: 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( snapshot = build_dashboard(
workspace_root=app.state.workspace_root, workspace_root=app.state.workspace_root,
online=request.online, online=request.online,
@@ -319,18 +398,29 @@ def create_app(
), ),
) )
try: try:
return app.state.release_runs.create( plan_payload = bind_plan_source_states(
plan=plan_payload,
repo_versions=dict(request.repo_versions),
workspace_root=app.state.workspace_root,
)
return release_run_view(
app.state.release_runs.create(
request_id=request.request_id, request_id=request.request_id,
input_snapshot=input_snapshot, input_snapshot=input_snapshot,
plan_snapshot=plan_payload, plan_snapshot=plan_payload,
) )
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: )
except (
ReleaseExecutionBlocked,
ReleaseRunConflict,
ReleaseRunCorrupt,
) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.get("/api/release-runs/{run_id}") @app.get("/api/release-runs/{run_id}")
def release_run(run_id: str) -> dict[str, object]: def release_run(run_id: str) -> dict[str, object]:
try: try:
return app.state.release_runs.get(run_id) return release_run_view(app.state.release_runs.get(run_id))
except ReleaseRunNotFound as exc: except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
@@ -341,7 +431,11 @@ def create_app(
run_id: str, request: ReleaseRunCommandRequest run_id: str, request: ReleaseRunCommandRequest
) -> dict[str, object]: ) -> dict[str, object]:
try: 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: except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
@@ -352,11 +446,13 @@ def create_app(
run_id: str, step_id: str, request: ReleaseRunCommandRequest run_id: str, step_id: str, request: ReleaseRunCommandRequest
) -> dict[str, object]: ) -> dict[str, object]:
try: try:
return app.state.release_runs.retry_step( return release_run_view(
app.state.release_runs.retry_step(
run_id, run_id,
step_id, step_id,
request_id=request.request_id, request_id=request.request_id,
) )
)
except ReleaseRunNotFound as exc: except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
@@ -367,53 +463,442 @@ def create_app(
run_id: str, step_id: str, request: ReleaseRunReconcileRequest run_id: str, step_id: str, request: ReleaseRunReconcileRequest
) -> dict[str, object]: ) -> dict[str, object]:
try: try:
return app.state.release_runs.reconcile_step( 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, run_id,
step_id, step_id,
request_id=request.request_id, request_id=request.request_id,
outcome=request.outcome, outcome=request.outcome,
confirmation=request.confirm, confirmation=request.confirm,
result_receipt=receipt,
)
) )
except ReleaseRunNotFound as exc: except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from 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 raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.get("/api/catalog-candidates") @app.get("/api/catalog-candidates")
def catalog_candidates() -> dict[str, object]: 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") @app.post("/api/catalog-candidates")
def catalog_candidate(request: CatalogCandidateRequest) -> dict[str, object]: def catalog_candidate(request: CatalogCandidateRequest) -> dict[str, object]:
repo_versions = dict(request.repo_versions) del request
if not repo_versions and request.repos and request.target_version: raise HTTPException(
repo_versions = {repo: request.target_version for repo in request.repos} status_code=409,
if not repo_versions: detail=(
raise HTTPException(status_code=400, detail="repo_versions or repos with target_version is required") "Signed catalog generation is available only through a durable "
signing_keys = tuple(request.signing_keys or default_signing_keys()) "release run with a trusted runtime and receipt-bound source tags."
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)
@app.post("/api/catalog-candidates/publish") @app.post("/api/catalog-candidates/publish")
def publish_candidate(request: PublishCandidateRequest) -> dict[str, object]: 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": if request.push and request.confirm != "PUSH":
raise HTTPException(status_code=400, detail="Push requires 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": 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())) repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
if not repos: if not repos:
raise HTTPException(status_code=400, detail="At least one repository must be selected") raise HTTPException(status_code=400, detail="At least one repository must be selected")
if request.apply and request.confirm != "PUSH": if request.apply:
raise HTTPException(status_code=400, detail="Repository push requires confirm=PUSH") 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) result = push_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply)
return to_jsonable(result) 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())) repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
if not repos: if not repos:
raise HTTPException(status_code=400, detail="At least one repository must be selected") raise HTTPException(status_code=400, detail="At least one repository must be selected")
if request.apply and request.confirm != "SYNC": if request.apply:
raise HTTPException(status_code=400, detail="Repository sync requires confirm=SYNC") 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) result = sync_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply)
return to_jsonable(result) 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())) repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
if not repos: if not repos:
raise HTTPException(status_code=400, detail="At least one repository must be selected") 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": if request.apply and request.confirm != "COMMIT":
raise HTTPException(status_code=400, detail="Repository prepare requires confirm=COMMIT") raise HTTPException(status_code=400, detail="Repository prepare requires confirm=COMMIT")
result = prepare_repositories( result = prepare_repositories(
@@ -478,6 +984,14 @@ def create_app(
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip())) repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
if not repos: if not repos:
raise HTTPException(status_code=400, detail="At least one repository must be selected") 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": if request.apply and request.push and request.confirm != "PUBLISH":
raise HTTPException(status_code=400, detail="Release tag publication requires 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": if request.apply and not request.push and request.confirm != "TAG":
@@ -496,6 +1010,164 @@ def create_app(
return 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: def default_release_run_root(workspace_root: Path) -> Path:
configured_state_home = os.environ.get("XDG_STATE_HOME", "").strip() configured_state_home = os.environ.get("XDG_STATE_HOME", "").strip()
state_home = Path(configured_state_home).expanduser() state_home = Path(configured_state_home).expanduser()
@@ -552,8 +1224,8 @@ def default_signing_keys() -> tuple[str, ...]:
return (f"release-key-1={key_path}",) return (f"release-key-1={key_path}",)
def list_catalog_candidates() -> list[dict[str, object]]: def list_catalog_candidates(root: Path | None = None) -> list[dict[str, object]]:
root = META_ROOT / "runtime" / "release-candidates" root = root or (META_ROOT / "runtime" / "release-candidates")
if not root.exists(): if not root.exists():
return [] return []
candidates: list[dict[str, object]] = [] candidates: list[dict[str, object]] = []

View File

@@ -608,7 +608,7 @@
</div> </div>
<div class="units-toolbar"> <div class="units-toolbar">
<button id="buildSelectedPlan">Build Plan</button> <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="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="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> <button class="secondary" id="selectUnreleasedUnits">Select Unreleased</button>
@@ -642,7 +642,7 @@
<small id="runStatus">no run selected</small> <small id="runStatus">no run selected</small>
</div> </div>
<div class="details"> <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 class="form-grid">
<div> <div>
<label for="releaseRun">Saved run</label> <label for="releaseRun">Saved run</label>
@@ -678,7 +678,7 @@
<small id="pushStatus"></small> <small id="pushStatus"></small>
</div> </div>
<div class="details"> <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 class="form-grid">
<div> <div>
<label for="pushRemote">Remote</label> <label for="pushRemote">Remote</label>
@@ -691,9 +691,9 @@
</div> </div>
<div class="button-row"> <div class="button-row">
<button class="secondary" id="previewRepoSync">Preview Sync</button> <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="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>
<div class="actions" id="pushOutput"></div> <div class="actions" id="pushOutput"></div>
</div> </div>
@@ -705,7 +705,7 @@
<small id="prepareStatus"></small> <small id="prepareStatus"></small>
</div> </div>
<div class="details"> <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 class="form-grid">
<div> <div>
<label for="prepareMessage">Commit message</label> <label for="prepareMessage">Commit message</label>
@@ -713,12 +713,12 @@
</div> </div>
<div> <div>
<label for="prepareConfirmText">Confirm</label> <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> </div>
<div class="button-row"> <div class="button-row">
<button class="secondary" id="previewPrepare">Preview Prepare</button> <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>
<div class="actions" id="prepareOutput"></div> <div class="actions" id="prepareOutput"></div>
</div> </div>
@@ -730,7 +730,7 @@
<small><span id="tagStatus">idle</span> · plan <span id="planStatus">idle</span></small> <small><span id="tagStatus">idle</span> · plan <span id="planStatus">idle</span></small>
</div> </div>
<div class="details"> <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 class="form-grid">
<div> <div>
<label for="tagMessage">Tag message</label> <label for="tagMessage">Tag message</label>
@@ -738,13 +738,13 @@
</div> </div>
<div> <div>
<label for="tagConfirmText">Confirm</label> <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> </div>
<div class="button-row"> <div class="button-row">
<button class="secondary" id="previewReleaseTags">Preview Tag + Publish</button> <button class="secondary" id="previewReleaseTags">Preview Tag + Publish</button>
<button id="createReleaseTags">Create 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">Publish 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>
<div class="actions" id="tagOutput"></div> <div class="actions" id="tagOutput"></div>
</div> </div>
@@ -764,18 +764,18 @@
</div> </div>
<div> <div>
<label for="candidateDir">Candidate dir</label> <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>
<div> <div>
<label for="confirmText">Confirm</label> <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> </div>
<div class="button-row"> <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="previewPublish">Preview</button>
<button class="secondary" id="applyPublish">Apply + Website Tag</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">Push Website Release</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>
<div class="actions" id="workflowOutput"></div> <div class="actions" id="workflowOutput"></div>
</div> </div>
@@ -1047,23 +1047,23 @@
renderCatalog(data.catalog, data.target_version); renderCatalog(data.catalog, data.target_version);
} }
function renderReleaseRunList(runs, options = {}) { function renderReleaseRunList(runs, renderOptions = {}) {
releaseState.runStoreAvailable = true; releaseState.runStoreAvailable = true;
const incoming = Array.isArray(runs) ? runs : []; 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()] ? [...new Map([...releaseState.runs, ...incoming].map((run) => [run.run_id, run])).values()]
: incoming; : incoming;
releaseState.runNextCursor = options.nextCursor || null; releaseState.runNextCursor = renderOptions.nextCursor || null;
elements.releaseRun.disabled = false; elements.releaseRun.disabled = false;
elements.createReleaseRun.disabled = false; elements.createReleaseRun.disabled = false;
elements.loadOlderReleaseRuns.disabled = !releaseState.runNextCursor; elements.loadOlderReleaseRuns.disabled = !releaseState.runNextCursor;
const selectedId = releaseState.currentRun?.run_id || ""; 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 repositories = run.repo_versions ? Object.keys(run.repo_versions).length : 0;
const label = `${run.run_id} · ${run.status || "unknown"} · ${repositories} repos`; 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>`; return `<option value="${escapeAttr(run.run_id)}" ${run.run_id === selectedId ? "selected" : ""}>${escapeHtml(label)}</option>`;
}).join(""); }).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)) { if (selectedId && releaseState.runs.some((run) => run.run_id === selectedId)) {
elements.releaseRun.value = selectedId; elements.releaseRun.value = selectedId;
} }
@@ -1252,8 +1252,15 @@
const stepHtml = steps.map((step) => { const stepHtml = steps.map((step) => {
const kind = step.state === "succeeded" ? "ok" : ["failed", "interrupted"].includes(step.state) ? "block" : step.available ? "ok" : "warn"; 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 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)}"> 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><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 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>` : ""; </div>` : "";
@@ -1261,6 +1268,8 @@
<div class="stage-title"><h3>${escapeHtml(`${step.order}. ${step.title || step.id}`)}</h3>${pill(step.state, kind)}</div> <div class="stage-title"><h3>${escapeHtml(`${step.order}. ${step.title || step.id}`)}</h3>${pill(step.state, kind)}</div>
<p>${escapeHtml(step.detail || "")}</p> <p>${escapeHtml(step.detail || "")}</p>
${step.result_code ? `<p class="action-meta">Result code: ${escapeHtml(step.result_code)}</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> <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} ${reconciliationHtml}
</div>`; </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>` : ""; 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.runStatus.textContent = state.status || "unknown";
elements.resumeReleaseRun.disabled = state.status !== "running"; 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]")) { for (const button of elements.runOutput.querySelectorAll("[data-run-retry-step]")) {
button.addEventListener("click", () => retryReleaseRunStep(button.dataset.runRetryStep)); button.addEventListener("click", () => retryReleaseRunStep(button.dataset.runRetryStep));
} }
@@ -1283,6 +1292,78 @@
confirmation.addEventListener("input", refresh); confirmation.addEventListener("input", refresh);
submit.addEventListener("click", () => reconcileReleaseRunStep(controls.dataset.runReconcileStep, outcome.value, confirmation.value.trim(), submit)); 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() { async function resumeReleaseRun() {
@@ -1346,7 +1427,17 @@
function readInFlightRunCommands() { function readInFlightRunCommands() {
try { try {
const commands = JSON.parse(sessionStorage.getItem(pendingRunCommandsKey) || "{}"); 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) { } catch (_error) {
sessionStorage.removeItem(pendingRunCommandsKey); sessionStorage.removeItem(pendingRunCommandsKey);
return {}; return {};
@@ -1387,6 +1478,19 @@
body: { request_id: requestIdToReplay, outcome, confirm: "RECONCILE" }, 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; return null;
} }
@@ -1398,7 +1502,12 @@
let recoveredRun = null; let recoveredRun = null;
let pendingError = null; let pendingError = null;
let rejectedError = 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); const command = pendingRunCommandRequest(commandKey, requestIdToReplay);
if (!command) { if (!command) {
clearInFlightRunCommand(commandKey, requestIdToReplay); clearInFlightRunCommand(commandKey, requestIdToReplay);
@@ -1438,6 +1547,7 @@
function inFlightRunCommandId(commandKey, prefix) { function inFlightRunCommandId(commandKey, prefix) {
const commands = readInFlightRunCommands(); const commands = readInFlightRunCommands();
if (typeof commands[commandKey] === "string") return commands[commandKey]; if (typeof commands[commandKey] === "string") return commands[commandKey];
if (typeof commands[commandKey]?.request_id === "string") return commands[commandKey].request_id;
const identifier = requestId(prefix); const identifier = requestId(prefix);
commands[commandKey] = identifier; commands[commandKey] = identifier;
sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands)); sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands));
@@ -1446,7 +1556,8 @@
function clearInFlightRunCommand(commandKey, requestIdToClear) { function clearInFlightRunCommand(commandKey, requestIdToClear) {
const commands = readInFlightRunCommands(); 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]; delete commands[commandKey];
if (Object.keys(commands).length) sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands)); if (Object.keys(commands).length) sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands));
else sessionStorage.removeItem(pendingRunCommandsKey); else sessionStorage.removeItem(pendingRunCommandsKey);
@@ -1493,14 +1604,14 @@
tagStatus, tagStatus,
releaseBlockers ? "block" : selected ? "ok" : "warn", releaseBlockers ? "block" : selected ? "ok" : "warn",
selected 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." : "Select repositories, set target versions, then preview the source release tags."
), ),
workflowStage( workflowStage(
"4. Sign and publish website catalog", "4. Sign and publish website catalog",
signedStatus, signedStatus,
hasCandidate ? "ok" : "warn", 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( workflowStage(
"5. Maintain install catalog", "5. Maintain install catalog",
@@ -2348,7 +2459,7 @@
function setBusy(buttons, busy) { function setBusy(buttons, busy) {
for (const button of buttons) { for (const button of buttons) {
button.disabled = busy; button.disabled = busy || button.dataset.releaseDisabled === "true";
} }
} }