Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ variant for the other platforms (e.g. `dir_c_memfs` vs `dir_c_memfs_msys`).
Manifest schema (`schema/patches.schema.yml`): `version: "<line>"` plus a
`patches:` **array** of entries (ordered; entries apply in listed order).
Each entry: `feature: <snake_case>`, `file: <path>` (local or
`../<line>/...`), and optionally `version: "<patch>"`.
`../<line>/...`), and optionally `version: "<patch>"`. An overlay entry
may also carry `until: "<patch>"`: the release monitor carries the entry
forward up to that patch level inclusive, then drops it — the model for a
feature that ends mid-line (e.g. upstream absorbed the fix).

Resolution for ruby **X.Y.Z** (`Tfs::PatchSelection`):

Expand Down Expand Up @@ -190,7 +193,9 @@ wires children with `autoload`):
versions.yml (official URL + sha256 of the fetched tarball), seeds a
manifest for a new line from the nearest existing line, extends
complete-partition families to the new patch level, carries the line's
overlay forward, and lints the whole set with `git apply --check`
overlay forward (entries bounded by `until:` are dropped past their
bound, with the bound preserved across carries), and lints the whole
set with `git apply --check`
against the sha256-verified tarball. On any failure every touched file
is restored — nothing is released silently.
- `tools/smoke_matrix <release-tag>` — prints the release-src
Expand Down
4 changes: 3 additions & 1 deletion patches/3.4/patch-3.4.7.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ version: "3.4.7"
patches:
# bundled openssl gem 3.3.0 sets V_FLAG_CRL_CHECK_ALL on the default
# store; OpenSSL 3.6 hard-fails CRL-DP chains (tebako-runtime-ruby#123).
# removed upstream (ruby/openssl#949); vendored ssl.rb fixed >= 3.4.8
# removed upstream (ruby/openssl#949); vendored ssl.rb fixed >= 3.4.8,
# so the carry ends at this overlay -- never carried forward.
- feature: openssl_ssl_default_store_crl_removal
file: ../3.1/openssl_ssl_default_store_crl_removal.patch
until: "7"
6 changes: 6 additions & 0 deletions patches/3.4/patch-3.4.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ patches:
- feature: openssl_extconf_ruby_export_msys
file: openssl_extconf_ruby_export_msys_10.patch
version: "10"
- feature: openssl_extconf_ruby_export_msys
file: openssl_extconf_ruby_export_msys_10.patch
version: "11"
# clang (and cl.exe) reject digest.h's plain extern against digest.c's
# dllexport definition under --with-static-linked-ext; upstream's macro
# fix, carried for every 3.4.x (unshipped upstream, ruby/ruby d0e0f2af)
Expand Down Expand Up @@ -89,6 +92,9 @@ patches:
- feature: prism_compile_memfs
file: prism_compile_memfs_6.patch
version: "10"
- feature: prism_compile_memfs
file: prism_compile_memfs_6.patch
version: "11"
- feature: ruby_c_fcntl_shim
file: ruby_c_fcntl_shim.patch
- feature: ruby_c_memfs_path_msys
Expand Down
3 changes: 3 additions & 0 deletions schema/patches.schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ properties:
version:
type: string
pattern: "^[0-9]+$"
until:
type: string
pattern: "^[0-9]+$"
76 changes: 64 additions & 12 deletions spec/tfs/onboarder_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@

# Builds a scratch repo: versions.yml from the fixture, a 3.3 line
# manifest with one real patch (tiny), a complete-partition family (gnu),
# and a 3.3.7 overlay (exact_only). Cache is seeded with a runtime-built
# and a 3.3.7 overlay (exact_only; +overlay_until+ bounds the onboarder's
# carry-forward of it). Cache is seeded with a runtime-built
# tarball for the new version, so nothing touches the network.
def build_repo(dir, with_bogus: false)
def build_repo(dir, with_bogus: false, overlay_until: nil)
FileUtils.cp(File.join(SPEC_FIXTURES, "versions.yml"), File.join(dir, "versions.yml"))

