Skip to content

DOMAIN:DEVOPS:GITHUB_ACTIONS

OWNER: alex (Team Alfa), tjitte (Team Bravo)
UPDATED: 2026-03-24
SCOPE: GitHub Actions CI/CD for GE client projects
AGENTS: alex (Alfa CI/CD), tjitte (Bravo CI/CD), marta/iwona (merge gate), koen (quality)


GITHUB_ACTIONS:OVERVIEW

PURPOSE: automate build, test, lint, deploy for every GE client project
STACK: Next.js 15 (App Router) + Hono (API) + Vitest (unit) + Playwright (e2e)
PRINCIPLE: CI must be fast, deterministic, and cheap. Flaky = broken.

CONSTRAINT: GE runs on k3s single-node today, GitHub-hosted runners for CI
CONSTRAINT: self-hosted runners DEFERRED until client volume justifies cost
CONSTRAINT: every workflow must complete in under 10 minutes for standard PRs


GITHUB_ACTIONS:WORKFLOW_STRUCTURE

STANDARD_WORKFLOWS

Every GE client repo MUST have these workflows:

WORKFLOW: ci.yml
TRIGGER: pull_request (opened, synchronize, reopened) + push to main
PURPOSE: lint, typecheck, unit test, build
RUNS_ON: ubuntu-latest

WORKFLOW: e2e.yml
TRIGGER: pull_request (opened, synchronize, reopened)
PURPOSE: Playwright end-to-end tests
RUNS_ON: ubuntu-latest
DEPENDS_ON: ci.yml must pass first (use workflow_call or needs)

WORKFLOW: deploy-staging.yml
TRIGGER: push to main
PURPOSE: deploy to staging environment
RUNS_ON: ubuntu-latest

WORKFLOW: deploy-production.yml
TRIGGER: workflow_dispatch (manual) OR release published
PURPOSE: deploy to production
RUNS_ON: ubuntu-latest
REQUIRES: staging deploy succeeded + manual approval

WORKFLOW: security.yml
TRIGGER: schedule (weekly) + pull_request
PURPOSE: dependency audit, SAST scanning
RUNS_ON: ubuntu-latest

WORKFLOW_NAMING

CONVENTION: kebab-case, descriptive
GOOD: ci.yml, deploy-staging.yml, e2e.yml, security.yml
BAD: build.yml, test.yml, main.yml (too vague)


GITHUB_ACTIONS:REUSABLE_WORKFLOWS

CONCEPT

WHAT: workflows that can be called by other workflows using workflow_call
WHY: DRY across multiple client repos, single source of truth for CI logic
WHERE: store in ge-bootstrap/.github/workflows/ as shared templates

REUSABLE_WORKFLOW_TEMPLATE

# .github/workflows/reusable-ci.yml
name: Reusable CI

on:
  workflow_call:
    inputs:
      node-version:
        description: 'Node.js version'
        required: false
        type: string
        default: '22'
      package-manager:
        description: 'Package manager (pnpm or npm)'
        required: false
        type: string
        default: 'pnpm'
      pnpm-version:
        description: 'pnpm version'
        required: false
        type: string
        default: '9'
    secrets:
      DATABASE_URL:
        required: false

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        if: inputs.package-manager == 'pnpm'
        with:
          version: ${{ inputs.pnpm-version }}

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: ${{ inputs.package-manager }}

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Lint
        run: pnpm lint

      - name: Type check
        run: pnpm typecheck

      - name: Unit tests
        run: pnpm test
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

      - name: Build
        run: pnpm build

CALLING_REUSABLE_WORKFLOW

# In client repo: .github/workflows/ci.yml
name: CI
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  ci:
    uses: growing-europe/.github/.github/workflows/reusable-ci.yml@main
    with:
      node-version: '22'
      package-manager: 'pnpm'
    secrets:
      DATABASE_URL: ${{ secrets.DATABASE_URL }}

