Skip to content

Fullstack deploy smoke (real providers) #6

Fullstack deploy smoke (real providers)

Fullstack deploy smoke (real providers) #6

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