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
44 changes: 36 additions & 8 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.4` source commit directly from GitHub:
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
```

Expand Down Expand Up @@ -86,16 +86,44 @@ 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
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:
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@9986210b077ebb4c53f2c5b1bbd461ba02dfce19",
"deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@94fe042a58c731343c8bbd73d0bdfcb38a55866f",
"deploybot-mcp"
]
}
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@9986210b077ebb4c53f2c5b1bbd461ba02dfce19",
"deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@94fe042a58c731343c8bbd73d0bdfcb38a55866f",
"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@9986210b077ebb4c53f2c5b1bbd461ba02dfce19",
"deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@94fe042a58c731343c8bbd73d0bdfcb38a55866f",
"deploybot-mcp"
]
}
Expand Down
4 changes: 2 additions & 2 deletions examples/github-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.4"
version = "0.2.5"
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.4"
__version__ = "0.2.5"
85 changes: 73 additions & 12 deletions src/agent_merge_queue/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/"
Expand Down Expand Up @@ -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]] = []
Expand Down Expand Up @@ -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
Expand Down
48 changes: 45 additions & 3 deletions src/agent_merge_queue/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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"
Expand All @@ -209,6 +250,7 @@ def follow_release(
return {
**value,
"state": state,
"dispatched_deployments": dispatched_deployments,
"observed_sha": observed_sha,
"verifications": last_verifications,
}
Expand Down
30 changes: 30 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading