LOCAL PREVIEW View on GitHub

CD-05: Frontend Deployment Pipeline

User Story

As a Frontend Developer on the MangaAssist AI Chatbot team, I want to establish an automated pipeline that builds, tests, and deploys the React chat widget to S3 and CloudFront, So that frontend changes (UI improvements, WebSocket enhancements, new chat features) reach customers within minutes — with instant cache invalidation and automatic rollback capability.


Acceptance Criteria

  • Every merge to main (frontend paths) triggers build → test → deploy → invalidate
  • React app is built with production optimizations (minification, tree-shaking, code splitting)
  • Static assets are uploaded to S3 with content-hash filenames for cache busting
  • CloudFront cache is invalidated selectively (only changed paths, not wildcard)
  • Smoke tests validate chat widget loads, WebSocket connects, and first message sends
  • Preview deployments are created automatically for every pull request
  • Pipeline completes in < 5 minutes (commit to live)
  • Rollback is instant by re-pointing CloudFront to previous S3 prefix
  • Bundle size is tracked per deploy — alert if > 10% increase
  • Lighthouse CI score is validated (Performance > 90, Accessibility > 95)

High-Level Design

Frontend Pipeline Overview

flowchart LR
    subgraph "Source"
        A[GitHub PR/Merge] --> B{Branch?}
    end

    subgraph "CI Phase (2 min)"
        B -->|Feature Branch| C1[Lint + Type Check]
        B -->|Main| C2[Lint + Type Check]
        C1 --> D1[Unit Tests — Jest]
        C2 --> D2[Unit Tests — Jest]
        D1 --> E1[Build — Preview]
        D2 --> E2[Build — Production]
    end

    subgraph "Preview Deploy"
        E1 --> F1[Upload to S3 /preview/PR-123/]
        F1 --> G1[CloudFront preview.mangaassist.com/PR-123]
        G1 --> H1[Comment preview URL on PR]
    end

    subgraph "Production Deploy"
        E2 --> F2[Upload to S3 /releases/v1.42/]
        F2 --> G2[CloudFront Selective Invalidation]
        G2 --> H2[Smoke Tests]
        H2 -->|Pass| I2[Live at mangaassist.com]
        H2 -->|Fail| J2[Rollback to Previous Release]
    end

    style I2 fill:#1B660F,color:#fff
    style J2 fill:#DD344C,color:#fff

Deployment Architecture

graph LR
    subgraph "CDN Edge"
        CF[CloudFront Distribution]
        CF -->|"/chat-widget/*"| S3_WIDGET["S3: Chat Widget<br/>(React SPA)"]
        CF -->|"/api/*"| APIGW["API Gateway<br/>(WebSocket + REST)"]
        CF -->|"/*"| S3_STATIC["S3: Static Assets<br/>(images, fonts)"]
    end

    subgraph "S3 Bucket Structure"
        S3_WIDGET --> R1["/releases/v1.42/ (current)"]
        S3_WIDGET --> R2["/releases/v1.41/ (previous)"]
        S3_WIDGET --> R3["/preview/PR-123/"]
        S3_WIDGET --> R4["/preview/PR-456/"]
    end

    style CF fill:#ff9900,color:#000

Low-Level Design

1. Build Configuration

// vite.config.ts — Production build with content hashing
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig(({ mode }) => ({
  plugins: [
    react(),
    mode === 'production' && visualizer({
      filename: 'dist/bundle-stats.html',
      gzipSize: true,
    }),
  ],
  build: {
    outDir: 'dist',
    sourcemap: true,
    rollupOptions: {
      output: {
        // Content-hash filenames for cache busting
        entryFileNames: 'assets/[name]-[hash].js',
        chunkFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash].[ext]',
        manualChunks: {
          vendor: ['react', 'react-dom'],
          websocket: ['./src/lib/websocket.ts'],
          chat: ['./src/components/ChatWidget/index.tsx'],
        },
      },
    },
  },
}));

2. GitHub Actions Pipeline