GITHUB_ACTIONS:MATRIX_BUILDS

WHEN_TO_USE

USE_WHEN: testing across multiple Node versions or multiple OS targets
SKIP_WHEN: GE client projects target single Node version (most cases)
RECOMMENDATION: use matrix only for shared libraries, not client apps

MATRIX_TEMPLATE

jobs:
  test:
    strategy:
      matrix:
        node-version: [20, 22]
      fail-fast: true
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm test

PITFALL: matrix with fail-fast=false wastes minutes on doomed runs
RULE: always fail-fast=true unless debugging cross-version issues


GITHUB_ACTIONS:CACHING

NODE_MODULES_CACHE

METHOD: built-in cache in actions/setup-node@v4
HOW: set cache: 'pnpm' in setup-node step
CACHE_KEY: auto-generated from pnpm-lock.yaml hash
SAVINGS: 30-60 seconds per run

PITFALL: do NOT cache node_modules directly, cache the pnpm store
PITFALL: cache key MUST include lockfile hash or you get stale deps

- uses: pnpm/action-setup@v4
  with:
    version: 9
- uses: actions/setup-node@v4
  with:
    node-version: '22'
    cache: 'pnpm'

DOCKER_LAYER_CACHE

METHOD: docker/build-push-action with GitHub Actions cache backend
SAVINGS: 2-5 minutes per build (layer reuse)

- uses: docker/setup-buildx-action@v3

- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

PITFALL: mode=max caches all layers (not just final). Use it.
PITFALL: GHA cache has 10GB limit per repo. Monitor usage.

PLAYWRIGHT_BROWSER_CACHE

METHOD: manual cache action keyed on Playwright version

- name: Get Playwright version
  id: playwright-version
  run: echo "version=$(pnpm exec playwright --version)" >> $GITHUB_OUTPUT

- uses: actions/cache@v4
  id: playwright-cache
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ steps.playwright-version.outputs.version }}

- name: Install Playwright browsers
  if: steps.playwright-cache.outputs.cache-hit != 'true'
  run: pnpm exec playwright install --with-deps chromium

SAVINGS: 1-2 minutes (browser download skip)
NOTE: only cache chromium, not all browsers, unless tests require webkit/firefox

NEXT_BUILD_CACHE

METHOD: cache .next/cache directory

- uses: actions/cache@v4
  with:
    path: ${{ github.workspace }}/.next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      nextjs-${{ runner.os }}-

SAVINGS: 30-90 seconds (incremental build)


GITHUB_ACTIONS:SECRETS_MANAGEMENT

HIERARCHY

LEVEL: organization secrets (shared across all client repos)
USE_FOR: Docker registry creds, shared API keys, Vault tokens

LEVEL: repository secrets (per-client)
USE_FOR: client DATABASE_URL, client-specific API keys, deploy credentials

LEVEL: environment secrets (per-deploy-target)
USE_FOR: staging vs production DATABASE_URL, domain-specific creds

RULES

RULE: NEVER echo secrets in logs
RULE: NEVER pass secrets as command-line arguments (visible in ps)
RULE: use environment variables exclusively
RULE: rotate secrets quarterly (Hilrieke tracks via agent vitality)

PITFALL: ${{ secrets.MISSING_SECRET }} evaluates to empty string, not error
MITIGATION: add explicit check step

- name: Verify required secrets
  run: |
    if [ -z "$DATABASE_URL" ]; then
      echo "::error::DATABASE_URL secret is not set"
      exit 1
    fi
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}

ENVIRONMENTS

SETUP: create staging and production environments in GitHub repo settings
PRODUCTION_RULES: require manual approval, restrict to main branch

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://${{ vars.PRODUCTION_DOMAIN }}
    steps:
      - uses: actions/checkout@v4
      # deploy steps...

GITHUB_ACTIONS:WORKFLOW_DISPATCH

PURPOSE

