CI/CD & Releases

This document covers the GitHub Actions workflows, Docker multi-stage build, release tagging, changelog process, and known gotchas.

GitHub Actions Workflows

All workflows live in .github/workflows/. Here is a summary:

WorkflowFileTriggerPurpose
Teststest.ymlPR to main/masterFull PR gate with real PostgreSQL
Releaserelease.ymlPush of v*.*.* tag (except v*-enterprise)Quality checks + Docker build + Helm chart + GitHub Release
Migration Testingmigration-test.ymlPR/push changing prisma/Validates migrations apply and reset cleanly
Secret Scanningsecret-scan.ymlPR/push to main/masterGitleaks scan for committed secrets
Helm Charthelm-chart.ymlPR changing deploy/helm/, env schema or update routesLints and renders the chart, runs chart safety tests
Manual Load Benchmarkload-nightly.ymlManual dispatch onlyLoad harness with threshold and trend gates

Test Workflow (test.yml)

Runs on every pull request targeting main or master.

What It Does

  1. Spins up a PostgreSQL 16-Alpine service container
  2. Installs dependencies (npm ci)
  3. Generates Prisma client (npx prisma generate)
  4. Applies migrations (npx prisma migrate deploy)
  5. Checks schema drift between migrations and prisma/schema.prisma
  6. Runs npm run typecheck
  7. Validates the zh-CN language pack (npm run i18n:validate:zh-cn)
  8. Runs frontend lint and the RTL logical-CSS check (lint and lint:rtl in client)
  9. Builds the frontend and checks bundle budgets (npm run bundle:check)
  10. Runs the backend suite (npm run test:run), high-risk tests (npm run test:release) and frontend tests (npm run test:run --prefix client)
  11. Checks documentation coverage (./scripts/docs/doc-check.sh --strict)

Required Environment Variables

These must be present for src/config/env.ts to pass validation at import time:

env:
  NODE_OPTIONS: --max-old-space-size=4096
  NODE_ENV: test
  DATABASE_URL: postgresql://postgres:postgres@localhost:5432/poker_engine_test?schema=public
  JWT_ACCESS_SECRET: <64+ chars>
  JWT_REFRESH_SECRET: <64+ chars>
  JWT_2FA_TEMP_SECRET: <64+ chars>
  CSRF_SECRET: <32+ chars>
  ENCRYPTION_KEY: <64 hex chars>
  CORS_ORIGINS: http://localhost:3000,http://localhost:5173
  BCRYPT_ROUNDS: 4   # Low for speed in CI

Note: BCRYPT_ROUNDS: 4 is intentional — bcrypt with 12 rounds would make auth tests extremely slow in CI.

Operational Services Covered By CI

The test workflow includes a strict documentation coverage check for changed backend services. Operational hardening work commonly touches:

  • admin-table.service.ts — loads persisted table rows into the in-memory table manager during startup and admin table lifecycle operations. Startup paths should skip stale orphaned records without preventing the rest of the table set from loading.
  • update-manager.service.ts — owns file-backed update configuration, state snapshots, queued update commands, command audit records, run history, and admin update page snapshots. State normalization must treat the running package version as the current source of truth.

Release Workflow (release.yml)

Triggered by pushing a version tag matching v*.*.*. Tags ending in -enterprise are excluded and never start a product release.

Pipeline Structure

Push tag v0.x.x
    │
    ▼
┌─────────────────────────────────────┐
│  Job 1: quality                     │
│  ├─ PostgreSQL service              │
│  ├─ npm ci + prisma generate        │
│  ├─ prisma migrate deploy           │
│  ├─ verify tag/package versions     │
│  ├─ check schema drift              │
│  ├─ npm run typecheck               │
│  ├─ npm run typecheck --prefix client│
│  ├─ frontend build + bundle budgets │
│  ├─ npm run test:run                │
│  ├─ npm run test:release            │
│  ├─ docs coverage check             │
│  └─ docs JSON up-to-date check      │
└─────────────────────────────────────┘
    │ (passes)
    ▼
┌─────────────────────────────────────┐
│  Job 2: build-and-push              │
│  ├─ Generate changelog from commits │
│  ├─ Classify migration risk         │
│  ├─ Docker build (multi-stage)      │
│  ├─ Push to ghcr.io (versioned+latest)│
│  ├─ Build certification manifest    │
│  ├─ Create main/update tarballs     │
│  ├─ Scan artifacts for credentials  │
│  ├─ Build signed release manifest   │
│  ├─ Verify signed release manifest  │
│  ├─ Package and publish Helm chart  │
│  ├─ Create draft GitHub Release     │
│  ├─ Publish release                 │
│  └─ Verify the update feed          │
└─────────────────────────────────────┘