name: frontend-deploy
on:
  push:
    branches: [main]
    paths: ['frontend/**']
  pull_request:
    paths: ['frontend/**']

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: frontend
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
          cache-dependency-path: frontend/package-lock.json

      - run: npm ci

      - name: Lint + Type Check
        run: |
          npm run lint
          npm run type-check

      - name: Unit Tests
        run: npm test -- --coverage --ci

      - name: Build
        run: npm run build
        env:
          VITE_API_URL: ${{ github.event_name == 'push' && 'https://api.mangaassist.com' || 'https://api-staging.mangaassist.com' }}
          VITE_WS_URL: ${{ github.event_name == 'push' && 'wss://ws.mangaassist.com' || 'wss://ws-staging.mangaassist.com' }}

      - name: Bundle Size Check
        run: |
          TOTAL=$(du -sb dist | cut -f1)
          echo "Bundle size: $TOTAL bytes"
          if [ "$TOTAL" -gt 524288 ]; then  # 512KB limit
            echo "::warning::Bundle size exceeds 512KB"
          fi

      - uses: actions/upload-artifact@v4
        with:
          name: frontend-build
          path: frontend/dist

  preview-deploy:
    if: github.event_name == 'pull_request'
    needs: build-and-test
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::role/github-actions-frontend
          aws-region: us-east-1

      - uses: actions/download-artifact@v4
        with:
          name: frontend-build
          path: dist

      - name: Deploy to preview
        run: |
          aws s3 sync dist/ s3://mangaassist-frontend/preview/PR-${{ github.event.number }}/ \
            --delete --cache-control "no-cache"

      - name: Comment preview URL
        uses: marocchino/sticky-pull-request-comment@v2
        with:
          message: |
            ### Preview Deployment
            URL: https://preview.mangaassist.com/PR-${{ github.event.number }}/
            Build: ${{ github.sha }}

  production-deploy:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    needs: build-and-test
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::role/github-actions-frontend
          aws-region: us-east-1

      - uses: actions/download-artifact@v4
        with:
          name: frontend-build
          path: dist

      - name: Upload to S3 (versioned release)
        run: |
          VERSION=$(echo ${{ github.sha }} | cut -c1-8)
          aws s3 sync dist/ s3://mangaassist-frontend/releases/$VERSION/ \
            --cache-control "public, max-age=31536000, immutable"
          # Update pointer file to current release
          echo $VERSION > /tmp/current-version.txt
          aws s3 cp /tmp/current-version.txt s3://mangaassist-frontend/current-version.txt \
            --cache-control "no-cache, no-store"

      - name: CloudFront Selective Invalidation
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_DIST_ID }} \
            --paths "/index.html" "/current-version.txt" "/chat-widget/*"

      - name: Smoke Tests
        run: |
          # Wait for invalidation propagation
          sleep 15
          # Check chat widget loads
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://mangaassist.com/chat-widget/)
          if [ "$STATUS" != "200" ]; then
            echo "Smoke test failed: HTTP $STATUS"
            exit 1
          fi

      - name: Lighthouse CI
        uses: treosh/lighthouse-ci-action@v11
        with:
          urls: 'https://mangaassist.com/'
          budgetPath: frontend/lighthouse-budget.json

3. CloudFront Cache Strategy

flowchart TD
    subgraph "Cache TTLs"
        A["index.html<br/>Cache-Control: no-cache<br/>(always fresh)"]
        B["assets/*-[hash].js<br/>Cache-Control: immutable, max-age=1yr<br/>(forever cached)"]
        C["assets/*-[hash].css<br/>Cache-Control: immutable, max-age=1yr<br/>(forever cached)"]
        D["images/*<br/>Cache-Control: max-age=86400<br/>(1 day)"]
    end

    E[User Browser] --> F{Cache Hit?}
    F -->|"index.html"| G[Always re-validate with origin]
    F -->|"hashed assets"| H[Serve from edge cache — never re-validate]
    F -->|"images"| I[Serve from cache, re-validate after 1 day]

4. Rollback Mechanism

# Rollback by re-pointing to previous release
import boto3

def rollback_frontend(distribution_id: str, s3_bucket: str):
    s3 = boto3.client('s3')
    cf = boto3.client('cloudfront')

    # Read current version
    current = s3.get_object(
        Bucket=s3_bucket, Key='current-version.txt'
    )['Body'].read().decode().strip()

    # List all release versions, find the one before current
    response = s3.list_objects_v2(
        Bucket=s3_bucket, Prefix='releases/', Delimiter='/'
    )
    versions = sorted([
        p['Prefix'].split('/')[1]
        for p in response['CommonPrefixes']
    ])

    current_idx = versions.index(current)
    if current_idx == 0:
        raise ValueError("No previous version to rollback to")

    previous = versions[current_idx - 1]

    # Update pointer to previous version
    s3.put_object(
        Bucket=s3_bucket,
        Key='current-version.txt',
        Body=previous.encode(),
        CacheControl='no-cache, no-store',
    )

    # Invalidate CloudFront to pick up new pointer
    cf.create_invalidation(
        DistributionId=distribution_id,
        InvalidationBatch={
            'Paths': {'Quantity': 2, 'Items': ['/index.html', '/current-version.txt']},
            'CallerReference': f"rollback-{previous}",
        },
    )
    return previous

Critical Decisions

Decision 1: Cache Invalidation Strategy — Wildcard vs Selective vs Versioned Assets

