DEV Community

Hans Schnedlitz
Hans Schnedlitz

Posted on • Originally published at hansschnedlitz.com on

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"

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

# ...

Enter fullscreen mode Exit fullscreen mode

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"

Enter fullscreen mode Exit fullscreen mode

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": "pnpm@10.18.3",
  "devDependencies": { ... },
  "dependencies": { ... }
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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']

Enter fullscreen mode Exit fullscreen mode

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 %>

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)