From 3920c491ed1f6898bc71ea4923330c146f3fec14 Mon Sep 17 00:00:00 2001 From: Daniel Gorgonha Date: Wed, 22 Apr 2026 15:34:46 -0300 Subject: [PATCH] feat(scripts): add deploy automation and preflight check --- scripts/README.md | 51 ++++++++++++++++ scripts/deploy-bff.sh | 102 +++++++++++++++++++++++++++++++ scripts/deploy-contracts.sh | 99 ++++++++++++++++++++++++++++++ scripts/deploy-discourse.sh | 68 +++++++++++++++++++++ scripts/preflight.sh | 117 ++++++++++++++++++++++++++++++++++++ scripts/smoke-test.sh | 100 ++++++++++++++++++++++++++++++ 6 files changed, 537 insertions(+) create mode 100644 scripts/README.md create mode 100755 scripts/deploy-bff.sh create mode 100755 scripts/deploy-contracts.sh create mode 100755 scripts/deploy-discourse.sh create mode 100755 scripts/preflight.sh create mode 100755 scripts/smoke-test.sh diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..871aec1 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,51 @@ +# Deploy scripts + +Helpers for deploying and verifying the Polis Protocol stack. Each script is idempotent and prints the command it's about to run before running it, so nothing surprising happens. + +## Prerequisites + +Before running any deploy script: + +1. Complete account setup in [`docs/ENVIRONMENT.md`](../docs/ENVIRONMENT.md). +2. Install CLI tools: `terraform`, `fly`, `doppler`, `gh`, `forge`, `jq`, `curl`. +3. Run `./scripts/preflight.sh` — validates your local environment. + +## Order of operations + +Deploys are layered; later ones depend on earlier ones. Target order for a fresh deploy: + +``` +preflight.sh → validates tools & credentials +deploy-discourse.sh → Hetzner VPS + Discourse container (~25 min) +deploy-contracts.sh → PolisArchive.sol on Base Sepolia (~3 min) +deploy-bff.sh → BFF on Fly.io (needs Discourse done first) +smoke-test.sh → curl health checks end-to-end +``` + +## Scripts + +| Script | Purpose | Idempotent | Duration | +|--------|---------|------------|----------| +| `preflight.sh` | Check tools + credentials are set | ✓ | ~5s | +| `deploy-discourse.sh` | Provision Hetzner + initial Discourse config | partial (terraform is, SSH step is not) | ~25 min first time | +| `deploy-contracts.sh` | Forge deploy PolisArchive to Base (Sepolia or mainnet) | ✗ (each run = new deployment) | ~3 min | +| `deploy-bff.sh` | Fly deploy BFF with secrets from Doppler | ✓ | ~5 min | +| `smoke-test.sh` | curl health/GraphQL/contract endpoints | ✓ | ~30s | + +## Environment + +Scripts read from: + +1. **Shell env** — exported vars (`export HCLOUD_TOKEN=...`) +2. **Doppler** — if `doppler` is configured, secrets are pulled at runtime +3. **`.env.local`** — at repo root, gitignored + +Preferred flow: **Doppler** for prod secrets, `.env.local` for dev overrides, shell env for one-off experiments. + +## Rollback + +Each deploy script prints the rollback command on exit. For quick reference: + +- **Discourse:** `terraform -chdir=infra/terraform/hetzner destroy` (destroys VPS, keeps backups) +- **Contracts:** immutable — deploy a new version and call `transferAdmin` if needed +- **BFF:** `fly releases --app polis-bff-ipe list` → `fly deploy --image ` diff --git a/scripts/deploy-bff.sh b/scripts/deploy-bff.sh new file mode 100755 index 0000000..ad2a657 --- /dev/null +++ b/scripts/deploy-bff.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# deploy-bff.sh — deploy the BFF (Fastify + GraphQL) to Fly.io. +# Pulls secrets from Doppler and pushes to Fly. +# Idempotent: can be re-run safely to update secrets or deploy a new build. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BFF_DIR="$REPO_ROOT/packages/bff" +FLY_APP="${FLY_APP:-polis-bff-ipe}" +FLY_REGION="${FLY_REGION:-gru}" + +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +DIM='\033[2m' +RESET='\033[0m' + +log() { echo -e "${YELLOW}▸${RESET} $1"; } +ok() { echo -e "${GREEN}✓${RESET} $1"; } +fail() { echo -e "${RED}✗${RESET} $1" >&2; } + +# ── 1. Validate tooling ──────────────────────────────────────────── +command -v fly >/dev/null 2>&1 || { fail "fly CLI not installed"; exit 1; } +command -v doppler >/dev/null 2>&1 || { fail "doppler CLI not installed"; exit 1; } +fly auth whoami >/dev/null 2>&1 || { fail "fly not logged in (run: fly auth login)"; exit 1; } +doppler me >/dev/null 2>&1 || { fail "doppler not logged in (run: doppler login)"; exit 1; } + +# ── 2. Build locally first (catch issues before deploy) ──────────── +log "building BFF locally (catch errors before deploy)" +cd "$REPO_ROOT" +pnpm --filter '@polisprotocol/bff' build + +# ── 3. Ensure Fly app exists ──────────────────────────────────────── +if ! fly status --app "$FLY_APP" >/dev/null 2>&1; then + log "creating Fly app $FLY_APP in region $FLY_REGION" + cd "$BFF_DIR" + fly launch --no-deploy --name "$FLY_APP" --region "$FLY_REGION" --copy-config --yes +else + ok "Fly app $FLY_APP exists" +fi + +# ── 4. Sync secrets from Doppler to Fly ──────────────────────────── +log "syncing secrets from Doppler to Fly" +REQUIRED_SECRETS=( + DATABASE_URL + REDIS_URL + DISCOURSE_URL + DISCOURSE_API_KEY + DISCOURSE_API_USERNAME + DISCOURSE_SSO_SECRET + SESSION_SECRET + SIWE_DOMAIN + CORS_ORIGIN +) + +FLY_SECRET_ARGS=() +MISSING=() +for key in "${REQUIRED_SECRETS[@]}"; do + val=$(doppler secrets get "$key" --plain 2>/dev/null || true) + if [ -z "$val" ]; then + MISSING+=("$key") + else + FLY_SECRET_ARGS+=("$key=$val") + fi +done + +if [ ${#MISSING[@]} -gt 0 ]; then + fail "missing secrets in Doppler: ${MISSING[*]}" + echo "Set them with:" + for key in "${MISSING[@]}"; do + echo " doppler secrets set $key=" + done + exit 1 +fi + +cd "$BFF_DIR" +fly secrets set "${FLY_SECRET_ARGS[@]}" --app "$FLY_APP" --stage +ok "secrets staged" + +# ── 5. Deploy ─────────────────────────────────────────────────────── +log "fly deploy" +fly deploy --app "$FLY_APP" --remote-only --ha=false + +# ── 6. Smoke test ─────────────────────────────────────────────────── +log "smoke test /health endpoint" +URL="https://$FLY_APP.fly.dev/health" +for i in 1 2 3 4 5; do + if curl -sf "$URL" >/dev/null; then + ok "$URL returned 200" + break + fi + echo " attempt $i/5 failed, retrying in 3s..." + sleep 3 +done + +echo +echo "Deployed: https://$FLY_APP.fly.dev" +echo "GraphQL: https://$FLY_APP.fly.dev/graphql" +echo +echo -e "${DIM}Rollback: fly releases --app $FLY_APP list${RESET}" +echo -e "${DIM} fly deploy --app $FLY_APP --image ${RESET}" diff --git a/scripts/deploy-contracts.sh b/scripts/deploy-contracts.sh new file mode 100755 index 0000000..cef4f85 --- /dev/null +++ b/scripts/deploy-contracts.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# deploy-contracts.sh — deploy PolisArchive.sol to Base Sepolia or mainnet. +# Not idempotent — each run creates a new deployment. For upgrades to an +# existing contract, use transferAdmin or deploy a new version and migrate. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CONTRACTS_DIR="$REPO_ROOT/packages/contracts" + +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +DIM='\033[2m' +RESET='\033[0m' + +log() { echo -e "${YELLOW}▸${RESET} $1"; } +ok() { echo -e "${GREEN}✓${RESET} $1"; } +fail() { echo -e "${RED}✗${RESET} $1" >&2; } + +# ── 1. Pick network ───────────────────────────────────────────────── +NETWORK="${1:-sepolia}" +case "$NETWORK" in + sepolia) + RPC_VAR="BASE_SEPOLIA_RPC_URL" + CHAIN_ID=84532 + EXPLORER="https://sepolia.basescan.org" + ;; + mainnet) + RPC_VAR="BASE_RPC_URL" + CHAIN_ID=8453 + EXPLORER="https://basescan.org" + ;; + *) + fail "Usage: $0 [sepolia|mainnet]" + exit 1 + ;; +esac + +log "target network: Base $NETWORK (chainId=$CHAIN_ID)" + +# ── 2. Load env ───────────────────────────────────────────────────── +# Precedence: Doppler > shell env > .env file in contracts dir +if [ -f "$CONTRACTS_DIR/.env" ]; then + # shellcheck disable=SC1091 + set -a; source "$CONTRACTS_DIR/.env"; set +a +fi + +: "${PRIVATE_KEY:?PRIVATE_KEY not set — add to $CONTRACTS_DIR/.env or export in shell}" +: "${!RPC_VAR:?$RPC_VAR not set}" +: "${BASESCAN_API_KEY:?BASESCAN_API_KEY not set (needed for --verify)}" + +RPC_URL="${!RPC_VAR}" + +# ── 3. Final confirmation for mainnet ────────────────────────────── +if [ "$NETWORK" = "mainnet" ]; then + echo + echo "⚠ You are deploying to Base MAINNET. This costs real ETH." + read -p "Type 'DEPLOY TO MAINNET' to proceed: " -r reply + if [ "$reply" != "DEPLOY TO MAINNET" ]; then + echo "Aborted." + exit 0 + fi +fi + +# ── 4. Build + deploy ─────────────────────────────────────────────── +log "forge build" +cd "$CONTRACTS_DIR" +forge build --sizes + +log "forge script DeployPolisArchive.s.sol" +forge script script/DeployPolisArchive.s.sol \ + --rpc-url "$RPC_URL" \ + --broadcast \ + --verify \ + --etherscan-api-key "$BASESCAN_API_KEY" \ + -vvv + +# ── 5. Extract deployed address ───────────────────────────────────── +BROADCAST_FILE="$CONTRACTS_DIR/broadcast/DeployPolisArchive.s.sol/$CHAIN_ID/run-latest.json" +if [ -f "$BROADCAST_FILE" ]; then + ADDR=$(jq -r '.transactions[] | select(.contractName=="PolisArchive") | .contractAddress' "$BROADCAST_FILE" | head -1) + ok "deployed: $ADDR" + echo + echo "Explorer: $EXPLORER/address/$ADDR" + echo + echo "Save to Doppler:" + if [ "$NETWORK" = "mainnet" ]; then + echo " doppler secrets set ARCHIVE_CONTRACT=$ADDR" + else + echo " doppler secrets set ARCHIVE_CONTRACT_SEPOLIA=$ADDR" + fi +else + fail "broadcast file not found at $BROADCAST_FILE" + exit 1 +fi + +echo +echo -e "${DIM}Next: register your city with ./scripts/deploy-contracts.sh register $ADDR${RESET}" diff --git a/scripts/deploy-discourse.sh b/scripts/deploy-discourse.sh new file mode 100755 index 0000000..86c314f --- /dev/null +++ b/scripts/deploy-discourse.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# deploy-discourse.sh — provision Hetzner VPS and bootstrap Discourse. +# First run takes ~25 min (terraform + cloud-init + launcher rebuild). +# Subsequent runs are fast — terraform is idempotent, SSH step is a no-op if already set up. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TF_DIR="$REPO_ROOT/infra/terraform/hetzner" + +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +DIM='\033[2m' +RESET='\033[0m' + +log() { echo -e "${YELLOW}▸${RESET} $1"; } +ok() { echo -e "${GREEN}✓${RESET} $1"; } +fail() { echo -e "${RED}✗${RESET} $1" >&2; } + +# ── 1. Validate inputs ────────────────────────────────────────────── +if [ ! -f "$TF_DIR/terraform.tfvars" ]; then + fail "terraform.tfvars not found at $TF_DIR/terraform.tfvars" + echo "Copy the example and fill it in:" + echo " cp $TF_DIR/terraform.tfvars.example $TF_DIR/terraform.tfvars" + echo " \$EDITOR $TF_DIR/terraform.tfvars" + exit 1 +fi + +# ── 2. Terraform apply ────────────────────────────────────────────── +log "terraform init" +terraform -chdir="$TF_DIR" init -upgrade -input=false + +log "terraform plan" +terraform -chdir="$TF_DIR" plan -out=tfplan -input=false + +echo +read -p "Apply this plan? (yes/no) " -r reply +if [ "$reply" != "yes" ]; then + echo "Aborted." + exit 0 +fi + +log "terraform apply" +terraform -chdir="$TF_DIR" apply -input=false tfplan +rm -f "$TF_DIR/tfplan" + +SERVER_IP=$(terraform -chdir="$TF_DIR" output -raw server_ip) +ok "server provisioned: $SERVER_IP" + +# ── 3. Post-install Discourse ─────────────────────────────────────── +echo +log "Next step (manual): SSH in and run the Discourse container." +echo +echo " ssh root@$SERVER_IP" +echo " cd /var/discourse" +echo " ./launcher rebuild app # takes ~20 min the first time" +echo +echo "After the rebuild completes, visit https://forum./ and:" +echo " 1. Create the admin account" +echo " 2. Admin → API → generate API key (user: system)" +echo " 3. Admin → Customize → Categories → create your polis.config categories" +echo " 4. Admin → Settings → Login → enable DiscourseConnect, set URL + SSO secret" +echo +echo "Store the API key in Doppler:" +echo " doppler secrets set DISCOURSE_API_KEY=" +echo +echo -e "${DIM}Rollback: terraform -chdir=$TF_DIR destroy${RESET}" diff --git a/scripts/preflight.sh b/scripts/preflight.sh new file mode 100755 index 0000000..1542503 --- /dev/null +++ b/scripts/preflight.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# preflight.sh — verify local environment is ready for deploy scripts. +# Exits non-zero if anything is missing, with actionable hints. + +set -u + +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +DIM='\033[2m' +RESET='\033[0m' + +FAIL_COUNT=0 + +ok() { echo -e " ${GREEN}✓${RESET} $1"; } +fail() { echo -e " ${RED}✗${RESET} $1"; FAIL_COUNT=$((FAIL_COUNT + 1)); } +warn() { echo -e " ${YELLOW}!${RESET} $1"; } +hint() { echo -e " ${DIM}→ $1${RESET}"; } + +section() { echo; echo -e "${YELLOW}━ $1${RESET}"; } + +check_cli() { + local name=$1 + local hint_cmd=${2:-""} + if command -v "$name" >/dev/null 2>&1; then + local version + version=$("$name" --version 2>/dev/null | head -1 || echo "unknown") + ok "$name ($version)" + else + fail "$name not found" + [ -n "$hint_cmd" ] && hint "install: $hint_cmd" + fi +} + +check_env() { + local name=$1 + local doppler_fallback=${2:-false} + local value="${!name:-}" + if [ -n "$value" ]; then + ok "\$$name set (${#value} chars)" + elif $doppler_fallback && command -v doppler >/dev/null 2>&1 && doppler secrets get "$name" --plain >/dev/null 2>&1; then + ok "\$$name available via Doppler" + else + fail "\$$name not set" + fi +} + +# ────────────────────────────────────────── +echo -e "${YELLOW}Polis Protocol preflight check${RESET}" +echo -e "${DIM}Validates that your environment is ready for deploy scripts.${RESET}" + +section "CLI tools" +check_cli "node" "nvm install 22" +check_cli "pnpm" "corepack enable && corepack prepare pnpm@10.28.1 --activate" +check_cli "gh" "sudo apt install gh" +check_cli "terraform" "sudo apt install terraform" +check_cli "fly" "curl -L https://fly.io/install.sh | sh" +check_cli "doppler" "curl -Ls https://cli.doppler.com/install.sh | sudo sh" +check_cli "forge" "curl -L https://foundry.paradigm.xyz | bash && foundryup" +check_cli "jq" "sudo apt install jq" +check_cli "curl" "" + +section "GitHub auth" +if gh auth status >/dev/null 2>&1; then + user=$(gh api user --jq .login 2>/dev/null) + ok "gh logged in as $user" + if gh api user/orgs --jq '.[].login' 2>/dev/null | grep -qx "polis-protocol"; then + ok "access to polis-protocol org" + else + warn "polis-protocol org not in your gh org list (may be permission issue)" + fi +else + fail "gh not authenticated" + hint "run: gh auth login" +fi + +section "Doppler" +if command -v doppler >/dev/null 2>&1; then + if doppler me >/dev/null 2>&1; then + ok "doppler authenticated" + if doppler configure get project --plain 2>/dev/null | grep -q polis-protocol; then + ok "doppler project = polis-protocol" + else + warn "doppler project is not polis-protocol (cd into repo and run: doppler setup)" + fi + else + warn "doppler installed but not logged in" + hint "run: doppler login" + fi +fi + +section "Provider tokens (shell env or Doppler)" +check_env "HCLOUD_TOKEN" true +check_env "FLY_API_TOKEN" true +check_env "ALCHEMY_API_KEY" true + +section "Deploy-time secrets (at least one of each)" +check_env "DATABASE_URL" true +check_env "DISCOURSE_URL" true +check_env "DISCOURSE_API_KEY" true + +section "Optional (prod deploys)" +command -v doppler >/dev/null 2>&1 && doppler secrets get SENTRY_DSN --plain >/dev/null 2>&1 && ok "SENTRY_DSN (Doppler)" || warn "SENTRY_DSN not set (optional)" + +echo +if [ $FAIL_COUNT -eq 0 ]; then + echo -e "${GREEN}All preflight checks passed.${RESET}" + echo "You can run the deploy scripts in order:" + echo " 1. ./scripts/deploy-discourse.sh" + echo " 2. ./scripts/deploy-contracts.sh" + echo " 3. ./scripts/deploy-bff.sh" + echo " 4. ./scripts/smoke-test.sh" + exit 0 +else + echo -e "${RED}${FAIL_COUNT} check(s) failed.${RESET} Fix the issues above before deploying." + exit 1 +fi diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 0000000..b1f2297 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# smoke-test.sh — verify the deployed stack responds correctly. +# Run after any deploy. Exits non-zero if any check fails. + +set -u + +DISCOURSE_URL="${DISCOURSE_URL:-https://forum.ipehub.xyz}" +BFF_URL="${BFF_URL:-https://api.ipehub.xyz}" +ARCHIVE_CONTRACT="${ARCHIVE_CONTRACT:-}" +BASE_RPC_URL="${BASE_RPC_URL:-https://base-mainnet.public.blastapi.io}" + +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +DIM='\033[2m' +RESET='\033[0m' + +section() { echo; echo -e "${YELLOW}━ $1${RESET}"; } +ok() { echo -e " ${GREEN}✓${RESET} $1"; } +fail() { echo -e " ${RED}✗${RESET} $1"; FAIL_COUNT=$((FAIL_COUNT + 1)); } + +FAIL_COUNT=0 + +check_http() { + local name=$1 url=$2 expected_status=${3:-200} + local status + status=$(curl -s -o /dev/null -w "%{http_code}" "$url" --max-time 10 || echo "000") + if [ "$status" = "$expected_status" ]; then + ok "$name — $url → $status" + else + fail "$name — $url → $status (expected $expected_status)" + fi +} + +check_json() { + local name=$1 url=$2 jq_expr=$3 expected=$4 + local actual + actual=$(curl -s --max-time 10 "$url" | jq -r "$jq_expr" 2>/dev/null || echo "") + if [ "$actual" = "$expected" ]; then + ok "$name — $jq_expr = $expected" + else + fail "$name — expected $expected, got '$actual'" + fi +} + +# ── Discourse ────────────────────────────────────────────────────── +section "Discourse ($DISCOURSE_URL)" +check_http "homepage" "$DISCOURSE_URL/" 200 +check_http "categories" "$DISCOURSE_URL/categories.json" 200 +check_http "admin denied" "$DISCOURSE_URL/admin/" 302 + +# ── BFF ──────────────────────────────────────────────────────────── +section "BFF ($BFF_URL)" +check_http "health" "$BFF_URL/health" 200 +check_http "graphql" "$BFF_URL/graphql" 200 + +# GraphQL query +if command -v jq >/dev/null 2>&1; then + response=$(curl -s "$BFF_URL/graphql" \ + -H "Content-Type: application/json" \ + -d '{"query":"{ categories { id name slug } }"}' \ + --max-time 15 || echo "{}") + count=$(echo "$response" | jq -r '.data.categories | length' 2>/dev/null || echo "0") + if [ "$count" -gt 0 ] 2>/dev/null; then + ok "GraphQL categories query returned $count categories" + else + fail "GraphQL categories query returned 0 or errored — response: $(echo "$response" | head -c 200)" + fi +fi + +# ── Contracts ────────────────────────────────────────────────────── +if [ -n "$ARCHIVE_CONTRACT" ] && command -v cast >/dev/null 2>&1; then + section "PolisArchive ($ARCHIVE_CONTRACT on Base)" + owner=$(cast call "$ARCHIVE_CONTRACT" "owner()(address)" --rpc-url "$BASE_RPC_URL" 2>/dev/null || echo "") + if [[ "$owner" =~ ^0x ]]; then + ok "owner() = $owner" + else + fail "owner() call failed" + fi + + # Try getCity(1) — may revert if no city registered yet + if cast call "$ARCHIVE_CONTRACT" "getCity(uint256)(string,address)" 1 --rpc-url "$BASE_RPC_URL" >/dev/null 2>&1; then + ok "getCity(1) returned data" + else + echo -e " ${DIM}○ getCity(1) reverted (probably no city registered yet — run registerCity)${RESET}" + fi +else + section "PolisArchive — skipped" + echo -e " ${DIM}(set ARCHIVE_CONTRACT and install foundry to check)${RESET}" +fi + +# ── Summary ──────────────────────────────────────────────────────── +echo +if [ $FAIL_COUNT -eq 0 ]; then + echo -e "${GREEN}All smoke tests passed.${RESET}" + exit 0 +else + echo -e "${RED}${FAIL_COUNT} check(s) failed.${RESET}" + exit 1 +fi