Skip to content

Fullstack deploy smoke (real providers) #10

Fullstack deploy smoke (real providers)

Fullstack deploy smoke (real providers) #10

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 scheduled and workflow_dispatch
# conclusions with newest-wins across triggers (ADR-0134): a weekly red run
# blocks release exactly like a manual red, and a newer manual green
# supersedes older scheduled reds. Manual runs intended as release evidence
# must select the `provision` mode so they cover the full probe.
#
# #1070 / ADR-0139: real-provider qualification is a v0.44 gate. Absence is
# recorded honestly without blocking the provider-neutral v0.43.1 release.
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: 45
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' }}
# Scanner steps run only when both private-engine secrets exist;
# otherwise the artifact records not-configured instead of claiming a
# positive-path qualification.
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 is not configured (#1070): attachments stay pending_scan and undownloadable — fail-closed"
echo "real benign/EICAR provider qualification is tracked for v0.44 (#1070)"
- 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: Qualify real scanner clean and EICAR paths
if: env.ASYNC_MODE == 'provision' && env.SCANNER_CONFIGURED == 'true'
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
cookie_jar=$(mktemp)
clean_file=$(mktemp)
eicar_file=$(mktemp)
user_id=''
cleanup() {
if [ -n "$user_id" ]; then
# Remove private objects before deleting the Auth owner. Never
# print keys, user ids, credentials, or fixture contents.
rows=$(curl -sf --max-time 20 \
"$SUPABASE_URL/rest/v1/attachment_reservations?select=object_key&user_id=eq.$user_id" \
-H "apikey: $SUPABASE_SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" || echo '[]')
echo "$rows" | jq -r '.[].object_key' | while IFS= read -r key; do
encoded=$(printf '%s' "$key" | jq -sRr @uri | sed 's|%2F|/|g')
curl -s -o /dev/null -X DELETE \
"$SUPABASE_URL/storage/v1/object/notes-attachments/$encoded" \
-H "apikey: $SUPABASE_SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" || true
done
curl -s -o /dev/null -X DELETE \
"$SUPABASE_URL/auth/v1/admin/users/$user_id" \
-H "apikey: $SUPABASE_SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" || true
fi
rm -f "$cookie_jar" "$clean_file" "$eicar_file"
}
trap cleanup EXIT
suffix="$(date +%s)-$RANDOM"
email="scanner-smoke-$suffix@example.com"
password="Scanner-Smoke-$suffix-$RANDOM"
user_id=$(curl -sf --max-time 20 -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\":\"$email\",\"password\":\"$password\",\"email_confirm\":true}" \
| jq -er .id)
curl -sf --max-time 20 -c "$cookie_jar" -o /dev/null -X POST "$WORKER_URL/login" \
-H "Origin: $WORKER_URL" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode "email=$email" --data-urlencode "password=$password"
clean_name="scanner-clean-$suffix.txt"
eicar_name="scanner-eicar-$suffix.txt"
printf 'OpenElement scanner qualification clean fixture.\n' > "$clean_file"
# Standard EICAR test string, generated only in the ephemeral runner.
printf '%s' 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > "$eicar_file"
for fixture in clean eicar; do
name_var="${fixture}_name"; file_var="${fixture}_file"
curl -sf --max-time 30 -b "$cookie_jar" -o /dev/null -X POST \
"$WORKER_URL/upload?/upload" -H "Origin: $WORKER_URL" \
-F "file=@${!file_var};filename=${!name_var};type=text/plain"
done
reservation_state() {
local name="$1"
curl -sf --max-time 20 \
"$SUPABASE_URL/rest/v1/attachment_reservations?select=state&user_id=eq.$user_id&display_name=eq.$name" \
-H "apikey: $SUPABASE_SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" | jq -r '.[0].state // "missing"'
}
wait_for_state() {
local name="$1" expected="$2" state=''
for _ in $(seq 1 36); do
state=$(reservation_state "$name")
[ "$state" = "$expected" ] && return 0
case "$state" in pending_scan|reserved|missing) sleep 5 ;; *) return 1 ;; esac
done
return 1
}
wait_for_state "$clean_name" clean
wait_for_state "$eicar_name" quarantined
owner_html=$(curl -sf --max-time 20 -b "$cookie_jar" "$WORKER_URL/upload")
printf '%s' "$owner_html" | grep -q "$clean_name"
if printf '%s' "$owner_html" | grep -q "$eicar_name"; then
echo 'quarantined fixture became owner-downloadable' >&2; exit 1
fi
signed_url=$(printf '%s' "$owner_html" | \
grep -oE '<a href="[^"]+">[^<]*</a>' | grep "$clean_name" | \
sed -n 's/.*href="\([^"]*\)".*/\1/p' | head -1 | sed 's/&amp;/\&/g')
[ -n "$signed_url" ]
[ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "$signed_url")" = '200' ]
echo '{"check":"attachment-scanner-real-clean-owner-download","result":"pass"}' >> .smoke/results.jsonl
echo '{"check":"attachment-scanner-real-eicar-quarantined","result":"pass"}' >> .smoke/results.jsonl
echo 'real scanner clean/EICAR owner-download qualification -> OK (redacted)'
- name: Qualify scanner retry, DLQ, and authenticated replay
if: env.ASYNC_MODE == 'provision' && env.SCANNER_CONFIGURED == 'true'
env:
WORKER_URL: https://openelement-ref-starter.freemanzheng.workers.dev
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
METADEFENDER_CORE_URL: ${{ secrets.METADEFENDER_CORE_URL }}
SCANNER_SERVICE: ${{ env.SCANNER_SERVICE }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: bash tools/qualify-scanner-retry-replay.sh
- 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