Pinning Node and Python Versions with mise
A new contributor clones the repo, runs npm install, and the build fails with a native module that was compiled against a Node ABI they do not have — because their machine defaults to Node 22 while the project was written against Node 20. This page shows how to pin exact Node and Python versions in a committed mise.toml and have them auto-activate the moment anyone cds into the project, as one concrete practice under toolchain version management. The result is that the runtime a person gets is a property of the directory they are standing in, not of whatever they happened to install last.
Diagnostic
Confirm the symptom first: two contributors on the same commit are running different interpreter versions, and nothing in the repository forces them to agree. Ask both to run the same two commands at the repo root.
#!/usr/bin/env bash
set -euo pipefail
# Run at the repo root on each contributor's machine.
echo "node: $(node -v 2>/dev/null || echo 'not found')"
echo "python: $(python3 -V 2>/dev/null || echo 'not found')"
On an un-pinned project the two machines disagree, and that disagreement is the whole problem:
# Contributor A
node: v20.11.1
python: Python 3.12.2
# Contributor B
node: v22.4.0
python: Python 3.11.9
Now check whether the repository actually declares a version anywhere a tool would read. The absence of any of these files is the confirmation that nothing is pinned:
#!/usr/bin/env bash
set -euo pipefail
for f in mise.toml .mise.toml .tool-versions .nvmrc .python-version; do
[ -f "$f" ] && echo "FOUND: $f" || echo "MISSING: $f"
done
If every line reads MISSING, the runtime each contributor gets is decided by their global install and their PATH ordering. That is non-deterministic across a team, and it is the exact condition that produces "works on my machine" native-module failures, lockfile churn from a different npm bundled with a different Node, and Python virtualenvs built against a minor version the CI runner does not have.
Root cause
Version managers that require a manual switch — typing nvm use or pyenv shell — only bind a version to your current shell session, and only if you remember to run them. Nothing about cloning the repo installs the right runtime, and nothing about entering the directory selects it. The version is therefore ambient state that lives in the person, not in the project. When that state differs between two engineers, their node_modules, their compiled wheels, and their lockfiles diverge, and the divergence surfaces later as a failure that is expensive to trace back to a version mismatch.
mise removes the ambient state by making the active runtime a function of the working directory. You commit a mise.toml that names exact versions, mise installs those versions into a per-version store, and a shell hook re-evaluates which versions are active every time the directory changes. Because the file is in the repository, the pin travels with the code: a checkout of an older commit activates that commit's runtimes automatically, and a new hire gets the same interpreter as the author the first time they cd in.
Resolution
Follow these steps once per machine to install mise and wire it into the shell, then once per repository to write the pins. Every contributor runs the machine-level steps a single time; the repository-level file is committed so nobody repeats it.
Install mise. Use the official installer, which drops a single static binary into
~/.local/binwith no runtime dependencies.#!/usr/bin/env bash set -euo pipefail curl -fsSL https://mise.run | sh # Confirm the binary is on PATH for this check. ~/.local/bin/mise --versionActivate mise in your shell. Add the activation line to your shell rc so the directory hook is installed for every new session. Pick the block that matches your shell.
#!/usr/bin/env bash set -euo pipefail # bash echo 'eval "$(~/.local/bin/mise activate bash)"' >> ~/.bashrc # zsh echo 'eval "$(~/.local/bin/mise activate zsh)"' >> ~/.zshrc # Reload the current shell so the hook is live now. exec "$SHELL"Write the pins into
mise.toml. At the repo root, runmise useto create or update the file with exact versions. Pin to a fullmajor.minor.patchso the runtime is reproducible, not merely "the latest 20.x".#!/usr/bin/env bash set -euo pipefail mise use [email protected] mise use [email protected] cat mise.tomlThe committed file is small and declarative:
[tools] node = "20.11.1" python = "3.12.2" [env] # Optional: values every contributor gets when the directory is active. PYTHONDONTWRITEBYTECODE = "1"Trust the config. mise refuses to load a config file it has not been told to trust, which stops a cloned repository from silently running arbitrary
[env]or task code. Each contributor trusts the file once after cloning.#!/usr/bin/env bash set -euo pipefail mise trustInstall the pinned runtimes.
mise installreadsmise.tomland downloads exactly the versions named, into a per-version directory under~/.local/share/mise. Nothing touches the system Python or a globally installed Node.#!/usr/bin/env bash set -euo pipefail mise install mise lsCommit the pin. Add
mise.tomlto version control so the next contributor inherits it. Do not commit the version store or any per-machine cache.#!/usr/bin/env bash set -euo pipefail git add mise.toml git commit -m "Pin Node 20.11.1 and Python 3.12.2 with mise"
Expected output
With the config committed and trusted, entering the directory activates both runtimes with no manual command. Verify by cding out and back in, then asking each tool where it resolves from.
#!/usr/bin/env bash
set -euo pipefail
cd .. && cd - >/dev/null
mise current
which node python3
node -v
python3 -V
Correct output shows both interpreters resolving to the mise store at the pinned versions, identical on every machine that checked out this commit:
node 20.11.1
python 3.12.2
/home/dev/.local/share/mise/installs/node/20.11.1/bin/node
/home/dev/.local/share/mise/installs/python/3.12.2/bin/python3
v20.11.1
Python 3.12.2
The which output is the important line: node and python3 now point inside the mise store, not at /usr/bin or a global nvm shim. That is the proof that the pin, not the machine's default, is deciding the version. Re-run the diagnostic from the top of this page on a second contributor's machine and the two now agree exactly.
Prevention
Pinning once is not enough; a version can still drift if CI installs a different runtime than contributors, or if the file is edited by hand into an unresolvable state. Add two guards so the pin stays honest.
First, run mise install in CI from the same committed file, so the runner uses the identical version the humans use. This closes the gap that produces lockfiles generated under one Node and validated under another — a class of failure covered in depth in debugging works-on-my-machine runtime drift.
name: verify-runtime-pin
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- name: Assert pinned versions are active
run: |
set -euo pipefail
test "$(node -v)" = "v20.11.1"
test "$(python3 -V)" = "Python 3.12.2"
Second, fold the install into your one-command onboarding path so a fresh clone provisions the runtimes without a separate instruction. If you already have a bootstrap target, add mise install as its first prerequisite — see writing a make bootstrap target for one-command setup for the surrounding structure. Making the pin part of setup rather than a thing to remember is what actually reduces friction for the people who most need it, as discussed in reducing setup friction for junior engineers.
Platform caveats
The one-line activation works the same everywhere, but the runtime build step differs by platform and is where new machines most often stall.
macOS (Apple Silicon, ARM64): Building Python from source needs the Command Line Tools and a few libraries. If
mise install [email protected]fails with a missing_sslorzlibmodule, install the build deps withbrew install openssl readline sqlite3 xz zlib tcl-tkand re-run. mise prefers precompiled Python builds where available, which sidesteps this entirely — keep mise current so it picks them up.
WSL2: Install and activate mise inside the Linux distribution, never call a Windows-side
node.exethrough the interopPATH. Awhich nodethat returns a/mnt/c/...path means the Windows binary is shadowing the mise shim; remove the Windows Node from your WSLPATHso the directory-scoped version wins.
Docker / CI containers: mise's shell hook does not fire in a non-interactive container shell. Either run commands through
mise exec -- node app.js/mise x -- python app.py, or addmise activate --shimsto a login profile so the shims directory is onPATHwithout an interactive hook.
Rollback
If a pin causes a regression and you need to fall back to the machine's global runtime immediately, disable mise for the current shell without uninstalling anything.
#!/usr/bin/env bash
set -euo pipefail
mise deactivate # drop mise from this shell's PATH
hash -r # forget cached command locations
which node python3 # now resolves to the system/global install
To undo the repository change entirely, remove the file and commit — contributors revert to their previous version manager on their next cd.
#!/usr/bin/env bash
set -euo pipefail
git rm mise.toml
git commit -m "Revert mise runtime pin"
Frequently Asked Questions
Does mise.toml replace my .nvmrc and .python-version files?
It can read them or replace them, your choice. mise natively parses .nvmrc and .python-version when you enable idiomatic version files, so a repository that already has them keeps working without a rewrite. But the recommended end state is a single mise.toml that pins every runtime in one place: it removes the ambiguity of two files that can disagree, and it is the only file that also carries [env] and task definitions. If you migrate, delete the old files in the same commit so there is exactly one source of truth.
Why does mise ask me to run mise trust after I clone a repo?
Because a mise.toml can define environment variables and runnable tasks, loading one from an untrusted checkout would let a cloned repository execute code or inject values into your shell. mise blocks that by refusing to read a config file until you explicitly trust its path, and it re-prompts if the file's checksum changes. Run mise trust once after cloning, and again only if you deliberately change the file. This is a deliberate security boundary, not a bug — do not disable it globally.
Should I pin to node = "20" or the full 20.11.1?
Pin the full major.minor.patch for reproducibility. Pinning node = "20" resolves to whatever the newest installed 20.x is, which can differ between a machine that installed the runtime in January and one that installed it in June — reintroducing the drift you are trying to remove. A full patch pin guarantees every contributor and the CI runner resolve the exact same binary. Bump the patch deliberately in a commit when you want the upgrade, so the change is reviewable rather than ambient.
Do contributors still need mise install if activation is automatic?
Yes, once per version. Activation only puts the pinned runtime on PATH; it does not download a version that is not present in the store. The first time someone enters a directory whose pinned version they have never installed, mise flags it as missing rather than silently falling back. Running mise install (or wiring it into your bootstrap target) downloads the exact versions named in mise.toml. After that, every future cd into the directory is instant because the version is already in the store.