18 minutes read
Runtime drift
offing drift finds every place a repository states a runtime version — version files, manifests, container images, and CI configuration — and reports, with exact locations, when the values disagree. A Dockerfile on Node.js 18, CI on 20, and .nvmrc on 22 is one line of output instead of three files to compare by hand.
Every command in this page reads repository files only. Drift checks never run a runtime, execute a repository's tools, or use the network. --align prints a patch and never writes it.
offing drift . --check
offing drift . --runtime ruby --json
offing drift . --runtime ruby --align 3.4
How to read the report
Each runtime block lists every value with its path:line, the file or field it came from, the value as written, and the release line it resolves to. A value that cannot be resolved offline is reported too, with the reason. When the pin values select more than one release, the block says DRIFT. A floor is a minimum the project supports; when a floor is above the lowest pinned release that applies to it, the block says FLOOR CONFLICT. A floor that names a directory is checked against the pins that apply to that directory, not against every pin.
--check exits 1 when any runtime drifted or has a floor conflict. --align <release> requires --runtime, prints a unified diff for the pins it can rewrite, lists refused pins on stderr, and never writes a file. It refuses floors and ranges, and each collector documents the files it can rewrite.
Adding a collector
One collector per runtime lives in src/drift/<runtime>.ts, reuses the parsers the detectors already have, and is registered once in driftCollectors in src/drift/index.ts. Each section below names the sources it reads, its release granularity, what it refuses to resolve, and which file it can align.
Ruby
Reads, at any depth:
.ruby-version, including the rbenvruby-3.3.1and3.3.1p123forms..tool-versionsrubylines, including several fallback versions.Gemfile: an exactruby "3.3.0"is a pin; a literal requirement such asruby ">= 3.1.0"orruby "~> 3.2"is a floor.Gemfile.lockRUBY VERSION.*.gemspec: a literalrequired_ruby_version, such as">= 3.1.0"or"~> 3.2", as a floor.Dockerfile*andContainerfile*:FROM ruby:<tag>and a literalRUN asdf install ruby <version>..github/workflows/*.yml: theruby-versioninput ofruby/setup-ruby..gitlab-ci.ymlimage:values and.circleci/config.ymldocker:images for the officialrubyandcimg/rubyimages.
Release granularity is major.minor (3.3).
Unresolved values are reported with the reason and excluded from the drift comparison: system, latest, an image without a tag, a moving image tag, a ${{ matrix.* }} expression, and a requirement that states no minimum.
align <release> rewrites .ruby-version, a single-value .tool-versions ruby line, an exact Gemfile ruby directive, a ruby/setup-ruby ruby-version input, and the tag of a Dockerfile, GitLab CI, or CircleCI image. It refuses floors, ranges, image digests, and multi-value lines. Gemfile.lock is reported and refused with "regenerate the lockfile": Bundler owns that file.
offing drift finds every place a repository states a runtime version and reports, per runtime, whether those values agree. A repository that runs Node 18 in its Dockerfile, Node 20 in CI, and Node 22 in .nvmrc has a drift, even when every test passes on the version the developer uses. The command reads repository files only: scans never use the network and never execute repository code.
offing drift . # text report
offing drift . --json # https://github.com/velella-sh/offing/schemas/drift.v1.schema.json
offing drift . --check # exit 1 when a runtime drifts or has a floor conflict
offing drift . --runtime java --align 21 # print a patch; never write it
--json prints an offing.drift.v1 document. Every pin carries its repository-relative path, the 1-based line of the value, the source it came from, the value exactly as written, and the release it resolves to offline. A value that cannot be resolved offline keeps its place in the report with an unresolved reason instead of a guessed release.
--align <release> prints a unified diff for the pins of one runtime that can be rewritten safely, and lists the refused pins on stderr. Offing never writes the patch.
Java
Java releases are compared at the feature-release major: 17, 21, 25. A legacy 1.8 resolves to 8; a vendor prefix (temurin-) or suffix (-tem) is ignored. The vendor never changes support status.
| File | Fields read |
|---|---|
pom.xml |
maven.compiler.release, maven.compiler.source, maven.compiler.target, and java.version properties; release, source, and target in maven-compiler-plugin configuration, including pluginManagement. |
build.gradle, build.gradle.kts |
java.toolchain.languageVersion (.set(...) or =), jvmToolchain(N), sourceCompatibility, and targetCompatibility, as literal values only. |
.java-version |
The first non-comment line. |
.sdkmanrc |
The java= entry. |
.tool-versions |
The first version of the java entry. |
Dockerfile, *.Dockerfile, Containerfile* |
FROM images of the eclipse-temurin, openjdk, and amazoncorretto families; maven and gradle tags that name a JDK, such as 3.9-eclipse-temurin-17 or 8.10-jdk21; and sdk install java or asdf install java RUN lines. |
.github/workflows/*.yml |
The java-version input of actions/setup-java. A ${{ matrix.<key> }} value is followed to the matrix list in the same job. |
.gitlab-ci.yml, .circleci/config.yml |
image references of the same JDK and build-image families. |
The Maven release, source, target, and java.version properties and the Gradle sourceCompatibility and targetCompatibility settings are floors: they say the oldest JDK the compiled code runs on, not the JDK that builds it, so a build on JDK 21 that compiles for 17 is not drift. Everything else is a pin: the JDK that builds or runs the project. --align rewrites pins only. |
|
| Reported as unresolved, never guessed: |
- a Maven property reference such as
${java.version}; - a Gradle expression such as
JavaVersion.toVersion(libs.versions.java.get()); - a matrix or other workflow expression that does not resolve to a literal;
latest,lts,system, or another floating version-file value;- an image with no tag, a floating tag, or an image reference containing a variable.
--alignrewrites only values it can select a release with: - Maven compiler properties and
maven-compiler-pluginsettings whose text is a literal version; .java-version,.sdkmanrc, and.tool-versionsentries with a literal version;- an image tag that is a plain major, such as
eclipse-temurin:17, or the JDK segment of a Maven or Gradle tag, such as3.9-eclipse-temurin-17; - a single scalar
java-versioninactions/setup-java. It refuses Gradle toolchain and compatibility settings,Dockerfile RUNinstalls, property references, floating tags, matrix values, and image tags with a digest or a non-major suffix.
Drift
offing drift reads every place a repository states a runtime version and reports, with exact file and line, when those values select different release lines. A repository with Python 3.10 in .python-version, 3.11 in its Dockerfile, and 3.12 in CI has drift: tests pass on one version while production runs another.
Scans read repository files and nothing else. No drift path uses the network, runs a command, or evaluates repository code, and --align prints patches instead of writing files.
offing drift [path] [--json] [--check] [--runtime
--checkexits1when any runtime has drifted or has a floor conflict.--jsonprints theoffing.drift.v1document described byschemas/drift.v1.schema.json.--runtime <id>limits the report to one runtime.--align <release>requires--runtime, and prints a unified diff that rewrites the pins it understands torelease. It lists pins it refuses on stderr. Each runtime is reported as its own block. Every pin carries its location, the source it came from, the value as written, and the release line the value selects offline. A value that cannot be resolved carries the reason instead of a guess. Aflooris a minimum the project supports, not the version that builds it; a floor above the lowest pin is reported as a floor conflict. Release granularity differs by runtime. Each section below states the granularity it reports and the values it leaves unresolved.
.NET
.NET pins are reported at major.minor granularity: the SDK 8.0.204, the image tag 8.0-alpine, and the moniker net8.0 all select 8.0.
netcoreapp below 5 resolves to its own name (netcoreapp3.1) and is not compared with netX.Y releases. netstandard* is a compatibility contract that many runtimes satisfy, so it is reported as unresolved and never counted as drift, as are .NET Framework monikers such as net48.
Pins
| Source | Field | Align |
|---|---|---|
global.json |
sdk.version, wherever the file appears |
yes, keeping rollForward |
*.csproj, *.fsproj, *.vbproj |
a single TargetFramework in a PropertyGroup |
yes |
Directory.Build.props |
a single TargetFramework |
yes |
Dockerfile, *.Dockerfile, Containerfile |
FROM mcr.microsoft.com/dotnet/{sdk,aspnet,runtime,runtime-deps}:<tag>, including legacy dotnet/core/* images and --platform |
yes |
| GitHub Actions | actions/setup-dotnet dotnet-version, one pin per line of a multi-line value |
single value only |
.gitlab-ci.yml |
image and service values that name a Microsoft .NET image |
no |
.circleci/config.yml |
job and executor docker/machine image values that name a Microsoft .NET image |
no |
azure-pipelines.yml |
container and resources.containers[].image values that name a Microsoft .NET image |
no |
A Microsoft .NET image is mcr.microsoft.com/dotnet/sdk, aspnet, runtime, or runtime-deps, or the legacy dotnet/core/* equivalents. Image tags keep their suffix: 8.0-alpine selects 8.0. |
||
| Multi-targeting | ||
<TargetFrameworks> with several entries is intentional multi-targeting. Each entry is reported as a pin with source TargetFrameworks, the entries share one group, and they are not counted against each other. A source outside the list still counts: TargetFrameworks of net6.0;net8.0 agrees with a Dockerfile that selects 8.0, but drifts when the Dockerfile selects 9.0. --align refuses the multi-targeting form. |
||
| Unresolved | ||
A .NET Framework moniker, an MSBuild property reference such as $(Tfm), a version with no minor (8, latest), a dynamic image reference, an expression, an image with no tag, and any value Offing does not read are reported as pins with a one-sentence reason and no release. A value is never guessed from a newer version existing. |
||
| Align | ||
--align rewrites global.json sdk.version (keeping rollForward), a single TargetFramework, FROM tags that preserve the image suffix, and a single-value setup-dotnet dotnet-version. It refuses TargetFrameworks, MSBuild property references, .NET Framework monikers, CI image references, and any line whose form it does not recognize. |
||
offing drift reports every place a repository states a runtime version: Dockerfiles, CI configuration, version files, manifests, and toolchain files. Each value is listed with its exact file, line, source, and the release line it resolves to offline. When the values disagree, the runtime is marked DRIFT; when a minimum the project requires is above the version that builds it, the value is listed as a floor conflict. |
||
| offing drift . # what the repository states, by runtime | ||
| offing drift . --check # exit 1 when any runtime disagrees | ||
| offing drift . --runtime rust --align 1.82 # print a patch; nothing is written | ||
| offing drift . --runtime go --align 1.22 # print a patch; nothing is written | ||
Drift reads repository files only. It never uses the network, never executes repository code or tools, and never writes a file. --align prints a unified diff for review; apply it yourself, for example with git apply. |
||
| A value is a pin when it selects the version that builds or runs the project, and a floor when it states a minimum the project supports. Floors are compared with the lowest pinned release: a floor above it is a conflict, because the environment the pin selects cannot satisfy the project. | ||
| Values that cannot be resolved offline are still reported, with the reason. Offing never guesses a release. |
Rust
Release granularity is 1.minor: the toolchain channel 1.79.0, the image tag 1.79.0-slim-bookworm, and the .tool-versions entry 1.79.0 all resolve to 1.79. A rust-toolchain channel selects the toolchain that builds the project, so it is a pin; rust-version in Cargo.toml states the minimum the project supports, so it is a floor.
| Source | Field | Kind |
| rust-toolchain.toml, rust-toolchain | channel (or the one-line legacy form) | pin |
| .tool-versions | rust entry | pin |
| Dockerfile*, Containerfile*, *.Dockerfile | FROM rust:<tag> | pin |
| .github/workflows/*.yml | dtolnay/rust-toolchain@<ref> | pin |
| .github/workflows/*.yml | dtolnay/rust-toolchain toolchain: input | pin |
| .github/workflows/*.yml | actions-rs/toolchain toolchain: input | pin |
| .github/workflows/*.yml | job container: and services.<name>.image rust image | pin |
| .gitlab-ci.yml | image: or services: rust image | pin |
| .circleci/config.yml | Docker executor rust image | pin |
| azure-pipelines.yml | job container: rust image | pin |
| Cargo.toml | package.rust-version and workspace.package.rust-version | floor |
The Rust collector reuses the existing rust-toolchain and Cargo.toml parsers and reads Dockerfile FROM lines with the container parser. It reports these values as unresolved, without a release: stable, beta, nightly, and dated nightlies, a floating image tag such as rust:latest, an image with no tag, a tag that is not 1.minor, an expression that is not a literal version, and a .tool-versions entry that names a ref: or path:.
--align rewrites channel = "1.x" lines and the one-line rust-toolchain, the rust entry of .tool-versions, rust:<version>[-variant] tags in Dockerfiles and CI configuration, dtolnay/rust-toolchain@<ref>, and the toolchain: input of dtolnay/rust-toolchain and actions-rs/toolchain. It keeps the target or image variant suffix. It refuses floors, named channels, expressions, and images pinned by digest.
Go
Release granularity is 1.minor: go1.22.4 and the image tag 1.22.4-alpine both resolve to 1.22. A go directive is a minimum since Go 1.21 (Go toolchains, go directive), so it is a floor; a toolchain line selects the toolchain that builds the module, so it is a pin.
| go.mod | toolchain go1.x.y | pin |
| go.work | toolchain go1.x.y | pin |
| go.mod | go 1.x[.y] | floor |
| go.work | go 1.x[.y] | floor |
| .go-version | first non-comment line | pin |
| .tool-versions | golang entry | pin |
| Dockerfile*, Containerfile* | FROM golang:<tag> | pin |
| .github/workflows/*.yml | actions/setup-go go-version | pin |
| .github/workflows/*.yml | actions/setup-go go-version-file | pin, pointing at the named file |
| .gitlab-ci.yml | image: or services: golang image | pin |
| .circleci/config.yml | Docker executor golang image | pin |
| azure-pipelines.yml | job container: golang image | pin |
The Go collector reuses the existing go.mod and go.work parsers and reads Dockerfile FROM lines with the container parser. It reports these values as unresolved, without a release: a floating image tag such as golang:latest, an image with no tag, a tag that is not major.minor, a stable or oldstable go-version, an expression that is not a matrix reference, a matrix entry that is not a plain version, and a .go-version or .tool-versions value that is not a Go version.
--align rewrites toolchain go1.x.y lines, .go-version, the golang entry of .tool-versions, FROM golang:<version>[-variant] (keeping the variant suffix), and a single-value actions/setup-go go-version. It refuses floors, matrix and expression values, go-version-file pointers, and floating tags.
Python
Python pins are reported at major.minor granularity: 3.11.4, 3.11, and python-3.11 all select 3.11. Version files and manifests are read at any depth, so a monorepo reports each service.
| .python-version | every line | yes |
| .tool-versions | the python line, one pin per version | yes |
| runtime.txt | python-<version> | yes |
| Pipfile | [requires] python_version and python_full_version | yes |
| Dockerfile, *.Dockerfile, Containerfile | FROM python:<version>[-variant], including --platform and all stages | yes |
| GitHub Actions | actions/setup-python python-version, following a ${{ matrix.<key> }} reference to its literal list; matrix values keep one pin each | single value only |
| .gitlab-ci.yml | image and service values that name a Python image | no |
| .circleci/config.yml | job and executor docker/machine image values that name a Python image | no |
| azure-pipelines.yml | container and resources.containers[].image values that name a Python image | no |
| environment.yml, environment.yaml | literal python=... entries under dependencies; a lower bound is a floor | no |
A Python image is python, cimg/python, or circleci/python on Docker Hub. A multi-version .python-version or .tool-versions line reports one pin per version.
Floors
| Source | Field |
| pyproject.toml | [project] requires-python |
| pyproject.toml | [tool.poetry.dependencies] python |
| setup.cfg | [options] python_requires, including continuation lines |
| setup.py | a literal python_requires="..." string; the file is read, never executed |
A floor resolves from its first lower bound, so >=3.10,<3.13, ^3.10, and ~=3.10.2 all report 3.10.
3, 3.x, a wildcard with no minor version, latest, a tag or image with no version, a dynamic image reference, an expression, a constraint with no lower bound, and any value Offing does not read are reported as pins with a one-sentence reason and no release. A value is never guessed from a newer version existing.
--align rewrites .python-version, .tool-versions, runtime.txt, Pipfile, FROM python:<version>[-variant] (keeping the variant suffix and the rest of the instruction), and a single-value setup-python python-version. It refuses floors, matrix entries, CI image references, and any line whose form it does not recognize.
Node.js
Node.js pins are reported at the major release: 18.19.0, v18, and the image tag 18-alpine all select 18.
| Source | Field | Kind |
|---|---|---|
.nvmrc |
the version line | pin |
.node-version |
the version line | pin |
.tool-versions |
the nodejs entry, one pin per version |
pin |
package.json |
volta.node |
pin |
package.json |
engines.node |
floor |
Dockerfile, *.Dockerfile, Containerfile* |
FROM node:<tag>, including every stage |
pin |
devcontainer.json |
the image tag and the ghcr.io/devcontainers/features/node feature version |
pin |
.github/workflows/*.yml |
actions/setup-node node-version |
pin |
.gitlab-ci.yml |
image and services values of the official node, cimg/node, and circleci/node images |
pin |
.circleci/config.yml |
Docker executor values of those images | pin |
azure-pipelines.yml |
job container values of those images |
pin |
A Dev Container image is read for the node, cimg/node, and circleci/node Docker Hub images and for the devcontainers/javascript-node and devcontainers/typescript-node images, whose tags carry the Node.js major after the image major, such as 1-18-bookworm.
LTS codenames
lts/<codename> resolves through the Node.js release schedule (nodejs/Release): argon 4, boron 6, carbon 8, dubnium 10, erbium 12, fermium 14, gallium 16, hydrogen 18, iron 20, jod 22, and krypton 24. The Docker tag form lts-<codename>[-variant] resolves the same way.
Matrix builds
A setup-node matrix with several versions is intentional. Each value is reported as a pin with source setup-node matrix, the values share one group, and they are not counted against each other. A drift is reported only when a non-matrix pin disagrees with another non-matrix pin, or when a non-matrix pin falls outside the matrix.
Unresolved
lts/*, latest, current, node, an LTS codename Offing does not know, an image tag that names no Node.js release, a dynamic image reference, an expression, a matrix entry that is not a plain version, and a floor with no lower bound are reported as pins with a one-sentence reason and no release.
--align rewrites .nvmrc, .node-version, .tool-versions, package.json volta.node, FROM node:<major>[-variant] (keeping the variant suffix and the rest of the instruction), and a single-value actions/setup-node node-version. It refuses floors, setup-node matrices, Dev Container and CI image references, and any line whose form it does not recognize.
Terraform
Terraform pins are reported at major.minor granularity: the .terraform-version value 1.17.0, the image tag 1.17, and required_version = ">= 1.17.0" all select 1.17.
| Source | Field | Kind |
|---|---|---|
.terraform-version, at any depth |
the version line, one pin per file | pin |
.tool-versions |
the first version of the terraform entry |
pin |
Dockerfile, *.Dockerfile, Containerfile* |
FROM hashicorp/terraform:<tag> |
pin |
.github/workflows/*.yml |
hashicorp/setup-terraform terraform_version |
pin |
.github/workflows/*.yml |
job container: and services.<name>.image hashicorp/terraform images |
pin |
.gitlab-ci.yml |
image: or services: hashicorp/terraform images |
pin |
.circleci/config.yml |
Docker executor hashicorp/terraform images |
pin |
azure-pipelines.yml |
job container: hashicorp/terraform images |
pin |
*.tf, in every module |
required_version |
pin when exact ("1.17.0", "= 1.17.0", "~> 1.17.0"), else floor |
The collector reuses the existing .terraform-version and .tool-versions parsers and reads required_version with the HCL parser. Provider versions in required_providers are not Terraform CLI versions and are not read.
Directories
Version files and required_version apply to the directory they live in, because modules in different directories can legitimately differ. A floor is checked only against the pins that apply to its directory: the nearest .terraform-version or .tool-versions at or above it, or the repository-wide pins (Dockerfiles, CI images, setup-terraform) when its directory declares none. A floor of >= 1.17.0 beside a module .terraform-version of 1.17.0 agrees, even when the rest of the repository pins 1.16. Pins are still compared across the repository, so modules on different release lines are a drift.
Unresolved
latest, min-required, system, a partial version such as 1.9, a prerelease, a .tool-versions entry that names a ref: or path:, an image with no tag, a floating image tag, a workflow expression, and a constraint with no lower bound are reported with a one-sentence reason and no release.
Align
--align <release> rewrites .terraform-version, a single-value .tool-versions terraform entry, FROM hashicorp/terraform:<version> tags, a single-value setup-terraform terraform_version, and an exact required_version (keeping the = form when written). Where a full version is required, a release line such as 1.18 is written as 1.18.0. It refuses floors, ranges, CI image references, and images pinned by digest.
PHP
PHP pins are reported at major.minor granularity: 8.3.12, 8.3, and the image tag 8.3-fpm all select 8.3.
| Source | Field | Kind |
|---|---|---|
composer.json |
config.platform.php |
pin |
composer.json |
require.php |
floor |
composer.lock |
platform.php, when present |
floor |
.php-version |
the single version, including the phpenv php-8.3.12 form |
pin |
.tool-versions |
the php entry, one pin per version |
pin |
Dockerfile, *.Dockerfile, Containerfile* |
FROM php:<tag> and a literal RUN asdf install php <version> |
pin |
.github/workflows/*.yml |
the php-version input of shivammathur/setup-php |
pin |
.gitlab-ci.yml |
image: values that name the official php image |
pin |
.circleci/config.yml |
Docker executor values that name the php or cimg/php image |
pin |
A PHP image is the Docker Hub php image, or cimg/php in CI. Image tags keep their variant suffix: 8.3-fpm selects 8.3.
A setup-php matrix with several versions is intentional. Each value is reported as a pin with source setup-php matrix, the values share one group, and they are not counted against each other.
Floors
require.php and composer.lock platform.php state what the project installs against, not the PHP that builds it, so they are floors. A floor resolves from its first lower bound: ^8.2, >=8.1, and 8.1.* all resolve to their major.minor. A floor above the lowest pinned release is a floor conflict, because that environment cannot install the project.
Unresolved
latest, system, a version with no minor (8), a moving image tag, an image with no tag, an expression that is not a matrix reference, and a constraint with no lower bound are reported as pins with a one-sentence reason and no release. A value is never guessed from a newer version existing.
Align
--align <release> rewrites .php-version (keeping the php- prefix), a single-value .tool-versions php line, composer.json config.platform.php, FROM php:<version>[-variant] tags, GitLab CI and CircleCI image tags, and a single-value setup-php php-version. It refuses floors, composer.json require.php, composer.lock, setup-php matrix values, multi-value .tool-versions lines, and image references pinned by digest. composer.lock is reported and refused with "regenerate the lockfile": Composer owns that file.
Elixir
Elixir pins are reported at major.minor granularity: 1.16.2, 1.16, and the image tag 1.16.2-otp-26-alpine all select 1.16.
| Source | Field | Kind |
|---|---|---|
.elixir-version |
the single version, including the asdf 1.16.2-otp-26 form |
pin |
.tool-versions |
the elixir entry, one pin per version, the -otp-NN suffix kept as written |
pin |
mix.exs |
the project elixir: constraint, quoted literals only |
floor |
Dockerfile, *.Dockerfile, Containerfile* |
FROM elixir:<tag> and FROM hexpm/elixir:<elixir>-erlang-<otp>-<os> |
pin |
.github/workflows/*.yml |
the elixir-version input of erlef/setup-beam |
pin |
.gitlab-ci.yml |
image: values that name the official elixir or hexpm/elixir image |
pin |
.circleci/config.yml |
Docker executor values that name elixir or hexpm/elixir |
pin |
An Elixir image is the Docker Hub elixir image or the hexpm/elixir build image; other registries and other repositories are not read. Image tags keep their suffixes: 1.16.2-otp-26-alpine selects 1.16, and hexpm/elixir:1.17.3-erlang-27.1.1-alpine-3.20 selects 1.17 from the version its tag names first.
The collector reuses the Elixir detector's runtime parser and mix.exs parser, so the same version grammar decides what is a pin here as in offing scan. A setup-beam matrix with several Elixir versions is intentional. Each value is reported as a pin with source setup-beam elixir matrix, the values share one group, and they are not counted against each other.
Floors
mix.exs elixir: states the oldest Elixir the project supports, not the one that builds it, so it is a floor. Only a quoted literal constraint is read: ~> 1.15 and >= 1.15.7 resolve to their first lower bound, 1.15. Mix is never run and mix.exs is never evaluated. A floor above the lowest pinned Elixir is a floor conflict, because that project cannot build on the runtime its own pins select.
Unresolved
latest, main, a version with no minor (1), an image with no tag, a moving image tag such as elixir:latest, a hexpm/elixir tag that is not <elixir>-erlang-<otp>[-<os>], an expression that is not a matrix reference, and a constraint with no lower bound are reported as pins with a one-sentence reason and no release. A value is never guessed from a newer version existing.
Align
--align <release> rewrites .elixir-version (keeping its -otp-NN suffix), a single-value .tool-versions elixir line, a single-value setup-beam elixir-version (its quote style kept), and elixir:<version>[-otp-<otp>][-variant] tags in Dockerfiles, GitLab CI and CircleCI (the OTP part and the variant are preserved). It refuses the mix.exs floor, every hexpm/elixir tag — choosing its OS part is a decision, not a rewrite — setup-beam matrix values, multi-value .tool-versions lines, and image references pinned by digest.
Erlang/OTP
Erlang/OTP pins are reported at the OTP major: 26.2.5, OTP-26.2.5, and the -otp-26.2.5 of an Elixir pin all select 26.
| Source | Field | Kind |
|---|---|---|
.tool-versions |
the erlang entry, one pin per version |
pin |
.elixir-version, .tool-versions |
the -otp-NN suffix of an Elixir version that names one |
pin |
Dockerfile, *.Dockerfile, Containerfile*, .gitlab-ci.yml, .circleci/config.yml |
the erlang-<otp> part of a hexpm/elixir tag, and the -otp-<otp> part of an elixir tag |
pin |
.github/workflows/*.yml |
the otp-version input of erlef/setup-beam, including its OTP-26.2.5 form |
pin |
OTP is versioned with Elixir, so one line states both: FROM hexpm/elixir:1.17.3-erlang-27.1.1-alpine-3.20 is an Elixir 1.17 pin for the Elixir collector and an OTP 27 pin for this one, at the same path and line. The Docker Hub erlang image is not a source of drift here, because the issue that scoped this collector lists only the sources above; a repository that runs Erlang on its own gets no pin from FROM erlang:27.
There is no Erlang floor source: mix.exs constrains Elixir, not OTP.
A setup-beam matrix with several OTP versions is intentional: the values are reported with source setup-beam otp matrix, share one group, and are not counted against each other.
Unresolved
main, master, and latest, an otp-version that is not a version (edge), an expression that is not a matrix reference, an .tool-versions erlang token that is not an OTP release, and a workflow that does not parse as YAML are reported with a reason or skipped; nothing is guessed. An Elixir version with no -otp- part simply yields no OTP pin.
Align
--align <release> rewrites a single-value .tool-versions erlang line, the -otp-NN suffix of .elixir-version and of a .tool-versions elixir line, a single-value setup-beam otp-version (written as the bare major, with the line's quotes kept), and the -otp-<otp> part of elixir:<version>-otp-<otp>[-variant] tags in Dockerfiles, GitLab CI and CircleCI. It refuses every hexpm/elixir tag, setup-beam matrix values, and image references pinned by digest.