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.

Declared vs Installed Versions Table comparing pinned toolchain versions with what two CI workflows installed. Declared vs Installed Versions Tool Pin file test.yml lint.yml Node 20.17.0 20.11.1 18.20.4 Python 3.12.5 3.12.1 3.12.1 npm 10.8.2 10.2.4 10.7.0
Each workflow restated versions its own way, and none matched the pin file.

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

  1. 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
  1. Pin the package manager too. Declare npm's version in package.json and 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
  1. 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.

  1. 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"
One Pin File, Every Environment Flow from the pin file to developer machines, CI setup actions and container builds. One Pin File, Every Environment .tool-versions single source laptops mise install CI setup actions version-file drift check fails on mismatch
Nothing restates a version; everything reads the same file and a check verifies it.

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

  1. Keep the lint and the drift check in the required CI checks, so hard-coded versions cannot return.

  2. Let Renovate update the pin file (it understands .tool-versions and mise.toml), so upgrades arrive as reviewed pull requests tested by CI.

  3. 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.

Restated Versions vs Version Files Comparison of hard-coding versions in each workflow against reading them from the pin file. Restated Versions vs Version Files node-version: 20 per workflow node-version-file floats with runner image exact pinned patch workflows disagree all read one file upgrade in many places one-line upgrade drift is silent drift check fails
Reading the pin file removes a whole category of CI-only failures.

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-versions works with setup-node and setup-python on 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.