From 94fe042a58c731343c8bbd73d0bdfcb38a55866f Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:38:56 -0700 Subject: [PATCH 1/2] Dispatch deploys after token-driven CI --- README.md | 40 +++++++++++-- pyproject.toml | 2 +- src/agent_merge_queue/__init__.py | 2 +- src/agent_merge_queue/cli.py | 85 +++++++++++++++++++++++---- src/agent_merge_queue/pipeline.py | 48 +++++++++++++++- tests/test_cli.py | 30 ++++++++++ tests/test_pipeline.py | 95 +++++++++++++++++++++++++++++++ 7 files changed, 279 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e95e33d..2d3ee9d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ integration PRs, follows `main` through production, and pauses after failures. ## Install -Install the reviewed `v0.2.4` source commit directly from GitHub: +Install the reviewed `v0.2.5` source commit directly from GitHub: ```bash python3 -m pip install \ @@ -91,11 +91,39 @@ executes pull-request code. Pin the Action to the full reviewed release commit: The Action uses GitHub's built-in workflow token. GitHub intentionally does not turn merges made by that token into ordinary `push` workflow runs, so DeployBot -dispatches each configured CI workflow once after it merges a batch. Those CI -workflows must accept `workflow_dispatch`; the resulting completed CI run can -trigger the repository's normal deployment workflow. Set Action input -`dispatch_ci: "false"` only when a caller supplies a different merge identity -that already triggers push CI. +dispatches each configured CI workflow once after it merges a batch. GitHub can +also suppress the usual `workflow_run` handoff after that token-driven CI run, +so DeployBot explicitly dispatches each configured deployment workflow after +exact-main CI succeeds. CI workflows must accept `workflow_dispatch`. +Deployment workflows must accept `workflow_dispatch` inputs named `ci_sha` and +`ci_run_id`, verify that run through the GitHub API, and deploy only when it is +successful CI for the current base-branch head. Skipped deployment wake-ups +from pull-request CI are ignored. Set Action input `dispatch_ci: "false"` only +when a caller supplies a different merge identity that already triggers push +CI. + +The deployment workflow keeps its normal `workflow_run` trigger for push CI +and adds this exact-input recovery path for DeployBot-dispatched CI: + +```yaml +on: + workflow_run: + workflows: [CI] + types: [completed] + workflow_dispatch: + inputs: + ci_sha: + required: true + type: string + ci_run_id: + required: true + type: string +``` + +Before releasing, use `ci_run_id` to read the run from GitHub and require its +workflow name, base branch, head SHA, event, status, and conclusion to match the +expected successful exact-main CI run. The deployment must still pull the +current base branch and stop if it no longer equals `ci_sha`. The workflow bot and each person allowed to request deployment must be explicitly listed: diff --git a/pyproject.toml b/pyproject.toml index 48640e7..4b3a8d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deploybot-merge-queue" -version = "0.2.4" +version = "0.2.5" 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 1a17d6d..5059a71 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.4" +__version__ = "0.2.5" diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index 7eed72a..aeaba91 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -342,10 +342,7 @@ def effective_queue_marker( delegated and integration and delegated.get("integration_batch_id") == integration.get("batch_id") - and ( - integration_authorized is None - or integration_authorized(integration) - ) + and (integration_authorized is None or integration_authorized(integration)) ): return delegated return direct @@ -892,6 +889,65 @@ def dispatch_ci_workflows(self) -> list[dict[str, Any]]: dispatched.append({"id": workflow_id, "name": name}) return dispatched + def dispatch_deploy_workflows( + self, *, ci_run: dict[str, Any] + ) -> list[dict[str, Any]]: + configured = self.config.pipeline.deploy_workflows + if not configured: + raise QueueError("no deployment workflow is configured") + ci_sha = str(ci_run.get("head_sha") or "") + ci_run_id = int(ci_run.get("id") or 0) + if not ci_sha or not ci_run_id: + raise QueueError("successful CI identity is incomplete") + values = self._json( + "workflow", + "list", + "--repo", + self.repository, + "--all", + "--limit", + "1000", + "--json", + "id,name,state", + ) + workflows = values if isinstance(values, list) else [] + dispatched: list[dict[str, Any]] = [] + for name in configured: + matches = [ + value + for value in workflows + if str(value.get("name") or "") == name + and str(value.get("state") or "") == "active" + ] + if len(matches) != 1: + raise QueueError( + f"configured deployment workflow {name!r} did not resolve " + "to one active workflow" + ) + workflow_id = int(matches[0]["id"]) + self._run( + "workflow", + "run", + str(workflow_id), + "--repo", + self.repository, + "--ref", + self.config.base_branch, + "-f", + f"ci_sha={ci_sha}", + "-f", + f"ci_run_id={ci_run_id}", + ) + dispatched.append( + { + "id": workflow_id, + "name": name, + "ci_sha": ci_sha, + "ci_run_id": ci_run_id, + } + ) + return dispatched + def recent_merged_pull_requests(self, limit: int) -> list[dict[str, Any]]: values = self._paged_api( "repos/" @@ -1101,9 +1157,7 @@ def _paged_api(self, endpoint: str) -> list[dict[str, Any]]: values.extend(item for item in page if isinstance(item, dict)) return values - def _paged_object_items( - self, endpoint: str, key: str - ) -> list[dict[str, Any]]: + def _paged_object_items(self, endpoint: str, key: str) -> list[dict[str, Any]]: data = self._json("api", "--paginate", "--slurp", endpoint) pages = data if isinstance(data, list) else [] values: list[dict[str, Any]] = [] @@ -2587,11 +2641,18 @@ def command_follow( json_output: bool, emit: bool = True, ) -> dict[str, Any]: - result = follow_release( - client, - timeout_seconds=timeout_seconds, - poll_seconds=poll_seconds, - ) + try: + result = follow_release( + client, + timeout_seconds=timeout_seconds, + poll_seconds=poll_seconds, + ) + except QueueError as error: + if client.config.pipeline.pause_on_failure: + client.set_pipeline_control( + "paused", "release workflow dispatch failed: " + str(error) + ) + raise if ( result["state"] in {"ci-failed", "deploy-failed", "verify-failed"} and client.config.pipeline.pause_on_failure diff --git a/src/agent_merge_queue/pipeline.py b/src/agent_merge_queue/pipeline.py index 68ce0f9..ce28c45 100644 --- a/src/agent_merge_queue/pipeline.py +++ b/src/agent_merge_queue/pipeline.py @@ -49,7 +49,25 @@ def release_state( *, main_sha: str, runs: list[dict[str, Any]], config: PipelineConfig ) -> dict[str, Any]: ci = latest_run(runs, config.ci_workflows, main_sha) - deploy = latest_run(runs, config.deploy_workflows, main_sha) + # A workflow_run deployment commonly starts for pull-request CI and then + # skips itself because the upstream run was not exact main. GitHub reports + # the downstream run against the default-branch SHA, so it can otherwise + # hide a real deployment (or look like a deployment failure) for that same + # revision. A skipped run did not attempt a release and is not evidence. + ci_fence = str((ci or {}).get("updated_at") or (ci or {}).get("created_at") or "") + deploy = latest_run( + [ + run + for run in runs + if not ( + str(run.get("status") or "") == "completed" + and str(run.get("conclusion") or "") == "skipped" + ) + and (not ci_fence or str(run.get("created_at") or "") >= ci_fence) + ], + config.deploy_workflows, + main_sha, + ) active_ci = [ workflow_run(run) for run in runs @@ -186,6 +204,8 @@ def follow_release( deadline = time.monotonic() + timeout_seconds observed_sha = "" last_verifications: list[dict[str, Any]] = [] + dispatched_deployments: list[dict[str, Any]] = [] + dispatched_for: set[tuple[str, int]] = set() while True: main_sha = client.base_sha() runs = client.workflow_runs() @@ -194,12 +214,33 @@ def follow_release( ) observed_sha = main_sha if value["state"] in {"ci-failed", "deploy-failed"}: - return {**value, "verifications": []} + return { + **value, + "dispatched_deployments": dispatched_deployments, + "verifications": [], + } + if value["state"] == "awaiting-deploy": + ci = value.get("latest_ci") or {} + ci_id = int(ci.get("id") or 0) + key = (main_sha, ci_id) + # Workflows launched with github.token do not reliably emit the + # workflow_run handoff that repositories usually use for deploys. + # Dispatch the configured deployment explicitly, carrying the + # exact successful CI identity into the protected workflow. + if ci.get("event") == "workflow_dispatch" and key not in dispatched_for: + dispatched_deployments.extend( + client.dispatch_deploy_workflows(ci_run=ci) + ) + dispatched_for.add(key) if value["state"] == "verified": checks = http_verifications(client.config.pipeline) last_verifications = checks if all(item["passed"] for item in checks): - return {**value, "verifications": checks} + return { + **value, + "dispatched_deployments": dispatched_deployments, + "verifications": checks, + } if time.monotonic() >= deadline: state = ( "verify-failed" @@ -209,6 +250,7 @@ def follow_release( return { **value, "state": state, + "dispatched_deployments": dispatched_deployments, "observed_sha": observed_sha, "verifications": last_verifications, } diff --git a/tests/test_cli.py b/tests/test_cli.py index 9043969..f9a4733 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1237,6 +1237,36 @@ def test_github_dispatches_each_configured_active_ci_workflow(self) -> None: "workflow", "run", "7", "--repo", "example/repo", "--ref", "main" ) + def test_github_dispatches_deploy_with_exact_successful_ci(self) -> None: + client = object.__new__(GitHub) + client.config = CONFIG + client.repository = "example/repo" + client._json = Mock( + return_value=[{"id": 8, "name": "Deploy", "state": "active"}] + ) + client._run = Mock(return_value="") + sha = "a" * 40 + + result = client.dispatch_deploy_workflows(ci_run={"id": 42, "head_sha": sha}) + + self.assertEqual( + result, + [{"id": 8, "name": "Deploy", "ci_sha": sha, "ci_run_id": 42}], + ) + client._run.assert_called_once_with( + "workflow", + "run", + "8", + "--repo", + "example/repo", + "--ref", + "main", + "-f", + f"ci_sha={sha}", + "-f", + "ci_run_id=42", + ) + def test_pipeline_pause_blocks_every_merge_path(self) -> None: client = Mock() client.pipeline_control.return_value = { diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 0f5c445..1d94274 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -50,6 +50,101 @@ def test_release_state_requires_exact_main_ci_then_deploy(self) -> None: "testing", ) + def test_release_state_ignores_skipped_deployment_wakeups(self) -> None: + sha = "a" * 40 + runs = [ + { + "id": 1, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "event": "workflow_dispatch", + "created_at": "2026-06-20T00:00:00Z", + }, + { + "id": 2, + "name": "Deploy", + "head_sha": sha, + "status": "completed", + "conclusion": "skipped", + "created_at": "2026-06-20T00:01:00Z", + }, + ] + + value = release_state(main_sha=sha, runs=runs, config=CONFIG.pipeline) + + self.assertEqual(value["state"], "awaiting-deploy") + self.assertIsNone(value["latest_deploy"]) + + def test_new_successful_ci_supersedes_an_older_failed_deploy(self) -> None: + sha = "a" * 40 + runs = [ + { + "id": 1, + "name": "Deploy", + "head_sha": sha, + "status": "completed", + "conclusion": "failure", + "created_at": "2026-06-20T00:01:00Z", + }, + { + "id": 2, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "event": "workflow_dispatch", + "created_at": "2026-06-20T00:02:00Z", + "updated_at": "2026-06-20T00:03:00Z", + }, + ] + + value = release_state(main_sha=sha, runs=runs, config=CONFIG.pipeline) + + self.assertEqual(value["state"], "awaiting-deploy") + self.assertIsNone(value["latest_deploy"]) + + def test_follow_dispatches_deploy_after_token_dispatched_ci(self) -> None: + sha = "a" * 40 + ci = { + "id": 1, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "event": "workflow_dispatch", + "created_at": "2026-06-20T00:00:00Z", + } + deploy = { + "id": 2, + "name": "Deploy", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "event": "workflow_dispatch", + "created_at": "2026-06-20T00:01:00Z", + } + client = Mock() + client.config = CONFIG + client.base_sha.return_value = sha + client.workflow_runs.side_effect = [[ci], [ci, deploy]] + client.dispatch_deploy_workflows.return_value = [ + {"id": 9, "name": "Deploy", "ci_sha": sha, "ci_run_id": 1} + ] + with ( + patch("agent_merge_queue.pipeline.time.sleep"), + patch("agent_merge_queue.pipeline.time.monotonic", side_effect=[0, 1]), + ): + result = follow_release(client, timeout_seconds=10, poll_seconds=1) + + client.dispatch_deploy_workflows.assert_called_once() + dispatched_ci = client.dispatch_deploy_workflows.call_args.kwargs["ci_run"] + self.assertEqual(dispatched_ci["id"], 1) + self.assertEqual(dispatched_ci["head_sha"], sha) + self.assertEqual(result["state"], "verified") + self.assertEqual(result["dispatched_deployments"][0]["id"], 9) + def test_follow_switches_to_newer_cumulative_main(self) -> None: old = "a" * 40 new = "b" * 40 From 5b8d7812ef51a34cb4bc6d5d3650e4229ffac872 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:39:13 -0700 Subject: [PATCH 2/2] Pin DeployBot v0.2.5 clients to implementation --- README.md | 4 ++-- adapters/claude-code/.mcp.json | 2 +- adapters/codex/agent-merge-queue/.mcp.json | 2 +- adapters/cursor/.cursor/mcp.json | 2 +- examples/github-workflow.yml | 4 ++-- tests/test_skill.py | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2d3ee9d..255565f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Install the reviewed `v0.2.5` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@9986210b077ebb4c53f2c5b1bbd461ba02dfce19' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@94fe042a58c731343c8bbd73d0bdfcb38a55866f' deploybot init ``` @@ -86,7 +86,7 @@ aligned with `pipeline.ci_workflows`. The privileged worker never checks out or executes pull-request code. Pin the Action to the full reviewed release commit: ```yaml -- uses: Forward-Future/DeployBot@9986210b077ebb4c53f2c5b1bbd461ba02dfce19 +- uses: Forward-Future/DeployBot@94fe042a58c731343c8bbd73d0bdfcb38a55866f ``` 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 934ebe7..51dcc92 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@9986210b077ebb4c53f2c5b1bbd461ba02dfce19", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@94fe042a58c731343c8bbd73d0bdfcb38a55866f", "deploybot-mcp" ] } diff --git a/adapters/codex/agent-merge-queue/.mcp.json b/adapters/codex/agent-merge-queue/.mcp.json index 934ebe7..51dcc92 100644 --- a/adapters/codex/agent-merge-queue/.mcp.json +++ b/adapters/codex/agent-merge-queue/.mcp.json @@ -4,7 +4,7 @@ "command": "uvx", "args": [ "--from", - "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@9986210b077ebb4c53f2c5b1bbd461ba02dfce19", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@94fe042a58c731343c8bbd73d0bdfcb38a55866f", "deploybot-mcp" ] } diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index 934ebe7..fa50b07 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@9986210b077ebb4c53f2c5b1bbd461ba02dfce19", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@94fe042a58c731343c8bbd73d0bdfcb38a55866f", "deploybot-mcp" ] } diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index 2a48ed2..d693f02 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -55,5 +55,5 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - # v0.2.4 implementation; keep the full commit for privileged workflows. - - uses: Forward-Future/DeployBot@9986210b077ebb4c53f2c5b1bbd461ba02dfce19 + # v0.2.5 implementation; keep the full commit for privileged workflows. + - uses: Forward-Future/DeployBot@94fe042a58c731343c8bbd73d0bdfcb38a55866f diff --git a/tests/test_skill.py b/tests/test_skill.py index 2efdab2..439d66d 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -6,7 +6,7 @@ ROOT = Path(__file__).resolve().parents[1] CANONICAL = ROOT / "skills" / "deploybot" / "SKILL.md" -RELEASE_COMMIT = "9986210b077ebb4c53f2c5b1bbd461ba02dfce19" +RELEASE_COMMIT = "94fe042a58c731343c8bbd73d0bdfcb38a55866f" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"