From 7191193c71e5c82d6ae703a9c530d676862aa178 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 29 Jun 2026 10:37:33 +0800 Subject: [PATCH] feat(runner): verify non110 cd closure evidence --- .../services/delivery_closure_workbench.py | 40 +++ .../test_delivery_closure_workbench_api.py | 38 ++- apps/web/src/lib/api-client.ts | 8 + docs/LOGBOOK.md | 9 + ...tion-deploy-readback-blocker.snapshot.json | 15 +- ops/runner/README.md | 18 + .../test_verify_awoooi_non110_cd_closure.py | 164 ++++++++++ ops/runner/verify-awoooi-non110-cd-closure.py | 307 ++++++++++++++++++ 8 files changed, 594 insertions(+), 5 deletions(-) create mode 100644 ops/runner/test_verify_awoooi_non110_cd_closure.py create mode 100755 ops/runner/verify-awoooi-non110-cd-closure.py diff --git a/apps/api/src/services/delivery_closure_workbench.py b/apps/api/src/services/delivery_closure_workbench.py index dcfaeec18..9e52ea43f 100644 --- a/apps/api/src/services/delivery_closure_workbench.py +++ b/apps/api/src/services/delivery_closure_workbench.py @@ -222,6 +222,28 @@ def build_delivery_closure_workbench( ) or "" ), + "non110_runner_cd_closure_verifier_schema_version": str( + production_deploy_readback.get( + "non110_runner_cd_closure_verifier_schema_version" + ) + or "" + ), + "non110_runner_cd_closure_verifier": str( + production_deploy_readback.get( + "non110_runner_cd_closure_verifier" + ) + or "" + ), + "non110_runner_cd_closure_status": str( + production_deploy_readback.get( + "non110_runner_cd_closure_status" + ) + or "" + ), + "non110_runner_cd_closure_required": production_deploy_readback.get( + "non110_runner_cd_closure_required" + ) + is True, "non110_runner_ready": production_deploy_readback.get( "non110_runner_ready" ) @@ -540,6 +562,24 @@ def build_delivery_closure_workbench( production_deploy_readback.get("public_actions_queue_readback_verifier") or "" ), + "production_deploy_non110_runner_cd_closure_verifier_schema_version": str( + production_deploy_readback.get( + "non110_runner_cd_closure_verifier_schema_version" + ) + or "" + ), + "production_deploy_non110_runner_cd_closure_verifier": str( + production_deploy_readback.get("non110_runner_cd_closure_verifier") + or "" + ), + "production_deploy_non110_runner_cd_closure_status": str( + production_deploy_readback.get("non110_runner_cd_closure_status") + or "" + ), + "production_deploy_non110_runner_cd_closure_required": ( + production_deploy_readback.get("non110_runner_cd_closure_required") + is True + ), "production_deploy_non110_runner_ready": production_deploy_readback.get( "non110_runner_ready" ) diff --git a/apps/api/tests/test_delivery_closure_workbench_api.py b/apps/api/tests/test_delivery_closure_workbench_api.py index 696461440..29be7ecb0 100644 --- a/apps/api/tests/test_delivery_closure_workbench_api.py +++ b/apps/api/tests/test_delivery_closure_workbench_api.py @@ -93,6 +93,24 @@ def test_delivery_closure_workbench_endpoint_returns_product_summary(): data["summary"]["production_deploy_public_actions_queue_readback_verifier"] == "ops/runner/read-public-gitea-actions-queue.py --json" ) + assert ( + data["summary"][ + "production_deploy_non110_runner_cd_closure_verifier_schema_version" + ] + == "awoooi_non110_cd_closure_verifier_v1" + ) + assert ( + data["summary"]["production_deploy_non110_runner_cd_closure_status"] + == "blocked_non110_runner_not_ready" + ) + assert ( + data["summary"]["production_deploy_non110_runner_cd_closure_required"] + is True + ) + assert ( + "ops/runner/verify-awoooi-non110-cd-closure.py" + in data["summary"]["production_deploy_non110_runner_cd_closure_verifier"] + ) assert data["summary"]["production_deploy_non110_runner_ready"] is False assert ( data["summary"][ @@ -194,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" - ] == "c53cbca4a26d" + ] == "76071f21a811" assert lanes["production_deploy"]["metric"][ "production_image_tag_short_sha" ] == "af45811e87" @@ -276,6 +294,24 @@ def test_delivery_closure_workbench_endpoint_returns_product_summary(): lanes["production_deploy"]["metric"]["public_actions_queue_readback_verifier"] == "ops/runner/read-public-gitea-actions-queue.py --json" ) + assert ( + lanes["production_deploy"]["metric"][ + "non110_runner_cd_closure_verifier_schema_version" + ] + == "awoooi_non110_cd_closure_verifier_v1" + ) + assert ( + lanes["production_deploy"]["metric"]["non110_runner_cd_closure_status"] + == "blocked_non110_runner_not_ready" + ) + assert ( + lanes["production_deploy"]["metric"]["non110_runner_cd_closure_required"] + is True + ) + assert ( + "ops/runner/verify-awoooi-non110-cd-closure.py" + in lanes["production_deploy"]["metric"]["non110_runner_cd_closure_verifier"] + ) assert lanes["production_deploy"]["metric"]["non110_runner_ready"] is False assert ( lanes["production_deploy"]["metric"][ diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 82830ab38..6fec69d0a 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -2565,6 +2565,10 @@ export interface DeliveryClosureWorkbenchSnapshot { production_deploy_latest_visible_waiting_runner_label: string production_deploy_public_actions_queue_readback_schema_version: string production_deploy_public_actions_queue_readback_verifier: string + production_deploy_non110_runner_cd_closure_verifier_schema_version: string + production_deploy_non110_runner_cd_closure_verifier: string + production_deploy_non110_runner_cd_closure_status: string + production_deploy_non110_runner_cd_closure_required: boolean production_deploy_non110_runner_ready: boolean production_deploy_non110_runner_prepare_only_source_ready: boolean production_deploy_non110_runner_safe_registration_helper_ready: boolean @@ -2628,6 +2632,10 @@ export interface DeliveryClosureWorkbenchSnapshot { latest_visible_waiting_runner_label: string public_actions_queue_readback_schema_version: string public_actions_queue_readback_verifier: string + non110_runner_cd_closure_verifier_schema_version: string + non110_runner_cd_closure_verifier: string + non110_runner_cd_closure_status: string + non110_runner_cd_closure_required: boolean non110_runner_ready: boolean non110_runner_prepare_only_source_ready: boolean non110_runner_safe_registration_helper_ready: boolean diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 49c376513..76c6c1988 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,12 @@ +## 2026-06-29 — 10:31 non-110 CD closure verifier contract + +**完成內容**: +- 新增 `ops/runner/verify-awoooi-non110-cd-closure.py`,彙整 sanitized non-110 readiness、public Gitea Actions queue readback 與 production Delivery Workbench readback,只有 runner ready、no-matching-runner 消失、production image tag 對齊 main 且 governance fields 出現時才回 `closure_verified`。 +- 將 `non110_runner_cd_closure_verifier_schema_version`、verifier command、closure status 與 required flag 投影到 production deploy snapshot、Delivery Workbench summary / `production_deploy` lane metric / web API type。 +- 刷新 source readback 觀測 SHA 為 `76071f21a811`;目前 closure status 仍是 `blocked_non110_runner_not_ready`,public queue 仍讀回 `blocked_no_matching_online_runner`。 + +**邊界**:只改 committed source / snapshot / API type / tests / docs;未使用 GitHub;未讀 token / `.runner` 內容 / cookie / session / secret;未操作 host / Docker / K8s / runner service;未 workflow_dispatch。 + ## 2026-06-29 — 09:36 credential escrow intake scorecard no-secret readback **完成內容**: diff --git a/docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json b/docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json index 47b1a16c4..c2493e75e 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-29T09:30:31+08:00", + "generated_at": "2026-06-29T10:31:17+08:00", "status": "blocked_waiting_authorized_gitea_workflow_dispatch_and_runner_queue", "priority": "P0", "scope": "awoooi_production_truth", "readback": { - "observed_source_control_main_sha": "c53cbca4a26d03e8b8a9cff38d7acfe698f7089d", - "observed_source_control_main_short_sha": "c53cbca4a26d", + "observed_source_control_main_sha": "76071f21a811fe3c28998fa384e5beacd412c2f7", + "observed_source_control_main_short_sha": "76071f21a811", "governance_closure_merge_sha": "27b96f0450d0e3ca6651d6b5f274a341dd727ef2", "governance_closure_commit_sha": "9e3e7fbb6ba3ffd324b45abf3ad1e7b6ec826b22", "production_image_tag_sha": "af45811e876fda322ee63c036fbc39c9f07ffd76", @@ -29,6 +29,10 @@ "latest_visible_waiting_runner_label": "awoooi-non110-ubuntu", "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_status": "blocked_non110_runner_not_ready", + "non110_runner_cd_closure_required": true, "current_main_cd_run_visible": false, "manual_run_button_visible": false, "gitea_sign_in_required": true, @@ -95,13 +99,14 @@ "等 non-110 或硬限制 runner readiness channel 成立後,使用已授權的 Gitea workflow_dispatch channel 觸發 cd.yaml ref=main。", "若 #3853 仍 Waiting 且 jobs_total_count=0,先不要重推或手改 K8s tag;改以 runner readiness verifier 的非 secret readback 建立可用 runner channel。", "使用 safe registration helper 完成 registration metadata 與 active service readback,但不得輸出 token 或 .runner 內容。", + "註冊與 autostart 後,使用 ops/runner/verify-awoooi-non110-cd-closure.py 彙整 non-110 readiness、public queue 與 production workbench;closure_verified 前不得宣稱 CD lane 已恢復。", "讀回 Gitea Actions HTML 的 no-matching-runner status;在 runner channel online 前不要把 workflow labels aligned 誤判為可部署。", "CD 完成後讀回 production image tag,確認不再是 af45811e87。", "重新讀回 /api/v1/agents/github-target-controlled-execution-preflight 與 /api/v1/agents/delivery-closure-workbench,確認 internal_governance_writeback 與 KM / PlayBook counters 出現。" ], "rollups": { "hard_blocker_count": 4, - "next_action_count": 6, + "next_action_count": 7, "source_control_main_ready": true, "production_image_tag_matches_main": false, "production_governance_fields_present": false, @@ -109,6 +114,8 @@ "non110_runner_ready": false, "non110_runner_online_label_match": false, "non110_runner_autostart_path_armed": true, + "non110_runner_cd_closure_required": true, + "non110_runner_cd_closure_status": "blocked_non110_runner_not_ready", "non110_runner_remaining_blocker_count": 3, "runtime_write_performed": false, "secret_values_collected": false diff --git a/ops/runner/README.md b/ops/runner/README.md index bc720a6a5..80ba6a987 100644 --- a/ops/runner/README.md +++ b/ops/runner/README.md @@ -511,6 +511,24 @@ ssh ollama@192.168.0.188 'bash -s -- --check' \ `act_runner register`,不使用 `--token` argv、不列印 token、不讀 `.runner` 內容;若 `.runner` 已存在,預設只回報下一步 enable / verifier,不覆寫。 +註冊與 autostart 後,CD lane 不能只靠「runner 已註冊」宣稱恢復;必須用 +sanitized evidence 彙整 verifier: + +```bash +python3 ops/runner/verify-awoooi-non110-cd-closure.py \ + --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 \ + --json +``` + +此 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。 +只有 `AWOOOI_NON110_RUNNER_READY=1`、public queue 不再顯示 +`No matching online runner`、production image tag 已跟 main 對齊且 governance fields +已出現時,才會輸出 `closure_verified`。 + `--enable` 只允許在 `AWOOOI_NON110_ENABLE=1`、`act_runner` executable、 `config.yaml` present、`.runner` present 且 service 已由 verifier 證明 target / limits 正確後使用。installer 不會讀 `.runner` 內容,也不會寫 runner token;rollback 只會 diff --git a/ops/runner/test_verify_awoooi_non110_cd_closure.py b/ops/runner/test_verify_awoooi_non110_cd_closure.py new file mode 100644 index 000000000..f89696dea --- /dev/null +++ b/ops/runner/test_verify_awoooi_non110_cd_closure.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "ops/runner/verify-awoooi-non110-cd-closure.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location( + "verify_awoooi_non110_cd_closure", + SCRIPT, + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _queue(*, no_matching: bool) -> dict: + return { + "schema_version": "awoooi_public_gitea_actions_queue_readback_v1", + "status": ( + "blocked_no_matching_online_runner" + if no_matching + else "no_matching_runner_not_visible" + ), + "readback": { + "no_matching_online_runner_visible": no_matching, + "latest_visible_no_matching_runner_label": ( + "awoooi-non110-ubuntu" if no_matching else "" + ), + }, + "operation_boundaries": { + "workflow_dispatch_performed": False, + "secret_or_runner_token_read": False, + "github_api_used": False, + }, + } + + +def _workbench(*, image_current: bool, governance_ready: bool) -> dict: + return { + "schema_version": "delivery_closure_workbench_v1", + "summary": { + "source_count": 6, + "production_deploy_image_tag_matches_main": image_current, + "production_deploy_governance_fields_present": governance_ready, + }, + } + + +def _readiness(*, ready: bool) -> str: + if ready: + return "\n".join( + [ + "raw_runner_registration_read=false", + "READY_CONFIG_COUNT=1", + "READY_BINARY_COUNT=1", + "READY_REGISTRATION_COUNT=1", + "READY_SERVICE_COUNT=1", + "READY_ACTIVE_SERVICE_COUNT=1", + "READY_AUTOSTART_PATH_COUNT=1", + "AWOOOI_NON110_RUNNER_READY=1", + ] + ) + return "\n".join( + [ + "raw_runner_registration_read=false", + "BLOCKER runner_registration_missing", + "BLOCKER runner_service_not_active", + "READY_CONFIG_COUNT=1", + "READY_BINARY_COUNT=1", + "READY_REGISTRATION_COUNT=0", + "READY_SERVICE_COUNT=1", + "READY_ACTIVE_SERVICE_COUNT=0", + "READY_AUTOSTART_PATH_COUNT=1", + "AWOOOI_NON110_RUNNER_READY=0", + "safe_next_step=run_register_awoooi_non110_runner_script_without_printing_token_then_autostart_path_will_enable_service_and_rerun_this_verifier", + ] + ) + + +def test_closure_verifier_blocks_runner_not_ready_without_secret_leak() -> None: + module = _load_module() + payload = module.build_closure_verifier( + readiness_text=_readiness(ready=False), + queue=_queue(no_matching=True), + production_workbench=_workbench(image_current=False, governance_ready=False), + ) + text = json.dumps(payload, sort_keys=True) + assert payload["schema_version"] == module.SCHEMA_VERSION + assert payload["status"] == "blocked_non110_runner_not_ready" + assert "runner_registration_missing" in payload["runner_readiness_blockers"] + assert payload["readback"]["non110_runner_ready_registration_count"] == 0 + assert payload["operation_boundaries"]["secret_or_runner_token_read"] is False + assert payload["operation_boundaries"]["raw_runner_registration_read"] is False + assert "secret-token-like-content" not in text + + +def test_closure_verifier_blocks_queue_after_runner_ready() -> None: + module = _load_module() + payload = module.build_closure_verifier( + readiness_text=_readiness(ready=True), + queue=_queue(no_matching=True), + production_workbench=_workbench(image_current=False, governance_ready=False), + ) + assert payload["status"] == "blocked_no_matching_online_runner" + assert "public_queue_still_has_no_matching_online_runner" in payload["blockers"] + + +def test_closure_verifier_accepts_full_closure_evidence() -> None: + module = _load_module() + payload = module.build_closure_verifier( + readiness_text=_readiness(ready=True), + queue=_queue(no_matching=False), + production_workbench=_workbench(image_current=True, governance_ready=True), + ) + assert payload["status"] == "closure_verified" + assert payload["blockers"] == [] + assert payload["readback"]["production_deploy_image_tag_matches_main"] is True + + +def test_cli_uses_fixture_files_without_live_dispatch(tmp_path: Path) -> None: + readiness_path = tmp_path / "readiness.txt" + queue_path = tmp_path / "queue.json" + workbench_path = tmp_path / "workbench.json" + readiness_path.write_text(_readiness(ready=False), encoding="utf-8") + queue_path.write_text(json.dumps(_queue(no_matching=True)), 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), + "--readiness-file", + str(readiness_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["operation_boundaries"]["workflow_dispatch_performed"] is False + assert payload["operation_boundaries"]["github_api_used"] is False diff --git a/ops/runner/verify-awoooi-non110-cd-closure.py b/ops/runner/verify-awoooi-non110-cd-closure.py new file mode 100755 index 000000000..d04a8a1e8 --- /dev/null +++ b/ops/runner/verify-awoooi-non110-cd-closure.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import urllib.request +from pathlib import Path +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_WORKBENCH_URL = ( + "https://awoooi.wooo.work/api/v1/agents/delivery-closure-workbench" +) + + +def _read_text(path: str | None) -> str: + if not path: + return "" + return Path(path).read_text(encoding="utf-8") + + +def _load_json_file(path: str | None) -> dict[str, Any]: + if not path: + return {} + with Path(path).open(encoding="utf-8") as fh: + payload = json.load(fh) + return payload if isinstance(payload, dict) else {} + + +def _load_url_json(url: str) -> dict[str, Any]: + with urllib.request.urlopen(url, timeout=20) as response: + payload = json.load(response) + return payload if isinstance(payload, dict) else {} + + +def _load_live_public_queue() -> dict[str, Any]: + result = subprocess.run( + [sys.executable, str(PUBLIC_QUEUE_READER), "--json"], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + return { + "schema_version": "missing_public_gitea_actions_queue_readback_v1", + "status": "blocked_public_queue_readback_failed", + "readback": {}, + "rollups": {}, + "operation_boundaries": { + "workflow_dispatch_performed": False, + "secret_or_runner_token_read": False, + "github_api_used": False, + }, + "error": result.stderr.strip().splitlines()[-1:] or ["unknown_error"], + } + payload = json.loads(result.stdout) + return payload if isinstance(payload, dict) else {} + + +def _int(value: Any) -> int: + try: + return int(str(value)) + except (TypeError, ValueError): + return 0 + + +def parse_readiness_text(text: str) -> dict[str, Any]: + key_values: dict[str, str] = {} + blockers: list[str] = [] + warnings: list[str] = [] + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line: + continue + if line.startswith("BLOCKER "): + blockers.append(line.removeprefix("BLOCKER ").strip()) + continue + if line.startswith("WARNING "): + warnings.append(line.removeprefix("WARNING ").strip()) + continue + if "=" in line and not line.startswith("RUNNER_REGISTRATION "): + key, value = line.split("=", 1) + if key.isidentifier() or key.isupper() or key in {"safe_next_step"}: + key_values[key] = value + + return { + "provided": bool(text.strip()), + "ready": key_values.get("AWOOOI_NON110_RUNNER_READY") == "1", + "blockers": blockers, + "warnings": warnings, + "ready_config_count": _int(key_values.get("READY_CONFIG_COUNT")), + "ready_binary_count": _int(key_values.get("READY_BINARY_COUNT")), + "ready_registration_count": _int( + key_values.get("READY_REGISTRATION_COUNT") + ), + "ready_service_count": _int(key_values.get("READY_SERVICE_COUNT")), + "ready_active_service_count": _int( + key_values.get("READY_ACTIVE_SERVICE_COUNT") + ), + "ready_autostart_path_count": _int( + key_values.get("READY_AUTOSTART_PATH_COUNT") + ), + "raw_runner_registration_read": key_values.get( + "raw_runner_registration_read" + ) + == "true", + "safe_next_step": key_values.get("safe_next_step", ""), + } + + +def _production_summary(workbench: dict[str, Any]) -> dict[str, Any]: + summary = workbench.get("summary") + return summary if isinstance(summary, dict) else {} + + +def build_closure_verifier( + *, + readiness_text: str, + queue: dict[str, Any], + production_workbench: dict[str, Any], +) -> dict[str, Any]: + readiness = parse_readiness_text(readiness_text) + queue_readback = queue.get("readback") if isinstance(queue.get("readback"), dict) else {} + queue_boundaries = ( + queue.get("operation_boundaries") + if isinstance(queue.get("operation_boundaries"), dict) + else {} + ) + production = _production_summary(production_workbench) + + no_matching_runner_visible = ( + queue_readback.get("no_matching_online_runner_visible") is True + ) + production_workbench_present = bool(production) + production_image_tag_matches_main = ( + production.get("production_deploy_image_tag_matches_main") is True + ) + production_governance_fields_present = ( + production.get("production_deploy_governance_fields_present") is True + ) + + blockers: list[str] = [] + if not readiness["provided"]: + blockers.append("missing_non110_readiness_readback") + elif not readiness["ready"]: + blockers.append("non110_runner_not_ready") + if readiness["raw_runner_registration_read"]: + blockers.append("raw_runner_registration_read_not_allowed") + if no_matching_runner_visible: + blockers.append("public_queue_still_has_no_matching_online_runner") + if not production_workbench_present: + blockers.append("production_workbench_readback_missing") + elif not production_image_tag_matches_main: + blockers.append("production_image_tag_not_current") + if production_workbench_present and not production_governance_fields_present: + blockers.append("production_governance_fields_missing") + + if "missing_non110_readiness_readback" in blockers: + status = "blocked_missing_non110_readiness_readback" + elif "non110_runner_not_ready" in blockers: + status = "blocked_non110_runner_not_ready" + elif "raw_runner_registration_read_not_allowed" in blockers: + status = "blocked_secret_boundary_violation" + elif "public_queue_still_has_no_matching_online_runner" in blockers: + status = "blocked_no_matching_online_runner" + elif "production_workbench_readback_missing" in blockers: + status = "blocked_production_workbench_readback_missing" + elif "production_image_tag_not_current" in blockers: + status = "blocked_production_image_not_current" + elif "production_governance_fields_missing" in blockers: + status = "blocked_production_governance_fields_missing" + else: + status = "closure_verified" + + return { + "schema_version": SCHEMA_VERSION, + "status": status, + "readback": { + "non110_runner_ready": readiness["ready"], + "non110_runner_ready_config_count": readiness["ready_config_count"], + "non110_runner_ready_binary_count": readiness["ready_binary_count"], + "non110_runner_ready_registration_count": readiness[ + "ready_registration_count" + ], + "non110_runner_ready_service_count": readiness["ready_service_count"], + "non110_runner_ready_active_service_count": readiness[ + "ready_active_service_count" + ], + "non110_runner_ready_autostart_path_count": readiness[ + "ready_autostart_path_count" + ], + "public_actions_queue_status": str(queue.get("status") or ""), + "public_actions_queue_schema_version": str( + queue.get("schema_version") or "" + ), + "public_queue_no_matching_online_runner_visible": ( + no_matching_runner_visible + ), + "public_queue_latest_no_matching_runner_label": str( + queue_readback.get("latest_visible_no_matching_runner_label") or "" + ), + "production_workbench_present": production_workbench_present, + "production_workbench_source_count": _int(production.get("source_count")), + "production_deploy_image_tag_matches_main": ( + production_image_tag_matches_main + ), + "production_deploy_governance_fields_present": ( + production_governance_fields_present + ), + }, + "blockers": blockers, + "runner_readiness_blockers": readiness["blockers"], + "runner_readiness_warnings": readiness["warnings"], + "next_actions": [ + action + for action in [ + readiness["safe_next_step"] + if not readiness["ready"] + else "", + "rerun_public_queue_readback_until_no_matching_runner_is_absent" + if no_matching_runner_visible + else "", + "read_production_delivery_workbench_after_deploy" + if not production_workbench_present + else "", + "complete_authorized_cd_then_verify_image_tag_matches_main" + if production_workbench_present + and not production_image_tag_matches_main + else "", + "verify_internal_governance_writeback_fields_after_deploy" + if production_workbench_present + and not production_governance_fields_present + else "", + ] + if action + ], + "operation_boundaries": { + "secret_or_runner_token_read": False, + "raw_runner_registration_read": False, + "workflow_dispatch_performed": False, + "host_write_performed": False, + "gitea_api_write_performed": False, + "github_api_used": False, + "public_queue_workflow_dispatch_performed": ( + queue_boundaries.get("workflow_dispatch_performed") is True + ), + "public_queue_secret_or_runner_token_read": ( + queue_boundaries.get("secret_or_runner_token_read") is True + ), + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Verify sanitized AWOOOI non-110 CD closure evidence." + ) + parser.add_argument("--readiness-file", default="") + parser.add_argument("--queue-json-file", default="") + parser.add_argument("--production-workbench-json-file", default="") + parser.add_argument( + "--production-workbench-url", + default="", + help="Read production workbench JSON from URL when no file is provided.", + ) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + readiness_text = _read_text(args.readiness_file) + queue = ( + _load_json_file(args.queue_json_file) + if args.queue_json_file + else _load_live_public_queue() + ) + if args.production_workbench_json_file: + production_workbench = _load_json_file(args.production_workbench_json_file) + elif args.production_workbench_url: + production_workbench = _load_url_json(args.production_workbench_url) + else: + production_workbench = _load_url_json(DEFAULT_PRODUCTION_WORKBENCH_URL) + + payload = build_closure_verifier( + readiness_text=readiness_text, + queue=queue, + production_workbench=production_workbench, + ) + + if args.json: + print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) + else: + print(f"status={payload['status']}") + print(f"blocker_count={len(payload['blockers'])}") + for blocker in payload["blockers"]: + print(f"BLOCKER {blocker}") + for action in payload["next_actions"]: + print(f"safe_next_step={action}") + return 0 if payload["status"] == "closure_verified" else 1 + + +if __name__ == "__main__": + raise SystemExit(main())