· 4 minutes read misegithub-actionsdevex

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

With 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-node with node-version-file, since v3.4.0 in 2022 (#373)
  • actions/setup-java with java-version-file, since v4.2.0 in 2024 (#606)
  • actions/setup-python with python-version-file, since v5.5.0 in 2025 (#1043)
  • actions/setup-go with go-version-file, since v6.1.0 in 2025 (#673)
  • golangci/golangci-lint-action with version-file, since v9.2.0 in 2025 (#1320)
  • goreleaser/goreleaser-action with version-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 --clean

No 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@v5

It 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:

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.

Who reads .tool-versionsOne .tool-versions file with five lines: golang, nodejs, terraform, golangci-lint and goreleaser. On your machine, mise reads every line. In CI, setup-go reads the golang line, setup-node the nodejs line, golangci-lint-action and goreleaser-action their own lines, and mise-action installs terraform, whose setup action does not read the file.On your machineIn CI.tool-versionsgolang 1.25.3nodejs 22.11.0terraform 1.13.4golangci-lint 2.6.0goreleaser 2.12.0misereads every linesetup-gosetup-nodemise-actiongolangci-lint-actiongoreleaser-action
One file, every reader. Terraform’s own action doesn’t read it yet, so 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.