Fullstack deploy smoke (real providers) #8
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 (Queue/DLQ verification, plus scanner redeploy when | |
| # the engine secrets exist), 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. | |
| # | |
| # #1070 / ADR-0132: real scan-engine evidence is deferred to v0.44. When | |
| # METADEFENDER_CORE_URL / METADEFENDER_API_KEY are absent, the scanner steps | |
| # are skipped and the artifact records not-configured — an honest annotation, | |
| # never a green scanner claim. | |
| 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' }} | |
| # #1070 / ADR-0132: the real scan engine is deferred to v0.44. Scanner | |
| # steps run only when both engine secrets exist; otherwise the run | |
| # records not-configured instead of failing. | |
| SCANNER_CONFIGURED: ${{ secrets.METADEFENDER_CORE_URL != '' && secrets.METADEFENDER_API_KEY != '' }} | |
| steps: | |
| # v7.0.1 | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 | |
| - 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' && env.SCANNER_CONFIGURED == 'true' | |
| 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: Record the scan engine as not configured | |
| if: env.ASYNC_MODE == 'provision' && env.SCANNER_CONFIGURED != 'true' | |
| run: | | |
| mkdir -p .smoke | |
| echo '{"check":"attachment-scanner-engine","result":"not-configured"}' >> .smoke/results.jsonl | |
| echo "scan engine deferred to v0.44 (#1070, ADR-0132): attachments stay pending_scan and undownloadable — fail-closed" | |
| - 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; } | |
| if [ "$SCANNER_CONFIGURED" = "true" ]; then | |
| deno run --allow-read --allow-write ../../tools/render-cloudflare-async-config.ts \ | |
| wrangler.jsonc .wrangler-async.generated.json "$SCANNER_SERVICE" | |
| else | |
| deno run --allow-read --allow-write ../../tools/render-cloudflare-async-config.ts \ | |
| wrangler.jsonc .wrangler-async.generated.json --omit-scanner | |
| fi | |
| # wrangler 4.x `queues list` has no --json flag; enumerate existing | |
| # queues through the Cloudflare API instead. | |
| queues=$(curl -sf --max-time 30 \ | |
| -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ | |
| "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/queues?per_page=100") | |
| # Queue names are hardcoded here; the canonical source is | |
| # examples/supabase-cloudflare-starter/lib/cloudflare-queues.ts | |
| # (parity covered by tools/render-cloudflare-async-config.test.ts). | |
| 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" '.result[] | 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. The | |
| # id goes to $GITHUB_ENV so the always() cleanup step below can | |
| # delete the user again. | |
| SMOKE_EMAIL="deploy-smoke-$(date +%s)@example.com" | |
| SMOKE_PASSWORD="Deploy-Smoke-$(date +%s)-$RANDOM$RANDOM" | |
| SMOKE_USER_ID=$(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}" \ | |
| | jq -r .id) | |
| echo "SMOKE_USER_ID=$SMOKE_USER_ID" >> "$GITHUB_ENV" | |
| 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 | |
| if [ "$SCANNER_CONFIGURED" = "true" ]; then | |
| deno run -A npm:wrangler@4.123.0 deployments list \ | |
| --name "$SCANNER_SERVICE" --json | jq -e 'length > 0' >/dev/null | |
| else | |
| echo "scan engine not configured (#1070); skipping scanner deployment check" | |
| fi | |
| # Queue names mirror the provisioning loop above; canonical source: | |
| # examples/supabase-cloudflare-starter/lib/cloudflare-queues.ts. | |
| 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: Delete the throwaway smoke user (Auth admin API) | |
| id: cleanup-user | |
| # Cleanup guarantee: runs even when the probes above fail, so the | |
| # project never accumulates deploy-smoke users. The report step | |
| # records this step's outcome as the artifact's cleanup field. | |
| if: always() | |
| env: | |
| SUPABASE_URL: ${{ secrets.SUPABASE_URL }} | |
| SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} | |
| run: | | |
| if [ -z "${SMOKE_USER_ID:-}" ]; then | |
| echo "no smoke user was created; nothing to clean up" | |
| exit 0 | |
| fi | |
| code=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE \ | |
| "$SUPABASE_URL/auth/v1/admin/users/$SMOKE_USER_ID" \ | |
| -H "apikey: $SUPABASE_SERVICE_ROLE_KEY" \ | |
| -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY") | |
| echo "DELETE admin user -> $code" | |
| [ "$code" = "200" ] | |
| - 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" \ | |
| --arg scannerEngine "$SCANNER_CONFIGURED" \ | |
| --argjson asyncResourcesVerified "$([ "$ASYNC_MODE" = "provision" ] && echo true || echo false)" \ | |
| --arg status "${{ job.status }}" \ | |
| --arg cleanup "${{ steps.cleanup-user.outcome || 'not-run' }}" \ | |
| --argjson checks "$checks" \ | |
| '{ | |
| version: $version, | |
| runId: $runId, | |
| timestamp: $timestamp, | |
| environment: $environment, | |
| workerUrl: $workerUrl, | |
| asyncMode: $asyncMode, | |
| scannerEngine: (if $scannerEngine == "true" then "configured" else "not-configured" end), | |
| 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, only the smoke user is cleaned up per run" | |
| }' > 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 |