One Version to Rule Them All
Managing different tools in your modern Ruby on Rails application can be a pain. You definitely use Ruby. You probably use Node, and some package manager - npm, pnpm or whatever - to go along with it. Locally, managing versions for all these tools is made easy by tools like Mise or ASDF.
# Your local tool versions via Mise
[tools]
ruby = "4.0.0"
node = "25.2.1"
pnpm = "10.18.3"
Unfortunately, managing those versions locally is only part of the equation. You do use CI, right? Your Continuous Integration environments - for example, GitHub Actions - should obviously use the same tool versions.
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '4.0'
bundler-cache: true
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: "10.18.3"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '25.2.1'
cache: pnpm
The same is true for your deployments. If you use Kamal that usually means updating your Dockerfile.
ARG RUBY_VERSION=4.0.0
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Base image setup goes here...
FROM base AS build
ARG NODE_VERSION=25.2.1
ARG PNPM_VERSION=10.13.1
# ...
ENV PATH=/usr/local/node/bin:$PATH
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
/tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
npm install -g pnpm@${PNPM_VERSION} && \
rm -rf /tmp/node-build-master
# ...
That’s a bunch of versions to update across a number of places. Like I said, a bit of a pain to maintain.
Let’s Fix This Mess
Obviously, there are ways to improve this situation. Some GitHub actions support reading from a central file. setup-ruby can read mise.toml - but setup-node can not, at least for now. You can read pnpm versions directly from your package.json file - but that doesn’t really help us, does it now? There just isn’t one standard that allows us to specify each version once, in a single place.
I’m aware of jdx/mise-action. It’s a fix in theory - at least for GitHub actions, but I’ve found it lacking in practice. For one, it supports caching the tool setup itself - but not caching dependencies installed by those tools. The specialized actions do to that quite well. Also, different steps or workflows only need some tools. It is rare that I need to install all the tools defined in mise.toml for every workflow and step.
Now, there’s also Docker and Kamal, and matching versions there is a different story altogether. You can use Kamal build arguments to centralize versions for Docker - but for now that just moves the versions to manage to deploy.yml instead of our Dockerfile.
builder:
arch: amd64
cache:
type: gha
args:
RUBY_VERSION: "4.0"
NODE_VERSION: "25.2.1"
PNPM_VERSION: "10.18.3"
So what gives? Here’s what I ended up with. We go back to good, old, individual version files for each tool.
# .ruby-version
4.0.0
# .node-version
25.2.1
# package.json
{
"private": true,
"type": "module",
"packageManager": "[email protected]",
"devDependencies": { ... },
"dependencies": { ... }
}
Now, hear me out. Here’s why this works.
Let’s talk about GitHub actions first. Obviously, every specialized setup tool supports reading each specialized version file out of the box. Easy.
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
# Reads from .ruby-version automatically
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
# Reads from package.json automatically
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version-file: .node-version
cache: pnpm
- name: Install JavaScript dependencies
run: pnpm install
Mise is a beast and can work with almost anything you throw at it. Including package.json and specialized version files - the latter out of the box. By using a configuration like this your local setup is now also covered.
[tools]
# Picked up from .ruby-version and .node-version
[settings]
experimental = true
[hooks]
# Enabling corepack will install the `pnpm` package manager specified in your package.json
postinstall = 'npx corepack enable'
[env]
_.path = ['/node_modules/.bin']
That leaves Kamal. And here we can do something fun. Because our version files are simple, we can read from them directly without much of a hassle. And because Kamal supports ERB templating, we can do this.
# Just read the versions from the version files, easy
builder:
arch: amd64
cache:
type: gha
args:
RUBY_VERSION: <%= File.read('.ruby-version').strip %>
NODE_VERSION: <%= File.read('.node-version').strip %>
PNPM_VERSION: <%= JSON.parse(File.read('package.json'))['packageManager'].split('@').last %>
Want to upgrade Ruby? Change .ruby-version. Want to upgrade Node? Change .node-version. Update pnpm? Change the version in your package.json. Any change will be picked up across all your environments. It’s simple, and it works beautifully.