From c51d5d3008c992d7787a26508fac0d3afe65e30b Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:25:43 -0700 Subject: [PATCH 1/2] Keep integration failures batch-local --- .../claude-code/.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- docs/reference.md | 2 +- examples/github-workflow.yml | 5 + pyproject.toml | 2 +- src/agent_merge_queue/__init__.py | 2 +- src/agent_merge_queue/cli.py | 256 ++++++++++++- src/agent_merge_queue/doctor.py | 61 ++++ tests/test_cli.py | 337 ++++++++++++++++++ tests/test_doctor.py | 56 +++ tests/test_skill.py | 2 + 11 files changed, 705 insertions(+), 22 deletions(-) diff --git a/adapters/claude-code/.claude-plugin/plugin.json b/adapters/claude-code/.claude-plugin/plugin.json index d36f98c..1394779 100644 --- a/adapters/claude-code/.claude-plugin/plugin.json +++ b/adapters/claude-code/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "deploybot", - "version": "0.2.21", + "version": "0.2.22", "description": "DeployBot: a provider-neutral GitHub merge queue for coding agents", "author": { "name": "DeployBot contributors" diff --git a/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json b/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json index 1c47ba4..9fa3f74 100644 --- a/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json +++ b/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "deploybot", - "version": "0.2.21", + "version": "0.2.22", "description": "Coordinate exact-head pull requests through verified deployment and thread notification", "author": { "name": "DeployBot contributors" diff --git a/docs/reference.md b/docs/reference.md index d912da3..4e59751 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,7 +1,7 @@ # DeployBot reference This reference describes the CLI, MCP server, policy file, and GitHub Action in -DeployBot v0.2.21. GitHub labels and authenticated comments are the durable state; +DeployBot v0.2.22. GitHub labels and authenticated comments are the durable state; the CLI and MCP tools are two interfaces to the same operations. ## CLI diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index 9bbcd9e..d38d6d5 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -79,3 +79,8 @@ jobs: persist-credentials: false # v0.2.21 implementation; keep the full commit for privileged workflows. - uses: Forward-Future/DeployBot@ecc5f60e90d6201daddf3764be60cb7122360722 + with: + # PR and review events reconcile quickly. Only release-owner events + # stay attached to cumulative main through CI and deployment. + follow: ${{ github.event_name == 'workflow_run' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + timeout: ${{ (github.event_name == 'workflow_run' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '2400' || '600' }} diff --git a/pyproject.toml b/pyproject.toml index 87fd35f..2fbe0f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deploybot-merge-queue" -version = "0.2.21" +version = "0.2.22" description = "DeployBot: a provider-neutral GitHub merge queue for coding agents" readme = "README.md" license = "MIT" diff --git a/src/agent_merge_queue/__init__.py b/src/agent_merge_queue/__init__.py index 70bf889..bdfcfe2 100644 --- a/src/agent_merge_queue/__init__.py +++ b/src/agent_merge_queue/__init__.py @@ -1,3 +1,3 @@ """DeployBot: a provider-neutral GitHub merge queue for coding agents.""" -__version__ = "0.2.21" +__version__ = "0.2.22" diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index c33bb90..9b221c5 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -103,6 +103,14 @@ } PASSED_CHECK_STATES = {"NEUTRAL", "SKIPPED", "SUCCESS"} MERGEABILITY_RETRIES = 6 +SUPERSEDED_CONTROLLER_PAUSES = ( + "ci-failed on ", + "deploy-failed on ", + "integration CI ownership failed: ", + "post-merge CI dispatch failed: ", + "release workflow dispatch failed: ", + "verify-failed on ", +) REVIEW_THREADS_QUERY = """ query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { @@ -3512,6 +3520,94 @@ def record_integration_conflict_repair( return repair +def record_integration_ci_repair( + client: GitHub, + result: dict[str, Any], +) -> dict[str, Any] | None: + """Block only one failed integration batch and elect its repair owner.""" + + integration_number = int(result["pull_request"]) + integration = result.get("integration") + if not isinstance(integration, dict): + raise QueueError( + f"integration PR #{integration_number} CI repair is missing membership" + ) + frozen_heads_value = integration.get("heads") + frozen_numbers_value = integration.get("pull_requests") + if not isinstance(frozen_heads_value, dict) or not isinstance( + frozen_numbers_value, list + ): + raise QueueError( + f"integration PR #{integration_number} CI repair is missing frozen membership" + ) + frozen_heads = { + str(number): str(head_sha) + for number, head_sha in frozen_heads_value.items() + } + frozen_numbers = [int(number) for number in frozen_numbers_value] + if set(frozen_heads) != {str(number) for number in frozen_numbers}: + raise QueueError( + f"integration PR #{integration_number} CI repair has inconsistent frozen members" + ) + + reason = str(result.get("reason") or "integration CI failed") + owner: QueueEntry | None = None + owner_intent: dict[str, Any] | None = None + for number in frozen_numbers: + try: + candidate = client.snapshot( + number, + require_marker=False, + allow_blocked_label=True, + ) + except QueueError: + continue + intent = latest_intent(client.comments(number), client.trusted_logins) + if ( + intent + and intent.get("state") == "requested" + and intent.get("provider") + and intent.get("thread_id") + ): + owner = candidate + owner_intent = intent + break + if owner is None: + owner = candidate + owner_intent = intent + if owner is None: + return None + + repair = record_repair( + client, + owner, + owner_intent, + reason, + resume_pull_request=integration_number, + source_heads=frozen_heads, + source_pull_requests=frozen_numbers, + ) + # Publish the terminal integration marker only after the durable repair + # handoff exists. If source reads or notification writes fail, the clean + # marker remains retryable on the next reconciliation. + failed_marker = {key: value for key, value in integration.items() if key != "schema"} + failed_marker["conflict"] = { + "number": integration_number, + "reason": reason, + } + failed_marker["failed_at"] = utc_now() + client.comment(integration_number, integration_body(failed_marker)) + integration_labels = client.labels(integration_number) + if client.config.blocked_label not in integration_labels: + client.add_label(integration_number, client.config.blocked_label) + result["repair_owner"] = { + "pull_request": owner.number, + "provider": repair.get("provider"), + "thread_id": repair.get("thread_id"), + } + return repair + + def reconcile_externally_merged_threads(client: GitHub) -> list[dict[str, Any]]: reconciled: list[dict[str, Any]] = [] records = client.thread_records() @@ -3850,6 +3946,7 @@ def settle_integration_checks( selected = [int(value) for value in numbers] configured = tuple(client.config.pipeline.ci_workflows) targets: dict[int, dict[str, Any]] = {} + results: list[dict[str, Any]] = [] for number in selected: comments = client.comments(number) integration = latest_payload( @@ -3862,16 +3959,39 @@ def settle_integration_checks( pull = client.pull_head(number) branch = str(pull.get("branch") or "") head_sha = str(pull.get("head_sha") or "") + if pull.get("state") == "MERGED": + results.append( + { + "branch": branch, + "dispatched_ci": [], + "head_sha": head_sha, + "integration": integration, + "pull_request": number, + "state": "merged", + } + ) + continue if pull.get("state") != "OPEN" or not branch or not head_sha: - raise QueueError(f"integration PR #{number} is no longer open") + results.append( + { + "branch": branch, + "dispatched_ci": [], + "head_sha": head_sha, + "integration": integration, + "pull_request": number, + "reason": f"integration PR #{number} is no longer open", + "state": "blocked", + } + ) + continue targets[number] = { "branch": branch, "dispatched": [], "head_sha": head_sha, + "integration": integration, "requested": set(), } - results: list[dict[str, Any]] = [] pending = dict(targets) deadline = time.monotonic() + timeout_seconds while pending: @@ -3879,12 +3999,50 @@ def settle_integration_checks( branch = str(target["branch"]) head_sha = str(target["head_sha"]) current = client.pull_head(number) + if current.get("state") == "MERGED": + results.append( + { + "branch": branch, + "dispatched_ci": target["dispatched"], + "head_sha": head_sha, + "integration": target["integration"], + "pull_request": number, + "state": "merged", + } + ) + del pending[number] + continue if current.get("state") != "OPEN": - raise QueueError(f"integration PR #{number} is no longer open") + results.append( + { + "branch": branch, + "dispatched_ci": target["dispatched"], + "head_sha": head_sha, + "integration": target["integration"], + "pull_request": number, + "reason": f"integration PR #{number} is no longer open", + "state": "blocked", + } + ) + del pending[number] + continue if current.get("head_sha") != head_sha: - raise QueueError( - f"integration PR #{number} changed while CI ownership was active" + results.append( + { + "branch": branch, + "dispatched_ci": target["dispatched"], + "head_sha": head_sha, + "integration": target["integration"], + "pull_request": number, + "reason": ( + f"integration PR #{number} changed while CI ownership " + "was active" + ), + "state": "blocked", + } ) + del pending[number] + continue runs = client.workflow_runs_for_branch(branch) latest = latest_exact_workflow_runs( runs, @@ -3898,9 +4056,22 @@ def settle_integration_checks( and str(run.get("conclusion") or "") != "success" ] if failed: - raise QueueError( - f"integration PR #{number} CI failed: " + ", ".join(failed) + results.append( + { + "branch": branch, + "dispatched_ci": target["dispatched"], + "head_sha": head_sha, + "integration": target["integration"], + "pull_request": number, + "reason": ( + f"integration PR #{number} CI failed: " + + ", ".join(failed) + ), + "state": "blocked", + } ) + del pending[number] + continue missing = [name for name in configured if name not in latest] undispatched = [ name for name in missing if name not in target["requested"] @@ -3943,16 +4114,39 @@ def settle_integration_checks( del pending[number] continue if entry.state == "blocked": - raise QueueError( - f"integration PR #{number} is blocked: " - + "; ".join(entry.reasons or ["unknown gate"]) + results.append( + { + "branch": branch, + "dispatched_ci": target["dispatched"], + "head_sha": head_sha, + "integration": target["integration"], + "pull_request": number, + "reason": ( + f"integration PR #{number} is blocked: " + + "; ".join(entry.reasons or ["unknown gate"]) + ), + "state": "blocked", + } ) + del pending[number] + continue if not pending: break if time.monotonic() >= deadline: - numbers = ", ".join(f"#{number}" for number in pending) - noun = "PR" if len(pending) == 1 else "PRs" - raise QueueError(f"integration {noun} {numbers} CI timed out") + for number, target in list(pending.items()): + results.append( + { + "branch": target["branch"], + "dispatched_ci": target["dispatched"], + "head_sha": target["head_sha"], + "integration": target["integration"], + "pull_request": number, + "reason": f"integration PR #{number} CI timed out", + "state": "pending", + } + ) + del pending[number] + break time.sleep(poll_seconds) return results @@ -5505,9 +5699,30 @@ def command_react( ) -> dict[str, Any]: control = client.pipeline_control() if control.get("state") == "paused": - result = {"state": "paused", "reason": control.get("reason")} - print(json.dumps(result, indent=2, sort_keys=True)) - return result + paused_main = str(control.get("main_sha") or "") + current_main = client.base_sha() + verified_main = client.verified_main_sha() + if ( + paused_main + and str(control.get("reason") or "").startswith( + SUPERSEDED_CONTROLLER_PAUSES + ) + and current_main != paused_main + and verified_main == current_main + and client.is_ancestor(paused_main, current_main) + ): + control_id = str(control.get("control_id") or "") + if control_id: + client.set_pipeline_control( + "running", + f"newer verified main {current_main} supersedes paused {paused_main}", + resumes_control_id=control_id, + ) + control = client.pipeline_control() + if control.get("state") == "paused": + result = {"state": "paused", "reason": control.get("reason")} + print(json.dumps(result, indent=2, sort_keys=True)) + return result # An authorized PR may have landed outside the controller. Materialize its # durable release obligation before deciding whether an empty watermark @@ -5655,18 +5870,25 @@ def own_integration_checks( numbers: Iterable[int] | None = None, ) -> list[dict[str, Any]]: try: - return settle_integration_checks( + results = settle_integration_checks( client, timeout_seconds=timeout_seconds, poll_seconds=10, numbers=numbers, ) except QueueError as error: + # Ordinary integration gate failures are returned as batch-local + # results. Unexpected API, dispatch, or consistency failures still + # honor the configured fail-closed pipeline policy. if client.config.pipeline.pause_on_failure: client.set_pipeline_control( "paused", "integration CI ownership failed: " + str(error) ) raise + for result in results: + if result.get("state") == "blocked": + record_integration_ci_repair(client, result) + return results integration_checks = own_integration_checks() promoted_integrations = promote_integrations( diff --git a/src/agent_merge_queue/doctor.py b/src/agent_merge_queue/doctor.py index 46968c2..4e50452 100644 --- a/src/agent_merge_queue/doctor.py +++ b/src/agent_merge_queue/doctor.py @@ -2,15 +2,24 @@ from __future__ import annotations +import base64 import json +import re import shutil import subprocess from pathlib import Path from typing import Any +from . import __version__ from .config import ConfigError, QueueConfig, load_config +DEPLOYBOT_ACTION = re.compile( + r"uses:\s*Forward-Future/DeployBot@([0-9a-f]{40})\b", re.IGNORECASE +) +PACKAGE_VERSION = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE) + + def row( check: str, status: str, detail: str, hint: str | None = None ) -> dict[str, Any]: @@ -39,6 +48,16 @@ def _json(*arguments: str, cwd: Path) -> tuple[int, Any, str]: return 1, None, "GitHub returned invalid JSON" +def _decoded_content(value: Any) -> str | None: + encoded = str((value or {}).get("content") or "") + if not encoded: + return None + try: + return base64.b64decode(encoded).decode("utf-8") + except (ValueError, UnicodeDecodeError): + return None + + def diagnose( *, config_path: str | None, @@ -165,6 +184,48 @@ def diagnose( else "Install examples/github-workflow.yml on the default branch.", ) ) + if installed: + workflow_path = str(installed[0].get("path") or "").lstrip("/") + code, workflow_file, _ = _json( + "api", f"repos/{repo}/contents/{workflow_path}", cwd=root + ) + workflow_text = _decoded_content(workflow_file) if code == 0 else None + action_match = DEPLOYBOT_ACTION.search(workflow_text or "") + hosted_version: str | None = None + if action_match: + action_sha = action_match.group(1) + code, package_file, _ = _json( + "api", + "repos/Forward-Future/DeployBot/contents/pyproject.toml" + f"?ref={action_sha}", + cwd=root, + ) + package_text = _decoded_content(package_file) if code == 0 else None + version_match = PACKAGE_VERSION.search(package_text or "") + hosted_version = version_match.group(1) if version_match else None + if hosted_version: + rows.append( + row( + "controller-version", + "ok" if hosted_version == __version__ else "warn", + f"Hosted workflow runs v{hosted_version}; local tool is " + f"v{__version__}", + ( + None + if hosted_version == __version__ + else "Pin the reviewed current DeployBot action commit." + ), + ) + ) + else: + rows.append( + row( + "controller-version", + "warn", + "Could not identify the hosted DeployBot action version", + "Use an immutable 40-character Forward-Future/DeployBot pin.", + ) + ) if ( config.integration.mode in {"overlap", "all"} diff --git a/tests/test_cli.py b/tests/test_cli.py index 2b8722b..727a0af 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -62,6 +62,7 @@ queue_timestamp, reconcile_externally_merged_threads, record_integration_conflict_repair, + record_integration_ci_repair, record_repair, release_follow_needed, repair_overlap_hold_active, @@ -2110,6 +2111,91 @@ def test_reactor_holds_admission_until_existing_release_finishes(self) -> None: self.assertEqual(result["state"], "release-held") promote.assert_not_called() + def test_reactor_auto_resumes_pause_superseded_by_verified_main(self) -> None: + paused = "a" * 40 + current = "b" * 40 + client = Mock() + client.config = CONFIG + client.pipeline_control.side_effect = [ + { + "state": "paused", + "control_id": "pause-1", + "main_sha": paused, + "reason": "integration CI ownership failed: integration PR #983 CI failed", + }, + { + "state": "running", + "control_id": "resume-1", + "resumes_control_id": "pause-1", + "recovered_main_sha": paused, + }, + ] + client.base_sha.return_value = current + client.verified_main_sha.return_value = current + client.is_ancestor.return_value = True + frozen = FreezeResult(None, [], [], [], []) + with ( + patch("agent_merge_queue.cli.settle_integration_checks", return_value=[]), + patch("agent_merge_queue.cli.promote_integrations", return_value=[]), + patch( + "agent_merge_queue.cli.command_promote", + return_value={"promoted": [], "waiting": [], "blocked": []}, + ), + patch( + "agent_merge_queue.cli.command_drain", + return_value={"merged": []}, + ), + patch("agent_merge_queue.cli.freeze_queue", return_value=frozen), + redirect_stdout(io.StringIO()), + ): + result = command_react(client, follow=False, timeout_seconds=10) + + self.assertEqual(result["state"], "complete") + client.set_pipeline_control.assert_called_once_with( + "running", + f"newer verified main {current} supersedes paused {paused}", + resumes_control_id="pause-1", + ) + + def test_reactor_keeps_pause_when_newer_main_is_not_verified(self) -> None: + paused = "a" * 40 + current = "b" * 40 + client = Mock() + client.pipeline_control.return_value = { + "state": "paused", + "control_id": "pause-1", + "main_sha": paused, + "reason": "main CI failed", + } + client.base_sha.return_value = current + client.verified_main_sha.return_value = paused + + with redirect_stdout(io.StringIO()): + result = command_react(client, follow=False, timeout_seconds=10) + + self.assertEqual(result["state"], "paused") + client.set_pipeline_control.assert_not_called() + + def test_reactor_never_auto_resumes_explicit_operator_pause(self) -> None: + paused = "a" * 40 + current = "b" * 40 + client = Mock() + client.pipeline_control.return_value = { + "state": "paused", + "control_id": "pause-1", + "main_sha": paused, + "reason": "hold releases for maintenance", + } + client.base_sha.return_value = current + client.verified_main_sha.return_value = current + client.is_ancestor.return_value = True + + with redirect_stdout(io.StringIO()): + result = command_react(client, follow=False, timeout_seconds=10) + + self.assertEqual(result["state"], "paused") + client.set_pipeline_control.assert_not_called() + def test_reactor_holds_admission_even_without_follow(self) -> None: sha = "a" * 40 client = Mock() @@ -4770,6 +4856,92 @@ def tracked_intent( self.assertEqual(result["repair_owner"]["pull_request"], 2) self.assertEqual(repair["thread_id"], "thread-2") + def test_failed_integration_ci_blocks_only_its_batch_and_elects_owner( + self, + ) -> None: + source = entry(1, "shared.py") + intent = { + "id": 1, + "created_at": "2026-06-20T00:00:00Z", + "user": {"login": "trusted"}, + "body": intent_body( + intent_id="intent-1", + state="requested", + requested_at="2026-06-20T00:00:00Z", + requested_head=source.head_sha, + provider="codex", + thread_id="thread-1", + ), + } + integration = { + "batch_id": "batch-1", + "conflict": None, + "heads": {"1": source.head_sha}, + "pull_requests": [1], + } + result = { + "pull_request": 99, + "integration": integration, + "reason": "integration PR #99 CI failed: CI", + "state": "blocked", + } + client = Mock() + client.config = CONFIG + client.trusted_logins = {"trusted"} + client.snapshot.return_value = source + client.comments.return_value = [intent] + client.labels.return_value = set() + client.base_sha.return_value = "b" * 40 + + repair = record_integration_ci_repair(client, result) + + self.assertEqual(repair["repair_pull_request"], 99) + self.assertEqual(result["repair_owner"]["pull_request"], 1) + self.assertEqual( + [value.args for value in client.add_label.call_args_list], + [(1, CONFIG.blocked_label), (99, CONFIG.blocked_label)], + ) + integration_comment = client.comment.call_args_list[-1].args[1] + failed = latest_payload( + [ + { + "id": 2, + "created_at": "2026-06-20T00:01:00Z", + "user": {"login": "coordinator"}, + "body": integration_comment, + } + ], + INTEGRATION_MARKER, + {"coordinator"}, + ) + self.assertEqual(failed["conflict"]["number"], 99) + client.set_pipeline_control.assert_not_called() + + def test_failed_integration_stays_retryable_until_repair_owner_exists( + self, + ) -> None: + source_head = "1" * 40 + result = { + "pull_request": 99, + "integration": { + "batch_id": "batch-1", + "conflict": None, + "heads": {"1": source_head}, + "pull_requests": [1], + }, + "reason": "integration PR #99 CI failed: CI", + "state": "blocked", + } + client = Mock() + client.config = CONFIG + client.snapshot.side_effect = QueueError("transient source read failure") + + repair = record_integration_ci_repair(client, result) + + self.assertIsNone(repair) + client.comment.assert_not_called() + client.add_label.assert_not_called() + def test_resumed_integration_finishes_source_label_delegation(self) -> None: integration_head = "9" * 40 marker = { @@ -5961,6 +6133,171 @@ def test_integration_ci_dispatch_is_owned_until_the_pr_is_ready(self) -> None: ) self.assertEqual(result[0]["state"], "ready") + def test_failed_integration_ci_returns_batch_local_block(self) -> None: + number = 38 + head_sha = "a" * 40 + branch = "deploybot/integration/batch" + marker = { + "batch_id": "batch", + "conflict": None, + "heads": {"1": "1" * 40, "2": "2" * 40}, + "pull_requests": [1, 2], + } + client = Mock() + client.config = CONFIG + client.coordinator_logins = {"coordinator"} + client.comments.return_value = [ + { + "created_at": "2026-06-20T00:00:00Z", + "user": {"login": "coordinator"}, + "body": integration_body(marker), + } + ] + client.pull_head.return_value = { + "branch": branch, + "head_sha": head_sha, + "state": "OPEN", + } + client.workflow_runs_for_branch.return_value = [ + { + "id": 7, + "name": "CI", + "head_sha": head_sha, + "event": "workflow_dispatch", + "status": "completed", + "conclusion": "failure", + "created_at": "2026-06-20T00:01:00Z", + } + ] + + result = settle_integration_checks( + client, + timeout_seconds=10, + poll_seconds=0, + numbers=[number], + ) + + self.assertEqual(result[0]["state"], "blocked") + self.assertEqual(result[0]["integration"]["pull_requests"], [1, 2]) + self.assertIn("CI failed", result[0]["reason"]) + client.set_pipeline_control.assert_not_called() + + def test_closed_integration_before_ci_returns_batch_local_block(self) -> None: + number = 38 + marker = { + "batch_id": "batch", + "conflict": None, + "heads": {"1": "1" * 40}, + "pull_requests": [1], + } + client = Mock() + client.config = CONFIG + client.coordinator_logins = {"coordinator"} + client.comments.return_value = [ + { + "created_at": "2026-06-20T00:00:00Z", + "user": {"login": "coordinator"}, + "body": integration_body(marker), + } + ] + client.pull_head.return_value = { + "branch": "deploybot/integration/batch", + "head_sha": "a" * 40, + "state": "CLOSED", + } + + result = settle_integration_checks( + client, + timeout_seconds=10, + poll_seconds=0, + numbers=[number], + ) + + self.assertEqual(result[0]["state"], "blocked") + self.assertIn("no longer open", result[0]["reason"]) + + def test_merged_integration_before_ci_is_not_blocked(self) -> None: + number = 38 + marker = { + "batch_id": "batch", + "conflict": None, + "heads": {"1": "1" * 40}, + "pull_requests": [1], + } + client = Mock() + client.config = CONFIG + client.coordinator_logins = {"coordinator"} + client.comments.return_value = [ + { + "created_at": "2026-06-20T00:00:00Z", + "user": {"login": "coordinator"}, + "body": integration_body(marker), + } + ] + client.pull_head.return_value = { + "branch": "deploybot/integration/batch", + "head_sha": "a" * 40, + "state": "MERGED", + } + + result = settle_integration_checks( + client, + timeout_seconds=10, + poll_seconds=0, + numbers=[number], + ) + + self.assertEqual(result[0]["state"], "merged") + client.add_label.assert_not_called() + + def test_integration_ci_poll_timeout_remains_retryable(self) -> None: + number = 38 + head_sha = "a" * 40 + marker = { + "batch_id": "batch", + "conflict": None, + "heads": {"1": "1" * 40}, + "pull_requests": [1], + } + client = Mock() + client.config = CONFIG + client.coordinator_logins = {"coordinator"} + client.comments.return_value = [ + { + "created_at": "2026-06-20T00:00:00Z", + "user": {"login": "coordinator"}, + "body": integration_body(marker), + } + ] + client.pull_head.return_value = { + "branch": "deploybot/integration/batch", + "head_sha": head_sha, + "state": "OPEN", + } + client.workflow_runs_for_branch.return_value = [ + { + "id": 7, + "name": "CI", + "head_sha": head_sha, + "event": "workflow_dispatch", + "status": "in_progress", + "conclusion": None, + "created_at": "2026-06-20T00:01:00Z", + } + ] + + with patch("agent_merge_queue.cli.time.monotonic", side_effect=[0, 2]): + result = settle_integration_checks( + client, + timeout_seconds=1, + poll_seconds=0, + numbers=[number], + ) + + self.assertEqual(result[0]["state"], "pending") + self.assertIn("timed out", result[0]["reason"]) + client.add_label.assert_not_called() + def test_integration_ci_dispatches_when_token_authored_pr_run_is_suppressed( self, ) -> None: diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 055cd6e..be13b98 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import tempfile import unittest from pathlib import Path @@ -9,6 +10,61 @@ class DoctorTest(unittest.TestCase): + def test_hosted_controller_version_drift_is_reported(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / ".mergequeue.toml").write_text( + '[queue]\nrequired_checks = ["CI"]\ntrusted_actors = ["trusted"]\n', + encoding="utf-8", + ) + + def encoded(value: str) -> dict[str, str]: + return { + "content": base64.b64encode(value.encode("utf-8")).decode() + } + + def fake_json(*arguments: str, cwd: Path): + joined = " ".join(arguments) + if "workflow list" in joined: + return 0, [ + { + "name": "DeployBot", + "path": ".github/workflows/deploybot.yml", + "state": "active", + } + ], "" + if "contents/.github/workflows/deploybot.yml" in joined: + return 0, encoded( + "uses: Forward-Future/DeployBot@" + "a" * 40 + ), "" + if "contents/pyproject.toml" in joined: + return 0, encoded('[project]\nversion = "0.2.18"\n'), "" + if "users/trusted" in joined: + return 0, {"login": "trusted"}, "" + if "label list" in joined or "pr list" in joined: + return 0, [], "" + if "protection" in joined: + return 1, None, "not available" + return 0, { + "nameWithOwner": "owner/repo", + "hasIssuesEnabled": True, + }, "" + + with ( + patch( + "agent_merge_queue.doctor.shutil.which", return_value="/usr/bin/gh" + ), + patch("agent_merge_queue.doctor._gh", return_value=(0, "ok")), + patch("agent_merge_queue.doctor._json", side_effect=fake_json), + ): + rows = diagnose(config_path=None, repository="owner/repo", cwd=root) + + version = next( + value for value in rows if value["check"] == "controller-version" + ) + self.assertEqual(version["status"], "warn") + self.assertIn("v0.2.18", version["detail"]) + def test_missing_github_cli_is_one_clean_failure(self) -> None: with patch("agent_merge_queue.doctor.shutil.which", return_value=None): rows = diagnose(config_path=None, repository=None) diff --git a/tests/test_skill.py b/tests/test_skill.py index 1e446d6..c074a82 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -175,6 +175,8 @@ def test_github_workflow_wakes_after_named_ci_finishes(self) -> None: self.assertIn("github.event.check_suite.app.slug != 'github-actions'", workflow) self.assertIn("github.event.check_suite.pull_requests[0].base.ref", workflow) self.assertIn("persist-credentials: false", workflow) + self.assertIn("follow: ${{ github.event_name == 'workflow_run'", workflow) + self.assertIn("&& '2400' || '600'", workflow) def test_workflows_pin_current_checkout_runtime(self) -> None: paths = [ From 5f509bc5889b411d17336d79493f622d60682c22 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:25:58 -0700 Subject: [PATCH 2/2] Pin DeployBot v0.2.22 runtime --- README.md | 6 +++--- adapters/claude-code/.mcp.json | 2 +- adapters/cursor/.cursor/mcp.json | 2 +- examples/github-workflow.yml | 4 ++-- tests/test_skill.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d2b476c..c46c149 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ integration PRs, follows `main` through production, and pauses after failures. ## Install -Install the reviewed `v0.2.21` source commit directly from GitHub: +Install the reviewed `v0.2.22` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@ecc5f60e90d6201daddf3764be60cb7122360722' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@c51d5d3008c992d7787a26508fac0d3afe65e30b' deploybot init ``` @@ -95,7 +95,7 @@ worker can dispatch deployment when GitHub suppresses the `workflow_run` event for token-dispatched CI. Pin the Action to the full reviewed release commit: ```yaml -- uses: Forward-Future/DeployBot@ecc5f60e90d6201daddf3764be60cb7122360722 +- uses: Forward-Future/DeployBot@c51d5d3008c992d7787a26508fac0d3afe65e30b ``` The Action uses GitHub's built-in workflow token. GitHub intentionally does not diff --git a/adapters/claude-code/.mcp.json b/adapters/claude-code/.mcp.json index ac4afcf..2a37c49 100644 --- a/adapters/claude-code/.mcp.json +++ b/adapters/claude-code/.mcp.json @@ -4,7 +4,7 @@ "command": "uvx", "args": [ "--from", - "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@ecc5f60e90d6201daddf3764be60cb7122360722", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@c51d5d3008c992d7787a26508fac0d3afe65e30b", "deploybot-mcp" ] } diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index ac4afcf..2a37c49 100644 --- a/adapters/cursor/.cursor/mcp.json +++ b/adapters/cursor/.cursor/mcp.json @@ -4,7 +4,7 @@ "command": "uvx", "args": [ "--from", - "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@ecc5f60e90d6201daddf3764be60cb7122360722", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@c51d5d3008c992d7787a26508fac0d3afe65e30b", "deploybot-mcp" ] } diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index d38d6d5..1f4dd18 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -77,8 +77,8 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - # v0.2.21 implementation; keep the full commit for privileged workflows. - - uses: Forward-Future/DeployBot@ecc5f60e90d6201daddf3764be60cb7122360722 + # v0.2.22 implementation; keep the full commit for privileged workflows. + - uses: Forward-Future/DeployBot@c51d5d3008c992d7787a26508fac0d3afe65e30b with: # PR and review events reconcile quickly. Only release-owner events # stay attached to cumulative main through CI and deployment. diff --git a/tests/test_skill.py b/tests/test_skill.py index c074a82..cf5e9b5 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -8,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] CANONICAL = ROOT / "skills" / "deploybot" / "SKILL.md" -RELEASE_COMMIT = "ecc5f60e90d6201daddf3764be60cb7122360722" +RELEASE_COMMIT = "c51d5d3008c992d7787a26508fac0d3afe65e30b" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"