diff --git a/tests/test_release_entrypoint_gates.py b/tests/test_release_entrypoint_gates.py index efa275f..e727351 100644 --- a/tests/test_release_entrypoint_gates.py +++ b/tests/test_release_entrypoint_gates.py @@ -55,7 +55,8 @@ class ReleaseEntrypointGateTests(unittest.TestCase): self.assertIn("trusted_keys_from_keyring(\n target_keyring_payload", publisher) self.assertIn("trusted_keys=publication_trust", publisher) self.assertNotIn("trusted_keys=candidate_keys", publisher) - self.assertIn("write_module_directory(", publisher) + self.assertIn("module_directory_payloads(", publisher) + self.assertIn("verify_committed_publication(", publisher) self.assertNotIn("shutil.copytree(candidate_modules", publisher) def test_release_integration_honors_independent_module_versions(self) -> None: diff --git a/tests/test_release_repository_tag.py b/tests/test_release_repository_tag.py index 0489555..d3238de 100644 --- a/tests/test_release_repository_tag.py +++ b/tests/test_release_repository_tag.py @@ -230,10 +230,14 @@ class ReleaseRepositoryTagTests(unittest.TestCase): for repository in (self.repo, self.remote, campaign, campaign_remote): self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10")) - def test_api_requires_explicit_confirmation_and_ui_exposes_source_release(self) -> None: - with TestClient(create_app(workspace_root=self.workspace)) as client: + def test_api_keeps_legacy_source_release_preview_only(self) -> None: + with TestClient( + create_app(workspace_root=self.workspace, token="token") + ) as client: + headers = {"X-Release-Console-Token": "token"} rejected = client.post( "/api/repositories/tag", + headers=headers, json={ "repos": ["govoplan-core"], "repo_versions": {"govoplan-core": "0.1.10"}, @@ -244,6 +248,7 @@ class ReleaseRepositoryTagTests(unittest.TestCase): ) preview = client.post( "/api/repositories/tag", + headers=headers, json={ "repos": ["govoplan-core"], "repo_versions": {"govoplan-core": "0.1.10"}, @@ -251,14 +256,47 @@ class ReleaseRepositoryTagTests(unittest.TestCase): "push": True, }, ) + legacy_push = client.post( + "/api/repositories/push", + headers=headers, + json={ + "repos": ["govoplan-core"], + "apply": True, + "confirm": "PUSH", + }, + ) + legacy_sync = client.post( + "/api/repositories/sync", + headers=headers, + json={ + "repos": ["govoplan-core"], + "apply": True, + "confirm": "SYNC", + }, + ) ui = client.get("/") - self.assertEqual(rejected.status_code, 400) + self.assertEqual(rejected.status_code, 409) + self.assertIn("durable release run", rejected.json()["detail"]) self.assertEqual(preview.status_code, 200) + self.assertEqual(409, legacy_push.status_code) + self.assertEqual(409, legacy_sync.status_code) + self.assertIn("durable", legacy_push.json()["detail"]) + self.assertIn("durable", legacy_sync.json()["detail"]) self.assertEqual(preview.json()["repositories"][0]["repo"], "govoplan-core") self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10")) self.assertIn('id="publishReleaseTags"', ui.text) + self.assertIn( + 'id="publishReleaseTags" data-release-disabled="true" disabled', + ui.text, + ) self.assertIn('postJson("/api/repositories/tag"', ui.text) + self.assertIn( + 'id="syncRepos" disabled data-release-disabled="true"', ui.text + ) + self.assertIn( + 'id="pushRepos" disabled data-release-disabled="true"', ui.text + ) self.assertIn("Signed Website Catalog", ui.text) self.assertIn("Apply + Website Tag", ui.text) diff --git a/tests/test_release_run_state.py b/tests/test_release_run_state.py index beb9b86..0205e87 100644 --- a/tests/test_release_run_state.py +++ b/tests/test_release_run_state.py @@ -32,6 +32,12 @@ from govoplan_release.release_run import ( # noqa: E402 ReleaseRunStore, _record_digest, ) +from govoplan_release.candidate_artifact import ( # noqa: E402 + CandidateArtifactReceipt, + candidate_output_path, + harden_private_candidate_tree, + issue_candidate_receipt, +) from server.app import ( # noqa: E402 create_app, default_release_run_root, @@ -51,8 +57,24 @@ class ReleaseRunStoreTests(unittest.TestCase): self.root, expected_workspace_fingerprint=WORKSPACE_FINGERPRINT, ) + self.runtime_trust = patch( + "server.app.require_trusted_release_runtime" + ) + self.runtime_binding = patch( + "server.app.verify_release_runtime_binding" + ) + self.source_bindings = patch( + "server.app.bind_plan_source_states", + side_effect=bind_test_plan_source_states, + ) + self.runtime_trust.start() + self.runtime_binding.start() + self.source_bindings.start() def tearDown(self) -> None: + self.source_bindings.stop() + self.runtime_binding.stop() + self.runtime_trust.stop() self.temporary.cleanup() def test_create_persists_private_immutable_plan_and_ordered_steps(self) -> None: @@ -68,7 +90,7 @@ class ReleaseRunStoreTests(unittest.TestCase): record_path = self.root / f"{run['run_id']}.json" self.assertEqual("govoplan.release-run", reopened["schema"]) - self.assertEqual(3, reopened["schema_version"]) + self.assertEqual(4, reopened["schema_version"]) self.assertEqual( {"govoplan-core": "1.2.3", "govoplan-files": "1.2.4"}, reopened["immutable"]["input"]["repo_versions"], @@ -82,6 +104,119 @@ class ReleaseRunStoreTests(unittest.TestCase): self.assertNotIn("processed_requests", reopened["state"]) self.assertNotIn("attempt_fingerprint", reopened["state"]["steps"][0]) + def test_catalog_receipt_is_bounded_persisted_and_idempotent(self) -> None: + run_id = create_run( + self.store, + plan_snapshot=mutating_first_plan(), + request_id="receipt-create-request-0001", + )["run_id"] + attempt_id = "receipt-attempt-request-0001" + self.store.start_step(run_id, "core:tag", attempt_id=attempt_id) + receipt = { + "kind": "catalog_candidate", + "candidate_id": "candidate-" + "a" * 32, + "catalog_sha256": "b" * 64, + } + + finished = self.store.finish_step( + run_id, + "core:tag", + attempt_id=attempt_id, + succeeded=True, + result_code="catalog_candidate_ready", + result_receipt=receipt, + ) + replayed = self.store.finish_step( + run_id, + "core:tag", + attempt_id=attempt_id, + succeeded=True, + result_code="catalog_candidate_ready", + result_receipt=receipt, + ) + + self.assertEqual(receipt, finished["state"]["steps"][0]["result_receipt"]) + self.assertEqual(finished, replayed) + with self.assertRaisesRegex(ReleaseRunConflict, "different recorded outcome"): + self.store.finish_step( + run_id, + "core:tag", + attempt_id=attempt_id, + succeeded=True, + result_code="catalog_candidate_ready", + result_receipt=receipt | {"catalog_sha256": "c" * 64}, + ) + + def test_catalog_publication_receipt_binds_candidate_origin_commit_and_tag( + self, + ) -> None: + run_id = create_run( + self.store, + plan_snapshot=mutating_first_plan(), + request_id="publication-receipt-create-0001", + )["run_id"] + attempt_id = "publication-receipt-attempt-0001" + self.store.start_step(run_id, "core:tag", attempt_id=attempt_id) + receipt = { + "kind": "catalog_publication", + "candidate_id": "candidate-" + "a" * 32, + "catalog_sha256": "b" * 64, + "keyring_sha256": "c" * 64, + "publication_commit_sha": "d" * 40, + "publication_tag_object_sha": "e" * 40, + "publication_tag_commit_sha": "d" * 40, + "branch": "main", + "tag_name": "catalog-stable-1", + "remote": "origin", + "remote_sha256": "f" * 64, + } + + finished = self.store.finish_step( + run_id, + "core:tag", + attempt_id=attempt_id, + succeeded=True, + result_code="catalog_published", + result_receipt=receipt, + ) + + self.assertEqual(receipt, finished["state"]["steps"][0]["result_receipt"]) + with self.assertRaisesRegex( + ReleaseRunConflict, "publication receipt identity" + ): + self.store.finish_step( + run_id, + "core:tag", + attempt_id=attempt_id, + succeeded=True, + result_code="catalog_published", + result_receipt=receipt + | {"publication_tag_commit_sha": "0" * 40}, + ) + + def test_schema_three_record_is_verified_then_migrated_privately(self) -> None: + run_id = create_run( + self.store, + request_id="legacy-schema-create-request-0001", + )["run_id"] + path = self.root / f"{run_id}.json" + payload = json.loads(path.read_text(encoding="utf-8")) + payload["schema_version"] = 3 + for step in payload["state"]["steps"]: + step.pop("result_receipt") + payload["record_digest"] = _record_digest(payload) + path.write_text(json.dumps(payload), encoding="utf-8") + path.chmod(0o600) + + migrated = self.store.get(run_id) + persisted = json.loads(path.read_text(encoding="utf-8")) + + self.assertEqual(4, migrated["schema_version"]) + self.assertEqual(4, persisted["schema_version"]) + self.assertTrue( + all(step["result_receipt"] is None for step in persisted["state"]["steps"]) + ) + def test_zero_step_plan_is_rejected(self) -> None: plan = release_plan() plan["dry_run_steps"] = [] @@ -293,7 +428,7 @@ class ReleaseRunStoreTests(unittest.TestCase): self.assertEqual(0, untouched["state"]["steps"][0]["attempt_count"]) accepted_plan = mutating_first_plan() - accepted_plan["notes"] = ["x" * (MAX_RECORD_BYTES - 3_000)] + accepted_plan["notes"] = ["x" * (MAX_RECORD_BYTES - 4_300)] accepted_run_id = create_run( self.store, request_id="byte-capacity-accepted-create-0001", @@ -334,7 +469,7 @@ class ReleaseRunStoreTests(unittest.TestCase): ) -> None: plan = mutating_first_plan() plan["dry_run_steps"][0]["mutating"] = False # type: ignore[index] - plan["notes"] = ["x" * (MAX_RECORD_BYTES - 3_000)] + plan["notes"] = ["x" * (MAX_RECORD_BYTES - 5_500)] run_id = create_run( self.store, request_id="byte-capacity-read-only-create-0001", @@ -747,7 +882,7 @@ class ReleaseRunStoreTests(unittest.TestCase): listed = self.store.list(limit=1) self.assertEqual([local_run["run_id"]], [item["run_id"] for item in listed]) - def test_list_orders_verified_runs_by_updated_timestamp_before_unavailable( + def test_list_orders_verified_runs_by_creation_timestamp_before_unavailable( self, ) -> None: with patch( @@ -786,11 +921,11 @@ class ReleaseRunStoreTests(unittest.TestCase): listed = self.store.list(limit=3) self.assertEqual( - [older["run_id"], newer["run_id"], corrupt["run_id"]], + [newer["run_id"], older["run_id"], corrupt["run_id"]], [item["run_id"] for item in listed], ) self.assertEqual("unavailable", listed[-1]["status"]) - self.assertEqual(older["run_id"], self.store.list(limit=1)[0]["run_id"]) + self.assertEqual(newer["run_id"], self.store.list(limit=1)[0]["run_id"]) def test_cursor_pagination_is_stable_and_keeps_unavailable_records_visible( self, @@ -822,6 +957,43 @@ class ReleaseRunStoreTests(unittest.TestCase): with self.assertRaisesRegex(ReleaseRunConflict, "cursor is invalid"): self.store.list_page(limit=1, cursor=f"{first['next_cursor']}tampered") + def test_cursor_does_not_skip_or_repeat_when_an_older_run_is_updated( + self, + ) -> None: + created: list[dict[str, object]] = [] + for index in range(3): + with patch( + "govoplan_release.release_run._timestamp", + return_value=f"2026-07-22T0{index}:00:00Z", + ): + created.append( + create_run( + self.store, + request_id=f"cursor-update-create-request-{index:04d}", + ) + ) + first = self.store.list_page(limit=1) + with patch( + "govoplan_release.release_run._timestamp", + return_value="2026-07-22T12:00:00Z", + ): + self.store.start_step( + created[0]["run_id"], + "core:preflight", + attempt_id="cursor-update-attempt-request-0001", + ) + second = self.store.list_page(limit=1, cursor=first["next_cursor"]) + third = self.store.list_page(limit=1, cursor=second["next_cursor"]) + + self.assertEqual( + [item["run_id"] for item in reversed(created)], + [ + first["runs"][0]["run_id"], + second["runs"][0]["run_id"], + third["runs"][0]["run_id"], + ], + ) + def test_retention_prunes_only_oldest_completed_records_and_fails_closed( self, ) -> None: @@ -963,6 +1135,22 @@ class ReleaseRunStoreTests(unittest.TestCase): class ReleaseRunApiTests(unittest.TestCase): + def setUp(self) -> None: + self.runtime_trust = patch("server.app.require_trusted_release_runtime") + self.runtime_binding = patch("server.app.verify_release_runtime_binding") + self.source_bindings = patch( + "server.app.bind_plan_source_states", + side_effect=bind_test_plan_source_states, + ) + self.runtime_trust.start() + self.runtime_binding.start() + self.source_bindings.start() + + def tearDown(self) -> None: + self.source_bindings.stop() + self.runtime_binding.stop() + self.runtime_trust.stop() + def test_api_exposes_and_validates_cursor_pagination(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: app = create_app( @@ -1008,6 +1196,338 @@ class ReleaseRunApiTests(unittest.TestCase): self.assertIsNone(second["next_cursor"]) self.assertEqual(409, invalid.status_code) + def test_executor_claims_before_effect_and_replays_lost_success_response( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + app = create_app( + workspace_root=Path(temp_dir), + run_state_root=Path(temp_dir) / "runs", + candidate_root=Path(temp_dir) / "candidates", + token="token", + ) + headers = {"X-Release-Console-Token": "token"} + with ( + patch("server.app.build_dashboard", return_value=object()), + patch( + "server.app.build_selective_release_plan", + return_value=release_plan(), + ), + patch( + "server.app.bind_plan_source_states", + side_effect=bind_test_plan_source_states, + ), + patch( + "server.app.verify_repository_preflight_binding", + return_value=repository_receipt(), + ), + patch( + "server.app.execute_repository_step", + return_value=({"status": "inspected"}, repository_receipt()), + ) as executor, + TestClient(app) as client, + ): + created = client.post( + "/api/release-runs", + headers=headers, + json=api_run_input(request_id="api-executor-create-0001"), + ).json() + run_id = created["run_id"] + self.assertTrue(created["state"]["steps"][0]["executor"]["available"]) + self.assertEqual( + "preflight", created["state"]["steps"][0]["executor"]["kind"] + ) + payload = {"request_id": "api-executor-attempt-0001"} + executed = client.post( + f"/api/release-runs/{run_id}/steps/core:preflight/execute", + headers=headers, + json=payload, + ) + replayed = client.post( + f"/api/release-runs/{run_id}/steps/core:preflight/execute", + headers=headers, + json=payload, + ) + + self.assertEqual(200, executed.status_code) + self.assertEqual("succeeded", executed.json()["state"]["steps"][0]["state"]) + self.assertEqual("replayed", replayed.json()["execution_result"]["status"]) + executor.assert_called_once() + + def test_lost_finish_write_interrupts_without_reexecuting_effect(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + app = create_app( + workspace_root=Path(temp_dir), + run_state_root=Path(temp_dir) / "runs", + token="token", + ) + headers = {"X-Release-Console-Token": "token"} + with ( + patch("server.app.build_dashboard", return_value=object()), + patch( + "server.app.build_selective_release_plan", + return_value=release_plan(), + ), + patch( + "server.app.bind_plan_source_states", + side_effect=bind_test_plan_source_states, + ), + patch( + "server.app.verify_repository_preflight_binding", + return_value=repository_receipt(), + ), + patch( + "server.app.execute_repository_step", + return_value=({"status": "inspected"}, repository_receipt()), + ) as executor, + TestClient(app) as client, + ): + run_id = client.post( + "/api/release-runs", + headers=headers, + json=api_run_input(request_id="api-lost-finish-create-0001"), + ).json()["run_id"] + with patch.object( + app.state.release_runs, + "finish_step", + side_effect=ReleaseRunCorrupt("simulated durable write failure"), + ): + first = client.post( + f"/api/release-runs/{run_id}/steps/core:preflight/execute", + headers=headers, + json={"request_id": "api-lost-finish-attempt-0001"}, + ) + replay = client.post( + f"/api/release-runs/{run_id}/steps/core:preflight/execute", + headers=headers, + json={"request_id": "api-lost-finish-attempt-0001"}, + ) + loaded = client.get( + f"/api/release-runs/{run_id}", headers=headers + ).json() + + self.assertEqual(409, first.status_code) + self.assertEqual(409, replay.status_code) + self.assertEqual("interrupted", loaded["state"]["steps"][0]["state"]) + executor.assert_called_once() + + def test_executor_exception_is_interrupted_and_never_retried_implicitly(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + app = create_app( + workspace_root=Path(temp_dir), + run_state_root=Path(temp_dir) / "runs", + token="token", + ) + headers = {"X-Release-Console-Token": "token"} + with ( + patch("server.app.build_dashboard", return_value=object()), + patch( + "server.app.build_selective_release_plan", + return_value=mutating_first_plan(), + ), + patch( + "server.app.bind_plan_source_states", + side_effect=bind_test_plan_source_states, + ), + patch("server.app.verify_repository_step_precondition"), + patch( + "server.app.execute_repository_step", + side_effect=RuntimeError("connection lost after push"), + ) as executor, + TestClient(app) as client, + ): + run_id = client.post( + "/api/release-runs", + headers=headers, + json=api_run_input(request_id="api-ambiguous-create-0001"), + ).json()["run_id"] + payload = { + "request_id": "api-ambiguous-attempt-0001", + "confirm": "TAG", + } + first = client.post( + f"/api/release-runs/{run_id}/steps/core:tag/execute", + headers=headers, + json=payload, + ) + second = client.post( + f"/api/release-runs/{run_id}/steps/core:tag/execute", + headers=headers, + json=payload, + ) + loaded = client.get( + f"/api/release-runs/{run_id}", headers=headers + ).json() + + self.assertEqual(409, first.status_code) + self.assertEqual(409, second.status_code) + self.assertEqual("interrupted", loaded["state"]["steps"][0]["state"]) + self.assertEqual( + "executor_outcome_unknown", + loaded["state"]["steps"][0]["result_code"], + ) + executor.assert_called_once() + + def test_catalog_execution_persists_and_consumes_only_issued_receipt(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + candidate_root = Path(temp_dir) / "candidates" + app = create_app( + workspace_root=Path(temp_dir), + run_state_root=Path(temp_dir) / "runs", + candidate_root=candidate_root, + token="token", + ) + headers = {"X-Release-Console-Token": "token"} + + def generated(**kwargs: object) -> tuple[dict[str, object], CandidateArtifactReceipt]: + candidate_id = str(kwargs["candidate_id"]) + candidate = candidate_output_path(candidate_root, candidate_id) + channels = candidate / "channels" + channels.mkdir(parents=True) + (channels / "stable.json").write_text( + json.dumps({"channel": "stable", "signatures": [{}]}), + encoding="utf-8", + ) + harden_private_candidate_tree(candidate) + return ( + {"status": "ready"}, + issue_candidate_receipt( + root=candidate_root, + candidate_id=candidate_id, + channel="stable", + ), + ) + + with ( + patch("server.app.build_dashboard", return_value=object()), + patch( + "server.app.build_selective_release_plan", + return_value=catalog_release_plan(), + ), + patch( + "server.app.bind_plan_source_states", + side_effect=bind_test_plan_source_states, + ), + patch( + "server.app.verify_catalog_publication_precondition", + return_value=repository_receipt( + repo="addideas-govoplan-website", target_tag="" + ), + ), + patch("server.app.default_signing_keys", return_value=("key=/private",)), + patch( + "server.app.generate_catalog_candidate", side_effect=generated + ) as generator, + patch( + "server.app.publish_received_candidate", + side_effect=published_candidate_result, + ) as publisher, + TestClient(app) as client, + ): + run_id = client.post( + "/api/release-runs", + headers=headers, + json=api_run_input(request_id="api-candidate-create-0001"), + ).json()["run_id"] + generated_response = client.post( + f"/api/release-runs/{run_id}/steps/catalog:selective-generator/execute", + headers=headers, + json={ + "request_id": "api-candidate-generate-0001", + "confirm": "GENERATE", + }, + ) + with patch("server.app.default_signing_keys", return_value=()): + replayed_generation = client.post( + f"/api/release-runs/{run_id}/steps/catalog:selective-generator/execute", + headers=headers, + json={ + "request_id": "api-candidate-generate-0001", + "confirm": "GENERATE", + }, + ) + published = client.post( + f"/api/release-runs/{run_id}/steps/catalog:validate-sign-publish/execute", + headers=headers, + json={ + "request_id": "api-candidate-publish-0001", + "confirm": "PUSH", + }, + ) + + receipt = generated_response.json()["state"]["steps"][0]["result_receipt"] + self.assertRegex(receipt["candidate_id"], r"^candidate-[0-9a-f]{32}$") + self.assertEqual(64, len(receipt["catalog_sha256"])) + self.assertEqual(200, replayed_generation.status_code) + self.assertEqual( + "replayed", + replayed_generation.json()["execution_result"]["status"], + ) + generator.assert_called_once() + self.assertEqual(200, published.status_code) + self.assertEqual("completed", published.json()["state"]["status"]) + publisher.assert_called_once_with( + candidate_path=candidate_root / receipt["candidate_id"], + candidate_receipt=receipt, + channel="stable", + workspace_root=Path(temp_dir), + remote="origin", + expected_website_receipt=repository_receipt( + repo="addideas-govoplan-website", target_tag="" + ), + ) + + def test_legacy_catalog_endpoint_is_preview_only(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + app = create_app(workspace_root=Path(temp_dir), token="token") + with TestClient(app) as client: + response = client.post( + "/api/catalog-candidates/publish", + headers={"X-Release-Console-Token": "token"}, + json={ + "candidate_dir": "/tmp/untrusted", + "apply": True, + "push": True, + "confirm": "PUSH", + }, + ) + self.assertEqual(409, response.status_code) + self.assertIn("durable release run", response.json()["detail"]) + + def test_release_channels_and_durable_remote_are_strictly_validated(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + app = create_app(workspace_root=Path(temp_dir), token="token") + headers = {"X-Release-Console-Token": "token"} + with ( + patch("server.app.build_dashboard") as dashboard, + TestClient(app) as client, + ): + query = client.get( + "/api/dashboard", headers=headers, params={"channel": "../stable"} + ) + created = client.post( + "/api/release-runs", + headers=headers, + json=api_run_input( + request_id="api-invalid-channel-create-0001", + channel="/absolute", + ), + ) + execute = client.post( + "/api/release-runs/not-a-run/steps/core:tag/execute", + headers=headers, + json={ + "request_id": "api-invalid-remote-attempt-0001", + "confirm": "TAG", + "remote": "upstream", + }, + ) + + self.assertEqual(422, query.status_code) + self.assertEqual(422, created.status_code) + self.assertEqual(422, execute.status_code) + dashboard.assert_not_called() + def test_token_guarded_create_list_read_resume_and_retry(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) / "runs" @@ -1205,6 +1725,10 @@ class ReleaseRunApiTests(unittest.TestCase): "server.app.build_selective_release_plan", return_value=mutating_first_plan(), ), + patch( + "server.app.reconciled_repository_receipt", + return_value=repository_receipt(), + ), TestClient(app) as client, ): created = client.post( @@ -1372,12 +1896,16 @@ class ReleaseRunApiTests(unittest.TestCase): ) self.assertIn("Durable Run State", webui) - self.assertIn("State tracking only.", webui) + self.assertIn("Durable execution.", webui) self.assertIn("Create Run from Selection", webui) self.assertIn("Prepare Retry", webui) self.assertIn("step.retry_available", webui) self.assertIn("Record Reconciliation", webui) self.assertIn("effect_succeeded", webui) + self.assertNotIn( + 'step.id === "catalog:validate-sign-publish" ? "disabled"', + webui, + ) self.assertIn("renderReleaseRunStoreUnavailable", webui) self.assertIn('const runsRequest = api("/api/release-runs")', webui) self.assertIn("pendingReleaseRunPayload", webui) @@ -1385,13 +1913,30 @@ class ReleaseRunApiTests(unittest.TestCase): self.assertIn("recoverPendingRunCommands", webui) self.assertIn("inFlightRunCommandId", webui) self.assertIn("Run saved; list refresh unavailable", webui) - self.assertIn("This foundation tracks state only.", runbook) + self.assertIn("The run record is execution evidence only", runbook) + self.assertIn("isolated checkout", runbook) self.assertIn("effect_absent", runbook) self.assertIn("$XDG_STATE_HOME", runbook) self.assertIn("same-directory", runbook) self.assertIn("caller-generated `request_id`", runbook) self.assertIn("reserves the two command-ledger", runbook) + def test_entire_release_console_script_is_valid_javascript(self) -> None: + node = shutil.which("node") + if node is None: + self.skipTest("Node.js is required for the Release Console UI check") + webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8") + match = re.search(r"", webui, re.DOTALL) + self.assertIsNotNone(match, "release console script element is missing") + result = subprocess.run( # noqa: S603 + [node, "--check", "-"], + input=match.group("script") if match is not None else "", + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(0, result.returncode, result.stderr) + def test_ui_reuses_uncertain_ids_and_separates_saved_run_list_failure( self, ) -> None: @@ -1493,6 +2038,16 @@ async function api(_path) { const reconcileRequest = pendingRunCommandRequest("reconcile:rr-known:core:tag:effect_succeeded", "reconcile-request"); assert(reconcileRequest.path.endsWith("/steps/core%3Atag/reconcile"), "reconciliation recovery path was not executable"); assert(reconcileRequest.body.confirm === "RECONCILE" && reconcileRequest.body.outcome === "effect_succeeded", "reconciliation recovery body changed"); + const executeKey = "execute:rr-known:catalog:selective-generator:GENERATE"; + const executeId = inFlightRunCommandId(executeKey, "execute", { signing_keys: ["release-key=/private/key"] }); + assert(!values.get(pendingRunCommandsKey).includes("signing_keys"), "signing key path was persisted in session storage"); + const executeRequest = pendingRunCommandRequest(executeKey, executeId); + assert(!Object.hasOwn(executeRequest.body, "signing_keys"), "execute replay reconstructed signing material"); + values.set(pendingRunCommandsKey, JSON.stringify({ [commandKey]: commandOne, [executeKey]: { request_id: executeId, body: { signing_keys: ["legacy-secret"] } } })); + const migratedCommands = readInFlightRunCommands(); + assert(migratedCommands[executeKey] === executeId, "legacy pending execute request ID was lost"); + assert(!values.get(pendingRunCommandsKey).includes("legacy-secret"), "legacy persisted signing material was not purged"); + clearInFlightRunCommand(executeKey, executeId); failCreate = true; const uncertainCommand = await recoverPendingRunCommands(); @@ -1591,6 +2146,64 @@ def run_input( } +def repository_receipt( + *, repo: str = "govoplan-core", target_tag: str = "v1.2.3" +) -> dict[str, object]: + return { + "kind": "repository_state", + "repo": repo, + "head": "a" * 40, + "branch": "main", + "remote": "origin", + "remote_sha256": "b" * 64, + "worktree_clean": True, + "target_tag": target_tag, + "tag_object": None, + } + + +def published_candidate_result( + **kwargs: object, +) -> tuple[dict[str, object], dict[str, object]]: + candidate = kwargs["candidate_receipt"] + website = kwargs["expected_website_receipt"] + if not isinstance(candidate, dict) or not isinstance(website, dict): + raise AssertionError("publication test receipts are unavailable") + receipt = { + "kind": "catalog_publication", + "candidate_id": candidate["candidate_id"], + "catalog_sha256": candidate["catalog_sha256"], + "keyring_sha256": "c" * 64, + "publication_commit_sha": "d" * 40, + "publication_tag_object_sha": "e" * 40, + "publication_tag_commit_sha": "d" * 40, + "branch": website["branch"], + "tag_name": "catalog-stable-7", + "remote": "origin", + "remote_sha256": website["remote_sha256"], + } + return {"status": "published"}, receipt + + +def bind_test_plan_source_states(**kwargs: object) -> dict[str, object]: + plan = kwargs["plan"] + if not isinstance(plan, dict): + raise AssertionError("test plan is not an object") + steps = plan.get("dry_run_steps") + if not isinstance(steps, list): + raise AssertionError("test plan has no steps") + for step in steps: + if not isinstance(step, dict): + continue + if isinstance(step.get("repo"), str): + step["source_binding"] = repository_receipt() + elif step.get("id") == "catalog:validate-sign-publish": + step["source_binding"] = repository_receipt( + repo="addideas-govoplan-website", target_tag="" + ) + return plan + + def release_plan() -> dict[str, object]: return { "generated_at": "2026-07-22T00:00:00Z", @@ -1641,5 +2254,32 @@ def mutating_first_plan() -> dict[str, object]: return plan +def catalog_release_plan() -> dict[str, object]: + plan = release_plan() + plan["dry_run_steps"] = [ + { + "id": "catalog:selective-generator", + "title": "Generate candidate", + "detail": "Build artifacts and sign a candidate.", + "command": "release-catalog selective", + "cwd": "/workspace/govoplan", + "mutating": True, + "repo": None, + "status": "planned", + }, + { + "id": "catalog:validate-sign-publish", + "title": "Publish candidate", + "detail": "Publish only the receipt-bound candidate.", + "command": "release-catalog publish-candidate", + "cwd": "/workspace/govoplan", + "mutating": True, + "repo": None, + "status": "planned", + }, + ] + return plan + + if __name__ == "__main__": unittest.main() diff --git a/tools/release/govoplan_release/release_run.py b/tools/release/govoplan_release/release_run.py index f252931..f547062 100644 --- a/tools/release/govoplan_release/release_run.py +++ b/tools/release/govoplan_release/release_run.py @@ -1,9 +1,8 @@ -"""Durable state for guided, explicitly executed release runs. +"""Durable state and bounded receipts for explicitly executed release runs. -The run store records an immutable selective-plan snapshot and a small mutable -state machine. It does not execute release commands. Existing release -executors remain responsible for their narrow confirmations and will be wired -to this state in a later slice. +The store freezes a selective plan, claims each supported attempt before its +executor is called, and records only bounded outcomes. Narrow executors remain +separate from this persistence module. """ from __future__ import annotations @@ -11,6 +10,7 @@ from __future__ import annotations import base64 import binascii from copy import deepcopy +from dataclasses import dataclass from datetime import UTC, datetime import hashlib import json @@ -24,9 +24,12 @@ from typing import Any from filelock import FileLock, Timeout as FileLockTimeout +from .candidate_artifact import CandidateArtifactError, validate_release_channel + SCHEMA = "govoplan.release-run" -SCHEMA_VERSION = 3 +SCHEMA_VERSION = 4 +LEGACY_SCHEMA_VERSIONS = {3} MAX_RECORD_BYTES = 2 * 1024 * 1024 MAX_PLAN_STEPS = 512 MAX_REPOSITORY_VERSIONS = 128 @@ -40,7 +43,6 @@ RUN_ID_PATTERN = re.compile( ) STEP_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$") REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{7,127}$") -CHANNEL_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") WORKSPACE_FINGERPRINT_PATTERN = re.compile(r"^[0-9a-f]{64}$") VERSION_PATTERN = re.compile( @@ -65,6 +67,13 @@ RECONCILIATION_CONFIRMATION = "RECONCILE" START_RECOVERY_RESERVE = 2 PROJECTION_TIMESTAMP = "9999-12-31T23:59:59Z" PROJECTION_RESULT_CODE = "r" * 64 +RECEIPT_KINDS = { + "catalog_candidate", + "catalog_publication", + "repository_state", +} +RECEIPT_ID_PATTERN = re.compile(r"^candidate-[0-9a-f]{32}$") +GIT_OBJECT_PATTERN = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") class ReleaseRunError(RuntimeError): @@ -87,6 +96,17 @@ class ReleaseRunCorrupt(ReleaseRunError): """Raised when a persisted record fails its integrity or schema checks.""" +@dataclass(frozen=True, slots=True) +class ReleaseStepClaim: + """Private executor claim result; attempt identifiers remain fingerprinted.""" + + run: dict[str, Any] + claimed: bool + outcome: str + result_code: str | None + result_receipt: dict[str, Any] | None + + class ReleaseRunStore: """Atomic, bounded local JSON store for release-run state.""" @@ -135,6 +155,7 @@ class ReleaseRunStore: "started_at": None, "finished_at": None, "result_code": None, + "result_receipt": None, } for item in immutable_plan["dry_run_steps"] ] @@ -251,7 +272,7 @@ class ReleaseRunStore: ( ( 1, - view["updated_at"], + view["created_at"], view["created_at"], view["run_id"], ), @@ -364,6 +385,7 @@ class ReleaseRunStore: "started_at": None, "finished_at": None, "result_code": None, + "result_receipt": None, } ) _remember_request( @@ -391,6 +413,7 @@ class ReleaseRunStore: request_id: str, outcome: str, confirmation: str, + result_receipt: dict[str, Any] | None = None, ) -> dict[str, Any]: """Record an operator-observed outcome for an interrupted mutation.""" @@ -401,12 +424,22 @@ class ReleaseRunStore: raise ReleaseRunConflict( f"Type {RECONCILIATION_CONFIRMATION} to record reconciliation." ) + receipt = _validated_result_receipt(result_receipt) + if receipt is not None and outcome != "effect_succeeded": + raise ReleaseRunConflict( + "Only a reconciled successful effect can carry a result receipt." + ) request_fingerprint = _request_fingerprint(request_id) with self._locked(): record = self._read(run_id) if _request_seen( record, request_fingerprint, "reconcile", step_id, outcome ): + step, _plan_step = _step_pair(record, step_id) + if step.get("result_receipt") != receipt: + raise ReleaseRunConflict( + "The reconciliation request already has a different result receipt." + ) return _view(record) step, plan_step = _step_pair(record, step_id) if step["state"] != "interrupted" or not bool( @@ -438,6 +471,7 @@ class ReleaseRunStore: "started_at": None, "finished_at": None, "result_code": None, + "result_receipt": None, } ) elif outcome == "effect_succeeded": @@ -446,6 +480,7 @@ class ReleaseRunStore: "state": "succeeded", "finished_at": _timestamp(), "result_code": "reconciled_effect_succeeded", + "result_receipt": receipt, } ) _remember_request( @@ -472,10 +507,20 @@ class ReleaseRunStore: *, attempt_id: str, ) -> dict[str, Any]: - """Claim one step for a future confirmed executor. + """Claim one step while preserving the original store API.""" - This is deliberately not an HTTP endpoint in the foundation slice. - """ + return self.claim_step( + run_id, step_id, attempt_id=attempt_id + ).run + + def claim_step( + self, + run_id: str, + step_id: str, + *, + attempt_id: str, + ) -> ReleaseStepClaim: + """Durably claim a step and distinguish a delayed attempt replay.""" _validate_step_id(step_id) attempt_fingerprint = _request_fingerprint(attempt_id) @@ -487,7 +532,14 @@ class ReleaseRunStore: raise ReleaseRunConflict( "The attempt identifier was already used for another release step." ) - return _view(record) + step, _plan_step = _step_pair(record, step_id) + return ReleaseStepClaim( + run=_view(record), + claimed=False, + outcome=existing_attempt["outcome"], + result_code=existing_attempt["result_code"], + result_receipt=deepcopy(step.get("result_receipt")), + ) step, plan_step = _step_pair(record, step_id) available, reason = _step_available(record, step_id) if not available: @@ -502,6 +554,7 @@ class ReleaseRunStore: "started_at": _timestamp(), "finished_at": None, "result_code": None, + "result_receipt": None, } ) record["state"]["processed_attempts"].append( @@ -519,7 +572,13 @@ class ReleaseRunStore: mutating=bool(plan_step.get("mutating")), ) self._persist_update(record) - return _view(record) + return ReleaseStepClaim( + run=_view(record), + claimed=True, + outcome="running", + result_code=None, + result_receipt=None, + ) def finish_step( self, @@ -529,12 +588,16 @@ class ReleaseRunStore: attempt_id: str, succeeded: bool, result_code: str, + result_receipt: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Record a future executor outcome without persisting its output.""" + """Record a bounded executor outcome without persisting process output.""" _validate_step_id(step_id) attempt_fingerprint = _request_fingerprint(attempt_id) result_code = _safe_code(result_code) + receipt = _validated_result_receipt(result_receipt) + if receipt is not None and not succeeded: + raise ReleaseRunConflict("A failed release step cannot carry a result receipt.") target_state = "succeeded" if succeeded else "failed" with self._locked(): record = self._read(run_id) @@ -548,6 +611,7 @@ class ReleaseRunStore: if ( attempt["outcome"] == target_state and attempt["result_code"] == result_code + and step.get("result_receipt") == receipt ): return _view(record) raise ReleaseRunConflict( @@ -565,6 +629,7 @@ class ReleaseRunStore: "state": target_state, "finished_at": _timestamp(), "result_code": result_code, + "result_receipt": receipt, } ) attempt["outcome"] = target_state @@ -578,6 +643,76 @@ class ReleaseRunStore: self._persist_update(record) return _view(record) + def interrupt_step( + self, + run_id: str, + step_id: str, + *, + attempt_id: str, + result_code: str = "executor_outcome_unknown", + ) -> dict[str, Any]: + """Freeze an executor exception as an ambiguous, reconcilable attempt.""" + + _validate_step_id(step_id) + attempt_fingerprint = _request_fingerprint(attempt_id) + result_code = _safe_code(result_code) + if result_code != "executor_outcome_unknown": + raise ReleaseRunConflict("Executor interruption result code is invalid.") + with self._locked(): + record = self._read(run_id) + step, _plan_step = _step_pair(record, step_id) + attempt = _find_attempt(record, attempt_fingerprint) + if attempt is None or attempt["step_id"] != step_id: + raise ReleaseRunConflict( + "The release attempt identifier was not claimed for this step." + ) + if attempt["outcome"] == "interrupted": + if attempt["result_code"] == result_code: + return _view(record) + raise ReleaseRunConflict( + "The release attempt already has a different interruption outcome." + ) + if ( + attempt["outcome"] != "running" + or step["state"] != "running" + or step["attempt_fingerprint"] != attempt_fingerprint + ): + raise ReleaseRunConflict( + "The release step is not running with this attempt identifier." + ) + step.update( + { + "state": "interrupted", + "finished_at": _timestamp(), + "result_code": result_code, + "result_receipt": None, + } + ) + attempt["outcome"] = "interrupted" + attempt["result_code"] = result_code + _append_event( + record, + event_type="step_interrupted", + step_id=step_id, + result_code=result_code, + ) + self._persist_update(record) + return _view(record) + + def current_attempt_fingerprint(self, run_id: str, step_id: str) -> str: + """Return the opaque current fingerprint for server-side artifact recovery.""" + + _validate_step_id(step_id) + with self._locked(): + record = self._read(run_id) + step, _plan_step = _step_pair(record, step_id) + fingerprint = step.get("attempt_fingerprint") + if not isinstance(fingerprint, str): + raise ReleaseRunConflict( + "Release step has no current attempt to reconcile." + ) + return fingerprint + def _persist_update(self, record: dict[str, Any]) -> None: self._assert_workspace(record) record["updated_at"] = _timestamp() @@ -638,6 +773,9 @@ class ReleaseRunStore: ) from exc _validate_record(payload, expected_run_id=run_id) self._assert_workspace(payload) + if payload["schema_version"] in LEGACY_SCHEMA_VERSIONS: + payload = _upgrade_record(payload) + self._atomic_write(path, payload) return payload def _read_optional(self, run_id: str) -> dict[str, Any] | None: @@ -888,7 +1026,9 @@ def _validated_input(value: dict[str, Any]) -> dict[str, Any]: raise ReleaseRunConflict("Release run input fields are invalid.") channel = value.get("channel") repo_versions = value.get("repo_versions") - if not isinstance(channel, str) or not CHANNEL_PATTERN.fullmatch(channel): + try: + channel = validate_release_channel(channel) + except CandidateArtifactError: raise ReleaseRunConflict("Release channel is invalid.") if ( not isinstance(repo_versions, dict) @@ -924,7 +1064,7 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]: if not isinstance(value, dict): raise ReleaseRunConflict("Release plan snapshot must be an object.") copied = deepcopy(value) - if set(copied) != { + base_fields = { "generated_at", "target_channel", "status", @@ -935,15 +1075,32 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]: "gate_findings", "recommended_action", "source_preflight_ready", - }: + } + if set(copied) not in (base_fields, base_fields | {"runtime_binding"}): raise ReleaseRunConflict("Release plan snapshot fields are invalid.") + if "runtime_binding" in copied: + runtime_binding = copied["runtime_binding"] + validated_runtime = _validated_result_receipt(runtime_binding) + if ( + validated_runtime != runtime_binding + or runtime_binding.get("kind") != "repository_state" + or runtime_binding.get("repo") != "govoplan" + or runtime_binding.get("target_tag") != "" + or runtime_binding.get("tag_object") is not None + or runtime_binding.get("worktree_clean") is not True + ): + raise ReleaseRunConflict( + "Release plan runtime binding is invalid." + ) if not _is_timestamp(copied.get("generated_at")): raise ReleaseRunConflict("Release plan timestamp is invalid.") if copied.get("status") not in PLAN_STATUSES: raise ReleaseRunConflict("Release plan status is invalid.") - if not isinstance( - copied.get("target_channel"), str - ) or not CHANNEL_PATTERN.fullmatch(copied["target_channel"]): + try: + target_channel = validate_release_channel(copied.get("target_channel")) + except CandidateArtifactError: + raise ReleaseRunConflict("Release plan channel is invalid.") + if target_channel != copied["target_channel"]: raise ReleaseRunConflict("Release plan channel is invalid.") if not isinstance(copied.get("units"), list): raise ReleaseRunConflict("Release plan units are invalid.") @@ -969,7 +1126,7 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]: raise ReleaseRunConflict("Release plan step collection is invalid.") seen: set[str] = set() for step in steps: - if not isinstance(step, dict) or set(step) != { + if not isinstance(step, dict) or set(step) not in ({ "id", "title", "detail", @@ -978,7 +1135,17 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]: "mutating", "repo", "status", - }: + }, { + "id", + "title", + "detail", + "command", + "cwd", + "mutating", + "repo", + "status", + "source_binding", + }): raise ReleaseRunConflict("Release plan step is invalid.") step_id = step.get("id") _validate_step_id(step_id) @@ -997,6 +1164,21 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]: not isinstance(step[field], str) or len(step[field]) > 8_192 ): raise ReleaseRunConflict("Release plan step metadata is invalid.") + if "source_binding" in step: + binding = step["source_binding"] + if binding is not None: + validated_binding = _validated_result_receipt(binding) + if ( + validated_binding != binding + or binding.get("kind") != "repository_state" + or ( + step.get("repo") is not None + and binding.get("repo") != step.get("repo") + ) + ): + raise ReleaseRunConflict( + "Release plan source binding is invalid." + ) encoded = _canonical_json(copied) if len(encoded) > MAX_RECORD_BYTES: raise ReleaseRunConflict("Release plan snapshot exceeds its size limit.") @@ -1022,9 +1204,11 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None: if ( value.get("schema") != SCHEMA or type(value.get("schema_version")) is not int - or value["schema_version"] != SCHEMA_VERSION + or value["schema_version"] + not in {SCHEMA_VERSION, *LEGACY_SCHEMA_VERSIONS} ): raise ValueError + schema_version = value["schema_version"] run_id = value.get("run_id") if not isinstance(run_id, str) or not RUN_ID_PATTERN.fullmatch(run_id): raise ValueError @@ -1084,18 +1268,20 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None: if not isinstance(steps, list) or len(steps) != len(plan_steps): raise ValueError for step, plan_step in zip(steps, plan_steps, strict=True): + expected_step_fields = { + "id", + "state", + "attempt_count", + "attempt_fingerprint", + "started_at", + "finished_at", + "result_code", + } + if schema_version >= 4: + expected_step_fields.add("result_receipt") if ( not isinstance(step, dict) - or set(step) - != { - "id", - "state", - "attempt_count", - "attempt_fingerprint", - "started_at", - "finished_at", - "result_code", - } + or set(step) != expected_step_fields or step.get("id") != plan_step["id"] ): raise ValueError @@ -1114,6 +1300,9 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None: result_code = step.get("result_code") if result_code is not None: _safe_code(result_code) + receipt = _validated_result_receipt(step.get("result_receipt")) + if receipt != step.get("result_receipt"): + raise ValueError _validate_step_state_fields(step) for timestamp in (step.get("started_at"), step.get("finished_at")): if timestamp is not None and not ( @@ -1221,7 +1410,8 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None: _safe_code(attempt_result) if ( attempt["outcome"] == "interrupted" - and attempt_result != "process_interrupted" + and attempt_result + not in {"process_interrupted", "executor_outcome_unknown"} ): raise ValueError if len({attempt["fingerprint"] for attempt in attempts}) != len(attempts): @@ -1550,6 +1740,7 @@ def _project_finish( "state": target_state, "finished_at": PROJECTION_TIMESTAMP, "result_code": PROJECTION_RESULT_CODE, + "result_receipt": _projection_receipt() if succeeded else None, } ) attempt["outcome"] = target_state @@ -1565,6 +1756,22 @@ def _project_finish( return projected +def _projection_receipt() -> dict[str, Any]: + return { + "kind": "catalog_publication", + "candidate_id": "candidate-" + "c" * 32, + "catalog_sha256": "c" * 64, + "keyring_sha256": "c" * 64, + "publication_commit_sha": "c" * 64, + "publication_tag_object_sha": "c" * 64, + "publication_tag_commit_sha": "c" * 64, + "branch": "b" * 255, + "tag_name": "t" * 160, + "remote": "origin", + "remote_sha256": "c" * 64, + } + + def _project_resume(record: dict[str, Any]) -> dict[str, Any]: projected = deepcopy(record) running_steps = [ @@ -1615,6 +1822,7 @@ def _project_retry(record: dict[str, Any], step_id: str) -> dict[str, Any]: "started_at": None, "finished_at": None, "result_code": None, + "result_receipt": None, } ) _remember_request( @@ -1650,6 +1858,7 @@ def _project_reconcile( "started_at": None, "finished_at": None, "result_code": None, + "result_receipt": None, } ) elif outcome == "effect_succeeded": @@ -1658,6 +1867,7 @@ def _project_reconcile( "state": "succeeded", "finished_at": PROJECTION_TIMESTAMP, "result_code": "reconciled_effect_succeeded", + "result_receipt": _projection_receipt(), } ) _remember_request( @@ -1944,6 +2154,118 @@ def _safe_code(value: str) -> str: return value +def _validated_result_receipt( + value: dict[str, Any] | None, +) -> dict[str, Any] | None: + if value is None: + return None + if not isinstance(value, dict) or value.get("kind") not in RECEIPT_KINDS: + raise ReleaseRunConflict("Release result receipt is invalid.") + if value["kind"] == "catalog_candidate": + if set(value) != {"kind", "candidate_id", "catalog_sha256"}: + raise ReleaseRunConflict("Release result receipt is invalid.") + candidate_id = value.get("candidate_id") + digest = value.get("catalog_sha256") + if ( + not isinstance(candidate_id, str) + or not RECEIPT_ID_PATTERN.fullmatch(candidate_id) + or not _is_sha256(digest) + ): + raise ReleaseRunConflict("Release result receipt identity is invalid.") + return dict(value) + if value["kind"] == "catalog_publication": + if set(value) != { + "kind", + "candidate_id", + "catalog_sha256", + "keyring_sha256", + "publication_commit_sha", + "publication_tag_object_sha", + "publication_tag_commit_sha", + "branch", + "tag_name", + "remote", + "remote_sha256", + }: + raise ReleaseRunConflict("Catalog publication receipt is invalid.") + candidate_id = value.get("candidate_id") + commit = value.get("publication_commit_sha") + tagged_commit = value.get("publication_tag_commit_sha") + if ( + not isinstance(candidate_id, str) + or not RECEIPT_ID_PATTERN.fullmatch(candidate_id) + or not _is_sha256(value.get("catalog_sha256")) + or not _is_sha256(value.get("keyring_sha256")) + or not isinstance(commit, str) + or not GIT_OBJECT_PATTERN.fullmatch(commit) + or not isinstance(value.get("publication_tag_object_sha"), str) + or not GIT_OBJECT_PATTERN.fullmatch( + value["publication_tag_object_sha"] + ) + or not isinstance(tagged_commit, str) + or not GIT_OBJECT_PATTERN.fullmatch(tagged_commit) + or tagged_commit != commit + or not isinstance(value.get("branch"), str) + or not value["branch"] + or len(value["branch"]) > 255 + or not isinstance(value.get("tag_name"), str) + or not value["tag_name"] + or len(value["tag_name"]) > 160 + or value.get("remote") != "origin" + or not _is_sha256(value.get("remote_sha256")) + ): + raise ReleaseRunConflict( + "Catalog publication receipt identity is invalid." + ) + return dict(value) + if set(value) != { + "kind", + "repo", + "head", + "branch", + "remote", + "remote_sha256", + "worktree_clean", + "target_tag", + "tag_object", + }: + raise ReleaseRunConflict("Repository result receipt is invalid.") + if ( + not isinstance(value.get("repo"), str) + or not REPOSITORY_PATTERN.fullmatch(value["repo"]) + or not isinstance(value.get("head"), str) + or not GIT_OBJECT_PATTERN.fullmatch(value["head"]) + or not isinstance(value.get("branch"), str) + or not value["branch"] + or len(value["branch"]) > 255 + or value.get("remote") != "origin" + or not _is_sha256(value.get("remote_sha256")) + or type(value.get("worktree_clean")) is not bool + or not isinstance(value.get("target_tag"), str) + or len(value["target_tag"]) > 160 + or ( + value.get("tag_object") is not None + and ( + not isinstance(value["tag_object"], str) + or not GIT_OBJECT_PATTERN.fullmatch(value["tag_object"]) + ) + ) + ): + raise ReleaseRunConflict("Repository result receipt identity is invalid.") + return dict(value) + + +def _upgrade_record(record: dict[str, Any]) -> dict[str, Any]: + upgraded = deepcopy(record) + if upgraded.get("schema_version") == 3: + for step in upgraded["state"]["steps"]: + step["result_receipt"] = None + upgraded["schema_version"] = SCHEMA_VERSION + upgraded["record_digest"] = _record_digest(upgraded) + _validate_record(upgraded) + return upgraded + + def _is_sha256(value: Any) -> bool: return isinstance(value, str) and bool(re.fullmatch(r"[0-9a-f]{64}", value)) @@ -1967,6 +2289,7 @@ def _validate_step_state_fields(step: dict[str, Any]) -> None: started_at = step["started_at"] finished_at = step["finished_at"] result_code = step["result_code"] + result_receipt = step.get("result_receipt") if started_at is not None and not _is_timestamp(started_at): raise ValueError if finished_at is not None and not _is_timestamp(finished_at): @@ -1976,19 +2299,34 @@ def _validate_step_state_fields(step: dict[str, Any]) -> None: if state == "pending": if any( value is not None - for value in (fingerprint, started_at, finished_at, result_code) + for value in ( + fingerprint, + started_at, + finished_at, + result_code, + result_receipt, + ) ): raise ValueError return if attempt_count < 1 or fingerprint is None or started_at is None: raise ValueError if state == "running": - if finished_at is not None or result_code is not None: + if ( + finished_at is not None + or result_code is not None + or result_receipt is not None + ): raise ValueError return if finished_at is None or result_code is None: raise ValueError - if state == "interrupted" and result_code != "process_interrupted": + if state != "succeeded" and result_receipt is not None: + raise ValueError + if state == "interrupted" and result_code not in { + "process_interrupted", + "executor_outcome_unknown", + }: raise ValueError @@ -2014,7 +2352,8 @@ def _validate_event_fields(event: dict[str, Any]) -> None: raise ValueError if ( event_type == "step_interrupted" - and event["result_code"] != "process_interrupted" + and event["result_code"] + not in {"process_interrupted", "executor_outcome_unknown"} ): raise ValueError diff --git a/tools/release/govoplan_release/selective_planner.py b/tools/release/govoplan_release/selective_planner.py index 772883d..abac48a 100644 --- a/tools/release/govoplan_release/selective_planner.py +++ b/tools/release/govoplan_release/selective_planner.py @@ -501,6 +501,24 @@ def dry_run_steps( ) -> tuple[ReleasePlanStep, ...]: steps: list[ReleasePlanStep] = [] snapshots = {repo.spec.name: repo for repo in dashboard.repositories} + selected_names = {unit.repo for unit in units} + if "govoplan-core" in selected_names and len(selected_names) > 1: + steps.append( + ReleasePlanStep( + id="release:cross-repository-ordering", + title="Resolve module/Core release ordering", + detail=( + "A combined Core/module release needs a dependency-aware DAG: " + "local module tags, Core release-lock regeneration and commit, " + "Core tag, full alignment, then atomic pushes. The current linear " + "executor must not publish a module before that lock is committed." + ), + command=None, + cwd=dashboard.meta_root, + mutating=True, + status="needs-executor", + ) + ) for unit in units: steps.append( ReleasePlanStep( @@ -618,6 +636,7 @@ def dry_run_steps( f'--candidate-dir "$CANDIDATE_DIR" --channel {shlex.quote(channel)}' ), cwd=dashboard.meta_root, + mutating=True, status="planned", ) ) diff --git a/tools/release/server/app.py b/tools/release/server/app.py index a47e353..1a31eb5 100644 --- a/tools/release/server/app.py +++ b/tools/release/server/app.py @@ -9,13 +9,12 @@ from typing import Literal from fastapi import FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from govoplan_release import ( build_dashboard, build_release_intelligence, build_release_plan, - build_selective_catalog_candidate, build_selective_release_plan, publish_catalog_candidate, prepare_repositories, @@ -24,13 +23,37 @@ from govoplan_release import ( tag_repositories, ) from govoplan_release.model import to_jsonable +from govoplan_release.candidate_artifact import ( + CandidateArtifactError, + issue_candidate_id, + validate_release_channel, + verify_candidate_receipt, +) +from govoplan_release.release_execution import ( + DURABLE_REMOTE, + ReleaseExecutionAmbiguous, + ReleaseExecutionBlocked, + bind_plan_source_states, + execute_repository_step, + executor_spec, + generate_catalog_candidate, + publish_received_candidate, + require_trusted_release_runtime, + reconciled_catalog_publication_receipt, + reconciled_repository_receipt, + validated_candidate_receipt, + verify_catalog_publication_precondition, + verify_repository_preflight_binding, + verify_repository_step_precondition, + verify_release_runtime_binding, +) from govoplan_release.release_run import ( ReleaseRunConflict, ReleaseRunCorrupt, ReleaseRunNotFound, ReleaseRunStore, ) -from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT +from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT, website_root class CatalogCandidateRequest(BaseModel): @@ -41,6 +64,7 @@ class CatalogCandidateRequest(BaseModel): signing_keys: list[str] = Field(default_factory=list) output_dir: str | None = None base_catalog: str | None = None + base_keyring: str | None = None expires_days: int = 90 sequence: int | None = None public_base_url: str = "https://govoplan.add-ideas.de" @@ -48,6 +72,11 @@ class CatalogCandidateRequest(BaseModel): source_remote: str = "origin" check_public: bool = True + @field_validator("channel") + @classmethod + def validate_channel(cls, value: str) -> str: + return validate_release_channel(value) + class PublishCandidateRequest(BaseModel): candidate_dir: str @@ -66,17 +95,22 @@ class PublishCandidateRequest(BaseModel): allow_dirty_website: bool = False confirm: str = "" + @field_validator("channel") + @classmethod + def validate_channel(cls, value: str) -> str: + return validate_release_channel(value) + class RepositoryPushRequest(BaseModel): repos: list[str] = Field(default_factory=list) - remote: str = "origin" + remote: Literal["origin"] = "origin" apply: bool = False # noqa: A003 - API field mirrors CLI. confirm: str = "" class RepositorySyncRequest(BaseModel): repos: list[str] = Field(default_factory=list) - remote: str = "origin" + remote: Literal["origin"] = "origin" apply: bool = False # noqa: A003 - API field mirrors CLI. confirm: str = "" @@ -108,6 +142,11 @@ class ReleaseRunCreateRequest(BaseModel): public_catalog: bool = True include_migrations: bool = False + @field_validator("channel") + @classmethod + def validate_channel(cls, value: str) -> str: + return validate_release_channel(value) + class ReleaseRunCommandRequest(BaseModel): request_id: str = Field(min_length=8, max_length=128) @@ -119,11 +158,23 @@ class ReleaseRunReconcileRequest(BaseModel): confirm: str = Field(default="", max_length=32) +class ReleaseRunExecuteRequest(BaseModel): + request_id: str = Field(min_length=8, max_length=128) + confirm: str = Field(default="", max_length=32) + remote: Literal["origin"] = "origin" + signing_keys: list[str] = Field(default_factory=list, max_length=8) + + +class ReleaseRunPreviewRequest(BaseModel): + remote: Literal["origin"] = "origin" + + def create_app( *, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | None = None, run_state_root: Path | None = None, + candidate_root: Path | None = None, ) -> FastAPI: app = FastAPI(title="GovOPlaN Release Console", version="0.1.0") app.state.workspace_root = workspace_root @@ -140,13 +191,31 @@ def create_app( release_run_root, expected_workspace_fingerprint=app.state.workspace_fingerprint, ) + app.state.release_candidate_root = ( + Path(os.path.abspath(os.fspath(candidate_root.expanduser()))) + if candidate_root is not None + else release_run_root.parent / "release-candidates" + ) @app.middleware("http") async def require_token(request: Request, call_next): # type: ignore[no-untyped-def] - if token and request.url.path.startswith("/api/"): - provided = request.headers.get("x-release-console-token") - if provided != token: - return JSONResponse({"detail": "release console token required"}, status_code=401) + if request.url.path.startswith("/api/"): + if token: + provided = request.headers.get("x-release-console-token") + if provided != token: + return JSONResponse( + {"detail": "release console token required"}, + status_code=401, + ) + elif request.method not in {"GET", "HEAD", "OPTIONS"}: + return JSONResponse( + { + "detail": ( + "release console is read-only without an API token" + ) + }, + status_code=403, + ) return await call_next(request) @app.get("/", response_class=HTMLResponse) @@ -174,6 +243,7 @@ def create_app( include_website: bool = False, channel: str = "stable", ) -> dict[str, object]: + channel = http_release_channel(channel) snapshot = build_dashboard( workspace_root=app.state.workspace_root, target_version=target_version, @@ -195,6 +265,7 @@ def create_app( include_migrations: bool = False, channel: str = "stable", ) -> dict[str, object]: + channel = http_release_channel(channel) snapshot = build_dashboard( workspace_root=app.state.workspace_root, target_version=target_version, @@ -212,6 +283,7 @@ def create_app( channel: str = "stable", public_catalog: bool = True, ) -> dict[str, object]: + channel = http_release_channel(channel) intelligence = build_release_intelligence( workspace_root=app.state.workspace_root, channel=channel, @@ -230,6 +302,7 @@ def create_app( include_migrations: bool = False, channel: str = "stable", ) -> dict[str, object]: + channel = http_release_channel(channel) snapshot = build_dashboard( workspace_root=app.state.workspace_root, target_version=target_version, @@ -280,7 +353,13 @@ def create_app( except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc if existing is not None: - return existing + return release_run_view(existing) + try: + require_trusted_release_runtime( + workspace_root=app.state.workspace_root + ) + except ReleaseExecutionBlocked as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc snapshot = build_dashboard( workspace_root=app.state.workspace_root, online=request.online, @@ -319,18 +398,29 @@ def create_app( ), ) try: - return app.state.release_runs.create( - request_id=request.request_id, - input_snapshot=input_snapshot, - plan_snapshot=plan_payload, + plan_payload = bind_plan_source_states( + plan=plan_payload, + repo_versions=dict(request.repo_versions), + workspace_root=app.state.workspace_root, ) - except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: + return release_run_view( + app.state.release_runs.create( + request_id=request.request_id, + input_snapshot=input_snapshot, + plan_snapshot=plan_payload, + ) + ) + except ( + ReleaseExecutionBlocked, + ReleaseRunConflict, + ReleaseRunCorrupt, + ) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.get("/api/release-runs/{run_id}") def release_run(run_id: str) -> dict[str, object]: try: - return app.state.release_runs.get(run_id) + return release_run_view(app.state.release_runs.get(run_id)) except ReleaseRunNotFound as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: @@ -341,7 +431,11 @@ def create_app( run_id: str, request: ReleaseRunCommandRequest ) -> dict[str, object]: try: - return app.state.release_runs.resume(run_id, request_id=request.request_id) + return release_run_view( + app.state.release_runs.resume( + run_id, request_id=request.request_id + ) + ) except ReleaseRunNotFound as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: @@ -352,10 +446,12 @@ def create_app( run_id: str, step_id: str, request: ReleaseRunCommandRequest ) -> dict[str, object]: try: - return app.state.release_runs.retry_step( - run_id, - step_id, - request_id=request.request_id, + return release_run_view( + app.state.release_runs.retry_step( + run_id, + step_id, + request_id=request.request_id, + ) ) except ReleaseRunNotFound as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @@ -367,53 +463,442 @@ def create_app( run_id: str, step_id: str, request: ReleaseRunReconcileRequest ) -> dict[str, object]: try: - return app.state.release_runs.reconcile_step( - run_id, - step_id, - request_id=request.request_id, - outcome=request.outcome, - confirmation=request.confirm, + receipt = None + run = app.state.release_runs.get(run_id) + state_step = release_run_state_step(run, step_id) + immutable = run.get("immutable") + frozen_plan = ( + immutable.get("plan") if isinstance(immutable, dict) else None + ) + verify_release_runtime_binding( + expected=( + frozen_plan.get("runtime_binding") + if isinstance(frozen_plan, dict) + else None + ), + workspace_root=app.state.workspace_root, + ) + if ( + request.outcome == "effect_succeeded" + and state_step.get("state") == "succeeded" + and state_step.get("result_code") == "reconciled_effect_succeeded" + ): + receipt = state_step.get("result_receipt") + if ( + request.outcome == "effect_succeeded" + and step_id == "catalog:selective-generator" + and state_step.get("state") == "interrupted" + ): + attempt_fingerprint = ( + app.state.release_runs.current_attempt_fingerprint( + run_id, step_id + ) + ) + candidate_id = issue_candidate_id( + run_id, step_id, attempt_fingerprint + ) + candidate_receipt = validated_candidate_receipt( + candidate_root=app.state.release_candidate_root, + candidate_id=candidate_id, + channel=app.state.release_runs.get(run_id)["immutable"]["input"][ + "channel" + ], + ) + receipt = { + "kind": "catalog_candidate", + "candidate_id": candidate_receipt.candidate_id, + "catalog_sha256": candidate_receipt.catalog_sha256, + } + elif ( + request.outcome == "effect_succeeded" + and step_id == "catalog:validate-sign-publish" + and state_step.get("state") == "interrupted" + ): + plan_step = release_run_plan_step(run, step_id) + candidate_receipt = release_run_candidate_receipt(run) + receipt = reconciled_catalog_publication_receipt( + candidate_path=verified_run_candidate( + run, + candidate_root=app.state.release_candidate_root, + ), + candidate_receipt=candidate_receipt, + channel=run["immutable"]["input"]["channel"], + workspace_root=app.state.workspace_root, + remote=DURABLE_REMOTE, + expected_website_receipt=plan_step.get("source_binding"), + ) + elif request.outcome == "effect_succeeded" and state_step.get( + "state" + ) == "interrupted": + plan_step = release_run_plan_step(run, step_id) + spec = executor_spec(plan_step) + if spec is not None and spec.kind in {"tag", "push"}: + repo = str(plan_step["repo"]) + receipt = reconciled_repository_receipt( + spec=spec, + plan_step=plan_step, + version=run["immutable"]["input"]["repo_versions"][repo], + workspace_root=app.state.workspace_root, + expected_receipt=preceding_repository_receipt( + run, step_id=step_id, repo=repo + ), + ) + return release_run_view( + app.state.release_runs.reconcile_step( + run_id, + step_id, + request_id=request.request_id, + outcome=request.outcome, + confirmation=request.confirm, + result_receipt=receipt, + ) ) except ReleaseRunNotFound as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc - except (ReleaseRunConflict, ReleaseRunCorrupt) as exc: + except ( + CandidateArtifactError, + ReleaseExecutionBlocked, + ReleaseRunConflict, + ReleaseRunCorrupt, + ) as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + @app.post("/api/release-runs/{run_id}/steps/{step_id}/execute") + def execute_release_run_step( + run_id: str, step_id: str, request: ReleaseRunExecuteRequest + ) -> dict[str, object]: + claim_acquired = False + try: + run = app.state.release_runs.get(run_id) + plan_step = release_run_plan_step(run, step_id) + immutable = run.get("immutable") + frozen_plan = ( + immutable.get("plan") if isinstance(immutable, dict) else None + ) + verify_release_runtime_binding( + expected=( + frozen_plan.get("runtime_binding") + if isinstance(frozen_plan, dict) + else None + ), + workspace_root=app.state.workspace_root, + ) + spec = executor_spec(plan_step) + if spec is None: + raise ReleaseRunConflict( + "This frozen plan step has no bounded durable executor." + ) + if request.confirm != spec.confirmation: + required = spec.confirmation or "an empty confirmation" + raise ReleaseRunConflict( + f"Release step requires exactly {required}." + ) + expected_repository_receipt = None + claim = app.state.release_runs.claim_step( + run_id, + step_id, + attempt_id=request.request_id, + ) + if not claim.claimed: + if claim.outcome == "succeeded": + replay = release_run_view(claim.run) + replay["execution_result"] = { + "status": "replayed", + "result_code": claim.result_code, + "result_receipt": claim.result_receipt, + } + return replay + if claim.outcome == "running": + raise ReleaseRunConflict( + "This exact executor attempt is still running or its response " + "is uncertain; resume and reconcile it before retrying." + ) + raise ReleaseRunConflict( + "This exact executor attempt already ended; use the run's explicit " + "retry or reconciliation action." + ) + claim_acquired = True + signing_keys = tuple(request.signing_keys or default_signing_keys()) + if spec.kind == "catalog_generate" and not signing_keys: + raise ReleaseExecutionBlocked( + "Durable catalog generation requires a configured signing key." + ) + + candidate_path = None + candidate_receipt = None + website_receipt = None + if spec.kind == "catalog_publish": + candidate_path = verified_run_candidate( + run, + candidate_root=app.state.release_candidate_root, + ) + candidate_receipt = release_run_candidate_receipt(run) + website_receipt = verify_catalog_publication_precondition( + plan_step=plan_step, + workspace_root=app.state.workspace_root, + ) + if spec.kind in {"preflight", "tag", "push"}: + repo = str(plan_step["repo"]) + version = run["immutable"]["input"]["repo_versions"][repo] + expected_repository_receipt = preceding_repository_receipt( + run, step_id=step_id, repo=repo + ) + if spec.kind == "preflight": + verify_repository_preflight_binding( + plan_step=plan_step, + version=version, + workspace_root=app.state.workspace_root, + ) + else: + verify_repository_step_precondition( + spec=spec, + plan_step=plan_step, + version=version, + workspace_root=app.state.workspace_root, + expected_receipt=expected_repository_receipt, + ) + + receipt = None + if spec.kind in {"preflight", "tag", "push"}: + result, receipt = execute_repository_step( + spec=spec, + plan_step=plan_step, + repo_versions=run["immutable"]["input"]["repo_versions"], + workspace_root=app.state.workspace_root, + remote=DURABLE_REMOTE, + expected_receipt=expected_repository_receipt, + ) + elif spec.kind == "catalog_generate": + attempt_fingerprint = ( + app.state.release_runs.current_attempt_fingerprint( + run_id, step_id + ) + ) + candidate_id = issue_candidate_id( + run_id, step_id, attempt_fingerprint + ) + base_catalog, base_keyring = release_catalog_base_paths( + app.state.workspace_root, + channel=run["immutable"]["input"]["channel"], + ) + result, candidate_receipt = generate_catalog_candidate( + candidate_root=app.state.release_candidate_root, + candidate_id=candidate_id, + channel=run["immutable"]["input"]["channel"], + repo_versions=run["immutable"]["input"]["repo_versions"], + workspace_root=app.state.workspace_root, + signing_keys=signing_keys, + remote=DURABLE_REMOTE, + check_public=run["immutable"]["input"]["public_catalog"], + source_receipts={ + repo: receipt + for repo in run["immutable"]["input"]["repo_versions"] + if isinstance( + ( + receipt := preceding_repository_receipt( + run, step_id=step_id, repo=repo + ) + ), + dict, + ) + }, + base_catalog=base_catalog, + base_keyring=base_keyring, + ) + receipt = { + "kind": "catalog_candidate", + "candidate_id": candidate_receipt.candidate_id, + "catalog_sha256": candidate_receipt.catalog_sha256, + } + elif ( + spec.kind == "catalog_publish" + and candidate_path is not None + and candidate_receipt is not None + and website_receipt is not None + ): + candidate_path = verified_run_candidate( + run, + candidate_root=app.state.release_candidate_root, + ) + result, receipt = publish_received_candidate( + candidate_path=candidate_path, + candidate_receipt=candidate_receipt, + channel=run["immutable"]["input"]["channel"], + workspace_root=app.state.workspace_root, + remote=DURABLE_REMOTE, + expected_website_receipt=website_receipt, + ) + try: + verified_run_candidate( + run, + candidate_root=app.state.release_candidate_root, + ) + except CandidateArtifactError as exc: + raise ReleaseExecutionAmbiguous( + "Published candidate changed while its effect was in flight; " + "reconcile the immutable website commit and remote tag." + ) from exc + else: + raise ReleaseRunConflict("Release executor mapping is incomplete.") + + finished = app.state.release_runs.finish_step( + run_id, + step_id, + attempt_id=request.request_id, + succeeded=True, + result_code=spec.result_code, + result_receipt=receipt, + ) + response = release_run_view(finished) + response["execution_result"] = { + "status": "succeeded", + "result_code": spec.result_code, + "result_receipt": receipt, + "output": result, + } + return response + except ReleaseExecutionBlocked as exc: + if not claim_acquired: + raise HTTPException(status_code=409, detail=str(exc)[:4000]) from exc + failed = app.state.release_runs.finish_step( + run_id, + step_id, + attempt_id=request.request_id, + succeeded=False, + result_code="executor_blocked", + ) + response = release_run_view(failed) + response["execution_result"] = { + "status": "failed", + "result_code": "executor_blocked", + "detail": str(exc)[:4000], + } + return response + except ReleaseExecutionAmbiguous as exc: + app.state.release_runs.interrupt_step( + run_id, + step_id, + attempt_id=request.request_id, + ) + raise HTTPException(status_code=409, detail=str(exc)[:4000]) from exc + except ReleaseRunNotFound as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except CandidateArtifactError as exc: + if claim_acquired: + failed = app.state.release_runs.finish_step( + run_id, + step_id, + attempt_id=request.request_id, + succeeded=False, + result_code="candidate_invalid", + ) + response = release_run_view(failed) + response["execution_result"] = { + "status": "failed", + "result_code": "candidate_invalid", + "detail": str(exc)[:4000], + } + return response + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ReleaseRunCorrupt as exc: + if claim_acquired: + try: + app.state.release_runs.interrupt_step( + run_id, + step_id, + attempt_id=request.request_id, + ) + except (ReleaseRunConflict, ReleaseRunCorrupt, ReleaseRunNotFound): + pass + raise HTTPException( + status_code=409, + detail=( + "The executor outcome could not be persisted safely; resume " + "and reconcile this attempt before retrying." + ), + ) from exc + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ReleaseRunConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 - never retry an uncertain effect. + try: + app.state.release_runs.interrupt_step( + run_id, + step_id, + attempt_id=request.request_id, + ) + except (ReleaseRunConflict, ReleaseRunCorrupt, ReleaseRunNotFound): + pass + raise HTTPException( + status_code=409, + detail=( + f"Executor raised {type(exc).__name__}; its effect is unknown. " + "Resume and reconcile before retry." + ), + ) from exc + + @app.post("/api/release-runs/{run_id}/steps/{step_id}/preview") + def preview_release_run_step( + run_id: str, step_id: str, request: ReleaseRunPreviewRequest + ) -> dict[str, object]: + try: + run = app.state.release_runs.get(run_id) + plan_step = release_run_plan_step(run, step_id) + spec = executor_spec(plan_step) + if spec is None or spec.kind != "catalog_publish": + raise ReleaseRunConflict( + "Only the receipt-bound catalog publication step has this preview." + ) + candidate = verified_run_candidate( + run, + candidate_root=app.state.release_candidate_root, + ) + return to_jsonable( + publish_catalog_candidate( + candidate_dir=candidate, + workspace_root=app.state.workspace_root, + channel=run["immutable"]["input"]["channel"], + remote=DURABLE_REMOTE, + source_remote=DURABLE_REMOTE, + apply=False, + ) + ) + except ReleaseRunNotFound as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ( + CandidateArtifactError, + ReleaseRunConflict, + ReleaseRunCorrupt, + ) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.get("/api/catalog-candidates") def catalog_candidates() -> dict[str, object]: - return {"candidates": list_catalog_candidates()} + return { + "candidates": list_catalog_candidates(app.state.release_candidate_root) + } @app.post("/api/catalog-candidates") def catalog_candidate(request: CatalogCandidateRequest) -> dict[str, object]: - repo_versions = dict(request.repo_versions) - if not repo_versions and request.repos and request.target_version: - repo_versions = {repo: request.target_version for repo in request.repos} - if not repo_versions: - raise HTTPException(status_code=400, detail="repo_versions or repos with target_version is required") - signing_keys = tuple(request.signing_keys or default_signing_keys()) - if not signing_keys: - raise HTTPException(status_code=400, detail="No signing key supplied and default release key was not found") - try: - candidate = build_selective_catalog_candidate( - repo_versions=repo_versions, - channel=request.channel, - workspace_root=app.state.workspace_root, - output_dir=request.output_dir, - base_catalog=request.base_catalog, - signing_keys=signing_keys, - public_base_url=request.public_base_url, - repository_base=request.repository_base, - source_remote=request.source_remote, - expires_days=request.expires_days, - sequence=request.sequence, - check_public=request.check_public, - ) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - return to_jsonable(candidate) + del request + raise HTTPException( + status_code=409, + detail=( + "Signed catalog generation is available only through a durable " + "release run with a trusted runtime and receipt-bound source tags." + ), + ) @app.post("/api/catalog-candidates/publish") def publish_candidate(request: PublishCandidateRequest) -> dict[str, object]: + if request.apply or request.commit or request.tag or request.push or request.build_web: + raise HTTPException( + status_code=409, + detail=( + "Catalog mutation is available only through a durable release run " + "with a server-issued candidate receipt." + ), + ) if request.push and request.confirm != "PUSH": raise HTTPException(status_code=400, detail="Push requires confirm=PUSH") if (request.apply or request.commit or request.tag or request.build_web) and not request.push and request.confirm != "APPLY": @@ -442,8 +927,14 @@ def create_app( repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip())) if not repos: raise HTTPException(status_code=400, detail="At least one repository must be selected") - if request.apply and request.confirm != "PUSH": - raise HTTPException(status_code=400, detail="Repository push requires confirm=PUSH") + if request.apply: + raise HTTPException( + status_code=409, + detail=( + "Repository push mutation is disabled outside a durable " + "receipt-bound maintenance run." + ), + ) result = push_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply) return to_jsonable(result) @@ -452,8 +943,14 @@ def create_app( repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip())) if not repos: raise HTTPException(status_code=400, detail="At least one repository must be selected") - if request.apply and request.confirm != "SYNC": - raise HTTPException(status_code=400, detail="Repository sync requires confirm=SYNC") + if request.apply: + raise HTTPException( + status_code=409, + detail=( + "Repository sync mutation is disabled outside a durable " + "receipt-bound maintenance run." + ), + ) result = sync_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply) return to_jsonable(result) @@ -462,6 +959,15 @@ def create_app( repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip())) if not repos: raise HTTPException(status_code=400, detail="At least one repository must be selected") + if request.apply: + raise HTTPException( + status_code=409, + detail=( + "Release preparation mutation is disabled outside a durable " + "release executor; use preview and commit reviewed changes " + "outside this console for now." + ), + ) if request.apply and request.confirm != "COMMIT": raise HTTPException(status_code=400, detail="Repository prepare requires confirm=COMMIT") result = prepare_repositories( @@ -478,6 +984,14 @@ def create_app( repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip())) if not repos: raise HTTPException(status_code=400, detail="At least one repository must be selected") + if request.apply: + raise HTTPException( + status_code=409, + detail=( + "Release tag mutation is available only through a durable " + "release run with creation-time repository bindings." + ), + ) if request.apply and request.push and request.confirm != "PUBLISH": raise HTTPException(status_code=400, detail="Release tag publication requires confirm=PUBLISH") if request.apply and not request.push and request.confirm != "TAG": @@ -496,6 +1010,164 @@ def create_app( return app +def release_run_view(run: dict[str, object]) -> dict[str, object]: + state = run.get("state") + immutable = run.get("immutable") + plan = immutable.get("plan") if isinstance(immutable, dict) else None + steps = state.get("steps", []) if isinstance(state, dict) else [] + plan_steps = plan.get("dry_run_steps", []) if isinstance(plan, dict) else [] + if isinstance(steps, list) and isinstance(plan_steps, list): + plan_by_id = { + step.get("id"): step for step in plan_steps if isinstance(step, dict) + } + for step in steps: + if not isinstance(step, dict): + continue + plan_step = plan_by_id.get(step.get("id")) + spec = executor_spec(plan_step) if isinstance(plan_step, dict) else None + step["executor"] = { + "available": spec is not None, + "kind": spec.kind if spec is not None else None, + "confirmation": spec.confirmation if spec is not None else None, + } + return run + + +def release_run_plan_step( + run: dict[str, object], step_id: str +) -> dict[str, object]: + immutable = run.get("immutable") + plan = immutable.get("plan") if isinstance(immutable, dict) else None + steps = plan.get("dry_run_steps") if isinstance(plan, dict) else None + if not isinstance(steps, list): + raise ReleaseRunCorrupt("Release run plan steps are unavailable.") + for step in steps: + if isinstance(step, dict) and step.get("id") == step_id: + return step + raise ReleaseRunNotFound("Release run step was not found.") + + +def release_run_state_step( + run: dict[str, object], step_id: str +) -> dict[str, object]: + state = run.get("state") + steps = state.get("steps") if isinstance(state, dict) else None + if not isinstance(steps, list): + raise ReleaseRunCorrupt("Release run state steps are unavailable.") + for step in steps: + if isinstance(step, dict) and step.get("id") == step_id: + return step + raise ReleaseRunNotFound("Release run step was not found.") + + +def preceding_repository_receipt( + run: dict[str, object], *, step_id: str, repo: str +) -> dict[str, object] | None: + immutable = run.get("immutable") + plan = immutable.get("plan") if isinstance(immutable, dict) else None + plan_steps = plan.get("dry_run_steps") if isinstance(plan, dict) else None + state = run.get("state") + state_steps = state.get("steps") if isinstance(state, dict) else None + if not isinstance(plan_steps, list) or not isinstance(state_steps, list): + raise ReleaseRunCorrupt("Release run repository receipt chain is unavailable.") + pairs = list(zip(plan_steps, state_steps, strict=True)) + current_index = next( + ( + index + for index, (planned, _mutable) in enumerate(pairs) + if isinstance(planned, dict) and planned.get("id") == step_id + ), + None, + ) + if current_index is None: + raise ReleaseRunNotFound("Release run step was not found.") + for planned, mutable in reversed(pairs[:current_index]): + if not isinstance(planned, dict) or planned.get("repo") != repo: + continue + if not isinstance(mutable, dict) or mutable.get("state") != "succeeded": + return None + receipt = mutable.get("result_receipt") + return ( + receipt + if isinstance(receipt, dict) + and receipt.get("kind") == "repository_state" + and receipt.get("repo") == repo + else None + ) + return None + + +def verified_run_candidate( + run: dict[str, object], *, candidate_root: Path +) -> Path: + receipt, channel = release_run_candidate_receipt(run, include_channel=True) + return verify_candidate_receipt( + root=candidate_root, + candidate_id=str(receipt.get("candidate_id") or ""), + catalog_sha256=str(receipt.get("catalog_sha256") or ""), + channel=channel, + ) + + +def release_run_candidate_receipt( + run: dict[str, object], *, include_channel: bool = False +) -> dict[str, object] | tuple[dict[str, object], str]: + state = run.get("state") + steps = state.get("steps") if isinstance(state, dict) else None + input_snapshot = ( + run.get("immutable", {}).get("input", {}) + if isinstance(run.get("immutable"), dict) + else {} + ) + channel = input_snapshot.get("channel") if isinstance(input_snapshot, dict) else None + if not isinstance(steps, list) or not isinstance(channel, str): + raise ReleaseRunCorrupt("Release run candidate dependency is unavailable.") + generator = next( + ( + step + for step in steps + if isinstance(step, dict) + and step.get("id") == "catalog:selective-generator" + ), + None, + ) + receipt = generator.get("result_receipt") if isinstance(generator, dict) else None + if ( + not isinstance(generator, dict) + or generator.get("state") != "succeeded" + or not isinstance(receipt, dict) + or receipt.get("kind") != "catalog_candidate" + ): + raise ReleaseRunConflict( + "Catalog publication requires the successful generator's durable receipt." + ) + copied = dict(receipt) + return (copied, channel) if include_channel else copied + + +def default_release_candidate_root(workspace_root: Path) -> Path: + return default_release_run_root(workspace_root).parent / "release-candidates" + + +def release_catalog_base_paths( + workspace_root: Path, *, channel: str +) -> tuple[Path, Path]: + """Resolve durable generation trust material only from the website checkout.""" + + catalog_root = website_root(workspace_root) / "public" / "catalogs" / "v1" + return ( + catalog_root / "channels" / f"{validate_release_channel(channel)}.json", + catalog_root / "keyring.json", + ) + + +def http_release_channel(value: str) -> str: + try: + return validate_release_channel(value) + except CandidateArtifactError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + def default_release_run_root(workspace_root: Path) -> Path: configured_state_home = os.environ.get("XDG_STATE_HOME", "").strip() state_home = Path(configured_state_home).expanduser() @@ -552,8 +1224,8 @@ def default_signing_keys() -> tuple[str, ...]: return (f"release-key-1={key_path}",) -def list_catalog_candidates() -> list[dict[str, object]]: - root = META_ROOT / "runtime" / "release-candidates" +def list_catalog_candidates(root: Path | None = None) -> list[dict[str, object]]: + root = root or (META_ROOT / "runtime" / "release-candidates") if not root.exists(): return [] candidates: list[dict[str, object]] = [] diff --git a/tools/release/webui/index.html b/tools/release/webui/index.html index 9b34002..51d1154 100644 --- a/tools/release/webui/index.html +++ b/tools/release/webui/index.html @@ -608,7 +608,7 @@
- + @@ -642,7 +642,7 @@ no run selected
-

State tracking only. 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.

+

Durable execution. 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.

@@ -678,7 +678,7 @@
-

Sync selected fetches remote refs and tags only. It does not merge, rebase, commit, tag, or push.

+

Repository sync and push are preview-only until they have a receipt-bound durable maintenance executor.

@@ -691,9 +691,9 @@
- + - +
@@ -705,7 +705,7 @@
-

Prepare commits selected dirty worktrees only. It does not tag or push.

+

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.

@@ -713,12 +713,12 @@
- +
- +
@@ -730,7 +730,7 @@ idle · plan idle
-

Creates an annotated source tag at the selected clean HEAD. Publish pushes the branch and tag atomically; existing release tags are never moved.

+

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.

@@ -738,13 +738,13 @@
- +
- - + +
@@ -764,18 +764,18 @@
- +
- +
- + - - + +
@@ -1047,23 +1047,23 @@ renderCatalog(data.catalog, data.target_version); } - function renderReleaseRunList(runs, options = {}) { + function renderReleaseRunList(runs, renderOptions = {}) { releaseState.runStoreAvailable = true; const incoming = Array.isArray(runs) ? runs : []; - releaseState.runs = options.append + releaseState.runs = renderOptions.append ? [...new Map([...releaseState.runs, ...incoming].map((run) => [run.run_id, run])).values()] : incoming; - releaseState.runNextCursor = options.nextCursor || null; + releaseState.runNextCursor = renderOptions.nextCursor || null; elements.releaseRun.disabled = false; elements.createReleaseRun.disabled = false; elements.loadOlderReleaseRuns.disabled = !releaseState.runNextCursor; const selectedId = releaseState.currentRun?.run_id || ""; - const options = releaseState.runs.map((run) => { + const runOptions = releaseState.runs.map((run) => { const repositories = run.repo_versions ? Object.keys(run.repo_versions).length : 0; const label = `${run.run_id} · ${run.status || "unknown"} · ${repositories} repos`; return ``; }).join(""); - elements.releaseRun.innerHTML = `${options}`; + elements.releaseRun.innerHTML = `${runOptions}`; if (selectedId && releaseState.runs.some((run) => run.run_id === selectedId)) { elements.releaseRun.value = selectedId; } @@ -1252,8 +1252,15 @@ const stepHtml = steps.map((step) => { const kind = step.state === "succeeded" ? "ok" : ["failed", "interrupted"].includes(step.state) ? "block" : step.available ? "ok" : "warn"; const retryReason = step.retry_available ? "Prepare this known failed or read-only interrupted step for an explicit retry." : (step.disabled_reason || "Retry is unavailable for this state."); + const executor = step.executor || {}; + const confirmation = executor.confirmation || ""; + const executeReason = executor.available ? (step.disabled_reason || "Execute this frozen plan step.") : "No bounded executor is available for this frozen step."; + const executorHtml = executor.available ? `
+ ${confirmation ? `
` : ""} +
${executor.kind === "catalog_publish" ? `` : ""}
+
` : `

No durable executor: ${escapeHtml(step.status || "unsupported")}

`; const reconciliationHtml = step.reconciliation_required ? `
-
+
` : ""; @@ -1261,6 +1268,8 @@

${escapeHtml(`${step.order}. ${step.title || step.id}`)}

${pill(step.state, kind)}

${escapeHtml(step.detail || "")}

${step.result_code ? `

Result code: ${escapeHtml(step.result_code)}

` : ""} + ${releaseReceiptHtml(step.result_receipt)} + ${executorHtml}
${reconciliationHtml} `; @@ -1268,7 +1277,7 @@ const eventHtml = events.length ? `

Recent bounded state events

${events.map((event) => `

${escapeHtml(`${event.at} · ${event.type}${event.step_id ? ` · ${event.step_id}` : ""}${event.result_code ? ` · ${event.result_code}` : ""}`)}

`).join("")}
` : ""; elements.runStatus.textContent = state.status || "unknown"; elements.resumeReleaseRun.disabled = state.status !== "running"; - elements.runOutput.innerHTML = `

${pill(state.status || "unknown", state.status === "blocked" ? "block" : state.status === "completed" ? "ok" : "warn")} ${escapeHtml(run.run_id)}

Frozen plan ${escapeHtml((run.immutable?.digest || "").slice(0, 16))}… · updated ${escapeHtml(run.updated_at || "-")}

Executor controls remain separate and keep their existing confirmations.

${recommendationHtml}${stepHtml}${eventHtml}`; + elements.runOutput.innerHTML = `

${pill(state.status || "unknown", state.status === "blocked" ? "block" : state.status === "completed" ? "ok" : "warn")} ${escapeHtml(run.run_id)}

Frozen plan ${escapeHtml((run.immutable?.digest || "").slice(0, 16))}… · updated ${escapeHtml(run.updated_at || "-")}

Every supported mutation is claimed durably before the existing narrow executor is called.

${recommendationHtml}${stepHtml}${eventHtml}`; for (const button of elements.runOutput.querySelectorAll("[data-run-retry-step]")) { button.addEventListener("click", () => retryReleaseRunStep(button.dataset.runRetryStep)); } @@ -1283,6 +1292,78 @@ confirmation.addEventListener("input", refresh); submit.addEventListener("click", () => reconcileReleaseRunStep(controls.dataset.runReconcileStep, outcome.value, confirmation.value.trim(), submit)); } + for (const controls of elements.runOutput.querySelectorAll("[data-run-execute-step]")) { + const required = controls.dataset.runExecuteConfirmation || ""; + const available = controls.dataset.runExecuteAvailable === "true"; + const input = controls.querySelector("[data-run-execute-confirm]"); + const submit = controls.querySelector("[data-run-execute-submit]"); + const preview = controls.querySelector("[data-run-preview-submit]"); + if (input) input.addEventListener("input", () => { + submit.disabled = !available || input.value.trim() !== required; + }); + submit.addEventListener("click", () => executeReleaseRunStep(controls.dataset.runExecuteStep, required, submit)); + if (preview) preview.addEventListener("click", () => previewReleaseRunStep(controls.dataset.runExecuteStep, preview)); + } + } + + async function executeReleaseRunStep(stepId, confirmation, button) { + const run = releaseState.currentRun; + if (!run || !stepId) return; + const commandKey = `execute:${run.run_id}:${stepId}:${confirmation || "NONE"}`; + const commandBody = { + confirm: confirmation, + remote: "origin", + signing_keys: elements.signingKey.value.trim() ? [elements.signingKey.value.trim()] : [], + }; + const commandRequestId = inFlightRunCommandId(commandKey, "execute"); + setBusy([button], true); + try { + const executed = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/steps/${encodeURIComponent(stepId)}/execute`, { + request_id: commandRequestId, + ...commandBody, + }); + clearInFlightRunCommand(commandKey, commandRequestId); + releaseState.currentRun = executed; + renderReleaseRun(executed); + const result = executed.execution_result || {}; + elements.runOutput.insertAdjacentHTML("afterbegin", `

${pill(result.status || "recorded", result.status === "failed" ? "block" : "ok")} Executor result

${escapeHtml(result.detail || result.result_code || "The bounded result was recorded.")}

`); + } catch (error) { + if (deterministicRunCommandError(error)) clearInFlightRunCommand(commandKey, commandRequestId); + elements.runOutput.insertAdjacentHTML("afterbegin", `

${pill("reconcile", "block")} Executor did not establish a safe result

${escapeHtml(error.message)}

`); + } 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", `

${pill(result.status || "preview", result.status === "blocked" ? "block" : "ok")} Receipt-bound publication preview

${escapeHtml((result.notes || []).join("; ") || "Preview completed without changing run state.")}

`); + } catch (error) { + elements.runOutput.insertAdjacentHTML("afterbegin", `

${pill("blocked", "block")} Preview failed

${escapeHtml(error.message)}

`); + } finally { + setBusy([button], false); + } + } + + function releaseReceiptHtml(receipt) { + if (!receipt || typeof receipt !== "object") return ""; + if (receipt.kind === "catalog_candidate") { + return `

Candidate ${escapeHtml(receipt.candidate_id || "-")} · ${escapeHtml((receipt.catalog_sha256 || "").slice(0, 16))}…

`; + } + if (receipt.kind === "catalog_publication") { + return `

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 || "-")}

`; + } + if (receipt.kind === "repository_state") { + const tag = receipt.tag_object ? ` · tag ${(receipt.tag_object || "").slice(0, 12)}` : ""; + return `

Bound ${escapeHtml(receipt.repo || "-")} · HEAD ${escapeHtml((receipt.head || "").slice(0, 12))}${escapeHtml(tag)} · ${receipt.worktree_clean ? "clean" : "dirty"}

`; + } + return ""; } async function resumeReleaseRun() { @@ -1346,7 +1427,17 @@ function readInFlightRunCommands() { try { const commands = JSON.parse(sessionStorage.getItem(pendingRunCommandsKey) || "{}"); - return commands && typeof commands === "object" && !Array.isArray(commands) ? commands : {}; + if (!commands || typeof commands !== "object" || Array.isArray(commands)) return {}; + const normalized = {}; + for (const [commandKey, savedCommand] of Object.entries(commands)) { + const requestIdToReplay = typeof savedCommand === "string" ? savedCommand : savedCommand?.request_id; + if (typeof requestIdToReplay === "string") normalized[commandKey] = requestIdToReplay; + } + if (JSON.stringify(normalized) !== JSON.stringify(commands)) { + if (Object.keys(normalized).length) sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(normalized)); + else sessionStorage.removeItem(pendingRunCommandsKey); + } + return normalized; } catch (_error) { sessionStorage.removeItem(pendingRunCommandsKey); return {}; @@ -1387,6 +1478,19 @@ body: { request_id: requestIdToReplay, outcome, confirm: "RECONCILE" }, }; } + if (commandKey.startsWith("execute:")) { + const remainder = commandKey.slice("execute:".length); + const runSeparator = remainder.indexOf(":"); + const confirmationSeparator = remainder.lastIndexOf(":"); + const runId = remainder.slice(0, runSeparator); + const stepId = remainder.slice(runSeparator + 1, confirmationSeparator); + const confirmation = remainder.slice(confirmationSeparator + 1); + if (runSeparator < 1 || confirmationSeparator <= runSeparator + 1 || !stepId) return null; + return { + path: `/api/release-runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepId)}/execute`, + body: { request_id: requestIdToReplay, confirm: confirmation === "NONE" ? "" : confirmation }, + }; + } return null; } @@ -1398,7 +1502,12 @@ let recoveredRun = null; let pendingError = null; let rejectedError = null; - for (const [commandKey, requestIdToReplay] of entries) { + for (const [commandKey, savedCommand] of entries) { + const requestIdToReplay = typeof savedCommand === "string" ? savedCommand : savedCommand?.request_id; + if (typeof requestIdToReplay !== "string") { + delete commands[commandKey]; + continue; + } const command = pendingRunCommandRequest(commandKey, requestIdToReplay); if (!command) { clearInFlightRunCommand(commandKey, requestIdToReplay); @@ -1438,6 +1547,7 @@ function inFlightRunCommandId(commandKey, prefix) { const commands = readInFlightRunCommands(); if (typeof commands[commandKey] === "string") return commands[commandKey]; + if (typeof commands[commandKey]?.request_id === "string") return commands[commandKey].request_id; const identifier = requestId(prefix); commands[commandKey] = identifier; sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands)); @@ -1446,7 +1556,8 @@ function clearInFlightRunCommand(commandKey, requestIdToClear) { const commands = readInFlightRunCommands(); - if (commands[commandKey] !== requestIdToClear) return; + const storedRequestId = typeof commands[commandKey] === "string" ? commands[commandKey] : commands[commandKey]?.request_id; + if (storedRequestId !== requestIdToClear) return; delete commands[commandKey]; if (Object.keys(commands).length) sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands)); else sessionStorage.removeItem(pendingRunCommandsKey); @@ -1493,14 +1604,14 @@ tagStatus, releaseBlockers ? "block" : selected ? "ok" : "warn", selected - ? `${selected} selected; use Preview Tag + Publish, then Create Tags or Publish Tags after reviewing the source-release preflight.` + ? `${selected} selected; preview here, then create a durable run and execute its enabled tag/push steps.` : "Select repositories, set target versions, then preview the source release tags." ), workflowStage( "4. Sign and publish website catalog", signedStatus, hasCandidate ? "ok" : "warn", - hasCandidate ? `Candidate: ${elements.candidateDir.value.trim()}` : "After every selected source tag is remotely published, generate a signed candidate, preview it, then apply/tag/push the website publication." + hasCandidate ? `Preview candidate: ${elements.candidateDir.value.trim()}` : "After every selected source tag is remotely published, use a durable run to generate and publish its receipt-bound candidate." ), workflowStage( "5. Maintain install catalog", @@ -2348,7 +2459,7 @@ function setBusy(buttons, busy) { for (const button of buttons) { - button.disabled = busy; + button.disabled = busy || button.dataset.releaseDisabled === "true"; } }