CI/CD Guide

Complete guide for setting up continuous integration and deployment pipelines for Paladin using GitHub Actions.

Table of Contents

Overview

Paladin uses GitHub Actions for CI/CD with the following pipelines:

  • CI: Build, test, lint on every PR
  • Docker: Build and publish multi-arch images
  • Release: Automated releases with semantic versioning
  • Integration: Integration tests with Docker services
  • Security: Dependency scanning and vulnerability checks

GitHub Actions Workflows

Workflow Structure

.github/
├── workflows/
│   ├── benchmarks.yml            # Performance benchmark tracking
│   ├── ci.yml                    # Main CI pipeline (lint, test, integration, audit)
│   ├── codeql.yml                # Rust SAST scan — advisory only, does not gate a merge
│   ├── docs.yml                  # MDBook build + GitHub Pages deploy
│   ├── feature-flags.yml         # Feature-flag matrix tests
│   ├── pre-commit.yml            # Pre-commit checks
│   └── release.yml               # Release automation
└── dependabot.yml                # Dependency updates

docs.yml builds MDBook, runs ./scripts/check-doc-examples.sh (validates all fenced Rust code blocks), and deploys to GitHub Pages on merge to main.

CI Pipeline

ci.yml

ci.yml runs on every push (branches: ['**'], D-03) and on pull requests targeting main or release/**. It has grown well beyond the three-job (lint/test/coverage) sample previously shown here — the table below names every job the live file declares. The Required or advisory column is taken directly from .github/rulesets/protect-main-branch.json's required_status_checks array: a check whose display name is not listed there can fail without blocking a merge into main.

Job (ci.yml)Display nameWhat it gatesRequired or advisory
lintCode Qualitycargo fmt --all -- --check, cargo clippy --workspace --all-targets --all-features -- -D warnings, cargo doc warningsRequired
actionlintWorkflow LintLints every .github/workflows/*.yml file with actionlintRequired
security-auditSecurity Auditcargo audit against the RustSec advisory database, exceptions from .cargo/audit.tomlRequired
cargo-denyLicense & Dependency Policycargo deny check plus the repository's own policy scripts (changelogs, crate names, advisory register, workflow-suppression and workflow-trigger guards, CodeQL dismissal register, shell-guard regression tests)Required
osv-scannerOSV ScannerGoogle OSV database scan of Cargo.lock; SARIF uploaded for PR annotationRequired
api-surfaceAPI Surface Trackingcargo public-api diff against .project/current-exports.txt, plus deprecation-warning checksRequired
msrvMSRV (Rust 1.88)cargo check --workspace --all-features --all-targets at the pinned MSRVAdvisory
semverSemver Checks (vs v0.9.0)cargo-semver-checks for every publishable crate against the published v0.9.0 baselineAdvisory
testUnit Tests (stable / beta)cargo test --workspace --lib --bins and cargo test --workspace --doc, matrixed over stable and betaRequired
examplesExample Muster (Feature Matrix)Builds all 47 examples/*.rs targets across a 4-invocation feature matrixRequired
crate-isolationCrate Isolation (<crate>)Each of the 10 matrixed workspace crates builds and tests independently, with and without default featuresRequired
integration-testsIntegration TestsRedis + MinIO --ignored suites, plus the broad --features integration-tests workspace sweepRequired
docker-integrationDocker Integration TestsRuns the Docker Compose test stack's integration-tests serviceRequired
ollama-integrationOllama Integration Tests (live server)Live Ollama server suite (ollama_docker)Advisory
postgres-integrationPostgres Storage Contract Suites (live server)Every *::postgres contract suite against a live Postgres containerAdvisory
redis-cache-integrationRedis Node Cache Contract Suite (live server)node_cache::redis against a live Redis containerAdvisory
redis-queueRedis Run Queue Contract Suite (live server)run_queue::redis against a live Redis containerAdvisory
sdk-clientsGenerated SDK Clients (Python + TypeScript) smokeGenerates and smoke-tests the OpenAPI Python and TypeScript clients against a live paladin-serverAdvisory
e2e-platform-apiE2E Platform API (PRD 06 acceptance-1 lifecycle + SHIP-02 boot proof)The assistant → run → SSE → AwaitingInput → webhook → resume → history → fork lifecycle, plus the v0_9_config_boot backward-compat proofAdvisory
coverageCoverageWorkspace line-coverage measurement and floor gate (see excerpt below)Required
cli-testsCLI Snapshot Testscargo test -p paladin-ai --features cli --test cliRequired
bench-checkBenchmark Compile Checkcargo bench --workspace --no-run (compiles every [[bench]] target; runs none)Required
dockerDocker BuildMulti-arch image build and the 500 MB size budget; wall-clock is reported, not enforcedAdvisory
kubernetes-smokeKubernetes Smoke TestDeploys to a kind cluster and checks pod readinessAdvisory
e2e-testsEnd-to-End TestsFull Docker Compose stack end-to-end test; push-to-main onlyRequired
benchmark-regression-signalBenchmark Regression Signal (Non-Blocking)Criterion regression check on PRs/dispatch; continue-on-error: trueAdvisory
publish-dry-runPublish Dry Runcargo publish --workspace --dry-run; push-to-main onlyAdvisory

The coverage floor is not inlined in the workflow — the job delegates to the same script make coverage runs locally:

# excerpt: .github/workflows/ci.yml — job: coverage
      - name: Measure coverage
        env:
          USE_EXTERNAL_TEST_SERVICES: "true"
          TEST_REDIS_HOST: localhost
          TEST_REDIS_PORT: 6380
          TEST_MINIO_ENDPOINT: localhost:9010
          TEST_MINIO_ACCESS_KEY: testuser
          TEST_MINIO_SECRET_KEY: testpass123
        run: bash scripts/coverage.sh
# excerpt: scripts/coverage.sh
exec cargo llvm-cov --workspace --features integration-tests,llm-all \
    --lcov --output-path lcov.info --fail-under-lines "$FLOOR" -- --test-threads=1

$FLOOR defaults to 82 — the ADR-0006 coverage floor. See the Testing Guide for why the llm-all feature is load-bearing for that measurement.

codeql.yml — Rust SAST (advisory only)

codeql.yml runs Rust static analysis on every push, pull request and schedule (Wednesdays 07:00 UTC), reporting findings into the code-scanning UI. It is not pinned in any ruleset and does not gate a merge — CodeQL was evaluated and disqualified as a required-check-grade Rust SAST at CodeQL 2.26.3 (2026-08-25); the manual credential-handling review documented in .github/instructions/security.instructions.md stays the primary control for that class of code.

Docker Build Pipeline

Corrected 2026-08-24 (Phase 16 / DOCS-01). This section previously documented a docker-publish.yml workflow with a full YAML sample. No such workflow exists in .github/workflows/ and none ever did in this repository — the sample was fabricated. Docker image building and publishing is part of the release pipeline, described below and in Release Pipeline.

Container images are built and published by the build-docker job in .github/workflows/release.yml (release.yml:157), not by a standalone workflow.

AspectActual configurationSource
Registryghcr.iorelease.yml:21 (REGISTRY)
Multi-architectureQEMU + Buildxdocker/setup-qemu-action@v3, docker/setup-buildx-action@v3
Authenticationdocker/login-action@v3release.yml:175
Taggingdocker/metadata-action@v5release.yml:183
Published tags<version> and latestrelease.yml:146-147

Pull a published image with:

docker pull ghcr.io/<owner>/<image>:<version>
docker pull ghcr.io/<owner>/<image>:latest

The Dockerfiles themselves are described in Docker Deployment.

Release Pipeline

release.yml

release.yml triggers on a v*.*.* tag push or manual workflow_dispatch (with an optional dry_run input). An earlier version of this page described a single combined build-and-package job under a name that does not exist in the live file. The real jobs, in dependency order, are:

Job (release.yml)What it does
verify-tag-sourceThe tag-source guard: resolves the release commit and fails the whole run closed unless that commit is an ancestor of origin/main — enforces the "main is the source of truth" invariant before anything else runs
testcargo test --workspace; gates crates.io publishing only — Docker images and release binaries are not gated on it (a release with a failing test suite can still push an image and attach binaries, just not publish to crates.io)
create-releaseExtracts the matching ## [X.Y.Z] section from CHANGELOG.md and creates (or reuses) the GitHub release
build-dockerBuilds and pushes the multi-arch (linux/amd64, linux/arm64) image to ghcr.io
build-binariesCross-compiles and uploads release binaries for 4 platform targets (Linux amd64/arm64, macOS amd64/arm64)
check-release-consistencyPre-publish gate: fails closed if the tag disagrees with any publishable crate's manifest version, or with the tagged commit's own recorded CI conclusion
sbomGenerates a CycloneDX SBOM and uploads it to the release
finalize-release-bodyAggregates the Docker image digest, aggregated binary checksums, and SBOM asset name into the release body
publish-cratesPublishes to crates.io in dependency order via crates.io Trusted Publishing (short-lived OIDC token), after check-release-consistency and test both pass

verify-tag-source's guard is the reason a release tag must be cut from a merged PR into main rather than from a feature branch directly — see Branch Protection.

Integration Testing

ci.yml — integration-tests job

Integration testing runs as the integration-tests job inside ci.yml, absorbed from the former standalone integration-tests workflow file (deleted in commit 2cf9919). It shares ci.yml's trigger shown above rather than defining its own on: block.

jobs:
  integration-tests:
    name: Integration Tests
    runs-on: ubuntu-latest

    services:
      redis:
        image: redis:7-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 6379:6379

      minio:
        image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z.hotfix.7aa24e772
        env:
          MINIO_ROOT_USER: minioadmin
          MINIO_ROOT_PASSWORD: minioadmin
        options: >-
          --health-cmd "curl -f http://localhost:9000/minio/health/live"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 9000:9000

    steps:
      - uses: actions/checkout@v4

      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable

      - name: Wait for services
        run: |
          timeout 60 bash -c 'until curl -f http://localhost:9000/minio/health/live; do sleep 2; done'
          timeout 60 bash -c 'until redis-cli -h localhost ping; do sleep 2; done'

      - name: Run integration tests
        run: cargo test --features integration-tests --test '*_integration_test'
        env:
          REDIS_URL: redis://localhost:6379
          MINIO_ENDPOINT: localhost:9000
          MINIO_ACCESS_KEY: minioadmin
          MINIO_SECRET_KEY: minioadmin
          RUST_LOG: debug

      - name: Integration test coverage
        run: |
          cargo install cargo-llvm-cov
          cargo llvm-cov --features integration-tests --test '*_integration_test' --lcov --output-path integration-lcov.info

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: integration-lcov.info
          flags: integration

Security Scanning

Corrected 2026-08-24 (Phase 16 / DOCS-01). This section previously documented a security.yml workflow containing a Snyk job (snyk/actions/rust@master with a SNYK_TOKEN secret). No such workflow exists, and the Snyk step in particular contradicts a recorded project decision: Snyk was evaluated and removed on 2026-08-18 because it has no meaningful Rust coverage — a "clean" Snyk result on this workspace means nothing was analysed, which is worse than no scan because it reads as assurance. See .github/instructions/security.instructions.md. Do not reintroduce a Snyk step. The real security jobs are listed below.

Security scanning runs as three jobs inside .github/workflows/ci.yml:

JobNameWhat it checksLocation
security-auditSecurity Auditcargo audit against the RustSec advisory database, with exceptions declared in .cargo/audit.tomlci.yml:83
cargo-denyLicense & Dependency PolicyLicences, bans, sources and advisories via cargo-deny, plus the repository's own policy scripts (changelogs, crate names, advisory register, workflow suppressions and triggers)ci.yml:103
osv-scannerOSV ScannerOpen Source Vulnerabilities database scanci.yml:155

Run the dependency checks locally with the same tools CI uses:

make audit      # cargo-audit (RustSec advisory DB)
make deny       # cargo-deny (licenses, bans, sources, advisories)
make security   # both of the above
make sbom       # cargo-cyclonedx dependency inventory

Known gap, stated plainly: there is no static taint analysis (SAST) for first-party Rust in this pipeline. cargo-audit and cargo-deny scan dependencies; clippy is a lint. Evaluating a Rust-capable SAST is open work. Until then, credential-handling code is reviewed by hand per the manual checklist in security.instructions.md.

Deployment Automation

No workflow of this shape ships in this repository — the sample below (and the eight Best Practices fragments that follow it) is illustrative teaching material only; the real release path is release.yml, described above.

Deploy to Kubernetes

Illustrative only — no workflow of this shape ships in this repository; the real release path is release.yml.

name: Deploy

on:
  push:
    tags:
      - 'v*.*.*'
  workflow_dispatch:
    inputs:
      environment:
        description: 'Environment to deploy to'
        required: true
        type: choice
        options:
          - staging
          - production

jobs:
  deploy:
    name: Deploy to ${{ github.event.inputs.environment || 'production' }}
    runs-on: ubuntu-latest
    environment:
      name: ${{ github.event.inputs.environment || 'production' }}
      url: https://paladin.${{ github.event.inputs.environment || 'prod' }}.example.com

    steps:
      - uses: actions/checkout@v4

      - name: Configure kubectl
        uses: azure/k8s-set-context@v3
        with:
          method: kubeconfig
          kubeconfig: ${{ secrets.KUBE_CONFIG }}

      - name: Deploy with Helm
        run: |
          helm upgrade --install paladin ./paladin-chart \
            --namespace paladin \
            --create-namespace \
            --set image.tag=${{ github.ref_name }} \
            --set secrets.openaiApiKey=${{ secrets.OPENAI_API_KEY }} \
            --values values-${{ github.event.inputs.environment || 'production' }}.yaml \
            --wait

      - name: Verify deployment
        run: |
          kubectl rollout status deployment/paladin -n paladin
          kubectl get pods -n paladin

Best Practices

1. Branch Protection

Configure branch protection rules in GitHub:

Illustrative only — no workflow of this shape ships in this repository; the real release path is release.yml.

# Required status checks
- CI / check
- CI / test (ubuntu-latest, stable)
- CI / test (macos-latest, stable)
- CI / coverage
- Integration Tests

# Required reviews: 1
# Dismiss stale reviews: true
# Require linear history: true

2. Secrets Management

Store secrets in GitHub repository settings:

Illustrative only — no workflow of this shape ships in this repository; the real release path is release.yml.

# Required secrets
GITHUB_TOKEN          # Auto-provided
OPENAI_API_KEY        # For integration tests
KUBE_CONFIG           # For K8s deployment

3. Caching Strategy

Illustrative only — no workflow of this shape ships in this repository; the real release path is release.yml.

# Cache Cargo dependencies
- uses: actions/cache@v3
  with:
    path: |
      ~/.cargo/registry
      ~/.cargo/git
      target
    key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
    restore-keys: |
      ${{ runner.os }}-cargo-

4. Concurrency Control

Illustrative only — no workflow of this shape ships in this repository; the real release path is release.yml.

# Cancel in-progress runs for same PR
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

5. Conditional Workflows

Illustrative only — no workflow of this shape ships in this repository; the real release path is release.yml.

# Skip CI for docs-only changes
on:
  push:
    paths-ignore:
      - '**.md'
      - 'docs/**'

6. Matrix Testing

Illustrative only — no workflow of this shape ships in this repository; the real release path is release.yml.

strategy:
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]
    rust: [stable, beta, nightly]
  fail-fast: false  # Continue other jobs on failure

7. Artifact Retention

Illustrative only — no workflow of this shape ships in this repository; the real release path is release.yml.

- uses: actions/upload-artifact@v3
  with:
    name: test-results
    path: target/test-results/
    retention-days: 30

8. Notifications

Illustrative only — no workflow of this shape ships in this repository; the real release path is release.yml.

- name: Slack Notification
  if: failure()
  uses: 8398a7/action-slack@v3
  with:
    status: ${{ job.status }}
    webhook_url: ${{ secrets.SLACK_WEBHOOK }}

Next Steps