A standardized CI/CD pipeline for Erlang/OTP projects.
Three ways to use it:
- Reusable CI workflow — a complete CI pipeline with parallel jobs
- Reusable release workflow — auto-tag and release from conventional commits
- Composite action — just setup + caching, bring your own jobs
Create .github/workflows/ci.yml in your project:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
otp-version: '28'This runs compile → fmt | xref | dialyzer | eunit in parallel.
After compile, all enabled steps run in parallel:
┌─ fmt ─────────────────────┐
├─ xref ────────────────────┤
├─ dialyzer ────────────────┤
├─ lint ────────────────────┤
├─ hank ────────────────────┤
├─ sheldon ─────────────────┤
├─ audit ───────────────────┤
compile ──────────► ├─ eunit ───────────────────┤
├─ ct ──────────────────────┤
├─ coverage ────────────────┤
├─ sbom ────────────────────┤
├─ mutate ──────────────────┤
├─ dependency-submission ───┤
├─ ex-doc ──────────────────┤
└───────────────────────────┘
Steps are automatically scoped to where they provide value. No configuration needed — the workflow detects the event type.
| Step | PR | Main | Rationale |
|---|---|---|---|
| Compile | Yes | Yes | Catch merge-induced breakage |
| EUnit / CT | Yes | Yes | Catch merge-induced breakage |
| Audit | Yes | Yes | Advisories can appear anytime |
| SBOM scan | Yes | Yes | Catch vulnerabilities early |
| Format / Xref | Yes | — | Already validated in PR |
| Dialyzer | Yes | — | Already validated in PR |
| Lint / Hank / Sheldon | Yes | — | Already validated in PR |
| ELP Lint / eqWAlize | Yes | — | Already validated in PR |
| Coverage | Yes | — | Already validated in PR |
| Mutation testing | Yes | — | Already PR-only (diff mode) |
| Summary comment | Yes | — | PR context only |
| Documentation (ex_doc) | — | Yes | Publish on merge |
| SBOM artifact upload | — | Yes | Persist for releases |
| Dependency submission | — | Yes | Update GitHub dep graph |
| Step | Default | Input | Requires |
|---|---|---|---|
| Compile | always | — | — |
Format (rebar3 fmt --check) |
on | enable-fmt |
erlfmt plugin |
| Xref | on | enable-xref |
— |
| Dialyzer | on | enable-dialyzer |
— |
Lint (rebar3 lint) |
off | enable-lint |
rebar3_lint plugin |
Hank (rebar3 hank) |
off | enable-hank |
rebar3_hank plugin |
Sheldon (rebar3 spellcheck) |
off | enable-sheldon |
rebar3_sheldon plugin |
| EUnit | on | enable-eunit |
— |
| Common Test | off | enable-ct |
— |
| ExDoc | off | enable-ex-doc |
rebar3_ex_doc plugin |
| Audit | off | enable-audit |
rebar3_audit plugin |
| Coverage | off | enable-coverage |
covertool plugin + {cover_enabled, true} |
| SBOM | off | enable-sbom |
rebar3_sbom plugin |
| SBOM vulnerability scan | off | enable-sbom-scan |
rebar3_sbom plugin + enable-sbom |
| Mutation Testing | off | enable-mutate |
rebar3_mutate plugin |
| Dependency Submission | off | enable-dependency-submission |
— (self-contained) |
A reusable workflow that auto-tags and creates GitHub releases from conventional commits using git-cliff.
How it works:
- Analyzes commits since the last tag
- Determines the next semver (
feat:→ minor,fix:→ patch, breaking → major) - Creates a git tag and pushes it
- Creates a GitHub release with auto-generated changelog
- Skips silently if no version bump is needed
Usage:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
otp-version: '28'
release:
needs: ci
if: github.event_name == 'push'
uses: Taure/erlang-ci/.github/workflows/release.yml@v1
permissions:
contents: write
secrets: inheritRequirements:
- Conventional commit messages (
feat:,fix:,chore:,docs:,refactor:,test:) - A
cliff.tomlin your project root (copy from this repo)
| Input | Default | Description |
|---|---|---|
cliff-config |
cliff.toml |
Path to git-cliff config file |
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
otp-version: '28'Tests run on all OTP versions. Dialyzer, xref, and fmt run on the primary version only.
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
otp-version: '28'
otp-matrix: '["27", "28"]'When postgres: true is set, eunit, CT, and mutation testing jobs get a PostgreSQL service container with built-in health checks (the job waits until PostgreSQL is ready). PG connection details are available as environment variables (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE).
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
otp-version: '28'
enable-ct: true
postgres: true
postgres-version: '17'
postgres-db: 'myapp_test'jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
otp-version: '28'
enable-ct: true
kafka: trueKafka runs in KRaft mode (no ZooKeeper) with built-in health checks. It is available at localhost:9092. The environment variables KAFKA_HOST and KAFKA_PORT are set for test configuration.
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
otp-version: '28'
enable-ct: true
postgres: true
kafka: trueMutation testing verifies your tests can detect real bugs by introducing small code changes (mutants) and checking that tests catch them. On PRs, only changed code is mutated via --diff.
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
otp-version: '28'
enable-mutate: true
mutate-min-score: '80'Set mutate-min-score to fail the build if the mutation score drops below a threshold. Use mutate-test-framework: ct if your tests are Common Test suites.
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
permissions:
contents: write
pull-requests: write
secrets: inherit
with:
otp-version: '28'
otp-matrix: '["27", "28"]'
enable-ct: true
enable-ex-doc: true
enable-audit: true
enable-lint: true
enable-hank: true
enable-sheldon: true
enable-coverage: true
enable-sbom: true
enable-sbom-scan: true
enable-dependency-submission: true
enable-mutate: true
mutate-min-score: '80'
postgres: true
release:
needs: ci
if: github.event_name == 'push'
uses: Taure/erlang-ci/.github/workflows/release.yml@v1
permissions:
contents: write
secrets: inheritRequired rebar.config plugins for the full pipeline:
{project_plugins, [
erlfmt,
rebar3_ex_doc,
rebar3_lint,
rebar3_hank,
rebar3_sheldon,
rebar3_audit,
covertool,
rebar3_sbom,
rebar3_mutate
]}.
{cover_enabled, true}.jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
permissions:
contents: write
pull-requests: write
secrets:
ssh-key: ${{ secrets.PRIVATE_DEPS_SSH_KEY }}
hex-api-key: ${{ secrets.HEX_API_KEY }}
with:
otp-version: '28'
enable-ct: true
enable-audit: true
enable-coverage: true
enable-sbom: true
enable-sbom-scan: true
postgres: true
postgres-db: 'myapp_test'
kafka: true
extra-services-compose: docker-compose.test.yml
pre-test-command: |
rebar3 kura migrate
./scripts/create_kafka_topics.shServices (PostgreSQL, Kafka) use native GitHub Actions service containers with built-in health checks — the job won't start until all services are healthy. The extra-services-compose input is the escape hatch for additional services (authz-mock, fake-gcs-server, etc.) that aren't built in.
If your project has a .tool-versions file, it will be used automatically — no configuration needed:
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1You can also point to a specific file explicitly:
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
version-file: 'mise.toml'If you prefer writing your own workflow but want the setup and caching handled:
steps:
- uses: actions/checkout@v4
- uses: Taure/erlang-ci@v1
with:
otp-version: '28'
- run: rebar3 compile
- run: rebar3 eunitThe composite action handles:
- Installing Erlang/OTP and rebar3 via erlef/setup-beam
- Caching
~/.cache/rebar3(hex packages, plugins) - Caching
_build(compiled dependencies)
Reusable workflows run as complete jobs — you cannot inject steps into them. Instead, add your own jobs alongside the reusable workflow and use needs: to control execution order.
Every job in a workflow file runs in parallel by default. Adding needs: job_name makes a job wait until job_name completes successfully. This is how you build a pipeline:
ci ──► black-box ──► release
jobs:
ci: # 1. runs first (no needs)
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
otp-version: '28'
black-box: # 2. runs after ci passes
needs: ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Taure/erlang-ci@v1
with:
otp-version: '28'
- run: rebar3 release
- run: ./scripts/black_box_tests.sh
release: # 3. runs after black-box passes, only on merge
needs: black-box
if: github.event_name == 'push'
uses: Taure/erlang-ci/.github/workflows/release.yml@v1
permissions:
contents: writeYou can require multiple jobs with a list — the job waits for all of them:
┌─ ci ──────────┐
start ─┤ ├─► deploy
└─ security ────┘
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
otp-version: '28'
security: # runs in parallel with ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: company/security-scanner@v2
deploy: # waits for both ci AND security
needs: [ci, security]
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: company/deploy-action@v1ci ──► black-box ──► deploy-staging ──► release
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
permissions:
contents: write
pull-requests: write
with:
otp-version: '28'
enable-ct: true
enable-audit: true
postgres: true
black-box:
needs: ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Taure/erlang-ci@v1
with:
otp-version: '28'
- run: rebar3 release
- run: ./scripts/black_box_tests.sh
deploy-staging:
needs: black-box
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: company/deploy-action@v1
with:
environment: staging
release:
needs: deploy-staging
if: github.event_name == 'push'
uses: Taure/erlang-ci/.github/workflows/release.yml@v1
permissions:
contents: writeFor organizations that want to enforce additional steps across all repos, create an internal wrapper workflow. Reusable workflows can nest up to 10 levels deep.
# company/.github/workflows/erlang-ci.yml
name: Company Erlang CI
on:
workflow_call:
inputs:
otp-version:
type: string
default: '28'
enable-ct:
type: boolean
default: false
postgres:
type: boolean
default: false
pre-test-command:
type: string
default: ''
extra-services-compose:
type: string
default: ''
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
permissions:
contents: write
pull-requests: write
secrets: inherit # passes ssh-key, hex-api-key from caller
with:
otp-version: ${{ inputs.otp-version }}
enable-ct: ${{ inputs.enable-ct }}
enable-audit: true
enable-dependency-submission: true
postgres: ${{ inputs.postgres }}
pre-test-command: ${{ inputs.pre-test-command }}
extra-services-compose: ${{ inputs.extra-services-compose }}
compliance:
needs: ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: company/license-checker@v1
- uses: company/sbom-attestation@v1Individual repos then call the company wrapper with minimal config:
jobs:
ci:
uses: company/.github/workflows/erlang-ci.yml@v1
secrets: inherit
with:
otp-version: '28'
enable-ct: true
postgres: true
pre-test-command: |
rebar3 kura migrateWhen the reusable workflow is too opinionated, use the composite action directly and build your own pipeline:
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Taure/erlang-ci@v1
with:
otp-version: '28'
- run: rebar3 compile
- run: rebar3 fmt --check
- run: rebar3 xref
- run: rebar3 eunit
# Add whatever custom steps you need
- run: ./scripts/custom_checks.sh
- uses: company/notify-slack@v1
if: failure()| Approach | When to use |
|---|---|
| Reusable workflow only | Standard Erlang library, no custom steps needed |
| Reusable workflow + extra jobs | Need to add steps before/after the standard pipeline |
| Company wrapper workflow | Enforce org-wide policies across all repos |
| Composite action only | Need full control over job structure and step order |
| Input | Default | Description |
|---|---|---|
otp-version |
28 |
Erlang/OTP version |
rebar3-version |
3 |
Rebar3 version |
version-file |
— | Read versions from .tool-versions or mise.toml |
version-type |
— | Version match type (strict or loose). Defaults to strict when version-file is set |
otp-matrix |
— | JSON array of OTP versions for matrix testing (e.g. '["27","28"]') |
| Input | Default | Description |
|---|---|---|
enable-fmt |
true |
Run rebar3 fmt --check |
enable-xref |
true |
Run rebar3 xref |
enable-dialyzer |
true |
Run rebar3 dialyzer (with PLT caching) |
enable-eunit |
true |
Run rebar3 eunit |
enable-ct |
false |
Run rebar3 ct |
enable-ex-doc |
false |
Run rebar3 ex_doc |
enable-audit |
false |
Run rebar3 audit (dep vulnerability scanning) |
audit-level |
low |
Minimum severity to fail on (critical, high, medium, low) |
enable-coverage |
false |
Coverage via covertool (reported in PR summary) |
enable-sbom |
false |
Generate CycloneDX SBOM via rebar3 sbom |
enable-sbom-scan |
false |
Scan SBOM for vulnerabilities using Trivy (requires enable-sbom) |
enable-dependency-submission |
false |
Submit deps to GitHub Dependency Graph |
enable-mutate |
false |
Run mutation testing via rebar3 mutate |
mutate-min-score |
— | Minimum mutation score (0-100), fail if below |
mutate-test-framework |
eunit |
Test framework for mutation testing (eunit or ct) |
mutate-args |
— | Extra args for rebar3 mutate |
enable-summary |
true |
Post CI summary comment on PRs (coverage, audit, SBOM scan results) |
| Input | Default | Description |
|---|---|---|
pre-test-command |
— | Shell command to run before tests (e.g., DB migrations, Kafka topic creation) |
extra-services-compose |
— | Path to docker-compose.yml for additional services |
pre-test-command runs in eunit, CT, and mutation testing jobs after services are started:
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
otp-version: '28'
enable-ct: true
postgres: true
pre-test-command: |
rebar3 kura migrate
./scripts/create_kafka_topics.shextra-services-compose starts additional Docker services alongside the built-in ones:
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
otp-version: '28'
enable-ct: true
extra-services-compose: docker-compose.test.yml| Input | Default | Description |
|---|---|---|
cache-key-prefix |
erlang-ci |
Custom cache key prefix for cache isolation |
| Input | Default | Description |
|---|---|---|
postgres |
false |
Enable PostgreSQL service for eunit and CT |
postgres-version |
17 |
PostgreSQL Docker image version |
postgres-db |
test_db |
Database name |
postgres-user |
postgres |
Username |
postgres-password |
postgres |
Password |
postgres-port |
5432 |
Host port |
| Input | Default | Description |
|---|---|---|
kafka |
false |
Enable Kafka service for eunit and CT |
kafka-version |
3.9 |
Apache Kafka Docker image version |
kafka-port |
9092 |
Broker port |
| Input | Default | Description |
|---|---|---|
ct-config |
— | Path to CT sys.config file |
ct-args |
— | Extra args for rebar3 ct |
eunit-args |
— | Extra args for rebar3 eunit (e.g. --module=foo_tests) |
rebar3-compile-args |
— | Extra args for rebar3 compile |
| Secret | Description |
|---|---|
ssh-key |
SSH private key for accessing private git dependencies |
hex-api-key |
Hex.pm API key for accessing private packages |
For projects with private rebar3 deps ({dep, {git, "git@github.com:org/repo.git", ...}}), pass an SSH key so rebar3 compile can fetch them:
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
secrets:
ssh-key: ${{ secrets.PRIVATE_DEPS_SSH_KEY }}
with:
otp-version: '28'The composite action accepts ssh-key as an input:
steps:
- uses: actions/checkout@v4
- uses: Taure/erlang-ci@v1
with:
otp-version: '28'
ssh-key: ${{ secrets.PRIVATE_DEPS_SSH_KEY }}
- run: rebar3 compileFor private Hex packages, pass hex-api-key:
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
secrets:
hex-api-key: ${{ secrets.HEX_API_KEY }}
with:
otp-version: '28'Both secrets can be combined:
jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
secrets:
ssh-key: ${{ secrets.PRIVATE_DEPS_SSH_KEY }}
hex-api-key: ${{ secrets.HEX_API_KEY }}
with:
otp-version: '28'When enable-summary is enabled and any reporting feature is active (enable-coverage, enable-audit, or enable-sbom-scan), a single unified comment is posted on PRs with all results. The comment is updated on re-runs (never duplicated).
Requires pull-requests: write permission on the caller's ci job.
1042 of 1193 lines covered.
The badge color reflects coverage level: green (90%+), yellow (70%+), orange (50%+), red (below 50%).
Clean scan:
No vulnerabilities found in 5 dependencies.
Vulnerabilities found:
Severity Package Version Advisory Fix 🔴 Critical pgo 0.14.0GHSA-xxxx (CVE-2025-0001) Upgrade to 0.15.0🟡 Medium cowlib 2.12.0GHSA-yyyy No fix available
Each vulnerability includes an expandable details section with the full description and vulnerable version range.
The audit-level input controls the minimum severity that causes the job to fail (default: low — all vulnerabilities fail the build). Set to high or critical to allow lower-severity issues to pass.
Clean scan:
No vulnerabilities found.
Vulnerabilities found:
Severity Package Version Vulnerability Fix 🔴 Critical pgo 0.14.0CVE-2025-9999 Upgrade to 0.15.0
SBOM scan uses Trivy against a CycloneDX SBOM generated by rebar3 sbom. The trivy-action is pinned by SHA to mitigate supply chain attacks (see trivy-action incident, March 2026). The build fails on high or critical severity vulnerabilities. Duplicate matches (same CVE + package from multiple data sources) are deduplicated automatically.
When both audit and SBOM scan are enabled, both sections appear in the same PR comment separated by a divider.
All mutants caught:
12 mutants tested. 12 killed.
Some mutants survived:
20 mutants tested. 15 killed, 4 survived, 1 timed out.
The badge color reflects mutation score: green (80%+), yellow (60%+), orange (40%+), red (below 40%).
On PRs, only code changed in the PR is mutated (--diff). Set mutate-min-score to enforce a quality gate — the job fails if the score drops below the threshold.
A Common Test suite template for testing Kura migration rollbacks is available at templates/migration_rollback_SUITE.erl. It rolls back every migration one by one, verifies a clean state, then re-applies all migrations.
A default git-cliff config for conventional commits is available at cliff.toml. Copy it to your project root to use with the release workflow.
These projects use erlang-ci:
| Project | Config |
|---|---|
| Nova | OTP matrix 26/27/28, fmt, + nova_request_app integration |
| Kura | PostgreSQL, CT, eunit, ex_doc, audit, coverage, SBOM, dependency submission, auto-release |
| rebar3_fly | OTP matrix 27/28, ex_doc |
| rebar3_kura | OTP matrix 27/28 |
| rebar3_audit | Standard + custom dogfood job |
Instead of copying 50-120 lines of boilerplate YAML into every Erlang project:
# before: setup-beam, cache config, compile, fmt, xref, dialyzer, eunit...
# repeated in every repo, drifting apart over time
# after:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v1
with:
otp-version: '28'Apache-2.0