From b23fd4732f2a6d6e872dcd1852eb1d548255128a Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 13 Jul 2026 20:13:33 -0400 Subject: [PATCH] Add deploy-on-release workflow with post-deploy smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploys the Worker to Cloudflare when a new version is released (on release: published), so production always tracks a released tag rather than raw main — keeping serverInfo.version honest. Also runnable via workflow_dispatch. - deploy.yml: pnpm run deploy (wrangler + Sentry release/sourcemaps, all via env tokens — no interactive login) → wait → smoke test → wrangler rollback if the smoke test fails. - bin/smoke.mjs (+ `pnpm smoke`): MCP initialize handshake against the live /mcp endpoint asserting the worker is up and reports the released version. Requires repo secrets: CLOUDFLARE_API_TOKEN (account-scoped "Edit Cloudflare Workers"), CLOUDFLARE_ACCOUNT_ID, SENTRY_AUTH_TOKEN. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/deploy.yml | 64 ++++++++++++++++++++++++++++++++ bin/smoke.mjs | 72 ++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 137 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100755 bin/smoke.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..b6075c8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,64 @@ +name: Deploy to Cloudflare + +# Deploys the Worker when a new version is released (craft publishes the GitHub +# Release), so production always tracks a released tag — never raw main. Also +# runnable manually via workflow_dispatch. +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy: + name: Deploy to Cloudflare + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # `pnpm run deploy` runs wrangler deploy + the Sentry release & sourcemap + # upload. wrangler and sentry-cli both read their credentials from the env + # vars below, so no interactive login is needed in CI. + - name: Deploy worker + id: deploy + run: pnpm run deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + + - name: Wait for propagation + if: steps.deploy.outcome == 'success' + run: sleep 15 + + # Assert the live worker answers the MCP handshake and reports the version + # we just released. Fails the job (→ rollback) on any mismatch. + - name: Smoke test + id: smoke + if: steps.deploy.outcome == 'success' + run: node bin/smoke.mjs "https://plausible-mcp.sentry.dev/mcp" "$(node -p "require('./package.json').version")" + + - name: Roll back on smoke failure + if: steps.smoke.outcome == 'failure' + uses: cloudflare/wrangler-action@v3 + continue-on-error: true + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: rollback + packageManager: pnpm + + - name: Fail the job if smoke test failed + if: steps.smoke.outcome == 'failure' + run: exit 1 diff --git a/bin/smoke.mjs b/bin/smoke.mjs new file mode 100755 index 0000000..f7c1e8b --- /dev/null +++ b/bin/smoke.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Post-deploy smoke test for the Cloudflare Worker. +// Runs the MCP `initialize` handshake against the live /mcp endpoint and asserts +// the worker is reachable and reports the expected version. Exits non-zero on +// failure so CI can roll back. +// +// Scope: initialize only. It confirms the deploy is live and running the right +// build. It deliberately does NOT call tools/list — that path validates the +// bearer as a Plausible API key, so it would need a real key + live site to +// exercise, and tool registration is already covered by the unit tests. +// +// Usage: node bin/smoke.mjs [url] [expectedVersion] +// url defaults to $SMOKE_URL or https://plausible-mcp.sentry.dev/mcp +// expectedVersion defaults to $SMOKE_EXPECTED_VERSION (optional; skipped if unset) + +const URL = + process.argv[2] || process.env.SMOKE_URL || "https://plausible-mcp.sentry.dev/mcp"; +const EXPECTED = process.argv[3] || process.env.SMOKE_EXPECTED_VERSION || null; +// initialize never calls Plausible, so any bearer works for a liveness check. +const BEARER = process.env.SMOKE_BEARER || "smoke-test"; + +function fail(msg) { + console.error(`SMOKE FAIL: ${msg}`); + process.exit(1); +} + +console.log(`Smoke testing ${URL}${EXPECTED ? ` (expecting v${EXPECTED})` : ""}`); + +const res = await fetch(URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Authorization: `Bearer ${BEARER}`, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "ci-smoke", version: "1" }, + }, + }), +}); + +if (!res.ok) fail(`initialize returned HTTP ${res.status}`); + +// Response is an SSE stream ("data: {json}"); pull the JSON-RPC frame out. +const text = await res.text(); +const frame = text + .split("\n") + .map((l) => l.match(/^data:\s*(.+)$/)?.[1]) + .filter(Boolean) + .map((j) => { + try { + return JSON.parse(j); + } catch { + return null; + } + }) + .find((m) => m?.id === 1); + +const serverInfo = frame?.result?.serverInfo; +if (!serverInfo) fail(`no serverInfo in response: ${text.slice(0, 200)}`); +console.log(`serverInfo: ${serverInfo.name} v${serverInfo.version}`); +if (serverInfo.name !== "plausible-mcp") fail(`unexpected name "${serverInfo.name}"`); +if (EXPECTED && serverInfo.version !== EXPECTED) + fail(`version mismatch: live=${serverInfo.version} expected=${EXPECTED}`); + +console.log("SMOKE OK"); diff --git a/package.json b/package.json index b332b5e..be8281c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "eval": "tsx evals/run.ts", + "smoke": "node bin/smoke.mjs", "deploy": "SENTRY_RELEASE=$(node -p \"require('./package.json').version\") && npx wrangler deploy --outdir .sentry-build --var SENTRY_RELEASE:$SENTRY_RELEASE && npx sentry-cli releases new $SENTRY_RELEASE --org sentry-developer-experience --project plausible-mcp && npx sentry-cli sourcemaps upload --org sentry-developer-experience --project plausible-mcp --release $SENTRY_RELEASE .sentry-build && rm -rf .sentry-build" }, "keywords": [