For final versions, a further job then packages the enterprise source release. It runs separately and does not affect the product release.

Permissions Required

permissions:
  contents: write    # Create GitHub Release
  packages: write    # Push to GHCR

Changelog Generation

The workflow automatically generates a changelog from commit messages since the previous version tag, categorized by conventional commit prefixes:

  • feat: or feat(scope): → Features section
  • fix: or fix(scope): → Bug Fixes section
  • Anything else → Other section

A Helm section notes the published chart. Prefixes aren’t enforced, so use feat: and fix: when you want a commit categorised.

Release Tarball Contents

Each release includes separate fresh-install and update packages plus signed manifest assets:

poker-engine-X.Y.Z-main.tar.gz
poker-engine-X.Y.Z-update.tar.gz
image.tar.gz
poker-engine-manifest.json
poker-engine-manifest.json.sha256
poker-engine-manifest.json.sig

The main package is small and is used for fresh installs. The update package includes image.tar.gz for host-side upgrades. Both packages include compose/nginx files, backup scripts, deployment scripts, the manifest verification scripts in scripts/release/, and the trusted release public key. The main package also includes .env.prod.example, install.sh, download.sh, and the deployment README.

The release manifest is signed with a private key held as a GitHub secret and verified in CI before the GitHub Release is published. The workflow uploads assets to a draft release first, publishes it only after all assets are present, then checks that the update feed serves the release and that its manifest and signature verify against the trusted public key. If that check fails, the workflow moves the release back to draft so it is not offered as an update.

Before building the signed manifest, the release workflow compares Prisma migration files against the previous version tag. Changed migrations set migration.required=true; destructive SQL patterns such as table/type/index drops, truncation, and broad deletes set migration.destructive=true, migration.requiresManualApproval=true, and migration.estimatedDowntime=high. Releases without changed migrations publish migration.required=false and migration.estimatedDowntime=none.

Docker Multi-Stage Build

The Dockerfile uses a 5-stage build for optimized production images. The node:20-alpine base is pinned by digest.

Stages

Stage 1: base
  └─ node:20-alpine + openssl

Stage 2: deps  (extends base)
  └─ npm ci (all deps, including dev)
  └─ npx prisma generate

Stage 3: backend-builder  (extends deps)
  └─ Copy src/ + tsconfig files + packages/shared/
  └─ npm run build  → dist/

Stage 4: frontend-builder  (extends deps)
  └─ Install Alpine/musl lightningcss and rollup binaries
  └─ Copy packages/shared/ + client/
  └─ npm run build --prefix client  → client/dist/

Stage 5: production  (extends node:20-alpine)
  └─ npm ci --omit=dev --ignore-scripts  (no devDeps, no husky)
  └─ Install Alpine/musl sharp binaries
  └─ npx prisma generate
  └─ COPY dist/ from backend-builder
  └─ COPY client/dist/ from frontend-builder
  └─ Run as non-root user (poker:nodejs)
  └─ tini as init process, running docker/app/entrypoint.sh

Key Build Details

Alpine platform binaries for the frontend build:

RUN npm install --no-save \
    lightningcss-linux-x64-musl@1.30.2 \
    @rollup/rollup-linux-x64-musl@4.59.0 \
    2>/dev/null || true

--ignore-scripts prevents husky from failing:

RUN npm ci --omit=dev --ignore-scripts

Husky is a devDependency. In the production stage, devDeps are omitted, but npm ci would still try to run the prepare (husky install) script — which fails because husky isn’t installed. --ignore-scripts skips all lifecycle scripts.

Non-root user:

RUN addgroup -g 1001 -S nodejs && \
    adduser -S poker -u 1001 -G nodejs
USER poker

Health check:

HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
    CMD curl -f http://localhost:3000/health || exit 1

Building Locally

# Build production image
docker build -t poker-engine:latest .

# Run with environment file
docker run -p 3000:3000 --env-file .env poker-engine:latest

Release Tagging

Standard Release Process

# 1. Optional early confidence check before release work
npm run ci:local

# 2. Create the release commit and tag.
#    This runs npm run release:preflight automatically before commit/tag/push.
./scripts/release.sh 0.x.x

# 3. Monitor GitHub Actions - quality, build, publish, and feed verification run automatically

scripts/release.sh updates the root and client package.json files, both lockfiles, the Dockerfile version label when present, then runs npm run release:preflight -- --version X.Y.Z before creating chore: release vX.Y.Z, creating the annotated tag, and pushing both. The release workflow refuses a tag if any package metadata still disagrees with the tag. This keeps the /health version, Docker image tag, release manifest, and update version aligned.

The local release preflight fails before the tag is pushed if generated docs JSON is stale, the PR-quality gate fails, or the production Docker image cannot build. SKIP_RELEASE_PREFLIGHT=true skips it, for emergencies only.

