release: also publish an image on Ruby HEAD with ZJIT on
Each release now pushes a second image, built from the same Dockerfile
on rubylang/ruby:master-noble with RUBYOPT=--zjit, tagged
<release>-ruby-head-zjit and ruby-head-zjit. It runs in a job of its own
so a HEAD that breaks holds up neither the release nor the deploy.

The Dockerfile takes the base as RUBY_IMAGE, restates the official
image's gem layout for a base that lacks it, and installs what the
HEAD bundle needs to compile nokogiri, sqlite3 and google-protobuf
from source.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHtenXohDhpqANgMby5weB
change
commit 3f67c8233f1484c4137db0cc25db875332325ecd
author Claude <noreply@anthropic.com>
date
parent 4d7f93ec
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 19d97fa..8bbaec0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -86,6 +86,52 @@ jobs:
         # false, so the tag cannot ride the leftover git credentials.
         run: gh release create "${STEPS_META_OUTPUTS_VERSION}" --target "$GITHUB_SHA" --generate-notes
 
+  # The release again, on Ruby HEAD with ZJIT on, tagged
+  # <release>-ruby-head-zjit and ruby-head-zjit. A job of its own so a
+  # HEAD that will not build or install the bundle holds up neither the
+  # release nor the deploy.
+  build-head:
+    name: Build on Ruby HEAD with ZJIT
+    needs: build
+    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read # checkout
+      packages: write # push container image to GHCR
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+        with:
+          persist-credentials: false
+      - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
+      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
+        with:
+          registry: ghcr.io
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+      - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
+        id: meta
+        with:
+          images: ghcr.io/${{ github.repository }}
+          flavor: latest=false
+          tags: |
+            type=raw,value=${{ needs.build.outputs.version }}-ruby-head-zjit
+            type=raw,value=ruby-head-zjit
+      - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
+        with:
+          context: .
+          # Pulled fresh each time: HEAD is the point, so a cached base
+          # would be yesterday's Ruby.
+          pull: true
+          push: true
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+          build-args: |
+            RUBY_IMAGE=rubylang/ruby:master-noble
+            RUBYOPT=--zjit
+            VERSION=${{ needs.build.outputs.version }}-ruby-head-zjit
+          cache-from: type=gha,scope=ruby-head-zjit
+          cache-to: type=gha,scope=ruby-head-zjit,mode=max
+
   deploy:
     name: Deploy main preview
     needs: build
diff --git a/Dockerfile b/Dockerfile
index d52f9eb..4376cd3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,19 @@
-FROM ruby:4.0-slim AS build
+# The Ruby both stages build on. A release also builds this file on
+# rubylang/ruby:master-noble, Ruby HEAD as the core team builds it every
+# few hours, configured with --enable-zjit (see ci.yml's build-head).
+ARG RUBY_IMAGE=ruby:4.0-slim
 
-RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
+FROM ${RUBY_IMAGE} AS build
+
+# The official image's own gem layout, restated for a base that has
+# none: rubylang/ruby installs gems beside Ruby and keeps bundle config
+# in ~/.bundle, neither of which the runtime stage copies.
+ENV GEM_HOME=/usr/local/bundle BUNDLE_APP_CONFIG=/usr/local/bundle BUNDLE_SILENCE_ROOT_WARNING=1
+
+# pkg-config, patch and zlib, for the HEAD build: no precompiled
+# nokogiri, sqlite3 or google-protobuf is cut for an unreleased Ruby, so
+# its bundle compiles them from source.
+RUN apt-get update && apt-get install -y build-essential pkg-config patch zlib1g-dev && rm -rf /var/lib/apt/lists/*
 
 WORKDIR /app
 
@@ -9,7 +22,10 @@ RUN bundle config set without development && bundle install
 
 COPY . .
 
-FROM ruby:4.0-slim
+FROM ${RUBY_IMAGE}
+
+ENV GEM_HOME=/usr/local/bundle BUNDLE_APP_CONFIG=/usr/local/bundle BUNDLE_SILENCE_ROOT_WARNING=1
+ENV PATH=/usr/local/bundle/bin:$PATH
 
 # The image's own tag, and the release's name (see ci.yml) — the one
 # build fact the app reads back out, in the admin footer. It carries the
@@ -20,6 +36,10 @@ ENV VERSION=${VERSION}
 # rackup defaults to development, which wraps the app in Rack::Lint and
 # ShowExceptions.
 ENV RACK_ENV=production
+# Empty but for the HEAD build, which passes --zjit so the app runs
+# under ZJIT without anyone having to ask for it.
+ARG RUBYOPT
+ENV RUBYOPT=${RUBYOPT}
 # Set TZ at runtime — Date.today drives the birthday list's rollover.
 
 # git, for the commit `rake db:dump` makes of what it wrote
@@ -32,6 +52,10 @@ WORKDIR /app
 COPY --from=build /usr/local/bundle /usr/local/bundle
 COPY --from=build /app /app
 
+# An image tagged for ZJIT checks it really has it, rather than trusting
+# the base was configured with it.
+RUN case "$RUBYOPT" in *--zjit*) ruby -e 'abort "ZJIT is off" unless defined?(RubyVM::ZJIT) && RubyVM::ZJIT.enabled?' ;; esac
+
 EXPOSE 9292
 
 # 0.0.0.0 reaches only the container's own network namespace. Exposure to