From 421e56f99b7f6d18a3fdad9a262b52213815a2d2 Mon Sep 17 00:00:00 2001 From: betegon Date: Sun, 25 Oct 2026 22:49:12 +0100 Subject: [PATCH 1/3] ci(docs-mcp): deploy from Toolkit after main CI --- .github/workflows/deploy-docs-mcp.yml | 124 +++++++++++++++++++++++++ apps/docs-mcp/README.md | 44 ++++----- docs/operations/github-actions.md | 15 ++- scripts/cloudflare-deployment.mjs | 10 +- scripts/cloudflare-deployment.test.mjs | 7 ++ scripts/deploy-workflow.test.mjs | 38 ++++---- 6 files changed, 194 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/deploy-docs-mcp.yml diff --git a/.github/workflows/deploy-docs-mcp.yml b/.github/workflows/deploy-docs-mcp.yml new file mode 100644 index 000000000..dec56a1e6 --- /dev/null +++ b/.github/workflows/deploy-docs-mcp.yml @@ -0,0 +1,124 @@ +name: Deploy Docs MCP + +on: + workflow_run: + workflows: ["Test"] + types: [completed] + branches: [main] + +permissions: + contents: read + +concurrency: + group: docs-mcp-production-deploy + cancel-in-progress: false + +jobs: + deploy: + name: Deploy Docs MCP + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: production + if: >- + ${{ github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.id == github.event.repository.id && + github.event.workflow_run.head_branch == 'main' }} + env: + TESTED_SHA: ${{ github.event.workflow_run.head_sha }} + DEPLOYMENT_MARKER: toolkit-mcp:${{ github.run_id }}:${{ github.run_attempt }}:${{ github.event.workflow_run.head_sha }} + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + - name: Require tested revision on main + env: + GH_TOKEN: ${{ github.token }} + run: | + current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" + test "$current_sha" = "$TESTED_SHA" + + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22.23.4" + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Capture active Docs MCP version + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + node --input-type=module <<'NODE' + import { writeFile } from 'node:fs/promises'; + import { capturePrevious, listDeployments } from './scripts/cloudflare-deployment.mjs'; + const journal = capturePrevious(await listDeployments(process.env, 'docs-mcp'), process.env.DEPLOYMENT_MARKER); + await writeFile(`${process.env.RUNNER_TEMP}/docs-mcp-deployment.json`, JSON.stringify(journal), { mode: 0o600 }); + await writeFile(process.env.GITHUB_STEP_SUMMARY, `## Docs MCP deployment\n\nSource: ${process.env.TESTED_SHA}\n\nPrevious version: ${journal.previous.version}\n`, { flag: 'a' }); + NODE + + - name: Upload Docs MCP without moving traffic + working-directory: apps/docs-mcp + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/docs-mcp-upload.jsonl + run: | + pnpm exec wrangler versions upload --experimental-auto-create=false \ + --minify --keep-vars --message "$DEPLOYMENT_MARKER:upload" + + - name: Require tested revision on main before deployment + env: + GH_TOKEN: ${{ github.token }} + run: | + current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" + test "$current_sha" = "$TESTED_SHA" + + - name: Identify uploaded Docs MCP version + id: uploaded + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/docs-mcp-upload.jsonl + run: | + node --input-type=module <<'NODE' + import { readFile, writeFile } from 'node:fs/promises'; + import { capturePrevious, listDeployments, parseUploadedVersion } from './scripts/cloudflare-deployment.mjs'; + const path = `${process.env.RUNNER_TEMP}/docs-mcp-deployment.json`; + const journal = JSON.parse(await readFile(path, 'utf8')); + const candidateVersion = parseUploadedVersion(await readFile(process.env.WRANGLER_OUTPUT_FILE_PATH, 'utf8'), 'docs-mcp'); + const live = capturePrevious(await listDeployments(process.env, 'docs-mcp'), journal.marker); + if (live.previous.id !== journal.previous.id || live.previous.version !== journal.previous.version || candidateVersion === journal.previous.version) { + throw new Error('Production changed during Docs MCP upload'); + } + await writeFile(path, JSON.stringify({ ...journal, candidateVersion }), { mode: 0o600 }); + await writeFile(process.env.GITHUB_OUTPUT, `candidate_version=${candidateVersion}\n`, { flag: 'a' }); + NODE + + - name: Deploy exact Docs MCP version + working-directory: apps/docs-mcp + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CANDIDATE_VERSION: ${{ steps.uploaded.outputs.candidate_version }} + run: pnpm exec wrangler versions deploy "$CANDIDATE_VERSION@100" --yes --message "$DEPLOYMENT_MARKER" + + - name: Verify active Docs MCP version + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + node --input-type=module <<'NODE' + import { readFile, writeFile } from 'node:fs/promises'; + import { listDeployments, verifyCandidate } from './scripts/cloudflare-deployment.mjs'; + const journal = JSON.parse(await readFile(`${process.env.RUNNER_TEMP}/docs-mcp-deployment.json`, 'utf8')); + const verified = verifyCandidate(await listDeployments(process.env, 'docs-mcp'), journal); + if (verified.candidate.version !== journal.candidateVersion) { + throw new Error('Production does not serve the uploaded Docs MCP version'); + } + await writeFile(process.env.GITHUB_STEP_SUMMARY, `\nActive version: ${verified.candidate.version} (100% traffic)\n\nVerify functionality separately through the private service binding.\n`, { flag: 'a' }); + NODE diff --git a/apps/docs-mcp/README.md b/apps/docs-mcp/README.md index 25e59de84..bfb8b4dfa 100644 --- a/apps/docs-mcp/README.md +++ b/apps/docs-mcp/README.md @@ -78,29 +78,29 @@ the Worker bundle. ## Deployment ownership -Importing this app does not change Cloudflare or enable a new deployment workflow. -The existing Worker is connected to the source repository through Workers Builds. -Toolkit's MCP deployment workflow targets `sentry-mcp`, not this app. - -When a production cutover is scheduled: - -1. Record the existing Worker's account, build settings, runtime secrets, and - active version. Keep the name `docs-mcp`, compatibility settings, private - access configuration, and `OPENROUTER_API_KEY` runtime secret. -2. Disconnect the old repository under the existing Worker's **Settings → - Builds**. Ensure no old build remains in flight before enabling a new deploy. -3. Connect that same Worker to `getsentry/toolkit`, with the repository root as - its root directory. Set build variables `NODE_VERSION=22.23.4`, - `PNPM_VERSION=11.8.0`, and `SKIP_DEPENDENCY_INSTALL=1`. Use - `pnpm install --frozen-lockfile && pnpm --filter docs-mcp run build` as the - build command and `pnpm --filter docs-mcp run deploy` as the deploy command. - Watch paths must cover this app, the root `package.json`, lockfile/workspace - configuration, and patches. Review preview settings so the private service - is not exposed publicly. -4. Deploy the tested revision and verify its active version and service-binding - calls before removing the source repository's copy. +The `Deploy Docs MCP` GitHub Actions workflow deploys the existing `docs-mcp` +Worker after a successful `Test` push run for the current `main` commit. It uses +the same `production` environment, `CLOUDFLARE_API_TOKEN`, and +`CLOUDFLARE_ACCOUNT_ID` as the main MCP deployment, with a separate concurrency +group. The token must permit writing to the existing `docs-mcp` Worker. + +The workflow captures the prior active version, uploads without autocreating a +Worker, and activates the exact uploaded version at 100%. It preserves runtime +variables and secrets, compatibility settings, and private access. The Actions +summary records the source SHA, previous version, and verified active version. +There is no public HTTP smoke test: verify functionality through a real consumer +of the private service binding after deployment. + +Before merging the workflow for the first deployment, disconnect the source +repository under the existing Worker's **Settings → Builds** and ensure no old +build remains in flight. GitHub Actions does not disconnect Workers Builds; +leaving it connected would allow Init to overwrite a Toolkit deployment. Do not +connect the Worker to Toolkit's Workers Builds as well: Actions owns deployment. +Record the prior active version and preserve the existing Worker, bindings, and +`OPENROUTER_API_KEY`. Remove the source repository's copy only after verifying +the Toolkit deployment and service-binding calls. The caller's `service: "docs-mcp"` binding still identifies the same Worker in the same account. No consumer URL change is required. Runtime secrets belong to the Worker and are separate from build secrets; never copy secret values into -this repository. See [Cloudflare's repository-switch procedure](https://developers.cloudflare.com/workers/ci-cd/builds/#disconnecting-builds). +this repository. See [Cloudflare's disconnect procedure](https://developers.cloudflare.com/workers/ci-cd/builds/#disconnecting-builds). diff --git a/docs/operations/github-actions.md b/docs/operations/github-actions.md index f1c702ad6..257c91bc2 100644 --- a/docs/operations/github-actions.md +++ b/docs/operations/github-actions.md @@ -1,8 +1,8 @@ # GitHub Actions Toolkit CI covers the CLI, MCP, and shared packages. The Cloudflare deployment -workflows below serve the MCP Worker; CLI release and docs workflows are listed -in the [CLI guide](../cli/README.md). +workflows below serve the MCP and Docs MCP Workers; CLI release and docs +workflows are listed in the [CLI guide](../cli/README.md). `pnpm build` builds the MCP workspace; `pnpm build:cli` builds the imported CLI and docs when `SENTRY_CLIENT_ID` is available. @@ -42,6 +42,17 @@ staged a candidate that Cloudflare never lists. A leftover stage (one version at 100%, the candidate at 0%) counts as its 100% version, so the next deployment can replace it. Failure messages name the check that failed. +### deploy-docs-mcp.yml + +Uses the same successful `Test` push gate, tested `main` revision, and protected +`production` secrets to deploy the existing private `docs-mcp` Worker. It uploads +without moving traffic, checks that production is unchanged, activates the exact +version, and verifies its ID, ownership marker, and 100% traffic in Cloudflare. +It preserves runtime variables and secrets and keeps public URLs disabled. +Private service-binding functionality must be checked through a real consumer. +Disconnect the old Init Workers Builds connection before merging this workflow +for its first deployment. See the [Docs MCP cutover guide](../../apps/docs-mcp/README.md#deployment-ownership). + ### recover-cloudflare-deployment.yml Manual `workflow_dispatch` recovery accepts a deployment run ID and attempt. diff --git a/scripts/cloudflare-deployment.mjs b/scripts/cloudflare-deployment.mjs index fcfc2750a..6c1981dbd 100644 --- a/scripts/cloudflare-deployment.mjs +++ b/scripts/cloudflare-deployment.mjs @@ -205,7 +205,8 @@ export function assertOwnedCandidate(deployments, journal) { return journal.previous.version; } -async function listDeployments(env) { +/** Read deployment history for an existing Worker without exposing API responses. */ +export async function listDeployments(env, worker = WORKER) { if ( !/^[0-9a-f]{32}$/i.test(env.CLOUDFLARE_ACCOUNT_ID ?? "") || !env.CLOUDFLARE_API_TOKEN @@ -215,7 +216,7 @@ async function listDeployments(env) { ); } const response = await fetch( - `https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${WORKER}/deployments?per_page=3`, + `https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${encodeURIComponent(worker)}/deployments?per_page=3`, { headers: { Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}` }, signal: AbortSignal.timeout(15_000), @@ -275,7 +276,8 @@ function runWrangler(args, env, spawn = spawnSync) { throw new DeploymentError("Cloudflare version transition failed"); } -export function parseUploadedVersion(output) { +/** Identify the exact version uploaded for the intended Worker. */ +export function parseUploadedVersion(output, expectedWorker = WORKER) { if (typeof output !== "string" || output.length > 64_000) { throw new DeploymentError("Invalid Wrangler upload output"); } @@ -296,7 +298,7 @@ export function parseUploadedVersion(output) { session.command_line_args[1] !== "upload" || !session.command_line_args.includes("--experimental-auto-create=false") || success?.type !== "version-upload" || - success.worker_name !== WORKER || + success.worker_name !== expectedWorker || success.worker_name_overridden !== false || !UUID.test(success.version_id) ) { diff --git a/scripts/cloudflare-deployment.test.mjs b/scripts/cloudflare-deployment.test.mjs index f6dfc91c3..fc0e495d6 100644 --- a/scripts/cloudflare-deployment.test.mjs +++ b/scripts/cloudflare-deployment.test.mjs @@ -72,6 +72,13 @@ test("extracts only a confirmed upload of the production Worker", () => { ), candidateVersion, ); + const docsUpload = `${JSON.stringify(session)}\n${JSON.stringify({ ...success, worker_name: "docs-mcp" })}\n`; + assert.equal(parseUploadedVersion(docsUpload, "docs-mcp"), candidateVersion); + assert.throws(() => parseUploadedVersion(docsUpload), /production Worker/); + assert.throws( + () => parseUploadedVersion(docsUpload, "sentry-mcp-canary"), + /production Worker/, + ); assert.throws( () => parseUploadedVersion( diff --git a/scripts/deploy-workflow.test.mjs b/scripts/deploy-workflow.test.mjs index 19650c61e..33bc3847c 100644 --- a/scripts/deploy-workflow.test.mjs +++ b/scripts/deploy-workflow.test.mjs @@ -6,6 +6,10 @@ const workflow = readFileSync( new URL("../.github/workflows/deploy.yml", import.meta.url), "utf8", ); +const docsWorkflow = readFileSync( + new URL("../.github/workflows/deploy-docs-mcp.yml", import.meta.url), + "utf8", +); const recovery = readFileSync( new URL( "../.github/workflows/recover-cloudflare-deployment.yml", @@ -19,22 +23,24 @@ const smoke = readFileSync( ); test("production deploy requires a successful Test run on this repository's main branch", () => { - assert.doesNotMatch(workflow, /if:\s*\$\{\{\s*false\s*\}\}/); - assert.match(workflow, /workflow_run\.conclusion == 'success'/); - assert.match(workflow, /workflow_run\.event == 'push'/); - assert.match( - workflow, - /workflow_run\.head_repository\.id == github\.event\.repository\.id/, - ); - assert.match(workflow, /workflow_run\.head_branch == 'main'/); - assert.doesNotMatch(workflow, /^\s*workflow_dispatch:/m); - assert.match( - workflow, - /ref:\s*\$\{\{ github\.event\.workflow_run\.head_sha \}\}/, - ); - assert.match(workflow, /environment:\s*production/); + for (const candidate of [workflow, docsWorkflow]) { + assert.doesNotMatch(candidate, /if:\s*\$\{\{\s*false\s*\}\}/); + assert.match(candidate, /workflow_run\.conclusion == 'success'/); + assert.match(candidate, /workflow_run\.event == 'push'/); + assert.match( + candidate, + /workflow_run\.head_repository\.id == github\.event\.repository\.id/, + ); + assert.match(candidate, /workflow_run\.head_branch == 'main'/); + assert.match( + candidate, + /ref:\s*\$\{\{ github\.event\.workflow_run\.head_sha \}\}/, + ); + assert.match(candidate, /environment:\s*production/); + assert.doesNotMatch(candidate, /^\s*workflow_dispatch:/m); + assert.match(candidate, /pnpm install --frozen-lockfile/); + } assert.match(workflow, /group:\s*mcp-production-deploy/); - assert.match(workflow, /pnpm install --frozen-lockfile/); assert.match(workflow, /cloudflare-deployment\.mjs capture/); assert.match(workflow, /cloudflare-deployment\.mjs uploaded/); assert.match(workflow, /cloudflare-deployment\.mjs stage/); @@ -127,7 +133,7 @@ test("manual recovery requires the current main and a completed same-repo deploy }); test("external actions use immutable commit pins", () => { - for (const [, action] of `${workflow}\n${recovery}`.matchAll( + for (const [, action] of `${workflow}\n${docsWorkflow}\n${recovery}`.matchAll( /^\s*(?:- )?uses:\s*([^\s#]+)/gm, )) { assert.match(action, /^[\w.-]+\/[\w.-]+@[0-9a-f]{40}$/, action); From 8009b3a3b334357f72304b012584f3da70a6c6f7 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 26 Oct 2026 16:31:25 +0100 Subject: [PATCH 2/3] ci(docs-mcp): align deployment setup with main MCP --- .github/workflows/deploy-docs-mcp.yml | 56 ++++++++++++++++++++------- docs/operations/github-actions.md | 11 +++--- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/.github/workflows/deploy-docs-mcp.yml b/.github/workflows/deploy-docs-mcp.yml index dec56a1e6..b9f984fe4 100644 --- a/.github/workflows/deploy-docs-mcp.yml +++ b/.github/workflows/deploy-docs-mcp.yml @@ -29,7 +29,7 @@ jobs: DEPLOYMENT_MARKER: toolkit-mcp:${{ github.run_id }}:${{ github.run_attempt }}:${{ github.event.workflow_run.head_sha }} steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ github.event.workflow_run.head_sha }} persist-credentials: false @@ -37,16 +37,40 @@ jobs: - name: Require tested revision on main env: GH_TOKEN: ${{ github.token }} + EXPECTED_SHA: ${{ github.event.workflow_run.head_sha }} run: | current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" - test "$current_sha" = "$TESTED_SHA" + if [[ "$current_sha" != "$EXPECTED_SHA" ]]; then + echo 'The tested revision is no longer at main; refusing deployment.' >&2 + exit 1 + fi - - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "22.23.4" - cache: pnpm - - run: pnpm install --frozen-lockfile + node-version: "22" + + # pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda + name: Install pnpm + with: + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV" + + - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Capture active Docs MCP version env: @@ -71,13 +95,6 @@ jobs: pnpm exec wrangler versions upload --experimental-auto-create=false \ --minify --keep-vars --message "$DEPLOYMENT_MARKER:upload" - - name: Require tested revision on main before deployment - env: - GH_TOKEN: ${{ github.token }} - run: | - current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" - test "$current_sha" = "$TESTED_SHA" - - name: Identify uploaded Docs MCP version id: uploaded env: @@ -99,6 +116,17 @@ jobs: await writeFile(process.env.GITHUB_OUTPUT, `candidate_version=${candidateVersion}\n`, { flag: 'a' }); NODE + - name: Require tested revision on main before deployment + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" + if [[ "$current_sha" != "$EXPECTED_SHA" ]]; then + echo 'Main advanced before deployment; refusing deployment.' >&2 + exit 1 + fi + - name: Deploy exact Docs MCP version working-directory: apps/docs-mcp env: diff --git a/docs/operations/github-actions.md b/docs/operations/github-actions.md index 257c91bc2..b0e0484bb 100644 --- a/docs/operations/github-actions.md +++ b/docs/operations/github-actions.md @@ -145,11 +145,12 @@ confirm the restored version fails the job rather than guessing a recovery. ## Manual Deployment Manual dispatch cannot deploy a new revision. Use a reviewed change and its -passing `Test` run to deploy. To restore an owned deployment after its runner -has stopped, dispatch `Recover Cloudflare Deployment` from `main` with the -failed deployment run ID and attempt. Verify the active Cloudflare version and -live smoke-test result; if ownership has changed, investigate rather than -retrying against mutable history. Never run bare `wrangler rollback`. +passing `Test` run to deploy. For the main `sentry-mcp` Worker, restore an owned +deployment after its runner has stopped by dispatching `Recover Cloudflare +Deployment` from `main` with the failed deployment run ID and attempt. Verify +the active Cloudflare version and live smoke-test result; if ownership has +changed, investigate rather than retrying against mutable history. This recovery +workflow does not target Docs MCP. Never run bare `wrangler rollback`. ## Cloudflare token migration From 77e8ac9d87fa09a1210672edc53d42df79c816ce Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 29 Oct 2026 10:52:44 +0100 Subject: [PATCH 3/3] fix(docs-mcp): wait for deployment propagation --- .github/workflows/deploy-docs-mcp.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-docs-mcp.yml b/.github/workflows/deploy-docs-mcp.yml index b9f984fe4..3bd2c12e1 100644 --- a/.github/workflows/deploy-docs-mcp.yml +++ b/.github/workflows/deploy-docs-mcp.yml @@ -142,9 +142,14 @@ jobs: run: | node --input-type=module <<'NODE' import { readFile, writeFile } from 'node:fs/promises'; - import { listDeployments, verifyCandidate } from './scripts/cloudflare-deployment.mjs'; + import { listDeployments, listSettled, verifyCandidate } from './scripts/cloudflare-deployment.mjs'; const journal = JSON.parse(await readFile(`${process.env.RUNNER_TEMP}/docs-mcp-deployment.json`, 'utf8')); - const verified = verifyCandidate(await listDeployments(process.env, 'docs-mcp'), journal); + const deployments = await listSettled( + process.env, + (listed) => listed[0]?.annotations?.['workers/message'] === journal.marker, + { list: (env) => listDeployments(env, 'docs-mcp') }, + ); + const verified = verifyCandidate(deployments, journal); if (verified.candidate.version !== journal.candidateVersion) { throw new Error('Production does not serve the uploaded Docs MCP version'); }