From 7842edbd5fe75cdb68324e98c2a7244aed3009f6 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:07:07 -0700 Subject: [PATCH 1/2] Trust explicit wrapper gates after exact integration CI --- .mergequeue.example.toml | 3 + README.md | 1 + .../claude-code/.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- docs/reference.md | 3 +- pyproject.toml | 2 +- src/agent_merge_queue/__init__.py | 2 +- src/agent_merge_queue/cli.py | 85 +++++++++++++++++-- src/agent_merge_queue/config.py | 28 +++++- tests/test_cli.py | 79 +++++++++++++++++ tests/test_config.py | 29 +++++++ tests/test_docs.py | 1 + 12 files changed, 222 insertions(+), 15 deletions(-) diff --git a/.mergequeue.example.toml b/.mergequeue.example.toml index 7d9b18a..b48af47 100644 --- a/.mergequeue.example.toml +++ b/.mergequeue.example.toml @@ -67,3 +67,6 @@ branch_prefix = "deploybot/integration" title_prefix = "DeployBot integration" max_batch_size = 3 require_non_actions_author = false +# Name only pull_request-only wrapper checks that the successful exact-head +# pipeline.ci_workflows run already proves. Independent checks stay mandatory. +ci_satisfies_checks = [] diff --git a/README.md b/README.md index 96fbfa2..6b552c8 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,7 @@ expected_status = 200 mode = "overlap" max_batch_size = 3 # require_non_actions_author = true +# ci_satisfies_checks = ["Stable PR head", "Full test suite"] ``` For `overlap` or `all` mode with the hosted coordinator, enable **Allow GitHub diff --git a/adapters/claude-code/.claude-plugin/plugin.json b/adapters/claude-code/.claude-plugin/plugin.json index 82b138b..d8a8dc6 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.16", + "version": "0.2.17", "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 622d6e0..38b3a6c 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.16", + "version": "0.2.17", "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 97eceaa..7b8d89e 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.16. GitHub labels and authenticated comments are the durable state; +DeployBot v0.2.17. GitHub labels and authenticated comments are the durable state; the CLI and MCP tools are two interfaces to the same operations. ## CLI @@ -191,6 +191,7 @@ Provider fields are: | `title_prefix` | `"DeployBot integration"` | | `max_batch_size` | Positive maximum frozen batch size; default 3. Later FIFO entries remain in the next batch. A larger indivisible source-overlap or dependency closure ships alone rather than being split or deadlocked. | | `require_non_actions_author` | Default `false`; when `true`, integration creation requires the Action `token` input and an App bot author listed in `queue.coordinator_actors`. | +| `ci_satisfies_checks` | Default `[]`; explicit pull-request-only wrapper checks already proved by successful exact-head `pipeline.ci_workflows`. Must be a subset of `queue.required_checks` and never overrides a failed check. | ## GitHub Action diff --git a/pyproject.toml b/pyproject.toml index 089b8d4..b40f7a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deploybot-merge-queue" -version = "0.2.16" +version = "0.2.17" 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 d531d45..357d8e5 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.16" +__version__ = "0.2.17" diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index 21b6c04..04e567e 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -179,6 +179,18 @@ def check_states(checks: Iterable[dict[str, Any]]) -> dict[str, str]: return result +def merge_known_check_states( + observed: dict[str, str], + known: dict[str, str] | None, +) -> dict[str, str]: + result = dict(observed) + for name, state in (known or {}).items(): + if result.get(name) == "failed" and state != "failed": + continue + result[name] = state + return result + + def latest_exact_workflow_runs( runs: Iterable[dict[str, Any]], names: Iterable[str], @@ -1999,8 +2011,7 @@ def snapshot( marker = queue_marker_for_client(self, comments) head_sha = str(pull["headRefOid"]) check_rollup = list(pull.get("statusCheckRollup") or []) - checks = check_states(check_rollup) - checks.update(known_checks or {}) + checks = merge_known_check_states(check_states(check_rollup), known_checks) integration = latest_payload( comments, INTEGRATION_MARKER, @@ -2009,8 +2020,10 @@ def snapshot( if integration and any( checks.get(name) != "passed" for name in self.config.required_checks ): - checks = check_states(check_rollup + self.commit_check_runs(head_sha)) - checks.update(known_checks or {}) + checks = merge_known_check_states( + check_states(check_rollup + self.commit_check_runs(head_sha)), + known_checks, + ) source_paths = list(known_source_paths or []) generated_paths = list(known_generated_paths or []) paths_are_known = ( @@ -3414,13 +3427,18 @@ def settle_integration_checks( and str(run.get("conclusion") or "") == "success" for run in latest.values() ): - exact_checks = check_states(client.commit_check_runs(head_sha)) + exact_checks = integration_owned_check_states(client, head_sha) entry = client.snapshot( number, require_marker=False, allow_blocked_label=True, known_checks=exact_checks, ) + if entry.head_sha != head_sha: + raise QueueError( + f"integration PR #{number} changed while CI evidence " + "was being applied" + ) if entry.state == "ready": results.append( { @@ -3449,6 +3467,44 @@ def settle_integration_checks( return results +def integration_owned_check_states(client: GitHub, head_sha: str) -> dict[str, str]: + """Use successful exact integration CI as the required-check aggregate.""" + + checks = check_states(client.commit_check_runs(head_sha)) + # Token-authored integration PRs do not emit every pull_request-only wrapper + # check. The configured workflow's exact-head success is the aggregate proof + # for those required checks; review providers remain separate and unchanged. + for name in client.config.integration.ci_satisfies_checks: + if checks.get(name) != "failed": + checks[name] = "passed" + return checks + + +def completed_integration_ci_checks( + client: GitHub, + *, + branch: str, + head_sha: str, +) -> dict[str, str] | None: + configured = tuple(client.config.pipeline.ci_workflows) + if not configured: + return None + latest = latest_exact_workflow_runs( + client.workflow_runs_for_branch(branch), + configured, + head_sha=head_sha, + ) + if any(name not in latest for name in configured): + return None + if not all( + str(run.get("status") or "") == "completed" + and str(run.get("conclusion") or "") == "success" + for run in latest.values() + ): + return None + return integration_owned_check_states(client, head_sha) + + def command_resume(client: GitHub, selector: str | None) -> None: number = client.resolve_pr(selector) comments = client.comments(number) @@ -3458,7 +3514,24 @@ def command_resume(client: GitHub, selector: str | None) -> None: coordinator_logins(client), ) if integration: - entry = client.snapshot(number, require_marker=False, allow_blocked_label=True) + pull = client.pull_head(number) + branch = str(pull.get("branch") or "") + head_sha = str(pull.get("head_sha") or "") + known_checks = completed_integration_ci_checks( + client, + branch=branch, + head_sha=head_sha, + ) + entry = client.snapshot( + number, + require_marker=False, + allow_blocked_label=True, + known_checks=known_checks, + ) + if entry.head_sha != head_sha: + raise QueueError( + f"integration PR #{number} changed while CI evidence was being applied" + ) for source_number, source_head in (integration.get("heads") or {}).items(): if not client.is_ancestor(str(source_head), entry.head_sha): raise QueueError( diff --git a/src/agent_merge_queue/config.py b/src/agent_merge_queue/config.py index 3585be3..4934a61 100644 --- a/src/agent_merge_queue/config.py +++ b/src/agent_merge_queue/config.py @@ -66,6 +66,7 @@ class IntegrationConfig: title_prefix: str max_batch_size: int require_non_actions_author: bool + ci_satisfies_checks: tuple[str, ...] @dataclass(frozen=True) @@ -146,6 +147,8 @@ class QueueConfig: title_prefix = "DeployBot integration" max_batch_size = 3 require_non_actions_author = false +# Exact integration CI may replace only these pull_request-only wrapper checks. +ci_satisfies_checks = [] """ @@ -328,6 +331,10 @@ def parse_config(payload: dict[str, Any]) -> QueueConfig: raise ConfigError("review must be a table") if not isinstance(pipeline, dict) or not isinstance(integration, dict): raise ConfigError("pipeline and integration must be tables") + ci_workflows = _string_tuple( + pipeline.get("ci_workflows", ["CI"]), + "pipeline.ci_workflows", + ) merge_method = _require_string( queue.get("merge_method"), "queue.merge_method", "merge" @@ -386,6 +393,21 @@ def parse_config(payload: dict[str, Any]) -> QueueConfig: if integration_mode not in ALLOWED_INTEGRATION_MODES: allowed = ", ".join(sorted(ALLOWED_INTEGRATION_MODES)) raise ConfigError(f"integration.mode must be one of: {allowed}") + ci_satisfies_checks = _string_tuple( + integration.get("ci_satisfies_checks"), + "integration.ci_satisfies_checks", + ) + unknown_ci_checks = sorted(set(ci_satisfies_checks) - set(required_checks)) + if unknown_ci_checks: + raise ConfigError( + "integration.ci_satisfies_checks must be a subset of " + "queue.required_checks: " + ", ".join(unknown_ci_checks) + ) + if ci_satisfies_checks and not ci_workflows: + raise ConfigError( + "integration.ci_satisfies_checks requires at least one " + "pipeline.ci_workflows entry" + ) webhook_url_env = pipeline.get("webhook_url_env") if webhook_url_env is not None: webhook_url_env = _require_string(webhook_url_env, "pipeline.webhook_url_env") @@ -454,10 +476,7 @@ def parse_config(payload: dict[str, Any]) -> QueueConfig: "pipeline.thread_active_hours", 72, ), - ci_workflows=_string_tuple( - pipeline.get("ci_workflows", ["CI"]), - "pipeline.ci_workflows", - ), + ci_workflows=ci_workflows, deploy_workflows=_string_tuple( pipeline.get("deploy_workflows", ["Deploy"]), "pipeline.deploy_workflows", @@ -536,6 +555,7 @@ def parse_config(payload: dict[str, Any]) -> QueueConfig: "integration.require_non_actions_author", False, ), + ci_satisfies_checks=ci_satisfies_checks, ), ) diff --git a/tests/test_cli.py b/tests/test_cli.py index dabe23a..0ece816 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -42,10 +42,12 @@ freeze_queue, generated_only_change, integration_ci_active_gate, + integration_owned_check_states, latest_batch_marker, latest_marker, marker_queued_at, marker_priority_at, + merge_known_check_states, main, near_ready_overlap_holds, new_batch, @@ -3807,6 +3809,29 @@ def test_resumed_integration_finishes_source_label_delegation(self) -> None: client.coordinator_logins = {"coordinator"} client.resolve_pr.return_value = 99 client.comments.return_value = comments + client.pull_head.return_value = { + "branch": "deploybot/integration/batch-1", + "head_sha": integration_head, + "state": "OPEN", + } + client.workflow_runs_for_branch.return_value = [ + { + "id": 7, + "name": "CI", + "head_sha": integration_head, + "event": "workflow_dispatch", + "status": "completed", + "conclusion": "success", + "created_at": "2026-06-20T00:01:00Z", + } + ] + client.commit_check_runs.return_value = [ + { + "name": "CI", + "conclusion": "success", + "started_at": "2026-06-20T00:01:00Z", + } + ] client.snapshot.return_value = integration client.is_ancestor.return_value = True source_labels = { @@ -4513,6 +4538,60 @@ def snapshot(number: int, **_kwargs: Any) -> QueueEntry: numbers, ) + def test_exact_integration_ci_satisfies_pr_only_required_wrappers(self) -> None: + client = Mock() + client.config = parse_config( + { + "queue": { + "required_checks": [ + "Static checks", + "Stable PR head", + "Full test suite", + ], + "trusted_actors": ["trusted"], + }, + "integration": { + "ci_satisfies_checks": [ + "Stable PR head", + "Full test suite", + ] + }, + } + ) + client.commit_check_runs.return_value = [ + { + "name": "Static checks", + "conclusion": "success", + "started_at": "2026-06-20T00:01:00Z", + }, + { + "name": "Stable PR head", + "conclusion": "skipped", + "started_at": "2026-06-20T00:01:00Z", + }, + { + "name": "Greptile Review", + "status": "in_progress", + "started_at": "2026-06-20T00:01:00Z", + }, + ] + + checks = integration_owned_check_states(client, "a" * 40) + + self.assertEqual(checks["Static checks"], "passed") + self.assertEqual(checks["Stable PR head"], "passed") + self.assertEqual(checks["Full test suite"], "passed") + self.assertEqual(checks["Greptile Review"], "pending") + + def test_owned_integration_checks_never_replace_observed_failure(self) -> None: + checks = merge_known_check_states( + {"Stable PR head": "failed"}, + {"Stable PR head": "passed", "Full test suite": "passed"}, + ) + + self.assertEqual(checks["Stable PR head"], "failed") + self.assertEqual(checks["Full test suite"], "passed") + def test_integration_ci_does_not_repeat_dispatch_before_run_appears( self, ) -> None: diff --git a/tests/test_config.py b/tests/test_config.py index f9be80c..61b1ff5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -202,6 +202,7 @@ def test_parses_pipeline_integration_and_health_policy(self) -> None: "mode": "all", "max_batch_size": 2, "require_non_actions_author": True, + "ci_satisfies_checks": ["CI"], }, } ) @@ -217,6 +218,34 @@ def test_parses_pipeline_integration_and_health_policy(self) -> None: self.assertEqual(config.integration.mode, "all") self.assertEqual(config.integration.max_batch_size, 2) self.assertTrue(config.integration.require_non_actions_author) + self.assertEqual(config.integration.ci_satisfies_checks, ("CI",)) + + def test_integration_ci_satisfied_checks_must_be_required(self) -> None: + with self.assertRaisesRegex(ConfigError, "must be a subset"): + parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "integration": { + "ci_satisfies_checks": ["Unrelated security scan"] + }, + } + ) + + def test_integration_ci_satisfied_checks_require_a_workflow(self) -> None: + with self.assertRaisesRegex(ConfigError, "requires at least one"): + parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "pipeline": {"ci_workflows": []}, + "integration": {"ci_satisfies_checks": ["CI"]}, + } + ) def test_rejects_invalid_repair_branch_prefix(self) -> None: for prefix in ("/", "repairs//main", "repairs/../main", "repairs/main.lock"): diff --git a/tests/test_docs.py b/tests/test_docs.py index c4b8dfd..32e6938 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -150,6 +150,7 @@ def test_reference_names_every_policy_field(self) -> None: "title_prefix", "max_batch_size", "require_non_actions_author", + "ci_satisfies_checks", }, } queue_fields = {field.name for field in fields(QueueConfig)} - { From 8f93f938c134302743e95925d9df68bf045d50e5 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:07:26 -0700 Subject: [PATCH 2/2] Pin DeployBot v0.2.17 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 6b552c8..b3d44f7 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.16` source commit directly from GitHub: +Install the reviewed `v0.2.17` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@415f0a360bad2e54b156b330c2d21e3a64823a5a' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@7842edbd5fe75cdb68324e98c2a7244aed3009f6' 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@415f0a360bad2e54b156b330c2d21e3a64823a5a +- uses: Forward-Future/DeployBot@7842edbd5fe75cdb68324e98c2a7244aed3009f6 ``` 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 01e719d..b270d6c 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@415f0a360bad2e54b156b330c2d21e3a64823a5a", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@7842edbd5fe75cdb68324e98c2a7244aed3009f6", "deploybot-mcp" ] } diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index 01e719d..b270d6c 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@415f0a360bad2e54b156b330c2d21e3a64823a5a", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@7842edbd5fe75cdb68324e98c2a7244aed3009f6", "deploybot-mcp" ] } diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index 552dd52..fa34934 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -59,5 +59,5 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - # v0.2.16 implementation; keep the full commit for privileged workflows. - - uses: Forward-Future/DeployBot@415f0a360bad2e54b156b330c2d21e3a64823a5a + # v0.2.17 implementation; keep the full commit for privileged workflows. + - uses: Forward-Future/DeployBot@7842edbd5fe75cdb68324e98c2a7244aed3009f6 diff --git a/tests/test_skill.py b/tests/test_skill.py index e6da9a7..051b31b 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 = "415f0a360bad2e54b156b330c2d21e3a64823a5a" +RELEASE_COMMIT = "7842edbd5fe75cdb68324e98c2a7244aed3009f6" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"