Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .github/workflows/staging-cron.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: Staging cron (beta.sona.fast)

# Scheduled cron for the STAGING site only. Cloudflare Pages can't run native
# cron, so GitHub Actions is the scheduler: it POSTs the app's cron endpoints
# with a Bearer CRON_SECRET. This mirrors the three fork cron workflows
# (sticker-resync.yml, artist-sync.yml, cleanup-orphans.yml) but targets the
# fixed staging base URL and is gated to the canonical repo only.
#
# The three schedules keep each fork workflow's cadence. A single workflow_dispatch
# runs all three on demand from the Actions tab.
#
# Required: repo secret CRON_SECRET matching the sona-staging Pages secret of the
# same name. Unset = each job warns and skips (optional opt-in, same as forks).

on:
schedule:
- cron: '0 6 * * *' # sticker re-sync — daily 06:00 UTC
- cron: '30 6 * * *' # artist registry sync — daily 06:30 UTC
- cron: '0 7 * * 1' # storage orphan cleanup — weekly, Mondays 07:00 UTC
workflow_dispatch: {}

# The staging base URL is fixed (not a repo var like the forks) because this
# workflow only ever runs against beta.sona.fast.
env:
STAGING_URL: https://beta.sona.fast

jobs:
sticker-resync:
runs-on: ubuntu-latest
# Canonical repo only; on schedule, only the 06:00 cron. workflow_dispatch
# runs every job.
if: >-
github.repository == 'sona-fast/sona' &&
(github.event_name == 'workflow_dispatch' || github.event.schedule == '0 6 * * *')
concurrency:
group: staging-sticker-resync
cancel-in-progress: false
steps:
- name: Trigger re-sync endpoint
env:
CRON_SECRET: ${{ secrets.CRON_SECRET }}
run: |
if [ -z "$CRON_SECRET" ]; then
echo "::warning::CRON_SECRET not set — skipping staging sticker re-sync (set the repo secret to opt in)"; exit 0
fi
code=$(curl -sS --max-time 120 -o response.json -w '%{http_code}' \
-X POST \
-H "Authorization: Bearer $CRON_SECRET" \
"${STAGING_URL%/}/api/cron/resync-telegram")
echo "HTTP $code"; cat response.json || true; echo
if [ "$code" != "200" ]; then
echo "::error::Re-sync endpoint returned HTTP $code"; exit 1
fi

artist-sync:
runs-on: ubuntu-latest
if: >-
github.repository == 'sona-fast/sona' &&
(github.event_name == 'workflow_dispatch' || github.event.schedule == '30 6 * * *')
concurrency:
group: staging-artist-sync
cancel-in-progress: false
steps:
- name: Trigger artist-sync endpoint
env:
CRON_SECRET: ${{ secrets.CRON_SECRET }}
run: |
if [ -z "$CRON_SECRET" ]; then
echo "::warning::CRON_SECRET not set — skipping staging artist sync (set the repo secret to opt in)"; exit 0
fi
code=$(curl -sS --max-time 120 -o response.json -w '%{http_code}' \
-X POST \
-H "Authorization: Bearer $CRON_SECRET" \
"${STAGING_URL%/}/api/cron/sync-artists")
echo "HTTP $code"; cat response.json || true; echo
if [ "$code" = "503" ]; then
echo "Registry not configured on the site — skipping."; exit 0
fi
if [ "$code" != "200" ]; then
echo "::error::Sync endpoint returned HTTP $code"; exit 1
fi

cleanup-orphans:
runs-on: ubuntu-latest
if: >-
github.repository == 'sona-fast/sona' &&
(github.event_name == 'workflow_dispatch' || github.event.schedule == '0 7 * * 1')
concurrency:
group: staging-cleanup-orphans
cancel-in-progress: false
steps:
- name: Trigger cleanup-orphans endpoint
env:
CRON_SECRET: ${{ secrets.CRON_SECRET }}
run: |
if [ -z "$CRON_SECRET" ]; then
echo "::warning::CRON_SECRET not set — skipping staging cleanup (set the repo secret to opt in)"; exit 0
fi
code=$(curl -sS --max-time 120 -o response.json -w '%{http_code}' \
-X POST \
-H "Authorization: Bearer $CRON_SECRET" \
"${STAGING_URL%/}/api/cron/cleanup-orphans")
echo "HTTP $code"; cat response.json || true; echo
if [ "$code" = "503" ]; then
echo "Cron is not configured on the site (no CRON_SECRET Pages secret) — skipping."; exit 0
fi
if [ "$code" != "200" ]; then
echo "::error::Cleanup endpoint returned HTTP $code"; exit 1
fi
# A provider can SKIP itself (zero-keep safety belt) while the run stays
# HTTP 200. Surface that as a warning without failing the run.
skipped=$(jq -r '(.skipped // []) | join("; ")' response.json 2>/dev/null || true)
if [ -n "$skipped" ]; then
echo "::warning::Cleanup skipped a provider: $skipped"
fi
257 changes: 257 additions & 0 deletions .github/workflows/staging-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
name: Deploy to Cloudflare Pages (staging)

