---
title: CI/CD Workflow
description: GitHub Actions CI/CD workflow with optimized caching, parallel job execution, and comprehensive testing pipeline.
navigation:
  icon: i-lucide-workflow
---

# CI/CD Workflow

This guide explains the GitHub Actions CI/CD workflow configuration for Harmonic SaaS, including optimization strategies and troubleshooting.

## Overview

The CI workflow runs on every push to `main` and `authflow-and-e2etests` branches, as well as pull requests targeting `main`. It consists of 4 jobs that run in parallel after an initial setup phase:

::steps
1. **Setup & Build** - Install dependencies and build the application (runs once)
2. **Lint** - Run ESLint checks (parallel with test/e2e)
3. **Unit Tests** - Run Vitest test suite with coverage (parallel with lint/e2e)
4. **E2E Tests** - Run Playwright end-to-end tests with Mailpit (parallel with lint/test)
::

## Workflow Architecture

### Job Dependencies

```mermaid
graph TD
    A[Setup & Build] --> B[Lint]
    A --> C[Unit Tests]
    A --> D[E2E Tests]
    B -.parallel.-> C
    B -.parallel.-> D
    C -.parallel.-> D
```

All jobs depend on the `setup` job completing successfully, then run in parallel to minimize total CI time.

### Caching Strategy

The workflow uses GitHub Actions cache to share dependencies and build artifacts between jobs:

#### Node Modules Cache
- **Key**: `${{ runner.os }}-node-modules-${{ hashFiles('**/pnpm-lock.yaml') }}`
- **Purpose**: Cache installed dependencies to avoid reinstalling in every job
- **Invalidation**: Automatically invalidates when `pnpm-lock.yaml` changes
- **Size**: ~300-500 MB
- **Restoration time**: ~5-10 seconds

#### Build Output Cache
- **Key**: `${{ runner.os }}-build-${{ github.sha }}`
- **Purpose**: Share build artifacts (`.output`, `.nuxt`) across jobs
- **Invalidation**: Unique per commit (git SHA)
- **Size**: ~50-100 MB
- **Restoration time**: ~3-5 seconds

### Time Optimization

**Before optimization** (sequential execution):
- Checkout + Install: 1-2 min
- Build: 1-2 min
- Lint: 30 sec
- Test: 1-2 min
- E2E: 3-5 min
- **Total**: ~10-12 minutes

**After optimization** (parallel execution with caching):
- Setup & Build: 2-3 min (once)
- Lint + Test + E2E: 3-5 min (parallel)
- **Total**: ~4-6 minutes (60% improvement)

## Job Configuration

### 1. Setup & Build

This job runs first and prepares the environment for all other jobs.

```yaml
setup:
  name: Setup & Build
  runs-on: ubuntu-latest

  outputs:
    cache-key: ${{ steps.cache-keys.outputs.modules }}
    build-cache-key: ${{ steps.cache-keys.outputs.build }}

  steps:
    - name: Checkout
      uses: actions/checkout@v5

    - name: Install pnpm
      uses: pnpm/action-setup@v4

    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        cache: 'pnpm'

    - name: Generate cache keys
      id: cache-keys
      run: |
        echo "modules=${{ runner.os }}-node-modules-${{ hashFiles('**/pnpm-lock.yaml') }}" >> $GITHUB_OUTPUT
        echo "build=${{ runner.os }}-build-${{ github.sha }}" >> $GITHUB_OUTPUT

    - name: Cache node_modules
      id: cache-node-modules
      uses: actions/cache@v4
      with:
        path: |
          node_modules
          .nuxt
        key: ${{ steps.cache-keys.outputs.modules }}

    - name: Install dependencies
      if: steps.cache-node-modules.outputs.cache-hit != 'true'
      run: pnpm install --frozen-lockfile

    - name: Build
      run: pnpm run build
      env:
        # Required for build
        DATABASE_URL: postgresql://test:test@localhost:5432/test_db
        GITHUB_CLIENT_ID: test_github_id
        GITHUB_CLIENT_SECRET: test_github_secret
        GOOGLE_CLIENT_ID: test_google_id
        GOOGLE_CLIENT_SECRET: test_google_secret
        BETTER_AUTH_SECRET: test_better_auth_secret
        BETTER_AUTH_URL: test_better_auth_url
        BLOB_READ_WRITE_TOKEN: test_blob_token

    - name: Cache build output
      uses: actions/cache@v4
      with:
        path: |
          .output
          .nuxt
        key: ${{ steps.cache-keys.outputs.build }}
```

