One Version to Rule Them All

Posted on · 1 minute read

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.