Skip to content
Merged
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
157 changes: 157 additions & 0 deletions .github/workflows/deploy-docs-mcp.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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.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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
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:
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: 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: 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:
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, listSettled, verifyCandidate } from './scripts/cloudflare-deployment.mjs';
const journal = JSON.parse(await readFile(`${process.env.RUNNER_TEMP}/docs-mcp-deployment.json`, 'utf8'));
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');
}
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
Comment thread
cursor[bot] marked this conversation as resolved.
44 changes: 22 additions & 22 deletions apps/docs-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
26 changes: 19 additions & 7 deletions docs/operations/github-actions.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -134,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

Expand Down
10 changes: 6 additions & 4 deletions scripts/cloudflare-deployment.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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");
}
Expand All @@ -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)
) {
Expand Down
7 changes: 7 additions & 0 deletions scripts/cloudflare-deployment.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
38 changes: 22 additions & 16 deletions scripts/deploy-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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/);
Expand Down Expand Up @@ -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);
Expand Down
Loading