.tool-versions: Same Versions in Dev and CI
Pin runtimes and tools once in .tool-versions, and let both your machine and GitHub Actions read it. Dev and CI stop drifting apart.
Work on a few projects and you start collecting version managers.
nvm for Node, SDKMAN for Java,
pyenv for Python, tfenv
for Terraform. Each one has its own install, its own setup in your shell, and its own
file: .nvmrc, .sdkmanrc, .python-version, .terraform-version.
One manager per language doesn’t scale
They do their job. Node has had .nvmrc for years, so pinning a version isn’t the hard
part. Keeping all those managers around is. Every new project can mean one more on
every laptop, and CI has to be told the same versions again, in its own way.
And not every tool has a manager. A Go project also needs golangci-lint and
goreleaser, and those need a version too. Go 1.24 added
tool directives to go.mod, which look like the
answer, but they build the tool from source, mixed in with your project’s dependencies.
golangci-lint’s own docs say it plainly: installing it with go install, the tools
pattern or tool directives
“aren’t guaranteed to work”,
and they recommend the binary.
So the version ends up hardcoded in the workflow YAML while your machine runs whatever you installed last year. Same drift, one tool at a time.
One file for everything
.tool-versions comes from asdf,
and mise reads it too. One line per tool:
golang 1.25.3
nodejs 22.11.0
terraform 1.13.4
golangci-lint 2.6.0
goreleaser 2.12.0With mise activated in your shell, you cd into the repo and these exact versions
are on your PATH. mise install fetches whatever is missing.
If you were counting on .nvmrc, note that mise stopped reading the per-language
files by default in 2025.10.0
(#6501). You can turn them back on, tool by tool. One more reason
to keep everything in one file.
CI reads the same file
This is the part that changed over the last few years: adoption. It started with the
official setup actions, and it now reaches tools like golangci-lint and goreleaser.
One action after another adopted .tool-versions:
actions/setup-nodewithnode-version-file, since v3.4.0 in 2022 (#373)actions/setup-javawithjava-version-file, since v4.2.0 in 2024 (#606)actions/setup-pythonwithpython-version-file, since v5.5.0 in 2025 (#1043)actions/setup-gowithgo-version-file, since v6.1.0 in 2025 (#673)golangci/golangci-lint-actionwithversion-file, since v9.2.0 in 2025 (#1320)goreleaser/goreleaser-actionwithversion-file, since v7.2.0 in 2026 (#556)
So a Go and Node workflow looks like this:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: .tool-versions
- uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
- uses: golangci/golangci-lint-action@v9
with:
version-file: .tool-versions
- uses: goreleaser/goreleaser-action@v7
with:
version-file: .tool-versions
args: release --cleanNo version in the YAML. Bump a line in .tool-versions and your machine and CI move
together. In other words, there’s no second place to forget.
Why .tool-versions and not mise.toml?
mise has its own file, mise.toml, and it does more: environment variables, tasks.
This site uses it. So why not use it everywhere?
Compatibility with the actions. setup-node has started reading mise.toml in
v7.1.0
(#1421), but it’s the exception. For the rest, .tool-versions is the format they all understand.
Keep the file boring
The actions read .tool-versions with their own simple parsers, not with mise.
setup-go only looks for a golang line,
not go. setup-python
skips a line with a trailing comment. And mise-only syntax like latest or aqua: backends
means nothing to them. One tool, one exact version per line, and everything reads it.
When the action doesn’t read it
Not every action is there yet. hashicorp/setup-terraform
only takes a terraform_version input.
For those, jdx/mise-action reads .tool-versions and
installs everything listed in it:
- uses: jdx/mise-action@v5It could replace all the setup actions above too, in a single step. But it’s a tradeoff, because the setup actions do more than install a binary:
- Problem matchers.
setup-goregisters one so compiler errors show up as annotations on the pull request.setup-nodedoes the same fortscand ESLint. - Dependency caching.
setup-gocaches modules and build outputs by default, andsetup-nodecaches npm, yarn or pnpm data.mise-actioncaches the tools it installs, not your dependencies. - Language setup.
setup-nodewrites the.npmrcfor a private registry or for publishing.
With mise-action, each of those is an extra step you write yourself. For Terraform
there’s nothing extra to set up, so it’s the obvious choice. For Go and Node, the setup
actions save those steps. Having both options comes in handy.
mise-action installs it (dashed).Where it stands
Adoption keeps going. Every few months another action adopts it, and goreleaser was
the latest, in
April.
Terraform isn’t there yet, so for now that’s the one line where mise-action does the
job.
This post is part of Back to Basics.