diff --git a/src/agent_merge_queue/pipeline.py b/src/agent_merge_queue/pipeline.py index 40497cb..c466519 100644 --- a/src/agent_merge_queue/pipeline.py +++ b/src/agent_merge_queue/pipeline.py @@ -57,26 +57,46 @@ def release_state( ci_fence = parse_time( str((ci or {}).get("updated_at") or (ci or {}).get("created_at") or "") ) + eligible_deploys = [ + run + for run in runs + if not ( + str(run.get("status") or "") == "completed" + and str(run.get("conclusion") or "") == "skipped" + ) + and ( + ci_fence is None + or ( + (created_at := parse_time(str(run.get("created_at") or ""))) + is not None + and created_at >= ci_fence + ) + ) + ] deploy = latest_run( + eligible_deploys, + config.deploy_workflows, + main_sha, + ) + successful_deploy = latest_run( [ run - for run in runs - if not ( - str(run.get("status") or "") == "completed" - and str(run.get("conclusion") or "") == "skipped" - ) - and ( - ci_fence is None - or ( - (created_at := parse_time(str(run.get("created_at") or ""))) - is not None - and created_at >= ci_fence - ) - ) + for run in eligible_deploys + if str(run.get("status") or "") == "completed" + and str(run.get("conclusion") or "") == "success" ], config.deploy_workflows, main_sha, ) + # Successful exact-main release evidence is durable. A later duplicate + # dispatch may be cancelled by workflow concurrency, but it cannot make an + # already verified revision become undeployed. + if ( + successful_deploy is not None + and str((deploy or {}).get("status") or "") == "completed" + and str((deploy or {}).get("conclusion") or "") == "cancelled" + ): + deploy = successful_deploy active_ci = [ workflow_run(run) for run in runs diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 9243871..31acf18 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -77,6 +77,75 @@ def test_release_state_ignores_skipped_deployment_wakeups(self) -> None: self.assertEqual(value["state"], "awaiting-deploy") self.assertIsNone(value["latest_deploy"]) + def test_successful_deploy_survives_later_cancelled_duplicate(self) -> None: + sha = "a" * 40 + runs = [ + { + "id": 1, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "created_at": "2026-06-20T00:00:00Z", + "updated_at": "2026-06-20T00:00:30Z", + }, + { + "id": 2, + "name": "Deploy", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "created_at": "2026-06-20T00:01:00Z", + }, + { + "id": 3, + "name": "Deploy", + "head_sha": sha, + "status": "completed", + "conclusion": "cancelled", + "created_at": "2026-06-20T00:02:00Z", + }, + ] + + value = release_state(main_sha=sha, runs=runs, config=CONFIG.pipeline) + + self.assertEqual(value["state"], "verified") + self.assertEqual(value["latest_deploy"]["id"], 2) + + def test_later_failed_deploy_is_not_hidden_by_older_success(self) -> None: + sha = "a" * 40 + runs = [ + { + "id": 1, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "created_at": "2026-06-20T00:00:00Z", + }, + { + "id": 2, + "name": "Deploy", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "created_at": "2026-06-20T00:01:00Z", + }, + { + "id": 3, + "name": "Deploy", + "head_sha": sha, + "status": "completed", + "conclusion": "failure", + "created_at": "2026-06-20T00:02:00Z", + }, + ] + + value = release_state(main_sha=sha, runs=runs, config=CONFIG.pipeline) + + self.assertEqual(value["state"], "deploy-failed") + self.assertEqual(value["latest_deploy"]["id"], 3) + def test_new_successful_ci_supersedes_an_older_failed_deploy(self) -> None: sha = "a" * 40 runs = [