Enforcing Toolchain Versions in CI
The repository pins Node 20.17.0 in .tool-versions, developers get it through mise, and CI still runs Node 20.11.1 because the workflow says node-version: 20 — or 18, in the job nobody updated. A formatting rule that changed between versions passes locally and fails in CI; a lockfile generated with npm 10.8 is rewritten by npm 10.2 in CI and the build fails with npm ci can only install packages when your package.json and package-lock.json are in sync. Pinning versions for developers is half the job; CI must read the same pins. This page makes it do so and fail loudly on drift, as part of toolchain version management.
The principle: one file declares the versions, and every environment — laptops, CI, containers — reads that file rather than restating the versions.
Diagnostic
Compare the pinned versions with what each CI workflow installs:
#!/usr/bin/env bash
set -euo pipefail
cat .tool-versions 2>/dev/null || cat mise.toml
grep -rnE '(node|python|go|java)-version(-file)?:' .github/workflows | sed 's/^/ci: /'
gh run view --log "$(gh run list --limit 1 --json databaseId -q '.[0].databaseId')" 2>/dev/null \
| grep -m3 -E 'node --version|Python [0-9]|go version' || true
Expected bad output:
nodejs 20.17.0
python 3.12.5
ci: .github/workflows/test.yml:18: node-version: 20
ci: .github/workflows/lint.yml:15: node-version: 18
ci: .github/workflows/test.yml:22: python-version: '3.12'
v20.11.1
Python 3.12.1
Two workflows restate versions differently from the pin file, and both float to whatever the setup action resolves 20, 18 or 3.12 to on that runner.
Root cause
CI configuration is written separately from the developer toolchain, usually by copying a setup action example with a major version like node-version: 20. Setup actions resolve that to the newest cached patch on the runner image, which changes as runner images are updated, so CI's patch version drifts independently of the pin file. Each workflow file restates versions, so they also drift from each other. Package managers bundled with runtimes (npm with Node, pip with Python) drift along with them, and lockfile formats and resolution rules change between those versions. Nothing fails when versions diverge — builds simply behave differently — so the drift is discovered through confusing, version-specific failures rather than a clear error.
The failures that result are expensive precisely because they look like code problems. A test that depends on a sorting change in a newer V8, a type error that only a newer TypeScript reports, or a Python warning that became an error in a patch release all appear as a red build on someone's pull request, and the author spends time debugging code that is fine. Reproducing locally does not help, because the laptop runs the pinned version and passes. Only comparing versions reveals the cause, which is why this page makes the comparison automatic and the first step of every job.
There is also an onboarding cost. New hires read the pin file, install exactly those versions, and then see CI disagree with them — which teaches them early that local results cannot be trusted, the opposite of what the pinned toolchain is meant to establish. When CI reads the same file, "it passes locally" becomes a meaningful statement again, and the doctor check gives the same answer on a laptop and on a runner.
Resolution
- Point every setup action at the pin file instead of restating versions:
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .tool-versions
cache: npm
- uses: actions/setup-python@v5
with:
python-version-file: .tool-versions
- run: npm ci && npm test
setup-node and setup-python both read .tool-versions (and .nvmrc, .python-version). For mise.toml, use jdx/mise-action, which installs every pinned tool in one step:
- uses: jdx/mise-action@v2
with:
install: true
cache: true
- Pin the package manager too. Declare npm's version in
package.jsonand let Corepack enforce it, so CI and laptops use the same one:
#!/usr/bin/env bash
set -euo pipefail
npm pkg set packageManager="[email protected]"
corepack enable
npm --version
- Add a drift check that fails CI when installed versions differ from the pin file:
#!/usr/bin/env bash
set -euo pipefail
want_node=$(awk '$1=="nodejs"{print $2}' .tool-versions)
want_py=$(awk '$1=="python"{print $2}' .tool-versions)
have_node=$(node --version | tr -d v)
have_py=$(python3 -c 'import platform; print(platform.python_version())')
fail=0
[ "$want_node" = "$have_node" ] || { echo "node: pinned $want_node, running $have_node"; fail=1; }
[ "$want_py" = "$have_py" ] || { echo "python: pinned $want_py, running $have_py"; fail=1; }
[ $fail -eq 0 ] && echo "toolchain matches .tool-versions"
exit $fail
Run it as the first step after setup in every workflow, and as part of make doctor locally.
- Remove hard-coded versions from all workflows in one change, and keep them out with a lint:
#!/usr/bin/env bash
set -euo pipefail
if grep -rnE '(node|python|go|java)-version:\s' .github/workflows; then
echo "use *-version-file: .tool-versions instead of hard-coded versions"; exit 1
fi
echo "workflows read versions from the pin file"
Expected output
$ ./scripts/check-toolchain.sh
toolchain matches .tool-versions
$ grep -rnE 'version-file' .github/workflows | wc -l
4
$ gh run view --log | grep -m2 -E 'node --version|Python'
v20.17.0
Python 3.12.5
Every workflow reads the pin file, CI runs exactly the pinned versions, and the check passes on laptops and runners alike. Upgrading Node is now a one-line change to .tool-versions that updates every environment in the same pull request.
That last property changes how upgrades feel. Instead of a checklist — update the README, the Dockerfile, three workflows and the dev container — a toolchain bump is a single diff, and CI proves the new version on every job before merge. Teams that adopt this tend to upgrade more often and in smaller steps, which is itself a reduction in drift.
Prevention
Keep the lint and the drift check in the required CI checks, so hard-coded versions cannot return.
Let Renovate update the pin file (it understands
.tool-versionsandmise.toml), so upgrades arrive as reviewed pull requests tested by CI.Use the same file in Dockerfiles by reading it at build time or by generating base image tags from it, as described in pinning a local runtime to match the production image.
Platform caveats
Self-hosted runners: setup actions download versions not in the tool cache; on air-gapped runners, pre-populate the cache or install through mise with a mirror.
Windows runners:
.tool-versionsworks withsetup-nodeandsetup-pythonon Windows; mise also supports Windows natively for most tools.
Apple Silicon (ARM64) runners: some older patch versions lack arm64 builds; pin versions that publish binaries for every runner architecture you use.
Rollback
Restore the previous workflow files; CI returns to its own version choices:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- .github/workflows
Frequently Asked Questions
Can setup-node read .tool-versions directly?
Yes. node-version-file: .tool-versions reads the nodejs line; setup-python reads the python line the same way. Both also support .nvmrc and .python-version.
Why pin the npm version as well?
npm's lockfile handling and resolution change between versions, so different npm versions can rewrite or reject the same lockfile. Declaring packageManager in package.json and enabling Corepack keeps npm consistent.
What about tools without a setup action?
Use jdx/mise-action, which installs every tool in mise.toml or .tool-versions, or a Nix or Devbox shell, so CI uses the same definition as developers.
Should the drift check run locally too?
Yes. Adding it to make doctor catches a developer whose shell is not using the pinned versions, for example because the toolchain manager is not activated.