**Key features**:
- Conditional dependency installation (skips if cache hit)
- Generates cache keys for sharing with dependent jobs
- Builds application once for all jobs
- Uses test environment variables for build

### 2. Lint Job

Runs ESLint checks using cached dependencies and build output.

```yaml
lint:
  name: Lint
  runs-on: ubuntu-latest
  needs: setup

  steps:
    - name: Checkout
      uses: actions/checkout@v5

    - name: Install pnpm
      uses: pnpm/action-setup@v4

    - name: Setup Node.js
      uses: actions/setup-node@v4

    - name: Restore node_modules cache
      uses: actions/cache/restore@v4
      with:
        path: |
          node_modules
          .nuxt
        key: ${{ needs.setup.outputs.cache-key }}
        fail-on-cache-miss: true

    - name: Restore build cache
      uses: actions/cache/restore@v4
      with:
        path: |
          .output
          .nuxt
        key: ${{ needs.setup.outputs.build-cache-key }}
        fail-on-cache-miss: true

    - name: Run ESLint
      run: pnpm run lint
```

### 3. Unit Tests Job

Runs Vitest test suite with coverage reporting.

```yaml
test:
  name: Unit Tests
  runs-on: ubuntu-latest
  needs: setup

  steps:
    # ... cache restoration steps (same as lint)

    - name: Run Vitest
      run: pnpm run test

    - name: Upload coverage reports
      if: always()
      uses: actions/upload-artifact@v4
      with:
        name: coverage-report
        path: coverage/
        retention-days: 7
```

### 4. E2E Tests Job

Runs Playwright end-to-end tests with Mailpit for email testing.

```yaml
e2e:
  name: E2E Tests
  runs-on: ubuntu-latest
  needs: setup

  steps:
    # ... cache restoration steps

    - name: Start Mailpit
      run: |
        docker run -d \
          --name mailpit \
          -p 1025:1025 \
          -p 8025:8025 \
          axllent/mailpit

        # Wait for Mailpit to be ready
        timeout 30 bash -c 'until curl -s http://localhost:8025/api/v1/messages > /dev/null; do sleep 1; done'

    - name: Install Playwright Browsers
      run: npx playwright install --with-deps

    - name: Run Playwright tests
      run: npx playwright test
      env:
        CI: true
        # ... environment variables

    - name: Stop Mailpit
      if: always()
      run: docker stop mailpit && docker rm mailpit

    - name: Upload Playwright report
      if: ${{ !cancelled() }}
      uses: actions/upload-artifact@v4
      with:
        name: playwright-report
        path: playwright-report/
        retention-days: 30
```

## Mailpit Configuration

E2E tests require email testing. The workflow uses different approaches for local development vs CI:

### Local Development
Playwright config automatically starts Mailpit using the local binary:
```typescript
// playwright.config.ts
webServer: [
  // ... dev server
  ...(process.env.CI
    ? []
    : [{
        command: process.platform === 'darwin'
          ? '/opt/homebrew/opt/mailpit/bin/mailpit'
          : 'mailpit',
        url: 'http://localhost:8025',
        reuseExistingServer: true,
        timeout: 10 * 1000,
      }])
]
```

### GitHub Actions CI
The workflow starts Mailpit via Docker before running Playwright:
```yaml
- name: Start Mailpit
  run: |
    docker run -d \
      --name mailpit \
      -p 1025:1025 \
      -p 8025:8025 \
      axllent/mailpit

    timeout 30 bash -c 'until curl -s http://localhost:8025/api/v1/messages > /dev/null; do sleep 1; done'
```

This approach ensures:
- Cross-platform compatibility (macOS local, Linux CI)
- No platform-specific paths in workflow
- Automatic cleanup after tests
- Health check before running tests

## Environment Variables

### Required Secrets
These must be set in GitHub repository settings:

- `NUXT_UI_PRO_LICENSE` - Required for building with Nuxt UI Pro

### Test Environment Variables
These are hardcoded in the workflow for testing:

