Run merge request pipelines on Ruby 3.4 by default

What does this MR do and why?

Merge request pipelines now run on Ruby 3.4.10 instead of 3.3. In .gitlab-ci.yml, the rules for unlabelled, database, Community contribution, project access token bot, and AI principles distiller MRs switch to RUBY_VERSION_NEXT. Merge train pipelines follow, which changes nothing they test, since they only run pre-merge-checks and dependency scanning.

The opt-out label pipeline:run-in-ruby3_3, pipeline:run-with-rails-next, stable-branch MRs (now including -stable-jh), master, tags, scheduled master pipelines, and GitLab.com stay on 3.3. pipeline:run-with-ruby-next stays but no longer changes the version. Danger now warns when an MR changes RUBY_VERSION_DEFAULT without pipeline:run-in-ruby3_3, because its pipeline would not run the new version otherwise. Over 7 days, 0.09% of blocking RSpec jobs failed on the ruby-next branch (master's code on 3.4), against 0.58% on master.

  • Expect some broken master incidents from code that only works on 3.4, such as a spec matching its Hash#inspect output or an error message that quotes with ' instead of a backtick. Authors can add pipeline:run-in-ruby3_3 to reproduce 3.3 before merging.
  • This brings back the coupling that Decouple MR pipelines from RUBY_VERSION_NEXT bumps (!252108 - merged) removed: until the default moves to 3.4, a RUBY_VERSION_NEXT bump changes every MR pipeline's Ruby version. These rules should go back to the default version when it does.

Related: #635467 (closed)

Verification

The change does what the issue asked:

  • The updated pipeline_labels_spec.rb passes on this branch (28 examples, 0 failures). Its five next-Ruby examples fail on master's config, each getting RUBY_VERSION_DEFAULT where RUBY_VERSION_NEXT was expected. The 16-10-stable-jh example fails without the -stable-jh pattern fix.
  • I dry-ran 13 MR scenarios through Ci::CreatePipelineService (the same harness spec/dot_gitlab_ci uses) with the repo's real version values. Unlabelled, pipeline::tier-3, database, Community contribution, project access token bot, principles distiller, merge train, docs-only, and CI config change MRs now resolve RUBY_VERSION to 3.4.10 and show it in the pipeline name. The four scenarios with pipeline:run-in-ruby3_3, pipeline:run-with-rails-next, or pipeline:run-with-ruby-next (the last also combined with database) are unchanged.
  • All 14 distinct Ruby 3.4.10 images those pipelines reference exist (HTTP 200 on the manifest from registry.gitlab.com and Docker Hub).
  • I ran the Danger rule against a stubbed helper on 11 cases. It warns only when RUBY_VERSION_DEFAULT is added or changed without pipeline:run-in-ruby3_3 on an MR to a non-stable branch. It stays quiet for RUBY_VERSION_NEXT bumps, removed lines, other files, and MRs to -stable, -stable-ee, and -stable-jh branches.
  • A dry-run MR pipeline resolves the same Ruby version with and without pipeline:run-with-ruby-next, whether the MR has no other label, pipeline:run-in-ruby3_3, or pipeline:run-with-rails-next (6 of 6 cases).

Nothing else changed:

  • I dumped every job with its resolved variables, image, services, cache, and needs for the same 13 scenarios on master and on the branch. Both have the same job set in every scenario, from 1 job (merge train) to 265 (tier 3).
  • 5,187 job fields differ, and every one is the 3.3.12 to 3.4.10 swap (or GITLAB_RUBY3_3 to GITLAB_RUBY3_4) in the Ruby version, cache edition, CI image, job image, cache key, and pipeline name. The four labelled scenarios show no differences at all.
  • Locally, the full spec/dot_gitlab_ci suite passes apart from 3 of its 1,075 examples, all in rules_spec.rb. Those 3 fail the same way with master's .gitlab-ci.yml and don't fail on master in CI.
  • Whole-repo RuboCop: 54,322 files inspected, no offenses.
Method, commands, and harness source

The dump harness is a throwaway spec that is not committed. I ran it from the repo root twice with master's .gitlab-ci.yml and once on this branch, then compared the runs and checked the images:

git checkout origin/master -- .gitlab-ci.yml
DUMP_OUT=tmp/verify/v13/master-a bundle exec rspec -I spec tmp/verify/mr_ruby_dump_spec.rb
DUMP_OUT=tmp/verify/v13/master-b bundle exec rspec -I spec tmp/verify/mr_ruby_dump_spec.rb
git checkout HEAD -- .gitlab-ci.yml
DUMP_OUT=tmp/verify/v13/branch bundle exec rspec -I spec tmp/verify/mr_ruby_dump_spec.rb
python3 tmp/verify/compare.py tmp/verify/v13
python3 tmp/verify/list_images.py tmp/verify/v13/branch > tmp/verify/images13.txt
bash tmp/verify/check_images.sh tmp/verify/images13.txt

compare.py ignores a field for a job only when it differed for that job between the two master runs (IDs, random branch names). It also ignores CI_MERGE_REQUEST_TITLE (a factory sequence value, so the two master runs can match by chance) and CI_SERVER_REVISION (the commit of the GitLab code running the simulation). Its last line gives the total of differing job fields and how many are not the 3.3.12 to 3.4.10 swap: 5,187 and 0.

The bot scenarios pass GITLAB_USER_LOGIN as a pipeline variable. The pipeline names ("Ruby 3.4.10 MR (triggered by a project token)" and "Ruby 3.4.10 MR (triggered by the principles distiller)") show their own workflow rules matched.

The dry run of a merge train shows only pre-merge-checks, because it leaves out the gitlab.com-only security includes. The 10 most recent live train pipelines each ran pre-merge-checks and dependency-scanning-profile-0 and nothing else.

Workflow rules for non-MR pipelines keep the default version, as do the no-op security-mirror sync MRs.

The spec examples on master's config, and the -stable-jh check:

git checkout origin/master -- .gitlab-ci.yml
bundle exec rspec spec/dot_gitlab_ci/ci_configuration_validation/pipeline_labels_spec.rb -e 'Ruby version resolution'
git checkout HEAD -- .gitlab-ci.yml
# then change (-ee|-jh)? back to (-ee)? in the pipeline:run-in-ruby3_3 rule and rerun

The Danger cases vary FILES, DIFF_LINES (joined with |), and LABELS, for example:

FILES=.gitlab/ci/version.yml DIFF_LINES='+  RUBY_VERSION_DEFAULT: "3.3.13"' LABELS='' ruby tmp/verify/danger_check.rb

I ran the full suite at this branch's commit with bundle exec rspec spec/dot_gitlab_ci, using a local Postgres and Redis. Running rules_spec.rb alone with master's .gitlab-ci.yml gives the same 3 failures.

Two of them are the **/tsconfig*.json pattern examples, which find no file in my checkout. On master in CI they passed in all 502 pipelines that ran them in the 7 days to 2026-10-22. The third, "does not miss files to match", is quarantined in https://gitlab.com/gitlab-org/quality/test-failure-issues/-/issues/11473+, so CI skips it.

For readiness, readiness.sql counts blocking rspec and rspec-ee job attempts on master and ruby-next from 2026-10-15 20:45 to 2026-10-22 20:45 UTC, each attempt with its last status. On ruby-next, 14 of 16,274 failed (0.09%). On master, 1,137 of 195,529 failed (0.58%).

Gem caches: of 22 MR pipelines sampled on 2026-10-22, the 15 run by maintainers read the protected gem cache key and the 7 run by developers read the non-protected one. The protected 3.4.10 key for master's Gemfile.lock already exists, because the ruby-next schedule keeps it current (https://gitlab.com/gitlab-org/gitlab/-/jobs/17387796535 restored it, last written 2026-10-22 05:20 UTC). MR pipelines fill the non-protected 3.4.10 key themselves, so developer-run pipelines start with a cold cache at first and are slower, not failing.

tmp/verify/mr_ruby_dump_spec.rb:

# frozen_string_literal: true

# Throwaway verification harness, never committed. Dumps every job of a dry-run
# MR pipeline per scenario so two config versions can be diffed.

require 'spec_helper'
require_relative '../../spec/dot_gitlab_ci/ci_configuration_validation/shared_context_and_examples'

RSpec.describe 'MR pipeline dump for Ruby default verification', feature_category: :tooling do
  include CiConfigurationValidationHelper

  include_context 'with simulated pipeline attributes and shared project and user'
  include_context 'with simulated MR pipeline attributes'

  let(:pipeline_project) { gitlab_org_gitlab_project }

  subject(:pipeline) do
    create_pipeline_service
      .execute(:push, dry_run: true, merge_request: merge_request, variables_attributes: mr_pipeline_variables_attributes)
      .payload
  end

  scenarios = {
    'unlabelled' => { labels: [], files: ['app/models/user.rb'] },
    'tier-3' => { labels: ['pipeline::tier-3'], files: ['app/models/user.rb'] },
    'database' => { labels: ['database'], files: ['app/models/user.rb'] },
    'community' => { labels: ['Community contribution'], files: ['app/models/user.rb'] },
    'run-in-ruby3_3' => { labels: ['pipeline:run-in-ruby3_3'], files: ['app/models/user.rb'] },
    'rails-next' => { labels: ['pipeline:run-with-rails-next'], files: ['app/models/user.rb'] },
    'run-with-ruby-next' => { labels: ['pipeline:run-with-ruby-next'], files: ['app/models/user.rb'] },
    'ruby-next+database' => { labels: ['pipeline:run-with-ruby-next', 'database'], files: ['app/models/user.rb'] },
    'merge-train' => { labels: [], files: ['app/models/user.rb'], event: 'merge_train' },
    'docs-only' => { labels: [], files: ['doc/_index.md'] },
    'ci-config' => { labels: [], files: ['.gitlab/ci/version.yml'] },
    'project-token-bot' => { labels: [], files: ['app/models/user.rb'], login: 'project_278964_bot2' },
    'principles-distiller' => { labels: [], files: ['app/models/user.rb'], login: 'service-modelops-agent-principles-distiller' }
  }

  def expand(value, vars)
    case value
    when String then ExpandVariables.expand(value, vars)
    when Array then value.map { |v| expand(v, vars) }
    when Hash then value.transform_values { |v| expand(v, vars) }
    else value
    end
  end

  scenarios.each do |scenario, attrs|
    context scenario do
      let(:mr_labels) { attrs[:labels] }
      let(:changed_files) { attrs[:files] }
      let(:ci_merge_request_event_type) { attrs.fetch(:event, 'merged_result') }
      let(:mr_pipeline_variables_attributes) do
        attrs[:login] ? super() << { key: 'GITLAB_USER_LOGIN', value: attrs[:login] } : super()
      end

      it 'dumps the pipeline' do
        expect(pipeline.yaml_errors).to be_nil
        expect(pipeline.errors).to be_empty

        jobs = pipeline.stages.flat_map(&:statuses).sort_by(&:name).map do |job|
          vars = job.scoped_variables.sort_and_expand_all
          {
            name: job.name,
            stage: job.stage_name,
            when: job.when,
            allow_failure: job.allow_failure,
            image: expand(job.options[:image], vars),
            services: expand(job.options[:services], vars),
            cache: expand(job.options[:cache], vars),
            needs: job.needs.map(&:name).sort,
            variables: vars.to_hash.sort.to_h
          }
        end

        out = File.join(ENV.fetch('DUMP_OUT'), "#{scenario}.json")
        File.write(out, Gitlab::Json.pretty_generate({ pipeline_status: pipeline.status, jobs: jobs }))
      end
    end
  end
end

tmp/verify/compare.py:

#!/usr/bin/env python3
"""Compare MR pipeline dumps: master-a vs branch, with master-a vs master-b as the noise floor."""
import json
import sys
from collections import defaultdict
from pathlib import Path

root = Path(sys.argv[1])
branch_run = sys.argv[2] if len(sys.argv) > 2 else "branch"


def load(run, scenario):
    data = json.loads((root / run / f"{scenario}.json").read_text())
    return {job["name"]: job for job in data["jobs"]}, data["pipeline_status"]


def flatten(job):
    flat = {}
    for key, value in job.items():
        if key == "variables":
            for var, val in value.items():
                flat[f"var:{var}"] = val
        elif key != "name":
            flat[key] = json.dumps(value, sort_keys=True)
    return flat


scenarios = sorted(p.stem for p in (root / "master-a").glob("*.json"))
total = unexplained = 0
for scenario in scenarios:
    a, a_status = load("master-a", scenario)
    b, _ = load("master-b", scenario)
    br, br_status = load(branch_run, scenario)

    # Per job, ignore fields that differ between the two master runs (IDs, random names).
    # CI_MERGE_REQUEST_TITLE is a factory sequence value, so the two master runs can agree by chance.
    # CI_SERVER_REVISION is the commit of the GitLab code simulating the pipeline, not pipeline config.
    noise = {}
    for name in set(a) & set(b):
        fa, fb = flatten(a[name]), flatten(b[name])
        noise[name] = {"var:CI_MERGE_REQUEST_TITLE", "var:CI_SERVER_REVISION"} | {k for k in set(fa) | set(fb) if fa.get(k) != fb.get(k)}

    print(f"== {scenario}: {len(a)} jobs on master, {len(br)} on branch, status {a_status} -> {br_status}")
    print(f"   noisy job fields ignored: {sum(len(v) for v in noise.values())}")
    if set(a) != set(br) or set(a) != set(b):
        print(f"   ONLY ON MASTER: {sorted(set(a) - set(br))}")
        print(f"   ONLY ON BRANCH: {sorted(set(br) - set(a))}")

    changes = defaultdict(lambda: defaultdict(int))
    for name in set(a) & set(br):
        fa, fbr = flatten(a[name]), flatten(br[name])
        for key in set(fa) | set(fbr):
            if key in noise.get(name, set()) or fa.get(key) == fbr.get(key):
                continue
            changes[key][(fa.get(key), fbr.get(key))] += 1
            total += 1
            if str(fa.get(key)).replace("3.3.12", "3.4.10").replace("RUBY3_3", "RUBY3_4") != str(fbr.get(key)):
                unexplained += 1

    if not changes:
        print("   no differences")
    for key in sorted(changes):
        for (old, new), count in sorted(changes[key].items(), key=lambda kv: -kv[1]):
            print(f"   {key} [{count} jobs]\n      - {str(old)[:300]}\n      + {str(new)[:300]}")

print(f"TOTAL: {total} differing job fields, {unexplained} not explained by the 3.3.12 to 3.4.10 swap")

tmp/verify/list_images.py:

#!/usr/bin/env python3
"""Print every distinct image or service name containing 3.4.10 in one dump run, e.g. tmp/verify/branch-final."""
import json
import sys
from pathlib import Path

images = set()
for dump in Path(sys.argv[1]).glob("*.json"):
    for job in json.loads(dump.read_text())["jobs"]:
        for source in [job.get("image")] + (job.get("services") or []):
            name = source.get("name") if isinstance(source, dict) else source
            if name and "3.4.10" in name:
                images.add(name)
print("\n".join(sorted(images)))

tmp/verify/check_images.sh:

#!/usr/bin/env bash
# Prints the HTTP status of each image manifest listed in images.txt (one image per line).
set -euo pipefail
accept='Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json'
while read -r img; do
  if [[ $img == registry.gitlab.com/* ]]; then
    ref=${img#registry.gitlab.com/}; repo=${ref%%:*}; tag=${ref#*:}
    tok=$(curl -s "https://gitlab.com/jwt/auth?service=container_registry&scope=repository:${repo}:pull" | jq -r .token)
    url="https://registry.gitlab.com/v2/${repo}/manifests/${tag}"
  else
    repo=library/${img%%:*}; tag=${img#*:}
    tok=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull" | jq -r .token)
    url="https://registry-1.docker.io/v2/${repo}/manifests/${tag}"
  fi
  echo "$(curl -s -o /dev/null -w '%{http_code}' -I -H "Authorization: Bearer $tok" -H "$accept" "$url") $img"
done < "${1:-tmp/verify/images.txt}"

tmp/verify/danger_check.rb:

# Throwaway harness: loads the Dangerfile with a stubbed `helper` and captures `warn`.
Helper = Struct.new(:all_changed_files, :lines, :mr_labels) do
  def changed_lines(_file) = lines
end
$warnings = []
def helper = $h
def warn(msg) = $warnings << msg
$h = Helper.new(ENV.fetch('FILES').split(','), ENV.fetch('DIFF_LINES').split('|'), ENV.fetch('LABELS').split(','))
load 'danger/ruby_version_next/Dangerfile'
puts $warnings.empty? ? 'no warning' : $warnings

tmp/verify/readiness.sql:

-- Blocking RSpec job attempts (rspec and rspec-ee) and how many failed, per ref.
-- build_metrics has no dedup, so collapse each job id to its last status first.
SELECT job_ref,
       count() AS jobs,
       countIf(status = 'failed') AS failed,
       round(100 * failed / jobs, 2) AS failed_pct
FROM (
  SELECT id,
         any(ref) AS job_ref,
         argMax(status, finished_at) AS status,
         argMax(allow_failure, finished_at) AS blocking_exempt
  FROM ci_metrics.build_metrics
  WHERE project_id = 278964
    AND ref IN ('master', 'ruby-next')
    AND created_at >= '2026-10-15 20:45:00' AND created_at < '2026-10-22 20:45:00'
    AND (startsWith(name, 'rspec ') OR startsWith(name, 'rspec-ee '))
  GROUP BY id
)
WHERE NOT blocking_exempt
GROUP BY job_ref
ORDER BY job_ref
FORMAT PrettyCompactMonoBlock
Edited by Richard Chong

Merge request reports

Loading
Loading