Skip to main content

CI/CD Architecture

Hephaestus uses trunk-based continuous deployment powered by GitHub Actions. Every merge to main triggers automatic staging deployment, with production requiring manual approval.

๐Ÿ—๏ธ Architecture Overviewโ€‹

Staging tracks main HEAD and production tracks the last released tag, so the two are deliberately not the same commit.

๐Ÿš€ Release Flowโ€‹

Every merge to main runs CI and updates the accumulating Version PR (changesets). Merging that PR cuts the release โ€” tag vX.Y.Z, GitHub Release, docker tags X.Y.Z/X.Y/latest, then staging (automatic) and production (after approval). Full flow: Release Management.

๐Ÿ›ก๏ธ Quality Gatesโ€‹

Before any release, code must pass:

Gate (leg)ToolPurpose
Migration chain + drift (Database)LiquibaseFull chain applies empty โ†’ head, then schema is diffed against JPA entities
Changelog immutability (Migrations)git diffReleased changesets + master.xml are append-only
OpenAPI syncDiff checkClient โ†” Server sync
Java formattingPrettier (prettier-plugin-java)Code style
Java lintPMDStatic analysis
Webapp TypeScriptoxlint + Biome (webapp/biome.jsonc) + tscLint + format + typecheck
Everything else TypeScriptoxlint (.oxlintrc.json) + Biome (biome.jsonc) + tscoxlint reaches the Bun runtime, its specs, both precompute trees, scripts/**, docs/ and the repo-root config files; Biome formats all of those except docs/, which has its own config (docs/.oxlintrc.json) and no formatter
Agent runtimeBunRunner and precompute specs, on the Bun the sandbox ships โ€” CI reads ARG BUN_VERSION out of docker/agents/pi/Dockerfile rather than hard-coding it, and fails closed if that line is missing

๐Ÿ”’ Securityโ€‹

  • CodeQL โ€“ SAST scanning via GitHub's Default Setup (automatic, zero maintenance)
  • Trivy โ€“ Scans dependencies for CVEs
  • TruffleHog โ€“ Secret detection in code and history
  • Renovate โ€“ Monitors dependencies for vulnerabilities
  • Environment protection โ€“ Production requires approval

CodeQL Default Setupโ€‹

CodeQL runs automatically via GitHub's Default Setup (enabled in repository settings), providing:

  • Scans on every push to main and protected branches
  • Scans on pull request creation and updates
  • Weekly scheduled scans for the full codebase
  • Incremental analysis (20% faster on PRs)
  • Zero maintenance โ€“ GitHub manages query updates

This is more efficient than a custom workflow and doesn't consume CI minutes.

๐Ÿ“ฆ Environmentsโ€‹

EnvironmentProtectionDeploys On
Preview (Coolify)NoneEvery PR
StagingNoneEvery green commit on main (app services only)
ProductionApproval requiredTag + approval

Staging deploys the app services only. NATS/webhook (core) and the proxy are stateful and disruptive to recreate, so they are never auto-deployed; when their compose changes, CD flags it and an operator runs the staging deploy manually with the core/proxy switches on.

GitHub Environment Setupโ€‹

  1. Settings โ†’ Environments โ†’ New environment
  2. Create staging (no rules)
  3. Create production with Required reviewers

๐Ÿ”„ Preview Deploymentsโ€‹

Coolify handles PR previews:

  • Built directly on server (fast!)
  • URL: pr-{number}.preview.hephaestus.cit.tum.de
  • Auto-cleanup on PR close

โš™๏ธ Key Workflowsโ€‹

WorkflowTriggerPurpose
cicd.ymlPush to main, PRsOrchestrator: change detection + workflow dispatch
ci-quality-gates.ymlCalled by cicd.ymlCode quality, formatting, schema validation
ci-tests.ymlCalled by cicd.ymlUnit, integration, visual tests
ci-docker-build.ymlCalled by cicd.ymlDocker image builds per component
ci-security-scan.ymlCalled by cicd.ymlDependency scanning (Trivy), secret detection
ci-profile.ymlWeekly, manualProfiles server integration tests and Spring contexts
verify-changesets.ymlPRsFails shipped-code PRs that carry no changeset
ci-compose-validate.ymlPRs, push to mainRenders the reference and self-host compose stacks so an interpolation or merge break is a red check, not a stranger's bad first boot
version-pr.ymlPush to mainMaintains the accumulating Version PR (changesets)
release.ymlOn CI/CD SuccessCuts a release when the Version PR merged: tag + GitHub Release, gates production
cd-staging.ymlOn CI/CD Success on mainDeploys that commit's immutable image to staging
deploy-staging.ymlCalled by cd-staging.yml and manual dispatchDeploys to staging
deploy-prod.ymlworkflow_dispatchDeploys to production (manual trigger)

Workflow Architectureโ€‹

The cicd.yml workflow:

  1. Detects changes using dorny/paths-filter
  2. Dispatches sub-workflows with component-specific flags
  3. Aggregates results in the CI Status Gate job

๐ŸŽฏ Performance Optimizationsโ€‹

Path-Based Filteringโ€‹

CI only runs jobs for components that actually changed:

ComponentTriggers On
Webappwebapp/**, docs/images/readme/**, package.json, pnpm-lock.yaml, pnpm-workspace.yaml, .npmrc, .node-version
Application Serverserver/**, scripts/** (includes webhook receiver โ€” ADR 0008), docker/agents/**, tsconfig.agents.json, biome.jsonc, package.json, pnpm-lock.yaml
Agent imagesdocker/agents/**
Docsdocs/**
CI Config.github/workflows/**, .github/actions/** โ†’ runs all jobs

The whole of scripts/ counts as application-server change, not just the database helper: the contract validator and the changelog-immutability guard live there, and a PR editing only a guard would otherwise skip the workflow that runs it. docker/agents/** appears twice for the same reason โ€” it builds the agent images, and test:agents and typecheck:agents cover the precompute tree inside it, so a PR editing only a precompute script must still run those gates.

.oxlintrc.json, biome.jsonc, tsconfig.json, package.json and pnpm-lock.yaml are listed because they decide the verdict of the App Server leg's lint and format step: the two rule sets, the project the type-aware rules resolve against, the :agents scripts that invoke them, and the binary versions. A gate whose own configuration can change without re-running it is not a gate.

Docker Layer Cachingโ€‹

Docker builds use registry-based caching to store intermediate layers in ghcr.io:

How it works:

  • cache-from: Pulls cached layers from registry (main branch + current branch)
  • cache-to: Pushes new layers with mode=max (all intermediate layers)
  • Separate cache tags per platform: image:cache-linux-amd64, image:cache-linux-arm64
  • Native builds: amd64 on x86 runners, arm64 on ARM runners (no QEMU emulation)

The registry cache is not size-capped or time-evicted the way the Actions cache is, and it is shared across branches, so a pull request reuses main's layers.

Registry Authenticationโ€‹

Image builds log in to ghcr.io with the workflow's own GITHUB_TOKEN; there is no Docker Hub login step and no repository secret to configure. Base image metadata is resolved anonymously and is therefore subject to the registry's anonymous rate limit.

Parallel Executionโ€‹

  • Test legs run in parallel across the app server and the webapp. Webhook reception is part of the app-server test surface since ADR 0008.
  • Quality-gate legs (App Server, Webapp, OpenAPI, Database, Migrations) run in parallel, plus a legacy-cleanup guard
  • Docker images build for both architectures (amd64 + arm64)
  • fail-fast: false ensures all jobs complete for full feedback

Concurrency Controlโ€‹

  • Outdated PR runs cancelled automatically
  • Release runs never cancelled

Monitoring CIโ€‹

Use GitHub's organization-level Actions metrics for workflow and job run time, queue time, failure rate, and runner usage. Each CI Status Gate job also includes the current run's dependency-aware timeline and job summary.

The weekly CI profile workflow covers the server-specific data GitHub does not provide: JFR, resource usage, JUnit results, and Spring context-cache metrics. It signals only after three consecutive regressions against five earlier default-branch profiles. Branch dispatches produce standalone diagnostic artifacts without changing or enforcing that baseline.

SpringTestContextArchitectureTest separately enforces the reviewed Spring context keys. Run the profile options locally with:

mkdir -p ci-metrics
/usr/bin/time -v -o ci-metrics/server-integration-resource.txt \
pnpm run test:server:integration \
-DargLine=-XX:StartFlightRecording=filename=target/integration-profile.jfr,settings=profile,dumponexit=true \
-Dlogging.level.org.springframework.test.context.cache=DEBUG

๐Ÿ› ๏ธ Running CI Locallyโ€‹

Before pushing, run the same checks that CI runs:

# Format and check all services
pnpm run format && pnpm run check

Per-Service Commandsโ€‹

# Webapp
pnpm run check:webapp # Biome format check, then oxlint
pnpm run check:webapp:fix # Same, applying every safe fix
pnpm run typecheck:webapp # A separate leg โ€” check:webapp does not run it
pnpm run test:webapp # Unit tests

# Application Server (Java) โ€” includes the integration.core.webhook receiver
pnpm run format:java:check # Check formatting
cd server && ./mvnw test -P'!quick' # Unit tests โ€” see the testing guide for why `!quick` is mandatory

# Agent runtime (Bun) โ€” the Pi runner and the practice precompute scripts
pnpm run test:agents # Runner + precompute specs
pnpm run check:agents # Biome format, then oxlint, then both typechecks
pnpm run check:agents:fix # Same, applying every safe fix
pnpm run typecheck:agents # Agent + precompute TypeScript

check:agents covers every TypeScript tree outside the webapp โ€” the runtime, its specs, both precompute trees and scripts/**. Its rules and path roots live in biome.jsonc; CI runs the same thing as pnpm run ci:agents, which reports findings as inline annotations on the diff.

Common Issuesโ€‹

IssueSolution
Formatting errorsRun pnpm run format
Lint errors (agent runtime, precompute, scripts/)Run pnpm run check:agents:fix, then fix what remains
TypeScript errorsRun pnpm run typecheck to see details
Test failuresCheck the specific test output for details
OpenAPI out of syncRun pnpm run generate:api
Database schema driftRun pnpm run db:draft-changelog

๐Ÿ“Š CI Featuresโ€‹

Test Resultsโ€‹

All test suites generate JUnit XML reports that are displayed in the Test Results tab of each workflow run:

  • Application Server: Unit, integration, and architecture tests (incl. the in-process Pi mentor agent and the webhook receiver per ADR 0008)
  • Webapp: Unit tests and Storybook interaction tests

Job Summaryโ€‹

Each CI run generates a rich Job Summary in the Actions UI with:

  • Overall status with emoji indicators
  • Results table for each workflow (quality gates, tests, security, Docker)
  • Components changed table (from path filtering)
  • Failure-specific troubleshooting guides with fix commands
  • Performance metrics showing skipped workflows

Workflow Timelineโ€‹

The CI Status Gate job generates a visual Mermaid timeline showing:

  • Job execution order and duration
  • Parallel job execution
  • Job creation-to-start delay, including dependency waiting
  • Critical path identification

This helps identify bottlenecks and optimization opportunities.

๐Ÿ†• Adding a New Serviceโ€‹

When adding a new service to the monorepo, update CI configuration in this order:

Step 1: Path Detection (cicd.yml)โ€‹

Add a path filter and output for the new service:

# In detect-changes job outputs:
outputs:
new-service: ${{ steps.filter.outputs.new-service }}

# In paths-filter step:
filters: |
new-service:
- 'server/new-service/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'

Update the any-code aggregate output to include the new service.

Step 2: Quality Gates (ci-quality-gates.yml)โ€‹

  1. Add to matrix:
matrix:
check: [
# ... existing checks
new-service-quality,
]
  1. Add case statement in "Determine if check should run":
"new-service-quality")
echo "run=${{ inputs.new_service_changed }}" >> $GITHUB_OUTPUT
;;
  1. Add quality check step with the appropriate linting/type checking commands.

Step 3: Tests (ci-tests.yml)โ€‹

  1. Add to matrix:
matrix:
test-type: [
# ... existing tests
new-service-unit,
new-service-integration, # if applicable
]
  1. Add case statement in "Determine if test should run":
"new-service-unit"|"new-service-integration")
echo "run=${{ inputs.new_service_changed }}" >> $GITHUB_OUTPUT
;;
  1. Add test execution step with the test commands.

  2. Add test result upload for JUnit reporting.

Step 4: Docker Build (ci-docker-build.yml)โ€‹

Add a new build job:

new-service-build:
name: "Docker: new-service"
if: inputs.should_skip != 'true' && inputs.new_service_changed == 'true'
uses: ls1intum/.github/.github/workflows/build-and-push-docker-image.yml@main
with:
image-name: "ls1intum/hephaestus/new-service"
docker-file: "./server/new-service/Dockerfile"
docker-context: "./server/new-service"
# ... rest of config

Step 5: Caching (setup-caches/action.yml)โ€‹

Add the new service's cache-types to the appropriate conditions:

# For Node.js services:
- name: Cache Node.js dependencies
if: contains(fromJSON('["...", "new-service-quality", "new-service-unit"]'), inputs.cache-type)

# For Java services:
- name: Cache Maven dependencies
if: contains(fromJSON('["...", "new-service-unit"]'), inputs.cache-type)

Step 6: Update Workflow Inputsโ€‹

In cicd.yml, add the new input to workflow calls:

with:
new_service_changed: ${{ (needs.detect-changes.outputs.new-service == 'true' || ...) && 'true' || 'false' }}

Verification Checklistโ€‹

After adding a new service, verify:

  • Path filter correctly detects changes to new service
  • Quality gates run only when new service changes
  • Tests run only when new service changes
  • Docker build runs only when new service changes
  • CI config changes trigger all jobs (safety net)
  • JUnit reports appear in Test Results tab