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