Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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
72 changes: 72 additions & 0 deletions bin/smoke.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
Comment on lines +69 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The server version is hardcoded in src/server.ts, but the smoke test compares it against the dynamic version from package.json, which will cause future deployment failures.
Severity: CRITICAL

Suggested Fix

To prevent future deployment failures, replace the hardcoded version string in src/server.ts. Import the version from package.json directly into src/server.ts so that the server always reports the correct, current version. This ensures the smoke test will pass after a version bump.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: bin/smoke.mjs#L69-L70

Potential issue: The server's version is hardcoded as "0.4.0" in `src/server.ts`.
However, the smoke test in `bin/smoke.mjs` dynamically reads the expected version from
`package.json`. While both versions currently match, any future update to the
`package.json` version for a new release will cause a mismatch. This will lead the smoke
test to fail with a "version mismatch" error, consequently breaking the automated
deployment pipeline for all subsequent releases.

Also affects:

  • src/server.ts

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated as a false positive — no change needed.

src/server.ts and package.json don't drift: bin/bump-version.sh (added in #23, craft's preReleaseCommand) seds the released version into src/server.ts on every release, with a grep -q guard that fails the release loudly if the substitution doesn't take. deploy.yml triggers on the release tag, so the checked-out commit always has server.ts == package.json (confirmed: both 0.4.0 on main today).

So the smoke test comparing the live serverInfo.version to package.json is an intentional end-to-end check on that sync — if it ever broke, the smoke fails and the deploy rolls back. That's the guard working as designed, not a latent failure.

The suggested fix (import the version from package.json into server.ts) is the approach we deliberately rejected in #23: the same createServer() is bundled into the Cloudflare Worker, which has no filesystem, and a JSON import breaks the tsc rootDir layout. The build-time sed is what works for both targets.


console.log("SMOKE OK");
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Loading