# Staging deploy for the canonical repo ONLY. This is the exact inverse of
# deploy.yml's fork gate: deploy.yml runs on every fork and is gated OFF on
# sona-fast/sona; this one runs ONLY on sona-fast/sona and never on a fork. The
# two never fire on the same repo, so they can share the same repo vars/secrets
# without clobbering each other (on sona-fast/sona the vars point at the
# sona-staging project + DB; on a fork they point at that fork's own project).
#
# SECURITY — NEVER convert the trigger below to `pull_request_target`. This job
# holds CLOUDFLARE_API_TOKEN, SETUP_TOKEN, CRON_SECRET and more; pull_request_target
# runs with those secrets in the context of a PR's HEAD, which for an untrusted
# fork PR means arbitrary attacker-controlled code (build scripts, deps, tests)
# executes WITH our secrets and can exfiltrate them. Deploys must only ever run
# from trusted refs already merged to main. Keep the triggers `push` + manual.

on:
push:
branches: [main]
# GitHub's "Sync fork" button fast-forwards main WITHOUT emitting a push
# event; this also gives a manual "Run workflow" button in the Actions tab.
workflow_dispatch:

jobs:
deploy:
runs-on: ubuntu-latest
# Inverse of deploy.yml: staging runs ONLY on the canonical repo. A fork
# never deploys to staging (it has its own deploy.yml gated the other way).
if: github.repository == 'sona-fast/sona'
permissions:
contents: read
deployments: write

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- run: npm ci

- run: npm run check

- run: npm test

- run: npm run build

# Fail fast with a clear, actionable message if staging runs without
# Cloudflare credentials (rather than a cryptic wrangler error later).
- name: Verify Cloudflare credentials
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
echo "::error::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID not set — add both in Settings → Secrets and variables → Actions to enable the staging deploy"; exit 1
fi

# Per-deployment names. On sona-fast/sona set repo variables
# CLOUDFLARE_PAGES_PROJECT=sona-staging and D1_DATABASE_NAME=sona-staging-db.
# The fallbacks default to the STAGING names (not the prod 'sona'/'sona-db')
# so a missing var can never point this pipeline at production.
- name: Create Pages project if needed
env:
CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }}
run: |
curl -s -X POST \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"name\":\"${CLOUDFLARE_PAGES_PROJECT}\",\"production_branch\":\"main\"}" \
| jq '.success // .errors'

# Tracked migrations: a schema_migrations table records which files have run,
# so each is applied exactly once and a genuine SQL failure FAILS the deploy.
# Identical to deploy.yml — only the target DB (D1_DATABASE_NAME) differs.
- name: Run D1 migrations
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
D1_DATABASE_NAME: ${{ vars.D1_DATABASE_NAME || 'sona-staging-db' }}
run: |
set -euo pipefail
DB="$D1_DATABASE_NAME"
npx wrangler d1 execute "$DB" --remote --command \
"CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL);"
applied=$(npx wrangler d1 execute "$DB" --remote --json \
--command "SELECT name FROM schema_migrations;" | jq -r '.[0].results[].name' 2>/dev/null || true)
for f in drizzle/*.sql; do
name=$(basename "$f")
if printf '%s\n' "$applied" | grep -qxF "$name"; then
echo "✓ already applied: $name"
continue
fi
echo "→ applying: $name"
npx wrangler d1 execute "$DB" --remote --file="$f"
npx wrangler d1 execute "$DB" --remote --command \
"INSERT INTO schema_migrations (name, applied_at) VALUES ('$name', datetime('now'));"
done

# Optional feature: FurTrack photo integration. Set repo variable
# FURTRACK_MODE to `live` or `mock`. Per-key PATCH (nothing else clobbered);
# unset = logged skip. Identical to deploy.yml.
- name: Sync FURTRACK_MODE to Pages project
env:
FURTRACK_MODE: ${{ vars.FURTRACK_MODE }}
CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }}
run: |
set -euo pipefail
if [ -z "$FURTRACK_MODE" ]; then
echo "FURTRACK_MODE repo variable not set — skipping env var sync (FurTrack stays off)"
exit 0
fi
payload=$(jq -n --arg v "$FURTRACK_MODE" \
'{deployment_configs: {production: {env_vars: {FURTRACK_MODE: {type: "plain_text", value: $v}}}}}')
resp=$(curl -s -X PATCH \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${CLOUDFLARE_PAGES_PROJECT}" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$payload")
if ! echo "$resp" | jq -e '.success' >/dev/null; then
echo "::error::Failed to set FURTRACK_MODE on Pages project ${CLOUDFLARE_PAGES_PROJECT}"
echo "$resp" | jq '.errors'
exit 1
fi
echo "FURTRACK_MODE=${FURTRACK_MODE} set on Pages project ${CLOUDFLARE_PAGES_PROJECT}"

# Optional feature: observability (issue #6). Set repo variable
# OBSERVABILITY_ENABLED=true. Same per-key PATCH; unset = logged skip.
- name: Sync OBSERVABILITY_ENABLED to Pages project
env:
OBSERVABILITY_ENABLED: ${{ vars.OBSERVABILITY_ENABLED }}
CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }}
run: |
set -euo pipefail
if [ -z "$OBSERVABILITY_ENABLED" ]; then
echo "OBSERVABILITY_ENABLED repo variable not set — skipping env var sync (observability stays off)"
exit 0
fi
payload=$(jq -n --arg v "$OBSERVABILITY_ENABLED" \
'{deployment_configs: {production: {env_vars: {OBSERVABILITY_ENABLED: {type: "plain_text", value: $v}}}}}')
resp=$(curl -s -X PATCH \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${CLOUDFLARE_PAGES_PROJECT}" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$payload")
if ! echo "$resp" | jq -e '.success' >/dev/null; then
echo "::error::Failed to set OBSERVABILITY_ENABLED on Pages project ${CLOUDFLARE_PAGES_PROJECT}"
echo "$resp" | jq '.errors'
exit 1
fi
echo "OBSERVABILITY_ENABLED=${OBSERVABILITY_ENABLED} set on Pages project ${CLOUDFLARE_PAGES_PROJECT}"

# Optional feature: Telegram sticker import. Add repo secret
# TELEGRAM_BOT_TOKEN; synced to Pages here so this deploy binds it. Upsert
# (idempotent); unset = logged skip. Identical to deploy.yml.
- name: Sync TELEGRAM_BOT_TOKEN to Pages project
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }}
run: |
set -euo pipefail
if [ -z "$TELEGRAM_BOT_TOKEN" ]; then
echo "TELEGRAM_BOT_TOKEN repo secret not set — skipping secret sync (Telegram stickers stay off)"
exit 0
fi
printf '%s' "$TELEGRAM_BOT_TOKEN" | \
npx wrangler pages secret put TELEGRAM_BOT_TOKEN --project-name "$CLOUDFLARE_PAGES_PROJECT"
echo "TELEGRAM_BOT_TOKEN synced to Pages project ${CLOUDFLARE_PAGES_PROJECT} (binds on the deploy below)"

# Optional feature: admin password recovery email. Add repo secrets
# RESEND_API_KEY (required to enable) and optionally RESEND_FROM. Unset =
# logged skip. Identical to deploy.yml.
- name: Sync RESEND secrets to Pages project
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
RESEND_FROM: ${{ secrets.RESEND_FROM }}
CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }}
run: |
set -euo pipefail
if [ -z "$RESEND_API_KEY" ]; then
echo "RESEND_API_KEY repo secret not set — skipping secret sync (email password reset stays off; use npm run reset-password)"
exit 0
fi
printf '%s' "$RESEND_API_KEY" | \
npx wrangler pages secret put RESEND_API_KEY --project-name "$CLOUDFLARE_PAGES_PROJECT"
echo "RESEND_API_KEY synced to Pages project ${CLOUDFLARE_PAGES_PROJECT}"
if [ -n "$RESEND_FROM" ]; then
printf '%s' "$RESEND_FROM" | \
npx wrangler pages secret put RESEND_FROM --project-name "$CLOUDFLARE_PAGES_PROJECT"
echo "RESEND_FROM synced to Pages project ${CLOUDFLARE_PAGES_PROJECT}"
fi

# Bootstrap + cron secrets. Pages secrets bind at DEPLOY TIME, so re-put
# SETUP_TOKEN (first-run wizard gate) and CRON_SECRET (cron endpoint auth)
# right before the deploy so THIS deployment binds them. Idempotent upsert;
# unset = logged skip. Identical to deploy.yml.
- name: Sync SETUP_TOKEN + CRON_SECRET to Pages project
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
SETUP_TOKEN: ${{ secrets.SETUP_TOKEN }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }}
run: |
set -euo pipefail
if [ -n "$SETUP_TOKEN" ]; then
printf '%s' "$SETUP_TOKEN" | \
npx wrangler pages secret put SETUP_TOKEN --project-name "$CLOUDFLARE_PAGES_PROJECT"
echo "SETUP_TOKEN synced to Pages project ${CLOUDFLARE_PAGES_PROJECT} (binds on the deploy below)"
else
echo "SETUP_TOKEN repo secret not set — skipping (wizard already completed, or not wired)"
fi
if [ -n "$CRON_SECRET" ]; then
printf '%s' "$CRON_SECRET" | \
npx wrangler pages secret put CRON_SECRET --project-name "$CLOUDFLARE_PAGES_PROJECT"
echo "CRON_SECRET synced to Pages project ${CLOUDFLARE_PAGES_PROJECT} (binds on the deploy below)"
else
echo "CRON_SECRET repo secret not set — skipping"
fi

# Optional feature: the Cloudflare "edge traffic" panel on
# /admin/observability. Add repo secrets CLOUDFLARE_ANALYTICS_TOKEN and
# CLOUDFLARE_ZONE_ID. All three (incl. the account id, only a deploy secret
# otherwise) are put so the panel has them at runtime. Unset = logged skip.
- name: Sync Cloudflare analytics secrets to Pages project
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have these values set for production so how do we know that they're not gonna clash with staging?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They can't clash, for two reasons that stack. First, GitHub Actions secrets are per repository: the production values you're thinking of live in each fork's own repo (sparkyfen/sparky.ink and friends), and this workflow only ever reads sona-fast/sona's secrets, which as of right now are completely empty (I checked: zero secrets, zero variables set on canonical). Whatever you add here per the docs feeds staging alone, because the fork-facing workflows (deploy.yml and the crons) are gated off this repo. Second, even if a wrong value slipped in, the runtime targets resolve from canonical's own variables with fallbacks that fail toward staging names, never a production project or database (the adversarial review verified no fallback resolves to prod). For this analytics block specifically: the zone id you'd set is the sona.fast zone, which is exactly what beta.sona.fast needs, and the account-scoped analytics token from the fork rollout is safe to reuse since it lands only on the sona-staging Pages project. I've also added a note to docs/staging.md spelling out the per-repo isolation (commit dfc7483).

CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_ANALYTICS_TOKEN: ${{ secrets.CLOUDFLARE_ANALYTICS_TOKEN }}
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }}
run: |
set -euo pipefail
if [ -z "$CLOUDFLARE_ANALYTICS_TOKEN" ] || [ -z "$CLOUDFLARE_ZONE_ID" ]; then
echo "CLOUDFLARE_ANALYTICS_TOKEN / CLOUDFLARE_ZONE_ID repo secrets not both set — skipping (Cloudflare edge panel stays hidden)"
exit 0
fi
printf '%s' "$CLOUDFLARE_ANALYTICS_TOKEN" | \
npx wrangler pages secret put CLOUDFLARE_ANALYTICS_TOKEN --project-name "$CLOUDFLARE_PAGES_PROJECT"
printf '%s' "$CLOUDFLARE_ZONE_ID" | \
npx wrangler pages secret put CLOUDFLARE_ZONE_ID --project-name "$CLOUDFLARE_PAGES_PROJECT"
printf '%s' "$CLOUDFLARE_ACCOUNT_ID" | \
npx wrangler pages secret put CLOUDFLARE_ACCOUNT_ID --project-name "$CLOUDFLARE_PAGES_PROJECT"
echo "Cloudflare analytics secrets synced to Pages project ${CLOUDFLARE_PAGES_PROJECT} (bind on the deploy below)"

- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy .svelte-kit/cloudflare --project-name=${{ vars.CLOUDFLARE_PAGES_PROJECT || 'sona-staging' }}
Loading
Loading