line_dir = File.join(dir, "patches", "3.3")
Expand Down Expand Up @@ -40,12 +41,10 @@ def build_repo(dir, with_bogus: false)
manifest += [" - feature: bogus", " file: bogus.patch"]
end
File.write(File.join(line_dir, "patch-3.3.yaml"), manifest.join("\n") + "\n")
File.write(File.join(line_dir, "patch-3.3.7.yaml"), <<~YAML)
version: "3.3.7"
patches:
- feature: exact_only
file: exact_only.patch
YAML
overlay = ["version: \"3.3.7\"", "patches:",
" - feature: exact_only", " file: exact_only.patch"]
overlay << " until: \"#{overlay_until}\"" unless overlay_until.nil?
File.write(File.join(line_dir, "patch-3.3.7.yaml"), overlay.join("\n") + "\n")
end

def build_tarball(dir, version)
Expand All @@ -59,14 +58,20 @@ def build_tarball(dir, version)
tarball
end

def onboard_in(dir, version, with_bogus: false)
build_repo(dir, with_bogus: with_bogus)
def onboard_in(dir, version, with_bogus: false, overlay_until: nil, releases: nil)
build_repo(dir, with_bogus: with_bogus, overlay_until: overlay_until)
seed_cache(dir, version)
result = described_class.new(releases: releases || self.releases, repo_root: dir,
cache_dir: File.join(dir, "cache")).onboard(version)
[result, File.join(dir, "ruby-#{version}.tar.gz")]
end

def seed_cache(dir, version)
cache = File.join(dir, "cache")
FileUtils.mkdir_p(cache)
tarball = build_tarball(dir, version)
FileUtils.cp(tarball, File.join(cache, "ruby-#{version}.tar.gz"))
result = described_class.new(releases: releases, repo_root: dir, cache_dir: cache).onboard(version)
[result, tarball]
tarball
end

it "pins the new version into versions.yml and lints clean" do
Expand Down Expand Up @@ -110,6 +115,53 @@ def onboard_in(dir, version, with_bogus: false)
end
end

it "drops an overlay entry whose until bound ends at its own overlay" do
Dir.mktmpdir do |dir|
result, = onboard_in(dir, "3.3.8", overlay_until: "7")

expect(result).to be_applied
expect(result.dropped).to eq(["exact_only"])
expect(result.to_h["dropped"]).to eq(["exact_only"])
expect(File.exist?(File.join(dir, "patches", "3.3", "patch-3.3.8.yaml"))).to be(false)
expect(File.read(File.join(dir, "versions.yml"))).to include(" 3.3.8:\n")
end
end

it "carries an entry inside its until bound and preserves the bound" do
Dir.mktmpdir do |dir|
result, = onboard_in(dir, "3.3.8", overlay_until: "8")

expect(result).to be_applied
expect(result.dropped).to eq([])
overlay = File.join(dir, "patches", "3.3", "patch-3.3.8.yaml")
expect(File.read(overlay)).to match(/- feature: exact_only\n file: exact_only\.patch\n until: "8"/)
end
end

it "ends the carry at a preserved bound on the next onboarding" do
Dir.mktmpdir do |dir|
build_repo(dir, overlay_until: "8")
seed_cache(dir, "3.3.8")
described_class.new(releases: releases, repo_root: dir, cache_dir: File.join(dir, "cache")).onboard("3.3.8")

with_339 = Tfs::RubyReleases.new(
File.read(File.join(SPEC_FIXTURES, "releases.html")).sub(
"</table>",
" <tr><td>Ruby 3.3.9</td><td>2025-10-01</td><td>" \
'<a href="https://cache.ruby-lang.org/pub/ruby/3.3/ruby-3.3.9.tar.gz">download</a></td></tr>'"\n</table>"
)
)
seed_cache(dir, "3.3.9")
result = described_class.new(releases: with_339, repo_root: dir,
cache_dir: File.join(dir, "cache")).onboard("3.3.9")

expect(result).to be_applied
expect(result.dropped).to eq(["exact_only"])
expect(File.exist?(File.join(dir, "patches", "3.3", "patch-3.3.8.yaml"))).to be(true)
expect(File.exist?(File.join(dir, "patches", "3.3", "patch-3.3.9.yaml"))).to be(false)
end
end

