From 9986210b077ebb4c53f2c5b1bbd461ba02dfce19 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:12:18 -0700 Subject: [PATCH 1/2] Harden delivery event and follow state handling --- examples/github-workflow.yml | 26 ++++- pyproject.toml | 2 +- src/agent_merge_queue/__init__.py | 2 +- src/agent_merge_queue/cli.py | 165 ++++++++++++++++-------------- src/agent_merge_queue/pipeline.py | 11 +- tests/test_cli.py | 83 ++++++++++++++- tests/test_pipeline.py | 50 +++++++++ tests/test_skill.py | 8 ++ 8 files changed, 261 insertions(+), 86 deletions(-) diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index 6a3fef0..f74bfd2 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -27,10 +27,28 @@ concurrency: jobs: react: if: >- - github.event_name != 'pull_request_target' || - github.event.action != 'labeled' || - github.event.label.name == 'deploy-requested' || - github.event.label.name == 'merge-queue' + ( + github.event_name != 'pull_request_target' || + ( + github.event.action == 'labeled' && + ( + github.event.label.name == 'deploy-requested' || + github.event.label.name == 'merge-queue' + ) + ) || + ( + github.event.action != 'labeled' && + github.event.pull_request.head.repo.full_name == github.repository + ) + ) && + ( + github.event_name != 'check_suite' || + ( + github.event.check_suite.app.slug != 'github-actions' && + github.event.check_suite.pull_requests[0].base.ref == + github.event.repository.default_branch + ) + ) runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 diff --git a/pyproject.toml b/pyproject.toml index 9806aa9..48640e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deploybot-merge-queue" -version = "0.2.3" +version = "0.2.4" 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 38f4bf3..1a17d6d 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.3" +__version__ = "0.2.4" diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index bf2aad5..7eed72a 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -15,7 +15,7 @@ from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Any, Iterable +from typing import Any, Callable, Iterable from . import __version__ from .config import ConfigError, QueueConfig, initialize_config, load_config @@ -319,6 +319,7 @@ def effective_queue_marker( coordinator_logins: Iterable[str], *, intent_scope: str = "head", + integration_authorized: Callable[[dict[str, Any]], bool] | None = None, ) -> dict[str, Any] | None: """Accept coordinator state only when rooted in a live trusted deploy intent.""" values = list(comments) @@ -341,6 +342,10 @@ 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) + ) ): return delegated return direct @@ -356,6 +361,19 @@ def coordinator_logins(client: Any) -> set[str]: return set() +def queue_marker_for_client( + client: Any, comments: Iterable[dict[str, Any]] +) -> dict[str, Any] | None: + validator = getattr(client, "integration_sources_authorized", None) + return effective_queue_marker( + comments, + client.trusted_logins, + coordinator_logins(client), + intent_scope=client.config.pipeline.intent_scope, + integration_authorized=validator if callable(validator) else None, + ) + + def require_running_pipeline(client: Any) -> None: reader = getattr(client, "pipeline_control", None) if not callable(reader): @@ -785,14 +803,53 @@ def base_sha(self) -> str: return str(value["sha"]) def workflow_runs(self) -> list[dict[str, Any]]: - value = self._json( - "api", + return self._paged_object_items( f"repos/{self.repository}/actions/runs?branch={self.config.base_branch}&per_page=100", + "workflow_runs", + ) + + def source_deploy_authorized(self, number: int, expected_head: str) -> bool: + source = self._json( + "pr", + "view", + str(number), + "--repo", + self.repository, + "--json", + "headRefOid", + ) + if source.get("headRefOid") != expected_head: + return False + comments = self.comments(number) + intent = latest_intent(comments, self.trusted_logins) + direct = latest_marker(comments, self.trusted_logins) + return bool( + ( + intent + and intent.get("state") == "requested" + and intent.get("requested_head") == expected_head + ) + or ( + direct + and direct.get("state") == "queued" + and direct.get("head_sha") == expected_head + ) ) - runs = value.get("workflow_runs") if isinstance(value, dict) else None - if not isinstance(runs, list): - raise QueueError("GitHub returned invalid workflow-run data") - return [run for run in runs if isinstance(run, dict)] + + def integration_sources_authorized(self, integration: dict[str, Any]) -> bool: + expected_heads = integration.get("heads") or {} + sources = integration.get("pull_requests") or [] + if not sources: + return False + for value in sources: + try: + number = int(value) + except (TypeError, ValueError): + return False + expected = str(expected_heads.get(str(number)) or "") + if not expected or not self.source_deploy_authorized(number, expected): + return False + return True def dispatch_ci_workflows(self) -> list[dict[str, Any]]: configured = self.config.pipeline.ci_workflows @@ -1044,6 +1101,19 @@ 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]]: + data = self._json("api", "--paginate", "--slurp", endpoint) + pages = data if isinstance(data, list) else [] + values: list[dict[str, Any]] = [] + for page in pages: + items = page.get(key) if isinstance(page, dict) else None + if not isinstance(items, list): + raise QueueError(f"unexpected GitHub response for {endpoint}") + values.extend(item for item in items if isinstance(item, dict)) + return values + def comments(self, number: int) -> list[dict[str, Any]]: return self._paged_api( f"repos/{self.repository}/issues/{number}/comments?per_page=100" @@ -1127,12 +1197,7 @@ def snapshot( for provider in self.config.review_providers ) threads = self.review_threads(number) if needs_threads else [] - marker = effective_queue_marker( - comments, - self.trusted_logins, - self.coordinator_logins, - intent_scope=self.config.pipeline.intent_scope, - ) + marker = queue_marker_for_client(self, comments) head_sha = str(pull["headRefOid"]) checks = check_states(pull.get("statusCheckRollup") or []) changed_files = self.files(number) @@ -1302,12 +1367,7 @@ def merge(self, number: int, head_sha: str) -> str: raise QueueError(f"PR #{number} changed immediately before merge") if self.config.queue_label not in labels or self.config.blocked_label in labels: raise QueueError(f"PR #{number} queue authorization was revoked") - marker = effective_queue_marker( - self.comments(number), - self.trusted_logins, - getattr(self, "coordinator_logins", self.trusted_logins), - intent_scope=self.config.pipeline.intent_scope, - ) + marker = queue_marker_for_client(self, self.comments(number)) if ( not marker or marker.get("state") != "queued" @@ -1326,34 +1386,10 @@ def merge(self, number: int, head_sha: str) -> str: expected_heads = integration.get("heads") or {} for source_number in integration.get("pull_requests") or []: source_number = int(source_number) - source = self._json( - "pr", - "view", - str(source_number), - "--repo", - self.repository, - "--json", - "headRefOid", - ) expected = str(expected_heads.get(str(source_number)) or "") - if not expected or source.get("headRefOid") != expected: - raise QueueError( - f"source PR #{source_number} changed after integration freeze" - ) - source_comments = self.comments(source_number) - intent = latest_intent(source_comments, self.trusted_logins) - direct_marker = latest_marker(source_comments, self.trusted_logins) - has_intent = bool( - intent - and intent.get("state") == "requested" - and intent.get("requested_head") == expected - ) - has_direct_authorization = bool( - direct_marker - and direct_marker.get("state") == "queued" - and direct_marker.get("head_sha") == expected - ) - if not has_intent and not has_direct_authorization: + if not expected or not self.source_deploy_authorized( + source_number, expected + ): raise QueueError( f"source PR #{source_number} deploy authorization was revoked" ) @@ -1999,12 +2035,7 @@ def queue_from_intent( "run deploybot refresh-request after fresh review" ) comments = client.comments(entry.number) - previous = effective_queue_marker( - comments, - client.trusted_logins, - coordinator_logins(client), - intent_scope=client.config.pipeline.intent_scope, - ) + previous = queue_marker_for_client(client, comments) queued_at = queue_timestamp( previous, already_queued=client.config.queue_label in entry.labels, @@ -2158,12 +2189,7 @@ def command_block(client: GitHub, selector: str | None, reason: str) -> None: client.ensure_labels() entry = client.snapshot(number, require_marker=False, allow_blocked_label=True) comments = client.comments(number) - previous = effective_queue_marker( - comments, - client.trusted_logins, - coordinator_logins(client), - intent_scope=client.config.pipeline.intent_scope, - ) + previous = queue_marker_for_client(client, comments) if ( not previous or previous.get("state") != "queued" @@ -2195,12 +2221,7 @@ def command_block(client: GitHub, selector: str | None, reason: str) -> None: def command_unblock(client: GitHub, selector: str | None) -> None: number = client.resolve_pr(selector) entry = client.snapshot(number, require_marker=False, allow_blocked_label=True) - previous = effective_queue_marker( - client.comments(number), - client.trusted_logins, - coordinator_logins(client), - intent_scope=client.config.pipeline.intent_scope, - ) + previous = queue_marker_for_client(client, client.comments(number)) if ( not previous or previous.get("state") != "blocked" @@ -2228,12 +2249,7 @@ def command_unblock(client: GitHub, selector: str | None) -> None: def command_dequeue(client: GitHub, selector: str | None, reason: str) -> None: number = client.resolve_pr(selector) entry = client.snapshot(number, require_marker=False, allow_blocked_label=True) - previous = effective_queue_marker( - client.comments(number), - client.trusted_logins, - coordinator_logins(client), - intent_scope=client.config.pipeline.intent_scope, - ) + previous = queue_marker_for_client(client, client.comments(number)) labels = client.labels(number) if client.config.blocked_label not in labels: client.add_label(number, client.config.blocked_label) @@ -2577,7 +2593,7 @@ def command_follow( poll_seconds=poll_seconds, ) if ( - result["state"] in {"ci-failed", "deploy-failed"} + result["state"] in {"ci-failed", "deploy-failed", "verify-failed"} and client.config.pipeline.pause_on_failure ): client.set_pipeline_control( @@ -2718,12 +2734,7 @@ def delivery_metrics(client: GitHub, *, limit: int) -> dict[str, Any]: number = int(pull["number"]) comments = client.comments(number) intent = latest_intent(comments, client.trusted_logins) - marker = effective_queue_marker( - comments, - client.trusted_logins, - coordinator_logins(client), - intent_scope=client.config.pipeline.intent_scope, - ) + marker = queue_marker_for_client(client, comments) merged_at = str(pull.get("merged_at") or "") or None merge_sha = str(pull.get("merge_commit_sha") or "") live_at: str | None = None diff --git a/src/agent_merge_queue/pipeline.py b/src/agent_merge_queue/pipeline.py index abdeb84..68ce0f9 100644 --- a/src/agent_merge_queue/pipeline.py +++ b/src/agent_merge_queue/pipeline.py @@ -185,6 +185,7 @@ def follow_release( ) -> dict[str, Any]: deadline = time.monotonic() + timeout_seconds observed_sha = "" + last_verifications: list[dict[str, Any]] = [] while True: main_sha = client.base_sha() runs = client.workflow_runs() @@ -196,13 +197,19 @@ def follow_release( return {**value, "verifications": []} 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} if time.monotonic() >= deadline: + state = ( + "verify-failed" + if value["state"] == "verified" and last_verifications + else "timed-out" + ) return { **value, - "state": "timed-out", + "state": state, "observed_sha": observed_sha, - "verifications": [], + "verifications": last_verifications, } time.sleep(poll_seconds) diff --git a/tests/test_cli.py b/tests/test_cli.py index 72dba06..9043969 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,7 +4,7 @@ import json import unittest from contextlib import redirect_stdout -from unittest.mock import Mock, patch +from unittest.mock import Mock, call, patch from agent_merge_queue.cli import ( FreezeResult, @@ -241,6 +241,46 @@ def test_queue_discovery_uses_every_paginated_pull_request(self) -> None: self.assertEqual(client.queued_numbers(), list(range(1, 151))) + def test_workflow_history_uses_every_paginated_page(self) -> None: + client = object.__new__(GitHub) + client.config = CONFIG + client.repository = "example/repo" + client._json = Mock( + return_value=[ + {"workflow_runs": [{"id": number} for number in range(1, 101)]}, + {"workflow_runs": [{"id": number} for number in range(101, 151)]}, + ] + ) + + self.assertEqual( + [run["id"] for run in client.workflow_runs()], list(range(1, 151)) + ) + client._json.assert_called_once_with( + "api", + "--paginate", + "--slurp", + "repos/example/repo/actions/runs?branch=main&per_page=100", + ) + + def test_integration_status_requires_every_source_authorization(self) -> None: + client = object.__new__(GitHub) + client.source_deploy_authorized = Mock(return_value=True) + integration = { + "heads": {"1": "a" * 40, "2": "b" * 40}, + "pull_requests": [1, 2], + } + + self.assertTrue(client.integration_sources_authorized(integration)) + client.source_deploy_authorized.assert_has_calls( + [ + call(1, "a" * 40), + call(2, "b" * 40), + ] + ) + client.source_deploy_authorized.reset_mock(side_effect=True) + client.source_deploy_authorized.side_effect = [True, False] + self.assertFalse(client.integration_sources_authorized(integration)) + def test_dependency_must_be_on_configured_base_branch(self) -> None: client = object.__new__(GitHub) client.config = CONFIG @@ -842,6 +882,47 @@ def test_coordinator_queue_marker_requires_trusted_deploy_intent(self) -> None: ) ) + integration = { + "id": 3, + "created_at": "2026-06-20T00:02:00Z", + "user": {"login": "coordinator"}, + "body": integration_body( + { + "batch_id": "batch-1", + "heads": {"1": sha}, + "pull_requests": [1], + } + ), + } + integration_marker = { + "id": 4, + "created_at": "2026-06-20T00:03:00Z", + "user": {"login": "coordinator"}, + "body": queue_state_body( + "queued", + sha, + queued_at="2026-06-20T00:03:00Z", + integration_batch_id="batch-1", + ), + } + self.assertEqual( + effective_queue_marker( + [integration, integration_marker], + {"trusted"}, + {"coordinator"}, + integration_authorized=lambda _: True, + ), + latest_marker([integration_marker], {"coordinator"}), + ) + self.assertIsNone( + effective_queue_marker( + [integration, integration_marker], + {"trusted"}, + {"coordinator"}, + integration_authorized=lambda _: False, + ) + ) + def test_request_records_intent_before_gates_and_adds_intent_label(self) -> None: value = entry(1, state="waiting") value.labels = [] diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 87adef1..0f5c445 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -83,6 +83,56 @@ def test_follow_switches_to_newer_cumulative_main(self) -> None: self.assertEqual(result["state"], "verified") self.assertEqual(result["main_sha"], new) + def test_follow_reports_persistent_http_verification_failure(self) -> None: + sha = "a" * 40 + config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "pipeline": { + "verifications": [ + { + "name": "Health", + "url": "https://example.invalid/health", + } + ] + }, + } + ) + client = Mock() + client.config = config + client.base_sha.return_value = sha + client.workflow_runs.return_value = [ + { + "id": 1, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + }, + { + "id": 2, + "name": "Deploy", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + }, + ] + failed = [{"name": "Health", "passed": False, "status": 503}] + with ( + patch( + "agent_merge_queue.pipeline.http_verifications", + return_value=failed, + ), + patch("agent_merge_queue.pipeline.time.monotonic", side_effect=[0, 11]), + ): + result = follow_release(client, timeout_seconds=10, poll_seconds=1) + + self.assertEqual(result["state"], "verify-failed") + self.assertEqual(result["verifications"], failed) + def test_metrics_report_p50_and_p95(self) -> None: self.assertEqual(percentile([1, 2, 3, 100], 0.50), 2) self.assertEqual(percentile([1, 2, 3, 100], 0.95), 100) diff --git a/tests/test_skill.py b/tests/test_skill.py index eea4e64..4c68808 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -55,6 +55,14 @@ def test_github_workflow_wakes_after_named_ci_finishes(self) -> None: self.assertIn("workflow_run:", workflow) self.assertIn("workflows: [CI]", workflow) self.assertIn("github.event.repository.default_branch", workflow) + self.assertIn( + "github.event.pull_request.head.repo.full_name == github.repository", + workflow, + ) + self.assertIn( + "github.event.check_suite.app.slug != 'github-actions'", workflow + ) + self.assertIn("github.event.check_suite.pull_requests[0].base.ref", workflow) self.assertIn("persist-credentials: false", workflow) def test_workflows_pin_current_checkout_runtime(self) -> None: From 1651424cde80b28fd9e13ca3c5c9501da540fc64 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:12:40 -0700 Subject: [PATCH 2/2] Pin DeployBot v0.2.4 clients to implementation --- README.md | 6 +++--- adapters/claude-code/.claude-plugin/plugin.json | 2 +- adapters/claude-code/.mcp.json | 2 +- adapters/codex/agent-merge-queue/.codex-plugin/plugin.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 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0e25c43..e95e33d 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.3` source commit directly from GitHub: +Install the reviewed `v0.2.4` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@c9e3d6976b372ef5bcbb2986b0afd6bb1afe9470' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@9986210b077ebb4c53f2c5b1bbd461ba02dfce19' 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@c9e3d6976b372ef5bcbb2986b0afd6bb1afe9470 +- uses: Forward-Future/DeployBot@9986210b077ebb4c53f2c5b1bbd461ba02dfce19 ``` The Action uses GitHub's built-in workflow token. GitHub intentionally does not diff --git a/adapters/claude-code/.claude-plugin/plugin.json b/adapters/claude-code/.claude-plugin/plugin.json index 43f200e..16f32af 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.3", + "version": "0.2.4", "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 3c6171d..934ebe7 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@c9e3d6976b372ef5bcbb2986b0afd6bb1afe9470", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@9986210b077ebb4c53f2c5b1bbd461ba02dfce19", "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 8ba0978..a0b11d0 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.3", + "version": "0.2.4", "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 3c6171d..934ebe7 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@c9e3d6976b372ef5bcbb2986b0afd6bb1afe9470", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@9986210b077ebb4c53f2c5b1bbd461ba02dfce19", "deploybot-mcp" ] } diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index 3c6171d..934ebe7 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@c9e3d6976b372ef5bcbb2986b0afd6bb1afe9470", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@9986210b077ebb4c53f2c5b1bbd461ba02dfce19", "deploybot-mcp" ] } diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index f74bfd2..2a48ed2 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.3 implementation; keep the full commit for privileged workflows. - - uses: Forward-Future/DeployBot@c9e3d6976b372ef5bcbb2986b0afd6bb1afe9470 + # v0.2.4 implementation; keep the full commit for privileged workflows. + - uses: Forward-Future/DeployBot@9986210b077ebb4c53f2c5b1bbd461ba02dfce19 diff --git a/tests/test_skill.py b/tests/test_skill.py index 4c68808..2efdab2 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 = "c9e3d6976b372ef5bcbb2986b0afd6bb1afe9470" +RELEASE_COMMIT = "9986210b077ebb4c53f2c5b1bbd461ba02dfce19" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"