Skip to content

frontend: keep app shell visible during auth loading #339

frontend: keep app shell visible during auth loading

frontend: keep app shell visible during auth loading #339

name: Deploy FPAI Frontend to AWS (S3 + CloudFront)
on:
push:
branches: ["main", "prod"]
paths:
- "frontend/**"
- ".github/workflows/deploy-frontend-aws.yml"
workflow_dispatch:
permissions:
id-token: write
contents: read
concurrency:
group: deploy-fpai-frontend-${{ github.ref_name == 'prod' && 'prod' || 'dev' }}
cancel-in-progress: true
env:
AWS_REGION: us-east-1
AWS_ROLE_TO_ASSUME: arn:aws:iam::675177356722:role/GitHubActionsDeployer
DEPLOY_REF: ${{ github.ref_name }}
ENVIRONMENT: ${{ github.ref_name == 'prod' && 'prod' || 'dev' }}
jobs:
# ============================================
# PRE-DEPLOYMENT CHECKS (run in parallel)
# ============================================
lint:
name: Lint & Code Quality
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ env.DEPLOY_REF }}
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Run ESLint
working-directory: frontend
run: |
npm run lint 2>&1 | tee lint-output.txt || {
echo "::error::ESLint found issues"
exit 1
}
- name: Check for console.log statements
working-directory: frontend
run: |
if grep -rn "console\.log" src/ --include="*.js" --include="*.jsx" | grep -v "// debug" | head -5; then
echo "::warning::Found console.log statements - consider removing for production"
fi
security:
name: Security Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ env.DEPLOY_REF }}
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Run npm audit
working-directory: frontend
run: |
set +e
npm audit --audit-level=high --json > audit-report.json
STATUS=$?
set -e
if [ "$STATUS" -ne 0 ]; then
echo "::error::Security vulnerabilities found in dependencies"
jq '.metadata.vulnerabilities' audit-report.json || true
exit "$STATUS"
fi
- name: Upload npm audit report
uses: actions/upload-artifact@v4
if: always()
with:
name: frontend-audit-report
path: frontend/audit-report.json
retention-days: 7
- name: Check for outdated dependencies
working-directory: frontend
run: |
npm outdated || echo "Some packages are outdated"
secret-scan:
name: Secret Scan (Gitleaks)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ env.DEPLOY_REF }}
fetch-depth: 0
- name: Install gitleaks CLI
run: |
go version
go install github.com/zricethezav/gitleaks/v8@v8.24.2
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Run gitleaks
run: |
gitleaks detect --source . --redact --report-format sarif --report-path gitleaks-report.sarif
- name: Upload gitleaks report
uses: actions/upload-artifact@v4
if: always()
with:
name: frontend-gitleaks-report
path: gitleaks-report.sarif
retention-days: 7
build-test:
name: Build Verification
runs-on: ubuntu-latest
environment: ${{ github.ref_name == 'prod' && 'prod' || 'dev' }}
env:
NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL || secrets.NEXT_PUBLIC_API_URL || (github.ref_name == 'prod' && 'https://sfc3x4krb1.execute-api.us-east-1.amazonaws.com/prod' || '') }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL || vars.NEXT_PUBLIC_API_URL || secrets.NEXT_PUBLIC_API_BASE_URL || secrets.NEXT_PUBLIC_API_URL || (github.ref_name == 'prod' && 'https://sfc3x4krb1.execute-api.us-east-1.amazonaws.com/prod' || '') }}
NEXT_PUBLIC_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL || secrets.NEXT_PUBLIC_SITE_URL }}
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ vars.NEXT_PUBLIC_FIREBASE_API_KEY || secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ vars.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN || secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ vars.NEXT_PUBLIC_FIREBASE_PROJECT_ID || secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ vars.NEXT_PUBLIC_FIREBASE_APP_ID || secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ vars.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID || secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_PREPARE_UPLOADS: ${{ vars.NEXT_PUBLIC_PREPARE_UPLOADS || secrets.NEXT_PUBLIC_PREPARE_UPLOADS || (github.ref_name == 'prod' && 'false' || 'true') }}
NEXT_PUBLIC_ENABLE_JOB_EVENT_STREAMING: ${{ vars.NEXT_PUBLIC_ENABLE_JOB_EVENT_STREAMING || 'true' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ env.DEPLOY_REF }}
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Validate frontend env
run: |
if [ "${{ env.ENVIRONMENT }}" != "prod" ] && [ -z "$NEXT_PUBLIC_API_URL" ] && [ -z "$NEXT_PUBLIC_API_BASE_URL" ]; then
echo "::error::Missing NEXT_PUBLIC_API_URL (or NEXT_PUBLIC_API_BASE_URL) in GitHub environment vars/secrets"
exit 1
fi
test -n "$NEXT_PUBLIC_FIREBASE_API_KEY" || (echo "::error::Missing NEXT_PUBLIC_FIREBASE_API_KEY (repo variable or secret)" && exit 1)
test -n "$NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN" || (echo "::error::Missing NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN (repo variable or secret)" && exit 1)
test -n "$NEXT_PUBLIC_FIREBASE_PROJECT_ID" || (echo "::error::Missing NEXT_PUBLIC_FIREBASE_PROJECT_ID (repo variable or secret)" && exit 1)
test -n "$NEXT_PUBLIC_FIREBASE_APP_ID" || (echo "::error::Missing NEXT_PUBLIC_FIREBASE_APP_ID (repo variable or secret)" && exit 1)
- name: Build (Next.js)
working-directory: frontend
env:
NODE_ENV: production
NEXT_PUBLIC_BUILD_ID: ${{ github.sha }}
run: |
npm run build 2>&1 | tee build-output.txt
if [ ! -d ".next" ]; then
echo "::error::Build failed - .next directory not created"
exit 1
fi
- name: Export static site
working-directory: frontend
run: |
# Next.js 13+ with output: 'export' in next.config creates 'out' directory
# If not configured, we need to check if static export is set up
if [ -d "out" ]; then
echo "Static export found in 'out' directory"
elif [ -d ".next/static" ]; then
echo "Using .next build output"
else
echo "::error::No static output found"
exit 1
fi
- name: Verify build output
working-directory: frontend
run: |
echo "Checking build artifacts..."
# Determine output directory (Next.js uses 'out' for static export, or '.next' for server)
if [ -d "out" ]; then
OUTPUT_DIR="out"
else
OUTPUT_DIR=".next"
fi
echo "Using output directory: $OUTPUT_DIR"
# For static export, check index.html
if [ "$OUTPUT_DIR" = "out" ]; then
if [ ! -f "$OUTPUT_DIR/index.html" ]; then
echo "::error::Missing index.html in build output"
exit 1
fi
fi
# Check for static assets
if [ -d "$OUTPUT_DIR/static" ] || [ -d "$OUTPUT_DIR/_next/static" ]; then
echo "✅ Static assets found"
else
echo "::warning::No static assets directory found"
fi
echo "✅ Build artifacts verified"
- name: Analyze bundle size
working-directory: frontend
run: |
echo "## 📦 Bundle Size Analysis" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| File | Size |" >> $GITHUB_STEP_SUMMARY
echo "|------|------|" >> $GITHUB_STEP_SUMMARY
# Determine output directory
if [ -d "out" ]; then
OUTPUT_DIR="out"
ASSETS_DIR="out/_next/static"
else
OUTPUT_DIR=".next"
ASSETS_DIR=".next/static"
fi
# Calculate total JS size
JS_SIZE=$(find $ASSETS_DIR -name "*.js" -exec du -ch {} + 2>/dev/null | tail -1 | cut -f1 || echo "N/A")
echo "| JavaScript (total) | $JS_SIZE |" >> $GITHUB_STEP_SUMMARY
# Calculate total CSS size
CSS_SIZE=$(find $ASSETS_DIR -name "*.css" -exec du -ch {} + 2>/dev/null | tail -1 | cut -f1 || echo "N/A")
echo "| CSS (total) | $CSS_SIZE |" >> $GITHUB_STEP_SUMMARY
# Total output size
TOTAL_SIZE=$(du -sh $OUTPUT_DIR | cut -f1)
echo "| **Total** | **$TOTAL_SIZE** |" >> $GITHUB_STEP_SUMMARY
- name: Check for HTML validity
working-directory: frontend
run: |
# Determine output directory
if [ -d "out" ]; then
HTML_FILE="out/index.html"
else
echo "Skipping HTML validation for non-static export"
exit 0
fi
# Basic HTML structure check
if ! grep -q "<!DOCTYPE html>" "$HTML_FILE" && ! grep -q "<!doctype html>" "$HTML_FILE"; then
echo "::warning::index.html missing DOCTYPE declaration"
fi
if ! grep -q "<title>" "$HTML_FILE"; then
echo "::warning::index.html missing title tag (SEO issue)"
fi
if ! grep -q 'lang="' "$HTML_FILE"; then
echo "::warning::html tag missing lang attribute (accessibility issue)"
fi
echo "✅ HTML validation complete"
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: frontend-build
path: |
frontend/out
frontend/.next
retention-days: 7
# ============================================
# QUALITY GATE (all checks must pass)
# ============================================
quality-gate:
name: Quality Gate
needs: [lint, security, secret-scan, build-test]
runs-on: ubuntu-latest
steps:
- name: All checks passed
run: |
echo "## ✅ All Pre-Deployment Checks Passed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Lint & Code Quality | ✅ Passed |" >> $GITHUB_STEP_SUMMARY
echo "| Security Audit | ✅ Passed |" >> $GITHUB_STEP_SUMMARY
echo "| Secret Scan | ✅ Passed |" >> $GITHUB_STEP_SUMMARY
echo "| Build Verification | ✅ Passed |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Proceeding to deployment..." >> $GITHUB_STEP_SUMMARY
# ============================================
# DEPLOYMENT (only after quality gate passes)
# ============================================
deploy:
name: Deploy to AWS
needs: quality-gate
runs-on: ubuntu-latest
environment: ${{ github.ref_name == 'prod' && 'prod' || 'dev' }}
env:
S3_BUCKET: ${{ vars.FRONTEND_S3_BUCKET }}
CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.FRONTEND_CLOUDFRONT_DISTRIBUTION_ID }}
NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL || secrets.NEXT_PUBLIC_API_URL || (github.ref_name == 'prod' && 'https://sfc3x4krb1.execute-api.us-east-1.amazonaws.com/prod' || '') }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL || vars.NEXT_PUBLIC_API_URL || secrets.NEXT_PUBLIC_API_BASE_URL || secrets.NEXT_PUBLIC_API_URL || (github.ref_name == 'prod' && 'https://sfc3x4krb1.execute-api.us-east-1.amazonaws.com/prod' || '') }}
NEXT_PUBLIC_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL || secrets.NEXT_PUBLIC_SITE_URL }}
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ vars.NEXT_PUBLIC_FIREBASE_API_KEY || secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ vars.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN || secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ vars.NEXT_PUBLIC_FIREBASE_PROJECT_ID || secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ vars.NEXT_PUBLIC_FIREBASE_APP_ID || secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ vars.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID || secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_PREPARE_UPLOADS: ${{ vars.NEXT_PUBLIC_PREPARE_UPLOADS || secrets.NEXT_PUBLIC_PREPARE_UPLOADS || (github.ref_name == 'prod' && 'false' || 'true') }}
NEXT_PUBLIC_ENABLE_JOB_EVENT_STREAMING: ${{ vars.NEXT_PUBLIC_ENABLE_JOB_EVENT_STREAMING || 'true' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ env.DEPLOY_REF }}
- name: Validate required config
run: |
test -n "$S3_BUCKET" || (echo "Missing variable: FRONTEND_S3_BUCKET" && exit 1)
test -n "$CLOUDFRONT_DISTRIBUTION_ID" || (echo "Missing variable: FRONTEND_CLOUDFRONT_DISTRIBUTION_ID" && exit 1)
if [ "${{ env.ENVIRONMENT }}" != "prod" ] && [ -z "$NEXT_PUBLIC_API_URL" ] && [ -z "$NEXT_PUBLIC_API_BASE_URL" ]; then
echo "::error::Missing NEXT_PUBLIC_API_URL (or NEXT_PUBLIC_API_BASE_URL) in GitHub environment vars/secrets"
exit 1
fi
test -n "$NEXT_PUBLIC_FIREBASE_API_KEY" || (echo "Missing variable/secret: NEXT_PUBLIC_FIREBASE_API_KEY" && exit 1)
test -n "$NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN" || (echo "Missing variable/secret: NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN" && exit 1)
test -n "$NEXT_PUBLIC_FIREBASE_PROJECT_ID" || (echo "Missing variable/secret: NEXT_PUBLIC_FIREBASE_PROJECT_ID" && exit 1)
test -n "$NEXT_PUBLIC_FIREBASE_APP_ID" || (echo "Missing variable/secret: NEXT_PUBLIC_FIREBASE_APP_ID" && exit 1)
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install deps
working-directory: frontend
run: npm ci
- name: Build (Next.js)
working-directory: frontend
env:
NODE_ENV: production
NEXT_PUBLIC_BUILD_ID: ${{ github.sha }}
run: |
npm run build
# Verify static export was created
if [ ! -d "out" ]; then
echo "::error::Build failed - 'out' directory not created. Ensure next.config has output: 'export'"
exit 1
fi
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ env.AWS_ROLE_TO_ASSUME }}
- name: Who am I (debug)
run: aws sts get-caller-identity
- name: Sync static assets to S3 (immutable)
run: |
# Next.js static export puts assets in _next/static
aws s3 sync frontend/out/_next "s3://$S3_BUCKET/_next" \
--delete \
--cache-control "public,max-age=31536000,immutable" \
--only-show-errors
- name: Upload non-asset files to S3
run: |
aws s3 sync frontend/out "s3://$S3_BUCKET" \
--delete \
--exclude "_next/*" \
--exclude "*.html" \
--cache-control "public,max-age=86400" \
--only-show-errors
- name: Create and upload version.json (no-cache)
run: |
echo '{"buildId":"${{ github.sha }}"}' > frontend/out/version.json
aws s3 cp frontend/out/version.json "s3://$S3_BUCKET/version.json" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "application/json" \
--only-show-errors
- name: Upload HTML files with no-cache
run: |
HTML_CACHE_CONTROL="no-cache,no-store,must-revalidate"
# Upload all HTML files (Next.js creates one per page)
find frontend/out -name "*.html" | while read html_file; do
relative_path="${html_file#frontend/out/}"
aws s3 cp "$html_file" "s3://$S3_BUCKET/$relative_path" \
--cache-control "$HTML_CACHE_CONTROL" \
--content-type "text/html; charset=utf-8" \
--only-show-errors
# CloudFront + private S3 origins do not resolve /route or /route/ to /route/index.html.
# Publish alias keys so both URL shapes work for exported pages.
if [[ "$relative_path" == */index.html ]] && [ "$relative_path" != "index.html" ]; then
route_key="${relative_path%/index.html}"
# Skip internal/special pages.
case "$route_key" in
_*|404)
continue
;;
esac
# /about -> object key "about"
aws s3api put-object \
--bucket "$S3_BUCKET" \
--key "$route_key" \
--body "$html_file" \
--cache-control "$HTML_CACHE_CONTROL" \
--content-type "text/html; charset=utf-8" \
>/dev/null
# /about/ -> object key "about/"
aws s3api put-object \
--bucket "$S3_BUCKET" \
--key "$route_key/" \
--body "$html_file" \
--cache-control "$HTML_CACHE_CONTROL" \
--content-type "text/html; charset=utf-8" \
>/dev/null
fi
done
- name: Verify index.html exists in S3
run: |
aws s3api head-object \
--bucket "$S3_BUCKET" \
--key "index.html" \
>/dev/null
- name: Ensure CloudFront default root object is index.html
run: |
set -euo pipefail
DIST_CONFIG_FILE="$(mktemp)"
UPDATED_CONFIG_FILE="$(mktemp)"
aws cloudfront get-distribution-config \
--id "$CLOUDFRONT_DISTRIBUTION_ID" \
> "$DIST_CONFIG_FILE"
ETAG="$(jq -r '.ETag' "$DIST_CONFIG_FILE")"
CURRENT_ROOT_OBJECT="$(jq -r '.DistributionConfig.DefaultRootObject // empty' "$DIST_CONFIG_FILE")"
if [ "$CURRENT_ROOT_OBJECT" = "index.html" ]; then
echo "CloudFront DefaultRootObject is already index.html"
exit 0
fi
echo "Updating DefaultRootObject to index.html (current: ${CURRENT_ROOT_OBJECT:-<empty>})"
jq '.DistributionConfig | .DefaultRootObject = "index.html"' "$DIST_CONFIG_FILE" > "$UPDATED_CONFIG_FILE"
aws cloudfront update-distribution \
--id "$CLOUDFRONT_DISTRIBUTION_ID" \
--if-match "$ETAG" \
--distribution-config "file://$UPDATED_CONFIG_FILE" \
>/dev/null
echo "CloudFront distribution update submitted"
- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation \
--distribution-id "$CLOUDFRONT_DISTRIBUTION_ID" \
--paths "/*"
- name: Smoke test CloudFront root response
run: |
set -euo pipefail
DISTRIBUTION_DOMAIN="$(aws cloudfront get-distribution --id "$CLOUDFRONT_DISTRIBUTION_ID" --query 'Distribution.DomainName' --output text)"
SITE_URL="https://$DISTRIBUTION_DOMAIN"
MAX_ATTEMPTS=12
ATTEMPT=1
echo "Running smoke test against $SITE_URL (/, /about, /team, /app, /demo)"
while [ "$ATTEMPT" -le "$MAX_ATTEMPTS" ]; do
FAILED=0
for path in / /about /team /app /demo; do
HTTP_CODE="$(curl -sS -L -o /tmp/cloudfront-smoke.html -w '%{http_code}' "$SITE_URL$path" || true)"
if [ "$HTTP_CODE" != "200" ] || grep -q "<Code>AccessDenied</Code>" /tmp/cloudfront-smoke.html; then
echo "Path $path failed (status: ${HTTP_CODE:-unknown})"
FAILED=1
break
fi
done
if [ "$FAILED" -eq 0 ]; then
echo "Smoke test passed on attempt $ATTEMPT"
exit 0
fi
if [ "$ATTEMPT" -eq "$MAX_ATTEMPTS" ]; then
echo "::error::CloudFront smoke test failed after $MAX_ATTEMPTS attempts"
echo "First lines of response:"
sed -n '1,30p' /tmp/cloudfront-smoke.html || true
exit 1
fi
echo "Attempt $ATTEMPT/$MAX_ATTEMPTS not ready; retrying in 20s..."
ATTEMPT=$((ATTEMPT + 1))
sleep 20
done
- name: Deployment Summary
run: |
EFFECTIVE_API_URL="${NEXT_PUBLIC_API_URL:-$NEXT_PUBLIC_API_BASE_URL}"
echo "## 🚀 Frontend Deployment Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| S3 Bucket | $S3_BUCKET |" >> $GITHUB_STEP_SUMMARY
echo "| CloudFront | $CLOUDFRONT_DISTRIBUTION_ID |" >> $GITHUB_STEP_SUMMARY
echo "| API URL | $EFFECTIVE_API_URL |" >> $GITHUB_STEP_SUMMARY
echo "| Triggered by | ${{ github.actor }} |" >> $GITHUB_STEP_SUMMARY
echo "| Commit | ${{ github.sha }} |" >> $GITHUB_STEP_SUMMARY