it "seeds a manifest for a new line from the nearest existing line" do
Dir.mktmpdir do |dir|
result, = onboard_in(dir, "3.4.2")
Expand Down
44 changes: 44 additions & 0 deletions spec/tfs/patch_manifest_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,48 @@
expect { described_class.new(path) }.to raise_error(ArgumentError)
end
end

it "parses an entry's until carry bound and bounds carriability with it" do
Dir.mktmpdir do |dir|
path = File.join(dir, "patch-1.1.7.yaml")
File.write(path, <<~YAML)
version: "1.1.7"
patches:
- feature: bounded
file: x.patch
until: "7"
- feature: unbounded
file: x.patch
YAML
bounded, unbounded = described_class.new(path).entries
expect(bounded.until).to eq("7")
expect(bounded).to be_carriable_to("7")
expect(bounded).not_to be_carriable_to("8")
expect(unbounded.until).to be_nil
expect(unbounded).to be_carriable_to("99")
end
end

it "rejects an entry with a malformed until" do
Dir.mktmpdir do |dir|
path = File.join(dir, "patch-1.1.yaml")
File.write(path, <<~YAML)
version: "1.1"
patches:
- feature: x
file: x.patch
until: 7
YAML
expect { described_class.new(path) }.to raise_error(ArgumentError)

File.write(path, <<~YAML)
version: "1.1"
patches:
- feature: x
file: x.patch
until: "seven"
YAML
expect { described_class.new(path) }.to raise_error(ArgumentError)
end
end
end
43 changes: 31 additions & 12 deletions tools/lib/tfs/onboarder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,34 +16,41 @@ module Tfs
# kept only if it applies);
# 4. carry the line's nearest lower overlay forward (e.g. the ruby3x7
# onigmo/winmain fixes) as a new patch-<line>.<z>.yaml, kept only
# if its patches apply;
# if its patches apply. An overlay entry with +until: "<z>"+ is
# carried up to that patch level inclusive and dropped past it
# (reported on the Result as dropped); a carried entry keeps its
# bound, so the end survives further carries;
# 5. lint: git apply --check of the whole selected set against the
# sha256-verified official tarball.
# On any failure every touched file is restored; nothing is released
# silently.
class Onboarder
# The outcome of one onboarding attempt. `failures` maps each failing
# patch name to its git apply output (the failing hunk), so the monitor
# workflow can file an issue that carries the actual drift.
# workflow can file an issue that carries the actual drift. `dropped`
# names the overlay features whose +until+ bound ended the carry at
# this patch level (the feature retired; not a failure).
class Result
def initialize(version:, applied:, failing:, written:, extended:, failures: {})
def initialize(version:, applied:, failing:, written:, extended:, failures: {}, dropped: [])
@version = version
@applied = applied
@failing = failing
@written = written
@extended = extended
@failures = failures
@dropped = dropped
end

attr_reader :version, :failing, :written, :extended, :failures
attr_reader :version, :failing, :written, :extended, :failures, :dropped

def applied?
@applied
end

def to_h
{ "version" => @version, "applied" => applied?, "failing" => @failing,
"failures" => @failures, "extended" => @extended, "written" => @written }
"failures" => @failures, "extended" => @extended, "dropped" => @dropped,
"written" => @written }
end
end

Expand All @@ -67,7 +74,7 @@ def onboard(version_name)
ensure_line_manifest(line, originals, written)
extended = extend_partitions(version_name, originals, written)

candidates = overlay_candidates(line, patchlevel)
candidates, dropped = overlay_candidates(line, patchlevel)
outcomes = Dir.mktmpdir do |dir|
build_prep.audit(version_name, dir, patches: selected_patches(version_name) + candidates)
end
Expand All @@ -80,7 +87,8 @@ def onboard(version_name)
restore(originals) unless applied

Result.new(version: version_name, applied: applied, failing: failing,
written: applied ? written.uniq : [], extended: extended, failures: failures)
written: applied ? written.uniq : [], extended: extended, failures: failures,
dropped: dropped)
end

private
Expand Down Expand Up @@ -210,22 +218,32 @@ def extend_entry(feature, version_name, originals, written)

# --- overlay carry-forward ------------------------------------------------

