diff --git a/apps/api/tests/test_delivery_closure_workbench_api.py b/apps/api/tests/test_delivery_closure_workbench_api.py index 29be7ecb0..5f7665eba 100644 --- a/apps/api/tests/test_delivery_closure_workbench_api.py +++ b/apps/api/tests/test_delivery_closure_workbench_api.py @@ -212,7 +212,7 @@ def test_delivery_closure_workbench_endpoint_returns_product_summary(): assert lanes["production_deploy"]["blocker_count"] == 4 assert lanes["production_deploy"]["metric"][ "observed_source_control_main_short_sha" - ] == "76071f21a811" + ] == "7191193c71e" assert lanes["production_deploy"]["metric"][ "production_image_tag_short_sha" ] == "af45811e87" diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 76c6c1988..15e81d6d6 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,12 @@ +## 2026-06-29 — 10:44 non-110 CD closure verifier snapshot fallback + +**完成內容**: +- `ops/runner/verify-awoooi-non110-cd-closure.py` 在未提供 fresh readiness file 時,會退回 committed production deploy snapshot 內的 sanitized non-110 readiness 欄位,輸出 `non110_runner_readiness_source=committed_production_deploy_snapshot`。 +- 補測試覆蓋缺 readiness file 但有 deploy snapshot 時的 fail-closed 結果;狀態會是 `blocked_non110_runner_not_ready`,不再只停在 `blocked_missing_non110_readiness_readback`。 +- 更新 production deploy snapshot / README,將 verifier command 明確包含 `--production-deploy-snapshot-json-file`;刷新 source readback 觀測 SHA 為 `7191193c71e`。 + +**邊界**:只改 committed source / snapshot / tests / docs;未使用 GitHub;未讀 token / `.runner` 內容 / cookie / session / secret;未操作 host / Docker / K8s / runner service;未 workflow_dispatch。snapshot fallback 只代表 source-level fail-closed evidence,不代表 live host proof。 + ## 2026-06-29 — 10:31 non-110 CD closure verifier contract **完成內容**: diff --git a/docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json b/docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json index c2493e75e..da3649973 100644 --- a/docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json +++ b/docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json @@ -1,12 +1,12 @@ { "schema_version": "awoooi_production_deploy_readback_blocker_v1", - "generated_at": "2026-06-29T10:31:17+08:00", + "generated_at": "2026-06-29T10:44:45+08:00", "status": "blocked_waiting_authorized_gitea_workflow_dispatch_and_runner_queue", "priority": "P0", "scope": "awoooi_production_truth", "readback": { - "observed_source_control_main_sha": "76071f21a811fe3c28998fa384e5beacd412c2f7", - "observed_source_control_main_short_sha": "76071f21a811", + "observed_source_control_main_sha": "7191193c71e5c82d6ae703a9c530d676862aa178", + "observed_source_control_main_short_sha": "7191193c71e", "governance_closure_merge_sha": "27b96f0450d0e3ca6651d6b5f274a341dd727ef2", "governance_closure_commit_sha": "9e3e7fbb6ba3ffd324b45abf3ad1e7b6ec826b22", "production_image_tag_sha": "af45811e876fda322ee63c036fbc39c9f07ffd76", @@ -30,7 +30,7 @@ "public_actions_queue_readback_schema_version": "awoooi_public_gitea_actions_queue_readback_v1", "public_actions_queue_readback_verifier": "ops/runner/read-public-gitea-actions-queue.py --json", "non110_runner_cd_closure_verifier_schema_version": "awoooi_non110_cd_closure_verifier_v1", - "non110_runner_cd_closure_verifier": "ops/runner/verify-awoooi-non110-cd-closure.py --readiness-file --queue-json-file --production-workbench-json-file --json", + "non110_runner_cd_closure_verifier": "ops/runner/verify-awoooi-non110-cd-closure.py --production-deploy-snapshot-json-file docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json --readiness-file --queue-json-file --production-workbench-json-file --json", "non110_runner_cd_closure_status": "blocked_non110_runner_not_ready", "non110_runner_cd_closure_required": true, "current_main_cd_run_visible": false, diff --git a/ops/runner/README.md b/ops/runner/README.md index 80ba6a987..edeb5d0f4 100644 --- a/ops/runner/README.md +++ b/ops/runner/README.md @@ -516,6 +516,7 @@ sanitized evidence 彙整 verifier: ```bash python3 ops/runner/verify-awoooi-non110-cd-closure.py \ + --production-deploy-snapshot-json-file docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json \ --readiness-file /path/to/sanitized-non110-readiness.txt \ --queue-json-file /path/to/public-actions-queue.json \ --production-workbench-json-file /path/to/delivery-closure-workbench.json \ @@ -525,6 +526,10 @@ python3 ops/runner/verify-awoooi-non110-cd-closure.py \ 此 verifier 只接受 `check-awoooi-non110-runner-readiness.sh` 的非 secret 輸出、 public queue JSON 與 production Delivery Workbench JSON/API;它不讀 `.runner` 內容、不讀 token、不 workflow_dispatch、不操作 host / Docker / K8s / runner service。 +若沒有傳入 fresh readiness file,verifier 只會退回 committed production deploy +snapshot 內的 sanitized non-110 readiness 欄位,並輸出 +`non110_runner_readiness_source=committed_production_deploy_snapshot`;這只代表 +source-level fail-closed evidence,不是 live host proof。 只有 `AWOOOI_NON110_RUNNER_READY=1`、public queue 不再顯示 `No matching online runner`、production image tag 已跟 main 對齊且 governance fields 已出現時,才會輸出 `closure_verified`。 diff --git a/ops/runner/test_verify_awoooi_non110_cd_closure.py b/ops/runner/test_verify_awoooi_non110_cd_closure.py index f89696dea..0ca90eaab 100644 --- a/ops/runner/test_verify_awoooi_non110_cd_closure.py +++ b/ops/runner/test_verify_awoooi_non110_cd_closure.py @@ -57,6 +57,26 @@ def _workbench(*, image_current: bool, governance_ready: bool) -> dict: } +def _deploy_snapshot() -> dict: + return { + "schema_version": "awoooi_production_deploy_readback_blocker_v1", + "readback": { + "non110_runner_ready": False, + "non110_runner_ready_config_count": 1, + "non110_runner_ready_binary_count": 1, + "non110_runner_ready_registration_count": 0, + "non110_runner_ready_service_count": 1, + "non110_runner_ready_active_service_count": 0, + "non110_runner_ready_autostart_path_count": 1, + "non110_runner_remaining_blockers": [ + "runner_registration_missing", + "runner_service_not_active", + ], + "non110_runner_safe_next_step": "run_register_awoooi_non110_runner_script_without_printing_token_then_autostart_path_will_enable_service_and_rerun_this_verifier", + }, + } + + def _readiness(*, ready: bool) -> str: if ready: return "\n".join( @@ -116,6 +136,32 @@ def test_closure_verifier_blocks_queue_after_runner_ready() -> None: assert "public_queue_still_has_no_matching_online_runner" in payload["blockers"] +def test_closure_verifier_uses_deploy_snapshot_when_readiness_file_missing() -> None: + module = _load_module() + payload = module.build_closure_verifier( + readiness_text="", + queue=_queue(no_matching=True), + production_workbench=_workbench(image_current=False, governance_ready=False), + production_deploy_snapshot=_deploy_snapshot(), + ) + assert payload["status"] == "blocked_non110_runner_not_ready" + assert payload["readback"]["non110_runner_readiness_source"] == ( + "committed_production_deploy_snapshot" + ) + assert payload["readback"]["non110_runner_ready_config_count"] == 1 + assert payload["readback"]["non110_runner_ready_registration_count"] == 0 + assert payload["runner_readiness_blockers"] == [ + "runner_registration_missing", + "runner_service_not_active", + ] + assert ( + payload["operation_boundaries"][ + "committed_production_deploy_snapshot_read" + ] + is True + ) + + def test_closure_verifier_accepts_full_closure_evidence() -> None: module = _load_module() payload = module.build_closure_verifier( @@ -162,3 +208,41 @@ def test_cli_uses_fixture_files_without_live_dispatch(tmp_path: Path) -> None: assert payload["status"] == "blocked_non110_runner_not_ready" assert payload["operation_boundaries"]["workflow_dispatch_performed"] is False assert payload["operation_boundaries"]["github_api_used"] is False + + +def test_cli_uses_deploy_snapshot_without_readiness_file(tmp_path: Path) -> None: + queue_path = tmp_path / "queue.json" + snapshot_path = tmp_path / "deploy-snapshot.json" + workbench_path = tmp_path / "workbench.json" + queue_path.write_text(json.dumps(_queue(no_matching=True)), encoding="utf-8") + snapshot_path.write_text(json.dumps(_deploy_snapshot()), encoding="utf-8") + workbench_path.write_text( + json.dumps(_workbench(image_current=False, governance_ready=False)), + encoding="utf-8", + ) + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--production-deploy-snapshot-json-file", + str(snapshot_path), + "--queue-json-file", + str(queue_path), + "--production-workbench-json-file", + str(workbench_path), + "--json", + ], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + assert result.returncode == 1 + payload = json.loads(result.stdout) + assert payload["status"] == "blocked_non110_runner_not_ready" + assert payload["readback"]["non110_runner_readiness_source"] == ( + "committed_production_deploy_snapshot" + ) + assert payload["operation_boundaries"]["raw_runner_registration_read"] is False diff --git a/ops/runner/verify-awoooi-non110-cd-closure.py b/ops/runner/verify-awoooi-non110-cd-closure.py index d04a8a1e8..a35372782 100755 --- a/ops/runner/verify-awoooi-non110-cd-closure.py +++ b/ops/runner/verify-awoooi-non110-cd-closure.py @@ -13,6 +13,9 @@ from typing import Any SCHEMA_VERSION = "awoooi_non110_cd_closure_verifier_v1" ROOT = Path(__file__).resolve().parents[2] PUBLIC_QUEUE_READER = ROOT / "ops/runner/read-public-gitea-actions-queue.py" +DEFAULT_PRODUCTION_DEPLOY_SNAPSHOT = ( + ROOT / "docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json" +) DEFAULT_PRODUCTION_WORKBENCH_URL = ( "https://awoooi.wooo.work/api/v1/agents/delivery-closure-workbench" ) @@ -70,6 +73,12 @@ def _int(value: Any) -> int: return 0 +def _strings(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if str(item)] + + def parse_readiness_text(text: str) -> dict[str, Any]: key_values: dict[str, str] = {} blockers: list[str] = [] @@ -91,6 +100,7 @@ def parse_readiness_text(text: str) -> dict[str, Any]: return { "provided": bool(text.strip()), + "source": "sanitized_readiness_text" if text.strip() else "", "ready": key_values.get("AWOOOI_NON110_RUNNER_READY") == "1", "blockers": blockers, "warnings": warnings, @@ -114,6 +124,34 @@ def parse_readiness_text(text: str) -> dict[str, Any]: } +def readiness_from_production_deploy_snapshot(snapshot: dict[str, Any]) -> dict[str, Any]: + readback = snapshot.get("readback") if isinstance(snapshot.get("readback"), dict) else {} + if "non110_runner_ready" not in readback: + return parse_readiness_text("") + + return { + "provided": True, + "source": "committed_production_deploy_snapshot", + "ready": readback.get("non110_runner_ready") is True, + "blockers": _strings(readback.get("non110_runner_remaining_blockers")), + "warnings": [], + "ready_config_count": _int(readback.get("non110_runner_ready_config_count")), + "ready_binary_count": _int(readback.get("non110_runner_ready_binary_count")), + "ready_registration_count": _int( + readback.get("non110_runner_ready_registration_count") + ), + "ready_service_count": _int(readback.get("non110_runner_ready_service_count")), + "ready_active_service_count": _int( + readback.get("non110_runner_ready_active_service_count") + ), + "ready_autostart_path_count": _int( + readback.get("non110_runner_ready_autostart_path_count") + ), + "raw_runner_registration_read": False, + "safe_next_step": str(readback.get("non110_runner_safe_next_step") or ""), + } + + def _production_summary(workbench: dict[str, Any]) -> dict[str, Any]: summary = workbench.get("summary") return summary if isinstance(summary, dict) else {} @@ -124,8 +162,13 @@ def build_closure_verifier( readiness_text: str, queue: dict[str, Any], production_workbench: dict[str, Any], + production_deploy_snapshot: dict[str, Any] | None = None, ) -> dict[str, Any]: readiness = parse_readiness_text(readiness_text) + if not readiness["provided"] and production_deploy_snapshot: + readiness = readiness_from_production_deploy_snapshot( + production_deploy_snapshot + ) queue_readback = queue.get("readback") if isinstance(queue.get("readback"), dict) else {} queue_boundaries = ( queue.get("operation_boundaries") @@ -183,6 +226,7 @@ def build_closure_verifier( "status": status, "readback": { "non110_runner_ready": readiness["ready"], + "non110_runner_readiness_source": readiness["source"], "non110_runner_ready_config_count": readiness["ready_config_count"], "non110_runner_ready_binary_count": readiness["ready_binary_count"], "non110_runner_ready_registration_count": readiness[ @@ -243,6 +287,9 @@ def build_closure_verifier( "operation_boundaries": { "secret_or_runner_token_read": False, "raw_runner_registration_read": False, + "committed_production_deploy_snapshot_read": ( + readiness["source"] == "committed_production_deploy_snapshot" + ), "workflow_dispatch_performed": False, "host_write_performed": False, "gitea_api_write_performed": False, @@ -262,6 +309,10 @@ def main() -> int: description="Verify sanitized AWOOOI non-110 CD closure evidence." ) parser.add_argument("--readiness-file", default="") + parser.add_argument( + "--production-deploy-snapshot-json-file", + default=str(DEFAULT_PRODUCTION_DEPLOY_SNAPSHOT), + ) parser.add_argument("--queue-json-file", default="") parser.add_argument("--production-workbench-json-file", default="") parser.add_argument( @@ -273,6 +324,9 @@ def main() -> int: args = parser.parse_args() readiness_text = _read_text(args.readiness_file) + production_deploy_snapshot = _load_json_file( + args.production_deploy_snapshot_json_file + ) queue = ( _load_json_file(args.queue_json_file) if args.queue_json_file @@ -289,6 +343,7 @@ def main() -> int: readiness_text=readiness_text, queue=queue, production_workbench=production_workbench, + production_deploy_snapshot=production_deploy_snapshot, ) if args.json: