ci: automated Telegram-gated Supabase deploy (edge functions + migrations)

Adds a deploy-supabase job to the quality gate so the self-hosted Supabase
side ships the same way as the frontend/backend instead of by hand:

- gated by the existing telegram-gate (owner approves once, in Telegram)
- runs on main-push in parallel with deploy-app, so edge functions, DB
  migrations and the new frontend go live in one post-approval window
- rsyncs the edge-function dirs into the edge-runtime volume (hot-reloaded,
  no restart; --no-delete preserves the telegram-* functions)
- applies pending migrations via deploy/apply-migrations.sh: idempotent and
  transactional, tracked in supabase_migrations.schema_migrations, skips
  non-versioned files (COMBINED_*), never half-records a failed migration
- post-deploy health check verifies the fund-custody guards took effect
  (anon lost EXECUTE on the escrow/withdraw RPCs; both freeze triggers exist)
- Telegram alert on success/failure

Requires repo secrets SUPABASE_VPS_HOST/USER/SSH_KEY/PORT.
This commit is contained in:
2026-07-25 17:53:31 -07:00
parent 193ef34c3e
commit 85b2c48025
2 changed files with 190 additions and 0 deletions
+112
View File
@@ -896,6 +896,118 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=${CEO_CHAT_ID}" --data-urlencode "text=$MSG"
# ========================================
# DEPLOY - SUPABASE (edge functions + migrations)
# Same Telegram-gated cutover as the frontend/backend: syncs the edge functions
# into the self-hosted edge-runtime volume (hot-reloaded, no restart) and applies
# any un-recorded migrations transactionally against the fund-custody Postgres.
# Runs in parallel with deploy-app so functions, migrations and the new frontend
# go live in the same post-approval window (minimal fail-closed gap).
# ========================================
deploy-supabase:
name: Deploy Supabase (functions + migrations)
runs-on: pwap-runner
needs: [telegram-gate]
if: |
always() &&
needs.telegram-gate.result == 'success' &&
github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Pack supabase tree
run: |
# __tests__ are Deno tests — kept out of the runtime bundle entirely.
tar czf supabase-deploy.tgz -C web/supabase \
--exclude='__tests__' functions migrations deploy
- name: Upload bundle to staging
uses: appleboy/scp-action@v1.0.0
with:
host: ${{ secrets.SUPABASE_VPS_HOST }}
username: ${{ secrets.SUPABASE_VPS_USER }}
key: ${{ secrets.SUPABASE_VPS_SSH_KEY }}
port: ${{ secrets.SUPABASE_VPS_SSH_PORT || 22 }}
source: "supabase-deploy.tgz"
target: /opt/supabase-self-hosted/deploy-staging
- name: Sync edge functions + apply migrations (health-checked)
id: apply
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SUPABASE_VPS_HOST }}
username: ${{ secrets.SUPABASE_VPS_USER }}
key: ${{ secrets.SUPABASE_VPS_SSH_KEY }}
port: ${{ secrets.SUPABASE_VPS_SSH_PORT || 22 }}
command_timeout: 15m
script: |
set -euo pipefail
BASE=/opt/supabase-self-hosted/deploy-staging
STAGING="$BASE/tree"
VOL=/opt/supabase-self-hosted/docker/volumes/functions
DB=supabase-db
echo "── 0. Unpack bundle ──"
rm -rf "$STAGING" && mkdir -p "$STAGING"
tar xzf "$BASE/supabase-deploy.tgz" -C "$STAGING"
echo "── 1. Sync edge functions (additive, no --delete: telegram-* preserved) ──"
# __tests__ are Deno test dirs — never ship them into the runtime volume.
rsync -a --exclude='__tests__' "$STAGING/functions/" "$VOL/"
echo " synced: $(ls "$STAGING/functions" | grep -v __tests__ | tr '\n' ' ')"
echo "── 2. Pre-flight: required edge-runtime secrets ──"
EF_ENV="$(docker exec supabase-edge-functions env 2>/dev/null || true)"
miss=""
for k in SUPABASE_URL SUPABASE_SERVICE_ROLE_KEY PLATFORM_PRIVATE_KEY; do
grep -q "^$k=" <<<"$EF_ENV" || miss="$miss $k"
done
if [ -n "$miss" ]; then
echo "::warning::edge-runtime missing secrets:$miss (fund functions may fail until set)"
fi
echo "── 3. Apply pending migrations (transactional, tracked) ──"
bash "$STAGING/deploy/apply-migrations.sh" "$STAGING/migrations" "$DB"
echo "── 4. Verify the fund-custody guards actually took effect ──"
q() { docker exec -i "$DB" psql -U postgres -tAq -c "$1"; }
fail=0
for fn in "release_escrow_internal" "lock_escrow_internal" "refund_escrow_internal" "request_withdraw"; do
# any signature match; expect NO anon EXECUTE
oid="$(q "SELECT oid FROM pg_proc WHERE proname='$fn' LIMIT 1;")"
if [ -n "$oid" ]; then
priv="$(q "SELECT has_function_privilege('anon', $oid, 'EXECUTE');")"
echo " anon EXECUTE $fn = $priv"
[ "$priv" = "f" ] || { echo "::error::anon still has EXECUTE on $fn"; fail=1; }
fi
done
trg="$(q "SELECT tgname FROM pg_trigger WHERE tgname IN ('trg_freeze_trade_financials','trg_freeze_offer_financials');")"
n_trg="$(printf '%s' "$trg" | grep -c . || true)"
echo " freeze triggers present: ${trg:-<none>} (count=$n_trg)"
[ "$n_trg" = "2" ] || { echo "::error::trade/offer freeze triggers missing (expected 2, got $n_trg)"; fail=1; }
rm -rf "$BASE"
[ "$fail" = "0" ] && echo "✅ Supabase deploy verified" || { echo "❌ post-deploy verification failed"; exit 1; }
- name: Telegram notify
if: always()
env:
BOT_TOKEN: ${{ secrets.PEXSEC_BOT_TOKEN }}
CEO_CHAT_ID: ${{ secrets.TELEGRAM_CEO_CHAT_ID }}
OUTCOME: ${{ steps.apply.outcome }}
run: |
if [ "$OUTCOME" = "success" ]; then
MSG="✅ pwap Supabase: edge functions synced + migrations applied & verified (${{ github.sha }})."
else
MSG="🚨 pwap Supabase deploy FAILED (${{ github.sha }}) — functions/migrations may be partially applied. Check the run."
fi
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d chat_id="${CEO_CHAT_ID}" -d text="$MSG" >/dev/null || true
# ========================================
# SECURITY CHECKS (BLOCKING)
# npm audit (high + critical) + TruffleHog secret scan
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
#
# apply-migrations.sh — idempotent, tracked migration applier for the self-hosted
# Supabase Postgres. Runs ON the Supabase host (invoked by the deploy-supabase CI
# job over SSH). Applies every versioned migration in $MIGRATIONS_DIR that is not
# yet recorded in supabase_migrations.schema_migrations, in ascending version
# order, each inside a single transaction together with its own tracking-row
# insert — so a failed migration rolls back cleanly and is never half-recorded.
#
# Safe to re-run: already-recorded versions are skipped. Non-versioned files
# (e.g. COMBINED_*.sql consolidated snapshots) are ignored.
#
# Usage: apply-migrations.sh <migrations_dir> [db_container]
set -euo pipefail
MIGRATIONS_DIR="${1:?migrations dir required}"
DB_CONTAINER="${2:-supabase-db}"
PSQL=(docker exec -i "$DB_CONTAINER" psql -U postgres -v ON_ERROR_STOP=1 --single-transaction -q)
PSQL_Q=(docker exec -i "$DB_CONTAINER" psql -U postgres -tAq)
echo "▶ apply-migrations: dir=$MIGRATIONS_DIR container=$DB_CONTAINER"
# Ensure the tracking schema/table exists (matches the Supabase CLI layout).
"${PSQL[@]}" >/dev/null <<'SQL'
CREATE SCHEMA IF NOT EXISTS supabase_migrations;
CREATE TABLE IF NOT EXISTS supabase_migrations.schema_migrations (
version text PRIMARY KEY,
statements text[],
name text
);
SQL
# Snapshot the already-applied versions once.
applied="$("${PSQL_Q[@]}" -c "SELECT version FROM supabase_migrations.schema_migrations;")"
is_applied() { grep -qxF "$1" <<<"$applied"; }
pending=0 done=0
# Sort by filename so version order == chronological order (14-digit timestamps
# and zero-padded legacy 0NN prefixes both sort correctly).
for f in $(ls "$MIGRATIONS_DIR"/*.sql 2>/dev/null | sort); do
base="$(basename "$f")"
# Versioned migrations only: <digits>_<name>.sql. Skips COMBINED_*, README, etc.
if [[ ! "$base" =~ ^([0-9]+)_(.+)\.sql$ ]]; then
echo " ↷ skip (not a versioned migration): $base"
continue
fi
version="${BASH_REMATCH[1]}"
name="${BASH_REMATCH[2]}"
if is_applied "$version"; then
continue
fi
pending=$((pending+1))
if [[ "${DRY_RUN:-}" == "1" ]]; then
echo " → [dry-run] WOULD apply $version ($name)"
continue
fi
echo " → applying $version ($name)"
# Migration body + its tracking insert in ONE transaction (--single-transaction):
# if the body errors, ON_ERROR_STOP aborts and the whole tx (including the insert)
# rolls back, so the version is never recorded for a failed apply.
# version is always digits and name always [a-z0-9_] (captured from the filename
# regex above), so direct single-quoting is safe — no injection surface.
{
cat "$f"
printf "\nINSERT INTO supabase_migrations.schema_migrations(version,name) VALUES ('%s','%s');\n" \
"$version" "$name"
} | "${PSQL[@]}"
echo " ✓ applied and recorded $version"
done=$((done+1))
done
if [[ $pending -eq 0 ]]; then
echo "✔ up to date — no pending migrations"
else
echo "✔ applied $done migration(s)"
fi