Re-triggering a Failed Release

If the workflow fails after the tag was pushed:

# Delete local tag
git tag -d v0.x.x

# Delete remote tag
git push origin :refs/tags/v0.x.x

# Fix the issue, commit, then re-create the tag
git tag v0.x.x
git push origin v0.x.x

Version in package.json

The release tag, root package.json, root package-lock.json, client/package.json, and client/package-lock.json must all match. The APP_VERSION value is read from root package.json at startup via health.service.ts, so mismatched package metadata makes post-update health verification unreliable and blocks the release workflow.

Secret Scanning (secret-scan.yml)

Uses Gitleaks to scan for accidentally committed secrets on every PR and push to main/master.

Configuration

Gitleaks configuration is in .gitleaks.toml. The config scans docs, tests, and workflow files; do not suppress whole path classes for those locations. Test-only values should be handled with exact placeholder regexes or inline gitleaks:allow comments.

[allowlist]
regexTarget = "match"
# Exact local/test-only values used in CI and docs.
regexes = [
    '''postgresql://postgres:postgres@localhost:5432/poker_engine_(test|shadow)(\?schema=public)?''',
    '''test-access-secret-that-is-long-enough-for-validation-minimum-sixty-four-chars!!'''
]

False Positives

If Gitleaks flags something that isn’t a real secret:

  1. Inline: Add gitleaks:allow on a deliberate placeholder line.
  2. Pattern-level: Add a narrow regex to .gitleaks.toml [allowlist] regexes for an exact placeholder family.
  3. File-level: Add [allowlist] paths patterns only for generated files or dependency metadata, never for docs, tests, specs, or .github/workflows.
  4. Commit-level: Add the commit SHA to .gitleaks.toml [allowlist] commits only for historical false positives that cannot be rewritten.

Run locally to test:

npm run scan:secrets

Migration Testing (migration-test.yml)

Runs when prisma/ files change. Verifies:

  1. All migrations apply cleanly (prisma migrate deploy)
  2. Schema is valid (prisma validate)
  3. Database can be reset and re-migrated (prisma migrate reset --force then prisma migrate deploy)

This workflow uses a disposable PostgreSQL container — safe to run migrate reset here.

Known Gotchas

1. Release workflow must mirror test.yml

The quality job in release.yml must have the same PostgreSQL service block and env vars as test.yml, and each workflow’s test steps need the same required variables. If you update test.yml (e.g., add a new required env var), update release.yml too.

2. Gitleaks scans workflow files

Test-only secrets in workflow env: blocks (dummy JWT secrets, test CSRF secret) can trigger false positives. Keep workflow files scanned and suppress only exact placeholder values or specific lines with gitleaks:allow.

3. Frontend builds in an isolated Docker stage

The frontend-builder stage does not have access to the root node_modules. Never use NodeJS.* types (e.g., NodeJS.Timeout) in client code or the shared package — @types/node is not in client/package.json. Use ReturnType<typeof setTimeout> instead.

4. --ignore-scripts required in production Docker stage

The production npm ci --omit=dev would fail trying to run prepare (which calls husky) because husky is not installed. Always include --ignore-scripts.

5. GHCR image naming

Images are pushed to ghcr.io/<owner>/poker-engine:<version> and ghcr.io/<owner>/poker-engine:latest. The owner is the GitHub repository owner (organization or user), not the repository name.

6. tini as init process

The production image uses tini as PID 1 to properly handle signals and zombie processes. Do not override the entrypoint without a proper init system.

Local CI Simulation

Use the local preflight scripts instead of manually copying workflow steps. They start disposable Docker services where needed, export the same test env values as Actions, and tear down after the run.

# Full PR gate parity: Postgres, Prisma migrate/drift, typecheck, i18n,
# client lint/RTL, client build/bundle, backend tests, release tests,
# frontend tests, and strict docs coverage.
npm run ci:local

# Prisma migration apply/reset/reapply gate for prisma changes.
npm run ci:migrations

# Helm rendering, kubeconform, chart packaging, and chart safety tests.
npm run ci:helm

# Secret scan for current files or branch history.
npm run scan:secrets
npm run scan:secrets:branch

# Release preflight. release.sh runs this automatically after bumping versions
# and before creating the release commit/tag. Includes PR parity, Helm
# packaging, and the local production Docker image build.
npm run release:preflight

# Docker-only release image build preflight.
npm run release:docker:preflight

The default local Postgres container is poker-engine-ci-postgres on host port 55432, so it does not collide with the normal development compose database on 5432. Set CI_KEEP_POSTGRES=true to leave the container running for inspection after a failed preflight.

Try the live demo Pricing

Have questions about Poker Engine? We reply fast.