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 rbenv ruby-3.3.1 and 3.3.1p123 forms.
  • .tool-versions ruby lines, including several fallback versions.
  • Gemfile: an exact ruby "3.3.0" is a pin; a literal requirement such as ruby ">= 3.1.0" or ruby "~> 3.2" is a floor.
  • Gemfile.lock RUBY VERSION.
  • *.gemspec: a literal required_ruby_version, such as ">= 3.1.0" or "~> 3.2", as a floor.
  • Dockerfile* and Containerfile*: FROM ruby:<tag> and a literal RUN asdf install ruby <version>.
  • .github/workflows/*.yml: the ruby-version input of ruby/setup-ruby.
  • .gitlab-ci.yml image: values and .circleci/config.yml docker: images for the official ruby and cimg/ruby images.

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. --align rewrites only values it can select a release with:
  • Maven compiler properties and maven-compiler-plugin settings whose text is a literal version;
  • .java-version, .sdkmanrc, and .tool-versions entries 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 as 3.9-eclipse-temurin-17;
  • a single scalar java-version in actions/setup-java. It refuses Gradle toolchain and compatibility settings, Dockerfile RUN installs, 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 ] [--align ]

  • --check exits 1 when any runtime has drifted or has a floor conflict.
  • --json prints the offing.drift.v1 document described by schemas/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 to release. 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. A floor is 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.

View Markdown

Use with an agent
View Markdown

Open in