Strategy How It Works Cache Hit Ratio Invalidation Cost Deploy Speed
Wildcard /* Invalidate everything on every deploy 0% immediately after deploy $0.005/path (but many edge locations) Fast — simple
Selective paths Invalidate only /index.html + changed paths ~95% — hashed assets stay cached Minimal Fast — targeted
Versioned assets only Hash-based filenames, never invalidate assets ~99% — only index.html re-fetched $0 for assets Fastest

Decision: Versioned assets (content-hash filenames) + selective invalidation of index.html only

Rationale: Content-hash filenames (main-a1b2c3d4.js) are globally unique — they never need cache invalidation because the filename changes when the content changes. Only index.html (which references the hashed files) needs to be invalidated. This gives us a 99%+ cache hit ratio on edge while deploying new code in seconds. Wildcard invalidation would nuke the entire edge cache (~100+ locations globally) on every deploy, causing a thundering herd of origin requests.


Decision 2: Preview Deployments — Per-PR Preview vs Shared Staging

Criteria Per-PR Preview Shared Staging
Isolation Complete — each PR has its own URL None — PRs share one environment
Conflict Risk Zero — independent deployments High — PRs overwrite each other
Cost ~$0.10/PR (S3 storage only) Free (one environment)
PM/Designer Review Excellent — share PR-specific URL Poor — must coordinate staging access
Cleanup Need automation (delete on PR close) No cleanup needed

Decision: Per-PR preview deployments

Rationale: At ~$0.10/PR for S3 storage, the cost is negligible. Per-PR previews dramatically improve the review workflow — designers and PMs can test a specific change without waiting for "staging to be free." Preview URLs are auto-commented on the PR, making feedback loops instant. Cleanup is handled by a GitHub Action that deletes preview files when PRs are closed/merged.


Decision 3: S3 Versioning — S3 Object Versioning vs Path-Based Versioning

Criteria S3 Object Versioning Path-Based Versioning (Current)
Rollback Restore previous object version Point current-version.txt to previous path
Storage Cost Higher — all versions of every file stored Lower — old releases deleted after 7 days
Simplicity Built-in AWS feature Custom logic but transparent
Debugging Harder — which version is live? Easy — current-version.txt tells you
Cleanup Lifecycle policies on versions Lifecycle policies on paths

Decision: Path-based versioning (/releases/{sha}/)

Rationale: Path-based versioning makes deployment state explicit — the current-version.txt file is a single pointer that tells you exactly what version is live. S3 object versioning hides this behind AWS internals. For debugging a production issue at 3 AM, having a clear /releases/a1b2c3d4/ path is much more transparent than navigating S3 version IDs.


Tradeoffs

The Debate: Cache Performance vs Deployment Speed

graph TD
    subgraph "Frontend Developer"
        FE1["Want instant deployment visibility"]
        FE2["Hate waiting for cache invalidation"]
        FE3["Preview URLs for every PR"]
    end

    subgraph "Architect"
        AR1["Cache hit ratio must be > 95%"]
        AR2["Global edge caching = low latency"]
        AR3["Aggressive caching saves origin costs"]
    end

    subgraph "Product Manager"
        PM1["Hotfix must reach customers in < 5 min"]
        PM2["Feature flags preferred over delayed deploys"]
        PM3["A/B test new UI without deploy"]
    end

    FE2 ---|"Tension"| AR2
    PM1 ---|"Tension"| AR1
    FE3 ---|"Costs"| PM3

Resolution

Concern Solution Compromise
FE: Instant visibility Hash-based assets + no-cache for index.html = visible in < 15 sec Hashed assets may take a few seconds to propagate globally
Architect: High cache ratio Only index.html is un-cached; all JS/CSS/images cached for 1 year index.html adds one extra origin request per user session
PM: Hotfix in < 5 min Pipeline runs in < 5 min; CloudFront propagation adds ~15 sec 5 min pipeline + 15 sec propagation = 5.25 min total
PM: Feature flags > deploys Chat widget reads flags from AppConfig at runtime (see CD-06) Adds ~20ms to initial load (flag fetch)

Key Tradeoff: Preview Environments Cost vs Developer Velocity

Monthly cost with per-PR previews:
- S3 storage: ~20 active PRs × ~500KB × $0.023/GB = ~$0.001/month
- CloudFront: Preview subdomain already on same distribution = $0
- Total: < $1/month

Value delivered:
- Designers review directly (saves ~30 min/PR in communication)
- PMs test features before merge (catches UX issues earlier)
- No "staging is busy" bottleneck
- ROI: ~$1/month for ~20 hours/month saved in review cycles

The math overwhelmingly favors per-PR previews. The only real cost is the CI pipeline minutes (~2 min per preview build), which is covered by GitHub Actions free tier.