Step 1: Pipeline Orchestration as a Directed Acyclic Graph
Avoid linear job execution. Define independent stages that run concurrently where dependencies allow. Linting, type checking, and unit tests can execute in parallel. Integration and e2e tests run after successful builds. Deployment triggers only on merged branches or tagged releases.
# .github/workflows/ci.yml
name: Frontend CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
unit-tests:
needs: lint-and-typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test:unit --coverage
build:
needs: [lint-and-typecheck, unit-tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
- uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: dist/
retention-days: 1
Step 2: Content-Addressable Caching Strategy
Timestamp or branch-based cache keys cause thrashing. Use lockfile content hashes to invalidate caches only when dependencies change. Combine with pnpm’s store path for maximum hit rates.
- uses: pnpm/action-setup@v3
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
Step 3: Test Sharding and Parallel Execution
Run e2e tests across multiple runners using test sharding. Distribute spec files evenly to reduce wall-clock time.
# package.json scripts
"scripts": {
"test:e2e:shard": "playwright test --shard=${{ env.SHARD_INDEX }}/${{ env.SHARD_TOTAL }}"
}
# GitHub Actions matrix
strategy:
matrix:
shard: [1, 2, 3, 4]
fail-fast: false
steps:
- run: pnpm test:e2e:shard
env:
SHARD_INDEX: ${{ matrix.shard }}
SHARD_TOTAL: 4
Step 4: Immutable Artifact Generation and Deployment
Never deploy directly from the build runner. Generate versioned artifacts, validate checksums, and push to edge storage or container registries. Use runtime environment injection instead of build-time constants for client-facing configurations.
// vite.config.ts
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
ui: ['@headlessui/react', '@radix-ui/react-dialog'],
},
},
},
sourcemap: mode === 'production' ? 'hidden' : true,
},
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
},
};
});
Architecture Decisions and Rationale
- pnpm over npm/yarn: Symlink-based node_modules reduces disk I/O by 60% and enforces strict dependency resolution, eliminating phantom package issues.
- Vite/Rspack over Webpack: Module graph traversal and HMR-compatible build pipelines reduce cold build times by 4–8x.
- Immutable artifacts: Ensures rollbacks are instant and reproducible. CDN caching becomes deterministic.
- Runtime env injection: Prevents build-time secret leakage and enables environment switching without rebuilds.
- DAG-based jobs: Maximizes runner utilization and isolates failure domains.
Pitfall Guide
Using main or develop as cache keys causes stale dependencies to persist across feature branches. Always hash pnpm-lock.yaml or package-lock.json. Restore keys should fall back to OS-level prefixes only.
2. Running Full E2E Suites on Every Commit
End-to-end tests require browser provisioning, network stubbing, and state cleanup. Running them on every PR increases queue time and introduces flakiness. Defer full e2e to merge queues or use test impact analysis to run only affected specs.
3. Hardcoding Environment Variables at Build Time
Embedding API URLs, feature flags, or auth endpoints into the bundle ties deployments to specific environments. Use runtime configuration loaders or CDN header injection to decouple builds from deployment targets.
Without automated size tracking, teams accumulate dead code, duplicate dependencies, and unoptimized assets. Integrate rollup-plugin-visualizer or webpack-bundle-analyzer into CI to fail builds when critical chunks exceed thresholds.
5. Monolithic Test Execution Without Sharding
Running all tests on a single runner creates a linear bottleneck. Modern test runners support parallelization via worker threads or CI matrix strategies. Shard by file, route, or component tree to distribute load.
6. Skipping Dependency Auditing and SBOM Generation
Frontend packages frequently introduce transitive vulnerabilities. Run pnpm audit or npm audit in CI, and generate Software Bill of Materials (SBOM) for compliance. Fail pipelines on critical/high severity findings.
7. Treating CI as a Single Job Instead of a DAG
Sequential pipelines force fast steps to wait for slow ones. Decouple independent workloads, define explicit dependencies, and use needs or depends_on to create parallel execution paths. This reduces total pipeline duration by 40–70%.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup / MVP (<10k LOC) | Sequential CI with basic caching | Low complexity, minimal runner costs, faster setup | Low compute spend, moderate developer wait time |
| Mid-size product (10k–100k LOC) | Parallelized modular CI with test sharding | Balances velocity and reliability, reduces queue bottlenecks | Moderate runner costs, 4–6x faster feedback |
| Enterprise / Regulated (>100k LOC) | Incremental/edge-optimized CI with SBOM & immutable deployments | Compliance, deterministic rollbacks, strict performance gates | Higher initial infra cost, 70% lower long-term operational overhead |
| Micro-frontend architecture | Independent pipeline per shell + federated artifact registry | Isolates team boundaries, prevents cross-module build failures | Increased registry storage, faster parallel deployments |
| High-traffic consumer app | Edge-optimized CI with CDN push and runtime env injection | Minimizes origin load, enables instant rollouts, decouples builds | CDN egress costs offset by reduced origin compute |
Configuration Template
# .github/workflows/frontend-ci.yml
name: Frontend CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
NODE_VERSION: 20
PNPM_VERSION: 8
jobs:
setup:
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache-key.outputs.key }}
steps:
- uses: actions/checkout@v4
- id: cache-key
run: echo "key=${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}" >> $GITHUB_OUTPUT
lint-and-typecheck:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
unit-tests:
needs: [setup, lint-and-typecheck]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test:unit --coverage --runInBand=false
build-and-artifact:
needs: [setup, unit-tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: sha256sum dist/**/* > dist/checksums.txt
- uses: actions/upload-artifact@v4
with:
name: frontend-dist-${{ github.sha }}
path: dist/
retention-days: 30
e2e-tests:
needs: [setup, build-and-artifact]
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- uses: actions/download-artifact@v4
with:
name: frontend-dist-${{ github.sha }}
path: dist/
- run: pnpm test:e2e --shard=${{ matrix.shard }}/3
// tsconfig.json (CI-optimized)
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"jsx": "preserve",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
Quick Start Guide
- Initialize project and lock package manager: Run
pnpm init, add "packageManager": "pnpm@8.x.x" to package.json, and generate pnpm-lock.yaml. Commit both files.
- Add CI workflow: Copy the configuration template into
.github/workflows/frontend-ci.yml. Adjust job names, test scripts, and shard counts to match your repository structure.
- Configure caching and parallelism: Ensure
actions/cache or pnpm/action-setup uses hashFiles('**/pnpm-lock.yaml'). Split test suites into test:unit and test:e2e scripts with parallel execution flags.
- Validate artifact generation: Run
pnpm build locally, verify dist/ contains hashed assets, and confirm checksum generation works. Commit the workflow and open a pull request to trigger the pipeline.
- Monitor and iterate: Check GitHub Actions run times, cache hit rates, and test shard distribution. Adjust runner counts, cache keys, or build thresholds based on observed metrics. Deploy to a staging environment using the uploaded artifact, not the CI runner directly.