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
masterincidents from code that only works on 3.4, such as a spec matching itsHash#inspectoutput or an error message that quotes with'instead of a backtick. Authors can addpipeline:run-in-ruby3_3to 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_NEXTbump 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.rbpasses on this branch (28 examples, 0 failures). Its five next-Ruby examples fail on master's config, each gettingRUBY_VERSION_DEFAULTwhereRUBY_VERSION_NEXTwas expected. The16-10-stable-jhexample fails without the-stable-jhpattern fix. - I dry-ran 13 MR scenarios through
Ci::CreatePipelineService(the same harnessspec/dot_gitlab_ciuses) 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 resolveRUBY_VERSIONto 3.4.10 and show it in the pipeline name. The four scenarios withpipeline:run-in-ruby3_3,pipeline:run-with-rails-next, orpipeline:run-with-ruby-next(the last also combined withdatabase) 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_DEFAULTis added or changed withoutpipeline:run-in-ruby3_3on an MR to a non-stable branch. It stays quiet forRUBY_VERSION_NEXTbumps, removed lines, other files, and MRs to-stable,-stable-ee, and-stable-jhbranches. - 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, orpipeline: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_3toGITLAB_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_cisuite passes apart from 3 of its 1,075 examples, all inrules_spec.rb. Those 3 fail the same way with master's.gitlab-ci.ymland don't fail onmasterin 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.txtcompare.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 rerunThe 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.rbI 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
endtmp/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' : $warningstmp/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