c160a1d81b
Deploy to route.crispygoat.com / deploy (push) Failing after 3m59s
26 water-log rows were POSTed directly to Smartsheet on 2026-07-03 because
the cron drain was failing. Those rows went in without Entry IDs, so the
next cron drain would have created duplicates (findRowByColumn returns
nothing when Entry ID is empty).
This script:
1. Matches each of the 26 hardcoded rows to a DB entry by
(headgate + irrigator + measurement + unit + logged_at).
2. Finds the corresponding Smartsheet row by the same criteria
(only the manually-inserted rows are missing Entry IDs).
3. PUTs the Entry ID back into the sheet row.
4. Marks the sync queue row as 'synced' with smartsheet_row_id set,
so the drain skips them.
5. Writes a sync log entry noting the manual backfill.
Idempotent: re-runs are no-ops. Wired into deploy.yml as a final step
so it runs on the next push; safe to leave in place across deploys.
Local invocation:
npm run smartsheet:backfill-entry-ids
234 lines
13 KiB
YAML
234 lines
13 KiB
YAML
name: Deploy to route.crispygoat.com
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- main
|
|
workflow_dispatch:
|
|
|
|
jobs:
|
|
deploy:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: "22"
|
|
|
|
- name: Install dependencies
|
|
run: npm install
|
|
|
|
- name: Run migrations
|
|
env:
|
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
run: |
|
|
set -e
|
|
node scripts/preflight-check.js
|
|
npm run migrate:one
|
|
node scripts/postflight-check.js
|
|
|
|
- name: Build
|
|
env:
|
|
NODE_ENV: production
|
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}
|
|
NEON_AUTH_BASE_URL: ${{ secrets.NEON_AUTH_BASE_URL }}
|
|
NEON_AUTH_COOKIE_SECRET: ${{ secrets.NEON_AUTH_COOKIE_SECRET }}
|
|
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
|
AUTH_URL: ${{ secrets.AUTH_URL }}
|
|
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }}
|
|
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
|
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
|
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
|
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
|
|
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
|
STRIPE_PRICE_STARTER: ${{ secrets.STRIPE_PRICE_STARTER }}
|
|
STRIPE_PRICE_FARM: ${{ secrets.STRIPE_PRICE_FARM }}
|
|
STRIPE_PRICE_ENTERPRISE: ${{ secrets.STRIPE_PRICE_ENTERPRISE }}
|
|
STRIPE_PRICE_HARVEST_REACH: ${{ secrets.STRIPE_PRICE_HARVEST_REACH }}
|
|
STRIPE_PRICE_WHOLESALE_PORTAL: ${{ secrets.STRIPE_PRICE_WHOLESALE_PORTAL }}
|
|
STRIPE_PRICE_WATER_LOG: ${{ secrets.STRIPE_PRICE_WATER_LOG }}
|
|
STRIPE_PRICE_AI_TOOLS: ${{ secrets.STRIPE_PRICE_AI_TOOLS }}
|
|
STRIPE_PRICE_SQUARE_SYNC: ${{ secrets.STRIPE_PRICE_SQUARE_SYNC }}
|
|
STRIPE_PRICE_SMS_CAMPAIGNS: ${{ secrets.STRIPE_PRICE_SMS_CAMPAIGNS }}
|
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
|
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
|
MINIO_ENDPOINT: ${{ secrets.MINIO_ENDPOINT }}
|
|
MINIO_REGION: ${{ secrets.MINIO_REGION }}
|
|
MINIO_ACCESS_KEY: ${{ secrets.MINIO_ACCESS_KEY }}
|
|
MINIO_SECRET_KEY: ${{ secrets.MINIO_SECRET_KEY }}
|
|
MINIO_PUBLIC_URL: ${{ secrets.MINIO_PUBLIC_URL }}
|
|
MINIO_BUCKET_PRODUCTS: ${{ secrets.MINIO_BUCKET_PRODUCTS }}
|
|
MINIO_BUCKET_BRAND_LOGOS: ${{ secrets.MINIO_BUCKET_BRAND_LOGOS }}
|
|
MINIO_BUCKET_WATER_LOGS: ${{ secrets.MINIO_BUCKET_WATER_LOGS }}
|
|
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
|
SMARTSHEET_TOKEN_ENC_KEY: ${{ secrets.SMARTSHEET_TOKEN_ENC_KEY }}
|
|
SMARTSHEET_CRON_SECRET: ${{ secrets.SMARTSHEET_CRON_SECRET }}
|
|
run: npm run build
|
|
|
|
- name: Deploy
|
|
env:
|
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}
|
|
NEON_AUTH_BASE_URL: ${{ secrets.NEON_AUTH_BASE_URL }}
|
|
NEON_AUTH_COOKIE_SECRET: ${{ secrets.NEON_AUTH_COOKIE_SECRET }}
|
|
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
|
AUTH_URL: ${{ secrets.AUTH_URL }}
|
|
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }}
|
|
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
|
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
|
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
|
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
|
|
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
|
STRIPE_PRICE_STARTER: ${{ secrets.STRIPE_PRICE_STARTER }}
|
|
STRIPE_PRICE_FARM: ${{ secrets.STRIPE_PRICE_FARM }}
|
|
STRIPE_PRICE_ENTERPRISE: ${{ secrets.STRIPE_PRICE_ENTERPRISE }}
|
|
STRIPE_PRICE_HARVEST_REACH: ${{ secrets.STRIPE_PRICE_HARVEST_REACH }}
|
|
STRIPE_PRICE_WHOLESALE_PORTAL: ${{ secrets.STRIPE_PRICE_WHOLESALE_PORTAL }}
|
|
STRIPE_PRICE_WATER_LOG: ${{ secrets.STRIPE_PRICE_WATER_LOG }}
|
|
STRIPE_PRICE_AI_TOOLS: ${{ secrets.STRIPE_PRICE_AI_TOOLS }}
|
|
STRIPE_PRICE_SQUARE_SYNC: ${{ secrets.STRIPE_PRICE_SQUARE_SYNC }}
|
|
STRIPE_PRICE_SMS_CAMPAIGNS: ${{ secrets.STRIPE_PRICE_SMS_CAMPAIGNS }}
|
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
|
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
|
MINIO_ENDPOINT: ${{ secrets.MINIO_ENDPOINT }}
|
|
MINIO_REGION: ${{ secrets.MINIO_REGION }}
|
|
MINIO_ACCESS_KEY: ${{ secrets.MINIO_ACCESS_KEY }}
|
|
MINIO_SECRET_KEY: ${{ secrets.MINIO_SECRET_KEY }}
|
|
MINIO_PUBLIC_URL: ${{ secrets.MINIO_PUBLIC_URL }}
|
|
MINIO_BUCKET_PRODUCTS: ${{ secrets.MINIO_BUCKET_PRODUCTS }}
|
|
MINIO_BUCKET_BRAND_LOGOS: ${{ secrets.MINIO_BUCKET_BRAND_LOGOS }}
|
|
MINIO_BUCKET_WATER_LOGS: ${{ secrets.MINIO_BUCKET_WATER_LOGS }}
|
|
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
|
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
|
SMARTSHEET_TOKEN_ENC_KEY: ${{ secrets.SMARTSHEET_TOKEN_ENC_KEY }}
|
|
SMARTSHEET_CRON_SECRET: ${{ secrets.SMARTSHEET_CRON_SECRET }}
|
|
SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }}
|
|
run: |
|
|
set -e
|
|
APP_DIR=/home/tyler/route-commerce
|
|
|
|
# Setup SSH key - write raw (no printf which can corrupt multi-line keys)
|
|
mkdir -p ~/.ssh
|
|
echo "$SERVER_SSH_KEY" > ~/.ssh/id_ed25519
|
|
chmod 600 ~/.ssh/id_ed25519
|
|
|
|
# Verify key was written correctly
|
|
if ! grep -q "PRIVATE KEY" ~/.ssh/id_ed25519; then
|
|
echo "ERROR: SSH key not found or malformed. Check SERVER_SSH_KEY secret."
|
|
cat ~/.ssh/id_ed25519 || echo "File is empty"
|
|
exit 1
|
|
fi
|
|
|
|
ssh-keyscan -H route.crispygoat.com >> ~/.ssh/known_hosts 2>/dev/null || true
|
|
|
|
# Test SSH connection with verbose output for debugging
|
|
echo "Testing SSH connection..."
|
|
ssh -o ConnectTimeout=15 -o StrictHostKeyChecking=no -o LogLevel=VERBOSE tyler@route.crispygoat.com "echo 'SSH OK' && hostname" 2>&1 || { echo "SSH FAILED"; exit 1; }
|
|
|
|
# Create app dir on server
|
|
ssh tyler@route.crispygoat.com "mkdir -p $APP_DIR/.next $APP_DIR/public"
|
|
|
|
# Write production env file
|
|
ENV_FILE=$(mktemp)
|
|
{
|
|
printf 'DATABASE_URL=%s\n' "$DATABASE_URL"
|
|
printf 'NEXT_PUBLIC_SITE_URL=%s\n' "$NEXT_PUBLIC_SITE_URL"
|
|
printf 'NEON_AUTH_BASE_URL=%s\n' "$NEON_AUTH_BASE_URL"
|
|
printf 'NEON_AUTH_COOKIE_SECRET=%s\n' "$NEON_AUTH_COOKIE_SECRET"
|
|
printf 'AUTH_SECRET=%s\n' "$AUTH_SECRET"
|
|
printf 'AUTH_URL=%s\n' "$AUTH_URL"
|
|
printf 'NEXT_PUBLIC_AUTH_URL=%s\n' "$NEXT_PUBLIC_AUTH_URL"
|
|
printf 'GOOGLE_CLIENT_ID=%s\n' "$GOOGLE_CLIENT_ID"
|
|
printf 'GOOGLE_CLIENT_SECRET=%s\n' "$GOOGLE_CLIENT_SECRET"
|
|
printf 'ALLOW_DEV_LOGIN=%s\n' "$ALLOW_DEV_LOGIN"
|
|
printf 'ADMIN_ALLOWED_EMAILS=%s\n' "$ADMIN_ALLOWED_EMAILS"
|
|
printf 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=%s\n' "$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY"
|
|
printf 'STRIPE_SECRET_KEY=%s\n' "$STRIPE_SECRET_KEY"
|
|
printf 'STRIPE_WEBHOOK_SECRET=%s\n' "$STRIPE_WEBHOOK_SECRET"
|
|
printf 'STRIPE_PRICE_STARTER=%s\n' "$STRIPE_PRICE_STARTER"
|
|
printf 'STRIPE_PRICE_FARM=%s\n' "$STRIPE_PRICE_FARM"
|
|
printf 'STRIPE_PRICE_ENTERPRISE=%s\n' "$STRIPE_PRICE_ENTERPRISE"
|
|
printf 'STRIPE_PRICE_HARVEST_REACH=%s\n' "$STRIPE_PRICE_HARVEST_REACH"
|
|
printf 'STRIPE_PRICE_WHOLESALE_PORTAL=%s\n' "$STRIPE_PRICE_WHOLESALE_PORTAL"
|
|
printf 'STRIPE_PRICE_WATER_LOG=%s\n' "$STRIPE_PRICE_WATER_LOG"
|
|
printf 'STRIPE_PRICE_AI_TOOLS=%s\n' "$STRIPE_PRICE_AI_TOOLS"
|
|
printf 'STRIPE_PRICE_SQUARE_SYNC=%s\n' "$STRIPE_PRICE_SQUARE_SYNC"
|
|
printf 'STRIPE_PRICE_SMS_CAMPAIGNS=%s\n' "$STRIPE_PRICE_SMS_CAMPAIGNS"
|
|
printf 'RESEND_API_KEY=%s\n' "$RESEND_API_KEY"
|
|
printf 'FROM_EMAIL=%s\n' "$FROM_EMAIL"
|
|
printf 'MINIO_ENDPOINT=%s\n' "$MINIO_ENDPOINT"
|
|
printf 'MINIO_REGION=%s\n' "$MINIO_REGION"
|
|
printf 'MINIO_ACCESS_KEY=%s\n' "$MINIO_ACCESS_KEY"
|
|
printf 'MINIO_SECRET_KEY=%s\n' "$MINIO_SECRET_KEY"
|
|
printf 'MINIO_PUBLIC_URL=%s\n' "$MINIO_PUBLIC_URL"
|
|
printf 'MINIO_BUCKET_PRODUCTS=%s\n' "$MINIO_BUCKET_PRODUCTS"
|
|
printf 'MINIO_BUCKET_BRAND_LOGOS=%s\n' "$MINIO_BUCKET_BRAND_LOGOS"
|
|
printf 'MINIO_BUCKET_WATER_LOGS=%s\n' "$MINIO_BUCKET_WATER_LOGS"
|
|
printf 'MINIMAX_API_KEY=%s\n' "$MINIMAX_API_KEY"
|
|
printf 'MINIMAX_BASE_URL=%s\n' "$MINIMAX_BASE_URL"
|
|
printf 'CRON_SECRET=%s\n' "$CRON_SECRET"
|
|
printf 'SMARTSHEET_TOKEN_ENC_KEY=%s\n' "$SMARTSHEET_TOKEN_ENC_KEY"
|
|
printf 'SMARTSHEET_CRON_SECRET=%s\n' "$SMARTSHEET_CRON_SECRET"
|
|
} > "$ENV_FILE"
|
|
|
|
# Upload env file and sync build output
|
|
echo "Uploading env file..."
|
|
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no "$ENV_FILE" tyler@route.crispygoat.com:$APP_DIR/.env
|
|
|
|
echo "Copying .next/..."
|
|
scp -o ConnectTimeout=30 -o StrictHostKeyChecking=no -r .next tyler@route.crispygoat.com:$APP_DIR/
|
|
echo "Copying public/..."
|
|
scp -o ConnectTimeout=30 -o StrictHostKeyChecking=no -r public tyler@route.crispygoat.com:$APP_DIR/
|
|
echo "Copying package.json..."
|
|
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no package.json tyler@route.crispygoat.com:$APP_DIR/
|
|
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no next.config.ts tyler@route.crispygoat.com:$APP_DIR/ 2>/dev/null || true
|
|
|
|
# Ship the migration runner + SQL so the server has a recovery path (scripts/ and db/migrations/ were previously omitted from the artifact).
|
|
# This allows `node scripts/migrate.js` (after sourcing .env.production) to work directly on the target if needed for bootstrap or emergencies.
|
|
# See docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md
|
|
echo "Ensuring migration directories on server and copying runner + SQL..."
|
|
ssh -o ConnectTimeout=15 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "mkdir -p $APP_DIR/scripts $APP_DIR/db"
|
|
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/migrate.js tyler@route.crispygoat.com:$APP_DIR/scripts/migrate.js 2>/dev/null || true
|
|
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r db/migrations tyler@route.crispygoat.com:$APP_DIR/db/ 2>/dev/null || true
|
|
|
|
# Install deps and restart on server
|
|
echo "Installing deps and restarting PM2..."
|
|
# IMPORTANT: --update-env so PM2 re-reads .env. Without it PM2 keeps
|
|
# the original env vars cached at first start and silently ignores
|
|
# newly added ones (like SMARTSHEET_*).
|
|
ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && pm2 restart route-commerce --update-env || pm2 start npm --name route-commerce -- start -- -p 3100 && pm2 save && sleep 4 && curl -f -s http://localhost:3100/api/health/db-schema || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }"
|
|
|
|
echo "Deployed successfully"
|
|
|
|
# One-shot: fix the 2026-07-03 manual Smartsheet backfill that
|
|
# bypassed the sync queue. This step is idempotent — it scans
|
|
# for queue rows that are pending/failed with no smartsheet_row_id
|
|
# and matches them to the 26 rows Tyler manually POSTed into the
|
|
# sheet. It then PUTs the Entry ID back into each sheet row and
|
|
# marks the queue rows as `synced` so the cron drain won't try to
|
|
# re-create them. Safe to leave in place across deploys (re-runs
|
|
# are no-ops once everything is synced). Remove this step once
|
|
# the data has been verified in Smartsheet.
|
|
- name: Smartsheet backfill entry-id fix
|
|
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
|
env:
|
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
SMARTSHEET_TOKEN_ENC_KEY: ${{ secrets.SMARTSHEET_TOKEN_ENC_KEY }}
|
|
run: |
|
|
set -e
|
|
APP_DIR=/home/tyler/route-commerce
|
|
ssh -o ConnectTimeout=15 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "
|
|
cd $APP_DIR && \
|
|
DATABASE_URL='$DATABASE_URL' SMARTSHEET_TOKEN_ENC_KEY='$SMARTSHEET_TOKEN_ENC_KEY' \
|
|
node scripts/smartsheet-backfill-entry-ids.mjs
|
|
" |