Fullstack deploy smoke (real providers) #5
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: Fullstack deploy smoke (real providers) | |
| # #984 tier 3: deploys the Supabase × Cloudflare reference starter to a real | |
| # Cloudflare Worker and smokes the live URL. Requires maintainer-provided | |
| # secrets (see below). Manual + weekly only: never runs on push/PR. | |
| # | |
| # #997 scheduled-run semantics: schedule events carry no inputs, so the | |
| # job-level env below defaults ASYNC_MODE to `provision` — the weekly run | |
| # executes the full probe (scanner redeploy + Queue/DLQ verification), which | |
| # is idempotent against the already-created bounded async resources. The | |
| # release freshness gate (tools/check-evidence-freshness.ts) reads only the | |
| # scheduled run conclusions, so a weekly red run blocks release exactly like | |
| # a manual one; there is no base/provision ambiguity for the gate to resolve. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| async_mode: | |
| description: 'Keep the safe base deployment, or provision Queue/DLQ/Cron after Supabase migrations are current' | |
| required: true | |
| default: base | |
| type: choice | |
| options: | |
| - base | |
| - provision | |
| scanner_service: | |
| description: 'Existing private scanner Worker name used by the provision mode service binding' | |
| required: true | |
| default: openelement-attachment-scanner | |
| type: string | |
| schedule: | |
| # Tuesdays 06:00 UTC (staggered from the tier-2 Monday run) | |
| - cron: '0 6 * * 2' | |
| concurrency: | |
| group: fullstack-real-cloudflare | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| jobs: | |
| deploy-smoke: | |
| name: Deploy reference starter to Workers and smoke the live URL | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| env: | |
| # Scheduled runs have no inputs; default them to the full provision | |
| # probe so the weekly evidence covers Queue/DLQ, not just HTTP. | |
| ASYNC_MODE: ${{ inputs.async_mode || 'provision' }} | |
| SCANNER_SERVICE: ${{ inputs.scanner_service || 'openelement-attachment-scanner' }} | |
| steps: | |
| # v7.0.0 | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 | |
| - uses: ./.github/actions/setup-deno-workspace | |
| - name: Build the Workers bundle | |
| working-directory: examples/supabase-cloudflare-starter | |
| run: | | |
| deno task build | |
| deno task nitro:build | |
| - name: Deploy the private attachment scanner | |
| if: env.ASYNC_MODE == 'provision' | |
| working-directory: examples/supabase-cloudflare-starter | |
| env: | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| SUPABASE_URL: ${{ secrets.SUPABASE_URL }} | |
| SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} | |
| METADEFENDER_CORE_URL: ${{ secrets.METADEFENDER_CORE_URL }} | |
| METADEFENDER_API_KEY: ${{ secrets.METADEFENDER_API_KEY }} | |
| run: | | |
| set -euo pipefail | |
| for required in SUPABASE_URL SUPABASE_SERVICE_ROLE_KEY METADEFENDER_CORE_URL METADEFENDER_API_KEY SCANNER_SERVICE; do | |
| [ -n "${!required}" ] || { echo "$required is not configured" >&2; exit 1; } | |
| done | |
| deno run -A npm:wrangler@4.123.0 deploy --config scanner-wrangler.jsonc \ | |
| --name "$SCANNER_SERVICE" \ | |
| --var "SUPABASE_URL:$SUPABASE_URL" "METADEFENDER_CORE_URL:$METADEFENDER_CORE_URL" | |
| jq -n \ | |
| --arg supabase "$SUPABASE_SERVICE_ROLE_KEY" \ | |
| --arg metadefender "$METADEFENDER_API_KEY" \ | |
| '{SUPABASE_SERVICE_ROLE_KEY:$supabase,METADEFENDER_API_KEY:$metadefender}' | \ | |
| deno run -A npm:wrangler@4.123.0 secret bulk \ | |
| --config scanner-wrangler.jsonc --name "$SCANNER_SERVICE" | |
| - name: Render and provision bounded async resources | |
| if: env.ASYNC_MODE == 'provision' | |
| working-directory: examples/supabase-cloudflare-starter | |
| env: | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} | |
| STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} | |
| STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} | |
| STRIPE_PRICE_ID: ${{ secrets.STRIPE_PRICE_ID }} | |
| run: | | |
| set -euo pipefail | |
| [ -n "$SUPABASE_SERVICE_ROLE_KEY" ] || { echo "SUPABASE_SERVICE_ROLE_KEY is not configured" >&2; exit 1; } | |
| [ -n "$STRIPE_SECRET_KEY" ] || { echo "STRIPE_SECRET_KEY is not configured" >&2; exit 1; } | |
| [ -n "$STRIPE_WEBHOOK_SECRET" ] || { echo "STRIPE_WEBHOOK_SECRET is not configured" >&2; exit 1; } | |
| [ -n "$STRIPE_PRICE_ID" ] || { echo "STRIPE_PRICE_ID is not configured" >&2; exit 1; } | |
| [ -n "$SCANNER_SERVICE" ] || { echo "scanner_service is required" >&2; exit 1; } | |
| deno run --allow-read --allow-write ../../tools/render-cloudflare-async-config.ts \ | |
| wrangler.jsonc .wrangler-async.generated.json "$SCANNER_SERVICE" | |
| queues=$(deno run -A npm:wrangler@4.123.0 queues list --json) | |
| for queue in \ | |
| openelement-attachment-scan \ | |
| openelement-attachment-scan-dlq \ | |
| openelement-attachment-scan-persistence-failures \ | |
| openelement-payment-events \ | |
| openelement-payment-events-dlq \ | |
| openelement-payment-events-persistence-failures; do | |
| if echo "$queues" | jq -e --arg name "$queue" '.[] | select(.queue_name == $name)' >/dev/null; then | |
| echo "$queue already exists" | |
| else | |
| deno run -A npm:wrangler@4.123.0 queues create "$queue" | |
| fi | |
| done | |
| printf '%s' "$SUPABASE_SERVICE_ROLE_KEY" | \ | |
| deno run -A npm:wrangler@4.123.0 secret put SUPABASE_SERVICE_ROLE_KEY \ | |
| --config .wrangler-async.generated.json | |
| for secret_name in STRIPE_SECRET_KEY STRIPE_WEBHOOK_SECRET STRIPE_PRICE_ID; do | |
| printf '%s' "${!secret_name}" | \ | |
| deno run -A npm:wrangler@4.123.0 secret put "$secret_name" \ | |
| --config .wrangler-async.generated.json | |
| done | |
| - name: Select deployment config | |
| working-directory: examples/supabase-cloudflare-starter | |
| run: | | |
| if [ "$ASYNC_MODE" = "provision" ]; then | |
| echo "WRANGLER_CONFIG=.wrangler-async.generated.json" >> "$GITHUB_ENV" | |
| else | |
| echo "WRANGLER_CONFIG=wrangler.jsonc" >> "$GITHUB_ENV" | |
| fi | |
| - name: Deploy to Cloudflare Workers | |
| working-directory: examples/supabase-cloudflare-starter | |
| env: | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| SUPABASE_URL: ${{ secrets.SUPABASE_URL }} | |
| SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} | |
| run: >- | |
| deno run -A npm:wrangler@4.123.0 deploy --config "$WRANGLER_CONFIG" | |
| --var "SUPABASE_URL:$SUPABASE_URL" "SUPABASE_ANON_KEY:$SUPABASE_ANON_KEY" | |
| "STRIPE_LIVEMODE:false" | |
| "APP_ORIGIN:https://openelement-ref-starter.freemanzheng.workers.dev" | |
| "STRIPE_CHECKOUT_HOST:checkout.stripe.com" | |
| - name: Smoke the live worker | |
| env: | |
| WORKER_URL: https://openelement-ref-starter.freemanzheng.workers.dev | |
| run: | | |
| set -e | |
| mkdir -p .smoke | |
| record() { echo "{\"check\":\"$1\",\"result\":\"$2\"}" >> .smoke/results.jsonl; } | |
| for i in 1 2 3 4 5 6; do | |
| code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$WORKER_URL/" || true) | |
| [ "$code" = "200" ] && break | |
| sleep 10 | |
| done | |
| echo "GET / -> $code"; [ "$code" = "200" ] | |
| record worker-root-200 pass | |
| notes=$(curl -s --max-time 15 "$WORKER_URL/notes") | |
| echo "$notes" | grep -q "Sign-in is required" | |
| record anonymous-notes-denied pass | |
| csrf=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST "$WORKER_URL/login" -H 'Origin: https://evil.example.com' -H 'Content-Type: application/x-www-form-urlencoded' -d 'x=1') | |
| echo "cross-origin POST /login -> $csrf"; [ "$csrf" = "403" ] | |
| record cross-origin-login-denied pass | |
| - name: Probe cookie security floor and Host hygiene | |
| env: | |
| WORKER_URL: https://openelement-ref-starter.freemanzheng.workers.dev | |
| SUPABASE_URL: ${{ secrets.SUPABASE_URL }} | |
| SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} | |
| run: | | |
| set -euo pipefail | |
| # Short-lived smoke user; the random password is never logged. | |
| SMOKE_EMAIL="deploy-smoke-$(date +%s)@example.com" | |
| SMOKE_PASSWORD="Deploy-Smoke-$(date +%s)-$RANDOM$RANDOM" | |
| curl -sf -X POST "$SUPABASE_URL/auth/v1/admin/users" \ | |
| -H "apikey: $SUPABASE_SERVICE_ROLE_KEY" \ | |
| -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" \ | |
| -H 'Content-Type: application/json' \ | |
| -d "{\"email\":\"$SMOKE_EMAIL\",\"password\":\"$SMOKE_PASSWORD\",\"email_confirm\":true}" \ | |
| >/dev/null | |
| ref=$(printf '%s' "$SUPABASE_URL" | sed -E 's|https?://([^.]+)\..*|\1|') | |
| headers=$(mktemp) | |
| # Plant stale chunk cookies: the session write must delete them too, | |
| # so the response carries several Set-Cookie headers and proves the | |
| # response path preserves multi Set-Cookie instead of collapsing it. | |
| curl -s -D "$headers" -o /dev/null --max-time 20 -X POST "$WORKER_URL/login" \ | |
| -H 'Content-Type: application/x-www-form-urlencoded' \ | |
| -H "Cookie: sb-$ref-auth-token.0=stale0; sb-$ref-auth-token.1=stale1" \ | |
| --data-urlencode "email=$SMOKE_EMAIL" \ | |
| --data-urlencode "password=$SMOKE_PASSWORD" | |
| tr -d '\r' < "$headers" > "$headers.clean" | |
| cookies=$(grep -c '^set-cookie:' "$headers.clean" || true) | |
| echo "POST /login Set-Cookie count -> $cookies" | |
| [ "$cookies" -ge 2 ] | |
| grep '^set-cookie:' "$headers.clean" | while IFS= read -r line; do | |
| for attribute in 'httponly' 'samesite=lax' 'path=/' 'secure'; do | |
| printf '%s' "$line" | grep -qi "$attribute" || { | |
| echo "Set-Cookie missing $attribute: $line"; exit 1; | |
| } | |
| done | |
| done | |
| echo "all $cookies Set-Cookie headers carry HttpOnly; SameSite=Lax; Path=/; Secure -> OK" | |
| location=$(grep -i '^location:' "$headers.clean" | head -1 | cut -d' ' -f2-) | |
| echo "POST /login Location -> $location" | |
| case "$location" in | |
| /*|"$WORKER_URL"*) : ;; | |
| *) echo "login redirect leaked a non-application host: $location"; exit 1 ;; | |
| esac | |
| if grep -iE 'localhost|127\.0\.0\.1|\.internal|\.supabase\.co' "$headers.clean"; then | |
| echo "internal/provider hostname leaked in login response headers"; exit 1 | |
| fi | |
| echo "Host hygiene: no internal/provider hostname in response headers -> OK" | |
| rm -f "$headers" "$headers.clean" | |
| - name: Verify provisioned Queue and DLQ | |
| if: env.ASYNC_MODE == 'provision' | |
| working-directory: examples/supabase-cloudflare-starter | |
| env: | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| deno run -A npm:wrangler@4.123.0 deployments list \ | |
| --name "$SCANNER_SERVICE" --json | jq -e 'length > 0' >/dev/null | |
| deno run -A npm:wrangler@4.123.0 queues info openelement-attachment-scan >/dev/null | |
| deno run -A npm:wrangler@4.123.0 queues info openelement-attachment-scan-dlq >/dev/null | |
| deno run -A npm:wrangler@4.123.0 queues info \ | |
| openelement-attachment-scan-persistence-failures >/dev/null | |
| deno run -A npm:wrangler@4.123.0 queues info openelement-payment-events >/dev/null | |
| deno run -A npm:wrangler@4.123.0 queues info openelement-payment-events-dlq >/dev/null | |
| deno run -A npm:wrangler@4.123.0 queues info \ | |
| openelement-payment-events-persistence-failures >/dev/null | |
| echo "Attachment/payment Queue + DLQ + persistence-failure provider lookup -> OK" | |
| echo '{"check":"attachment-payment-queues","result":"pass"}' \ | |
| >> "$GITHUB_WORKSPACE/.smoke/results.jsonl" | |
| - name: Write the smoke report | |
| if: always() | |
| env: | |
| WORKER_URL: https://openelement-ref-starter.freemanzheng.workers.dev | |
| run: | | |
| checks='[]' | |
| if [ -f .smoke/results.jsonl ]; then | |
| checks=$(jq -s '.' .smoke/results.jsonl) | |
| fi | |
| jq -n \ | |
| --arg version "$(git rev-parse --short HEAD)" \ | |
| --arg runId "${{ github.run_id }}" \ | |
| --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | |
| --arg environment "cloudflare-workers-test" \ | |
| --arg workerUrl "$WORKER_URL" \ | |
| --arg asyncMode "$ASYNC_MODE" \ | |
| --argjson asyncResourcesVerified "$([ "$ASYNC_MODE" = "provision" ] && echo true || echo false)" \ | |
| --arg status "${{ job.status }}" \ | |
| --arg cleanup "not-applicable" \ | |
| --argjson checks "$checks" \ | |
| '{ | |
| version: $version, | |
| runId: $runId, | |
| timestamp: $timestamp, | |
| environment: $environment, | |
| workerUrl: $workerUrl, | |
| asyncMode: $asyncMode, | |
| asyncResourcesVerified: $asyncResourcesVerified, | |
| jobStatus: $status, | |
| cleanup: $cleanup, | |
| checks: $checks, | |
| note: "tier-3 deploy smoke; redacted — no credentials in this report; the test worker and queues are persistent shared infrastructure, so there is no per-run cleanup" | |
| }' > fullstack-deploy-smoke.json | |
| - name: Archive the smoke report | |
| if: always() | |
| # v7.0.1 | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a | |
| with: | |
| name: fullstack-deploy-smoke | |
| path: fullstack-deploy-smoke.json | |
| if-no-files-found: error |