```yaml
DATABASE_URL: postgresql://test:test@localhost:5432/test_db
GITHUB_CLIENT_ID: test_github_id
GITHUB_CLIENT_SECRET: test_github_secret
GOOGLE_CLIENT_ID: test_google_id
GOOGLE_CLIENT_SECRET: test_google_secret
BETTER_AUTH_SECRET: test_better_auth_secret
BETTER_AUTH_URL: http://localhost:3000
BLOB_READ_WRITE_TOKEN: test_blob_token
```

::warning
These are placeholder values for CI testing only. They are NOT real credentials and cannot be used for actual OAuth authentication.
::

## Artifacts

The workflow uploads test artifacts for debugging:

### Coverage Report
- **Job**: Unit Tests
- **Retention**: 7 days
- **Path**: `coverage/`
- **Purpose**: View test coverage HTML reports

### Playwright Report
- **Job**: E2E Tests
- **Retention**: 30 days
- **Path**: `playwright-report/`
- **Purpose**: Interactive HTML report with screenshots/videos

### Test Results
- **Job**: E2E Tests
- **Retention**: 7 days
- **Path**: `test-results/`
- **Purpose**: Raw test results and failure screenshots

## Troubleshooting

::accordion
:::accordion-item{title="Cache restore failed"}
**Error**: `Failed to restore cache entry. Exiting as fail-on-cache-miss is set.`

**Cause**: Setup job didn't complete successfully, so cache wasn't created.

**Fix**:
1. Check setup job logs for build/install failures
2. Verify `pnpm-lock.yaml` hasn't been corrupted
3. Check if GitHub Actions cache storage quota is exceeded
:::

:::accordion-item{title="Build files not found"}
**Error**: Lint/test jobs fail with missing `.nuxt` or `.output` directories

**Cause**: Build cache wasn't properly saved or restored.

**Fix**:
1. Verify build step completed successfully in setup job
2. Check cache key matches between save and restore
3. Ensure `fail-on-cache-miss: true` is catching the issue early
:::

:::accordion-item{title="Mailpit timeout"}
**Error**: `Timed out waiting 120000ms from config.webServer`

**Cause**: Mailpit not starting or not accessible.

**Fix**:
1. Check Docker is available in runner
2. Verify ports 1025 and 8025 aren't in use
3. Check health check curl command succeeds
4. Review Mailpit container logs: `docker logs mailpit`
:::

:::accordion-item{title="Tests pass locally but fail in CI"}
**Possible causes**:
- Different environment variables between local and CI
- Timing issues (CI may be slower)
- Mailpit configuration differences
- Missing Playwright browsers in CI

**Fix**:
1. Compare environment variables
2. Increase timeouts for CI environment
3. Add more explicit waits in tests
4. Verify Playwright browsers installed with `--with-deps`
:::
::

## Performance Tips

### Reduce CI Time
1. **Use cache effectively**: Ensure lockfile doesn't change unnecessarily
2. **Parallelize tests**: Split E2E tests into multiple shards
3. **Skip unnecessary jobs**: Use path filters for specific changes
4. **Optimize dependencies**: Remove unused packages

### Reduce Cache Size
1. **Clean build artifacts**: Remove unnecessary files before caching
2. **Use `.gitignore` patterns**: Cache respects gitignore
3. **Separate caches**: Don't cache test results with node_modules

## Monitoring

### Check Workflow Status
View workflow runs at:
```
https://github.com/vernaillen/harmonic-saas/actions
```

### Download Artifacts
1. Go to workflow run page
2. Scroll to "Artifacts" section
3. Download coverage reports or test results

### Review Cache Usage
Check repository cache usage at:
```
Settings → Actions → Caches
```

GitHub provides 10 GB of cache storage per repository. Old caches are automatically evicted when limit is reached.

## Future Improvements

Potential enhancements to consider:

- [ ] Add test sharding for faster E2E test execution
- [ ] Implement cache warming for frequently used dependencies
- [ ] Add deployment job for automatic preview deployments
- [ ] Set up test result reporting (e.g., Codecov, Coveralls)
- [ ] Add performance testing job
- [ ] Implement matrix testing for multiple Node.js versions
- [ ] Add security scanning (Snyk, npm audit)
- [ ] Set up automatic dependency updates (Renovate, Dependabot)
