diff --git a/apps/api/src/services/awoooi_production_deploy_readback_blocker.py b/apps/api/src/services/awoooi_production_deploy_readback_blocker.py index 7e96e871..c9793be9 100644 --- a/apps/api/src/services/awoooi_production_deploy_readback_blocker.py +++ b/apps/api/src/services/awoooi_production_deploy_readback_blocker.py @@ -135,7 +135,11 @@ def _enrich_runtime_build_readback(payload: dict[str, Any]) -> None: readback["desired_main_api_image_tag_readback_status"] = "ok" readback["desired_main_api_image_tag_sha"] = desired_tag readback["desired_main_api_image_tag_short_sha"] = desired_tag[:10] - image_matches_main = build_sha == desired_tag + image_matches_main = ( + build_sha == desired_tag + and source_matches_runtime + and image_matches_runtime + ) readback["production_image_tag_matches_main"] = image_matches_main rollups["source_control_main_ready"] = True rollups["production_image_tag_matches_main"] = image_matches_main diff --git a/apps/api/tests/test_awoooi_production_deploy_readback_blocker.py b/apps/api/tests/test_awoooi_production_deploy_readback_blocker.py index efe21cc5..3a70aeef 100644 --- a/apps/api/tests/test_awoooi_production_deploy_readback_blocker.py +++ b/apps/api/tests/test_awoooi_production_deploy_readback_blocker.py @@ -5,7 +5,7 @@ from src.services import awoooi_production_deploy_readback_blocker as service _COMMITTED_SNAPSHOT_SHA = "a70c6756d9e76c33143676eef82bab7a49ac1839" -def test_production_deploy_readback_verifies_runtime_build_against_gitops_desired( +def test_production_deploy_readback_blocks_stale_source_even_when_gitops_desired_matches_runtime( monkeypatch, ): build_sha = "0123456789abcdef0123456789abcdef01234567" @@ -33,10 +33,13 @@ def test_production_deploy_readback_verifies_runtime_build_against_gitops_desire assert readback["desired_main_api_image_tag_sha"] == build_sha assert readback["desired_main_api_image_tag_source"] == "gitops_deployment_env" assert readback["desired_main_api_image_tag_readback_status"] == "ok" - assert readback["production_image_tag_matches_main"] is True - assert payload["status"] == "closure_verified" - assert rollups["production_image_tag_matches_main"] is True - assert rollups["hard_blocker_count"] == 0 + assert readback["production_image_tag_matches_main"] is False + assert payload["status"] == "blocked_production_runtime_image_tag_not_verified" + assert rollups["production_image_tag_matches_main"] is False + assert rollups["hard_blocker_count"] == 1 + assert "production_runtime_image_tag_does_not_match_gitea_main_desired_tag" in ( + payload["blockers"] + ) def test_production_deploy_readback_keeps_committed_snapshot_evidence(monkeypatch): diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 332fdee4..01ed3f86 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,16 @@ +## 2026-06-30 — 19:22 P0-006 production deploy closure false-positive guard + +**照主線修正的問題**: +- Production Delivery Workbench 仍讀到舊 runtime build `7890778b83`,但舊 `production_image_tag_matches_main=true` 只代表 runtime build 等於舊 GitOps desired tag,不能證明 runtime build 已等於目前 Gitea main。 +- `awoooi_production_deploy_readback_blocker.py` 現在要求 runtime build 同時等於 GitOps desired tag、committed source-control readback 與 committed production image tag,才可宣稱 `production_image_tag_matches_main=true`。 +- `verify-awoooi-non110-cd-closure.py` 現在把 runtime/source/image 三個 readback 布林納入 closure 判定;live production 目前正確回 `blocked_production_image_not_current`,下一步是完成 CD 後重跑 production route/readback。 + +**驗證**: +- `pytest apps/api/tests/test_awoooi_production_deploy_readback_blocker.py apps/api/tests/test_delivery_closure_workbench_api.py ops/runner/test_verify_awoooi_non110_cd_closure.py ops/runner/test_cd_controlled_runtime_profile.py -q`:`43 passed`。 +- `py_compile`、`git diff --check`:通過。 + +**邊界**:未 workflow_dispatch,未 SSH 寫主機,未重啟主機,未 restart Docker daemon / host Nginx / K3s / DB / Redis / firewall,未 prune / restore / DB write,未讀 secret / token / raw sessions / SQLite / `.env`,未使用 GitHub / `gh` / GitHub API。 + ## 2026-06-30 — 18:52 P0-006 StockPlatform receipt updated after upstream recovery **照主線修正的問題**: diff --git a/ops/runner/test_verify_awoooi_non110_cd_closure.py b/ops/runner/test_verify_awoooi_non110_cd_closure.py index 0e37357f..847f67a2 100644 --- a/ops/runner/test_verify_awoooi_non110_cd_closure.py +++ b/ops/runner/test_verify_awoooi_non110_cd_closure.py @@ -46,12 +46,35 @@ def _queue(*, no_matching: bool) -> dict: } -def _workbench(*, image_current: bool, governance_ready: bool) -> dict: +def _workbench( + *, + image_current: bool, + governance_ready: bool, + runtime_source_current: bool | None = None, + runtime_image_current: bool | None = None, +) -> dict: + runtime_source_current = ( + image_current if runtime_source_current is None else runtime_source_current + ) + runtime_image_current = ( + image_current if runtime_image_current is None else runtime_image_current + ) return { "schema_version": "delivery_closure_workbench_v1", "summary": { "source_count": 6, "production_deploy_image_tag_matches_main": image_current, + "production_deploy_runtime_build_matches_committed_source_control_readback": ( + runtime_source_current + ), + "production_deploy_runtime_build_matches_committed_production_image_tag": ( + runtime_image_current + ), + "production_deploy_runtime_build_readback_status": ( + "matches_committed_deploy_readback" + if runtime_source_current and runtime_image_current + else "runtime_build_diverges_from_committed_deploy_readback" + ), "production_deploy_governance_fields_present": governance_ready, }, } @@ -191,6 +214,37 @@ def test_closure_verifier_accepts_full_closure_evidence() -> None: assert all(step["status"] == "complete" for step in payload["ordered_steps"]) assert payload["blockers"] == [] assert payload["readback"]["production_deploy_image_tag_matches_main"] is True + assert ( + payload["readback"][ + "production_deploy_runtime_build_matches_committed_source_control_readback" + ] + is True + ) + + +def test_closure_verifier_blocks_stale_runtime_even_when_desired_tag_matches() -> 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, + runtime_source_current=False, + runtime_image_current=False, + ), + ) + assert payload["status"] == "blocked_production_image_not_current" + assert payload["progress"]["ordered_completed_prefix_count"] == 4 + assert payload["progress"]["next_blocked_step_id"] == "production_image_tag_current" + assert "production_image_tag_not_current" in payload["blockers"] + assert payload["readback"]["production_deploy_image_tag_matches_main"] is False + assert ( + payload["readback"][ + "production_deploy_runtime_build_matches_committed_source_control_readback" + ] + is False + ) def test_cli_uses_fixture_files_without_live_dispatch(tmp_path: Path) -> None: diff --git a/ops/runner/verify-awoooi-non110-cd-closure.py b/ops/runner/verify-awoooi-non110-cd-closure.py index b7a48081..07b00917 100755 --- a/ops/runner/verify-awoooi-non110-cd-closure.py +++ b/ops/runner/verify-awoooi-non110-cd-closure.py @@ -158,6 +158,20 @@ def _production_summary(workbench: dict[str, Any]) -> dict[str, Any]: return summary if isinstance(summary, dict) else {} +def _production_image_tag_current(production: dict[str, Any]) -> bool: + return ( + production.get("production_deploy_image_tag_matches_main") is True + and production.get( + "production_deploy_runtime_build_matches_committed_source_control_readback" + ) + is True + and production.get( + "production_deploy_runtime_build_matches_committed_production_image_tag" + ) + is True + ) + + def _build_ordered_steps( *, readiness: dict[str, Any], @@ -292,9 +306,7 @@ def build_closure_verifier( 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_image_tag_matches_main = _production_image_tag_current(production) production_governance_fields_present = ( production.get("production_deploy_governance_fields_present") is True ) @@ -375,6 +387,22 @@ def build_closure_verifier( "production_deploy_image_tag_matches_main": ( production_image_tag_matches_main ), + "production_deploy_runtime_build_matches_committed_source_control_readback": ( + production.get( + "production_deploy_runtime_build_matches_committed_source_control_readback" + ) + is True + ), + "production_deploy_runtime_build_matches_committed_production_image_tag": ( + production.get( + "production_deploy_runtime_build_matches_committed_production_image_tag" + ) + is True + ), + "production_deploy_runtime_build_readback_status": str( + production.get("production_deploy_runtime_build_readback_status") + or "" + ), "production_deploy_governance_fields_present": ( production_governance_fields_present ),