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
122 changes: 73 additions & 49 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,81 +71,109 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

# === BUILD AND DEPLOY CANARY WORKER ===
# Upload one production Worker version. A version includes code, assets,
# bindings and compatibility settings; the upload does not move traffic.
- name: Build
run: pnpm --filter '@sentry/mcp-cloudflare...' run build
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: production

- name: Deploy to Canary Worker
id: deploy_canary
- name: Capture active production version
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: Upload production version without moving traffic
id: upload
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
TESTED_SHA: ${{ github.event.workflow_run.head_sha }}
WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/wrangler-upload.jsonl
run: |
pnpm exec wrangler versions upload --experimental-auto-create=false \
--config wrangler.jsonc \
--message "toolkit-mcp:$GITHUB_RUN_ID:$GITHUB_RUN_ATTEMPT:$TESTED_SHA:upload"

- name: Identify uploaded production version
id: uploaded
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TESTED_SHA: ${{ github.event.workflow_run.head_sha }}
WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/wrangler-upload.jsonl
run: node scripts/cloudflare-deployment.mjs uploaded

- name: Wait for Canary to Propagate
if: success()
- name: Require tested revision on main before staging
env:
GH_TOKEN: ${{ github.token }}
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
echo "Waiting 30 seconds for canary deployment to propagate..."
sleep 30
current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')"
if [[ "$current_sha" != "$EXPECTED_SHA" ]]; then
echo 'Main advanced before candidate staging; refusing deployment.' >&2
exit 1
fi

# === SMOKE TEST CANARY ===
- name: Run Smoke Tests on Canary
id: canary_smoke_tests
if: success()
- name: Stage exact candidate at zero percent
id: stage
env:
PREVIEW_URL: https://sentry-mcp-canary.getsentry.workers.dev
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 stage

- name: Wait for staged candidate to propagate
run: sleep 30

- name: Smoke test exact production Worker version
id: candidate_smoke_tests
env:
PREVIEW_URL: https://mcp.sentry.dev
CLOUDFLARE_WORKER_NAME: sentry-mcp
CLOUDFLARE_VERSION_OVERRIDE: ${{ steps.uploaded.outputs.candidate_version }}
EXPECTED_VERSION_ID: ${{ steps.uploaded.outputs.candidate_version }}
run: |
echo "Running smoke tests against canary worker..."
cd packages/smoke-tests
pnpm test:ci

- name: Publish Canary Smoke Test Report
- name: Publish Candidate Smoke Test Report
uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857
if: always() && steps.canary_smoke_tests.outcome != 'skipped'
if: always() && steps.candidate_smoke_tests.outcome != 'skipped'
with:
report_paths: "packages/smoke-tests/tests.junit.xml"
check_name: "Canary Smoke Test Results"
check_name: "Candidate Smoke Test Results"
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'
- name: Require tested revision on main before promotion
if: steps.candidate_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
echo 'Main advanced during candidate testing; refusing promotion.' >&2
exit 1
fi

- name: Capture active production version
if: steps.canary_smoke_tests.outcome == 'success'
- name: Promote tested version to production
id: promote
if: steps.candidate_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'
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"
run: node scripts/cloudflare-deployment.mjs promote

- name: Verify production deployment ownership
id: verify_production
if: steps.deploy_production.outcome == 'success'
if: steps.promote.outcome == 'success'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
Expand All @@ -164,29 +192,25 @@ jobs:
if: steps.verify_production.outcome == 'success'
env:
PREVIEW_URL: https://mcp.sentry.dev
EXPECTED_VERSION_ID: ${{ steps.uploaded.outputs.candidate_version }}
run: |
echo "Running smoke tests on production..."
cd packages/smoke-tests
pnpm test:ci

- name: Recover captured previous version if owned transition fails
if: failure() && steps.uploaded.outcome == 'success' && steps.stage.outcome != 'skipped'
Comment thread
cursor[bot] marked this conversation as resolved.
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

# Report publication must not turn a verified promotion into a rollback.
- name: Publish Production Smoke Test Report
uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857
if: always() && steps.production_smoke_tests.outcome != 'skipped'
with:
report_paths: "packages/smoke-tests/tests.junit.xml"
check_name: "Production Smoke Test Results"
fail_on_failure: false

- 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: failure() && steps.production_smoke_tests.outcome == 'failure'
run: |
echo 'Production smoke tests failed. Inspect the deployment before changing traffic.' >&2
exit 1
11 changes: 8 additions & 3 deletions .github/workflows/migrate-cloudflare-token.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,14 @@ jobs:
.status == "completed" and
.conclusion == "success" and
(. as $job | [
"Deploy to Canary Worker",
"Run Smoke Tests on Canary",
"Deploy to Production Worker",
"Capture active production version",
"Upload production version without moving traffic",
"Identify uploaded production version",
"Require tested revision on main before staging",
"Stage exact candidate at zero percent",
"Smoke test exact production Worker version",
"Require tested revision on main before promotion",
"Promote tested version to production",
"Verify production deployment ownership",
"Run Smoke Tests on Production"
] | all(.[]; . as $name | ($job.steps | map(select(.name == $name and .status == "completed" and .conclusion == "success")) | length) == 1))
Expand Down
104 changes: 104 additions & 0 deletions .github/workflows/recover-cloudflare-deployment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: Recover Cloudflare Deployment

