Opinionated reusable GitHub Actions workflows for Elixir projects. Ready workflows for testing, linting, static code analysis, documentation and building OCI compliant container images.
Runs tests and static analysis for Elixir projects.
- Parallel jobs for fast feedback (deps, audit, build, test, credo, dialyzer run concurrently)
- Credo static code analysis, with results uploaded to GitHub code scanning (SARIF)
- Dialyzer type checking with PLT caching
- Sobelow security scanner (Phoenix)
- PostgreSQL service container with Ash/Ecto support
- SQLite support for Ash/Ecto
- NPM install for Phoenix assets
- Hex organization support for private packages
The examples use @main to keep them readable. Our org policy requires pinning uses: to a full commit SHA, so in your own workflows write @<sha> # vX.Y.Z with the SHA of the release you want. Dependabot keeps those pins up to date.
name: Test on: push: branches: [main] pull_request: branches: [main] jobs: test: # Uses .tool-versions from your project uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main
Or specify versions explicitly:
jobs: test: uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main with: elixir-version: "1.19" otp-version: "28"
For projects where the Elixir app lives in a subdirectory:
jobs: test: uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main with: directory: apps/my-elixir-app postgres: ecto
| Name | Type | Default | Description |
|---|---|---|---|
directory |
string | . |
Project directory (for subdirectory support) |
elixir-version |
string | - | Elixir version (uses .tool-versions if not specified) |
otp-version |
string | - | Erlang/OTP version (uses .tool-versions if not specified) |
audit |
boolean | true |
Run hex.audit and deps.audit |
credo |
boolean | true |
Run Credo static analysis |
dialyzer |
boolean | true |
Run Dialyzer type checking |
sobelow |
boolean | false |
Run Sobelow security scanner (Phoenix projects) |
postgres |
string | - | PostgreSQL migrations: ash or ecto (starts service automatically) |
postgres-image |
string | postgres |
Docker image name without tag (e.g., timescale/timescaledb) |
postgres-version |
string | 18 |
Docker image tag for the postgres-image (e.g., 18, latest-pg18) |
sqlite |
string | - | SQLite migrations: ash or ecto |
npm-install |
boolean | false |
Run npm install for assets |
npm-working-directory |
string | assets |
Directory for npm install |
npm-registry |
string | - | Custom NPM registry URL (e.g., https://npm.pkg.github.com) |
node-version |
string | latest |
Node.js version |
spark-formatter |
boolean | false |
Check Spark DSL formatting |
spark-extensions |
string | - | Spark DSL extensions for formatter (multiline, one per line) |
hex-organization |
string | - | Hex organization for private packages |
apt-packages |
string | - | Space-separated APT packages to install (e.g., libvips-dev) |
env |
string | - | Environment variables for all jobs (one KEY=VALUE per line) |
artifacts |
string | - | Multiline artifact definitions to download (name:path per line) |
services |
string | - | Multiline YAML list of Docker sidecar services (see below) |
No permissions are required to run the workflow. To have Credo findings show up in GitHub code scanning (the Security tab and PR annotations), grant these in the calling workflow or job:
permissions: actions: read # Private and internal repos only: the upload reads the workflow run contents: read security-events: write # Upload SARIF to code scanning
Private and internal repos also need GitHub Advanced Security (Code Security) enabled. Without these permissions, on pull requests from forks, or without Advanced Security, the upload is skipped or fails harmlessly. Credo findings fail the build either way.
| Name | Required | Description |
|---|---|---|
hex-organization-key |
No | Hex organization auth key |
ssh-private-key |
No | SSH private key(s) for private Git repository access |
npm-token |
No | NPM authentication token for private registries |
Declare arbitrary Docker containers to run alongside your tests. Containers
share a dedicated ci-services network, publish their ports on localhost,
and are removed automatically when the job ends (including on failure).
jobs: test: uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main with: postgres: ecto services: | - name: redis image: redis:7-alpine ports: 6379:6379 health-cmd: redis-cli ping - name: mosquitto image: eclipse-mosquitto:2 ports: 1883:1883 volumes: $GITHUB_WORKSPACE/ci/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
Supported per-service fields: name (required), image (required), ports,
env, volumes, options (raw docker run flags), health-cmd,
health-timeout (seconds, default 60). Multi-value fields accept either a
YAML sequence or a pipe-block scalar with one entry per line.
When health-cmd is set, the step waits for the container to report healthy
before continuing. Without health-cmd, the caller is responsible for
readiness (e.g. retrying in test setup).
optionsis a raw pass-through todocker runthat word-splits on whitespace. Don't rely on embedded spaces in quoted values; useenv:orvolumes:for structured values.- Avoid
--networkand--nameinsideoptions; they override the action's own flags and break container isolation or naming. Extra--labelflags are fine as long asreusable-elixir-ci=1remains present so cleanup still matches. - On self-hosted runners shared by concurrent jobs, containers are labeled
reusable-elixir-ci=1regardless of which workflow started them. Parallel jobs using this feature on the same runner host can interfere during cleanup. GitHub-hosted runners (a fresh VM per job) are unaffected.
Builds and deploys Elixir documentation to GitHub Pages.
name: Docs on: push: branches: [main] jobs: docs: permissions: pages: write id-token: write uses: intility/reusable-elixir/.github/workflows/elixir-docs.yaml@main
| Name | Type | Default | Description |
|---|---|---|---|
elixir-version |
string | - | Elixir version (uses .tool-versions if not specified) |
otp-version |
string | - | Erlang/OTP version (uses .tool-versions if not specified) |
hex-organization |
string | - | Hex organization for private packages |
apt-packages |
string | - | Space-separated APT packages to install (e.g., libvips-dev) |
env |
string | - | Environment variables (one KEY=VALUE per line) |
artifacts |
string | - | Multiline artifact definitions to download (name:path per line) |
| Name | Required | Description |
|---|---|---|
hex-organization-key |
No | Hex organization auth key |
ssh-private-key |
No | SSH private key(s) for private Git repository access |
Important
This workflow expects your project to use the ocibuild library for building container images.
Builds and publishes Elixir releases as OCI container images or downloadable tarballs with supply chain security.
- Reproducible builds (opt-in) via
SOURCE_DATE_EPOCHfor consistent layer digests - Layer caching - unchanged layers (ERTS, deps) are skipped on upload
- SLSA provenance attestation for supply chain security
- Automatic OCI annotations (source URL, revision, version, created timestamp)
- SBOM generation (Software Bill of Materials) included in images
- Private Hex packages support via organization authentication
- Multi-platform builds support (requires
include_erts: false) - Release tarballs for deploying to regular servers (uploaded to GitHub Releases)
name: Release on: push: tags: ["*"] branches: [main] jobs: release: permissions: contents: read packages: write id-token: write attestations: write # Uses .tool-versions from your project uses: intility/reusable-elixir/.github/workflows/elixir-release.yaml@main with: source-date-epoch: "0" # Set mtime on all files in the archive to January 1, 1970 00:00:00 UTC to allow reproducible builds secrets: inherit
To build a release tarball and upload it to the GitHub Release instead of (or alongside) a container image:
jobs: release: permissions: contents: write # Upload release assets id-token: write # OIDC token for attestation attestations: write # Create build provenance attestation uses: intility/reusable-elixir/.github/workflows/elixir-release.yaml@main with: docker: false tarball: true secrets: inherit
The tarball is named <name>-<version>-<os>-<arch>.tar.gz and includes a .sha256 checksum file. Both are uploaded as assets to the GitHub Release matching the current tag. The <os> and <arch> reflect the runner the workflow executes on (linux-amd64; the release job runs on ubuntu-24.04, so bundled ERTS needs glibc 2.39 or newer on the target); tarballs are single-architecture regardless of the platforms input, which only affects multi-arch OCI images.
For umbrella projects, or any project with multiple releases configured, set the release input to select which release to package. Single-release projects are detected automatically.
Note
The tarball input requires a GitHub Release to exist for the current tag. Use it with tag-triggered workflows or release-please. Re-running the workflow will overwrite previously uploaded assets with the same name.
| Name | Type | Default | Description |
|---|---|---|---|
directory |
string | . |
Project directory |
elixir-version |
string | - | Elixir version (uses .tool-versions if not specified) |
otp-version |
string | - | Erlang/OTP version (uses .tool-versions if not specified) |
hex-organization |
string | - | Hex organization for private packages |
image-name |
string | - | Image name without registry prefix (e.g., my-app). Prefixed with ghcr.io/<owner>/. Defaults to repository name. |
docker |
boolean | true |
Build and push OCI image |
tarball |
boolean | false |
Build release tarball and upload to GitHub Release |
base-image |
string | - | Override base image (e.g., elixir:1.19-slim) |
platforms |
string | - | Multi-arch platforms (e.g., linux/amd64,linux/arm64) |
release |
string | - | Release name if multiple configured |
tags |
string | semver + branch/pr | Docker metadata tags |
source-date-epoch |
string | - | SOURCE_DATE_EPOCH for reproducible builds (use 0 for layer caching) |
assets-deploy |
boolean | false |
Run mix assets.deploy to build and digest static assets |
npm-install |
boolean | false |
Run npm install and mix assets.deploy |
npm-working-directory |
string | assets |
Directory for npm install |
npm-registry |
string | - | Custom NPM registry URL (e.g., https://npm.pkg.github.com) |
node-version |
string | latest |
Node.js version |
apt-packages |
string | - | Space-separated APT packages to install (e.g., libvips-dev) |
artifacts |
string | - | Multiline artifact definitions to download (name:path per line) |
| Name | Required | Description |
|---|---|---|
hex-organization-key |
No | Hex organization auth key |
ssh-private-key |
No | SSH private key(s) for private Git repository access |
npm-token |
No | NPM authentication token for private registries |
pull-username |
No | Username for pulling base image (defaults to github.actor) |
pull-password |
No | Password for pulling base image (defaults to GITHUB_TOKEN) |
| Name | Description |
|---|---|
digest |
The digest of the pushed OCI image (empty when docker is false) |
tarball-name |
The filename of the uploaded tarball (empty when tarball is false) |
permissions: contents: read # Checkout repository packages: write # Push to GitHub Container Registry id-token: write # OIDC token for attestation attestations: write # Create build provenance attestation
When using tarball: true, the contents permission must be write (instead of read) to upload release assets:
permissions: contents: write # Upload release assets (use 'read' for docker-only) packages: write # Push to GitHub Container Registry id-token: write # OIDC token for attestation attestations: write # Create build provenance attestation
Set source-date-epoch: "0" to enable reproducible builds and layer caching:
jobs: release: uses: intility/reusable-elixir/.github/workflows/elixir-release.yaml@main with: source-date-epoch: "0"
This ensures file timestamps are consistent across builds, enabling:
- Layer caching - unchanged ERTS/deps layers keep the same digest and are skipped on upload
- Reproducible builds - same source always produces the same image
- Registry deduplication - identical content = identical digests
The actual build timestamp is recorded in the org.opencontainers.image.created annotation.
Submits every Hex package in every mix.lock, transitive packages included, to the GitHub dependency graph.
GitHub's dependency graph does not read mix.lock, so Hex packages are missing from it. Dependabot only works on packages in the graph. This workflow finds every mix.lock that git tracks, parses it as text and submits the packages through the dependency submission API. Nothing is built or restored, and no repo code runs: the job only reads mix.lock and mix.exs files.
Run it on every push to main, and on a schedule so the snapshot stays current:
name: Dependency submission on: push: branches: [main] schedule: - cron: "0 5 * * *" workflow_dispatch: jobs: submit: permissions: contents: write # Used to submit the dependency snapshot uses: intility/reusable-elixir/.github/workflows/elixir-dependency-submission.yaml@main
The snapshot is attached to the commit that triggered the run, so only run it on the default branch.
| Name | Type | Default | Description |
|---|---|---|---|
directory |
string | . |
Directory to search. Every mix.lock that git tracks below it is submitted, so a monorepo needs one call |
correlator |
string | hex-dependency-submission-<directory> |
Snapshot correlator. GitHub replaces a snapshot with a later one that has the same correlator |
- Only public
hexpmpackages are submitted. Packages from a private Hex organization and Git dependencies are skipped, since a private package could share its name with a public one and match the wrong advisory. - A package is submitted as direct if it is listed as a
{:name, ...}dependency in themix.exsnext to itsmix.lock, and as indirect otherwise. For an umbrella (apps_path), themix.exsof its apps count too. Othermix.exsfiles below the directory (other projects, fixtures, path dependencies) do not count. Dependencies built by a helper function are missed and show as indirect. Every package gets the runtime scope, sincemix.lockdoes not record it. - All
mix.lockfiles go into one snapshot, one manifest each. A lock with no public packages is skipped. - The run fails, before anything is submitted, if no
mix.lockis found, none has public Hex packages, or a lock has entries it cannot parse. The snapshot replaces the previous one, so submitting the other locks would drop the alerts of the broken one. If a lock has nomix.exsnext to it, or none of its packages appears in thatmix.exs, the run warns and submits that lock's packages as indirect. Alerts match on name and version from the lock, so only the direct or indirect label is lost.
For projects that depend on native binaries (Rust, Go, C) or other pre-compiled assets, you can inject artifacts from upstream jobs:
name: CI on: push: branches: [main] jobs: build-native: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: cargo build --release working-directory: native/cel_evaluator - uses: actions/upload-artifact@v4 with: name: cel-evaluator path: native/cel_evaluator/target/release/cel_evaluator test: needs: [build-native] uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main with: artifacts: | cel-evaluator:apps/my_app/priv/bin release: needs: [build-native, test] permissions: contents: read packages: write id-token: write attestations: write uses: intility/reusable-elixir/.github/workflows/elixir-release.yaml@main with: artifacts: | cel-evaluator:apps/my_app/priv/bin secrets: inherit
Multiple artifacts can be specified, one per line:
with: artifacts: | cel-evaluator:apps/my_app/priv/bin wasm-module:apps/my_app/priv/wasm
Note
GitHub artifact uploads strip POSIX file permissions. Handle this on your end (e.g., File.chmod/2 at runtime) rather than expecting executable bits to be preserved.
See example.yaml for a complete workflow using both test and release.
name: CI on: push: tags: ["*"] branches: [main] pull_request: branches: [main] jobs: test: uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main with: postgres: ash release: needs: test if: github.event_name != 'pull_request' permissions: contents: read packages: write id-token: write attestations: write uses: intility/reusable-elixir/.github/workflows/elixir-release.yaml@main with: source-date-epoch: "0" secrets: inherit
elixir-test, elixir-docs and elixir-release take their Elixir/OTP versions from either a .tool-versions file or the elixir-version and otp-version inputs. A matrix is a way to feed those inputs; it is not a separate mechanism.
- Neither input set: the project's
.tool-versionsis used. - Both inputs set: a
.tool-versionsis generated from them in the project directory on the runner, replacing any existing one for that run. Your committed file is not changed. - Only one input set: the run fails.
elixir-versionandotp-versionmust be given together.
If your project has a .tool-versions file, simply omit the version inputs:
jobs: test: uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main # Uses versions from .tool-versions automatically
Example .tool-versions:
elixir 1.19.0
erlang 28.0
Specify both versions to override .tool-versions:
jobs: test: uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main with: elixir-version: "1.19" otp-version: "28"
To test several Elixir/OTP combinations, pass the matrix values as the explicit inputs. Every entry must set both:
jobs: test: strategy: fail-fast: false matrix: include: # Elixir 1.18 supports OTP 25-27 - elixir: "1.18" otp: "27" # Elixir 1.19 supports OTP 27-28 - elixir: "1.19" otp: "27" - elixir: "1.19" otp: "28" uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main with: elixir-version: ${{ matrix.elixir }} otp-version: ${{ matrix.otp }}
Note
When using a matrix, each combination spawns a separate workflow run with independent caching.
For projects using private Hex organization packages:
jobs: test: uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main with: hex-organization: intility secrets: hex-organization-key: ${{ secrets.HEX_ORGANIZATION_KEY }} release: needs: test uses: intility/reusable-elixir/.github/workflows/elixir-release.yaml@main with: hex-organization: intility secrets: hex-organization-key: ${{ secrets.HEX_ORGANIZATION_KEY }}
For projects with dependencies hosted in private Git repositories:
jobs: test: uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main secrets: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} release: needs: test permissions: contents: read packages: write id-token: write attestations: write uses: intility/reusable-elixir/.github/workflows/elixir-release.yaml@main secrets: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
Multiple keys are supported for accessing different repositories:
secrets: ssh-private-key: | ${{ secrets.REPO_A_DEPLOY_KEY }} ${{ secrets.REPO_B_DEPLOY_KEY }}
The SSH key(s) should have read access to your private repositories. You can combine this with Hex organization authentication if needed.
For Phoenix projects with assets from private NPM registries:
jobs: test: uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main with: npm-install: true npm-registry: https://npm.pkg.github.com secrets: npm-token: ${{ secrets.NPM_TOKEN }}
For GitHub Packages, you can use GITHUB_TOKEN:
secrets: npm-token: ${{ secrets.GITHUB_TOKEN }}
Set postgres to start a PostgreSQL service and run migrations:
jobs: test: uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main with: postgres: ash # Ash Framework: ash_postgres.create + migrate # or postgres: ecto # Ecto/Phoenix: ecto.create + migrate postgres-version: "18" # Optional, defaults to 18
For PostgreSQL-compatible images (e.g., TimescaleDB), override the image name:
jobs: test: uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main with: postgres: ash postgres-image: timescale/timescaledb postgres-version: latest-pg18
The service container image is built as {postgres-image}:{postgres-version}, defaulting to postgres:18.
Set sqlite to run SQLite migrations (no service needed):
jobs: test: uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main with: sqlite: ash # Ash Framework: ash_sqlite.create + migrate # or sqlite: ecto # Ecto: ecto.create + migrate
For Phoenix projects using Mix-based asset tools (Tailwind, esbuild) without npm:
jobs: release: permissions: contents: read packages: write id-token: write attestations: write uses: intility/reusable-elixir/.github/workflows/elixir-release.yaml@main with: assets-deploy: true secrets: inherit
For Phoenix projects with JavaScript assets managed by npm:
jobs: release: permissions: contents: read packages: write id-token: write attestations: write uses: intility/reusable-elixir/.github/workflows/elixir-release.yaml@main with: npm-install: true secrets: inherit
Both options run mix assets.deploy before building the release. Use npm-install when your assets require npm ci first, or assets-deploy when your project uses Mix-managed tools like Tailwind and esbuild directly.
For projects that need custom environment variables at compile time or test time (e.g., Cloak encryption keys):
jobs: test: uses: intility/reusable-elixir/.github/workflows/elixir-test.yaml@main with: env: | CLOAK_KEY=b23S/6av/mvyVEh27ksmXb+784i50afsp1FD0DUf87E= MY_OTHER_VAR=value
Environment variables are available in all jobs and all steps within the workflow.
The mix-cache composite action manages two independent caches:
- Deps cache — the
deps/directory, keyed by.tool-versionsandmix.lock - Build cache — the
_build/<env>directory, keyed by.tool-versions,mix.lock, and source files
By default both caches are active. Jobs that only need dependencies (formatting, linting, auditing) can skip the build cache:
- uses: intility/reusable-elixir/.github/actions/mix-cache@main with: mix-env: test cache-build: "false" # Only cache deps/
| Input | Type | Default | Description |
|---|---|---|---|
mix-env |
string | test |
MIX_ENV to cache for |
cache-build |
string | "true" |
Whether to cache _build/ (set "false" to skip) |
directory |
string | . |
Directory containing the Elixir project |
The elixir-test workflow uses this internally — deps-only jobs skip the build cache so that build-test is the first to save it, and downstream jobs (test, dialyzer) restore the full build without recompiling.
The workflows are built from lightweight composite actions in .github/actions/. These assume the environment is already set up (Elixir installed, deps cached) and can also be used independently in your own workflows.
| Action | Description |
|---|---|
install-elixir |
Install Elixir and OTP via erlef/setup-beam using .tool-versions (supports directory) |
mix-deps-get |
Install Hex, Rebar, and fetch dependencies (supports directory) |
mix-compile |
Compile project (mix-env required, optional args, supports directory) |
mix-task |
Run any mix task (task and mix-env required, supports directory) |
mix-test |
Run mix test (mix-env required, supports directory) |
mix-hex-audit |
Run mix hex.audit (supports directory) |
mix-dialyzer |
Run Dialyzer with automatic PLT caching (mix-env required, supports directory) |
mix-docs |
Compile and generate documentation (mix-env required) |
mix-cache |
Cache deps/ and _build/ directories (supports directory) |
setup-tool-versions |
Generate or use .tool-versions file (supports working-directory) |
ssh-agent |
Set up SSH agent for private Git repositories |
set-env |
Export environment variables from multiline input |
apt-packages |
Install system packages via apt-get |
download-artifacts |
Download workflow artifacts from multiline definitions (supports directory) |
ocibuild |
Build and push OCI images |
steps: - uses: actions/checkout@v4 - uses: intility/reusable-elixir/.github/actions/install-elixir@main - uses: intility/reusable-elixir/.github/actions/mix-cache@main with: mix-env: test - uses: intility/reusable-elixir/.github/actions/mix-deps-get@main - uses: intility/reusable-elixir/.github/actions/mix-compile@main with: mix-env: test - uses: intility/reusable-elixir/.github/actions/mix-task@main with: mix-env: test task: credo --strict
All actions support a directory input (default .) for monorepo projects:
steps: - uses: actions/checkout@v4 - uses: intility/reusable-elixir/.github/actions/install-elixir@main with: directory: apps/my-app - uses: intility/reusable-elixir/.github/actions/mix-cache@main with: mix-env: test directory: apps/my-app - uses: intility/reusable-elixir/.github/actions/mix-deps-get@main with: directory: apps/my-app - uses: intility/reusable-elixir/.github/actions/mix-test@main with: mix-env: test directory: apps/my-app
For multi-architecture images (requires include_erts: false in your release config):
jobs: release: uses: intility/reusable-elixir/.github/workflows/elixir-release.yaml@main with: base-image: "elixir:1.19-slim" platforms: "linux/amd64,linux/arm64"
Contributions are welcome. Please read CONTRIBUTING.md for guidelines on reporting issues, submitting pull requests, and the overall project structure.
This project is licensed under the MIT License.