From 4cf6825069059ffb86faf40873c4ca22d53fdffd Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 1 Oct 2026 17:25:10 +0000 Subject: [PATCH] Restore guarded MCP production deployment Co-Authored-By: GPT-6 Sol --- .github/workflows/deploy.yml | 116 ++++++++++----- docs/operations/github-actions.md | 32 +++-- docs/releases/cloudflare.md | 46 +++--- docs/testing/remote.md | 32 ++--- package.json | 3 +- packages/mcp-cloudflare/package.json | 1 - scripts/cloudflare-deployment.mjs | 171 ++++++++++++++++++++++ scripts/cloudflare-deployment.test.mjs | 191 +++++++++++++++++++++++++ scripts/deploy-workflow.test.mjs | 46 ++++++ 9 files changed, 539 insertions(+), 99 deletions(-) create mode 100644 scripts/cloudflare-deployment.mjs create mode 100644 scripts/cloudflare-deployment.test.mjs create mode 100644 scripts/deploy-workflow.test.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index de4296c77..5f7eb9f16 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,21 +11,41 @@ on: types: - completed branches: [main] - workflow_dispatch: + +concurrency: + group: mcp-production-deploy + cancel-in-progress: false jobs: deploy: name: Deploy to Cloudflare runs-on: ubuntu-latest - # Keep the production Worker unchanged while the CLI/docs import lands. - # Restore deployments only through a separately reviewed workflow change. - if: ${{ false }} + 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' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + - 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')" + if [[ "$current_sha" != "$EXPECTED_SHA" ]]; then + echo 'The tested revision is no longer at main; refusing deployment.' >&2 + exit 1 + fi - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" @@ -40,7 +60,7 @@ jobs: run: | echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV" - - uses: actions/cache@v4 + - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 name: Setup pnpm cache with: path: ${{ env.STORE_PATH }} @@ -49,7 +69,7 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile # === BUILD AND DEPLOY CANARY WORKER === - name: Build @@ -61,13 +81,11 @@ jobs: - name: Deploy to Canary Worker id: deploy_canary - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - workingDirectory: packages/mcp-cloudflare - command: deploy --config wrangler.canary.jsonc - packageManager: pnpm + working-directory: packages/mcp-cloudflare + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: pnpm exec wrangler deploy --config wrangler.canary.jsonc - name: Wait for Canary to Propagate if: success() @@ -95,19 +113,47 @@ jobs: fail_on_failure: false # === DEPLOY PRODUCTION WORKER (only if canary tests pass) === + - name: Require tested revision on main before production + if: steps.canary_smoke_tests.outcome == 'success' + 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 during canary testing; refusing production deployment.' >&2 + exit 1 + fi + + - name: Capture active production version + if: steps.canary_smoke_tests.outcome == 'success' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + TESTED_SHA: ${{ github.event.workflow_run.head_sha }} + run: node scripts/cloudflare-deployment.mjs capture + - name: Deploy to Production Worker id: deploy_production if: steps.canary_smoke_tests.outcome == 'success' - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - workingDirectory: packages/mcp-cloudflare - command: deploy - packageManager: pnpm + working-directory: packages/mcp-cloudflare + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + TESTED_SHA: ${{ github.event.workflow_run.head_sha }} + run: pnpm exec wrangler deploy --message "toolkit-mcp:$GITHUB_RUN_ID:$GITHUB_RUN_ATTEMPT:$TESTED_SHA" - - name: Wait for Production to Propagate + - name: Verify production deployment ownership + id: verify_production if: steps.deploy_production.outcome == 'success' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + TESTED_SHA: ${{ github.event.workflow_run.head_sha }} + run: node scripts/cloudflare-deployment.mjs verify + + - name: Wait for Production to Propagate + if: steps.verify_production.outcome == 'success' run: | echo "Waiting 30 seconds for production deployment to propagate..." sleep 30 @@ -115,7 +161,7 @@ jobs: # === SMOKE TEST PRODUCTION === - name: Run Smoke Tests on Production id: production_smoke_tests - if: steps.deploy_production.outcome == 'success' + if: steps.verify_production.outcome == 'success' env: PREVIEW_URL: https://mcp.sentry.dev run: | @@ -131,20 +177,16 @@ jobs: check_name: "Production Smoke Test Results" fail_on_failure: false - # === ROLLBACK IF PRODUCTION SMOKE TESTS FAIL === - - name: Rollback Production on Smoke Test Failure - if: steps.production_smoke_tests.outcome == 'failure' - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - workingDirectory: packages/mcp-cloudflare - command: rollback - packageManager: pnpm - continue-on-error: true + - name: Recover captured previous version after smoke failure + if: failure() && steps.production_smoke_tests.outcome == 'failure' && steps.verify_production.outcome == 'success' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + TESTED_SHA: ${{ github.event.workflow_run.head_sha }} + run: node scripts/cloudflare-deployment.mjs recover - name: Fail Job if Production Smoke Tests Failed - if: steps.production_smoke_tests.outcome == 'failure' + if: failure() && steps.production_smoke_tests.outcome == 'failure' run: | - echo "Production smoke tests failed - job failed after rollback" + echo 'Production smoke tests failed. Inspect the deployment before changing traffic.' >&2 exit 1 diff --git a/docs/operations/github-actions.md b/docs/operations/github-actions.md index 5ccdb9da8..669b29be7 100644 --- a/docs/operations/github-actions.md +++ b/docs/operations/github-actions.md @@ -2,11 +2,8 @@ CI/CD workflows for the Sentry MCP project. -**Toolkit import landing:** The `Deploy to Cloudflare` job is disabled while -the CLI and docs import lands. A passing `Test` run on `main` cannot change the -production Worker. Restore deployments only through a separately reviewed -workflow change. `pnpm build` builds the MCP workspace; `pnpm build:cli` builds -the imported CLI and docs when `SENTRY_CLIENT_ID` is available. +`pnpm build` builds the MCP workspace; `pnpm build:cli` builds the imported CLI +and docs when `SENTRY_CLIENT_ID` is available. ## Workflows @@ -22,8 +19,12 @@ Package-specific exceptions live in `package.json#sentryCi`; the standalone smoke-test suite remains in its own workflow. ### deploy.yml -Currently disabled. Its old canary, production, and rollback steps must not -run until a separately reviewed workflow replaces them. +Runs after a successful `Test` push run on `main`. Checks out the tested commit, +requires that it is still the tip of `main`, then deploys and tests canary. +Records the active production version before changing traffic, deploys the +tested commit, and verifies the run-owned candidate before production smoke +tests. If those fail, restores the captured version only while this run's +candidate remains active. External changes stop recovery. ### eval.yml Runs evaluation tests against the MCP server. @@ -48,10 +49,13 @@ label creation. ## Required Secrets -Repository secrets (no environment needed): +The `production` environment is restricted to `main` and holds: - **`CLOUDFLARE_API_TOKEN`** - Cloudflare API token with Workers deployment permissions -- **`CLOUDFLARE_ACCOUNT_ID`** - Your Cloudflare account ID + +Other configuration: + +- **`CLOUDFLARE_ACCOUNT_ID`** - ID of the account owning the Workers - **`SENTRY_AUTH_TOKEN`** - For Sentry release tracking - **`SENTRY_CLIENT_SECRET`** - Sentry OAuth client secret - **`COOKIE_SECRET`** - Session cookie encryption secret @@ -75,13 +79,15 @@ Canary and production use separate resources for complete isolation: ### Deployment Flow -No production deployment runs while the import lands. The disabled workflow's -old rollback step must not be used to recover production. +The workflow never deploys an untested revision or a stale `main` commit. +Failure to identify the prior active version, verify deployment ownership, or +confirm the restored version fails the job rather than guessing a recovery. ## Manual Deployment -The deployment job is also disabled for manual workflow dispatch. Do not use -the old rollback path to deploy or recover the production Worker. +Manual production dispatch is unavailable. Use a reviewed change and its +passing `Test` run to deploy. Never run bare `wrangler rollback` against +production; that command chooses from mutable history. ## Troubleshooting diff --git a/docs/releases/cloudflare.md b/docs/releases/cloudflare.md index ac748959a..df0a2b3b6 100644 --- a/docs/releases/cloudflare.md +++ b/docs/releases/cloudflare.md @@ -136,32 +136,32 @@ pnpm dev ### Production Deployment -#### Automated via GitHub Actions (Recommended) - -Production deployments happen automatically when changes are pushed to the main branch: - -1. Push to main or merge a PR -2. GitHub Actions runs tests -3. If tests pass, deploys to Cloudflare - -Required secrets in GitHub repository settings: -- `CLOUDFLARE_API_TOKEN` - API token with Workers deployment permissions -- `CLOUDFLARE_ACCOUNT_ID` - Your Cloudflare account ID +#### Automated via GitHub Actions + +Production deployments run only from the trusted `Deploy to Cloudflare` workflow +after the `Test` workflow succeeds for the current `main` commit: + +1. Merge a PR into `main`; GitHub Actions tests that exact commit. +2. The workflow builds and deploys `sentry-mcp-canary`, then runs canary smoke tests. +3. After canary succeeds, it records the currently active production version + and deploys the tested commit to `sentry-mcp`. +4. It verifies that this run owns the new deployment and runs production smoke + tests. On failure it restores the captured previous version **only if** + production still serves this run's exact candidate. External changes or + ambiguous traffic allocation stop recovery rather than overwrite them. + +The `production` GitHub environment allows only `main`. Store +`CLOUDFLARE_API_TOKEN` there with Workers deployment permissions. Configure +`CLOUDFLARE_ACCOUNT_ID` for the account that owns both Workers. Keep credentials +out of command arguments and logs. After a verified deployment, remove any +repository-level copy of `CLOUDFLARE_API_TOKEN`. See `github-actions.md` for detailed setup instructions. -#### Manual Deployment - -```bash -# Build client assets -pnpm build - -# Deploy to Cloudflare -pnpm deploy - -# Or deploy specific environment -pnpm deploy --env production -``` +Production traffic changes must use the protected workflow. Do not use bare +`wrangler rollback`: it selects from mutable deployment history and can undo +someone else's deployment. If recovery declines because production changed, +inspect the active version and use a new reviewed workflow run to fix forward. #### Version Uploads (Gradual Rollouts) diff --git a/docs/testing/remote.md b/docs/testing/remote.md index 0301e9261..d6357a8fb 100644 --- a/docs/testing/remote.md +++ b/docs/testing/remote.md @@ -104,20 +104,11 @@ Server runs at: `http://localhost:5173` - Serves the web UI at root - MCP endpoint at `/mcp` -### Option 2: Deploy to Cloudflare +### Option 2: Test the Cloudflare Worker -**Deploy to your Cloudflare account:** -```bash -cd packages/mcp-cloudflare -pnpm deploy -``` - -**Deploy to production (requires permissions):** -```bash -# Automated via GitHub Actions on push to main -# Manual deployment: -pnpm deploy --env production -``` +The protected GitHub workflow deploys the canary first and then production +after successful tests on `main`. Use the canary URL to check hosted behavior +before production traffic changes. ## Testing with the CLI Client @@ -662,14 +653,9 @@ pnpm inspector ### Production Deploy -```bash -# Via GitHub Actions (automatic) -git push origin main - -# Manual (if needed) -cd packages/mcp-cloudflare -pnpm deploy --env production -``` +Merge a reviewed pull request into `main`. The protected workflow requires +passing tests for that exact revision and a successful canary smoke test before +it changes production traffic. ### After Deploy @@ -773,9 +759,9 @@ pnpm -w run cli --mcp-host=https://staging.mcp.sentry.dev "who am I?" ### Testing Self-Hosted ```bash -# Deploy to self-hosted Cloudflare account +# Deploy only to your own Worker using your own Wrangler configuration cd packages/mcp-cloudflare -pnpm deploy +pnpm exec wrangler deploy --config your-worker.jsonc # Test with self-hosted URL pnpm -w run cli --mcp-host=https://your-worker.workers.dev "who am I?" diff --git a/package.json b/package.json index d75ae8986..cc23a9914 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,12 @@ }, "scripts": { "docs:check": "node scripts/check-doc-links.mjs", - "test:ci-projects": "node --test scripts/ci-projects.test.mjs", + "test:ci-projects": "node --test scripts/ci-projects.test.mjs scripts/cloudflare-deployment.test.mjs scripts/deploy-workflow.test.mjs", "dev": "pnpm --filter '@sentry/mcp-cloudflare...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --parallel --filter @sentry/mcp-cloudflare --filter @sentry/mcp-core --filter @sentry/mcp-server-mocks --if-present run dev", "dev:stdio": "pnpm --filter '@sentry/mcp-server...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --parallel --filter @sentry/mcp-server --filter @sentry/mcp-core --filter @sentry/mcp-server-mocks --if-present run dev", "build": "dotenv -e .env -e .env.local -- pnpm -r --filter '!sentry' --filter '!sentry-cli-docs' --if-present run build", "build:cli": "dotenv -e .env -e .env.local -- pnpm --filter sentry run build && pnpm --filter sentry-cli-docs run build", "check:generated": "pnpm --filter @sentry/mcp-core generate-definitions && git diff --exit-code -- packages/mcp-core/src/toolDefinitions.json packages/mcp-core/src/skillDefinitions.json plugins/sentry-mcp/agents/sentry-mcp.md plugins/sentry-mcp-experimental/agents/sentry-mcp.md", - "deploy": "pnpm --filter '@sentry/mcp-cloudflare...' --if-present run build && pnpm --filter @sentry/mcp-cloudflare run deploy", "deploy:docs": "pnpm --filter sentry run generate:schema && pnpm --filter sentry run generate:docs && pnpm --filter sentry-cli-docs run build && pnpm --filter sentry-cli-docs run deploy", "eval": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --filter @sentry/mcp-server-evals run eval", "eval:ci": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && CI=true dotenv -e .env -e .env.local -- pnpm --stream -r run eval:ci", diff --git a/packages/mcp-cloudflare/package.json b/packages/mcp-cloudflare/package.json index 319a0a151..041dfe5cf 100644 --- a/packages/mcp-cloudflare/package.json +++ b/packages/mcp-cloudflare/package.json @@ -21,7 +21,6 @@ "scripts": { "build": "tsc -b && vite build", "dev": "vite", - "deploy": "pnpm exec wrangler deploy", "cf:versions:upload": "npx wrangler versions upload", "preview": "vite preview", "cf-typegen": "wrangler types", diff --git a/scripts/cloudflare-deployment.mjs b/scripts/cloudflare-deployment.mjs new file mode 100644 index 000000000..8783c9429 --- /dev/null +++ b/scripts/cloudflare-deployment.mjs @@ -0,0 +1,171 @@ +import { spawnSync } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const WORKER = "sentry-mcp"; + +function active(deployment) { + if ( + !deployment || + !UUID.test(deployment.id) || + deployment.versions?.length !== 1 || + deployment.versions[0].percentage !== 100 || + !UUID.test(deployment.versions[0].version_id) + ) { + throw new Error("Expected a single active Worker version at 100% traffic"); + } + return { id: deployment.id, version: deployment.versions[0].version_id }; +} + +export function capturePrevious(deployments, marker) { + if (!Array.isArray(deployments) || !marker) { + throw new Error("Missing deployment history or run identity"); + } + return { previous: active(deployments[0]), marker }; +} + +export function verifyCandidate(deployments, journal) { + if (!Array.isArray(deployments) || !journal?.previous || !journal.marker) { + throw new Error("Missing deployment history or previous-version journal"); + } + const candidate = active(deployments[0]); + if ( + candidate.id === journal.previous.id || + deployments[0].annotations?.["workers/message"] !== journal.marker || + deployments[1]?.id !== journal.previous.id || + active(deployments[1]).version !== journal.previous.version + ) { + throw new Error( + "Deployment ownership or prior version changed; refusing recovery", + ); + } + return { ...journal, candidate }; +} + +export function assertOwnedCandidate(deployments, journal) { + const actual = verifyCandidate(deployments, journal); + if ( + !journal.candidate || + actual.candidate.id !== journal.candidate.id || + actual.candidate.version !== journal.candidate.version + ) { + throw new Error( + "Production no longer runs this job's candidate; refusing recovery", + ); + } + return journal.previous.version; +} + +async function listDeployments(env) { + if ( + !/^[0-9a-f]{32}$/i.test(env.CLOUDFLARE_ACCOUNT_ID ?? "") || + !env.CLOUDFLARE_API_TOKEN + ) { + throw new Error("Cloudflare account ID and API token are required"); + } + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${WORKER}/deployments?per_page=2`, + { + headers: { Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}` }, + signal: AbortSignal.timeout(15_000), + }, + ); + if (!response.ok) { + throw new Error("Cloudflare deployment lookup failed"); + } + const body = await response.json(); + if (body.success !== true || !Array.isArray(body.result?.deployments)) { + throw new Error("Cloudflare returned an invalid deployment list"); + } + return body.result.deployments; +} + +function runMarker(env) { + if ( + !/^\d+$/.test(env.GITHUB_RUN_ID ?? "") || + !/^\d+$/.test(env.GITHUB_RUN_ATTEMPT ?? "") || + !/^[0-9a-f]{40}$/.test(env.TESTED_SHA ?? "") + ) { + throw new Error("Invalid GitHub run identity"); + } + return `toolkit-mcp:${env.GITHUB_RUN_ID}:${env.GITHUB_RUN_ATTEMPT}:${env.TESTED_SHA}`; +} + +export async function restorePreviousVersion( + deployments, + journal, + env, + { list = listDeployments, spawn = spawnSync } = {}, +) { + const previous = assertOwnedCandidate(deployments, journal); + if (!UUID.test(previous)) { + throw new Error("Invalid captured previous version"); + } + const child = spawn( + "pnpm", + [ + "exec", + "wrangler", + "versions", + "deploy", + `${previous}@100`, + "--yes", + "--message", + `Toolkit recovery ${env.GITHUB_RUN_ID}`, + ], + { + cwd: join(import.meta.dirname, "../packages/mcp-cloudflare"), + env, + stdio: "inherit", + }, + ); + if (child.error) throw child.error; + if (child.status !== 0) + throw new Error("Explicit previous-version deployment failed"); + const recovered = active((await list(env))[0]); + if (recovered.version !== previous) { + throw new Error( + "Cloudflare did not activate the captured previous version", + ); + } +} + +async function main(command, env) { + if (!env.RUNNER_TEMP) { + throw new Error("RUNNER_TEMP is required"); + } + const path = join(env.RUNNER_TEMP, "mcp-production-deployment.json"); + const deployments = await listDeployments(env); + if (command === "capture") { + const journal = capturePrevious(deployments, runMarker(env)); + await writeFile(path, JSON.stringify(journal), { mode: 0o600 }); + return; + } + const journal = JSON.parse(await readFile(path, "utf8")); + if (journal.marker !== runMarker(env)) { + throw new Error("Deployment journal belongs to another run"); + } + if (command === "verify") { + await writeFile( + path, + JSON.stringify(verifyCandidate(deployments, journal)), + { mode: 0o600 }, + ); + return; + } + if (command === "recover") { + await restorePreviousVersion(deployments, journal, env); + return; + } + throw new Error("Unknown Cloudflare deployment operation"); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(process.argv[2], process.env).catch((error) => { + // Keep API responses and credentials out of CI logs. + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/cloudflare-deployment.test.mjs b/scripts/cloudflare-deployment.test.mjs new file mode 100644 index 000000000..87d10ef64 --- /dev/null +++ b/scripts/cloudflare-deployment.test.mjs @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + assertOwnedCandidate, + capturePrevious, + restorePreviousVersion, + verifyCandidate, +} from "./cloudflare-deployment.mjs"; + +const previousVersion = "11111111-1111-4111-8111-111111111111"; +const candidateVersion = "22222222-2222-4222-8222-222222222222"; +const priorId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const candidateId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const marker = "toolkit-mcp:123:1:0123456789abcdef0123456789abcdef01234567"; + +const previous = { + id: priorId, + versions: [{ version_id: previousVersion, percentage: 100 }], +}; +const candidate = { + id: candidateId, + versions: [{ version_id: candidateVersion, percentage: 100 }], + annotations: { "workers/message": marker }, +}; + +test("captures the live version before mutation and restores only the owned candidate", () => { + const captured = capturePrevious([previous], marker); + assert.deepEqual(captured.previous, { + id: priorId, + version: previousVersion, + }); + const journal = verifyCandidate([candidate, previous], captured); + assert.deepEqual(journal.candidate, { + id: candidateId, + version: candidateVersion, + }); + assert.equal( + assertOwnedCandidate([candidate, previous], journal), + previousVersion, + ); +}); + +test("refuses restoration after external mutation at any lifecycle boundary", () => { + const journal = verifyCandidate( + [candidate, previous], + capturePrevious([previous], marker), + ); + const external = { ...candidate, id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc" }; + assert.throws( + () => verifyCandidate([candidate, external, previous], journal), + /ownership or prior version changed/, + ); + assert.throws( + () => assertOwnedCandidate([external, candidate, previous], journal), + /ownership or prior version changed/, + ); + assert.throws( + () => + assertOwnedCandidate( + [ + { + ...candidate, + versions: [{ version_id: previousVersion, percentage: 100 }], + }, + previous, + ], + journal, + ), + /no longer runs this job's candidate/, + ); + assert.throws( + () => + assertOwnedCandidate( + [ + { ...candidate, annotations: { "workers/message": "external" } }, + previous, + ], + journal, + ), + /ownership or prior version changed/, + ); +}); + +test("rejects ambiguous or malformed traffic allocations", () => { + assert.throws( + () => capturePrevious([], marker), + /single active Worker version/, + ); + assert.throws( + () => + capturePrevious( + [ + { + ...previous, + versions: [{ version_id: previousVersion, percentage: 99 }], + }, + ], + marker, + ), + /single active Worker version/, + ); + assert.throws( + () => + capturePrevious( + [ + { + ...previous, + versions: [{ version_id: "../other", percentage: 100 }], + }, + ], + marker, + ), + /single active Worker version/, + ); + assert.throws( + () => verifyCandidate([candidate], capturePrevious([previous], marker)), + /ownership or prior version changed/, + ); + assert.throws( + () => + verifyCandidate([candidate, previous], { + ...capturePrevious([previous], marker), + previous: { id: priorId, version: candidateVersion }, + }), + /ownership or prior version changed/, + ); +}); + +test("restores only the captured version and checks Cloudflare's resulting state", async () => { + const journal = verifyCandidate( + [candidate, previous], + capturePrevious([previous], marker), + ); + const env = { GITHUB_RUN_ID: "123" }; + const calls = []; + await restorePreviousVersion([candidate, previous], journal, env, { + spawn: (...args) => { + calls.push(args); + return { status: 0 }; + }, + list: async () => [previous, candidate], + }); + assert.equal(calls.length, 1); + assert.equal(calls[0][0], "pnpm"); + assert.deepEqual(calls[0][1], [ + "exec", + "wrangler", + "versions", + "deploy", + `${previousVersion}@100`, + "--yes", + "--message", + "Toolkit recovery 123", + ]); + assert.equal(calls[0][2].env, env); +}); + +test("never restores when ownership changed or the journal is malformed", async () => { + const journal = verifyCandidate( + [candidate, previous], + capturePrevious([previous], marker), + ); + const calls = []; + const spawn = (...args) => { + calls.push(args); + return { status: 0 }; + }; + const env = { GITHUB_RUN_ID: "123" }; + await assert.rejects( + restorePreviousVersion( + [ + { ...candidate, annotations: { "workers/message": "external" } }, + previous, + ], + journal, + env, + { spawn }, + ), + /ownership or prior version changed/, + ); + await assert.rejects( + restorePreviousVersion( + [candidate, previous], + { ...journal, previous: { ...journal.previous, version: "../other" } }, + env, + { spawn }, + ), + /ownership or prior version changed/, + ); + assert.equal(calls.length, 0); +}); diff --git a/scripts/deploy-workflow.test.mjs b/scripts/deploy-workflow.test.mjs new file mode 100644 index 000000000..378067e8d --- /dev/null +++ b/scripts/deploy-workflow.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +const workflow = readFileSync( + new URL("../.github/workflows/deploy.yml", import.meta.url), + "utf8", +); + +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/); + 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 verify/); + assert.match(workflow, /cloudflare-deployment\.mjs recover/); + assert.match(workflow, /wrangler deploy --message "toolkit-mcp:/); +}); + +test("deployment failures never invoke an unqualified rollback", () => { + assert.doesNotMatch(workflow, /(?:command:|pnpm exec wrangler)\s+rollback\b/); + assert.match(workflow, /steps\.production_smoke_tests\.outcome == 'failure'/); + assert.match(workflow, /exit 1/); + assert.doesNotMatch(workflow, /continue-on-error:\s*true/); +}); + +test("external actions use immutable commit pins", () => { + for (const [, action] of workflow.matchAll( + /^\s*(?:- )?uses:\s*([^\s#]+)/gm, + )) { + assert.match(action, /^[\w.-]+\/[\w.-]+@[0-9a-f]{40}$/, action); + } +});