Deploy Worker #72
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy Worker | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| workflow_dispatch: | |
| # Manual re-deploy without a new tag (e.g. R2 data refresh). | |
| # No inputs accepted. | |
| concurrency: | |
| group: deploy-worker | |
| cancel-in-progress: false | |
| jobs: | |
| deploy: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| environment: cloudflare | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: "22" | |
| cache: npm | |
| # Shared install + fetch + parse prefix. Same caches as | |
| # test.yml + release.yml. On a tag push the vendor/ + build/ | |
| # caches are typically warm (test.yml populated them when the | |
| # refresh commit landed on main moments earlier). | |
| - uses: ./.github/actions/build-spec-data | |
| # Build the VitePress docs site and stage it as Worker assets. | |
| # The Worker serves /mcp + /health from JS; everything else | |
| # falls through to the bundled assets directory configured in | |
| # `worker/wrangler.toml` `[assets]`. | |
| - name: Build docs site | |
| run: npm run docs:build | |
| - name: Stage docs as Worker assets | |
| run: | | |
| rm -rf worker/public | |
| mkdir -p worker/public | |
| cp -r docs/.vitepress/dist/. worker/public/ | |
| - name: Install worker dependencies | |
| working-directory: worker | |
| run: npm install | |
| - name: Worker type-check | |
| working-directory: worker | |
| run: npm run typecheck | |
| - name: Worker tests | |
| working-directory: worker | |
| run: npm test | |
| - name: Upload artifacts to R2 | |
| working-directory: worker | |
| env: | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| run: npm run upload-r2 | |
| - name: Deploy Worker | |
| id: deploy_worker | |
| working-directory: worker | |
| env: | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| run: npm run deploy | |
| - name: Verify deployment | |
| id: verify_deployment | |
| env: | |
| DEPLOY_URL: ${{ vars.WORKER_URL }} | |
| run: | | |
| # Hit the health endpoint + send a real MCP tools/call to | |
| # /mcp. If WORKER_URL isn't set as a repo variable, skip | |
| # silently. Catches "Worker deployed but R2 contents are | |
| # missing/broken." | |
| if [ -z "$DEPLOY_URL" ]; then | |
| echo "WORKER_URL repo variable not set; skipping post-deploy verification." | |
| exit 0 | |
| fi | |
| # 1. /health — basic up-check | |
| for i in 1 2 3; do | |
| if curl -fsS "$DEPLOY_URL/health" >/dev/null; then | |
| echo "Health check passed." | |
| break | |
| fi | |
| if [ "$i" = "3" ]; then | |
| echo "Health check failed after 3 attempts." >&2 | |
| exit 1 | |
| fi | |
| sleep 5 | |
| done | |
| # 2. initialize — verify protocol handshake + instructions field | |
| init=$(curl -fsS -X POST "$DEPLOY_URL/mcp" \ | |
| -H "content-type: application/json" \ | |
| -d '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"deploy-check","version":"0"}}}') | |
| echo "$init" | python3 -c " | |
| import json, sys | |
| msg = json.load(sys.stdin) | |
| if msg.get('error'): | |
| print(f'initialize returned error: {msg[\"error\"]}', file=sys.stderr); sys.exit(1) | |
| result = msg.get('result', {}) | |
| if result.get('protocolVersion') != '2024-11-05': | |
| print(f'unexpected protocolVersion: {result.get(\"protocolVersion\")}', file=sys.stderr); sys.exit(1) | |
| instr = result.get('instructions', '') | |
| if not isinstance(instr, str) or len(instr) < 100 or 'tc39-mcp' not in instr: | |
| print(f'missing/short instructions field (length={len(instr) if isinstance(instr, str) else \"n/a\"})', file=sys.stderr); sys.exit(1) | |
| print(f'initialize OK: protocolVersion={result[\"protocolVersion\"]}, instructions ({len(instr)} chars)') | |
| " | |
| # 3. tools/call spec.about — exercises the full R2 read path | |
| response=$(curl -fsS -X POST "$DEPLOY_URL/mcp" \ | |
| -H "content-type: application/json" \ | |
| -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"spec.about","arguments":{}}}') | |
| echo "$response" | python3 -c " | |
| import json, sys | |
| msg = json.load(sys.stdin) | |
| if msg.get('error'): | |
| print(f'tools/call returned error: {msg[\"error\"]}', file=sys.stderr) | |
| sys.exit(1) | |
| text = msg['result']['content'][0]['text'] | |
| inner = json.loads(text) | |
| if inner.get('server', {}).get('name') != 'tc39-mcp': | |
| print(f'unexpected server.name: {inner.get(\"server\", {}).get(\"name\")}', file=sys.stderr) | |
| sys.exit(1) | |
| present = sum(1 for s in inner.get('snapshots', []) if s.get('present')) | |
| if present == 0: | |
| print('0 snapshots present in R2 — bucket empty?', file=sys.stderr) | |
| sys.exit(1) | |
| print(f'spec.about OK: {present} snapshots present, server v{inner[\"server\"][\"version\"]}') | |
| " | |
| # 4. Docs site at origin root — verifies the [assets] binding | |
| # is wired and that the VitePress build made it into the | |
| # Worker bundle. Looks for the page title in the response | |
| # rather than a fixed substring so future theme tweaks don't | |
| # break the smoke. | |
| docs=$(curl -fsS "$DEPLOY_URL/" -H "accept: text/html") | |
| echo "$docs" | python3 -c " | |
| import sys | |
| body = sys.stdin.read() | |
| if '<title>' not in body or 'tc39-mcp' not in body: | |
| print('docs landing page missing or empty', file=sys.stderr); sys.exit(1) | |
| if '/snapshots' not in body: | |
| print('docs landing has no link to /snapshots', file=sys.stderr); sys.exit(1) | |
| print(f'docs landing OK ({len(body)} bytes HTML)') | |
| " | |
| # 5. Auto-generated snapshots page — proves docs:data ran | |
| # against the live build/ artifacts during this deploy. | |
| snaps=$(curl -fsS "$DEPLOY_URL/snapshots" -H "accept: text/html") | |
| echo "$snaps" | python3 -c " | |
| import sys | |
| body = sys.stdin.read() | |
| if 'SHA-pinned snapshots' not in body and 'Snapshot data not built yet' in body: | |
| print('snapshots page rendered placeholder — docs:data ran without build/ artifacts', file=sys.stderr); sys.exit(1) | |
| if 'github.com/tc39/ecma262/commit/' not in body: | |
| print('snapshots page has no commit links — generator regression?', file=sys.stderr); sys.exit(1) | |
| print(f'snapshots page OK ({len(body)} bytes HTML)') | |
| " | |
| - name: Rollback on smoke failure | |
| # If the post-deploy smoke failed AND the deploy itself | |
| # succeeded (so a previous version exists to rollback TO), | |
| # revert the Worker to the prior version. The R2 contents | |
| # stay updated — they're idempotent — but the Worker code is | |
| # the most likely thing to have shipped broken, and rolling | |
| # back is non-destructive (Cloudflare keeps the prior version | |
| # available for rollback by default). | |
| if: failure() && steps.verify_deployment.conclusion == 'failure' && steps.deploy_worker.conclusion == 'success' | |
| working-directory: worker | |
| env: | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| run: | | |
| # `wrangler rollback` without args reverts to the most recent | |
| # prior version. Best-effort: if Cloudflare doesn't have a | |
| # prior version (e.g., very first deploy), we surface the | |
| # original smoke failure rather than a confusing rollback | |
| # error. | |
| npx wrangler rollback --message "Auto-rollback: post-deploy smoke failed for $GITHUB_REF_NAME" \ | |
| || echo "(rollback failed or no prior version; the failing deploy stays live — manual action required)" |