on:
workflow_dispatch:
inputs:
run_id:
description: Deploy to Cloudflare run ID to recover
required: true
type: string
run_attempt:
description: Attempt of that deployment run
required: true
type: string

permissions:
actions: read
contents: read
deployments: write

concurrency:
group: mcp-production-deploy
cancel-in-progress: false

jobs:
recover:
name: Recover exact owned Worker version
if: ${{ github.ref == 'refs/heads/main' && github.event.repository.id == 957245447 }}
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false

- name: Require current main and validate source run
env:
GH_TOKEN: ${{ github.token }}
SOURCE_RUN_ID: ${{ inputs.run_id }}
SOURCE_RUN_ATTEMPT: ${{ inputs.run_attempt }}
EXPECTED_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [[ ! "$SOURCE_RUN_ID" =~ ^[1-9][0-9]*$ || ! "$SOURCE_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]; then
echo 'Invalid deployment run identity' >&2
exit 1
fi
current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')"
if [[ "$current_sha" != "$EXPECTED_SHA" ]]; then
echo 'Main advanced before manual recovery; retry from current main.' >&2
exit 1
fi
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID/attempts/$SOURCE_RUN_ATTEMPT" \
--jq '{id,run_attempt,head_sha,head_branch,event,status,path,repository_id:.repository.id}' \
> "$RUNNER_TEMP/source-run.json"
if ! jq -e --argjson id "$SOURCE_RUN_ID" --argjson attempt "$SOURCE_RUN_ATTEMPT" \
--argjson repository "$GITHUB_REPOSITORY_ID" \
'.id == $id and .run_attempt == $attempt and .repository_id == $repository and
.event == "workflow_run" and .head_branch == "main" and .status == "completed" and
.path == ".github/workflows/deploy.yml" and
(.head_sha | test("^[0-9a-f]{40}$"))' \
"$RUNNER_TEMP/source-run.json" > /dev/null; then
Comment thread
cursor[bot] marked this conversation as resolved.
echo 'Source run is not a completed Toolkit main deployment.' >&2
exit 1
fi
jq -r '.head_sha | "SOURCE_SHA=\(.)"' "$RUNNER_TEMP/source-run.json" >> "$GITHUB_ENV"

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"

- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4
with:
run_install: false

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Restore only the last owned deployment
id: restore
env:
GH_TOKEN: ${{ github.token }}
EXPECTED_SHA: ${{ github.sha }}
SOURCE_RUN_ID: ${{ inputs.run_id }}
SOURCE_RUN_ATTEMPT: ${{ inputs.run_attempt }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
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 manual recovery; retry from current main.' >&2
exit 1
fi
node scripts/cloudflare-deployment.mjs manual

- name: Wait for restored version to propagate
run: sleep 30

- name: Verify recovered production Worker
env:
PREVIEW_URL: https://mcp.sentry.dev
EXPECTED_VERSION_ID: ${{ steps.restore.outputs.previous_version }}
ALLOW_LEGACY_VERSION_ENDPOINT: "1"
run: pnpm --dir packages/smoke-tests test:ci
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
42 changes: 31 additions & 11 deletions docs/operations/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,28 @@ Package-specific exceptions live in `package.json#sentryCi`; the standalone
smoke-test suite remains in its own workflow.

### deploy.yml
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.
Runs after a successful `Test` push run on `main`. Checks out the tested commit
and requires that it is still the tip of `main`. Builds once, records the active
production version, and uploads one new version of `sentry-mcp`. It stages the
candidate at 0% alongside the old version at 100%, then runs smoke tests
through the production route with a version override on every request. The
version endpoint confirms the override reaches the candidate. Only then does
it promotes the tested version to 100% and repeats the smoke tests. On failure
it restores the exact captured prior version only if the live deployments still
belong to this run.

### recover-cloudflare-deployment.yml
Manual `workflow_dispatch` recovery accepts a deployment run ID and attempt.
It runs trusted current-`main` code in the protected `production` environment,
checks the completed source run, and derives the prior version from contiguous
run-owned Cloudflare deployment history. It refuses an intervening deployment,
split or ambiguous traffic, or a marker mismatch. A successful restore is
verified against Cloudflare and the live Worker. Recovery never builds or
deploys source code from the old run. The first restored version may predate
`/_health/version`; in that case, Cloudflare's active-version check and the
functional smoke tests verify recovery while the version-route test accepts
only its 404 response. When the route exists, the smoke test compares its
version ID with the restored version.

### migrate-cloudflare-token.yml
Moves the Cloudflare API token from a repository secret into the protected
Expand Down Expand Up @@ -77,10 +93,11 @@ Other configuration:

### Workers
- **`sentry-mcp`** - Production worker at `https://mcp.sentry.dev`
- **`sentry-mcp-canary`** - Canary worker at `https://sentry-mcp-canary.getsentry.workers.dev`
- The candidate is tested on the production Worker at 0% traffic before promotion.

### Resource Isolation
Canary and production use separate resources for complete isolation:
The existing canary Worker has separate resources; exact-version rollout does
not deploy it. The production candidate uses the production bindings:

| Resource | Production | Canary |
|----------|------------|---------|
Expand All @@ -96,9 +113,12 @@ confirm the restored version fails the job rather than guessing a recovery.

## Manual Deployment

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.
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`.

## Cloudflare token migration

Expand Down
Loading
Loading