USE_FOR: manual production deploys, ad-hoc tasks, rollback triggers
PRINCIPLE: production deploys are NEVER automatic from push

TEMPLATE

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deploy target'
        required: true
        type: choice
        options:
          - staging
          - production
      version:
        description: 'Version/tag to deploy (leave empty for latest main)'
        required: false
        type: string
        default: ''
      skip-e2e:
        description: 'Skip E2E tests (emergency only)'
        required: false
        type: boolean
        default: false

PITFALL: workflow_dispatch only works on default branch unless you specify ref
PITFALL: boolean inputs are strings in expressions, use inputs.skip-e2e == 'true'


GITHUB_ACTIONS:COMPLETE_CI_TEMPLATE

name: CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint-and-typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm typecheck

  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm test -- --reporter=verbose --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/

  build:
    needs: [lint-and-typecheck, unit-tests]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'pnpm'
      - uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      - run: pnpm install --frozen-lockfile
      - run: pnpm build

  e2e:
    needs: [build]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - name: Install Playwright
        run: pnpm exec playwright install --with-deps chromium
      - name: Run E2E
        run: pnpm exec playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

KEY_DECISIONS

DECISION: concurrency with cancel-in-progress=true
REASON: new push to same PR cancels stale run, saves minutes

DECISION: lint+typecheck parallel with unit-tests
REASON: independent, fail fast on either

DECISION: build depends on both, e2e depends on build
REASON: no point running e2e if build fails

DECISION: upload artifacts on failure only (e2e) or always (coverage)
REASON: Playwright traces only useful on failure, coverage always useful


GITHUB_ACTIONS:PITFALLS

PITFALL: using actions/checkout@v3 instead of @v4
IMPACT: v3 uses Node 16 (deprecated), v4 uses Node 20
FIX: always pin to @v4

PITFALL: not using --frozen-lockfile with pnpm install
IMPACT: CI modifies lockfile, non-deterministic builds
FIX: always use pnpm install --frozen-lockfile

PITFALL: caching node_modules instead of pnpm store
IMPACT: breaks on pnpm version changes, larger cache
FIX: use cache: 'pnpm' in setup-node, not manual cache of node_modules

PITFALL: running all Playwright browsers when only chromium needed
IMPACT: 3x install time, 3x test time
FIX: playwright install --with-deps chromium + projects: [chromium] in config

PITFALL: no concurrency control on push-triggered workflows
IMPACT: multiple deployments racing
FIX: add concurrency group with cancel-in-progress

PITFALL: hardcoded secrets in workflow files
IMPACT: SOC 2 CC6.1 violation, credential leak
FIX: always use ${{ secrets.NAME }}, never inline values

PITFALL: using if: success() (the default) for cleanup steps
IMPACT: cleanup skipped on failure
FIX: use if: always() for artifact uploads, notifications, cleanup


GITHUB_ACTIONS:GE_SPECIFIC_PATTERNS

ANTI_LLM_PIPELINE_INTEGRATION

The CI pipeline is the enforcement layer for GE's anti-LLM quality pipeline:

CHECK: Koen's lint rules run as pnpm lint step
CHECK: Antje's TDD specs validated as pnpm test step
CHECK: Marta/Iwona's merge gate runs as required status check
CHECK: Ashley's adversarial tests run in e2e job

IF any check fails THEN PR is blocked
IF PR is blocked THEN agent must fix and push again
NEVER bypass required status checks (SOC 2 CC8.1)

DEPLOYMENT_ZONES

ZONE: dev (feature branch deploys, optional)
TRIGGER: manual workflow_dispatch
LIFETIME: destroyed on PR merge

ZONE: staging (pre-production validation)
TRIGGER: push to main
VALIDATION: full e2e suite + smoke test

ZONE: production (live client environment)
TRIGGER: manual workflow_dispatch with approval
VALIDATION: staging must be green + release readiness score >= 0.8