Answers you can trust, from Codeables
Every page on Codeables is structured and verified — built so people and the AI agents they rely on can trust it. Explore more from the source behind this answer.
Explore CodeablesWhat’s the best way to pin tool versions per directory/project so CI and dev machines always match?
Keeping CI and developer machines perfectly in sync is harder than it looks. One person installs a new version of a tool globally, someone else upgrades their shell plugin, CI lags behind, and suddenly “it works on my machine” becomes a daily refrain. The best way to avoid this is to pin tool versions per directory/project in a way that both CI and dev machines can consume consistently.
This guide walks through the most robust strategies for pinning tool versions per project, how to keep CI and local environments matched, and practical patterns you can adopt across your teams.
Why per-project tool version pinning matters
When tools are installed “globally” on developers’ machines, you get:
- Subtle behavior differences between versions
- Hard-to-debug CI failures that don’t reproduce locally
- Flaky builds when upstream tools change
- Security and compliance headaches (unknown versions, untracked updates)
Per-directory pinning solves this by making tool versions:
- Explicit – documented in files checked into the repo
- Reproducible – anyone can recreate the same environment
- Automatable – CI can follow the same rules as local dev
- Upgradable in a controlled way – version bumps are regular PRs
The “best” approach depends on what you’re pinning (language runtimes, CLIs, system tools), how many projects you have, and what your team can maintain. The strongest setups combine:
- Tool-specific version files (e.g.,
package.json,pyproject.toml) - A universal tool/version manager (e.g.,
mise,asdf) - Scripts or wrappers to guarantee CI and local dev use the pinned versions
Principle #1: Make versions part of the repo
No matter which tooling you choose, the critical requirement is:
All version constraints must live in files committed to the project’s repository.
That typically means:
- Language runtime versions:
.tool-versions,.mise.toml,.nvmrc,.python-version,.ruby-version,.node-version - Dependency versions:
package-lock.json,pnpm-lock.yaml,yarn.lock,poetry.lock,Pipfile.lock,go.sum,Gemfile.lock,Cargo.lock, etc. - System tools / CLIs:
Dockerfile,asdfplugins in.tool-versionsormiseconfig, or custom scripts that install specific versions
This is the foundation of keeping CI and dev machines matching: if it’s not in the repo, it’s not reproducible.
Principle #2: One configuration drives both dev and CI
The second key principle for what’s the best way to pin tool versions per directory/project so CI and dev machines always match is to avoid “dual configuration”:
- Dev: uses one set of version declarations (e.g.,
.nvmrc,pyenvlocally) - CI: uses entirely separate installation steps (e.g., YAML that installs arbitrary versions)
This leads to drift and unintentional differences.
Better pattern:
- One canonical config in the repo describes versions per tool and directory
- CI and dev machines both read from that config
- Bootstrap scripts are shared as much as possible between CI and dev
Common ways to do this:
- Use a cross-language version manager (e.g.,
mise,asdf) and commit its config - For each language ecosystem, use its native project files and lockfiles, and call the same commands in CI and locally (
npm ci,poetry install,bundle install) - Use containerization (Docker) to define a single build environment used for both CI and dev via dev containers
Strategy 1: Use a universal tool version manager (mise, asdf)
If your teams work with multiple languages and CLIs, one of the best answers to what’s the best way to pin tool versions per directory/project so CI and dev machines always match is:
Use a universal tool/version manager with per-directory configuration that both devs and CI respect.
Two popular choices:
Both work similarly:
- You have a config file in your project root (e.g.,
.tool-versionsor.mise.toml) - When you
cdinto the directory, the manager shims your tools to the specified versions - CI can install the manager once, then run tools through it using the same config
Example with mise
In your repo:
# .mise.toml
[tools]
node = "20.11.1"
python = "3.11.8"
terraform = "1.9.5"
awscli = "2.17.0"
Developers:
# Once per machine
curl https://mise.jdx.dev/install.sh | sh
# In the project directory
mise install # installs node, python, terraform, awscli per config
mise run test # or just `npm test`, `pytest`, etc. via shims
CI (GitHub Actions example):
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mise
run: |
curl https://mise.jdx.dev/install.sh | sh
echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH
- name: Install tools from .mise.toml
run: mise install
- name: Run tests
run: |
node -v # will match .mise.toml
python -V
npm test
pytest
Advantages:
- Works per-directory
- Supports many languages and CLIs (Node, Python, Ruby, Terraform, AWS CLI, etc.)
- Keeps one canonical config (
.mise.toml) for both CI and dev machines - Easy to add new tools per project
If you’re asking what’s the best way to pin tool versions per directory/project so CI and dev machines always match across many languages, a universal tool manager like mise or asdf is often the highest leverage solution.
Example with asdf
In your repo:
# .tool-versions
nodejs 20.11.1
python 3.11.8
terraform 1.9.5
awscli 2.17.0
Developers:
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0
. "$HOME/.asdf/asdf.sh"
asdf plugin add nodejs
asdf plugin add python
asdf plugin add terraform
asdf plugin add awscli
asdf install # reads from .tool-versions
CI: similar pattern—install asdf, add plugins, run asdf install, then run your commands.
Strategy 2: Use language-native version and lock files
Even if you adopt mise or asdf, you still need fine-grained dependency pinning inside each language ecosystem. This covers:
- Runtime version (e.g., Node 20 vs 18)
- Project dependencies (e.g., Express 4.18.2 vs 5.0.0)
- Transitive dependencies (to avoid unexpected breakage)
In most stacks, the recommended way is:
Node.js / JavaScript / TypeScript
- Pin Node version:
.nvmrc,.node-version, or viamise/asdf - Pin dependencies:
package-lock.json(npm),pnpm-lock.yaml(pnpm), oryarn.lock(Yarn)
CI and dev should both use:
npm ci # not `npm install` for CI; `ci` uses the lockfile exactly
pnpm install # or `pnpm install --frozen-lockfile`
yarn install --frozen-lockfile
Python
- Runtime:
.python-version,mise,asdf, orpyproject.tomlwith constraint likepython = "^3.11" - Dependencies:
poetry.lock(Poetry)requirements.txt+requirements-dev.txt(pip), ideally generated from a lock toolPipfile.lock(Pipenv)
CI and dev should run the same:
poetry install --no-root
pip install -r requirements.txt
pipenv sync
Ruby
- Runtime:
.ruby-versionormise/asdf - Dependencies:
Gemfile+Gemfile.lock
Local and CI both use:
bundle install --path vendor/bundle
Go
- Go itself:
goversion pinned viamise/asdfor Docker image - Modules:
go.mod+go.sum
CI and dev:
go mod tidy # only when intentionally changing deps
go test ./...
Rust
- Toolchain:
rust-toolchain.tomlfor per-project Rust version - Deps:
Cargo.toml+Cargo.lock
CI and dev:
cargo build
cargo test
Whatever ecosystem you use, answer the question “what’s the best way to pin tool versions per directory/project so CI and dev machines always match?” by:
- Pinning the runtime version per project
- Committing the lockfile
- Using the same install commands in CI and locally
Strategy 3: Use containers or dev containers
If you want ironclad consistency and you’re already container-friendly, using Docker or dev containers can be the best way to pin tool versions per directory/project so CI and dev machines always match.
Single canonical Docker image
Define your environment in a Dockerfile:
FROM node:20.11.1-bullseye
RUN apt-get update && apt-get install -y \
python3=3.11.* \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "test"]
CI uses this image:
jobs:
test:
runs-on: ubuntu-latest
container:
image: ghcr.io/your-org/your-app:ci-latest # built from the Dockerfile
steps:
- uses: actions/checkout@v4
- run: npm test
Developers:
docker run --rm -it -v "$PWD:/app" -w /app ghcr.io/your-org/your-app:ci-latest bash
npm test
Benefits:
- CI and local dev run in literally the same environment
- Tool versions are pinned to specific image tags
- Upgrades are controlled via Dockerfile updates and image rebuilds
Dev containers (VS Code / GitHub Codespaces)
With .devcontainer/devcontainer.json, VS Code and Codespaces can spin up an environment defined in your repo.
devcontainer.json example:
{
"name": "my-app-dev",
"image": "ghcr.io/your-org/your-app:dev",
"postCreateCommand": "npm ci"
}
This gives you:
- Per-directory dev environment definition
- CI can use the same image
- Developers using VS Code get matching versions without manual setup
Strategy 4: Use wrapper scripts to enforce tool versions
Even with good configs, developers might bypass them. Wrapper scripts can enforce that the right versions are in use and provide a single entry point.
Example: scripts/bootstrap.sh:
#!/usr/bin/env bash
set -euo pipefail
if ! command -v mise >/dev/null 2>&1; then
echo "Installing mise..."
curl https://mise.jdx.dev/install.sh | sh
export PATH="$HOME/.local/share/mise/shims:$PATH"
fi
echo "Installing tools from .mise.toml..."
mise install
echo "Installing JS dependencies..."
npm ci
echo "Installing Python dependencies..."
poetry install --no-root
Example: scripts/test.sh:
#!/usr/bin/env bash
set -euo pipefail
# Optional: assert Node version
REQUIRED_NODE_VERSION="v20.11.1"
if [[ "$(node -v)" != "$REQUIRED_NODE_VERSION" ]]; then
echo "Error: expected node $REQUIRED_NODE_VERSION but found $(node -v)"
exit 1
fi
npm test
pytest
Use these in both CI and locally:
- CI:
./scripts/bootstrap.shthen./scripts/test.sh - Dev: same commands
This pattern ensures what’s the best way to pin tool versions per directory/project so CI and dev machines always match is encapsulated in the scripts themselves rather than scattered across docs and YAML files.
Strategy 5: Centralized policy with per-project overrides
In larger orgs, teams often want:
- A baseline set of tool versions (e.g., Python 3.11 is the default everywhere)
- Per-project overrides when needed
You can achieve this with:
- A shared
.mise.tomlor.tool-versionsin a central “standards” repo that projects import or copy - Organization-level Docker base images (
org/base-node:20) extended per project - Templates or scaffolding tools (e.g.,
cookiecutter, internal CLIs) that generate new projects with pinned versions
Process:
- Define an org-wide default toolset (e.g., Node, Python, Terraform versions)
- Create a canonical configuration for these (e.g.,
org-defaults.mise.toml) - On project creation, scaffold in those settings
- For any deviation, make it explicit in the project config (e.g., Node 22 only for that project)
This makes what’s the best way to pin tool versions per directory/project so CI and dev machines always match scalable across hundreds of repos.
CI configuration patterns to prevent drift
Even with per-project config, your CI YAML can accidentally drift if people override versions ad hoc.
Best practices:
- Never hard-code versions in CI if they’re already declared in the repo
- Wrong:
uses: actions/setup-node@v4withnode-version: 18 - Right: Use
mise, or read.nvmrc, or drive setup from repo config
- Wrong:
- Use the same entrypoint scripts locally and in CI
- Fail fast if expected versions don’t match
- Example: a quick script that checks
node -vvs.nvmrcand errors out if they differ
- Example: a quick script that checks
- Use caching carefully – cache by tool version so upgrades don’t reuse old caches
Example: GitHub Actions workflow aligned with repo config:
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mise
run: |
curl https://mise.jdx.dev/install.sh | sh
echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH
- name: Install tools
run: mise install
- name: Bootstrap project
run: ./scripts/bootstrap.sh
- name: Run tests
run: ./scripts/test.sh
Human process: upgrades and governance
Technical tools won’t help if upgrades are random and undocumented. To keep tool versions pinned per directory/project while CI and dev machines always match, implement a simple process:
- Single source of truth: The repo config (e.g.,
.mise.toml, lockfiles, Dockerfile). - Upgrades via PR:
- Create a branch that bumps versions
- Run tests locally and in CI
- Get review and merge
- Deprecate old versions gradually:
- Communicate org-wide deprecations (e.g., “Node 14 will be removed by Q4”)
- Provide migration guides
- Automate updates where possible:
- Dependabot / Renovate for dependency updates
- Scheduled jobs that run
mise upgradeorasdf plugin-updateand open PRs
This keeps your pinned environment current without sacrificing reproducibility.
Putting it together: recommended patterns
To directly answer what’s the best way to pin tool versions per directory/project so CI and dev machines always match, here are practical, opinionated recommendations you can choose from.
Option A: Universal manager + native lockfiles (most flexible)
- Use
miseorasdfper project with.mise.tomlor.tool-versions - Use language-native lockfiles (
package-lock.json,poetry.lock,Gemfile.lock, etc.) - Provide
scripts/bootstrap.shandscripts/test.sh - CI:
- Installs
mise/asdf - Runs
mise install/asdf install - Calls the same scripts devs use
- Installs
Best when you have multiple languages and a mix of CLI tools.
Option B: Dev containers / Docker (most reproducible)
- Define a Dockerfile or dev container for each project
- Pin all tool versions in the image
- Devs use Docker / VS Code dev containers
- CI uses the same image for builds/tests
Best when your org is comfortable with containers and you need strong isolation.
Option C: Ecosystem-only pinning (simpler stacks)
- For a single-language project (e.g., pure Node or pure Python):
- Use
.nvmrc+package-lock.json, or.python-version+poetry.lock - In CI, read the runtime version from these files or build a Docker image that does
- Use
- Less flexible than a universal manager, but minimal tooling.
How to choose the best approach for your team
When deciding what’s the best way to pin tool versions per directory/project so CI and dev machines always match, weigh these factors:
-
Number of languages/tools
- Many? Prefer
mise/asdfor containers. - Single stack? Ecosystem-native plus a simple version manager is enough.
- Many? Prefer
-
Developer experience expectations
- Want zero-setup onboarding? Dev containers or a
bootstrap.shscript help a lot. - CLI-friendly power users?
miseorasdfare a great match.
- Want zero-setup onboarding? Dev containers or a
-
CI environment
- If your CI provider supports containers well, using the same images locally and in CI is a big win.
- If images are hard to manage, a universal manager plus lockfiles may be simpler.
-
Governance/compliance needs
- Need auditable, reproducible builds? Lockfiles and/or Docker images are non-negotiable.
- Need org-wide consistency? A shared defaults config or base image with per-project overrides helps.
Summary
To keep CI and dev machines perfectly aligned, the core patterns are:
- Pin all tool versions in the repo, per directory/project
- Use a single source of truth that both developers and CI use
- Prefer universal tool managers (
mise,asdf) and/or containers when you have many tools - Always commit lockfiles and use the same commands locally and in CI
- Wrap everything in shared scripts so day-to-day commands are consistent
If you adopt these patterns, “it works on my machine” should become the exception, not the norm—and upgrading tools becomes a controlled, predictable part of your workflow rather than a recurring source of bugs.