CI/CD That Ships Itself: Our GitHub Actions Blueprint
(Pocket Portfolio — 12 Weeks of Shipping)
Fast releases are only useful if they are consistent and safe.
This post breaks down Pocket Portfolio’s CI/CD pipeline on GitHub Actions: how we lint, type-check, test against Firebase emulators, run Playwright smoke tests, and then canary-deploy to the Edge.
The goal is simple: every commit proves it can ship itself.
Pipeline Overview
Stages executed per pull request and on main:
1) Lint and TypeCheck
2) Build with reproducible caching
3) Firebase emulator tests (unit and integration)
4) Playwright UI smoke tests
5) Artifact capture (build, coverage, screenshots)
6) Canary deploy to next.pocketportfolio.app with manual approval
7) Post-deploy health checks and auto-rollback
We use:
- GitHub Actions matrix to cover Node versions and Next.js runtime modes
- Project-level concurrency to avoid clobbering environments
- Required checks enforced by branch protection rules
Repository Structure (relevant parts)
.github/
workflows/
ci.yml
deploy-canary.yml
apps/
web/ # Next.js 14 app
packages/
ui/ # shared components
config/ # ESLint, TS config
tests/
e2e/ # Playwright specs
seeds/ # Firebase test data
````
---
## Workflow 1: CI (PR and main)
`./github/workflows/ci.yml`
```yaml
name: CI
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
setup:
name: Setup
runs-on: ubuntu-latest
outputs:
node: ${{ steps.matrix.outputs.node }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- uses: pnpm/action-setup@v4
with:
version: 9
- name: Install
run: pnpm install --frozen-lockfile
lint_typecheck:
name: Lint and TypeCheck
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- run: pnpm eslint .
- run: pnpm tsc -p tsconfig.json --noEmit
build:
name: Build
needs: [setup, lint_typecheck]
runs-on: ubuntu-latest
env:
NEXT_TELEMETRY_DISABLED: 1
NODE_ENV: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- name: Build web
run: pnpm --filter ./apps/web build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: web-build
path: apps/web/.next
test_emulators:
name: Firebase Emulator Tests
needs: build
runs-on: ubuntu-latest
services:
firestore:
image: docker.io/bitnami/firebase-emulator:latest
ports:
- 4000:4000
- 8080:8080
- 9099:9099
- 9199:9199
env:
FIREBASE_AUTH_EMULATOR_HOST: localhost:9099
FIRESTORE_EMULATOR_HOST: localhost:8080
GCLOUD_PROJECT: test-project
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- name: Seed test data
run: pnpm tsx tests/seeds/seed.ts
- name: Unit and integration tests
run: pnpm test -- --reporter=junit --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage
e2e_smoke:
name: Playwright Smoke (headless)
needs: test_emulators
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- name: Install browsers
run: pnpm exec playwright install --with-deps
- name: Run smoke tests
env:
BASE_URL: http://localhost:3000
run: |
pnpm --filter ./apps/web dev & sleep 8
pnpm exec playwright test tests/e2e/smoke.spec.ts --reporter=line
- name: Upload Playwright artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts
path: |
playwright-report
test-results
````
What this does:
* Cancels superseded runs automatically
* Builds once and reuses artifacts
* Spins Firebase emulators for realistic tests
* Runs Playwright in headless mode against a local dev server
---
## Workflow 2: Canary Deploy to Edge
`./github/workflows/deploy-canary.yml`
```yaml
name: Deploy Canary
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [ main ]
permissions:
contents: read
deployments: write
id-token: write
concurrency:
group: deploy-canary
cancel-in-progress: false
jobs:
gate:
name: Require green CI
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- run: echo "CI passed, proceeding to deploy."
deploy:
name: Deploy to next.pocketportfolio.app
needs: gate
runs-on: ubuntu-latest
environment:
name: canary
url: https://next.pocketportfolio.app
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- name: Build
run: pnpm --filter ./apps/web build
- name: Vercel Deploy (Edge)
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
working-directory: ./apps/web
scope: ${{ secrets.VERCEL_ORG_ID }}
prod: false
- name: Health check
run: |
curl -fsSL https://next.pocketportfolio.app/api/health || exit 1
curl -fsSL https://next.pocketportfolio.app/api/health-price || exit 1
Notes:
workflow_run ensures deploy runs only after CI succeeds on main.
- Environment protection rules can require manual approval inside GitHub before the
deploy job starts.
- We target Edge by configuring Vercel project functions to run on the Edge runtime.
Health Checks and Auto-Rollback
We keep deploys safe with a simple pattern:
- After deployment, curl health and quote endpoints
- If any return non-2xx, mark the job failed
- A failing canary keeps production untouched
Optional rollback step (if you use Vercel Protected Branches):
- name: Roll back canary to previous deployment
if: failure()
run: |
echo "Rolling back..."
# Example: pin to previous successful alias or commit sha via Vercel CLI
Speed and Stability Techniques
- Caching:
actions/setup-node with cache: pnpm cuts install time.
- Concurrency: single deploy job at a time prevents environment drift.
- Artifacts: store build output and Playwright results for debugging.
- Separate concerns: CI proves code is safe; deploy workflow just ships.
- Emulators: Firebase emulator suite catches integration issues locally and in CI.
Failure Modes We Handle
- Stuck third-party provider: Playwright tests stub provider responses for critical flows.
- Flaky UI timing: Playwright uses test id selectors and realistic waits.
- Race conditions: matrix and concurrency settings keep builds deterministic.
Minimal Smoke Spec Example
tests/e2e/smoke.spec.ts
import { test, expect } from '@playwright/test';
test('dashboard loads and shows health badge', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('health-badge')).toHaveText(/healthy|degraded/);
});
test('price endpoint responds', async ({ request }) => {
const res = await request.get('/api/health-price');
expect(res.ok()).toBeTruthy();
});
What To Measure
- CI total duration (target: under 8 minutes for PRs)
- Flake rate (target: below 1 percent over 30 days)
- Canary failure rate and mean time to recovery
- Playwright runtime and retries per suite
Assets
- Workflow diagram (Setup -> Lint -> Build -> Emulators -> Playwright -> Canary Deploy)
- Screenshots of GitHub Checks and Environment approvals
- Example Playwright report
Links
TL;DR
Every commit runs the same path production will run: build, test, verify, deploy, and prove it is healthy.
If any step fails, the release stops itself. That is CI/CD that ships itself.