Execute receipt-bound durable release runs
This commit is contained in:
@@ -55,7 +55,8 @@ class ReleaseEntrypointGateTests(unittest.TestCase):
|
|||||||
self.assertIn("trusted_keys_from_keyring(\n target_keyring_payload", publisher)
|
self.assertIn("trusted_keys_from_keyring(\n target_keyring_payload", publisher)
|
||||||
self.assertIn("trusted_keys=publication_trust", publisher)
|
self.assertIn("trusted_keys=publication_trust", publisher)
|
||||||
self.assertNotIn("trusted_keys=candidate_keys", publisher)
|
self.assertNotIn("trusted_keys=candidate_keys", publisher)
|
||||||
self.assertIn("write_module_directory(", publisher)
|
self.assertIn("module_directory_payloads(", publisher)
|
||||||
|
self.assertIn("verify_committed_publication(", publisher)
|
||||||
self.assertNotIn("shutil.copytree(candidate_modules", publisher)
|
self.assertNotIn("shutil.copytree(candidate_modules", publisher)
|
||||||
|
|
||||||
def test_release_integration_honors_independent_module_versions(self) -> None:
|
def test_release_integration_honors_independent_module_versions(self) -> None:
|
||||||
|
|||||||
@@ -230,10 +230,14 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
|||||||
for repository in (self.repo, self.remote, campaign, campaign_remote):
|
for repository in (self.repo, self.remote, campaign, campaign_remote):
|
||||||
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
def test_api_requires_explicit_confirmation_and_ui_exposes_source_release(self) -> None:
|
def test_api_keeps_legacy_source_release_preview_only(self) -> None:
|
||||||
with TestClient(create_app(workspace_root=self.workspace)) as client:
|
with TestClient(
|
||||||
|
create_app(workspace_root=self.workspace, token="token")
|
||||||
|
) as client:
|
||||||
|
headers = {"X-Release-Console-Token": "token"}
|
||||||
rejected = client.post(
|
rejected = client.post(
|
||||||
"/api/repositories/tag",
|
"/api/repositories/tag",
|
||||||
|
headers=headers,
|
||||||
json={
|
json={
|
||||||
"repos": ["govoplan-core"],
|
"repos": ["govoplan-core"],
|
||||||
"repo_versions": {"govoplan-core": "0.1.10"},
|
"repo_versions": {"govoplan-core": "0.1.10"},
|
||||||
@@ -244,6 +248,7 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
preview = client.post(
|
preview = client.post(
|
||||||
"/api/repositories/tag",
|
"/api/repositories/tag",
|
||||||
|
headers=headers,
|
||||||
json={
|
json={
|
||||||
"repos": ["govoplan-core"],
|
"repos": ["govoplan-core"],
|
||||||
"repo_versions": {"govoplan-core": "0.1.10"},
|
"repo_versions": {"govoplan-core": "0.1.10"},
|
||||||
@@ -251,14 +256,47 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
|||||||
"push": True,
|
"push": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
legacy_push = client.post(
|
||||||
|
"/api/repositories/push",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"repos": ["govoplan-core"],
|
||||||
|
"apply": True,
|
||||||
|
"confirm": "PUSH",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
legacy_sync = client.post(
|
||||||
|
"/api/repositories/sync",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"repos": ["govoplan-core"],
|
||||||
|
"apply": True,
|
||||||
|
"confirm": "SYNC",
|
||||||
|
},
|
||||||
|
)
|
||||||
ui = client.get("/")
|
ui = client.get("/")
|
||||||
|
|
||||||
self.assertEqual(rejected.status_code, 400)
|
self.assertEqual(rejected.status_code, 409)
|
||||||
|
self.assertIn("durable release run", rejected.json()["detail"])
|
||||||
self.assertEqual(preview.status_code, 200)
|
self.assertEqual(preview.status_code, 200)
|
||||||
|
self.assertEqual(409, legacy_push.status_code)
|
||||||
|
self.assertEqual(409, legacy_sync.status_code)
|
||||||
|
self.assertIn("durable", legacy_push.json()["detail"])
|
||||||
|
self.assertIn("durable", legacy_sync.json()["detail"])
|
||||||
self.assertEqual(preview.json()["repositories"][0]["repo"], "govoplan-core")
|
self.assertEqual(preview.json()["repositories"][0]["repo"], "govoplan-core")
|
||||||
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10"))
|
||||||
self.assertIn('id="publishReleaseTags"', ui.text)
|
self.assertIn('id="publishReleaseTags"', ui.text)
|
||||||
|
self.assertIn(
|
||||||
|
'id="publishReleaseTags" data-release-disabled="true" disabled',
|
||||||
|
ui.text,
|
||||||
|
)
|
||||||
self.assertIn('postJson("/api/repositories/tag"', ui.text)
|
self.assertIn('postJson("/api/repositories/tag"', ui.text)
|
||||||
|
self.assertIn(
|
||||||
|
'id="syncRepos" disabled data-release-disabled="true"', ui.text
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'id="pushRepos" disabled data-release-disabled="true"', ui.text
|
||||||
|
)
|
||||||
self.assertIn("Signed Website Catalog", ui.text)
|
self.assertIn("Signed Website Catalog", ui.text)
|
||||||
self.assertIn("Apply + Website Tag", ui.text)
|
self.assertIn("Apply + Website Tag", ui.text)
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ from govoplan_release.release_run import ( # noqa: E402
|
|||||||
ReleaseRunStore,
|
ReleaseRunStore,
|
||||||
_record_digest,
|
_record_digest,
|
||||||
)
|
)
|
||||||
|
from govoplan_release.candidate_artifact import ( # noqa: E402
|
||||||
|
CandidateArtifactReceipt,
|
||||||
|
candidate_output_path,
|
||||||
|
harden_private_candidate_tree,
|
||||||
|
issue_candidate_receipt,
|
||||||
|
)
|
||||||
from server.app import ( # noqa: E402
|
from server.app import ( # noqa: E402
|
||||||
create_app,
|
create_app,
|
||||||
default_release_run_root,
|
default_release_run_root,
|
||||||
@@ -51,8 +57,24 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
self.root,
|
self.root,
|
||||||
expected_workspace_fingerprint=WORKSPACE_FINGERPRINT,
|
expected_workspace_fingerprint=WORKSPACE_FINGERPRINT,
|
||||||
)
|
)
|
||||||
|
self.runtime_trust = patch(
|
||||||
|
"server.app.require_trusted_release_runtime"
|
||||||
|
)
|
||||||
|
self.runtime_binding = patch(
|
||||||
|
"server.app.verify_release_runtime_binding"
|
||||||
|
)
|
||||||
|
self.source_bindings = patch(
|
||||||
|
"server.app.bind_plan_source_states",
|
||||||
|
side_effect=bind_test_plan_source_states,
|
||||||
|
)
|
||||||
|
self.runtime_trust.start()
|
||||||
|
self.runtime_binding.start()
|
||||||
|
self.source_bindings.start()
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
|
self.source_bindings.stop()
|
||||||
|
self.runtime_binding.stop()
|
||||||
|
self.runtime_trust.stop()
|
||||||
self.temporary.cleanup()
|
self.temporary.cleanup()
|
||||||
|
|
||||||
def test_create_persists_private_immutable_plan_and_ordered_steps(self) -> None:
|
def test_create_persists_private_immutable_plan_and_ordered_steps(self) -> None:
|
||||||
@@ -68,7 +90,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
record_path = self.root / f"{run['run_id']}.json"
|
record_path = self.root / f"{run['run_id']}.json"
|
||||||
|
|
||||||
self.assertEqual("govoplan.release-run", reopened["schema"])
|
self.assertEqual("govoplan.release-run", reopened["schema"])
|
||||||
self.assertEqual(3, reopened["schema_version"])
|
self.assertEqual(4, reopened["schema_version"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{"govoplan-core": "1.2.3", "govoplan-files": "1.2.4"},
|
{"govoplan-core": "1.2.3", "govoplan-files": "1.2.4"},
|
||||||
reopened["immutable"]["input"]["repo_versions"],
|
reopened["immutable"]["input"]["repo_versions"],
|
||||||
@@ -82,6 +104,119 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
self.assertNotIn("processed_requests", reopened["state"])
|
self.assertNotIn("processed_requests", reopened["state"])
|
||||||
self.assertNotIn("attempt_fingerprint", reopened["state"]["steps"][0])
|
self.assertNotIn("attempt_fingerprint", reopened["state"]["steps"][0])
|
||||||
|
|
||||||
|
def test_catalog_receipt_is_bounded_persisted_and_idempotent(self) -> None:
|
||||||
|
run_id = create_run(
|
||||||
|
self.store,
|
||||||
|
plan_snapshot=mutating_first_plan(),
|
||||||
|
request_id="receipt-create-request-0001",
|
||||||
|
)["run_id"]
|
||||||
|
attempt_id = "receipt-attempt-request-0001"
|
||||||
|
self.store.start_step(run_id, "core:tag", attempt_id=attempt_id)
|
||||||
|
receipt = {
|
||||||
|
"kind": "catalog_candidate",
|
||||||
|
"candidate_id": "candidate-" + "a" * 32,
|
||||||
|
"catalog_sha256": "b" * 64,
|
||||||
|
}
|
||||||
|
|
||||||
|
finished = self.store.finish_step(
|
||||||
|
run_id,
|
||||||
|
"core:tag",
|
||||||
|
attempt_id=attempt_id,
|
||||||
|
succeeded=True,
|
||||||
|
result_code="catalog_candidate_ready",
|
||||||
|
result_receipt=receipt,
|
||||||
|
)
|
||||||
|
replayed = self.store.finish_step(
|
||||||
|
run_id,
|
||||||
|
"core:tag",
|
||||||
|
attempt_id=attempt_id,
|
||||||
|
succeeded=True,
|
||||||
|
result_code="catalog_candidate_ready",
|
||||||
|
result_receipt=receipt,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(receipt, finished["state"]["steps"][0]["result_receipt"])
|
||||||
|
self.assertEqual(finished, replayed)
|
||||||
|
with self.assertRaisesRegex(ReleaseRunConflict, "different recorded outcome"):
|
||||||
|
self.store.finish_step(
|
||||||
|
run_id,
|
||||||
|
"core:tag",
|
||||||
|
attempt_id=attempt_id,
|
||||||
|
succeeded=True,
|
||||||
|
result_code="catalog_candidate_ready",
|
||||||
|
result_receipt=receipt | {"catalog_sha256": "c" * 64},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_catalog_publication_receipt_binds_candidate_origin_commit_and_tag(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
run_id = create_run(
|
||||||
|
self.store,
|
||||||
|
plan_snapshot=mutating_first_plan(),
|
||||||
|
request_id="publication-receipt-create-0001",
|
||||||
|
)["run_id"]
|
||||||
|
attempt_id = "publication-receipt-attempt-0001"
|
||||||
|
self.store.start_step(run_id, "core:tag", attempt_id=attempt_id)
|
||||||
|
receipt = {
|
||||||
|
"kind": "catalog_publication",
|
||||||
|
"candidate_id": "candidate-" + "a" * 32,
|
||||||
|
"catalog_sha256": "b" * 64,
|
||||||
|
"keyring_sha256": "c" * 64,
|
||||||
|
"publication_commit_sha": "d" * 40,
|
||||||
|
"publication_tag_object_sha": "e" * 40,
|
||||||
|
"publication_tag_commit_sha": "d" * 40,
|
||||||
|
"branch": "main",
|
||||||
|
"tag_name": "catalog-stable-1",
|
||||||
|
"remote": "origin",
|
||||||
|
"remote_sha256": "f" * 64,
|
||||||
|
}
|
||||||
|
|
||||||
|
finished = self.store.finish_step(
|
||||||
|
run_id,
|
||||||
|
"core:tag",
|
||||||
|
attempt_id=attempt_id,
|
||||||
|
succeeded=True,
|
||||||
|
result_code="catalog_published",
|
||||||
|
result_receipt=receipt,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(receipt, finished["state"]["steps"][0]["result_receipt"])
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ReleaseRunConflict, "publication receipt identity"
|
||||||
|
):
|
||||||
|
self.store.finish_step(
|
||||||
|
run_id,
|
||||||
|
"core:tag",
|
||||||
|
attempt_id=attempt_id,
|
||||||
|
succeeded=True,
|
||||||
|
result_code="catalog_published",
|
||||||
|
result_receipt=receipt
|
||||||
|
| {"publication_tag_commit_sha": "0" * 40},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schema_three_record_is_verified_then_migrated_privately(self) -> None:
|
||||||
|
run_id = create_run(
|
||||||
|
self.store,
|
||||||
|
request_id="legacy-schema-create-request-0001",
|
||||||
|
)["run_id"]
|
||||||
|
path = self.root / f"{run_id}.json"
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
payload["schema_version"] = 3
|
||||||
|
for step in payload["state"]["steps"]:
|
||||||
|
step.pop("result_receipt")
|
||||||
|
payload["record_digest"] = _record_digest(payload)
|
||||||
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
migrated = self.store.get(run_id)
|
||||||
|
persisted = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
self.assertEqual(4, migrated["schema_version"])
|
||||||
|
self.assertEqual(4, persisted["schema_version"])
|
||||||
|
self.assertTrue(
|
||||||
|
all(step["result_receipt"] is None for step in persisted["state"]["steps"])
|
||||||
|
)
|
||||||
|
|
||||||
def test_zero_step_plan_is_rejected(self) -> None:
|
def test_zero_step_plan_is_rejected(self) -> None:
|
||||||
plan = release_plan()
|
plan = release_plan()
|
||||||
plan["dry_run_steps"] = []
|
plan["dry_run_steps"] = []
|
||||||
@@ -293,7 +428,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
self.assertEqual(0, untouched["state"]["steps"][0]["attempt_count"])
|
self.assertEqual(0, untouched["state"]["steps"][0]["attempt_count"])
|
||||||
|
|
||||||
accepted_plan = mutating_first_plan()
|
accepted_plan = mutating_first_plan()
|
||||||
accepted_plan["notes"] = ["x" * (MAX_RECORD_BYTES - 3_000)]
|
accepted_plan["notes"] = ["x" * (MAX_RECORD_BYTES - 4_300)]
|
||||||
accepted_run_id = create_run(
|
accepted_run_id = create_run(
|
||||||
self.store,
|
self.store,
|
||||||
request_id="byte-capacity-accepted-create-0001",
|
request_id="byte-capacity-accepted-create-0001",
|
||||||
@@ -334,7 +469,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
) -> None:
|
) -> None:
|
||||||
plan = mutating_first_plan()
|
plan = mutating_first_plan()
|
||||||
plan["dry_run_steps"][0]["mutating"] = False # type: ignore[index]
|
plan["dry_run_steps"][0]["mutating"] = False # type: ignore[index]
|
||||||
plan["notes"] = ["x" * (MAX_RECORD_BYTES - 3_000)]
|
plan["notes"] = ["x" * (MAX_RECORD_BYTES - 5_500)]
|
||||||
run_id = create_run(
|
run_id = create_run(
|
||||||
self.store,
|
self.store,
|
||||||
request_id="byte-capacity-read-only-create-0001",
|
request_id="byte-capacity-read-only-create-0001",
|
||||||
@@ -747,7 +882,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
listed = self.store.list(limit=1)
|
listed = self.store.list(limit=1)
|
||||||
self.assertEqual([local_run["run_id"]], [item["run_id"] for item in listed])
|
self.assertEqual([local_run["run_id"]], [item["run_id"] for item in listed])
|
||||||
|
|
||||||
def test_list_orders_verified_runs_by_updated_timestamp_before_unavailable(
|
def test_list_orders_verified_runs_by_creation_timestamp_before_unavailable(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
with patch(
|
with patch(
|
||||||
@@ -786,11 +921,11 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
|
|
||||||
listed = self.store.list(limit=3)
|
listed = self.store.list(limit=3)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[older["run_id"], newer["run_id"], corrupt["run_id"]],
|
[newer["run_id"], older["run_id"], corrupt["run_id"]],
|
||||||
[item["run_id"] for item in listed],
|
[item["run_id"] for item in listed],
|
||||||
)
|
)
|
||||||
self.assertEqual("unavailable", listed[-1]["status"])
|
self.assertEqual("unavailable", listed[-1]["status"])
|
||||||
self.assertEqual(older["run_id"], self.store.list(limit=1)[0]["run_id"])
|
self.assertEqual(newer["run_id"], self.store.list(limit=1)[0]["run_id"])
|
||||||
|
|
||||||
def test_cursor_pagination_is_stable_and_keeps_unavailable_records_visible(
|
def test_cursor_pagination_is_stable_and_keeps_unavailable_records_visible(
|
||||||
self,
|
self,
|
||||||
@@ -822,6 +957,43 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ReleaseRunConflict, "cursor is invalid"):
|
with self.assertRaisesRegex(ReleaseRunConflict, "cursor is invalid"):
|
||||||
self.store.list_page(limit=1, cursor=f"{first['next_cursor']}tampered")
|
self.store.list_page(limit=1, cursor=f"{first['next_cursor']}tampered")
|
||||||
|
|
||||||
|
def test_cursor_does_not_skip_or_repeat_when_an_older_run_is_updated(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
created: list[dict[str, object]] = []
|
||||||
|
for index in range(3):
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_run._timestamp",
|
||||||
|
return_value=f"2026-07-22T0{index}:00:00Z",
|
||||||
|
):
|
||||||
|
created.append(
|
||||||
|
create_run(
|
||||||
|
self.store,
|
||||||
|
request_id=f"cursor-update-create-request-{index:04d}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
first = self.store.list_page(limit=1)
|
||||||
|
with patch(
|
||||||
|
"govoplan_release.release_run._timestamp",
|
||||||
|
return_value="2026-07-22T12:00:00Z",
|
||||||
|
):
|
||||||
|
self.store.start_step(
|
||||||
|
created[0]["run_id"],
|
||||||
|
"core:preflight",
|
||||||
|
attempt_id="cursor-update-attempt-request-0001",
|
||||||
|
)
|
||||||
|
second = self.store.list_page(limit=1, cursor=first["next_cursor"])
|
||||||
|
third = self.store.list_page(limit=1, cursor=second["next_cursor"])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[item["run_id"] for item in reversed(created)],
|
||||||
|
[
|
||||||
|
first["runs"][0]["run_id"],
|
||||||
|
second["runs"][0]["run_id"],
|
||||||
|
third["runs"][0]["run_id"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_retention_prunes_only_oldest_completed_records_and_fails_closed(
|
def test_retention_prunes_only_oldest_completed_records_and_fails_closed(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -963,6 +1135,22 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ReleaseRunApiTests(unittest.TestCase):
|
class ReleaseRunApiTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.runtime_trust = patch("server.app.require_trusted_release_runtime")
|
||||||
|
self.runtime_binding = patch("server.app.verify_release_runtime_binding")
|
||||||
|
self.source_bindings = patch(
|
||||||
|
"server.app.bind_plan_source_states",
|
||||||
|
side_effect=bind_test_plan_source_states,
|
||||||
|
)
|
||||||
|
self.runtime_trust.start()
|
||||||
|
self.runtime_binding.start()
|
||||||
|
self.source_bindings.start()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.source_bindings.stop()
|
||||||
|
self.runtime_binding.stop()
|
||||||
|
self.runtime_trust.stop()
|
||||||
|
|
||||||
def test_api_exposes_and_validates_cursor_pagination(self) -> None:
|
def test_api_exposes_and_validates_cursor_pagination(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
app = create_app(
|
app = create_app(
|
||||||
@@ -1008,6 +1196,338 @@ class ReleaseRunApiTests(unittest.TestCase):
|
|||||||
self.assertIsNone(second["next_cursor"])
|
self.assertIsNone(second["next_cursor"])
|
||||||
self.assertEqual(409, invalid.status_code)
|
self.assertEqual(409, invalid.status_code)
|
||||||
|
|
||||||
|
def test_executor_claims_before_effect_and_replays_lost_success_response(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
app = create_app(
|
||||||
|
workspace_root=Path(temp_dir),
|
||||||
|
run_state_root=Path(temp_dir) / "runs",
|
||||||
|
candidate_root=Path(temp_dir) / "candidates",
|
||||||
|
token="token",
|
||||||
|
)
|
||||||
|
headers = {"X-Release-Console-Token": "token"}
|
||||||
|
with (
|
||||||
|
patch("server.app.build_dashboard", return_value=object()),
|
||||||
|
patch(
|
||||||
|
"server.app.build_selective_release_plan",
|
||||||
|
return_value=release_plan(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.bind_plan_source_states",
|
||||||
|
side_effect=bind_test_plan_source_states,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.verify_repository_preflight_binding",
|
||||||
|
return_value=repository_receipt(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.execute_repository_step",
|
||||||
|
return_value=({"status": "inspected"}, repository_receipt()),
|
||||||
|
) as executor,
|
||||||
|
TestClient(app) as client,
|
||||||
|
):
|
||||||
|
created = client.post(
|
||||||
|
"/api/release-runs",
|
||||||
|
headers=headers,
|
||||||
|
json=api_run_input(request_id="api-executor-create-0001"),
|
||||||
|
).json()
|
||||||
|
run_id = created["run_id"]
|
||||||
|
self.assertTrue(created["state"]["steps"][0]["executor"]["available"])
|
||||||
|
self.assertEqual(
|
||||||
|
"preflight", created["state"]["steps"][0]["executor"]["kind"]
|
||||||
|
)
|
||||||
|
payload = {"request_id": "api-executor-attempt-0001"}
|
||||||
|
executed = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/core:preflight/execute",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
replayed = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/core:preflight/execute",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(200, executed.status_code)
|
||||||
|
self.assertEqual("succeeded", executed.json()["state"]["steps"][0]["state"])
|
||||||
|
self.assertEqual("replayed", replayed.json()["execution_result"]["status"])
|
||||||
|
executor.assert_called_once()
|
||||||
|
|
||||||
|
def test_lost_finish_write_interrupts_without_reexecuting_effect(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
app = create_app(
|
||||||
|
workspace_root=Path(temp_dir),
|
||||||
|
run_state_root=Path(temp_dir) / "runs",
|
||||||
|
token="token",
|
||||||
|
)
|
||||||
|
headers = {"X-Release-Console-Token": "token"}
|
||||||
|
with (
|
||||||
|
patch("server.app.build_dashboard", return_value=object()),
|
||||||
|
patch(
|
||||||
|
"server.app.build_selective_release_plan",
|
||||||
|
return_value=release_plan(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.bind_plan_source_states",
|
||||||
|
side_effect=bind_test_plan_source_states,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.verify_repository_preflight_binding",
|
||||||
|
return_value=repository_receipt(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.execute_repository_step",
|
||||||
|
return_value=({"status": "inspected"}, repository_receipt()),
|
||||||
|
) as executor,
|
||||||
|
TestClient(app) as client,
|
||||||
|
):
|
||||||
|
run_id = client.post(
|
||||||
|
"/api/release-runs",
|
||||||
|
headers=headers,
|
||||||
|
json=api_run_input(request_id="api-lost-finish-create-0001"),
|
||||||
|
).json()["run_id"]
|
||||||
|
with patch.object(
|
||||||
|
app.state.release_runs,
|
||||||
|
"finish_step",
|
||||||
|
side_effect=ReleaseRunCorrupt("simulated durable write failure"),
|
||||||
|
):
|
||||||
|
first = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/core:preflight/execute",
|
||||||
|
headers=headers,
|
||||||
|
json={"request_id": "api-lost-finish-attempt-0001"},
|
||||||
|
)
|
||||||
|
replay = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/core:preflight/execute",
|
||||||
|
headers=headers,
|
||||||
|
json={"request_id": "api-lost-finish-attempt-0001"},
|
||||||
|
)
|
||||||
|
loaded = client.get(
|
||||||
|
f"/api/release-runs/{run_id}", headers=headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
self.assertEqual(409, first.status_code)
|
||||||
|
self.assertEqual(409, replay.status_code)
|
||||||
|
self.assertEqual("interrupted", loaded["state"]["steps"][0]["state"])
|
||||||
|
executor.assert_called_once()
|
||||||
|
|
||||||
|
def test_executor_exception_is_interrupted_and_never_retried_implicitly(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
app = create_app(
|
||||||
|
workspace_root=Path(temp_dir),
|
||||||
|
run_state_root=Path(temp_dir) / "runs",
|
||||||
|
token="token",
|
||||||
|
)
|
||||||
|
headers = {"X-Release-Console-Token": "token"}
|
||||||
|
with (
|
||||||
|
patch("server.app.build_dashboard", return_value=object()),
|
||||||
|
patch(
|
||||||
|
"server.app.build_selective_release_plan",
|
||||||
|
return_value=mutating_first_plan(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.bind_plan_source_states",
|
||||||
|
side_effect=bind_test_plan_source_states,
|
||||||
|
),
|
||||||
|
patch("server.app.verify_repository_step_precondition"),
|
||||||
|
patch(
|
||||||
|
"server.app.execute_repository_step",
|
||||||
|
side_effect=RuntimeError("connection lost after push"),
|
||||||
|
) as executor,
|
||||||
|
TestClient(app) as client,
|
||||||
|
):
|
||||||
|
run_id = client.post(
|
||||||
|
"/api/release-runs",
|
||||||
|
headers=headers,
|
||||||
|
json=api_run_input(request_id="api-ambiguous-create-0001"),
|
||||||
|
).json()["run_id"]
|
||||||
|
payload = {
|
||||||
|
"request_id": "api-ambiguous-attempt-0001",
|
||||||
|
"confirm": "TAG",
|
||||||
|
}
|
||||||
|
first = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/core:tag/execute",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
second = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/core:tag/execute",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
loaded = client.get(
|
||||||
|
f"/api/release-runs/{run_id}", headers=headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
self.assertEqual(409, first.status_code)
|
||||||
|
self.assertEqual(409, second.status_code)
|
||||||
|
self.assertEqual("interrupted", loaded["state"]["steps"][0]["state"])
|
||||||
|
self.assertEqual(
|
||||||
|
"executor_outcome_unknown",
|
||||||
|
loaded["state"]["steps"][0]["result_code"],
|
||||||
|
)
|
||||||
|
executor.assert_called_once()
|
||||||
|
|
||||||
|
def test_catalog_execution_persists_and_consumes_only_issued_receipt(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
candidate_root = Path(temp_dir) / "candidates"
|
||||||
|
app = create_app(
|
||||||
|
workspace_root=Path(temp_dir),
|
||||||
|
run_state_root=Path(temp_dir) / "runs",
|
||||||
|
candidate_root=candidate_root,
|
||||||
|
token="token",
|
||||||
|
)
|
||||||
|
headers = {"X-Release-Console-Token": "token"}
|
||||||
|
|
||||||
|
def generated(**kwargs: object) -> tuple[dict[str, object], CandidateArtifactReceipt]:
|
||||||
|
candidate_id = str(kwargs["candidate_id"])
|
||||||
|
candidate = candidate_output_path(candidate_root, candidate_id)
|
||||||
|
channels = candidate / "channels"
|
||||||
|
channels.mkdir(parents=True)
|
||||||
|
(channels / "stable.json").write_text(
|
||||||
|
json.dumps({"channel": "stable", "signatures": [{}]}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
harden_private_candidate_tree(candidate)
|
||||||
|
return (
|
||||||
|
{"status": "ready"},
|
||||||
|
issue_candidate_receipt(
|
||||||
|
root=candidate_root,
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
channel="stable",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("server.app.build_dashboard", return_value=object()),
|
||||||
|
patch(
|
||||||
|
"server.app.build_selective_release_plan",
|
||||||
|
return_value=catalog_release_plan(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.bind_plan_source_states",
|
||||||
|
side_effect=bind_test_plan_source_states,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.verify_catalog_publication_precondition",
|
||||||
|
return_value=repository_receipt(
|
||||||
|
repo="addideas-govoplan-website", target_tag=""
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch("server.app.default_signing_keys", return_value=("key=/private",)),
|
||||||
|
patch(
|
||||||
|
"server.app.generate_catalog_candidate", side_effect=generated
|
||||||
|
) as generator,
|
||||||
|
patch(
|
||||||
|
"server.app.publish_received_candidate",
|
||||||
|
side_effect=published_candidate_result,
|
||||||
|
) as publisher,
|
||||||
|
TestClient(app) as client,
|
||||||
|
):
|
||||||
|
run_id = client.post(
|
||||||
|
"/api/release-runs",
|
||||||
|
headers=headers,
|
||||||
|
json=api_run_input(request_id="api-candidate-create-0001"),
|
||||||
|
).json()["run_id"]
|
||||||
|
generated_response = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/catalog:selective-generator/execute",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"request_id": "api-candidate-generate-0001",
|
||||||
|
"confirm": "GENERATE",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with patch("server.app.default_signing_keys", return_value=()):
|
||||||
|
replayed_generation = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/catalog:selective-generator/execute",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"request_id": "api-candidate-generate-0001",
|
||||||
|
"confirm": "GENERATE",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
published = client.post(
|
||||||
|
f"/api/release-runs/{run_id}/steps/catalog:validate-sign-publish/execute",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"request_id": "api-candidate-publish-0001",
|
||||||
|
"confirm": "PUSH",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt = generated_response.json()["state"]["steps"][0]["result_receipt"]
|
||||||
|
self.assertRegex(receipt["candidate_id"], r"^candidate-[0-9a-f]{32}$")
|
||||||
|
self.assertEqual(64, len(receipt["catalog_sha256"]))
|
||||||
|
self.assertEqual(200, replayed_generation.status_code)
|
||||||
|
self.assertEqual(
|
||||||
|
"replayed",
|
||||||
|
replayed_generation.json()["execution_result"]["status"],
|
||||||
|
)
|
||||||
|
generator.assert_called_once()
|
||||||
|
self.assertEqual(200, published.status_code)
|
||||||
|
self.assertEqual("completed", published.json()["state"]["status"])
|
||||||
|
publisher.assert_called_once_with(
|
||||||
|
candidate_path=candidate_root / receipt["candidate_id"],
|
||||||
|
candidate_receipt=receipt,
|
||||||
|
channel="stable",
|
||||||
|
workspace_root=Path(temp_dir),
|
||||||
|
remote="origin",
|
||||||
|
expected_website_receipt=repository_receipt(
|
||||||
|
repo="addideas-govoplan-website", target_tag=""
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_catalog_endpoint_is_preview_only(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
app = create_app(workspace_root=Path(temp_dir), token="token")
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/catalog-candidates/publish",
|
||||||
|
headers={"X-Release-Console-Token": "token"},
|
||||||
|
json={
|
||||||
|
"candidate_dir": "/tmp/untrusted",
|
||||||
|
"apply": True,
|
||||||
|
"push": True,
|
||||||
|
"confirm": "PUSH",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(409, response.status_code)
|
||||||
|
self.assertIn("durable release run", response.json()["detail"])
|
||||||
|
|
||||||
|
def test_release_channels_and_durable_remote_are_strictly_validated(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
app = create_app(workspace_root=Path(temp_dir), token="token")
|
||||||
|
headers = {"X-Release-Console-Token": "token"}
|
||||||
|
with (
|
||||||
|
patch("server.app.build_dashboard") as dashboard,
|
||||||
|
TestClient(app) as client,
|
||||||
|
):
|
||||||
|
query = client.get(
|
||||||
|
"/api/dashboard", headers=headers, params={"channel": "../stable"}
|
||||||
|
)
|
||||||
|
created = client.post(
|
||||||
|
"/api/release-runs",
|
||||||
|
headers=headers,
|
||||||
|
json=api_run_input(
|
||||||
|
request_id="api-invalid-channel-create-0001",
|
||||||
|
channel="/absolute",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
execute = client.post(
|
||||||
|
"/api/release-runs/not-a-run/steps/core:tag/execute",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"request_id": "api-invalid-remote-attempt-0001",
|
||||||
|
"confirm": "TAG",
|
||||||
|
"remote": "upstream",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(422, query.status_code)
|
||||||
|
self.assertEqual(422, created.status_code)
|
||||||
|
self.assertEqual(422, execute.status_code)
|
||||||
|
dashboard.assert_not_called()
|
||||||
|
|
||||||
def test_token_guarded_create_list_read_resume_and_retry(self) -> None:
|
def test_token_guarded_create_list_read_resume_and_retry(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
root = Path(temp_dir) / "runs"
|
root = Path(temp_dir) / "runs"
|
||||||
@@ -1205,6 +1725,10 @@ class ReleaseRunApiTests(unittest.TestCase):
|
|||||||
"server.app.build_selective_release_plan",
|
"server.app.build_selective_release_plan",
|
||||||
return_value=mutating_first_plan(),
|
return_value=mutating_first_plan(),
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"server.app.reconciled_repository_receipt",
|
||||||
|
return_value=repository_receipt(),
|
||||||
|
),
|
||||||
TestClient(app) as client,
|
TestClient(app) as client,
|
||||||
):
|
):
|
||||||
created = client.post(
|
created = client.post(
|
||||||
@@ -1372,12 +1896,16 @@ class ReleaseRunApiTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn("Durable Run State", webui)
|
self.assertIn("Durable Run State", webui)
|
||||||
self.assertIn("State tracking only.", webui)
|
self.assertIn("Durable execution.", webui)
|
||||||
self.assertIn("Create Run from Selection", webui)
|
self.assertIn("Create Run from Selection", webui)
|
||||||
self.assertIn("Prepare Retry", webui)
|
self.assertIn("Prepare Retry", webui)
|
||||||
self.assertIn("step.retry_available", webui)
|
self.assertIn("step.retry_available", webui)
|
||||||
self.assertIn("Record Reconciliation", webui)
|
self.assertIn("Record Reconciliation", webui)
|
||||||
self.assertIn("effect_succeeded", webui)
|
self.assertIn("effect_succeeded", webui)
|
||||||
|
self.assertNotIn(
|
||||||
|
'step.id === "catalog:validate-sign-publish" ? "disabled"',
|
||||||
|
webui,
|
||||||
|
)
|
||||||
self.assertIn("renderReleaseRunStoreUnavailable", webui)
|
self.assertIn("renderReleaseRunStoreUnavailable", webui)
|
||||||
self.assertIn('const runsRequest = api("/api/release-runs")', webui)
|
self.assertIn('const runsRequest = api("/api/release-runs")', webui)
|
||||||
self.assertIn("pendingReleaseRunPayload", webui)
|
self.assertIn("pendingReleaseRunPayload", webui)
|
||||||
@@ -1385,13 +1913,30 @@ class ReleaseRunApiTests(unittest.TestCase):
|
|||||||
self.assertIn("recoverPendingRunCommands", webui)
|
self.assertIn("recoverPendingRunCommands", webui)
|
||||||
self.assertIn("inFlightRunCommandId", webui)
|
self.assertIn("inFlightRunCommandId", webui)
|
||||||
self.assertIn("Run saved; list refresh unavailable", webui)
|
self.assertIn("Run saved; list refresh unavailable", webui)
|
||||||
self.assertIn("This foundation tracks state only.", runbook)
|
self.assertIn("The run record is execution evidence only", runbook)
|
||||||
|
self.assertIn("isolated checkout", runbook)
|
||||||
self.assertIn("effect_absent", runbook)
|
self.assertIn("effect_absent", runbook)
|
||||||
self.assertIn("$XDG_STATE_HOME", runbook)
|
self.assertIn("$XDG_STATE_HOME", runbook)
|
||||||
self.assertIn("same-directory", runbook)
|
self.assertIn("same-directory", runbook)
|
||||||
self.assertIn("caller-generated `request_id`", runbook)
|
self.assertIn("caller-generated `request_id`", runbook)
|
||||||
self.assertIn("reserves the two command-ledger", runbook)
|
self.assertIn("reserves the two command-ledger", runbook)
|
||||||
|
|
||||||
|
def test_entire_release_console_script_is_valid_javascript(self) -> None:
|
||||||
|
node = shutil.which("node")
|
||||||
|
if node is None:
|
||||||
|
self.skipTest("Node.js is required for the Release Console UI check")
|
||||||
|
webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||||
|
match = re.search(r"<script>(?P<script>.*?)</script>", webui, re.DOTALL)
|
||||||
|
self.assertIsNotNone(match, "release console script element is missing")
|
||||||
|
result = subprocess.run( # noqa: S603
|
||||||
|
[node, "--check", "-"],
|
||||||
|
input=match.group("script") if match is not None else "",
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
|
||||||
def test_ui_reuses_uncertain_ids_and_separates_saved_run_list_failure(
|
def test_ui_reuses_uncertain_ids_and_separates_saved_run_list_failure(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1493,6 +2038,16 @@ async function api(_path) {
|
|||||||
const reconcileRequest = pendingRunCommandRequest("reconcile:rr-known:core:tag:effect_succeeded", "reconcile-request");
|
const reconcileRequest = pendingRunCommandRequest("reconcile:rr-known:core:tag:effect_succeeded", "reconcile-request");
|
||||||
assert(reconcileRequest.path.endsWith("/steps/core%3Atag/reconcile"), "reconciliation recovery path was not executable");
|
assert(reconcileRequest.path.endsWith("/steps/core%3Atag/reconcile"), "reconciliation recovery path was not executable");
|
||||||
assert(reconcileRequest.body.confirm === "RECONCILE" && reconcileRequest.body.outcome === "effect_succeeded", "reconciliation recovery body changed");
|
assert(reconcileRequest.body.confirm === "RECONCILE" && reconcileRequest.body.outcome === "effect_succeeded", "reconciliation recovery body changed");
|
||||||
|
const executeKey = "execute:rr-known:catalog:selective-generator:GENERATE";
|
||||||
|
const executeId = inFlightRunCommandId(executeKey, "execute", { signing_keys: ["release-key=/private/key"] });
|
||||||
|
assert(!values.get(pendingRunCommandsKey).includes("signing_keys"), "signing key path was persisted in session storage");
|
||||||
|
const executeRequest = pendingRunCommandRequest(executeKey, executeId);
|
||||||
|
assert(!Object.hasOwn(executeRequest.body, "signing_keys"), "execute replay reconstructed signing material");
|
||||||
|
values.set(pendingRunCommandsKey, JSON.stringify({ [commandKey]: commandOne, [executeKey]: { request_id: executeId, body: { signing_keys: ["legacy-secret"] } } }));
|
||||||
|
const migratedCommands = readInFlightRunCommands();
|
||||||
|
assert(migratedCommands[executeKey] === executeId, "legacy pending execute request ID was lost");
|
||||||
|
assert(!values.get(pendingRunCommandsKey).includes("legacy-secret"), "legacy persisted signing material was not purged");
|
||||||
|
clearInFlightRunCommand(executeKey, executeId);
|
||||||
|
|
||||||
failCreate = true;
|
failCreate = true;
|
||||||
const uncertainCommand = await recoverPendingRunCommands();
|
const uncertainCommand = await recoverPendingRunCommands();
|
||||||
@@ -1591,6 +2146,64 @@ def run_input(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def repository_receipt(
|
||||||
|
*, repo: str = "govoplan-core", target_tag: str = "v1.2.3"
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"kind": "repository_state",
|
||||||
|
"repo": repo,
|
||||||
|
"head": "a" * 40,
|
||||||
|
"branch": "main",
|
||||||
|
"remote": "origin",
|
||||||
|
"remote_sha256": "b" * 64,
|
||||||
|
"worktree_clean": True,
|
||||||
|
"target_tag": target_tag,
|
||||||
|
"tag_object": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def published_candidate_result(
|
||||||
|
**kwargs: object,
|
||||||
|
) -> tuple[dict[str, object], dict[str, object]]:
|
||||||
|
candidate = kwargs["candidate_receipt"]
|
||||||
|
website = kwargs["expected_website_receipt"]
|
||||||
|
if not isinstance(candidate, dict) or not isinstance(website, dict):
|
||||||
|
raise AssertionError("publication test receipts are unavailable")
|
||||||
|
receipt = {
|
||||||
|
"kind": "catalog_publication",
|
||||||
|
"candidate_id": candidate["candidate_id"],
|
||||||
|
"catalog_sha256": candidate["catalog_sha256"],
|
||||||
|
"keyring_sha256": "c" * 64,
|
||||||
|
"publication_commit_sha": "d" * 40,
|
||||||
|
"publication_tag_object_sha": "e" * 40,
|
||||||
|
"publication_tag_commit_sha": "d" * 40,
|
||||||
|
"branch": website["branch"],
|
||||||
|
"tag_name": "catalog-stable-7",
|
||||||
|
"remote": "origin",
|
||||||
|
"remote_sha256": website["remote_sha256"],
|
||||||
|
}
|
||||||
|
return {"status": "published"}, receipt
|
||||||
|
|
||||||
|
|
||||||
|
def bind_test_plan_source_states(**kwargs: object) -> dict[str, object]:
|
||||||
|
plan = kwargs["plan"]
|
||||||
|
if not isinstance(plan, dict):
|
||||||
|
raise AssertionError("test plan is not an object")
|
||||||
|
steps = plan.get("dry_run_steps")
|
||||||
|
if not isinstance(steps, list):
|
||||||
|
raise AssertionError("test plan has no steps")
|
||||||
|
for step in steps:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
if isinstance(step.get("repo"), str):
|
||||||
|
step["source_binding"] = repository_receipt()
|
||||||
|
elif step.get("id") == "catalog:validate-sign-publish":
|
||||||
|
step["source_binding"] = repository_receipt(
|
||||||
|
repo="addideas-govoplan-website", target_tag=""
|
||||||
|
)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
def release_plan() -> dict[str, object]:
|
def release_plan() -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"generated_at": "2026-07-22T00:00:00Z",
|
"generated_at": "2026-07-22T00:00:00Z",
|
||||||
@@ -1641,5 +2254,32 @@ def mutating_first_plan() -> dict[str, object]:
|
|||||||
return plan
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_release_plan() -> dict[str, object]:
|
||||||
|
plan = release_plan()
|
||||||
|
plan["dry_run_steps"] = [
|
||||||
|
{
|
||||||
|
"id": "catalog:selective-generator",
|
||||||
|
"title": "Generate candidate",
|
||||||
|
"detail": "Build artifacts and sign a candidate.",
|
||||||
|
"command": "release-catalog selective",
|
||||||
|
"cwd": "/workspace/govoplan",
|
||||||
|
"mutating": True,
|
||||||
|
"repo": None,
|
||||||
|
"status": "planned",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "catalog:validate-sign-publish",
|
||||||
|
"title": "Publish candidate",
|
||||||
|
"detail": "Publish only the receipt-bound candidate.",
|
||||||
|
"command": "release-catalog publish-candidate",
|
||||||
|
"cwd": "/workspace/govoplan",
|
||||||
|
"mutating": True,
|
||||||
|
"repo": None,
|
||||||
|
"status": "planned",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Durable state for guided, explicitly executed release runs.
|
"""Durable state and bounded receipts for explicitly executed release runs.
|
||||||
|
|
||||||
The run store records an immutable selective-plan snapshot and a small mutable
|
The store freezes a selective plan, claims each supported attempt before its
|
||||||
state machine. It does not execute release commands. Existing release
|
executor is called, and records only bounded outcomes. Narrow executors remain
|
||||||
executors remain responsible for their narrow confirmations and will be wired
|
separate from this persistence module.
|
||||||
to this state in a later slice.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,6 +10,7 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -24,9 +24,12 @@ from typing import Any
|
|||||||
|
|
||||||
from filelock import FileLock, Timeout as FileLockTimeout
|
from filelock import FileLock, Timeout as FileLockTimeout
|
||||||
|
|
||||||
|
from .candidate_artifact import CandidateArtifactError, validate_release_channel
|
||||||
|
|
||||||
|
|
||||||
SCHEMA = "govoplan.release-run"
|
SCHEMA = "govoplan.release-run"
|
||||||
SCHEMA_VERSION = 3
|
SCHEMA_VERSION = 4
|
||||||
|
LEGACY_SCHEMA_VERSIONS = {3}
|
||||||
MAX_RECORD_BYTES = 2 * 1024 * 1024
|
MAX_RECORD_BYTES = 2 * 1024 * 1024
|
||||||
MAX_PLAN_STEPS = 512
|
MAX_PLAN_STEPS = 512
|
||||||
MAX_REPOSITORY_VERSIONS = 128
|
MAX_REPOSITORY_VERSIONS = 128
|
||||||
@@ -40,7 +43,6 @@ RUN_ID_PATTERN = re.compile(
|
|||||||
)
|
)
|
||||||
STEP_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$")
|
STEP_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$")
|
||||||
REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{7,127}$")
|
REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{7,127}$")
|
||||||
CHANNEL_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
||||||
REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
WORKSPACE_FINGERPRINT_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
WORKSPACE_FINGERPRINT_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||||
VERSION_PATTERN = re.compile(
|
VERSION_PATTERN = re.compile(
|
||||||
@@ -65,6 +67,13 @@ RECONCILIATION_CONFIRMATION = "RECONCILE"
|
|||||||
START_RECOVERY_RESERVE = 2
|
START_RECOVERY_RESERVE = 2
|
||||||
PROJECTION_TIMESTAMP = "9999-12-31T23:59:59Z"
|
PROJECTION_TIMESTAMP = "9999-12-31T23:59:59Z"
|
||||||
PROJECTION_RESULT_CODE = "r" * 64
|
PROJECTION_RESULT_CODE = "r" * 64
|
||||||
|
RECEIPT_KINDS = {
|
||||||
|
"catalog_candidate",
|
||||||
|
"catalog_publication",
|
||||||
|
"repository_state",
|
||||||
|
}
|
||||||
|
RECEIPT_ID_PATTERN = re.compile(r"^candidate-[0-9a-f]{32}$")
|
||||||
|
GIT_OBJECT_PATTERN = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
|
||||||
|
|
||||||
|
|
||||||
class ReleaseRunError(RuntimeError):
|
class ReleaseRunError(RuntimeError):
|
||||||
@@ -87,6 +96,17 @@ class ReleaseRunCorrupt(ReleaseRunError):
|
|||||||
"""Raised when a persisted record fails its integrity or schema checks."""
|
"""Raised when a persisted record fails its integrity or schema checks."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReleaseStepClaim:
|
||||||
|
"""Private executor claim result; attempt identifiers remain fingerprinted."""
|
||||||
|
|
||||||
|
run: dict[str, Any]
|
||||||
|
claimed: bool
|
||||||
|
outcome: str
|
||||||
|
result_code: str | None
|
||||||
|
result_receipt: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
class ReleaseRunStore:
|
class ReleaseRunStore:
|
||||||
"""Atomic, bounded local JSON store for release-run state."""
|
"""Atomic, bounded local JSON store for release-run state."""
|
||||||
|
|
||||||
@@ -135,6 +155,7 @@ class ReleaseRunStore:
|
|||||||
"started_at": None,
|
"started_at": None,
|
||||||
"finished_at": None,
|
"finished_at": None,
|
||||||
"result_code": None,
|
"result_code": None,
|
||||||
|
"result_receipt": None,
|
||||||
}
|
}
|
||||||
for item in immutable_plan["dry_run_steps"]
|
for item in immutable_plan["dry_run_steps"]
|
||||||
]
|
]
|
||||||
@@ -251,7 +272,7 @@ class ReleaseRunStore:
|
|||||||
(
|
(
|
||||||
(
|
(
|
||||||
1,
|
1,
|
||||||
view["updated_at"],
|
view["created_at"],
|
||||||
view["created_at"],
|
view["created_at"],
|
||||||
view["run_id"],
|
view["run_id"],
|
||||||
),
|
),
|
||||||
@@ -364,6 +385,7 @@ class ReleaseRunStore:
|
|||||||
"started_at": None,
|
"started_at": None,
|
||||||
"finished_at": None,
|
"finished_at": None,
|
||||||
"result_code": None,
|
"result_code": None,
|
||||||
|
"result_receipt": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_remember_request(
|
_remember_request(
|
||||||
@@ -391,6 +413,7 @@ class ReleaseRunStore:
|
|||||||
request_id: str,
|
request_id: str,
|
||||||
outcome: str,
|
outcome: str,
|
||||||
confirmation: str,
|
confirmation: str,
|
||||||
|
result_receipt: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Record an operator-observed outcome for an interrupted mutation."""
|
"""Record an operator-observed outcome for an interrupted mutation."""
|
||||||
|
|
||||||
@@ -401,12 +424,22 @@ class ReleaseRunStore:
|
|||||||
raise ReleaseRunConflict(
|
raise ReleaseRunConflict(
|
||||||
f"Type {RECONCILIATION_CONFIRMATION} to record reconciliation."
|
f"Type {RECONCILIATION_CONFIRMATION} to record reconciliation."
|
||||||
)
|
)
|
||||||
|
receipt = _validated_result_receipt(result_receipt)
|
||||||
|
if receipt is not None and outcome != "effect_succeeded":
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"Only a reconciled successful effect can carry a result receipt."
|
||||||
|
)
|
||||||
request_fingerprint = _request_fingerprint(request_id)
|
request_fingerprint = _request_fingerprint(request_id)
|
||||||
with self._locked():
|
with self._locked():
|
||||||
record = self._read(run_id)
|
record = self._read(run_id)
|
||||||
if _request_seen(
|
if _request_seen(
|
||||||
record, request_fingerprint, "reconcile", step_id, outcome
|
record, request_fingerprint, "reconcile", step_id, outcome
|
||||||
):
|
):
|
||||||
|
step, _plan_step = _step_pair(record, step_id)
|
||||||
|
if step.get("result_receipt") != receipt:
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"The reconciliation request already has a different result receipt."
|
||||||
|
)
|
||||||
return _view(record)
|
return _view(record)
|
||||||
step, plan_step = _step_pair(record, step_id)
|
step, plan_step = _step_pair(record, step_id)
|
||||||
if step["state"] != "interrupted" or not bool(
|
if step["state"] != "interrupted" or not bool(
|
||||||
@@ -438,6 +471,7 @@ class ReleaseRunStore:
|
|||||||
"started_at": None,
|
"started_at": None,
|
||||||
"finished_at": None,
|
"finished_at": None,
|
||||||
"result_code": None,
|
"result_code": None,
|
||||||
|
"result_receipt": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif outcome == "effect_succeeded":
|
elif outcome == "effect_succeeded":
|
||||||
@@ -446,6 +480,7 @@ class ReleaseRunStore:
|
|||||||
"state": "succeeded",
|
"state": "succeeded",
|
||||||
"finished_at": _timestamp(),
|
"finished_at": _timestamp(),
|
||||||
"result_code": "reconciled_effect_succeeded",
|
"result_code": "reconciled_effect_succeeded",
|
||||||
|
"result_receipt": receipt,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_remember_request(
|
_remember_request(
|
||||||
@@ -472,10 +507,20 @@ class ReleaseRunStore:
|
|||||||
*,
|
*,
|
||||||
attempt_id: str,
|
attempt_id: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Claim one step for a future confirmed executor.
|
"""Claim one step while preserving the original store API."""
|
||||||
|
|
||||||
This is deliberately not an HTTP endpoint in the foundation slice.
|
return self.claim_step(
|
||||||
"""
|
run_id, step_id, attempt_id=attempt_id
|
||||||
|
).run
|
||||||
|
|
||||||
|
def claim_step(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
step_id: str,
|
||||||
|
*,
|
||||||
|
attempt_id: str,
|
||||||
|
) -> ReleaseStepClaim:
|
||||||
|
"""Durably claim a step and distinguish a delayed attempt replay."""
|
||||||
|
|
||||||
_validate_step_id(step_id)
|
_validate_step_id(step_id)
|
||||||
attempt_fingerprint = _request_fingerprint(attempt_id)
|
attempt_fingerprint = _request_fingerprint(attempt_id)
|
||||||
@@ -487,7 +532,14 @@ class ReleaseRunStore:
|
|||||||
raise ReleaseRunConflict(
|
raise ReleaseRunConflict(
|
||||||
"The attempt identifier was already used for another release step."
|
"The attempt identifier was already used for another release step."
|
||||||
)
|
)
|
||||||
return _view(record)
|
step, _plan_step = _step_pair(record, step_id)
|
||||||
|
return ReleaseStepClaim(
|
||||||
|
run=_view(record),
|
||||||
|
claimed=False,
|
||||||
|
outcome=existing_attempt["outcome"],
|
||||||
|
result_code=existing_attempt["result_code"],
|
||||||
|
result_receipt=deepcopy(step.get("result_receipt")),
|
||||||
|
)
|
||||||
step, plan_step = _step_pair(record, step_id)
|
step, plan_step = _step_pair(record, step_id)
|
||||||
available, reason = _step_available(record, step_id)
|
available, reason = _step_available(record, step_id)
|
||||||
if not available:
|
if not available:
|
||||||
@@ -502,6 +554,7 @@ class ReleaseRunStore:
|
|||||||
"started_at": _timestamp(),
|
"started_at": _timestamp(),
|
||||||
"finished_at": None,
|
"finished_at": None,
|
||||||
"result_code": None,
|
"result_code": None,
|
||||||
|
"result_receipt": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
record["state"]["processed_attempts"].append(
|
record["state"]["processed_attempts"].append(
|
||||||
@@ -519,7 +572,13 @@ class ReleaseRunStore:
|
|||||||
mutating=bool(plan_step.get("mutating")),
|
mutating=bool(plan_step.get("mutating")),
|
||||||
)
|
)
|
||||||
self._persist_update(record)
|
self._persist_update(record)
|
||||||
return _view(record)
|
return ReleaseStepClaim(
|
||||||
|
run=_view(record),
|
||||||
|
claimed=True,
|
||||||
|
outcome="running",
|
||||||
|
result_code=None,
|
||||||
|
result_receipt=None,
|
||||||
|
)
|
||||||
|
|
||||||
def finish_step(
|
def finish_step(
|
||||||
self,
|
self,
|
||||||
@@ -529,12 +588,16 @@ class ReleaseRunStore:
|
|||||||
attempt_id: str,
|
attempt_id: str,
|
||||||
succeeded: bool,
|
succeeded: bool,
|
||||||
result_code: str,
|
result_code: str,
|
||||||
|
result_receipt: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Record a future executor outcome without persisting its output."""
|
"""Record a bounded executor outcome without persisting process output."""
|
||||||
|
|
||||||
_validate_step_id(step_id)
|
_validate_step_id(step_id)
|
||||||
attempt_fingerprint = _request_fingerprint(attempt_id)
|
attempt_fingerprint = _request_fingerprint(attempt_id)
|
||||||
result_code = _safe_code(result_code)
|
result_code = _safe_code(result_code)
|
||||||
|
receipt = _validated_result_receipt(result_receipt)
|
||||||
|
if receipt is not None and not succeeded:
|
||||||
|
raise ReleaseRunConflict("A failed release step cannot carry a result receipt.")
|
||||||
target_state = "succeeded" if succeeded else "failed"
|
target_state = "succeeded" if succeeded else "failed"
|
||||||
with self._locked():
|
with self._locked():
|
||||||
record = self._read(run_id)
|
record = self._read(run_id)
|
||||||
@@ -548,6 +611,7 @@ class ReleaseRunStore:
|
|||||||
if (
|
if (
|
||||||
attempt["outcome"] == target_state
|
attempt["outcome"] == target_state
|
||||||
and attempt["result_code"] == result_code
|
and attempt["result_code"] == result_code
|
||||||
|
and step.get("result_receipt") == receipt
|
||||||
):
|
):
|
||||||
return _view(record)
|
return _view(record)
|
||||||
raise ReleaseRunConflict(
|
raise ReleaseRunConflict(
|
||||||
@@ -565,6 +629,7 @@ class ReleaseRunStore:
|
|||||||
"state": target_state,
|
"state": target_state,
|
||||||
"finished_at": _timestamp(),
|
"finished_at": _timestamp(),
|
||||||
"result_code": result_code,
|
"result_code": result_code,
|
||||||
|
"result_receipt": receipt,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
attempt["outcome"] = target_state
|
attempt["outcome"] = target_state
|
||||||
@@ -578,6 +643,76 @@ class ReleaseRunStore:
|
|||||||
self._persist_update(record)
|
self._persist_update(record)
|
||||||
return _view(record)
|
return _view(record)
|
||||||
|
|
||||||
|
def interrupt_step(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
step_id: str,
|
||||||
|
*,
|
||||||
|
attempt_id: str,
|
||||||
|
result_code: str = "executor_outcome_unknown",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Freeze an executor exception as an ambiguous, reconcilable attempt."""
|
||||||
|
|
||||||
|
_validate_step_id(step_id)
|
||||||
|
attempt_fingerprint = _request_fingerprint(attempt_id)
|
||||||
|
result_code = _safe_code(result_code)
|
||||||
|
if result_code != "executor_outcome_unknown":
|
||||||
|
raise ReleaseRunConflict("Executor interruption result code is invalid.")
|
||||||
|
with self._locked():
|
||||||
|
record = self._read(run_id)
|
||||||
|
step, _plan_step = _step_pair(record, step_id)
|
||||||
|
attempt = _find_attempt(record, attempt_fingerprint)
|
||||||
|
if attempt is None or attempt["step_id"] != step_id:
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"The release attempt identifier was not claimed for this step."
|
||||||
|
)
|
||||||
|
if attempt["outcome"] == "interrupted":
|
||||||
|
if attempt["result_code"] == result_code:
|
||||||
|
return _view(record)
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"The release attempt already has a different interruption outcome."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
attempt["outcome"] != "running"
|
||||||
|
or step["state"] != "running"
|
||||||
|
or step["attempt_fingerprint"] != attempt_fingerprint
|
||||||
|
):
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"The release step is not running with this attempt identifier."
|
||||||
|
)
|
||||||
|
step.update(
|
||||||
|
{
|
||||||
|
"state": "interrupted",
|
||||||
|
"finished_at": _timestamp(),
|
||||||
|
"result_code": result_code,
|
||||||
|
"result_receipt": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
attempt["outcome"] = "interrupted"
|
||||||
|
attempt["result_code"] = result_code
|
||||||
|
_append_event(
|
||||||
|
record,
|
||||||
|
event_type="step_interrupted",
|
||||||
|
step_id=step_id,
|
||||||
|
result_code=result_code,
|
||||||
|
)
|
||||||
|
self._persist_update(record)
|
||||||
|
return _view(record)
|
||||||
|
|
||||||
|
def current_attempt_fingerprint(self, run_id: str, step_id: str) -> str:
|
||||||
|
"""Return the opaque current fingerprint for server-side artifact recovery."""
|
||||||
|
|
||||||
|
_validate_step_id(step_id)
|
||||||
|
with self._locked():
|
||||||
|
record = self._read(run_id)
|
||||||
|
step, _plan_step = _step_pair(record, step_id)
|
||||||
|
fingerprint = step.get("attempt_fingerprint")
|
||||||
|
if not isinstance(fingerprint, str):
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"Release step has no current attempt to reconcile."
|
||||||
|
)
|
||||||
|
return fingerprint
|
||||||
|
|
||||||
def _persist_update(self, record: dict[str, Any]) -> None:
|
def _persist_update(self, record: dict[str, Any]) -> None:
|
||||||
self._assert_workspace(record)
|
self._assert_workspace(record)
|
||||||
record["updated_at"] = _timestamp()
|
record["updated_at"] = _timestamp()
|
||||||
@@ -638,6 +773,9 @@ class ReleaseRunStore:
|
|||||||
) from exc
|
) from exc
|
||||||
_validate_record(payload, expected_run_id=run_id)
|
_validate_record(payload, expected_run_id=run_id)
|
||||||
self._assert_workspace(payload)
|
self._assert_workspace(payload)
|
||||||
|
if payload["schema_version"] in LEGACY_SCHEMA_VERSIONS:
|
||||||
|
payload = _upgrade_record(payload)
|
||||||
|
self._atomic_write(path, payload)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def _read_optional(self, run_id: str) -> dict[str, Any] | None:
|
def _read_optional(self, run_id: str) -> dict[str, Any] | None:
|
||||||
@@ -888,7 +1026,9 @@ def _validated_input(value: dict[str, Any]) -> dict[str, Any]:
|
|||||||
raise ReleaseRunConflict("Release run input fields are invalid.")
|
raise ReleaseRunConflict("Release run input fields are invalid.")
|
||||||
channel = value.get("channel")
|
channel = value.get("channel")
|
||||||
repo_versions = value.get("repo_versions")
|
repo_versions = value.get("repo_versions")
|
||||||
if not isinstance(channel, str) or not CHANNEL_PATTERN.fullmatch(channel):
|
try:
|
||||||
|
channel = validate_release_channel(channel)
|
||||||
|
except CandidateArtifactError:
|
||||||
raise ReleaseRunConflict("Release channel is invalid.")
|
raise ReleaseRunConflict("Release channel is invalid.")
|
||||||
if (
|
if (
|
||||||
not isinstance(repo_versions, dict)
|
not isinstance(repo_versions, dict)
|
||||||
@@ -924,7 +1064,7 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]:
|
|||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise ReleaseRunConflict("Release plan snapshot must be an object.")
|
raise ReleaseRunConflict("Release plan snapshot must be an object.")
|
||||||
copied = deepcopy(value)
|
copied = deepcopy(value)
|
||||||
if set(copied) != {
|
base_fields = {
|
||||||
"generated_at",
|
"generated_at",
|
||||||
"target_channel",
|
"target_channel",
|
||||||
"status",
|
"status",
|
||||||
@@ -935,15 +1075,32 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"gate_findings",
|
"gate_findings",
|
||||||
"recommended_action",
|
"recommended_action",
|
||||||
"source_preflight_ready",
|
"source_preflight_ready",
|
||||||
}:
|
}
|
||||||
|
if set(copied) not in (base_fields, base_fields | {"runtime_binding"}):
|
||||||
raise ReleaseRunConflict("Release plan snapshot fields are invalid.")
|
raise ReleaseRunConflict("Release plan snapshot fields are invalid.")
|
||||||
|
if "runtime_binding" in copied:
|
||||||
|
runtime_binding = copied["runtime_binding"]
|
||||||
|
validated_runtime = _validated_result_receipt(runtime_binding)
|
||||||
|
if (
|
||||||
|
validated_runtime != runtime_binding
|
||||||
|
or runtime_binding.get("kind") != "repository_state"
|
||||||
|
or runtime_binding.get("repo") != "govoplan"
|
||||||
|
or runtime_binding.get("target_tag") != ""
|
||||||
|
or runtime_binding.get("tag_object") is not None
|
||||||
|
or runtime_binding.get("worktree_clean") is not True
|
||||||
|
):
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"Release plan runtime binding is invalid."
|
||||||
|
)
|
||||||
if not _is_timestamp(copied.get("generated_at")):
|
if not _is_timestamp(copied.get("generated_at")):
|
||||||
raise ReleaseRunConflict("Release plan timestamp is invalid.")
|
raise ReleaseRunConflict("Release plan timestamp is invalid.")
|
||||||
if copied.get("status") not in PLAN_STATUSES:
|
if copied.get("status") not in PLAN_STATUSES:
|
||||||
raise ReleaseRunConflict("Release plan status is invalid.")
|
raise ReleaseRunConflict("Release plan status is invalid.")
|
||||||
if not isinstance(
|
try:
|
||||||
copied.get("target_channel"), str
|
target_channel = validate_release_channel(copied.get("target_channel"))
|
||||||
) or not CHANNEL_PATTERN.fullmatch(copied["target_channel"]):
|
except CandidateArtifactError:
|
||||||
|
raise ReleaseRunConflict("Release plan channel is invalid.")
|
||||||
|
if target_channel != copied["target_channel"]:
|
||||||
raise ReleaseRunConflict("Release plan channel is invalid.")
|
raise ReleaseRunConflict("Release plan channel is invalid.")
|
||||||
if not isinstance(copied.get("units"), list):
|
if not isinstance(copied.get("units"), list):
|
||||||
raise ReleaseRunConflict("Release plan units are invalid.")
|
raise ReleaseRunConflict("Release plan units are invalid.")
|
||||||
@@ -969,7 +1126,7 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]:
|
|||||||
raise ReleaseRunConflict("Release plan step collection is invalid.")
|
raise ReleaseRunConflict("Release plan step collection is invalid.")
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for step in steps:
|
for step in steps:
|
||||||
if not isinstance(step, dict) or set(step) != {
|
if not isinstance(step, dict) or set(step) not in ({
|
||||||
"id",
|
"id",
|
||||||
"title",
|
"title",
|
||||||
"detail",
|
"detail",
|
||||||
@@ -978,7 +1135,17 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"mutating",
|
"mutating",
|
||||||
"repo",
|
"repo",
|
||||||
"status",
|
"status",
|
||||||
}:
|
}, {
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"detail",
|
||||||
|
"command",
|
||||||
|
"cwd",
|
||||||
|
"mutating",
|
||||||
|
"repo",
|
||||||
|
"status",
|
||||||
|
"source_binding",
|
||||||
|
}):
|
||||||
raise ReleaseRunConflict("Release plan step is invalid.")
|
raise ReleaseRunConflict("Release plan step is invalid.")
|
||||||
step_id = step.get("id")
|
step_id = step.get("id")
|
||||||
_validate_step_id(step_id)
|
_validate_step_id(step_id)
|
||||||
@@ -997,6 +1164,21 @@ def _validated_plan(value: dict[str, Any]) -> dict[str, Any]:
|
|||||||
not isinstance(step[field], str) or len(step[field]) > 8_192
|
not isinstance(step[field], str) or len(step[field]) > 8_192
|
||||||
):
|
):
|
||||||
raise ReleaseRunConflict("Release plan step metadata is invalid.")
|
raise ReleaseRunConflict("Release plan step metadata is invalid.")
|
||||||
|
if "source_binding" in step:
|
||||||
|
binding = step["source_binding"]
|
||||||
|
if binding is not None:
|
||||||
|
validated_binding = _validated_result_receipt(binding)
|
||||||
|
if (
|
||||||
|
validated_binding != binding
|
||||||
|
or binding.get("kind") != "repository_state"
|
||||||
|
or (
|
||||||
|
step.get("repo") is not None
|
||||||
|
and binding.get("repo") != step.get("repo")
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"Release plan source binding is invalid."
|
||||||
|
)
|
||||||
encoded = _canonical_json(copied)
|
encoded = _canonical_json(copied)
|
||||||
if len(encoded) > MAX_RECORD_BYTES:
|
if len(encoded) > MAX_RECORD_BYTES:
|
||||||
raise ReleaseRunConflict("Release plan snapshot exceeds its size limit.")
|
raise ReleaseRunConflict("Release plan snapshot exceeds its size limit.")
|
||||||
@@ -1022,9 +1204,11 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None:
|
|||||||
if (
|
if (
|
||||||
value.get("schema") != SCHEMA
|
value.get("schema") != SCHEMA
|
||||||
or type(value.get("schema_version")) is not int
|
or type(value.get("schema_version")) is not int
|
||||||
or value["schema_version"] != SCHEMA_VERSION
|
or value["schema_version"]
|
||||||
|
not in {SCHEMA_VERSION, *LEGACY_SCHEMA_VERSIONS}
|
||||||
):
|
):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
|
schema_version = value["schema_version"]
|
||||||
run_id = value.get("run_id")
|
run_id = value.get("run_id")
|
||||||
if not isinstance(run_id, str) or not RUN_ID_PATTERN.fullmatch(run_id):
|
if not isinstance(run_id, str) or not RUN_ID_PATTERN.fullmatch(run_id):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
@@ -1084,10 +1268,7 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None:
|
|||||||
if not isinstance(steps, list) or len(steps) != len(plan_steps):
|
if not isinstance(steps, list) or len(steps) != len(plan_steps):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
for step, plan_step in zip(steps, plan_steps, strict=True):
|
for step, plan_step in zip(steps, plan_steps, strict=True):
|
||||||
if (
|
expected_step_fields = {
|
||||||
not isinstance(step, dict)
|
|
||||||
or set(step)
|
|
||||||
!= {
|
|
||||||
"id",
|
"id",
|
||||||
"state",
|
"state",
|
||||||
"attempt_count",
|
"attempt_count",
|
||||||
@@ -1096,6 +1277,11 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None:
|
|||||||
"finished_at",
|
"finished_at",
|
||||||
"result_code",
|
"result_code",
|
||||||
}
|
}
|
||||||
|
if schema_version >= 4:
|
||||||
|
expected_step_fields.add("result_receipt")
|
||||||
|
if (
|
||||||
|
not isinstance(step, dict)
|
||||||
|
or set(step) != expected_step_fields
|
||||||
or step.get("id") != plan_step["id"]
|
or step.get("id") != plan_step["id"]
|
||||||
):
|
):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
@@ -1114,6 +1300,9 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None:
|
|||||||
result_code = step.get("result_code")
|
result_code = step.get("result_code")
|
||||||
if result_code is not None:
|
if result_code is not None:
|
||||||
_safe_code(result_code)
|
_safe_code(result_code)
|
||||||
|
receipt = _validated_result_receipt(step.get("result_receipt"))
|
||||||
|
if receipt != step.get("result_receipt"):
|
||||||
|
raise ValueError
|
||||||
_validate_step_state_fields(step)
|
_validate_step_state_fields(step)
|
||||||
for timestamp in (step.get("started_at"), step.get("finished_at")):
|
for timestamp in (step.get("started_at"), step.get("finished_at")):
|
||||||
if timestamp is not None and not (
|
if timestamp is not None and not (
|
||||||
@@ -1221,7 +1410,8 @@ def _validate_record(value: Any, *, expected_run_id: str | None = None) -> None:
|
|||||||
_safe_code(attempt_result)
|
_safe_code(attempt_result)
|
||||||
if (
|
if (
|
||||||
attempt["outcome"] == "interrupted"
|
attempt["outcome"] == "interrupted"
|
||||||
and attempt_result != "process_interrupted"
|
and attempt_result
|
||||||
|
not in {"process_interrupted", "executor_outcome_unknown"}
|
||||||
):
|
):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
if len({attempt["fingerprint"] for attempt in attempts}) != len(attempts):
|
if len({attempt["fingerprint"] for attempt in attempts}) != len(attempts):
|
||||||
@@ -1550,6 +1740,7 @@ def _project_finish(
|
|||||||
"state": target_state,
|
"state": target_state,
|
||||||
"finished_at": PROJECTION_TIMESTAMP,
|
"finished_at": PROJECTION_TIMESTAMP,
|
||||||
"result_code": PROJECTION_RESULT_CODE,
|
"result_code": PROJECTION_RESULT_CODE,
|
||||||
|
"result_receipt": _projection_receipt() if succeeded else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
attempt["outcome"] = target_state
|
attempt["outcome"] = target_state
|
||||||
@@ -1565,6 +1756,22 @@ def _project_finish(
|
|||||||
return projected
|
return projected
|
||||||
|
|
||||||
|
|
||||||
|
def _projection_receipt() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"kind": "catalog_publication",
|
||||||
|
"candidate_id": "candidate-" + "c" * 32,
|
||||||
|
"catalog_sha256": "c" * 64,
|
||||||
|
"keyring_sha256": "c" * 64,
|
||||||
|
"publication_commit_sha": "c" * 64,
|
||||||
|
"publication_tag_object_sha": "c" * 64,
|
||||||
|
"publication_tag_commit_sha": "c" * 64,
|
||||||
|
"branch": "b" * 255,
|
||||||
|
"tag_name": "t" * 160,
|
||||||
|
"remote": "origin",
|
||||||
|
"remote_sha256": "c" * 64,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _project_resume(record: dict[str, Any]) -> dict[str, Any]:
|
def _project_resume(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
projected = deepcopy(record)
|
projected = deepcopy(record)
|
||||||
running_steps = [
|
running_steps = [
|
||||||
@@ -1615,6 +1822,7 @@ def _project_retry(record: dict[str, Any], step_id: str) -> dict[str, Any]:
|
|||||||
"started_at": None,
|
"started_at": None,
|
||||||
"finished_at": None,
|
"finished_at": None,
|
||||||
"result_code": None,
|
"result_code": None,
|
||||||
|
"result_receipt": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_remember_request(
|
_remember_request(
|
||||||
@@ -1650,6 +1858,7 @@ def _project_reconcile(
|
|||||||
"started_at": None,
|
"started_at": None,
|
||||||
"finished_at": None,
|
"finished_at": None,
|
||||||
"result_code": None,
|
"result_code": None,
|
||||||
|
"result_receipt": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif outcome == "effect_succeeded":
|
elif outcome == "effect_succeeded":
|
||||||
@@ -1658,6 +1867,7 @@ def _project_reconcile(
|
|||||||
"state": "succeeded",
|
"state": "succeeded",
|
||||||
"finished_at": PROJECTION_TIMESTAMP,
|
"finished_at": PROJECTION_TIMESTAMP,
|
||||||
"result_code": "reconciled_effect_succeeded",
|
"result_code": "reconciled_effect_succeeded",
|
||||||
|
"result_receipt": _projection_receipt(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_remember_request(
|
_remember_request(
|
||||||
@@ -1944,6 +2154,118 @@ def _safe_code(value: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_result_receipt(
|
||||||
|
value: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, dict) or value.get("kind") not in RECEIPT_KINDS:
|
||||||
|
raise ReleaseRunConflict("Release result receipt is invalid.")
|
||||||
|
if value["kind"] == "catalog_candidate":
|
||||||
|
if set(value) != {"kind", "candidate_id", "catalog_sha256"}:
|
||||||
|
raise ReleaseRunConflict("Release result receipt is invalid.")
|
||||||
|
candidate_id = value.get("candidate_id")
|
||||||
|
digest = value.get("catalog_sha256")
|
||||||
|
if (
|
||||||
|
not isinstance(candidate_id, str)
|
||||||
|
or not RECEIPT_ID_PATTERN.fullmatch(candidate_id)
|
||||||
|
or not _is_sha256(digest)
|
||||||
|
):
|
||||||
|
raise ReleaseRunConflict("Release result receipt identity is invalid.")
|
||||||
|
return dict(value)
|
||||||
|
if value["kind"] == "catalog_publication":
|
||||||
|
if set(value) != {
|
||||||
|
"kind",
|
||||||
|
"candidate_id",
|
||||||
|
"catalog_sha256",
|
||||||
|
"keyring_sha256",
|
||||||
|
"publication_commit_sha",
|
||||||
|
"publication_tag_object_sha",
|
||||||
|
"publication_tag_commit_sha",
|
||||||
|
"branch",
|
||||||
|
"tag_name",
|
||||||
|
"remote",
|
||||||
|
"remote_sha256",
|
||||||
|
}:
|
||||||
|
raise ReleaseRunConflict("Catalog publication receipt is invalid.")
|
||||||
|
candidate_id = value.get("candidate_id")
|
||||||
|
commit = value.get("publication_commit_sha")
|
||||||
|
tagged_commit = value.get("publication_tag_commit_sha")
|
||||||
|
if (
|
||||||
|
not isinstance(candidate_id, str)
|
||||||
|
or not RECEIPT_ID_PATTERN.fullmatch(candidate_id)
|
||||||
|
or not _is_sha256(value.get("catalog_sha256"))
|
||||||
|
or not _is_sha256(value.get("keyring_sha256"))
|
||||||
|
or not isinstance(commit, str)
|
||||||
|
or not GIT_OBJECT_PATTERN.fullmatch(commit)
|
||||||
|
or not isinstance(value.get("publication_tag_object_sha"), str)
|
||||||
|
or not GIT_OBJECT_PATTERN.fullmatch(
|
||||||
|
value["publication_tag_object_sha"]
|
||||||
|
)
|
||||||
|
or not isinstance(tagged_commit, str)
|
||||||
|
or not GIT_OBJECT_PATTERN.fullmatch(tagged_commit)
|
||||||
|
or tagged_commit != commit
|
||||||
|
or not isinstance(value.get("branch"), str)
|
||||||
|
or not value["branch"]
|
||||||
|
or len(value["branch"]) > 255
|
||||||
|
or not isinstance(value.get("tag_name"), str)
|
||||||
|
or not value["tag_name"]
|
||||||
|
or len(value["tag_name"]) > 160
|
||||||
|
or value.get("remote") != "origin"
|
||||||
|
or not _is_sha256(value.get("remote_sha256"))
|
||||||
|
):
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"Catalog publication receipt identity is invalid."
|
||||||
|
)
|
||||||
|
return dict(value)
|
||||||
|
if set(value) != {
|
||||||
|
"kind",
|
||||||
|
"repo",
|
||||||
|
"head",
|
||||||
|
"branch",
|
||||||
|
"remote",
|
||||||
|
"remote_sha256",
|
||||||
|
"worktree_clean",
|
||||||
|
"target_tag",
|
||||||
|
"tag_object",
|
||||||
|
}:
|
||||||
|
raise ReleaseRunConflict("Repository result receipt is invalid.")
|
||||||
|
if (
|
||||||
|
not isinstance(value.get("repo"), str)
|
||||||
|
or not REPOSITORY_PATTERN.fullmatch(value["repo"])
|
||||||
|
or not isinstance(value.get("head"), str)
|
||||||
|
or not GIT_OBJECT_PATTERN.fullmatch(value["head"])
|
||||||
|
or not isinstance(value.get("branch"), str)
|
||||||
|
or not value["branch"]
|
||||||
|
or len(value["branch"]) > 255
|
||||||
|
or value.get("remote") != "origin"
|
||||||
|
or not _is_sha256(value.get("remote_sha256"))
|
||||||
|
or type(value.get("worktree_clean")) is not bool
|
||||||
|
or not isinstance(value.get("target_tag"), str)
|
||||||
|
or len(value["target_tag"]) > 160
|
||||||
|
or (
|
||||||
|
value.get("tag_object") is not None
|
||||||
|
and (
|
||||||
|
not isinstance(value["tag_object"], str)
|
||||||
|
or not GIT_OBJECT_PATTERN.fullmatch(value["tag_object"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ReleaseRunConflict("Repository result receipt identity is invalid.")
|
||||||
|
return dict(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _upgrade_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
upgraded = deepcopy(record)
|
||||||
|
if upgraded.get("schema_version") == 3:
|
||||||
|
for step in upgraded["state"]["steps"]:
|
||||||
|
step["result_receipt"] = None
|
||||||
|
upgraded["schema_version"] = SCHEMA_VERSION
|
||||||
|
upgraded["record_digest"] = _record_digest(upgraded)
|
||||||
|
_validate_record(upgraded)
|
||||||
|
return upgraded
|
||||||
|
|
||||||
|
|
||||||
def _is_sha256(value: Any) -> bool:
|
def _is_sha256(value: Any) -> bool:
|
||||||
return isinstance(value, str) and bool(re.fullmatch(r"[0-9a-f]{64}", value))
|
return isinstance(value, str) and bool(re.fullmatch(r"[0-9a-f]{64}", value))
|
||||||
|
|
||||||
@@ -1967,6 +2289,7 @@ def _validate_step_state_fields(step: dict[str, Any]) -> None:
|
|||||||
started_at = step["started_at"]
|
started_at = step["started_at"]
|
||||||
finished_at = step["finished_at"]
|
finished_at = step["finished_at"]
|
||||||
result_code = step["result_code"]
|
result_code = step["result_code"]
|
||||||
|
result_receipt = step.get("result_receipt")
|
||||||
if started_at is not None and not _is_timestamp(started_at):
|
if started_at is not None and not _is_timestamp(started_at):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
if finished_at is not None and not _is_timestamp(finished_at):
|
if finished_at is not None and not _is_timestamp(finished_at):
|
||||||
@@ -1976,19 +2299,34 @@ def _validate_step_state_fields(step: dict[str, Any]) -> None:
|
|||||||
if state == "pending":
|
if state == "pending":
|
||||||
if any(
|
if any(
|
||||||
value is not None
|
value is not None
|
||||||
for value in (fingerprint, started_at, finished_at, result_code)
|
for value in (
|
||||||
|
fingerprint,
|
||||||
|
started_at,
|
||||||
|
finished_at,
|
||||||
|
result_code,
|
||||||
|
result_receipt,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
return
|
return
|
||||||
if attempt_count < 1 or fingerprint is None or started_at is None:
|
if attempt_count < 1 or fingerprint is None or started_at is None:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
if state == "running":
|
if state == "running":
|
||||||
if finished_at is not None or result_code is not None:
|
if (
|
||||||
|
finished_at is not None
|
||||||
|
or result_code is not None
|
||||||
|
or result_receipt is not None
|
||||||
|
):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
return
|
return
|
||||||
if finished_at is None or result_code is None:
|
if finished_at is None or result_code is None:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
if state == "interrupted" and result_code != "process_interrupted":
|
if state != "succeeded" and result_receipt is not None:
|
||||||
|
raise ValueError
|
||||||
|
if state == "interrupted" and result_code not in {
|
||||||
|
"process_interrupted",
|
||||||
|
"executor_outcome_unknown",
|
||||||
|
}:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
|
|
||||||
|
|
||||||
@@ -2014,7 +2352,8 @@ def _validate_event_fields(event: dict[str, Any]) -> None:
|
|||||||
raise ValueError
|
raise ValueError
|
||||||
if (
|
if (
|
||||||
event_type == "step_interrupted"
|
event_type == "step_interrupted"
|
||||||
and event["result_code"] != "process_interrupted"
|
and event["result_code"]
|
||||||
|
not in {"process_interrupted", "executor_outcome_unknown"}
|
||||||
):
|
):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
|
|
||||||
|
|||||||
@@ -501,6 +501,24 @@ def dry_run_steps(
|
|||||||
) -> tuple[ReleasePlanStep, ...]:
|
) -> tuple[ReleasePlanStep, ...]:
|
||||||
steps: list[ReleasePlanStep] = []
|
steps: list[ReleasePlanStep] = []
|
||||||
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
|
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
|
||||||
|
selected_names = {unit.repo for unit in units}
|
||||||
|
if "govoplan-core" in selected_names and len(selected_names) > 1:
|
||||||
|
steps.append(
|
||||||
|
ReleasePlanStep(
|
||||||
|
id="release:cross-repository-ordering",
|
||||||
|
title="Resolve module/Core release ordering",
|
||||||
|
detail=(
|
||||||
|
"A combined Core/module release needs a dependency-aware DAG: "
|
||||||
|
"local module tags, Core release-lock regeneration and commit, "
|
||||||
|
"Core tag, full alignment, then atomic pushes. The current linear "
|
||||||
|
"executor must not publish a module before that lock is committed."
|
||||||
|
),
|
||||||
|
command=None,
|
||||||
|
cwd=dashboard.meta_root,
|
||||||
|
mutating=True,
|
||||||
|
status="needs-executor",
|
||||||
|
)
|
||||||
|
)
|
||||||
for unit in units:
|
for unit in units:
|
||||||
steps.append(
|
steps.append(
|
||||||
ReleasePlanStep(
|
ReleasePlanStep(
|
||||||
@@ -618,6 +636,7 @@ def dry_run_steps(
|
|||||||
f'--candidate-dir "$CANDIDATE_DIR" --channel {shlex.quote(channel)}'
|
f'--candidate-dir "$CANDIDATE_DIR" --channel {shlex.quote(channel)}'
|
||||||
),
|
),
|
||||||
cwd=dashboard.meta_root,
|
cwd=dashboard.meta_root,
|
||||||
|
mutating=True,
|
||||||
status="planned",
|
status="planned",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ from typing import Literal
|
|||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
from govoplan_release import (
|
from govoplan_release import (
|
||||||
build_dashboard,
|
build_dashboard,
|
||||||
build_release_intelligence,
|
build_release_intelligence,
|
||||||
build_release_plan,
|
build_release_plan,
|
||||||
build_selective_catalog_candidate,
|
|
||||||
build_selective_release_plan,
|
build_selective_release_plan,
|
||||||
publish_catalog_candidate,
|
publish_catalog_candidate,
|
||||||
prepare_repositories,
|
prepare_repositories,
|
||||||
@@ -24,13 +23,37 @@ from govoplan_release import (
|
|||||||
tag_repositories,
|
tag_repositories,
|
||||||
)
|
)
|
||||||
from govoplan_release.model import to_jsonable
|
from govoplan_release.model import to_jsonable
|
||||||
|
from govoplan_release.candidate_artifact import (
|
||||||
|
CandidateArtifactError,
|
||||||
|
issue_candidate_id,
|
||||||
|
validate_release_channel,
|
||||||
|
verify_candidate_receipt,
|
||||||
|
)
|
||||||
|
from govoplan_release.release_execution import (
|
||||||
|
DURABLE_REMOTE,
|
||||||
|
ReleaseExecutionAmbiguous,
|
||||||
|
ReleaseExecutionBlocked,
|
||||||
|
bind_plan_source_states,
|
||||||
|
execute_repository_step,
|
||||||
|
executor_spec,
|
||||||
|
generate_catalog_candidate,
|
||||||
|
publish_received_candidate,
|
||||||
|
require_trusted_release_runtime,
|
||||||
|
reconciled_catalog_publication_receipt,
|
||||||
|
reconciled_repository_receipt,
|
||||||
|
validated_candidate_receipt,
|
||||||
|
verify_catalog_publication_precondition,
|
||||||
|
verify_repository_preflight_binding,
|
||||||
|
verify_repository_step_precondition,
|
||||||
|
verify_release_runtime_binding,
|
||||||
|
)
|
||||||
from govoplan_release.release_run import (
|
from govoplan_release.release_run import (
|
||||||
ReleaseRunConflict,
|
ReleaseRunConflict,
|
||||||
ReleaseRunCorrupt,
|
ReleaseRunCorrupt,
|
||||||
ReleaseRunNotFound,
|
ReleaseRunNotFound,
|
||||||
ReleaseRunStore,
|
ReleaseRunStore,
|
||||||
)
|
)
|
||||||
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT
|
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT, website_root
|
||||||
|
|
||||||
|
|
||||||
class CatalogCandidateRequest(BaseModel):
|
class CatalogCandidateRequest(BaseModel):
|
||||||
@@ -41,6 +64,7 @@ class CatalogCandidateRequest(BaseModel):
|
|||||||
signing_keys: list[str] = Field(default_factory=list)
|
signing_keys: list[str] = Field(default_factory=list)
|
||||||
output_dir: str | None = None
|
output_dir: str | None = None
|
||||||
base_catalog: str | None = None
|
base_catalog: str | None = None
|
||||||
|
base_keyring: str | None = None
|
||||||
expires_days: int = 90
|
expires_days: int = 90
|
||||||
sequence: int | None = None
|
sequence: int | None = None
|
||||||
public_base_url: str = "https://govoplan.add-ideas.de"
|
public_base_url: str = "https://govoplan.add-ideas.de"
|
||||||
@@ -48,6 +72,11 @@ class CatalogCandidateRequest(BaseModel):
|
|||||||
source_remote: str = "origin"
|
source_remote: str = "origin"
|
||||||
check_public: bool = True
|
check_public: bool = True
|
||||||
|
|
||||||
|
@field_validator("channel")
|
||||||
|
@classmethod
|
||||||
|
def validate_channel(cls, value: str) -> str:
|
||||||
|
return validate_release_channel(value)
|
||||||
|
|
||||||
|
|
||||||
class PublishCandidateRequest(BaseModel):
|
class PublishCandidateRequest(BaseModel):
|
||||||
candidate_dir: str
|
candidate_dir: str
|
||||||
@@ -66,17 +95,22 @@ class PublishCandidateRequest(BaseModel):
|
|||||||
allow_dirty_website: bool = False
|
allow_dirty_website: bool = False
|
||||||
confirm: str = ""
|
confirm: str = ""
|
||||||
|
|
||||||
|
@field_validator("channel")
|
||||||
|
@classmethod
|
||||||
|
def validate_channel(cls, value: str) -> str:
|
||||||
|
return validate_release_channel(value)
|
||||||
|
|
||||||
|
|
||||||
class RepositoryPushRequest(BaseModel):
|
class RepositoryPushRequest(BaseModel):
|
||||||
repos: list[str] = Field(default_factory=list)
|
repos: list[str] = Field(default_factory=list)
|
||||||
remote: str = "origin"
|
remote: Literal["origin"] = "origin"
|
||||||
apply: bool = False # noqa: A003 - API field mirrors CLI.
|
apply: bool = False # noqa: A003 - API field mirrors CLI.
|
||||||
confirm: str = ""
|
confirm: str = ""
|
||||||
|
|
||||||
|
|
||||||
class RepositorySyncRequest(BaseModel):
|
class RepositorySyncRequest(BaseModel):
|
||||||
repos: list[str] = Field(default_factory=list)
|
repos: list[str] = Field(default_factory=list)
|
||||||
remote: str = "origin"
|
remote: Literal["origin"] = "origin"
|
||||||
apply: bool = False # noqa: A003 - API field mirrors CLI.
|
apply: bool = False # noqa: A003 - API field mirrors CLI.
|
||||||
confirm: str = ""
|
confirm: str = ""
|
||||||
|
|
||||||
@@ -108,6 +142,11 @@ class ReleaseRunCreateRequest(BaseModel):
|
|||||||
public_catalog: bool = True
|
public_catalog: bool = True
|
||||||
include_migrations: bool = False
|
include_migrations: bool = False
|
||||||
|
|
||||||
|
@field_validator("channel")
|
||||||
|
@classmethod
|
||||||
|
def validate_channel(cls, value: str) -> str:
|
||||||
|
return validate_release_channel(value)
|
||||||
|
|
||||||
|
|
||||||
class ReleaseRunCommandRequest(BaseModel):
|
class ReleaseRunCommandRequest(BaseModel):
|
||||||
request_id: str = Field(min_length=8, max_length=128)
|
request_id: str = Field(min_length=8, max_length=128)
|
||||||
@@ -119,11 +158,23 @@ class ReleaseRunReconcileRequest(BaseModel):
|
|||||||
confirm: str = Field(default="", max_length=32)
|
confirm: str = Field(default="", max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseRunExecuteRequest(BaseModel):
|
||||||
|
request_id: str = Field(min_length=8, max_length=128)
|
||||||
|
confirm: str = Field(default="", max_length=32)
|
||||||
|
remote: Literal["origin"] = "origin"
|
||||||
|
signing_keys: list[str] = Field(default_factory=list, max_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseRunPreviewRequest(BaseModel):
|
||||||
|
remote: Literal["origin"] = "origin"
|
||||||
|
|
||||||
|
|
||||||
def create_app(
|
def create_app(
|
||||||
*,
|
*,
|
||||||
workspace_root: Path = DEFAULT_WORKSPACE_ROOT,
|
workspace_root: Path = DEFAULT_WORKSPACE_ROOT,
|
||||||
token: str | None = None,
|
token: str | None = None,
|
||||||
run_state_root: Path | None = None,
|
run_state_root: Path | None = None,
|
||||||
|
candidate_root: Path | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
app = FastAPI(title="GovOPlaN Release Console", version="0.1.0")
|
app = FastAPI(title="GovOPlaN Release Console", version="0.1.0")
|
||||||
app.state.workspace_root = workspace_root
|
app.state.workspace_root = workspace_root
|
||||||
@@ -140,13 +191,31 @@ def create_app(
|
|||||||
release_run_root,
|
release_run_root,
|
||||||
expected_workspace_fingerprint=app.state.workspace_fingerprint,
|
expected_workspace_fingerprint=app.state.workspace_fingerprint,
|
||||||
)
|
)
|
||||||
|
app.state.release_candidate_root = (
|
||||||
|
Path(os.path.abspath(os.fspath(candidate_root.expanduser())))
|
||||||
|
if candidate_root is not None
|
||||||
|
else release_run_root.parent / "release-candidates"
|
||||||
|
)
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def require_token(request: Request, call_next): # type: ignore[no-untyped-def]
|
async def require_token(request: Request, call_next): # type: ignore[no-untyped-def]
|
||||||
if token and request.url.path.startswith("/api/"):
|
if request.url.path.startswith("/api/"):
|
||||||
|
if token:
|
||||||
provided = request.headers.get("x-release-console-token")
|
provided = request.headers.get("x-release-console-token")
|
||||||
if provided != token:
|
if provided != token:
|
||||||
return JSONResponse({"detail": "release console token required"}, status_code=401)
|
return JSONResponse(
|
||||||
|
{"detail": "release console token required"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
elif request.method not in {"GET", "HEAD", "OPTIONS"}:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"detail": (
|
||||||
|
"release console is read-only without an API token"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
@@ -174,6 +243,7 @@ def create_app(
|
|||||||
include_website: bool = False,
|
include_website: bool = False,
|
||||||
channel: str = "stable",
|
channel: str = "stable",
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
channel = http_release_channel(channel)
|
||||||
snapshot = build_dashboard(
|
snapshot = build_dashboard(
|
||||||
workspace_root=app.state.workspace_root,
|
workspace_root=app.state.workspace_root,
|
||||||
target_version=target_version,
|
target_version=target_version,
|
||||||
@@ -195,6 +265,7 @@ def create_app(
|
|||||||
include_migrations: bool = False,
|
include_migrations: bool = False,
|
||||||
channel: str = "stable",
|
channel: str = "stable",
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
channel = http_release_channel(channel)
|
||||||
snapshot = build_dashboard(
|
snapshot = build_dashboard(
|
||||||
workspace_root=app.state.workspace_root,
|
workspace_root=app.state.workspace_root,
|
||||||
target_version=target_version,
|
target_version=target_version,
|
||||||
@@ -212,6 +283,7 @@ def create_app(
|
|||||||
channel: str = "stable",
|
channel: str = "stable",
|
||||||
public_catalog: bool = True,
|
public_catalog: bool = True,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
channel = http_release_channel(channel)
|
||||||
intelligence = build_release_intelligence(
|
intelligence = build_release_intelligence(
|
||||||
workspace_root=app.state.workspace_root,
|
workspace_root=app.state.workspace_root,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
@@ -230,6 +302,7 @@ def create_app(
|
|||||||
include_migrations: bool = False,
|
include_migrations: bool = False,
|
||||||
channel: str = "stable",
|
channel: str = "stable",
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
channel = http_release_channel(channel)
|
||||||
snapshot = build_dashboard(
|
snapshot = build_dashboard(
|
||||||
workspace_root=app.state.workspace_root,
|
workspace_root=app.state.workspace_root,
|
||||||
target_version=target_version,
|
target_version=target_version,
|
||||||
@@ -280,7 +353,13 @@ def create_app(
|
|||||||
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
|
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
return release_run_view(existing)
|
||||||
|
try:
|
||||||
|
require_trusted_release_runtime(
|
||||||
|
workspace_root=app.state.workspace_root
|
||||||
|
)
|
||||||
|
except ReleaseExecutionBlocked as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
snapshot = build_dashboard(
|
snapshot = build_dashboard(
|
||||||
workspace_root=app.state.workspace_root,
|
workspace_root=app.state.workspace_root,
|
||||||
online=request.online,
|
online=request.online,
|
||||||
@@ -319,18 +398,29 @@ def create_app(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return app.state.release_runs.create(
|
plan_payload = bind_plan_source_states(
|
||||||
|
plan=plan_payload,
|
||||||
|
repo_versions=dict(request.repo_versions),
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
)
|
||||||
|
return release_run_view(
|
||||||
|
app.state.release_runs.create(
|
||||||
request_id=request.request_id,
|
request_id=request.request_id,
|
||||||
input_snapshot=input_snapshot,
|
input_snapshot=input_snapshot,
|
||||||
plan_snapshot=plan_payload,
|
plan_snapshot=plan_payload,
|
||||||
)
|
)
|
||||||
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
|
)
|
||||||
|
except (
|
||||||
|
ReleaseExecutionBlocked,
|
||||||
|
ReleaseRunConflict,
|
||||||
|
ReleaseRunCorrupt,
|
||||||
|
) as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
@app.get("/api/release-runs/{run_id}")
|
@app.get("/api/release-runs/{run_id}")
|
||||||
def release_run(run_id: str) -> dict[str, object]:
|
def release_run(run_id: str) -> dict[str, object]:
|
||||||
try:
|
try:
|
||||||
return app.state.release_runs.get(run_id)
|
return release_run_view(app.state.release_runs.get(run_id))
|
||||||
except ReleaseRunNotFound as exc:
|
except ReleaseRunNotFound as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
|
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
|
||||||
@@ -341,7 +431,11 @@ def create_app(
|
|||||||
run_id: str, request: ReleaseRunCommandRequest
|
run_id: str, request: ReleaseRunCommandRequest
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
try:
|
try:
|
||||||
return app.state.release_runs.resume(run_id, request_id=request.request_id)
|
return release_run_view(
|
||||||
|
app.state.release_runs.resume(
|
||||||
|
run_id, request_id=request.request_id
|
||||||
|
)
|
||||||
|
)
|
||||||
except ReleaseRunNotFound as exc:
|
except ReleaseRunNotFound as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
|
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
|
||||||
@@ -352,11 +446,13 @@ def create_app(
|
|||||||
run_id: str, step_id: str, request: ReleaseRunCommandRequest
|
run_id: str, step_id: str, request: ReleaseRunCommandRequest
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
try:
|
try:
|
||||||
return app.state.release_runs.retry_step(
|
return release_run_view(
|
||||||
|
app.state.release_runs.retry_step(
|
||||||
run_id,
|
run_id,
|
||||||
step_id,
|
step_id,
|
||||||
request_id=request.request_id,
|
request_id=request.request_id,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
except ReleaseRunNotFound as exc:
|
except ReleaseRunNotFound as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
|
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
|
||||||
@@ -367,53 +463,442 @@ def create_app(
|
|||||||
run_id: str, step_id: str, request: ReleaseRunReconcileRequest
|
run_id: str, step_id: str, request: ReleaseRunReconcileRequest
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
try:
|
try:
|
||||||
return app.state.release_runs.reconcile_step(
|
receipt = None
|
||||||
|
run = app.state.release_runs.get(run_id)
|
||||||
|
state_step = release_run_state_step(run, step_id)
|
||||||
|
immutable = run.get("immutable")
|
||||||
|
frozen_plan = (
|
||||||
|
immutable.get("plan") if isinstance(immutable, dict) else None
|
||||||
|
)
|
||||||
|
verify_release_runtime_binding(
|
||||||
|
expected=(
|
||||||
|
frozen_plan.get("runtime_binding")
|
||||||
|
if isinstance(frozen_plan, dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
request.outcome == "effect_succeeded"
|
||||||
|
and state_step.get("state") == "succeeded"
|
||||||
|
and state_step.get("result_code") == "reconciled_effect_succeeded"
|
||||||
|
):
|
||||||
|
receipt = state_step.get("result_receipt")
|
||||||
|
if (
|
||||||
|
request.outcome == "effect_succeeded"
|
||||||
|
and step_id == "catalog:selective-generator"
|
||||||
|
and state_step.get("state") == "interrupted"
|
||||||
|
):
|
||||||
|
attempt_fingerprint = (
|
||||||
|
app.state.release_runs.current_attempt_fingerprint(
|
||||||
|
run_id, step_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
candidate_id = issue_candidate_id(
|
||||||
|
run_id, step_id, attempt_fingerprint
|
||||||
|
)
|
||||||
|
candidate_receipt = validated_candidate_receipt(
|
||||||
|
candidate_root=app.state.release_candidate_root,
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
channel=app.state.release_runs.get(run_id)["immutable"]["input"][
|
||||||
|
"channel"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
receipt = {
|
||||||
|
"kind": "catalog_candidate",
|
||||||
|
"candidate_id": candidate_receipt.candidate_id,
|
||||||
|
"catalog_sha256": candidate_receipt.catalog_sha256,
|
||||||
|
}
|
||||||
|
elif (
|
||||||
|
request.outcome == "effect_succeeded"
|
||||||
|
and step_id == "catalog:validate-sign-publish"
|
||||||
|
and state_step.get("state") == "interrupted"
|
||||||
|
):
|
||||||
|
plan_step = release_run_plan_step(run, step_id)
|
||||||
|
candidate_receipt = release_run_candidate_receipt(run)
|
||||||
|
receipt = reconciled_catalog_publication_receipt(
|
||||||
|
candidate_path=verified_run_candidate(
|
||||||
|
run,
|
||||||
|
candidate_root=app.state.release_candidate_root,
|
||||||
|
),
|
||||||
|
candidate_receipt=candidate_receipt,
|
||||||
|
channel=run["immutable"]["input"]["channel"],
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
remote=DURABLE_REMOTE,
|
||||||
|
expected_website_receipt=plan_step.get("source_binding"),
|
||||||
|
)
|
||||||
|
elif request.outcome == "effect_succeeded" and state_step.get(
|
||||||
|
"state"
|
||||||
|
) == "interrupted":
|
||||||
|
plan_step = release_run_plan_step(run, step_id)
|
||||||
|
spec = executor_spec(plan_step)
|
||||||
|
if spec is not None and spec.kind in {"tag", "push"}:
|
||||||
|
repo = str(plan_step["repo"])
|
||||||
|
receipt = reconciled_repository_receipt(
|
||||||
|
spec=spec,
|
||||||
|
plan_step=plan_step,
|
||||||
|
version=run["immutable"]["input"]["repo_versions"][repo],
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
expected_receipt=preceding_repository_receipt(
|
||||||
|
run, step_id=step_id, repo=repo
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return release_run_view(
|
||||||
|
app.state.release_runs.reconcile_step(
|
||||||
run_id,
|
run_id,
|
||||||
step_id,
|
step_id,
|
||||||
request_id=request.request_id,
|
request_id=request.request_id,
|
||||||
outcome=request.outcome,
|
outcome=request.outcome,
|
||||||
confirmation=request.confirm,
|
confirmation=request.confirm,
|
||||||
|
result_receipt=receipt,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except ReleaseRunNotFound as exc:
|
except ReleaseRunNotFound as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
|
except (
|
||||||
|
CandidateArtifactError,
|
||||||
|
ReleaseExecutionBlocked,
|
||||||
|
ReleaseRunConflict,
|
||||||
|
ReleaseRunCorrupt,
|
||||||
|
) as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
@app.post("/api/release-runs/{run_id}/steps/{step_id}/execute")
|
||||||
|
def execute_release_run_step(
|
||||||
|
run_id: str, step_id: str, request: ReleaseRunExecuteRequest
|
||||||
|
) -> dict[str, object]:
|
||||||
|
claim_acquired = False
|
||||||
|
try:
|
||||||
|
run = app.state.release_runs.get(run_id)
|
||||||
|
plan_step = release_run_plan_step(run, step_id)
|
||||||
|
immutable = run.get("immutable")
|
||||||
|
frozen_plan = (
|
||||||
|
immutable.get("plan") if isinstance(immutable, dict) else None
|
||||||
|
)
|
||||||
|
verify_release_runtime_binding(
|
||||||
|
expected=(
|
||||||
|
frozen_plan.get("runtime_binding")
|
||||||
|
if isinstance(frozen_plan, dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
)
|
||||||
|
spec = executor_spec(plan_step)
|
||||||
|
if spec is None:
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"This frozen plan step has no bounded durable executor."
|
||||||
|
)
|
||||||
|
if request.confirm != spec.confirmation:
|
||||||
|
required = spec.confirmation or "an empty confirmation"
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
f"Release step requires exactly {required}."
|
||||||
|
)
|
||||||
|
expected_repository_receipt = None
|
||||||
|
claim = app.state.release_runs.claim_step(
|
||||||
|
run_id,
|
||||||
|
step_id,
|
||||||
|
attempt_id=request.request_id,
|
||||||
|
)
|
||||||
|
if not claim.claimed:
|
||||||
|
if claim.outcome == "succeeded":
|
||||||
|
replay = release_run_view(claim.run)
|
||||||
|
replay["execution_result"] = {
|
||||||
|
"status": "replayed",
|
||||||
|
"result_code": claim.result_code,
|
||||||
|
"result_receipt": claim.result_receipt,
|
||||||
|
}
|
||||||
|
return replay
|
||||||
|
if claim.outcome == "running":
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"This exact executor attempt is still running or its response "
|
||||||
|
"is uncertain; resume and reconcile it before retrying."
|
||||||
|
)
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"This exact executor attempt already ended; use the run's explicit "
|
||||||
|
"retry or reconciliation action."
|
||||||
|
)
|
||||||
|
claim_acquired = True
|
||||||
|
signing_keys = tuple(request.signing_keys or default_signing_keys())
|
||||||
|
if spec.kind == "catalog_generate" and not signing_keys:
|
||||||
|
raise ReleaseExecutionBlocked(
|
||||||
|
"Durable catalog generation requires a configured signing key."
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate_path = None
|
||||||
|
candidate_receipt = None
|
||||||
|
website_receipt = None
|
||||||
|
if spec.kind == "catalog_publish":
|
||||||
|
candidate_path = verified_run_candidate(
|
||||||
|
run,
|
||||||
|
candidate_root=app.state.release_candidate_root,
|
||||||
|
)
|
||||||
|
candidate_receipt = release_run_candidate_receipt(run)
|
||||||
|
website_receipt = verify_catalog_publication_precondition(
|
||||||
|
plan_step=plan_step,
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
)
|
||||||
|
if spec.kind in {"preflight", "tag", "push"}:
|
||||||
|
repo = str(plan_step["repo"])
|
||||||
|
version = run["immutable"]["input"]["repo_versions"][repo]
|
||||||
|
expected_repository_receipt = preceding_repository_receipt(
|
||||||
|
run, step_id=step_id, repo=repo
|
||||||
|
)
|
||||||
|
if spec.kind == "preflight":
|
||||||
|
verify_repository_preflight_binding(
|
||||||
|
plan_step=plan_step,
|
||||||
|
version=version,
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
verify_repository_step_precondition(
|
||||||
|
spec=spec,
|
||||||
|
plan_step=plan_step,
|
||||||
|
version=version,
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
expected_receipt=expected_repository_receipt,
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt = None
|
||||||
|
if spec.kind in {"preflight", "tag", "push"}:
|
||||||
|
result, receipt = execute_repository_step(
|
||||||
|
spec=spec,
|
||||||
|
plan_step=plan_step,
|
||||||
|
repo_versions=run["immutable"]["input"]["repo_versions"],
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
remote=DURABLE_REMOTE,
|
||||||
|
expected_receipt=expected_repository_receipt,
|
||||||
|
)
|
||||||
|
elif spec.kind == "catalog_generate":
|
||||||
|
attempt_fingerprint = (
|
||||||
|
app.state.release_runs.current_attempt_fingerprint(
|
||||||
|
run_id, step_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
candidate_id = issue_candidate_id(
|
||||||
|
run_id, step_id, attempt_fingerprint
|
||||||
|
)
|
||||||
|
base_catalog, base_keyring = release_catalog_base_paths(
|
||||||
|
app.state.workspace_root,
|
||||||
|
channel=run["immutable"]["input"]["channel"],
|
||||||
|
)
|
||||||
|
result, candidate_receipt = generate_catalog_candidate(
|
||||||
|
candidate_root=app.state.release_candidate_root,
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
channel=run["immutable"]["input"]["channel"],
|
||||||
|
repo_versions=run["immutable"]["input"]["repo_versions"],
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
signing_keys=signing_keys,
|
||||||
|
remote=DURABLE_REMOTE,
|
||||||
|
check_public=run["immutable"]["input"]["public_catalog"],
|
||||||
|
source_receipts={
|
||||||
|
repo: receipt
|
||||||
|
for repo in run["immutable"]["input"]["repo_versions"]
|
||||||
|
if isinstance(
|
||||||
|
(
|
||||||
|
receipt := preceding_repository_receipt(
|
||||||
|
run, step_id=step_id, repo=repo
|
||||||
|
)
|
||||||
|
),
|
||||||
|
dict,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
base_catalog=base_catalog,
|
||||||
|
base_keyring=base_keyring,
|
||||||
|
)
|
||||||
|
receipt = {
|
||||||
|
"kind": "catalog_candidate",
|
||||||
|
"candidate_id": candidate_receipt.candidate_id,
|
||||||
|
"catalog_sha256": candidate_receipt.catalog_sha256,
|
||||||
|
}
|
||||||
|
elif (
|
||||||
|
spec.kind == "catalog_publish"
|
||||||
|
and candidate_path is not None
|
||||||
|
and candidate_receipt is not None
|
||||||
|
and website_receipt is not None
|
||||||
|
):
|
||||||
|
candidate_path = verified_run_candidate(
|
||||||
|
run,
|
||||||
|
candidate_root=app.state.release_candidate_root,
|
||||||
|
)
|
||||||
|
result, receipt = publish_received_candidate(
|
||||||
|
candidate_path=candidate_path,
|
||||||
|
candidate_receipt=candidate_receipt,
|
||||||
|
channel=run["immutable"]["input"]["channel"],
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
remote=DURABLE_REMOTE,
|
||||||
|
expected_website_receipt=website_receipt,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
verified_run_candidate(
|
||||||
|
run,
|
||||||
|
candidate_root=app.state.release_candidate_root,
|
||||||
|
)
|
||||||
|
except CandidateArtifactError as exc:
|
||||||
|
raise ReleaseExecutionAmbiguous(
|
||||||
|
"Published candidate changed while its effect was in flight; "
|
||||||
|
"reconcile the immutable website commit and remote tag."
|
||||||
|
) from exc
|
||||||
|
else:
|
||||||
|
raise ReleaseRunConflict("Release executor mapping is incomplete.")
|
||||||
|
|
||||||
|
finished = app.state.release_runs.finish_step(
|
||||||
|
run_id,
|
||||||
|
step_id,
|
||||||
|
attempt_id=request.request_id,
|
||||||
|
succeeded=True,
|
||||||
|
result_code=spec.result_code,
|
||||||
|
result_receipt=receipt,
|
||||||
|
)
|
||||||
|
response = release_run_view(finished)
|
||||||
|
response["execution_result"] = {
|
||||||
|
"status": "succeeded",
|
||||||
|
"result_code": spec.result_code,
|
||||||
|
"result_receipt": receipt,
|
||||||
|
"output": result,
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
except ReleaseExecutionBlocked as exc:
|
||||||
|
if not claim_acquired:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)[:4000]) from exc
|
||||||
|
failed = app.state.release_runs.finish_step(
|
||||||
|
run_id,
|
||||||
|
step_id,
|
||||||
|
attempt_id=request.request_id,
|
||||||
|
succeeded=False,
|
||||||
|
result_code="executor_blocked",
|
||||||
|
)
|
||||||
|
response = release_run_view(failed)
|
||||||
|
response["execution_result"] = {
|
||||||
|
"status": "failed",
|
||||||
|
"result_code": "executor_blocked",
|
||||||
|
"detail": str(exc)[:4000],
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
except ReleaseExecutionAmbiguous as exc:
|
||||||
|
app.state.release_runs.interrupt_step(
|
||||||
|
run_id,
|
||||||
|
step_id,
|
||||||
|
attempt_id=request.request_id,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)[:4000]) from exc
|
||||||
|
except ReleaseRunNotFound as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except CandidateArtifactError as exc:
|
||||||
|
if claim_acquired:
|
||||||
|
failed = app.state.release_runs.finish_step(
|
||||||
|
run_id,
|
||||||
|
step_id,
|
||||||
|
attempt_id=request.request_id,
|
||||||
|
succeeded=False,
|
||||||
|
result_code="candidate_invalid",
|
||||||
|
)
|
||||||
|
response = release_run_view(failed)
|
||||||
|
response["execution_result"] = {
|
||||||
|
"status": "failed",
|
||||||
|
"result_code": "candidate_invalid",
|
||||||
|
"detail": str(exc)[:4000],
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except ReleaseRunCorrupt as exc:
|
||||||
|
if claim_acquired:
|
||||||
|
try:
|
||||||
|
app.state.release_runs.interrupt_step(
|
||||||
|
run_id,
|
||||||
|
step_id,
|
||||||
|
attempt_id=request.request_id,
|
||||||
|
)
|
||||||
|
except (ReleaseRunConflict, ReleaseRunCorrupt, ReleaseRunNotFound):
|
||||||
|
pass
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
"The executor outcome could not be persisted safely; resume "
|
||||||
|
"and reconcile this attempt before retrying."
|
||||||
|
),
|
||||||
|
) from exc
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except ReleaseRunConflict as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except Exception as exc: # noqa: BLE001 - never retry an uncertain effect.
|
||||||
|
try:
|
||||||
|
app.state.release_runs.interrupt_step(
|
||||||
|
run_id,
|
||||||
|
step_id,
|
||||||
|
attempt_id=request.request_id,
|
||||||
|
)
|
||||||
|
except (ReleaseRunConflict, ReleaseRunCorrupt, ReleaseRunNotFound):
|
||||||
|
pass
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
f"Executor raised {type(exc).__name__}; its effect is unknown. "
|
||||||
|
"Resume and reconcile before retry."
|
||||||
|
),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
@app.post("/api/release-runs/{run_id}/steps/{step_id}/preview")
|
||||||
|
def preview_release_run_step(
|
||||||
|
run_id: str, step_id: str, request: ReleaseRunPreviewRequest
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
run = app.state.release_runs.get(run_id)
|
||||||
|
plan_step = release_run_plan_step(run, step_id)
|
||||||
|
spec = executor_spec(plan_step)
|
||||||
|
if spec is None or spec.kind != "catalog_publish":
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"Only the receipt-bound catalog publication step has this preview."
|
||||||
|
)
|
||||||
|
candidate = verified_run_candidate(
|
||||||
|
run,
|
||||||
|
candidate_root=app.state.release_candidate_root,
|
||||||
|
)
|
||||||
|
return to_jsonable(
|
||||||
|
publish_catalog_candidate(
|
||||||
|
candidate_dir=candidate,
|
||||||
|
workspace_root=app.state.workspace_root,
|
||||||
|
channel=run["immutable"]["input"]["channel"],
|
||||||
|
remote=DURABLE_REMOTE,
|
||||||
|
source_remote=DURABLE_REMOTE,
|
||||||
|
apply=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except ReleaseRunNotFound as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except (
|
||||||
|
CandidateArtifactError,
|
||||||
|
ReleaseRunConflict,
|
||||||
|
ReleaseRunCorrupt,
|
||||||
|
) as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
@app.get("/api/catalog-candidates")
|
@app.get("/api/catalog-candidates")
|
||||||
def catalog_candidates() -> dict[str, object]:
|
def catalog_candidates() -> dict[str, object]:
|
||||||
return {"candidates": list_catalog_candidates()}
|
return {
|
||||||
|
"candidates": list_catalog_candidates(app.state.release_candidate_root)
|
||||||
|
}
|
||||||
|
|
||||||
@app.post("/api/catalog-candidates")
|
@app.post("/api/catalog-candidates")
|
||||||
def catalog_candidate(request: CatalogCandidateRequest) -> dict[str, object]:
|
def catalog_candidate(request: CatalogCandidateRequest) -> dict[str, object]:
|
||||||
repo_versions = dict(request.repo_versions)
|
del request
|
||||||
if not repo_versions and request.repos and request.target_version:
|
raise HTTPException(
|
||||||
repo_versions = {repo: request.target_version for repo in request.repos}
|
status_code=409,
|
||||||
if not repo_versions:
|
detail=(
|
||||||
raise HTTPException(status_code=400, detail="repo_versions or repos with target_version is required")
|
"Signed catalog generation is available only through a durable "
|
||||||
signing_keys = tuple(request.signing_keys or default_signing_keys())
|
"release run with a trusted runtime and receipt-bound source tags."
|
||||||
if not signing_keys:
|
),
|
||||||
raise HTTPException(status_code=400, detail="No signing key supplied and default release key was not found")
|
|
||||||
try:
|
|
||||||
candidate = build_selective_catalog_candidate(
|
|
||||||
repo_versions=repo_versions,
|
|
||||||
channel=request.channel,
|
|
||||||
workspace_root=app.state.workspace_root,
|
|
||||||
output_dir=request.output_dir,
|
|
||||||
base_catalog=request.base_catalog,
|
|
||||||
signing_keys=signing_keys,
|
|
||||||
public_base_url=request.public_base_url,
|
|
||||||
repository_base=request.repository_base,
|
|
||||||
source_remote=request.source_remote,
|
|
||||||
expires_days=request.expires_days,
|
|
||||||
sequence=request.sequence,
|
|
||||||
check_public=request.check_public,
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
return to_jsonable(candidate)
|
|
||||||
|
|
||||||
@app.post("/api/catalog-candidates/publish")
|
@app.post("/api/catalog-candidates/publish")
|
||||||
def publish_candidate(request: PublishCandidateRequest) -> dict[str, object]:
|
def publish_candidate(request: PublishCandidateRequest) -> dict[str, object]:
|
||||||
|
if request.apply or request.commit or request.tag or request.push or request.build_web:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
"Catalog mutation is available only through a durable release run "
|
||||||
|
"with a server-issued candidate receipt."
|
||||||
|
),
|
||||||
|
)
|
||||||
if request.push and request.confirm != "PUSH":
|
if request.push and request.confirm != "PUSH":
|
||||||
raise HTTPException(status_code=400, detail="Push requires confirm=PUSH")
|
raise HTTPException(status_code=400, detail="Push requires confirm=PUSH")
|
||||||
if (request.apply or request.commit or request.tag or request.build_web) and not request.push and request.confirm != "APPLY":
|
if (request.apply or request.commit or request.tag or request.build_web) and not request.push and request.confirm != "APPLY":
|
||||||
@@ -442,8 +927,14 @@ def create_app(
|
|||||||
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
|
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
|
||||||
if not repos:
|
if not repos:
|
||||||
raise HTTPException(status_code=400, detail="At least one repository must be selected")
|
raise HTTPException(status_code=400, detail="At least one repository must be selected")
|
||||||
if request.apply and request.confirm != "PUSH":
|
if request.apply:
|
||||||
raise HTTPException(status_code=400, detail="Repository push requires confirm=PUSH")
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
"Repository push mutation is disabled outside a durable "
|
||||||
|
"receipt-bound maintenance run."
|
||||||
|
),
|
||||||
|
)
|
||||||
result = push_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply)
|
result = push_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply)
|
||||||
return to_jsonable(result)
|
return to_jsonable(result)
|
||||||
|
|
||||||
@@ -452,8 +943,14 @@ def create_app(
|
|||||||
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
|
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
|
||||||
if not repos:
|
if not repos:
|
||||||
raise HTTPException(status_code=400, detail="At least one repository must be selected")
|
raise HTTPException(status_code=400, detail="At least one repository must be selected")
|
||||||
if request.apply and request.confirm != "SYNC":
|
if request.apply:
|
||||||
raise HTTPException(status_code=400, detail="Repository sync requires confirm=SYNC")
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
"Repository sync mutation is disabled outside a durable "
|
||||||
|
"receipt-bound maintenance run."
|
||||||
|
),
|
||||||
|
)
|
||||||
result = sync_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply)
|
result = sync_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply)
|
||||||
return to_jsonable(result)
|
return to_jsonable(result)
|
||||||
|
|
||||||
@@ -462,6 +959,15 @@ def create_app(
|
|||||||
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
|
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
|
||||||
if not repos:
|
if not repos:
|
||||||
raise HTTPException(status_code=400, detail="At least one repository must be selected")
|
raise HTTPException(status_code=400, detail="At least one repository must be selected")
|
||||||
|
if request.apply:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
"Release preparation mutation is disabled outside a durable "
|
||||||
|
"release executor; use preview and commit reviewed changes "
|
||||||
|
"outside this console for now."
|
||||||
|
),
|
||||||
|
)
|
||||||
if request.apply and request.confirm != "COMMIT":
|
if request.apply and request.confirm != "COMMIT":
|
||||||
raise HTTPException(status_code=400, detail="Repository prepare requires confirm=COMMIT")
|
raise HTTPException(status_code=400, detail="Repository prepare requires confirm=COMMIT")
|
||||||
result = prepare_repositories(
|
result = prepare_repositories(
|
||||||
@@ -478,6 +984,14 @@ def create_app(
|
|||||||
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
|
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
|
||||||
if not repos:
|
if not repos:
|
||||||
raise HTTPException(status_code=400, detail="At least one repository must be selected")
|
raise HTTPException(status_code=400, detail="At least one repository must be selected")
|
||||||
|
if request.apply:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
"Release tag mutation is available only through a durable "
|
||||||
|
"release run with creation-time repository bindings."
|
||||||
|
),
|
||||||
|
)
|
||||||
if request.apply and request.push and request.confirm != "PUBLISH":
|
if request.apply and request.push and request.confirm != "PUBLISH":
|
||||||
raise HTTPException(status_code=400, detail="Release tag publication requires confirm=PUBLISH")
|
raise HTTPException(status_code=400, detail="Release tag publication requires confirm=PUBLISH")
|
||||||
if request.apply and not request.push and request.confirm != "TAG":
|
if request.apply and not request.push and request.confirm != "TAG":
|
||||||
@@ -496,6 +1010,164 @@ def create_app(
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def release_run_view(run: dict[str, object]) -> dict[str, object]:
|
||||||
|
state = run.get("state")
|
||||||
|
immutable = run.get("immutable")
|
||||||
|
plan = immutable.get("plan") if isinstance(immutable, dict) else None
|
||||||
|
steps = state.get("steps", []) if isinstance(state, dict) else []
|
||||||
|
plan_steps = plan.get("dry_run_steps", []) if isinstance(plan, dict) else []
|
||||||
|
if isinstance(steps, list) and isinstance(plan_steps, list):
|
||||||
|
plan_by_id = {
|
||||||
|
step.get("id"): step for step in plan_steps if isinstance(step, dict)
|
||||||
|
}
|
||||||
|
for step in steps:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
plan_step = plan_by_id.get(step.get("id"))
|
||||||
|
spec = executor_spec(plan_step) if isinstance(plan_step, dict) else None
|
||||||
|
step["executor"] = {
|
||||||
|
"available": spec is not None,
|
||||||
|
"kind": spec.kind if spec is not None else None,
|
||||||
|
"confirmation": spec.confirmation if spec is not None else None,
|
||||||
|
}
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def release_run_plan_step(
|
||||||
|
run: dict[str, object], step_id: str
|
||||||
|
) -> dict[str, object]:
|
||||||
|
immutable = run.get("immutable")
|
||||||
|
plan = immutable.get("plan") if isinstance(immutable, dict) else None
|
||||||
|
steps = plan.get("dry_run_steps") if isinstance(plan, dict) else None
|
||||||
|
if not isinstance(steps, list):
|
||||||
|
raise ReleaseRunCorrupt("Release run plan steps are unavailable.")
|
||||||
|
for step in steps:
|
||||||
|
if isinstance(step, dict) and step.get("id") == step_id:
|
||||||
|
return step
|
||||||
|
raise ReleaseRunNotFound("Release run step was not found.")
|
||||||
|
|
||||||
|
|
||||||
|
def release_run_state_step(
|
||||||
|
run: dict[str, object], step_id: str
|
||||||
|
) -> dict[str, object]:
|
||||||
|
state = run.get("state")
|
||||||
|
steps = state.get("steps") if isinstance(state, dict) else None
|
||||||
|
if not isinstance(steps, list):
|
||||||
|
raise ReleaseRunCorrupt("Release run state steps are unavailable.")
|
||||||
|
for step in steps:
|
||||||
|
if isinstance(step, dict) and step.get("id") == step_id:
|
||||||
|
return step
|
||||||
|
raise ReleaseRunNotFound("Release run step was not found.")
|
||||||
|
|
||||||
|
|
||||||
|
def preceding_repository_receipt(
|
||||||
|
run: dict[str, object], *, step_id: str, repo: str
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
immutable = run.get("immutable")
|
||||||
|
plan = immutable.get("plan") if isinstance(immutable, dict) else None
|
||||||
|
plan_steps = plan.get("dry_run_steps") if isinstance(plan, dict) else None
|
||||||
|
state = run.get("state")
|
||||||
|
state_steps = state.get("steps") if isinstance(state, dict) else None
|
||||||
|
if not isinstance(plan_steps, list) or not isinstance(state_steps, list):
|
||||||
|
raise ReleaseRunCorrupt("Release run repository receipt chain is unavailable.")
|
||||||
|
pairs = list(zip(plan_steps, state_steps, strict=True))
|
||||||
|
current_index = next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index, (planned, _mutable) in enumerate(pairs)
|
||||||
|
if isinstance(planned, dict) and planned.get("id") == step_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if current_index is None:
|
||||||
|
raise ReleaseRunNotFound("Release run step was not found.")
|
||||||
|
for planned, mutable in reversed(pairs[:current_index]):
|
||||||
|
if not isinstance(planned, dict) or planned.get("repo") != repo:
|
||||||
|
continue
|
||||||
|
if not isinstance(mutable, dict) or mutable.get("state") != "succeeded":
|
||||||
|
return None
|
||||||
|
receipt = mutable.get("result_receipt")
|
||||||
|
return (
|
||||||
|
receipt
|
||||||
|
if isinstance(receipt, dict)
|
||||||
|
and receipt.get("kind") == "repository_state"
|
||||||
|
and receipt.get("repo") == repo
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verified_run_candidate(
|
||||||
|
run: dict[str, object], *, candidate_root: Path
|
||||||
|
) -> Path:
|
||||||
|
receipt, channel = release_run_candidate_receipt(run, include_channel=True)
|
||||||
|
return verify_candidate_receipt(
|
||||||
|
root=candidate_root,
|
||||||
|
candidate_id=str(receipt.get("candidate_id") or ""),
|
||||||
|
catalog_sha256=str(receipt.get("catalog_sha256") or ""),
|
||||||
|
channel=channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def release_run_candidate_receipt(
|
||||||
|
run: dict[str, object], *, include_channel: bool = False
|
||||||
|
) -> dict[str, object] | tuple[dict[str, object], str]:
|
||||||
|
state = run.get("state")
|
||||||
|
steps = state.get("steps") if isinstance(state, dict) else None
|
||||||
|
input_snapshot = (
|
||||||
|
run.get("immutable", {}).get("input", {})
|
||||||
|
if isinstance(run.get("immutable"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
channel = input_snapshot.get("channel") if isinstance(input_snapshot, dict) else None
|
||||||
|
if not isinstance(steps, list) or not isinstance(channel, str):
|
||||||
|
raise ReleaseRunCorrupt("Release run candidate dependency is unavailable.")
|
||||||
|
generator = next(
|
||||||
|
(
|
||||||
|
step
|
||||||
|
for step in steps
|
||||||
|
if isinstance(step, dict)
|
||||||
|
and step.get("id") == "catalog:selective-generator"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
receipt = generator.get("result_receipt") if isinstance(generator, dict) else None
|
||||||
|
if (
|
||||||
|
not isinstance(generator, dict)
|
||||||
|
or generator.get("state") != "succeeded"
|
||||||
|
or not isinstance(receipt, dict)
|
||||||
|
or receipt.get("kind") != "catalog_candidate"
|
||||||
|
):
|
||||||
|
raise ReleaseRunConflict(
|
||||||
|
"Catalog publication requires the successful generator's durable receipt."
|
||||||
|
)
|
||||||
|
copied = dict(receipt)
|
||||||
|
return (copied, channel) if include_channel else copied
|
||||||
|
|
||||||
|
|
||||||
|
def default_release_candidate_root(workspace_root: Path) -> Path:
|
||||||
|
return default_release_run_root(workspace_root).parent / "release-candidates"
|
||||||
|
|
||||||
|
|
||||||
|
def release_catalog_base_paths(
|
||||||
|
workspace_root: Path, *, channel: str
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
"""Resolve durable generation trust material only from the website checkout."""
|
||||||
|
|
||||||
|
catalog_root = website_root(workspace_root) / "public" / "catalogs" / "v1"
|
||||||
|
return (
|
||||||
|
catalog_root / "channels" / f"{validate_release_channel(channel)}.json",
|
||||||
|
catalog_root / "keyring.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def http_release_channel(value: str) -> str:
|
||||||
|
try:
|
||||||
|
return validate_release_channel(value)
|
||||||
|
except CandidateArtifactError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
def default_release_run_root(workspace_root: Path) -> Path:
|
def default_release_run_root(workspace_root: Path) -> Path:
|
||||||
configured_state_home = os.environ.get("XDG_STATE_HOME", "").strip()
|
configured_state_home = os.environ.get("XDG_STATE_HOME", "").strip()
|
||||||
state_home = Path(configured_state_home).expanduser()
|
state_home = Path(configured_state_home).expanduser()
|
||||||
@@ -552,8 +1224,8 @@ def default_signing_keys() -> tuple[str, ...]:
|
|||||||
return (f"release-key-1={key_path}",)
|
return (f"release-key-1={key_path}",)
|
||||||
|
|
||||||
|
|
||||||
def list_catalog_candidates() -> list[dict[str, object]]:
|
def list_catalog_candidates(root: Path | None = None) -> list[dict[str, object]]:
|
||||||
root = META_ROOT / "runtime" / "release-candidates"
|
root = root or (META_ROOT / "runtime" / "release-candidates")
|
||||||
if not root.exists():
|
if not root.exists():
|
||||||
return []
|
return []
|
||||||
candidates: list[dict[str, object]] = []
|
candidates: list[dict[str, object]] = []
|
||||||
|
|||||||
@@ -608,7 +608,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="units-toolbar">
|
<div class="units-toolbar">
|
||||||
<button id="buildSelectedPlan">Build Plan</button>
|
<button id="buildSelectedPlan">Build Plan</button>
|
||||||
<button id="generateSelectedCandidate">Generate Candidate</button>
|
<button id="generateSelectedCandidate" disabled data-release-disabled="true" title="Use the receipt-bound generator in Durable Run State.">Generate Candidate</button>
|
||||||
<button class="secondary" id="selectUnpushedUnits" title="Select repositories with committed branch changes to push.">Select Unpushed</button>
|
<button class="secondary" id="selectUnpushedUnits" title="Select repositories with committed branch changes to push.">Select Unpushed</button>
|
||||||
<button class="secondary" id="selectChangedUnits" title="Select repositories with dirty/untracked paths, missing HEAD, or unpushed commits.">Select Dirty/Changed</button>
|
<button class="secondary" id="selectChangedUnits" title="Select repositories with dirty/untracked paths, missing HEAD, or unpushed commits.">Select Dirty/Changed</button>
|
||||||
<button class="secondary" id="selectUnreleasedUnits">Select Unreleased</button>
|
<button class="secondary" id="selectUnreleasedUnits">Select Unreleased</button>
|
||||||
@@ -642,7 +642,7 @@
|
|||||||
<small id="runStatus">no run selected</small>
|
<small id="runStatus">no run selected</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<p class="hint"><strong>State tracking only.</strong> A run freezes the selected inputs and plan, guides recovery, and records bounded state events. It does not execute or confirm tags, signing, publication, or catalog changes.</p>
|
<p class="hint"><strong>Durable execution.</strong> A run freezes the selected inputs and plan, claims each supported executor before its effect, and keeps explicit confirmation and reconciliation boundaries. Unsupported dependency-ordering or version-metadata steps stay visible and disabled.</p>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="releaseRun">Saved run</label>
|
<label for="releaseRun">Saved run</label>
|
||||||
@@ -678,7 +678,7 @@
|
|||||||
<small id="pushStatus"></small>
|
<small id="pushStatus"></small>
|
||||||
</div>
|
</div>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<p class="hint">Sync selected fetches remote refs and tags only. It does not merge, rebase, commit, tag, or push.</p>
|
<p class="hint">Repository sync and push are preview-only until they have a receipt-bound durable maintenance executor.</p>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="pushRemote">Remote</label>
|
<label for="pushRemote">Remote</label>
|
||||||
@@ -691,9 +691,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="secondary" id="previewRepoSync">Preview Sync</button>
|
<button class="secondary" id="previewRepoSync">Preview Sync</button>
|
||||||
<button id="syncRepos">Sync Selected</button>
|
<button id="syncRepos" disabled data-release-disabled="true" title="Durable maintenance executor required">Sync Selected</button>
|
||||||
<button class="secondary" id="previewRepoPush">Preview Push</button>
|
<button class="secondary" id="previewRepoPush">Preview Push</button>
|
||||||
<button class="danger" id="pushRepos">Push Selected</button>
|
<button class="danger" id="pushRepos" disabled data-release-disabled="true" title="Durable maintenance executor required">Push Selected</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" id="pushOutput"></div>
|
<div class="actions" id="pushOutput"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -705,7 +705,7 @@
|
|||||||
<small id="prepareStatus"></small>
|
<small id="prepareStatus"></small>
|
||||||
</div>
|
</div>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<p class="hint">Prepare commits selected dirty worktrees only. It does not tag or push.</p>
|
<p class="hint">Preview preparation only. Commit mutation is disabled outside a content-bound durable executor; commit and review dirty or version-changing work outside this console, then build a new run.</p>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="prepareMessage">Commit message</label>
|
<label for="prepareMessage">Commit message</label>
|
||||||
@@ -713,12 +713,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="prepareConfirmText">Confirm</label>
|
<label for="prepareConfirmText">Confirm</label>
|
||||||
<input id="prepareConfirmText" type="text" placeholder="COMMIT" />
|
<input id="prepareConfirmText" type="text" placeholder="durable commit executor pending" disabled />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="secondary" id="previewPrepare">Preview Prepare</button>
|
<button class="secondary" id="previewPrepare">Preview Prepare</button>
|
||||||
<button class="danger" id="commitPrepare">Commit Selected</button>
|
<button class="danger" id="commitPrepare" data-release-disabled="true" disabled title="No content-bound durable commit executor is available yet.">Commit Selected</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" id="prepareOutput"></div>
|
<div class="actions" id="prepareOutput"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -730,7 +730,7 @@
|
|||||||
<small><span id="tagStatus">idle</span> · plan <span id="planStatus">idle</span></small>
|
<small><span id="tagStatus">idle</span> · plan <span id="planStatus">idle</span></small>
|
||||||
</div>
|
</div>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<p class="hint">Creates an annotated source tag at the selected clean HEAD. Publish pushes the branch and tag atomically; existing release tags are never moved.</p>
|
<p class="hint">Preview is available here. Tag creation and publication use the matching durable run steps, bound to the frozen HEAD, branch, clean worktree, and registered origin.</p>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="tagMessage">Tag message</label>
|
<label for="tagMessage">Tag message</label>
|
||||||
@@ -738,13 +738,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="tagConfirmText">Confirm</label>
|
<label for="tagConfirmText">Confirm</label>
|
||||||
<input id="tagConfirmText" type="text" placeholder="TAG or PUBLISH" />
|
<input id="tagConfirmText" type="text" placeholder="use durable run controls" disabled />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="secondary" id="previewReleaseTags">Preview Tag + Publish</button>
|
<button class="secondary" id="previewReleaseTags">Preview Tag + Publish</button>
|
||||||
<button id="createReleaseTags">Create Tags</button>
|
<button id="createReleaseTags" data-release-disabled="true" disabled title="Use the receipt-bound tag step in Durable Run State.">Create Tags</button>
|
||||||
<button class="danger" id="publishReleaseTags">Publish Tags</button>
|
<button class="danger" id="publishReleaseTags" data-release-disabled="true" disabled title="Use the receipt-bound push step in Durable Run State.">Publish Tags</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" id="tagOutput"></div>
|
<div class="actions" id="tagOutput"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -764,18 +764,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="candidateDir">Candidate dir</label>
|
<label for="candidateDir">Candidate dir</label>
|
||||||
<input id="candidateDir" type="text" placeholder="runtime/release-candidates/..." />
|
<input id="candidateDir" type="text" placeholder="private state/release-candidates/candidate-..." />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="confirmText">Confirm</label>
|
<label for="confirmText">Confirm</label>
|
||||||
<input id="confirmText" type="text" placeholder="APPLY or PUSH" />
|
<input id="confirmText" type="text" placeholder="use durable run controls" disabled />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button id="generateCandidate">Generate</button>
|
<button id="generateCandidate" disabled data-release-disabled="true" title="Use the receipt-bound generator in Durable Run State.">Generate</button>
|
||||||
<button class="secondary" id="previewPublish">Preview</button>
|
<button class="secondary" id="previewPublish">Preview</button>
|
||||||
<button class="secondary" id="applyPublish">Apply + Website Tag</button>
|
<button class="secondary" id="applyPublish" data-release-disabled="true" disabled title="Use the receipt-bound publication step in Durable Run State.">Apply + Website Tag</button>
|
||||||
<button class="danger" id="pushPublish">Push Website Release</button>
|
<button class="danger" id="pushPublish" data-release-disabled="true" disabled title="Use the receipt-bound publication step in Durable Run State.">Push Website Release</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" id="workflowOutput"></div>
|
<div class="actions" id="workflowOutput"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1047,23 +1047,23 @@
|
|||||||
renderCatalog(data.catalog, data.target_version);
|
renderCatalog(data.catalog, data.target_version);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderReleaseRunList(runs, options = {}) {
|
function renderReleaseRunList(runs, renderOptions = {}) {
|
||||||
releaseState.runStoreAvailable = true;
|
releaseState.runStoreAvailable = true;
|
||||||
const incoming = Array.isArray(runs) ? runs : [];
|
const incoming = Array.isArray(runs) ? runs : [];
|
||||||
releaseState.runs = options.append
|
releaseState.runs = renderOptions.append
|
||||||
? [...new Map([...releaseState.runs, ...incoming].map((run) => [run.run_id, run])).values()]
|
? [...new Map([...releaseState.runs, ...incoming].map((run) => [run.run_id, run])).values()]
|
||||||
: incoming;
|
: incoming;
|
||||||
releaseState.runNextCursor = options.nextCursor || null;
|
releaseState.runNextCursor = renderOptions.nextCursor || null;
|
||||||
elements.releaseRun.disabled = false;
|
elements.releaseRun.disabled = false;
|
||||||
elements.createReleaseRun.disabled = false;
|
elements.createReleaseRun.disabled = false;
|
||||||
elements.loadOlderReleaseRuns.disabled = !releaseState.runNextCursor;
|
elements.loadOlderReleaseRuns.disabled = !releaseState.runNextCursor;
|
||||||
const selectedId = releaseState.currentRun?.run_id || "";
|
const selectedId = releaseState.currentRun?.run_id || "";
|
||||||
const options = releaseState.runs.map((run) => {
|
const runOptions = releaseState.runs.map((run) => {
|
||||||
const repositories = run.repo_versions ? Object.keys(run.repo_versions).length : 0;
|
const repositories = run.repo_versions ? Object.keys(run.repo_versions).length : 0;
|
||||||
const label = `${run.run_id} · ${run.status || "unknown"} · ${repositories} repos`;
|
const label = `${run.run_id} · ${run.status || "unknown"} · ${repositories} repos`;
|
||||||
return `<option value="${escapeAttr(run.run_id)}" ${run.run_id === selectedId ? "selected" : ""}>${escapeHtml(label)}</option>`;
|
return `<option value="${escapeAttr(run.run_id)}" ${run.run_id === selectedId ? "selected" : ""}>${escapeHtml(label)}</option>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
elements.releaseRun.innerHTML = `<option value="">No saved run selected</option>${options}`;
|
elements.releaseRun.innerHTML = `<option value="">No saved run selected</option>${runOptions}`;
|
||||||
if (selectedId && releaseState.runs.some((run) => run.run_id === selectedId)) {
|
if (selectedId && releaseState.runs.some((run) => run.run_id === selectedId)) {
|
||||||
elements.releaseRun.value = selectedId;
|
elements.releaseRun.value = selectedId;
|
||||||
}
|
}
|
||||||
@@ -1252,8 +1252,15 @@
|
|||||||
const stepHtml = steps.map((step) => {
|
const stepHtml = steps.map((step) => {
|
||||||
const kind = step.state === "succeeded" ? "ok" : ["failed", "interrupted"].includes(step.state) ? "block" : step.available ? "ok" : "warn";
|
const kind = step.state === "succeeded" ? "ok" : ["failed", "interrupted"].includes(step.state) ? "block" : step.available ? "ok" : "warn";
|
||||||
const retryReason = step.retry_available ? "Prepare this known failed or read-only interrupted step for an explicit retry." : (step.disabled_reason || "Retry is unavailable for this state.");
|
const retryReason = step.retry_available ? "Prepare this known failed or read-only interrupted step for an explicit retry." : (step.disabled_reason || "Retry is unavailable for this state.");
|
||||||
|
const executor = step.executor || {};
|
||||||
|
const confirmation = executor.confirmation || "";
|
||||||
|
const executeReason = executor.available ? (step.disabled_reason || "Execute this frozen plan step.") : "No bounded executor is available for this frozen step.";
|
||||||
|
const executorHtml = executor.available ? `<div class="form-grid" data-run-execute-step="${escapeAttr(step.id)}" data-run-execute-confirmation="${escapeAttr(confirmation)}" data-run-execute-available="${step.available ? "true" : "false"}">
|
||||||
|
${confirmation ? `<div><label>Confirmation</label><input type="text" data-run-execute-confirm placeholder="Type ${escapeAttr(confirmation)}" autocomplete="off" /></div>` : ""}
|
||||||
|
<div class="button-row">${executor.kind === "catalog_publish" ? `<button class="secondary" data-run-preview-submit ${step.available ? "" : "disabled"}>Preview receipt-bound candidate</button>` : ""}<button data-run-execute-submit ${step.available && !confirmation ? "" : "disabled"} title="${escapeAttr(executeReason)}">Execute Step</button></div>
|
||||||
|
</div>` : `<p class="action-meta">No durable executor: ${escapeHtml(step.status || "unsupported")}</p>`;
|
||||||
const reconciliationHtml = step.reconciliation_required ? `<div class="form-grid" data-run-reconcile-step="${escapeAttr(step.id)}">
|
const reconciliationHtml = step.reconciliation_required ? `<div class="form-grid" data-run-reconcile-step="${escapeAttr(step.id)}">
|
||||||
<div><label>Observed external effect</label><select data-run-reconcile-outcome><option value="">Choose verified outcome</option><option value="effect_absent">Effect is absent — retry is safe</option><option value="effect_succeeded">Effect succeeded — advance the run</option><option value="unresolved">Still unresolved — keep blocked</option></select></div>
|
<div><label>Observed external effect</label><select data-run-reconcile-outcome><option value="">Choose verified outcome</option><option value="effect_absent">Effect is absent — retry is safe</option><option value="effect_succeeded">Effect succeeded — advance the run after server verification</option><option value="unresolved">Still unresolved — keep blocked</option></select></div>
|
||||||
<div><label>Confirmation</label><input type="text" data-run-reconcile-confirm placeholder="Type RECONCILE" autocomplete="off" /></div>
|
<div><label>Confirmation</label><input type="text" data-run-reconcile-confirm placeholder="Type RECONCILE" autocomplete="off" /></div>
|
||||||
<div class="button-row"><button class="secondary" data-run-reconcile-submit disabled title="Verify local and remote state independently, choose the observed outcome, and type RECONCILE.">Record Reconciliation</button></div>
|
<div class="button-row"><button class="secondary" data-run-reconcile-submit disabled title="Verify local and remote state independently, choose the observed outcome, and type RECONCILE.">Record Reconciliation</button></div>
|
||||||
</div>` : "";
|
</div>` : "";
|
||||||
@@ -1261,6 +1268,8 @@
|
|||||||
<div class="stage-title"><h3>${escapeHtml(`${step.order}. ${step.title || step.id}`)}</h3>${pill(step.state, kind)}</div>
|
<div class="stage-title"><h3>${escapeHtml(`${step.order}. ${step.title || step.id}`)}</h3>${pill(step.state, kind)}</div>
|
||||||
<p>${escapeHtml(step.detail || "")}</p>
|
<p>${escapeHtml(step.detail || "")}</p>
|
||||||
${step.result_code ? `<p class="action-meta">Result code: ${escapeHtml(step.result_code)}</p>` : ""}
|
${step.result_code ? `<p class="action-meta">Result code: ${escapeHtml(step.result_code)}</p>` : ""}
|
||||||
|
${releaseReceiptHtml(step.result_receipt)}
|
||||||
|
${executorHtml}
|
||||||
<div class="button-row"><button class="secondary" data-run-retry-step="${escapeAttr(step.id)}" ${step.retry_available ? "" : "disabled"} title="${escapeAttr(retryReason)}">Prepare Retry</button></div>
|
<div class="button-row"><button class="secondary" data-run-retry-step="${escapeAttr(step.id)}" ${step.retry_available ? "" : "disabled"} title="${escapeAttr(retryReason)}">Prepare Retry</button></div>
|
||||||
${reconciliationHtml}
|
${reconciliationHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -1268,7 +1277,7 @@
|
|||||||
const eventHtml = events.length ? `<div class="action"><h3>Recent bounded state events</h3>${events.map((event) => `<p class="action-meta">${escapeHtml(`${event.at} · ${event.type}${event.step_id ? ` · ${event.step_id}` : ""}${event.result_code ? ` · ${event.result_code}` : ""}`)}</p>`).join("")}</div>` : "";
|
const eventHtml = events.length ? `<div class="action"><h3>Recent bounded state events</h3>${events.map((event) => `<p class="action-meta">${escapeHtml(`${event.at} · ${event.type}${event.step_id ? ` · ${event.step_id}` : ""}${event.result_code ? ` · ${event.result_code}` : ""}`)}</p>`).join("")}</div>` : "";
|
||||||
elements.runStatus.textContent = state.status || "unknown";
|
elements.runStatus.textContent = state.status || "unknown";
|
||||||
elements.resumeReleaseRun.disabled = state.status !== "running";
|
elements.resumeReleaseRun.disabled = state.status !== "running";
|
||||||
elements.runOutput.innerHTML = `<div class="action"><h3>${pill(state.status || "unknown", state.status === "blocked" ? "block" : state.status === "completed" ? "ok" : "warn")} ${escapeHtml(run.run_id)}</h3><p>Frozen plan ${escapeHtml((run.immutable?.digest || "").slice(0, 16))}… · updated ${escapeHtml(run.updated_at || "-")}</p><p class="action-meta">Executor controls remain separate and keep their existing confirmations.</p></div>${recommendationHtml}${stepHtml}${eventHtml}`;
|
elements.runOutput.innerHTML = `<div class="action"><h3>${pill(state.status || "unknown", state.status === "blocked" ? "block" : state.status === "completed" ? "ok" : "warn")} ${escapeHtml(run.run_id)}</h3><p>Frozen plan ${escapeHtml((run.immutable?.digest || "").slice(0, 16))}… · updated ${escapeHtml(run.updated_at || "-")}</p><p class="action-meta">Every supported mutation is claimed durably before the existing narrow executor is called.</p></div>${recommendationHtml}${stepHtml}${eventHtml}`;
|
||||||
for (const button of elements.runOutput.querySelectorAll("[data-run-retry-step]")) {
|
for (const button of elements.runOutput.querySelectorAll("[data-run-retry-step]")) {
|
||||||
button.addEventListener("click", () => retryReleaseRunStep(button.dataset.runRetryStep));
|
button.addEventListener("click", () => retryReleaseRunStep(button.dataset.runRetryStep));
|
||||||
}
|
}
|
||||||
@@ -1283,6 +1292,78 @@
|
|||||||
confirmation.addEventListener("input", refresh);
|
confirmation.addEventListener("input", refresh);
|
||||||
submit.addEventListener("click", () => reconcileReleaseRunStep(controls.dataset.runReconcileStep, outcome.value, confirmation.value.trim(), submit));
|
submit.addEventListener("click", () => reconcileReleaseRunStep(controls.dataset.runReconcileStep, outcome.value, confirmation.value.trim(), submit));
|
||||||
}
|
}
|
||||||
|
for (const controls of elements.runOutput.querySelectorAll("[data-run-execute-step]")) {
|
||||||
|
const required = controls.dataset.runExecuteConfirmation || "";
|
||||||
|
const available = controls.dataset.runExecuteAvailable === "true";
|
||||||
|
const input = controls.querySelector("[data-run-execute-confirm]");
|
||||||
|
const submit = controls.querySelector("[data-run-execute-submit]");
|
||||||
|
const preview = controls.querySelector("[data-run-preview-submit]");
|
||||||
|
if (input) input.addEventListener("input", () => {
|
||||||
|
submit.disabled = !available || input.value.trim() !== required;
|
||||||
|
});
|
||||||
|
submit.addEventListener("click", () => executeReleaseRunStep(controls.dataset.runExecuteStep, required, submit));
|
||||||
|
if (preview) preview.addEventListener("click", () => previewReleaseRunStep(controls.dataset.runExecuteStep, preview));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeReleaseRunStep(stepId, confirmation, button) {
|
||||||
|
const run = releaseState.currentRun;
|
||||||
|
if (!run || !stepId) return;
|
||||||
|
const commandKey = `execute:${run.run_id}:${stepId}:${confirmation || "NONE"}`;
|
||||||
|
const commandBody = {
|
||||||
|
confirm: confirmation,
|
||||||
|
remote: "origin",
|
||||||
|
signing_keys: elements.signingKey.value.trim() ? [elements.signingKey.value.trim()] : [],
|
||||||
|
};
|
||||||
|
const commandRequestId = inFlightRunCommandId(commandKey, "execute");
|
||||||
|
setBusy([button], true);
|
||||||
|
try {
|
||||||
|
const executed = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/steps/${encodeURIComponent(stepId)}/execute`, {
|
||||||
|
request_id: commandRequestId,
|
||||||
|
...commandBody,
|
||||||
|
});
|
||||||
|
clearInFlightRunCommand(commandKey, commandRequestId);
|
||||||
|
releaseState.currentRun = executed;
|
||||||
|
renderReleaseRun(executed);
|
||||||
|
const result = executed.execution_result || {};
|
||||||
|
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill(result.status || "recorded", result.status === "failed" ? "block" : "ok")} Executor result</h3><p>${escapeHtml(result.detail || result.result_code || "The bounded result was recorded.")}</p></div>`);
|
||||||
|
} catch (error) {
|
||||||
|
if (deterministicRunCommandError(error)) clearInFlightRunCommand(commandKey, commandRequestId);
|
||||||
|
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("reconcile", "block")} Executor did not establish a safe result</h3><p>${escapeHtml(error.message)}</p></div>`);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewReleaseRunStep(stepId, button) {
|
||||||
|
const run = releaseState.currentRun;
|
||||||
|
if (!run || !stepId) return;
|
||||||
|
setBusy([button], true);
|
||||||
|
try {
|
||||||
|
const result = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/steps/${encodeURIComponent(stepId)}/preview`, {
|
||||||
|
remote: "origin",
|
||||||
|
});
|
||||||
|
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill(result.status || "preview", result.status === "blocked" ? "block" : "ok")} Receipt-bound publication preview</h3><p>${escapeHtml((result.notes || []).join("; ") || "Preview completed without changing run state.")}</p></div>`);
|
||||||
|
} catch (error) {
|
||||||
|
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("blocked", "block")} Preview failed</h3><p>${escapeHtml(error.message)}</p></div>`);
|
||||||
|
} finally {
|
||||||
|
setBusy([button], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseReceiptHtml(receipt) {
|
||||||
|
if (!receipt || typeof receipt !== "object") return "";
|
||||||
|
if (receipt.kind === "catalog_candidate") {
|
||||||
|
return `<p class="action-meta">Candidate ${escapeHtml(receipt.candidate_id || "-")} · ${escapeHtml((receipt.catalog_sha256 || "").slice(0, 16))}…</p>`;
|
||||||
|
}
|
||||||
|
if (receipt.kind === "catalog_publication") {
|
||||||
|
return `<p class="action-meta">Published ${escapeHtml(receipt.candidate_id || "-")} · commit ${escapeHtml((receipt.publication_commit_sha || "").slice(0, 12))} · tag ${escapeHtml(receipt.tag_name || "-")} (${escapeHtml((receipt.publication_tag_object_sha || "").slice(0, 12))}) · ${escapeHtml(receipt.remote || "origin")}/${escapeHtml(receipt.branch || "-")}</p>`;
|
||||||
|
}
|
||||||
|
if (receipt.kind === "repository_state") {
|
||||||
|
const tag = receipt.tag_object ? ` · tag ${(receipt.tag_object || "").slice(0, 12)}` : "";
|
||||||
|
return `<p class="action-meta">Bound ${escapeHtml(receipt.repo || "-")} · HEAD ${escapeHtml((receipt.head || "").slice(0, 12))}${escapeHtml(tag)} · ${receipt.worktree_clean ? "clean" : "dirty"}</p>`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumeReleaseRun() {
|
async function resumeReleaseRun() {
|
||||||
@@ -1346,7 +1427,17 @@
|
|||||||
function readInFlightRunCommands() {
|
function readInFlightRunCommands() {
|
||||||
try {
|
try {
|
||||||
const commands = JSON.parse(sessionStorage.getItem(pendingRunCommandsKey) || "{}");
|
const commands = JSON.parse(sessionStorage.getItem(pendingRunCommandsKey) || "{}");
|
||||||
return commands && typeof commands === "object" && !Array.isArray(commands) ? commands : {};
|
if (!commands || typeof commands !== "object" || Array.isArray(commands)) return {};
|
||||||
|
const normalized = {};
|
||||||
|
for (const [commandKey, savedCommand] of Object.entries(commands)) {
|
||||||
|
const requestIdToReplay = typeof savedCommand === "string" ? savedCommand : savedCommand?.request_id;
|
||||||
|
if (typeof requestIdToReplay === "string") normalized[commandKey] = requestIdToReplay;
|
||||||
|
}
|
||||||
|
if (JSON.stringify(normalized) !== JSON.stringify(commands)) {
|
||||||
|
if (Object.keys(normalized).length) sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(normalized));
|
||||||
|
else sessionStorage.removeItem(pendingRunCommandsKey);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
sessionStorage.removeItem(pendingRunCommandsKey);
|
sessionStorage.removeItem(pendingRunCommandsKey);
|
||||||
return {};
|
return {};
|
||||||
@@ -1387,6 +1478,19 @@
|
|||||||
body: { request_id: requestIdToReplay, outcome, confirm: "RECONCILE" },
|
body: { request_id: requestIdToReplay, outcome, confirm: "RECONCILE" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (commandKey.startsWith("execute:")) {
|
||||||
|
const remainder = commandKey.slice("execute:".length);
|
||||||
|
const runSeparator = remainder.indexOf(":");
|
||||||
|
const confirmationSeparator = remainder.lastIndexOf(":");
|
||||||
|
const runId = remainder.slice(0, runSeparator);
|
||||||
|
const stepId = remainder.slice(runSeparator + 1, confirmationSeparator);
|
||||||
|
const confirmation = remainder.slice(confirmationSeparator + 1);
|
||||||
|
if (runSeparator < 1 || confirmationSeparator <= runSeparator + 1 || !stepId) return null;
|
||||||
|
return {
|
||||||
|
path: `/api/release-runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepId)}/execute`,
|
||||||
|
body: { request_id: requestIdToReplay, confirm: confirmation === "NONE" ? "" : confirmation },
|
||||||
|
};
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1398,7 +1502,12 @@
|
|||||||
let recoveredRun = null;
|
let recoveredRun = null;
|
||||||
let pendingError = null;
|
let pendingError = null;
|
||||||
let rejectedError = null;
|
let rejectedError = null;
|
||||||
for (const [commandKey, requestIdToReplay] of entries) {
|
for (const [commandKey, savedCommand] of entries) {
|
||||||
|
const requestIdToReplay = typeof savedCommand === "string" ? savedCommand : savedCommand?.request_id;
|
||||||
|
if (typeof requestIdToReplay !== "string") {
|
||||||
|
delete commands[commandKey];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const command = pendingRunCommandRequest(commandKey, requestIdToReplay);
|
const command = pendingRunCommandRequest(commandKey, requestIdToReplay);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
clearInFlightRunCommand(commandKey, requestIdToReplay);
|
clearInFlightRunCommand(commandKey, requestIdToReplay);
|
||||||
@@ -1438,6 +1547,7 @@
|
|||||||
function inFlightRunCommandId(commandKey, prefix) {
|
function inFlightRunCommandId(commandKey, prefix) {
|
||||||
const commands = readInFlightRunCommands();
|
const commands = readInFlightRunCommands();
|
||||||
if (typeof commands[commandKey] === "string") return commands[commandKey];
|
if (typeof commands[commandKey] === "string") return commands[commandKey];
|
||||||
|
if (typeof commands[commandKey]?.request_id === "string") return commands[commandKey].request_id;
|
||||||
const identifier = requestId(prefix);
|
const identifier = requestId(prefix);
|
||||||
commands[commandKey] = identifier;
|
commands[commandKey] = identifier;
|
||||||
sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands));
|
sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands));
|
||||||
@@ -1446,7 +1556,8 @@
|
|||||||
|
|
||||||
function clearInFlightRunCommand(commandKey, requestIdToClear) {
|
function clearInFlightRunCommand(commandKey, requestIdToClear) {
|
||||||
const commands = readInFlightRunCommands();
|
const commands = readInFlightRunCommands();
|
||||||
if (commands[commandKey] !== requestIdToClear) return;
|
const storedRequestId = typeof commands[commandKey] === "string" ? commands[commandKey] : commands[commandKey]?.request_id;
|
||||||
|
if (storedRequestId !== requestIdToClear) return;
|
||||||
delete commands[commandKey];
|
delete commands[commandKey];
|
||||||
if (Object.keys(commands).length) sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands));
|
if (Object.keys(commands).length) sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands));
|
||||||
else sessionStorage.removeItem(pendingRunCommandsKey);
|
else sessionStorage.removeItem(pendingRunCommandsKey);
|
||||||
@@ -1493,14 +1604,14 @@
|
|||||||
tagStatus,
|
tagStatus,
|
||||||
releaseBlockers ? "block" : selected ? "ok" : "warn",
|
releaseBlockers ? "block" : selected ? "ok" : "warn",
|
||||||
selected
|
selected
|
||||||
? `${selected} selected; use Preview Tag + Publish, then Create Tags or Publish Tags after reviewing the source-release preflight.`
|
? `${selected} selected; preview here, then create a durable run and execute its enabled tag/push steps.`
|
||||||
: "Select repositories, set target versions, then preview the source release tags."
|
: "Select repositories, set target versions, then preview the source release tags."
|
||||||
),
|
),
|
||||||
workflowStage(
|
workflowStage(
|
||||||
"4. Sign and publish website catalog",
|
"4. Sign and publish website catalog",
|
||||||
signedStatus,
|
signedStatus,
|
||||||
hasCandidate ? "ok" : "warn",
|
hasCandidate ? "ok" : "warn",
|
||||||
hasCandidate ? `Candidate: ${elements.candidateDir.value.trim()}` : "After every selected source tag is remotely published, generate a signed candidate, preview it, then apply/tag/push the website publication."
|
hasCandidate ? `Preview candidate: ${elements.candidateDir.value.trim()}` : "After every selected source tag is remotely published, use a durable run to generate and publish its receipt-bound candidate."
|
||||||
),
|
),
|
||||||
workflowStage(
|
workflowStage(
|
||||||
"5. Maintain install catalog",
|
"5. Maintain install catalog",
|
||||||
@@ -2348,7 +2459,7 @@
|
|||||||
|
|
||||||
function setBusy(buttons, busy) {
|
function setBusy(buttons, busy) {
|
||||||
for (const button of buttons) {
|
for (const button of buttons) {
|
||||||
button.disabled = busy;
|
button.disabled = busy || button.dataset.releaseDisabled === "true";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user