Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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:

Expand Down
8 changes: 8 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion adapters/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion adapters/claude-code/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
Expand Down
2 changes: 1 addition & 1 deletion adapters/codex/agent-merge-queue/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion adapters/codex/agent-merge-queue/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
Expand Down
2 changes: 1 addition & 1 deletion adapters/cursor/.cursor/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
Expand Down
6 changes: 3 additions & 3 deletions examples/github-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/agent_merge_queue/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""DeployBot: a provider-neutral GitHub merge queue for coding agents."""

__version__ = "0.2.1"
__version__ = "0.2.2"
59 changes: 59 additions & 0 deletions src/agent_merge_queue/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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(
Expand All @@ -2641,6 +2693,7 @@ def command_react(
"promoted": promoted,
"promoted_integrations": promoted_integrations,
"drain": drained,
"dispatched_ci": dispatched_ci,
"integrations": integrations,
"release": release,
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/agent_merge_queue/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)


Expand Down
71 changes: 71 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
15 changes: 14 additions & 1 deletion tests/test_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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",
Expand Down