diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ed766a..0091c9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: pull_request: push: branches: [main] + workflow_dispatch: permissions: contents: read diff --git a/README.md b/README.md index 8a27ecd..613df99 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.1` source commit directly from GitHub: +Install the reviewed `v0.2.2` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@6ed5569ffbcbb4dea1dece6f889726b67d9411f2' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@2a502a74251e2fbb07e5b4769ce99687235df5ae' deploybot init ``` @@ -86,9 +86,17 @@ 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@6ed5569ffbcbb4dea1dece6f889726b67d9411f2 +- uses: Forward-Future/DeployBot@2a502a74251e2fbb07e5b4769ce99687235df5ae ``` +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. + The workflow bot and each person allowed to request deployment must be explicitly listed: diff --git a/action.yml b/action.yml index d734682..dc3bff6 100644 --- a/action.yml +++ b/action.yml @@ -9,6 +9,10 @@ inputs: description: Follow cumulative main through CI and deployment required: false default: "false" + dispatch_ci: + description: Dispatch configured CI after a merge made with github.token + required: false + default: "true" timeout: description: Maximum release-follow time in seconds required: false @@ -26,9 +30,13 @@ runs: DEPLOYBOT_CONFIG: ${{ inputs.config }} GH_TOKEN: ${{ github.token }} DEPLOYBOT_FOLLOW: ${{ inputs.follow }} + DEPLOYBOT_DISPATCH_CI: ${{ inputs.dispatch_ci }} DEPLOYBOT_TIMEOUT: ${{ inputs.timeout }} run: | args=(react --timeout "$DEPLOYBOT_TIMEOUT") + if [[ "$DEPLOYBOT_DISPATCH_CI" == "true" ]]; then + args+=(--dispatch-ci) + fi if [[ "$DEPLOYBOT_FOLLOW" == "true" ]]; then args+=(--follow) fi diff --git a/adapters/claude-code/.claude-plugin/plugin.json b/adapters/claude-code/.claude-plugin/plugin.json index c002716..1264963 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.1", + "version": "0.2.2", "description": "DeployBot: a provider-neutral GitHub merge queue for coding agents", "author": { "name": "DeployBot contributors" diff --git a/adapters/claude-code/.mcp.json b/adapters/claude-code/.mcp.json index 160d419..6a20b28 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@6ed5569ffbcbb4dea1dece6f889726b67d9411f2", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@2a502a74251e2fbb07e5b4769ce99687235df5ae", "deploybot-mcp" ] } diff --git a/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json b/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json index d059ced..63b1eb3 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.1", + "version": "0.2.2", "description": "Review, queue, and land pull requests through a shared GitHub merge queue", "author": { "name": "DeployBot contributors" diff --git a/adapters/codex/agent-merge-queue/.mcp.json b/adapters/codex/agent-merge-queue/.mcp.json index 160d419..6a20b28 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@6ed5569ffbcbb4dea1dece6f889726b67d9411f2", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@2a502a74251e2fbb07e5b4769ce99687235df5ae", "deploybot-mcp" ] } diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index 160d419..6a20b28 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@6ed5569ffbcbb4dea1dece6f889726b67d9411f2", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@2a502a74251e2fbb07e5b4769ce99687235df5ae", "deploybot-mcp" ] } diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index 0ed7a1b..ff210d4 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -18,7 +18,7 @@ permissions: pull-requests: write checks: read issues: write - actions: read + actions: write # required for post-merge workflow_dispatch with github.token concurrency: group: deploybot-${{ github.repository }} @@ -37,5 +37,5 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - # v0.2.1 implementation; keep the full commit for privileged workflows. - - uses: Forward-Future/DeployBot@6ed5569ffbcbb4dea1dece6f889726b67d9411f2 + # v0.2.2 implementation; keep the full commit for privileged workflows. + - uses: Forward-Future/DeployBot@2a502a74251e2fbb07e5b4769ce99687235df5ae diff --git a/pyproject.toml b/pyproject.toml index 8d02f23..2aa2ee7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deploybot-merge-queue" -version = "0.2.1" +version = "0.2.2" 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 d271b3d..c5e411d 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.1" +__version__ = "0.2.2" diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index f8995b0..bf2aad5 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -794,6 +794,47 @@ def workflow_runs(self) -> list[dict[str, Any]]: raise QueueError("GitHub returned invalid workflow-run data") return [run for run in runs if isinstance(run, dict)] + def dispatch_ci_workflows(self) -> list[dict[str, Any]]: + configured = self.config.pipeline.ci_workflows + if not configured: + raise QueueError("no CI workflow is configured for post-merge dispatch") + 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 CI 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, + ) + dispatched.append({"id": workflow_id, "name": name}) + return dispatched + def recent_merged_pull_requests(self, limit: int) -> list[dict[str, Any]]: values = self._paged_api( "repos/" @@ -2578,6 +2619,7 @@ def command_react( *, follow: bool, timeout_seconds: int, + dispatch_ci: bool = False, ) -> dict[str, Any]: control = client.pipeline_control() if control.get("state") == "paused": @@ -2627,6 +2669,16 @@ def command_react( entries=overlap_entries, ) ) + dispatched_ci: list[dict[str, Any]] = [] + if dispatch_ci and drained.get("merged"): + try: + dispatched_ci = client.dispatch_ci_workflows() + except QueueError as error: + if client.config.pipeline.pause_on_failure: + client.set_pipeline_control( + "paused", "post-merge CI dispatch failed: " + str(error) + ) + raise release: dict[str, Any] | None = None if follow and drained.get("merged"): release = command_follow( @@ -2641,6 +2693,7 @@ def command_react( "promoted": promoted, "promoted_integrations": promoted_integrations, "drain": drained, + "dispatched_ci": dispatched_ci, "integrations": integrations, "release": release, } @@ -2772,6 +2825,11 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser("promote", help="promote ready deploy requests into queue") react = subparsers.add_parser("react", help="event-driven promote and drain worker") react.add_argument("--follow", action="store_true") + react.add_argument( + "--dispatch-ci", + action="store_true", + help="dispatch configured CI once after this worker merges a batch", + ) react.add_argument("--timeout", type=int, default=1800) integrate = subparsers.add_parser("integrate", help="scaffold a cumulative PR") integrate.add_argument("--all", action="store_true", dest="all_entries") @@ -2879,6 +2937,7 @@ def main(argv: list[str] | None = None) -> int: client, follow=arguments.follow, timeout_seconds=arguments.timeout, + dispatch_ci=arguments.dispatch_ci, ) elif arguments.command == "integrate": command_integrate(client, all_entries=arguments.all_entries) diff --git a/src/agent_merge_queue/mcp_server.py b/src/agent_merge_queue/mcp_server.py index cbde1b8..f2aeac6 100644 --- a/src/agent_merge_queue/mcp_server.py +++ b/src/agent_merge_queue/mcp_server.py @@ -144,6 +144,7 @@ def drain_queue(repository: str | None = None, config: str | None = None) -> str @mcp.tool() def react_to_delivery_event( follow: bool = False, + dispatch_ci: bool = False, timeout_seconds: int = 1800, repository: str | None = None, config: str | None = None, @@ -152,6 +153,8 @@ def react_to_delivery_event( arguments = ["--timeout", str(timeout_seconds)] if follow: arguments.append("--follow") + if dispatch_ci: + arguments.append("--dispatch-ci") return _run("react", *arguments, repository=repository, config=config) diff --git a/tests/test_cli.py b/tests/test_cli.py index f4037d5..72dba06 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1085,6 +1085,77 @@ def test_reactor_uses_cumulative_pr_in_all_mode(self) -> None: drain.assert_not_called() self.assertEqual(result["integrations"], [{"number": 99}]) + def test_reactor_dispatches_ci_once_after_a_merged_batch(self) -> None: + client = Mock() + client.config = CONFIG + client.pipeline_control.return_value = {"state": "running"} + client.dispatch_ci_workflows.return_value = [{"id": 7, "name": "CI"}] + empty = FreezeResult(None, [], [], [], []) + with ( + patch("agent_merge_queue.cli.promote_integrations", return_value=[]), + patch("agent_merge_queue.cli.command_promote", return_value={}), + patch("agent_merge_queue.cli.freeze_queue", return_value=empty), + patch( + "agent_merge_queue.cli.command_drain", + return_value={"merged": [{"number": 1, "merge_sha": "a" * 40}]}, + ), + redirect_stdout(io.StringIO()), + ): + result = command_react( + client, + follow=False, + timeout_seconds=10, + dispatch_ci=True, + ) + client.dispatch_ci_workflows.assert_called_once_with() + self.assertEqual(result["dispatched_ci"], [{"id": 7, "name": "CI"}]) + + def test_reactor_pauses_when_post_merge_ci_dispatch_fails(self) -> None: + client = Mock() + client.config = CONFIG + client.pipeline_control.return_value = {"state": "running"} + client.dispatch_ci_workflows.side_effect = QueueError("CI has no dispatch") + empty = FreezeResult(None, [], [], [], []) + with ( + patch("agent_merge_queue.cli.promote_integrations", return_value=[]), + patch("agent_merge_queue.cli.command_promote", return_value={}), + patch("agent_merge_queue.cli.freeze_queue", return_value=empty), + patch( + "agent_merge_queue.cli.command_drain", + return_value={"merged": [{"number": 1, "merge_sha": "a" * 40}]}, + ), + redirect_stdout(io.StringIO()), + self.assertRaisesRegex(QueueError, "CI has no dispatch"), + ): + command_react( + client, + follow=False, + timeout_seconds=10, + dispatch_ci=True, + ) + client.set_pipeline_control.assert_called_once_with( + "paused", "post-merge CI dispatch failed: CI has no dispatch" + ) + + def test_github_dispatches_each_configured_active_ci_workflow(self) -> None: + client = object.__new__(GitHub) + client.config = CONFIG + client.repository = "example/repo" + client._json = Mock( + return_value=[ + {"id": 7, "name": "CI", "state": "active"}, + {"id": 8, "name": "Old CI", "state": "disabled_manually"}, + ] + ) + client._run = Mock(return_value="") + + result = client.dispatch_ci_workflows() + + self.assertEqual(result, [{"id": 7, "name": "CI"}]) + client._run.assert_called_once_with( + "workflow", "run", "7", "--repo", "example/repo", "--ref", "main" + ) + def test_pipeline_pause_blocks_every_merge_path(self) -> None: client = Mock() client.pipeline_control.return_value = { diff --git a/tests/test_skill.py b/tests/test_skill.py index 7806df6..508c03e 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 = "6ed5569ffbcbb4dea1dece6f889726b67d9411f2" +RELEASE_COMMIT = "2a502a74251e2fbb07e5b4769ce99687235df5ae" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" @@ -66,6 +66,19 @@ def test_workflows_pin_current_checkout_runtime(self) -> None: with self.subTest(path=path): self.assertIn(CHECKOUT_COMMIT, path.read_text(encoding="utf-8")) + def test_action_dispatches_ci_after_builtin_token_merge(self) -> None: + action = (ROOT / "action.yml").read_text(encoding="utf-8") + example = (ROOT / "examples" / "github-workflow.yml").read_text( + encoding="utf-8" + ) + workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text( + encoding="utf-8" + ) + self.assertIn('default: "true"', action) + self.assertIn("args+=(--dispatch-ci)", action) + self.assertIn("actions: write", example) + self.assertIn("workflow_dispatch:", workflow) + def test_clients_pin_the_immutable_status_release(self) -> None: paths = [ ROOT / "adapters" / "codex" / "agent-merge-queue" / ".mcp.json",