← Back to Blog

Make SDK management boring with asdf and mise

Published August 13, 20267 min read
Developer ToolingasdfmiseReproducibility

The short version: this article focuses on mobile projects that combine several toolchains: Node for scripts, Ruby for Fastlane or CocoaPods, Java for Android, and project-specific CLIs.

  • For a new project with several runtimes or CLIs, I would choose mise.
  • asdf is also a good solution, especially for a team already using it successfully.
  • If there is only one managed runtime, use a dedicated manager such as nvm for Node.

The larger win is not the logo on the tool. It is putting intentional versions in the repository, giving the team one supported setup path, and exercising that path somewhere outside one developer’s laptop.

I came back to this topic while setting up a new MacBook. A fresh machine is a good way to uncover assumptions. The old laptop had accumulated years of installs and shell configuration without making all of them visible.

Install Node. Install Ruby. Install Java. Use this version of CocoaPods. Do not update that CLI yet. After a few years, this becomes archaeology.

Put the versions in the project first

Installation is usually the easy step. Trouble starts later, when a build fails and toolchain drift becomes another suspect. I want to rule out a local version mismatch before debugging the project itself. asdf and mise are polyglot version managers: they can select several runtimes and tools from project-owned configuration. In this example, .tool-versions turns part of that hidden machine state into project data:

nodejs 22.12.0
ruby 3.4.4
java temurin-21.0.6+7.0.LTS

With the manager and its required plugins or backends already configured, the setup command can become:

asdf install

or:

mise install

Those commands repeat project setup after the manager, plugins or backends, and system dependencies are available. A fresh-machine guide should record how to install those prerequisites.

I reproduced the same .tool-versions flow with those Node, Ruby, and Java versions in both mise and asdf. asdf requires the corresponding plugins to be installed first. After that, asdf install installs the versions declared by the project.

.tool-versions captures only part of the environment. System libraries, credentials, IDE settings, and installer dependencies stay elsewhere. Automation can also repeat stale configuration perfectly. A useful sequence is:

  1. Record the requirement in the repository.
  2. Pin or lock it where repeatability matters.
  3. Provide one visible setup command.
  4. Run the same path in CI or another clean environment.

CI should use the same declared versions. The installer can differ: cached setup actions or pinned images are fine when they are faster. Whenever possible, let CI read the repository’s version file instead of duplicating the values. Exercise the project’s setup command in at least one clean job so it remains reliable.

When a focused manager is enough

When Node is the only managed runtime, nvm and an .nvmrc may be enough. A polyglot layer adds little.

Most projects I have worked on needed more than that. A mobile app can use Node for scripts, Ruby for Fastlane or CocoaPods, Java for Android tooling, and a few project-specific CLIs. Backend projects can reach the same point with Python, Go, Java, OpenTofu, or database tools.

With several toolchains in play, separate managers and manual installations become personal setup choices stored in shell history. The repository no longer explains the machine.

I prefer one manager and setup path as the team default. Shared configuration makes onboarding repeatable and gives teammates the same setup to inspect when it fails.

A pinned .tool-versions file can work with both asdf and mise. I still document one team-supported path for setup and troubleshooting.

Choosing between asdf and mise

I used asdf for a few years. When I set up the new laptop, I tried mise as an experiment instead of recreating the same setup. After a few months of real project work, including tooling upgrades, I am still happy with that choice.

The day-to-day difference has not been dramatic for me. I enter a project, get the expected versions, and mostly forget about the manager until the next upgrade.

asdf dates to 2014, while mise dates to 2023. asdf version 0.16 was a complete rewrite from Bash to Go with breaking installation and command changes.

For a new multi-runtime project, I would choose mise. Its day-to-day interface is comfortable, its core tools cover Node, Ruby, and Java without a separate plugin installation, and it can read a pinned .tool-versions file. Mise documents gaps in that compatibility, so I treat it as a migration path.

For a team already using asdf successfully, I would keep it and spend the migration effort elsewhere.

Global Ruby tools stay with the runtime

On older iOS projects, parts of the CocoaPods toolchain did not support the Ruby version I was using. Pinning a compatible Ruby fixed that compatibility problem and removed one variable from later debugging. Changing Ruby could also make a globally installed pod executable disappear. The gem belonged to the previous Ruby environment.

Changing runtimes exposes packages installed globally under the old runtime. For CocoaPods, I use Bundler: declare CocoaPods in Gemfile, commit Gemfile.lock, run bundle install, and invoke it with bundle exec pod. Bundler does not remove the Ruby requirement. The selected Ruby still has to be compatible. Gemfile.lock pins the Ruby tooling, while Podfile.lock separately pins the app’s pod dependencies.

(If every required dependency can move to Swift Package Manager, Ruby may no longer be needed for dependency management. A project using Fastlane can still need Ruby.)

Keep ecosystem-specific setup paths

Some development tools sit outside the simple runtime-manager flow.

Android Studio itself should run on its bundled JetBrains Runtime. The JDK that runs Gradle is a separate choice. Gradle started inside the IDE uses the JDK selected there, while Gradle started from a terminal uses JAVA_HOME or java on PATH. I align those two Gradle paths on the same compatible JDK. Installing another JDK with mise does not automatically change the IDE’s choice. Gradle itself should already be project-owned through the committed Gradle Wrapper.

Xcode is commonly installed from the Mac App Store, where an automatic update can replace the version a project expects. Large or long-lived projects may depend on a particular Xcode build, SDK, Swift version, simulator runtime, or supported architecture. I therefore keep the working version until an explicit project upgrade.

I use Xcode Releases as a practical catalogue of Xcode versions, build numbers, SDKs, Swift versions, and Apple download links. It is a third-party catalogue, not an Apple site. The expected Xcode version and build number still belong in the project’s documentation. Installing that version deliberately instead of relying on automatic App Store updates keeps it stable. It also avoids discovering, just when I want to start work, that Xcode has begun a long update.

sbt adds another ecosystem exception. For sbt projects, I sometimes keep Java and the sbt launcher under SDKMAN, which the sbt setup documentation recommends considering. The project’s actual sbt version still belongs in project/build.properties.

Both mise and SDKMAN support a default Java version and a project-specific override. This gives me room to use mise for most projects but let an sbt project use .sdkmanrc when that is the clearer ecosystem path. Within one project, I still choose one manager as the supported source of JAVA_HOME and document that choice.

Plugins and executable configuration need the same scrutiny as dependencies. asdf community plugins sit outside the core project’s security policy, and mise configuration can require explicit trust. A manager still downloads and executes tool installers. I would review plugin or backend source changes like dependency updates and avoid trusting executable configuration from untrusted branches.

Start where the friction is

Start with the project that already needs several versions. A full environment redesign can wait.

  1. Write down the versions it uses today.
  2. Choose a manager that covers the real tool list.
  3. Move project commands away from incidental global installations.
  4. Document the bootstrap, then make one project command install what is missing.
  5. Check that CI or another clean environment follows the same path.

Then verify the obvious things: node --version, ruby --version, and java -version should report what the project declares. IDE-specific tools should have their expected versions documented separately.

When something breaks, the repository names the intended versions, the setup command is visible, and a clean environment has already exercised it. Fewer parts of the environment are unknown.

That is the kind of boring I want from a project that needs to live longer than one sprint.