# Candidates for the overlay carry, plus the features whose +until+
# bound ends the carry at this patch level: [carried, dropped].
def overlay_candidates(line, patchlevel)
return [] if File.exist?(File.join(@patches_root, line, "patch-#{line}.#{patchlevel}.yaml"))
return [[], []] if File.exist?(File.join(@patches_root, line, "patch-#{line}.#{patchlevel}.yaml"))

overlays = Dir.glob(File.join(@patches_root, line, "patch-#{line}.*.yaml")).filter_map do |path|
overlay_z = path[/#{Regexp.escape(line)}\.(\d+)\.yaml\z/, 1]
overlay_z if overlay_z && overlay_z.to_i < patchlevel.to_i
end
return [] if overlays.empty?
return [[], []] if overlays.empty?

nearest_z = overlays.max_by(&:to_i)
manifest = PatchManifest.new(File.join(@patches_root, line, "patch-#{line}.#{nearest_z}.yaml"))
line_dir = File.join(@patches_root, line)
manifest.entries.map do |entry|
PatchSelection::Patch.new(path: File.expand_path(entry.file, line_dir),
feature: entry.feature, version: entry.version)
carried = []
dropped = []
manifest.entries.each do |entry|
if entry.carriable_to?(patchlevel)
carried << PatchSelection::Patch.new(path: File.expand_path(entry.file, line_dir),
feature: entry.feature, version: entry.version,
carry_until: entry.until)
else
dropped << entry.feature
end
end
[carried, dropped]
end

def carry_overlay(line, patchlevel, candidates, originals, written)
Expand All @@ -241,6 +259,7 @@ def carry_overlay(line, patchlevel, candidates, originals, written)
candidates.each do |patch|
out << " - feature: #{patch.feature}"
out << " file: #{Pathname.new(patch.path).relative_path_from(Pathname.new(line_dir))}"
out << " until: \"#{patch.until}\"" unless patch.until.nil?
end
File.write(path, out.join("\n") + "\n")
written << path
Expand Down
25 changes: 20 additions & 5 deletions tools/lib/tfs/patch_manifest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@
module Tfs
# Parses one patch-<line>[.<patch>].yaml manifest: an ordered array of
# patch entries. Entry without +version+ applies to the whole line;
# entry with +version+ applies only to that exact patch level.
# entry with +version+ applies only to that exact patch level. An
# overlay entry may carry +until+: the onboarder carries it forward up
# to and including that patch level, then drops it (the feature ended,
# e.g. upstream absorbed the fix). +until+ has no selection meaning --
# an overlay always applies to its own exact patch level only.
class PatchManifest
# One manifest entry.
class Entry
FORMAT = /\A[a-z0-9]+(_[a-z0-9]+)*\z/.freeze
PATCHLEVEL = /\A[0-9]+\z/.freeze

def initialize(feature:, file:, version:, manifest:)
def initialize(feature:, file:, version:, carry_until:, manifest:)
@feature = feature
@file = file
@version = version
@until = carry_until
@manifest = manifest
end

attr_reader :feature, :file, :version
attr_reader :feature, :file, :version, :until

def whole_line?
@version.nil?
Expand All @@ -27,6 +33,13 @@ def whole_line?
def exact_for?(patchlevel)
@version == patchlevel
end

# May the onboarder carry this entry forward to +patchlevel+?
# Unbounded entries carry forever; +until+ bounds the carry to that
# patch level inclusive.
def carriable_to?(patchlevel)
@until.nil? || Gem::Version.new(patchlevel) <= Gem::Version.new(@until)
end
end

def initialize(path)
Expand All @@ -46,11 +59,13 @@ def initialize(path)
def entry(path, data)
unless data.is_a?(Hash) && data["feature"].is_a?(String) && data["file"].is_a?(String) &&
Entry::FORMAT.match?(data["feature"]) &&
(data["version"].nil? || data["version"].is_a?(String))
(data["version"].nil? || data["version"].is_a?(String)) &&
(data["until"].nil? || data["until"].is_a?(String) && Entry::PATCHLEVEL.match?(data["until"]))
raise ArgumentError, "#{path}: malformed entry #{data.inspect}"
end

Entry.new(feature: data["feature"], file: data["file"], version: data["version"], manifest: path)
Entry.new(feature: data["feature"], file: data["file"], version: data["version"],
carry_until: data["until"], manifest: path)
end
end
end
Loading
Loading