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:
| Workflow | File | Trigger | Purpose |
|---|---|---|---|
| Tests | test.yml | PR to main/master | Full PR gate with real PostgreSQL |
| Release | release.yml | Push of v*.*.* tag (except v*-enterprise) | Quality checks + Docker build + Helm chart + GitHub Release |
| Migration Testing | migration-test.yml | PR/push changing prisma/ | Validates migrations apply and reset cleanly |
| Secret Scanning | secret-scan.yml | PR/push to main/master | Gitleaks scan for committed secrets |
| Helm Chart | helm-chart.yml | PR changing deploy/helm/, env schema or update routes | Lints and renders the chart, runs chart safety tests |
| Manual Load Benchmark | load-nightly.yml | Manual dispatch only | Load harness with threshold and trend gates |
Test Workflow (test.yml)
Runs on every pull request targeting main or master.
What It Does
- Spins up a PostgreSQL 16-Alpine service container
- Installs dependencies (
npm ci) - Generates Prisma client (
npx prisma generate) - Applies migrations (
npx prisma migrate deploy) - Checks schema drift between migrations and
prisma/schema.prisma - Runs
npm run typecheck - Validates the zh-CN language pack (
npm run i18n:validate:zh-cn) - Runs frontend lint and the RTL logical-CSS check (
lintandlint:rtlinclient) - Builds the frontend and checks bundle budgets (
npm run bundle:check) - Runs the backend suite (
npm run test:run), high-risk tests (npm run test:release) and frontend tests (npm run test:run --prefix client) - 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: 4is 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:orfeat(scope):→ Features sectionfix:orfix(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:
- Inline: Add
gitleaks:allowon a deliberate placeholder line. - Pattern-level: Add a narrow regex to
.gitleaks.toml[allowlist]regexesfor an exact placeholder family. - File-level: Add
[allowlist]pathspatterns only for generated files or dependency metadata, never for docs, tests, specs, or.github/workflows. - Commit-level: Add the commit SHA to
.gitleaks.toml[allowlist]commitsonly 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:
- All migrations apply cleanly (
prisma migrate deploy) - Schema is valid (
prisma validate) - Database can be reset and re-migrated (
prisma migrate reset --forcethenprisma 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.