diff --git a/gems/ast-crispr/Gemfile b/gems/ast-crispr/Gemfile index b467db17b..321d26a87 100644 --- a/gems/ast-crispr/Gemfile +++ b/gems/ast-crispr/Gemfile @@ -24,11 +24,8 @@ gem 'kettle-family', '~> 1.2', '>= 1.2.100' structuredmerge_rust_dev = ENV.fetch('STRUCTUREDMERGE_RUST_DEV', '').strip if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredmerge_rust_dev.downcase) gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') -end - -# CI current and coverage jobs require the published Rust host gem; this fails until it is released. -if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && - instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } +elsif ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? + # CI current and coverage jobs require the published Rust host gem; this fails until it is released. gem 'structuredmerge_host_prototype', '~> 0.2' end diff --git a/gems/ast-merge-git/Gemfile b/gems/ast-merge-git/Gemfile index cdec3f5c8..8b159725f 100644 --- a/gems/ast-merge-git/Gemfile +++ b/gems/ast-merge-git/Gemfile @@ -11,11 +11,8 @@ source 'https://gem.coop' structuredmerge_rust_dev = ENV.fetch('STRUCTUREDMERGE_RUST_DEV', '').strip if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredmerge_rust_dev.downcase) gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') -end - -# CI current and coverage jobs require the published Rust host gem; this fails until it is released. -if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && - instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } +elsif ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? + # CI current and coverage jobs require the published Rust host gem; this fails until it is released. gem 'structuredmerge_host_prototype', '~> 0.2' end diff --git a/gems/ast-template/Gemfile b/gems/ast-template/Gemfile index 3dcb46b5a..c3281eb24 100644 --- a/gems/ast-template/Gemfile +++ b/gems/ast-template/Gemfile @@ -32,11 +32,8 @@ direct_sibling_gems = %w[ structuredmerge_rust_dev = ENV.fetch('STRUCTUREDMERGE_RUST_DEV', '').strip if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredmerge_rust_dev.downcase) gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') -end - -# CI current and coverage jobs require the published Rust host gem; this fails until it is released. -if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && - instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } +elsif ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? + # CI current and coverage jobs require the published Rust host gem; this fails until it is released. gem 'structuredmerge_host_prototype', '~> 0.2' end diff --git a/gems/bash-merge/Gemfile b/gems/bash-merge/Gemfile index 26874c328..b1be037f3 100644 --- a/gems/bash-merge/Gemfile +++ b/gems/bash-merge/Gemfile @@ -19,13 +19,10 @@ git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" } gemspec structuredmerge_rust_dev = ENV.fetch('STRUCTUREDMERGE_RUST_DEV', '').strip -unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structuredmerge_rust_dev.downcase) +if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredmerge_rust_dev.downcase) gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') -end - -# CI current and coverage jobs require the published Rust host gem; this fails until it is released. -if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && - instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } +elsif ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? + # CI current and coverage jobs require the published Rust host gem; this fails until it is released. gem 'structuredmerge_host_prototype', '~> 0.2' end diff --git a/gems/go-merge/Gemfile b/gems/go-merge/Gemfile index 3ba8fe33b..ec57d0c32 100644 --- a/gems/go-merge/Gemfile +++ b/gems/go-merge/Gemfile @@ -19,13 +19,10 @@ git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" } gemspec structuredmerge_rust_dev = ENV.fetch('STRUCTUREDMERGE_RUST_DEV', '').strip -unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structuredmerge_rust_dev.downcase) +if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredmerge_rust_dev.downcase) gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') -end - -# CI current and coverage jobs require the published Rust host gem; this fails until it is released. -if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && - instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } +elsif ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? + # CI current and coverage jobs require the published Rust host gem; this fails until it is released. gem 'structuredmerge_host_prototype', '~> 0.2' end diff --git a/gems/json-merge/Gemfile b/gems/json-merge/Gemfile index 16d2dba24..2a6973940 100644 --- a/gems/json-merge/Gemfile +++ b/gems/json-merge/Gemfile @@ -19,13 +19,10 @@ git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" } gemspec structuredmerge_rust_dev = ENV.fetch('STRUCTUREDMERGE_RUST_DEV', '').strip -unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structuredmerge_rust_dev.downcase) +if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredmerge_rust_dev.downcase) gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') -end - -# CI current and coverage jobs require the published Rust host gem; this fails until it is released. -if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && - instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } +elsif ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? + # CI current and coverage jobs require the published Rust host gem; this fails until it is released. gem 'structuredmerge_host_prototype', '~> 0.2' end diff --git a/gems/kettle-jem/CHANGELOG.md b/gems/kettle-jem/CHANGELOG.md index d8130b32f..07180ba9f 100644 --- a/gems/kettle-jem/CHANGELOG.md +++ b/gems/kettle-jem/CHANGELOG.md @@ -31,9 +31,21 @@ Please file a bug if you notice a violation of semantic versioning. the existing template-managed (case 1) conflicts, and the same auto-resolution path for any future entry in this category with `force_review: false`. +- `workflows.framework_matrix` gemfiles for gems tracked by kettle-rb's + `Kettle::Rb::GemFloors` (activemodel, activerecord, activesupport, sqlite3) + require the minor series' security floor when the version's requirement + still admits the series' first release (e.g. + `gem "activerecord", "~> 7.1.0", ">= 7.1.6"`), and carry a `# kettle-rb:` + warning comment when no release in the series fixes an advisory. ### Changed +- Require kettle-rb `>= 0.1.14` (security floor data). +- The `sqlite3` `KNOWN_GEM_CONFLICT_RESOLUTIONS` reason now points at a + `platforms: [:ruby]` modular gemfile (kettle-jem-appraisals generates + `gemfiles/modular/activerecord_support/*.gemfile`) instead of saying sqlite3 + has no modular home. + - [kc] kettle-jem-deps-floor: Update kettle-jem template dependency floors: - appraisal2 (>= 3.2.3 -> >= 3.2.4) - appraisal2-rubocop (>= 1.0.2 -> >= 1.0.3) diff --git a/gems/kettle-jem/Gemfile b/gems/kettle-jem/Gemfile index 63691c8bb..0a9fd7ed2 100644 --- a/gems/kettle-jem/Gemfile +++ b/gems/kettle-jem/Gemfile @@ -19,13 +19,10 @@ git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" } gemspec structuredmerge_rust_dev = ENV.fetch("STRUCTUREDMERGE_RUST_DEV", "").strip -unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structuredmerge_rust_dev.downcase) +if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredmerge_rust_dev.downcase) gem "structuredmerge_host_prototype", path: File.join(structuredmerge_rust_dev, "packages/ruby") -end - -# CI current and coverage jobs require the published Rust host gem; this fails until it is released. -if ENV.fetch("STRUCTUREDMERGE_RUST_HOST_PUBLISHED", "false").casecmp("true").zero? && - instance_variable_get(:@dependencies).none? { |dependency| dependency.name == "structuredmerge_host_prototype" } +elsif ENV.fetch("STRUCTUREDMERGE_RUST_HOST_PUBLISHED", "false").casecmp("true").zero? + # CI current and coverage jobs require the published Rust host gem; this fails until it is released. gem "structuredmerge_host_prototype", "~> 0.2" end diff --git a/gems/kettle-jem/Gemfile.lock b/gems/kettle-jem/Gemfile.lock index 901b41c3e..3714f7efc 100644 --- a/gems/kettle-jem/Gemfile.lock +++ b/gems/kettle-jem/Gemfile.lock @@ -24,7 +24,7 @@ PATH kettle-dev (~> 3.0, >= 3.0.34) kettle-gha-pins (~> 0.3, >= 0.3.14) kettle-ndjson (~> 0.1, >= 0.1.13) - kettle-rb (~> 0.1, >= 0.1.11) + kettle-rb (~> 0.1, >= 0.1.14) kramdown-merge (~> 7.1, >= 7.1.8) markdown-merge (~> 7.1, >= 7.1.8) markly-merge (~> 7.1, >= 7.1.8) @@ -337,8 +337,6 @@ GEM command_kit-completion (0.3.0) command_kit (~> 0.1) completely (~> 0.6) - commonmarker (2.10.0) - rb_sys (~> 0.9) commonmarker (2.10.0-aarch64-linux) commonmarker (2.10.0-aarch64-linux-musl) commonmarker (2.10.0-arm64-darwin) @@ -439,7 +437,7 @@ GEM version_gem (~> 1.1, >= 1.1.15) kettle-ndjson (0.1.15) version_gem (~> 1.1, >= 1.1.15) - kettle-rb (0.1.12) + kettle-rb (0.1.14) kettle-soup-cover (3.0.10) kettle-wash (~> 0.1, >= 0.1.6) simplecov (>= 1.0) @@ -510,9 +508,6 @@ GEM racc (1.8.1) rainbow (3.1.1) rake (13.4.2) - rake-compiler-dock (1.12.0) - rb_sys (0.9.130) - rake-compiler-dock (= 1.12.0) rbs (4.2.0) logger prism (>= 1.6.0) @@ -707,9 +702,6 @@ GEM toml-rb (4.2.1) citrus (~> 3.0, > 3.0) racc (~> 1.7) - tree_sitter_language_pack (1.19.1) - rb_sys (>= 0.9.130, < 0.10) - sorbet-runtime (~> 0.5) tree_sitter_language_pack (1.19.1-aarch64-linux) sorbet-runtime (~> 0.5) tree_sitter_language_pack (1.19.1-arm64-darwin) @@ -875,7 +867,6 @@ CHECKSUMS colsole (1.0.1) sha256=7632a300ace2db877c024ffe413c684954cf37e41264b75ace9f456c9d657b02 command_kit (0.6.0) sha256=e57f29cf5aee4f571c210041d910ff87f85749e42dfe75139c57cc93d84afa90 command_kit-completion (0.3.0) sha256=d9c498555125bcdd0c9d869f2a64a25a2f0205102a7f28c81c7937ae633ea737 - commonmarker (2.10.0) sha256=1884529957bf59bcd4f19fcc29e931f2042760ced273353e8010a20fe4ae68cb commonmarker (2.10.0-aarch64-linux) sha256=eeb167dea203dee066eb0dd99b3fd2deb526c8b5e7297ad385f7a5d60f869478 commonmarker (2.10.0-aarch64-linux-musl) sha256=e95d9e87ea6306e21ffa11855a7bb55518538143b23a227fe3bb00b41873ce25 commonmarker (2.10.0-arm64-darwin) sha256=39ecc891d1e4308b5233cc28b40383de854a2bfbefe17d68e49174347a8aa79e @@ -913,12 +904,12 @@ CHECKSUMS json-merge (7.1.9) kettle-changelog (1.0.7) sha256=42b9307e46e1e551434e8054ca4f467e8b13b69bd65e5dea4544b279179a50ff kettle-dev (3.1.0) sha256=2780915bf964f2ef0afe8468aaf90991195545f46628f9ec65f999ac58f19957 - kettle-drift (1.0.13) sha256=bb39982c637ecc6cdc56276cbd7679cb071317bb601dcd40c122997543643ed6 + kettle-drift (1.0.13) kettle-family (1.3.1) sha256=0e77fb67d8e989a806ae453fdcac43a44e3dd43e1d2dae7a51418504309e27d6 kettle-gha-pins (0.3.14) sha256=443a218149d3a2ab300875eb10ef55d5c4bc340bce7ba7caba026e1680dd8c21 kettle-jem (7.1.28) kettle-ndjson (0.1.15) sha256=8606ab5b59092d46ba50394b2ad65738f6a5cc11258c6cc943f8e451da864a1f - kettle-rb (0.1.12) sha256=918ea072d3069f998e7dbd76836a14378cc9a16e317e2bf79da714f5ec0be5a4 + kettle-rb (0.1.14) sha256=22b124f56d4c45a6ccebb686e9c7a802bc23a6636f572b2f07d5b9365bcc03d1 kettle-soup-cover (3.0.10) sha256=579e09d382d5c46b2a4787df492fdf598c774545d4ab1b7bd8cfaf6104424edd kettle-test (2.0.21) sha256=2ab0dd1a1d11568f8fba61084ebef84717cd4dff028a527777c92d4e77f0e9c5 kettle-wash (0.1.6) sha256=934dfb955f41b89540fe5df87614cf718a73ebc1696de7c50e7940a4695753ed @@ -960,8 +951,6 @@ CHECKSUMS racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 - rake-compiler-dock (1.12.0) sha256=f13205c2738f3d2053afcd03491a9e4541b22a59a0bfc53fc8bc883bd8188023 - rb_sys (0.9.130) sha256=7d486d99c1da02635515deaf9860fc5aea90bb4ab2589b2deec7fdc7d3548615 rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 rbs-merge (7.1.9) rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 @@ -1027,11 +1016,10 @@ CHECKSUMS timecop (0.9.11) sha256=41284dc6e5041f2184f781ace766f942108c842f8d8c1386a26e6343decc7542 timecop-rspec (1.0.3) sha256=005f14841bb606dcaefb060e321b5388e2e59537742bee8b3a9a9a40e598fab9 token-resolver (2.0.10) sha256=188df2d18d90816c690ba9a66d7299ba9de9a73779d816170ea70da624aa3d87 - toml (0.3.0) sha256=0a91d0f76a9036e84e44f95693b12f58d358c1df3ac8e3b5271f2056ecc6e4b9 + toml (0.3.0) toml-merge (7.1.9) toml-rb (4.2.1) sha256=e6c40e27905e86a7d38cc252eb4bc12d72c61576c826a358390c30f5de0b9dbb tree_haver (7.1.9) - tree_sitter_language_pack (1.19.1) sha256=12623c568e593e490f5bd027a4a9ab1d10aa49f283ff6d9e361cafbde8977e43 tree_sitter_language_pack (1.19.1-aarch64-linux) sha256=83cf05faaf78d4df8817217afb6b7eda479c1643f20bb2346e948101b7f632ec tree_sitter_language_pack (1.19.1-arm64-darwin) sha256=b3f42f5ae54b37893eb52ca8e1afa3bf7027d0a50af36c5b2a842466a372b6f4 tree_sitter_language_pack (1.19.1-x86_64-darwin) sha256=a84070e2b71f9edf8c3b07db48b5fedf96b6556abf133f412d1b7f0ef763d75f diff --git a/gems/kettle-jem/kettle-jem.gemspec b/gems/kettle-jem/kettle-jem.gemspec index e6cf0700e..e6f65e65d 100644 --- a/gems/kettle-jem/kettle-jem.gemspec +++ b/gems/kettle-jem/kettle-jem.gemspec @@ -108,7 +108,7 @@ Gem::Specification.new do |spec| spec.add_dependency("kettle-dev", "~> 3.0", ">= 3.0.34") # ruby >= 2.3.0 spec.add_dependency("kettle-gha-pins", "~> 0.3", ">= 0.3.14") # ruby >= 2.4.0 spec.add_dependency("kettle-ndjson", "~> 0.1", ">= 0.1.13") # ruby >= 2.4.0 - spec.add_dependency("kettle-rb", "~> 0.1", ">= 0.1.11") # ruby >= 1.8.7 + spec.add_dependency("kettle-rb", "~> 0.1", ">= 0.1.14") # ruby >= 1.8.7 spec.add_dependency("kramdown-merge", "~> 7.1", ">= 7.1.8") # ruby >= 4.0.0 spec.add_dependency("markdown-merge", "~> 7.1", ">= 7.1.8") # ruby >= 4.0.0 spec.add_dependency("markly-merge", "~> 7.1", ">= 7.1.8") # ruby >= 4.0.0 diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index c287c00af..94e6fa503 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -23,6 +23,7 @@ require "kettle/dev" require "kettle/changelog" require "kettle/rb/compat_matrix" +require "kettle/rb/gem_floors" require_relative "jem/version" require_relative "jem/license_txt_migrator" require_relative "jem/maintenance_changelog" @@ -120,8 +121,8 @@ class Error < StandardError; end # # :engine_incompatible - the gem doesn't support every engine the # destination declares (config["engines"], falling back to - # DEFAULT_ENGINES) and has no modular home (below the template's - # inclusion threshold). Detected by #engine_dependency_conflicts, a + # DEFAULT_ENGINES) and has no template-managed modular home (below the + # template's inclusion threshold). Detected by #engine_dependency_conflicts, a # separate engine-declaration-based check (comparing supported_engines # against the destination's declared engines) rather than by # modular_dependency_conflicts, since there's no modular-side @@ -144,7 +145,7 @@ class Error < StandardError; end "sqlite3" => { category: :engine_incompatible, action: "review", - reason: "sqlite3 has no jruby-platform release on rubygems.org and is below the template-inclusion threshold, so it has no modular home; resolve per destination based on declared engines.", + reason: "sqlite3 has no jruby-platform release on rubygems.org and is not template-managed; declare it in a modular gemfile with platforms: [:ruby] instead of a direct dependency (kettle-jem-appraisals generates gemfiles/modular/activerecord_support/*.gemfile, with the JDBC adapter on JRuby), or resolve per destination based on declared engines.", supported_engines: %w[ruby].freeze, force_review: true }.freeze @@ -584,39 +585,10 @@ class Error < StandardError; end } ]) README_TOP_LOGO_TYPES = %w[related_org ruby language org project affiliated_project].freeze - APPRAISAL_NAME_PREFIX = "kja" - APPRAISAL_GEM_ABBREVIATIONS = { - "activerecord" => "ar", - "actionmailer" => "am", - "actionpack" => "ap", - "activesupport" => "as", - "activejob" => "aj", - "actioncable" => "ac", - "actionview" => "av", - "activestorage" => "ast", - "actionmailbox" => "amb", - "actiontext" => "at", - "omniauth" => "oa", - "mongoid" => "mo", - "sequel" => "sq", - "couch_potato" => "cp", - "rom" => "rom", - "rom-sql" => "rsql" - }.freeze - APPRAISAL_WORKFLOW_LIFECYCLE_RANGES = Ractor.make_shareable({ - "current" => {min: Gem::Version.new("3.4"), max: Gem::Version.new("3.99")}, - "supported" => {min: Gem::Version.new("3.2"), max: Gem::Version.new("3.3")}, - "legacy" => {min: Gem::Version.new("3.0"), max: Gem::Version.new("3.1")}, - "unsupported" => {min: Gem::Version.new("2.6"), max: Gem::Version.new("2.7")}, - "ancient" => {min: Gem::Version.new("2.3"), max: Gem::Version.new("2.5")} - }) - APPRAISAL_ALWAYS_EXCLUDED_GEMS = %w[version_gem].freeze - APPRAISAL_VERSION_SELECTION_MODES = %w[major minor patch minor-minmax semver].freeze DEFAULT_TEST_MINIMUM_RUBY = Gem::Version.new("2.4") REQUIRE_RELATIVE_MIN_RUBY = Gem::Version.new("2.2").freeze ANONYMOUS_VERSION_LOADER_MIN_RUBY = Gem::Version.new("2.2").freeze MODERN_GEMSPEC_VERSION_LOADER_MIN_RUBY = Gem::Version.new("3.1").freeze - APPRAISAL_DEFAULT_FRESHNESS_TTL = 604_800 DECISION_ACTIONS = %w[create merge replace keep delete skip].freeze DECISION_SEVERITIES = %w[advisory warning fatal].freeze @@ -2701,546 +2673,6 @@ def install_tasks load(File.expand_path("jem/tasks.rb", __dir__)) end - def appraisal_gem_abbreviation(gem_name) - APPRAISAL_GEM_ABBREVIATIONS.fetch(gem_name.to_s, gem_name.to_s) - end - - def appraisal_format_version(version) - version.to_s.tr(".", "-") - end - - def appraisal_name(tier1_gem:, tier1_version:, ruby_series:, tier2_gem: nil, tier2_version: nil) - parts = [ - APPRAISAL_NAME_PREFIX, - appraisal_gem_abbreviation(tier1_gem), - appraisal_format_version(tier1_version) - ] - unless tier2_gem.to_s.empty? - parts << appraisal_gem_abbreviation(tier2_gem) - parts << appraisal_format_version(tier2_version) - end - parts << ruby_series.to_s - parts.join("-") - end - - def appraisal_modular_gemfile_path(gem_name:, version:, ruby_series:) - File.join("gemfiles", "modular", gem_name.to_s, ruby_series.to_s, "v#{version}.gemfile") - end - - def appraisal_modular_gemfile_content(gem_name:, version:, sub_dependencies: {}) - lines = [ - "# frozen_string_literal: true", - "", - "# Generated by kettle-jem", - "", - %(gem "#{gem_name}", "#{appraisal_version_requirement(version)}") - ] - sub_dependencies.each do |name, requirement| - lines << %(gem "#{name}", "~> #{requirement}") - end - ensure_trailing_newline(lines.join("\n")) - end - - def appraisal_version_requirement(version) - segments = version.to_s.split(".") - (segments.length >= 3) ? "~> #{version}" : "~> #{version}.0" - end - - def appraisal_file_content(matrix_entries) - lines = [ - "# frozen_string_literal: true", - "", - "# Generated by kettle-jem", - "# Do not edit directly; regenerate from Kettle/Jem appraisal matrix metadata.", - "" - ] - matrix_entries.each do |entry| - lines << %(appraise "#{entry.fetch(:name)}" do) - lines << %( eval_gemfile "#{appraisal_eval_gemfile_path(entry.fetch(:tier1_gemfile))}") if entry[:tier1_gemfile] - lines << %( eval_gemfile "#{appraisal_eval_gemfile_path(entry.fetch(:tier2_gemfile))}") if entry[:tier2_gemfile] - lines << %( eval_gemfile "#{appraisal_eval_gemfile_path(entry.fetch(:x_std_libs_gemfile))}") if entry[:x_std_libs_gemfile] - lines << "end" - lines << "" - end - lines.pop while lines.last == "" - ensure_trailing_newline(lines.join("\n")) - end - - def appraisal_eval_gemfile_path(path) - path.to_s.delete_prefix("gemfiles/") - end - - def appraisal_workflow_groups(matrix_entries, bucket_ranges:, exec_cmd: "kettle-test") - grouped = Hash.new { |hash, key| hash[key] = [] } - normalized_ranges = bucket_ranges.transform_values do |range| - { - floor: Gem::Version.new((range[:floor] || range["floor"]).to_s), - ceiling: Gem::Version.new((range[:ceiling] || range["ceiling"]).to_s) - } - end - matrix_entries.each do |entry| - ruby_series = entry[:ruby_series] || entry["ruby_series"] - range = normalized_ranges[ruby_series] - next unless range - - lifecycle = appraisal_workflow_lifecycle(range.fetch(:floor)) - grouped[lifecycle] << { - ruby: appraisal_workflow_ruby(range.fetch(:floor), lifecycle), - appraisal: entry[:name] || entry["name"], - exec_cmd: exec_cmd, - rubygems: "latest", - bundler: "latest" - } - end - grouped.transform_values { |entries| entries.sort_by { |entry| entry.fetch(:appraisal).to_s } } - end - - def appraisal_workflow_yaml_snippets(matrix_entries, bucket_ranges:, exec_cmd: "kettle-test") - appraisal_workflow_groups(matrix_entries, bucket_ranges: bucket_ranges, exec_cmd: exec_cmd).transform_values do |entries| - lines = ["strategy:", " matrix:", " include:"] - entries.each do |entry| - lines << %( - ruby: "#{entry.fetch(:ruby)}") - lines << %( appraisal: "#{entry.fetch(:appraisal)}") - lines << %( exec_cmd: "#{entry.fetch(:exec_cmd)}") - lines << %( rubygems: "#{entry.fetch(:rubygems)}") - lines << %( bundler: "#{entry.fetch(:bundler)}") - end - lines.join("\n") - end - end - - def appraisal_workflow_lifecycle(ruby_floor) - APPRAISAL_WORKFLOW_LIFECYCLE_RANGES.each do |name, range| - return name if ruby_floor.between?(range.fetch(:min), range.fetch(:max)) - end - (ruby_floor < APPRAISAL_WORKFLOW_LIFECYCLE_RANGES.fetch("ancient").fetch(:min)) ? "ancient" : "current" - end - - def appraisal_workflow_ruby(ruby_floor, lifecycle) - return "ruby" if lifecycle == "current" - - segments = ruby_floor.segments - "#{segments[0]}.#{segments[1] || 0}" - end - - def appraisal_x_stdlib_exclusions(template_content) - gems = ruby_call_records(template_content, :eval_gemfile).filter_map do |call| - path = ruby_string_argument(call).to_s - next unless path.start_with?("../") - - path.delete_prefix("../").split("/").first - end - (gems + APPRAISAL_ALWAYS_EXCLUDED_GEMS).uniq.sort - end - - def appraisal_select_versions(version_metadata, mode:, requirements: nil) - mode = mode.to_s - raise ArgumentError, "invalid appraisal version selection mode: #{mode}" unless APPRAISAL_VERSION_SELECTION_MODES.include?(mode) - - versions = appraisal_filtered_versions(version_metadata, requirements: requirements) - return versions if mode == "patch" - - by_major = appraisal_minor_versions_by_major(versions) - return [] if by_major.empty? - - current_major = by_major.last.fetch(:major) - case mode - when "major" - by_major.map { |entry| entry.fetch(:minors).last } - when "minor" - by_major.flat_map { |entry| entry.fetch(:minors) } - when "minor-minmax" - by_major.flat_map do |entry| - minors = entry.fetch(:minors) - (entry.fetch(:major) < current_major) ? [minors.first, minors.last].uniq : minors - end - when "semver" - by_major.flat_map do |entry| - (entry.fetch(:major) < current_major) ? [entry.fetch(:minors).last] : entry.fetch(:minors) - end - end - end - - def appraisal_matrix_entries(tier1_gems:, tier2_gems: []) - entries = [] - tier1_gems.each do |tier1| - tier1_name = tier1[:name] || tier1["name"] - assignments = tier1[:assignments] || tier1["assignments"] || [] - assignments.each do |assignment| - tier1_version = assignment[:version] || assignment["version"] - ruby_series = assignment[:bucket] || assignment["bucket"] || assignment[:ruby_series] || assignment["ruby_series"] - if tier2_gems.empty? - entries << appraisal_matrix_entry( - tier1_name: tier1_name, - tier1_version: tier1_version, - ruby_series: ruby_series - ) - else - tier2_gems.each do |tier2| - tier2_name = tier2[:name] || tier2["name"] - Array(tier2[:versions] || tier2["versions"]).each do |tier2_version| - entries << appraisal_matrix_entry( - tier1_name: tier1_name, - tier1_version: tier1_version, - ruby_series: ruby_series, - tier2_name: tier2_name, - tier2_version: tier2_version - ) - end - end - end - end - end - entries - end - - def appraisal_matrix_entry(tier1_name:, tier1_version:, ruby_series:, tier2_name: nil, tier2_version: nil) - { - name: appraisal_name( - tier1_gem: tier1_name, - tier1_version: tier1_version, - tier2_gem: tier2_name, - tier2_version: tier2_version, - ruby_series: ruby_series - ), - tier1_gemfile: appraisal_modular_gemfile_path(gem_name: tier1_name, version: tier1_version, ruby_series: ruby_series), - tier2_gemfile: tier2_name ? appraisal_modular_gemfile_path(gem_name: tier2_name, version: tier2_version, ruby_series: ruby_series) : nil, - x_std_libs_gemfile: File.join("gemfiles", "modular", "x_std_libs", ruby_series.to_s, "libs.gemfile"), - ruby_series: ruby_series.to_s - } - end - - def appraisal_filtered_versions(version_metadata, requirements:) - requirement = requirements ? Gem::Requirement.new(Array(requirements)) : nil - version_metadata.filter_map do |entry| - number = entry[:number] || entry["number"] - next if number.to_s.empty? - next if entry[:prerelease] || entry["prerelease"] - next if requirement && !requirement.satisfied_by?(Gem::Version.new(number)) - - number.to_s - end.sort_by { |version| Gem::Version.new(version) } - end - - def appraisal_minor_versions_by_major(versions) - versions.map do |version| - gem_version = Gem::Version.new(version) - segments = gem_version.segments - { - major: segments[0], - minor: "#{segments[0]}.#{segments[1] || 0}" - } - end.uniq.group_by { |entry| entry.fetch(:major) }.map do |major, entries| - { - major: major, - minors: entries.map { |entry| entry.fetch(:minor) }.sort_by { |minor| Gem::Version.new(minor) } - } - end.sort_by { |entry| entry.fetch(:major) } - end - - def appraisal_find_ruby_seams(version_metadata) - minors = appraisal_latest_patch_by_minor(version_metadata) - seams = [] - previous = nil - minors.sort_by { |minor, _entry| Gem::Version.new(minor) }.each do |minor, entry| - min_ruby = Gem::Version.new((entry[:min_ruby] || entry["min_ruby"]).to_s) - min_ruby = [min_ruby, DEFAULT_TEST_MINIMUM_RUBY].max - if previous.nil? || min_ruby > previous - seams << {version: minor, min_ruby: min_ruby} - end - previous = min_ruby - end - seams - end - - def appraisal_ruby_series(version_metadata, project_min_ruby: nil) - floors = appraisal_find_ruby_seams(version_metadata).map { |seam| seam.fetch(:min_ruby) } - if project_min_ruby - floor = Gem::Version.new(project_min_ruby.to_s) - floors.reject! { |version| version < floor } - floors << floor unless floors.include?(floor) - end - floors = [Gem::Version.new("3.2")] if floors.empty? - appraisal_minor_versions_to_buckets(floors.map { |version| appraisal_minor_key(version) }.uniq.sort) - end - - def appraisal_assign_version_buckets(selected_versions:, seams:, buckets:, bucket_ranges:, all_versions:) - return [] if selected_versions.empty? || buckets.empty? - - normalized_ranges = appraisal_normalized_bucket_ranges(bucket_ranges) - version_min_rubies = appraisal_version_min_ruby_map(all_versions, seams) - assignments = selected_versions.sort_by { |version| Gem::Version.new(version) }.filter_map do |version| - min_ruby = version_min_rubies[version] - next unless min_ruby - - next_seam = appraisal_next_seam_ruby(version, min_ruby, all_versions, version_min_rubies) - bucket = next_seam ? appraisal_bucket_below(next_seam, buckets, normalized_ranges) : buckets.last - {version: version, bucket: bucket} if bucket - end - appraisal_fill_bucket_gaps(assignments, buckets, normalized_ranges, version_min_rubies, all_versions) - end - - def appraisal_latest_patch_by_minor(version_metadata) - version_metadata.each_with_object({}) do |entry, latest| - number = (entry[:number] || entry["number"]).to_s - next if number.empty? - - version = Gem::Version.new(number) - minor = "#{version.segments[0]}.#{version.segments[1] || 0}" - current = latest[minor] - latest[minor] = entry if current.nil? || version > Gem::Version.new((current[:number] || current["number"]).to_s) - end - end - - def appraisal_minor_versions_to_buckets(minor_versions) - by_major = minor_versions.group_by { |minor| minor.split(".").first.to_i } - buckets = [] - ranges = {} - by_major.each do |major, minors| - sorted = minors.sort_by { |minor| Gem::Version.new(minor) } - sorted.each_with_index do |minor, index| - bucket = (index == sorted.length - 1) ? "r#{major}" : "r#{major}.#{[sorted[index + 1].split(".").last.to_i - 1, minor.split(".").last.to_i].max}" - next if ranges.key?(bucket) - - buckets << bucket - ceiling = if index == sorted.length - 1 - "#{major}.99" - else - bucket.split(".").last ? "#{major}.#{bucket.split(".").last}" : "#{major}.99" - end - ranges[bucket] = {floor: Gem::Version.new(minor), ceiling: Gem::Version.new(ceiling)} - end - end - {buckets: buckets.sort_by { |bucket| appraisal_bucket_sort_key(bucket) }, bucket_ranges: ranges} - end - - def appraisal_minor_key(version) - segments = Gem::Version.new(version.to_s).segments - "#{segments[0]}.#{segments[1] || 0}" - end - - def appraisal_bucket_sort_key(bucket) - match = bucket.to_s.match(/\Ar(\d+)(?:\.(\d+))?\z/) - [match[1].to_i, match[2] ? match[2].to_i : 999] - end - - def appraisal_normalized_bucket_ranges(bucket_ranges) - bucket_ranges.transform_values do |range| - { - floor: Gem::Version.new((range[:floor] || range["floor"]).to_s), - ceiling: Gem::Version.new((range[:ceiling] || range["ceiling"]).to_s) - } - end - end - - def appraisal_version_min_ruby_map(all_versions, seams) - sorted_seams = seams.sort_by { |seam| Gem::Version.new(seam[:version] || seam["version"]) } - seam_index = 0 - current = nil - all_versions.sort_by { |version| Gem::Version.new(version) }.each_with_object({}) do |version, map| - while seam_index < sorted_seams.length && Gem::Version.new(sorted_seams[seam_index][:version] || sorted_seams[seam_index]["version"]) <= Gem::Version.new(version) - current = Gem::Version.new((sorted_seams[seam_index][:min_ruby] || sorted_seams[seam_index]["min_ruby"]).to_s) - current = [current, DEFAULT_TEST_MINIMUM_RUBY].max - seam_index += 1 - end - map[version] = current if current - end - end - - def appraisal_next_seam_ruby(version, min_ruby, all_versions, version_min_rubies) - found = false - all_versions.sort_by { |candidate| Gem::Version.new(candidate) }.each do |candidate| - found ||= Gem::Version.new(candidate) >= Gem::Version.new(version) - next unless found - - candidate_ruby = version_min_rubies[candidate] - return candidate_ruby if candidate_ruby && candidate_ruby > min_ruby - end - nil - end - - def appraisal_bucket_below(ruby_floor, buckets, bucket_ranges) - buckets.filter_map do |bucket| - range = bucket_ranges[bucket] - next unless range && range.fetch(:ceiling) < ruby_floor - - [bucket, range.fetch(:ceiling)] - end.max_by(&:last)&.first - end - - def appraisal_fill_bucket_gaps(assignments, buckets, bucket_ranges, version_min_rubies, all_versions) - covered = assignments.map { |assignment| assignment.fetch(:bucket) } - (buckets - covered).each do |bucket| - range = bucket_ranges[bucket] - next unless range - - filler = all_versions.sort_by { |version| Gem::Version.new(version) }.reverse.find do |version| - ruby = version_min_rubies[version] - ruby&.between?(range.fetch(:floor), range.fetch(:ceiling)) - end - filler ||= all_versions.sort_by { |version| Gem::Version.new(version) }.reverse.find do |version| - ruby = version_min_rubies[version] - ruby && ruby <= range.fetch(:ceiling) - end - assignments << {version: filler, bucket: bucket, filler: true} if filler - end - assignments.sort_by { |assignment| bucket_ranges.fetch(assignment.fetch(:bucket)).fetch(:floor) } - end - - def appraisal_resolve_sub_dependencies(parent_gem:, parent_version:, parent_versions:, dependency_versions:, ruby_min: nil, excluded_gems: []) - parent = appraisal_latest_version_matching(parent_versions, parent_version) - return {} unless parent - - ruby_floor = Gem::Version.new(ruby_min.to_s) unless ruby_min.to_s.empty? - excluded = excluded_gems.map(&:to_s) - Array(parent[:runtime_dependencies] || parent["runtime_dependencies"]).each_with_object({}) do |dependency, resolved| - name = (dependency[:name] || dependency["name"]).to_s - next if name.empty? || excluded.include?(name) - - requirement = begin - Gem::Requirement.new(dependency[:requirements] || dependency["requirements"] || ">= 0") - rescue ArgumentError - Gem::Requirement.default - end - selected = appraisal_select_dependency_version( - Array(dependency_versions[name] || dependency_versions[name.to_sym]), - requirement: requirement, - ruby_min: ruby_floor - ) - resolved[name] = selected if selected - end - end - - def appraisal_latest_version_matching(version_metadata, requested_version) - prefix = "#{requested_version}." - version_metadata.select do |entry| - number = (entry[:number] || entry["number"]).to_s - number == requested_version.to_s || number.start_with?(prefix) - end.max_by { |entry| Gem::Version.new((entry[:number] || entry["number"]).to_s) } - end - - def appraisal_select_dependency_version(version_metadata, requirement:, ruby_min:) - compatible = version_metadata.select do |entry| - number = (entry[:number] || entry["number"]).to_s - !number.empty? && requirement.satisfied_by?(Gem::Version.new(number)) - end.sort_by { |entry| Gem::Version.new((entry[:number] || entry["number"]).to_s) } - return if compatible.empty? - - if ruby_min - selected = compatible.reverse.find do |entry| - min_ruby = entry[:min_ruby] || entry["min_ruby"] - min_ruby.to_s.empty? || Gem::Version.new(min_ruby.to_s) <= ruby_min - end - return (selected || compatible.first).then { |entry| entry[:number] || entry["number"] } - end - compatible.last.then { |entry| entry[:number] || entry["number"] } - end - - def appraisal_stale_gemfile_paths(existing_paths:, current_entries:) - current_names = current_entries.map { |entry| (entry[:name] || entry["name"]).to_s }.to_set - existing_paths.map(&:to_s).select do |path| - basename = File.basename(path, ".gemfile") - path.start_with?("gemfiles/#{APPRAISAL_NAME_PREFIX}-") && - path.end_with?(".gemfile") && - !current_names.include?(basename) - end.sort - end - - def appraisal_extract_runtime_dependencies(gemspec_content) - gemspec_dependency_records(gemspec_content) - .reject { |record| record.fetch(:kind) == "add_development_dependency" } - .map { |record| record.fetch(:name) } - .uniq - end - - def appraisal_scaffold_config(gemspec_content:, existing_config: {}, exclusions: [], default_mode: "semver", freshness_ttl: APPRAISAL_DEFAULT_FRESHNESS_TTL) - excluded = exclusions.map(&:to_s).to_set - runtime_dependencies = appraisal_extract_runtime_dependencies(gemspec_content) - tier1 = runtime_dependencies.reject { |name| excluded.include?(name) }.map { |name| {"name" => name} } - config = deep_string_key_hash(existing_config) - matrix = config["appraisal_matrix"] || {} - gems = matrix["gems"] || {} - - matrix["mode"] ||= default_mode - matrix["freshness_ttl"] ||= freshness_ttl - gems["tier1"] = tier1 - gems["tier2"] ||= [] - matrix["gems"] = gems - config["appraisal_matrix"] = matrix - config - end - - def appraisal_matrix_has_versions?(matrix) - gems = deep_string_key_hash(matrix || {}).fetch("gems", {}) - %w[tier1 tier2].any? do |tier| - Array(gems[tier]).any? { |gem_config| Array(gem_config["versions"]).any? } - end - end - - def appraisal_matrix_fresh?(matrix, now: Time.now.to_i) - resolved_at = (matrix || {})[:resolved_at] || (matrix || {})["resolved_at"] - return false unless resolved_at - - ttl = (matrix || {})[:freshness_ttl] || (matrix || {})["freshness_ttl"] || APPRAISAL_DEFAULT_FRESHNESS_TTL - (now.to_i - resolved_at.to_i) < ttl.to_i - end - - def appraisal_time_ago(timestamp, now: Time.now.to_i) - return "unknown" unless timestamp - - seconds = now.to_i - timestamp.to_i - return "#{seconds / 60}m" if seconds < 3600 - return "#{seconds / 3600}h" if seconds < 86_400 - - "#{seconds / 86_400}d" - end - - def appraisal_all_versions_for(resolver:, gem_name:, mode:, requirements: nil, include_versions: nil, exclude_versions: nil) - base_versions = if mode.to_s == "patch" - resolver.versions(gem_name, requirements: requirements).map { |entry| entry[:number] || entry["number"] } - else - resolver.minor_versions_by_major(gem_name, requirements: requirements).flat_map { |entry| entry[:minors] || entry["minors"] } - end - appraisal_finalize_versions(base_versions, include_versions: include_versions, exclude_versions: exclude_versions) - end - - def appraisal_finalize_versions(base_versions, include_versions: nil, exclude_versions: nil) - merged = appraisal_sort_versions(Array(base_versions) + Array(include_versions)) - excluded = Array(exclude_versions).map(&:to_s).to_set - return merged if excluded.empty? - - appraisal_sort_versions(merged.reject { |version| excluded.include?(version) }) - end - - def appraisal_compatible_version_for_bucket?(resolver:, gem_name:, version:, ruby_series:, bucket_ranges:) - range = bucket_ranges[ruby_series] || bucket_ranges[ruby_series.to_s] - return true unless range - - ceiling = Gem::Version.new((range[:ceiling] || range["ceiling"]).to_s) - exact_version = appraisal_latest_minor_patch(resolver: resolver, gem_name: gem_name, version: version) - min_ruby = resolver.min_ruby_version(gem_name, exact_version) - min_ruby.nil? || Gem::Version.new(min_ruby.to_s) <= ceiling - rescue - true - end - - def appraisal_latest_minor_patch(resolver:, gem_name:, version:) - all_versions = resolver.versions(gem_name) - prefix = "#{version}." - matching = all_versions.select do |entry| - number = (entry[:number] || entry["number"]).to_s - number == version.to_s || number.start_with?(prefix) - end - return version.to_s if matching.empty? - - latest = matching.max_by { |entry| Gem::Version.new((entry[:number] || entry["number"]).to_s) } - (latest[:number] || latest["number"]).to_s - end - - def appraisal_sort_versions(values) - values.compact.map(&:to_s).reject(&:empty?).uniq.sort_by { |version| Gem::Version.new(version) } - end - def deep_string_key_hash(value) case value when Hash @@ -9866,8 +9298,8 @@ def modular_dependency_conflicts(project_root, recipe_reports) # (config["engines"], falling back to DEFAULT_ENGINES — see # #enabled_ruby_engines). Unlike #modular_dependency_conflicts, this # needs no modular-side declaration to match against: these are gems - # below the template's inclusion threshold with no modular home at all - # (sqlite3 is the pilot; see KNOWN_GEM_CONFLICT_RESOLUTIONS), so the + # below the template's inclusion threshold with no template-managed + # modular home (sqlite3 is the pilot; see KNOWN_GEM_CONFLICT_RESOLUTIONS), so the # incompatibility itself — not a direct-vs-modular duplicate — is the # conflict. The :modular field is a fixed, human-readable placeholder # (there is no modular file to name) rather than the specific @@ -19378,6 +18810,7 @@ def github_actions_framework_matrix(config) } gemfile_entry[:default] = true if entry[:default] gemfile_entry[:replaces] = entry[:replaces] unless entry.fetch(:replaces, []).empty? + gemfile_entry.merge!(framework_matrix_security(framework_gem, entry)) gemfile_entry end.uniq { |entry| entry.fetch(:path) } default_gemfiles = gemfiles.select { |entry| entry[:default] } @@ -19563,6 +18996,32 @@ def default_framework_matrix_requirement(version) "~> #{normalized}" end + # Security data from kettle-rb (Kettle::Rb::GemFloors) for a framework + # matrix version of a tracked gem. The series' security floor is added + # when the version's requirement still admits the series' first release, + # and advisories that no release in the series fixes are recorded so the + # generated gemfile can warn about them. + def framework_matrix_security(gem_name, entry) + return {} unless Kettle::Rb::GemFloors.tracked?(gem_name) + + series = Kettle::Rb::GemFloors.series_for(entry.fetch(:label)) + return {} unless series + + security = {} + first_release = Gem::Version.new("#{series}.0") + floor = Kettle::Rb::GemFloors.floor(gem_name, series) + requirement = Gem::Requirement.new(*entry.fetch(:requirement).split(/\s*,\s*/)) + if floor && Gem::Version.new(floor) > first_release && requirement.satisfied_by?(first_release) + security[:security_floor] = floor + end + unpatched = Kettle::Rb::GemFloors.unpatched_advisories(gem_name, series) + security[:unpatched_advisories] = unpatched unless unpatched.empty? + security[:security_series] = series unless security.empty? + security + rescue ArgumentError + {} + end + def github_actions_coverage_config(config, env = ENV) workflows = config["workflows"] return {} unless workflows.is_a?(Hash) @@ -20083,7 +19542,13 @@ def synchronize_github_actions_framework_gemfile(target_path, facts) lines << %(ENV[#{key.inspect}] = #{value.inspect}) end lines << "" - lines << %(gem #{gemfile.fetch(:gem).inspect}, #{gemfile.fetch(:requirement).inspect}) + unpatched = gemfile.fetch(:unpatched_advisories, []) + unless unpatched.empty? + lines << "# kettle-rb: no #{gemfile.fetch(:gem)} #{gemfile.fetch(:security_series)} release fixes #{unpatched.join(", ")}" + end + requirements = [gemfile.fetch(:requirement)] + requirements << ">= #{gemfile.fetch(:security_floor)}" if gemfile[:security_floor] + lines << %(gem #{[gemfile.fetch(:gem), *requirements].map(&:inspect).join(", ")}) "#{lines.join("\n")}\n" end diff --git a/gems/kettle-jem/spec/integration/appraisal_helper_and_token_spec.rb b/gems/kettle-jem/spec/integration/appraisal_helper_and_token_spec.rb index b265f09d2..7ae0da8aa 100644 --- a/gems/kettle-jem/spec/integration/appraisal_helper_and_token_spec.rb +++ b/gems/kettle-jem/spec/integration/appraisal_helper_and_token_spec.rb @@ -4,214 +4,6 @@ include_context "with isolated kettle-jem environment" include_context "with kettle-jem fixture contracts" - it "renders deterministic appraisal helper outputs" do - matrix_entries = [ - { - name: described_class.appraisal_name( - tier1_gem: "activerecord", - tier1_version: "7.1", - tier2_gem: "omniauth", - tier2_version: "2.1", - ruby_series: "r3" - ), - ruby_series: "r3", - tier1_gemfile: "gemfiles/modular/activerecord/r3/v7.1.gemfile", - tier2_gemfile: "gemfiles/modular/omniauth/r3/v2.1.gemfile", - x_std_libs_gemfile: "gemfiles/modular/x_std_libs/r3/libs.gemfile" - }, - { - name: described_class.appraisal_name( - tier1_gem: "mail", - tier1_version: "2.8", - ruby_series: "r2" - ), - ruby_series: "r2", - tier1_gemfile: "gemfiles/modular/mail/r2/v2.8.gemfile", - x_std_libs_gemfile: "gemfiles/modular/x_std_libs/r2/libs.gemfile" - } - ] - bucket_ranges = { - "r2" => {floor: "2.7", ceiling: "2.99"}, - "r3" => {floor: "3.2", ceiling: "3.99"} - } - - expect(described_class.appraisal_gem_abbreviation("activerecord")).to eq("ar") - expect(described_class.appraisal_gem_abbreviation("unknown")).to eq("unknown") - expect(described_class.appraisal_format_version("7.1.5")).to eq("7-1-5") - expect(matrix_entries.map { |entry| entry.fetch(:name) }).to eq(["kja-ar-7-1-oa-2-1-r3", "kja-mail-2-8-r2"]) - expect(described_class.appraisal_modular_gemfile_path(gem_name: "activerecord", version: "7.1", ruby_series: "r3")).to eq( - "gemfiles/modular/activerecord/r3/v7.1.gemfile" - ) - expect(described_class.appraisal_modular_gemfile_content( - gem_name: "activerecord", - version: "7.1", - sub_dependencies: {"sqlite3" => "1.6.9"} - )).to eq(<<~RUBY) - # frozen_string_literal: true - - # Generated by kettle-jem - - gem "activerecord", "~> 7.1.0" - gem "sqlite3", "~> 1.6.9" - RUBY - - appraisals = described_class.appraisal_file_content(matrix_entries) - expect(appraisals).to include('appraise "kja-ar-7-1-oa-2-1-r3" do') - expect(appraisals).to include('eval_gemfile "modular/activerecord/r3/v7.1.gemfile"') - expect(appraisals).not_to include('eval_gemfile "gemfiles/') - expect(appraisals).to include('appraise "kja-mail-2-8-r2" do') - expect(appraisals).not_to end_with("\n\n") - - groups = described_class.appraisal_workflow_groups(matrix_entries, bucket_ranges: bucket_ranges) - expect(groups).to eq( - "supported" => [ - { - ruby: "3.2", - appraisal: "kja-ar-7-1-oa-2-1-r3", - exec_cmd: "kettle-test", - rubygems: "latest", - bundler: "latest" - } - ], - "unsupported" => [ - { - ruby: "2.7", - appraisal: "kja-mail-2-8-r2", - exec_cmd: "kettle-test", - rubygems: "latest", - bundler: "latest" - } - ] - ) - expect(described_class.appraisal_workflow_yaml_snippets(matrix_entries, bucket_ranges: bucket_ranges).fetch("supported")).to include( - 'appraisal: "kja-ar-7-1-oa-2-1-r3"' - ) - expect(described_class.appraisal_x_stdlib_exclusions(<<~RUBY)).to eq(["erb", "mutex_m", "version_gem"]) - eval_gemfile "../erb/vHEAD.gemfile" - eval_gemfile "../mutex_m/vHEAD.gemfile" - RUBY - end - - it "plans deterministic appraisal matrices from supplied version metadata" do - versions = %w[5.0.0 5.1.0 5.2.0 6.0.0 6.1.0 7.0.0 7.1.0 7.2.0].map do |number| - {number: number} - end - - expect(described_class.appraisal_select_versions(versions, mode: "major")).to eq(["5.2", "6.1", "7.2"]) - expect(described_class.appraisal_select_versions(versions, mode: "minor")).to eq( - ["5.0", "5.1", "5.2", "6.0", "6.1", "7.0", "7.1", "7.2"] - ) - expect(described_class.appraisal_select_versions(versions, mode: "patch")).to eq( - ["5.0.0", "5.1.0", "5.2.0", "6.0.0", "6.1.0", "7.0.0", "7.1.0", "7.2.0"] - ) - expect(described_class.appraisal_select_versions(versions, mode: "minor-minmax")).to eq( - ["5.0", "5.2", "6.0", "6.1", "7.0", "7.1", "7.2"] - ) - expect(described_class.appraisal_select_versions(versions, mode: "semver")).to eq(["5.2", "6.1", "7.0", "7.1", "7.2"]) - expect(described_class.appraisal_select_versions(versions, mode: "minor", requirements: [">= 6.0", "< 7.0"])).to eq(["6.0", "6.1"]) - - entries = described_class.appraisal_matrix_entries( - tier1_gems: [ - { - name: "activerecord", - assignments: [ - {version: "6.1", bucket: "r2"}, - {version: "7.2", bucket: "r3"} - ] - } - ], - tier2_gems: [ - {name: "omniauth", versions: ["2.1"]} - ] - ) - - expect(entries).to eq( - [ - { - name: "kja-ar-6-1-oa-2-1-r2", - tier1_gemfile: "gemfiles/modular/activerecord/r2/v6.1.gemfile", - tier2_gemfile: "gemfiles/modular/omniauth/r2/v2.1.gemfile", - x_std_libs_gemfile: "gemfiles/modular/x_std_libs/r2/libs.gemfile", - ruby_series: "r2" - }, - { - name: "kja-ar-7-2-oa-2-1-r3", - tier1_gemfile: "gemfiles/modular/activerecord/r3/v7.2.gemfile", - tier2_gemfile: "gemfiles/modular/omniauth/r3/v2.1.gemfile", - x_std_libs_gemfile: "gemfiles/modular/x_std_libs/r3/libs.gemfile", - ruby_series: "r3" - } - ] - ) - end - - it "detects appraisal Ruby seams and assigns selected versions to buckets" do - versions = [ - {number: "5.2.8", min_ruby: "2.3"}, - {number: "6.0.6", min_ruby: "2.5"}, - {number: "6.1.7", min_ruby: "2.5"}, - {number: "7.0.8", min_ruby: "2.7"}, - {number: "7.1.5", min_ruby: "2.7"}, - {number: "7.2.2", min_ruby: "3.1"} - ] - - seams = described_class.appraisal_find_ruby_seams(versions) - expect(seams).to eq( - [ - {version: "5.2", min_ruby: Gem::Version.new("2.4")}, - {version: "6.0", min_ruby: Gem::Version.new("2.5")}, - {version: "7.0", min_ruby: Gem::Version.new("2.7")}, - {version: "7.2", min_ruby: Gem::Version.new("3.1")} - ] - ) - - series = described_class.appraisal_ruby_series(versions) - expect(series.fetch(:buckets)).to eq(["r2.4", "r2.6", "r2", "r3"]) - - assignments = described_class.appraisal_assign_version_buckets( - selected_versions: ["5.2", "6.1", "7.2"], - seams: seams, - buckets: series.fetch(:buckets), - bucket_ranges: series.fetch(:bucket_ranges), - all_versions: ["5.2", "6.0", "6.1", "7.0", "7.1", "7.2"] - ) - expect(assignments).to eq( - [ - {version: "5.2", bucket: "r2.4"}, - {version: "6.1", bucket: "r2.6"}, - {version: "7.1", bucket: "r2", filler: true}, - {version: "7.2", bucket: "r3"} - ] - ) - end - - it "resolves appraisal sub-dependencies from supplied metadata" do - resolved = described_class.appraisal_resolve_sub_dependencies( - parent_gem: "activerecord", - parent_version: "7.1", - ruby_min: "3.0", - excluded_gems: ["erb", "version_gem"], - parent_versions: [ - { - number: "7.1.3", - runtime_dependencies: [ - {name: "sqlite3", requirements: "~> 1.6"}, - {name: "erb", requirements: ">= 0"} - ] - } - ], - dependency_versions: { - "sqlite3" => [ - {number: "1.6.8", min_ruby: "2.7"}, - {number: "1.6.9", min_ruby: "3.0"}, - {number: "1.7.0", min_ruby: "3.2"} - ] - } - ) - - expect(resolved).to eq("sqlite3" => "1.6.9") - end - it "resolves RubyGems version metadata through a cacheable Kettle/Jem resolver" do response = Struct.new(:code, :body) calls = [] @@ -319,119 +111,6 @@ described_class::GemSpecReader.clear_cache! end - it "ports appraisal CLI config orchestration helpers into Kettle/Jem" do - gemspec_content = <<~RUBY - Gem::Specification.new do |spec| - spec.add_dependency "activerecord", "~> 7.1" - spec.add_runtime_dependency("erb", ">= 0") - # spec.add_dependency "ignored" - spec.add_runtime_dependency "sequel", ">= 5.0" - spec.add_development_dependency "dev_only", ">= 0" - end - RUBY - - scaffold = described_class.appraisal_scaffold_config( - gemspec_content: gemspec_content, - existing_config: { - appraisal_matrix: { - gems: { - tier2: [ - {name: "omniauth"} - ] - } - } - }, - exclusions: ["erb"], - freshness_ttl: 86_400 - ) - - expect(scaffold.fetch("appraisal_matrix")).to include( - "mode" => "semver", - "freshness_ttl" => 86_400 - ) - expect(scaffold.dig("appraisal_matrix", "gems", "tier1")).to eq( - [ - {"name" => "activerecord"}, - {"name" => "sequel"} - ] - ) - expect(scaffold.dig("appraisal_matrix", "gems", "tier2")).to eq([{"name" => "omniauth"}]) - - expect(described_class.appraisal_matrix_has_versions?( - "gems" => { - "tier1" => [{"name" => "activerecord", "versions" => []}], - "tier2" => [{"name" => "omniauth", "versions" => ["2.1"]}] - } - )).to be true - expect(described_class.appraisal_matrix_fresh?({"resolved_at" => 100, "freshness_ttl" => 50}, now: 149)).to be true - expect(described_class.appraisal_matrix_fresh?({"resolved_at" => 100, "freshness_ttl" => 50}, now: 150)).to be false - expect(described_class.appraisal_time_ago(0, now: 90_000)).to eq("1d") - expect(described_class.appraisal_finalize_versions(%w[7.1.0 7.1.1], include_versions: ["6.0.9"], exclude_versions: ["7.1.0"])).to eq( - %w[6.0.9 7.1.1] - ) - - resolver = Class.new do - def versions(gem_name, requirements: nil) - case [gem_name, requirements] - when ["activerecord", [">= 7.1", "< 7.2"]] - [{number: "7.1.0"}, {number: "7.1.1"}] - when ["omniauth", nil] - [{number: "2.0.0"}, {number: "2.1.3"}] - else - [] - end - end - - def minor_versions_by_major(gem_name, requirements: nil) - case [gem_name, requirements] - when ["sequel", nil] - [{major: 5, minors: ["5.0", "5.9"]}] - else - [] - end - end - - def min_ruby_version(gem_name, version) - return Gem::Version.new("3.2") if gem_name == "omniauth" && version == "2.1.3" - - Gem::Version.new("2.7") - end - end.new - - expect(described_class.appraisal_all_versions_for( - resolver: resolver, - gem_name: "activerecord", - mode: "patch", - requirements: [">= 7.1", "< 7.2"], - include_versions: ["6.0.9"], - exclude_versions: ["7.1.0"] - )).to eq(%w[6.0.9 7.1.1]) - expect(described_class.appraisal_all_versions_for(resolver: resolver, gem_name: "sequel", mode: "major")).to eq(%w[5.0 5.9]) - expect(described_class.appraisal_compatible_version_for_bucket?( - resolver: resolver, - gem_name: "omniauth", - version: "2.1", - ruby_series: "r3.1", - bucket_ranges: {"r3.1" => {floor: "3.0", ceiling: "3.1"}} - )).to be false - end - - it "plans stale flat appraisal gemfile cleanup paths" do - stale_paths = described_class.appraisal_stale_gemfile_paths( - existing_paths: [ - "gemfiles/kja-ar-7-1-r3.gemfile", - "gemfiles/kja-ar-6-1-r2.gemfile", - "gemfiles/manual.gemfile", - "gemfiles/modular/activerecord/r3/v7.1.gemfile" - ], - current_entries: [ - {name: "kja-ar-7-1-r3"} - ] - ) - - expect(stale_paths).to eq(["gemfiles/kja-ar-6-1-r2.gemfile"]) - end - it "honors author template token config and environment overrides" do tmp_root = File.expand_path("../tmp", __dir__) FileUtils.mkdir_p(tmp_root) diff --git a/gems/kettle-jem/spec/integration/appraisals_and_gemfile_template_spec.rb b/gems/kettle-jem/spec/integration/appraisals_and_gemfile_template_spec.rb index d86e3d5a6..9ff35ae51 100644 --- a/gems/kettle-jem/spec/integration/appraisals_and_gemfile_template_spec.rb +++ b/gems/kettle-jem/spec/integration/appraisals_and_gemfile_template_spec.rb @@ -545,6 +545,42 @@ end end + it "adds kettle-rb security floors and unfixed advisories to tracked framework-matrix gemfiles" do + config = { + "workflows" => { + "preset" => "framework", + "framework_matrix" => { + "dimension" => "activerecord", + "gem" => "activerecord", + "gemfile_pattern" => "ar_{version}.gemfile", + "versions" => [ + "7.0", + "7.1", + {"label" => "7.2", "slug" => "7_2", "requirement" => "~> 7.2.3"}, + {"label" => "8.0+", "slug" => "8_0_plus", "requirement" => ">= 8.0"} + ] + } + } + } + + framework_matrix = described_class.send(:github_actions_framework_matrix, config) + facts = {ci: {framework_matrix: framework_matrix}} + contents = framework_matrix.fetch(:gemfiles).map do |entry| + described_class.send(:synchronize_github_actions_framework_gemfile, entry.fetch(:path), facts) + end + unpatched = Kettle::Rb::GemFloors.unpatched_advisories("activerecord", "7.0") + + expect(unpatched).to include("CVE-2025-55193") + expect(contents[0]).to include( + "# kettle-rb: no activerecord 7.0 release fixes #{unpatched.join(", ")}\n", + %(gem "activerecord", "~> 7.0.0", ">= #{Kettle::Rb::GemFloors.floor("activerecord", "7.0")}"\n) + ) + expect(contents[1]).to include(%(gem "activerecord", "~> 7.1.0", ">= #{Kettle::Rb::GemFloors.floor("activerecord", "7.1")}"\n)) + expect(contents[1]).not_to include("# kettle-rb:") + expect(contents[2]).to include(%(gem "activerecord", "~> 7.2.3"\n)) + expect(contents[3]).to include(%(gem "activerecord", ">= 8.0"\n)) + end + it "uses one framework-matrix default version for the default local test bundle" do config = { "workflows" => { diff --git a/gems/markdown-merge/Gemfile b/gems/markdown-merge/Gemfile index ccc2b7293..25d450dcc 100644 --- a/gems/markdown-merge/Gemfile +++ b/gems/markdown-merge/Gemfile @@ -19,13 +19,10 @@ git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" } gemspec structuredmerge_rust_dev = ENV.fetch('STRUCTUREDMERGE_RUST_DEV', '').strip -unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structuredmerge_rust_dev.downcase) +if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredmerge_rust_dev.downcase) gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') -end - -# CI current and coverage jobs require the published Rust host gem; this fails until it is released. -if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && - instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } +elsif ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? + # CI current and coverage jobs require the published Rust host gem; this fails until it is released. gem 'structuredmerge_host_prototype', '~> 0.2' end diff --git a/gems/rust-merge/Gemfile b/gems/rust-merge/Gemfile index 2bd240f4a..4bdf2ab06 100644 --- a/gems/rust-merge/Gemfile +++ b/gems/rust-merge/Gemfile @@ -19,13 +19,10 @@ git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" } gemspec structuredmerge_rust_dev = ENV.fetch('STRUCTUREDMERGE_RUST_DEV', '').strip -unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structuredmerge_rust_dev.downcase) +if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredmerge_rust_dev.downcase) gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') -end - -# CI current and coverage jobs require the published Rust host gem; this fails until it is released. -if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && - instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } +elsif ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? + # CI current and coverage jobs require the published Rust host gem; this fails until it is released. gem 'structuredmerge_host_prototype', '~> 0.2' end diff --git a/gems/tree_haver/Gemfile b/gems/tree_haver/Gemfile index 93d226ccc..058b2e0a4 100644 --- a/gems/tree_haver/Gemfile +++ b/gems/tree_haver/Gemfile @@ -19,13 +19,10 @@ git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" } gemspec structuredmerge_rust_dev = ENV.fetch('STRUCTUREDMERGE_RUST_DEV', '').strip -unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structuredmerge_rust_dev.downcase) +if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredmerge_rust_dev.downcase) gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') -end - -# CI current and coverage jobs require the published Rust host gem; this fails until it is released. -if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && - instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } +elsif ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? + # CI current and coverage jobs require the published Rust host gem; this fails until it is released. gem 'structuredmerge_host_prototype', '~> 0.2' end diff --git a/gems/typescript-merge/Gemfile b/gems/typescript-merge/Gemfile index 3da71a648..672fc2696 100644 --- a/gems/typescript-merge/Gemfile +++ b/gems/typescript-merge/Gemfile @@ -19,13 +19,10 @@ git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" } gemspec structuredmerge_rust_dev = ENV.fetch('STRUCTUREDMERGE_RUST_DEV', '').strip -unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structuredmerge_rust_dev.downcase) +if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredmerge_rust_dev.downcase) gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') -end - -# CI current and coverage jobs require the published Rust host gem; this fails until it is released. -if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && - instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } +elsif ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? + # CI current and coverage jobs require the published Rust host gem; this fails until it is released. gem 'structuredmerge_host_prototype', '~> 0.2' end diff --git a/gems/yaml-merge/Gemfile b/gems/yaml-merge/Gemfile index 5714d44b1..cb88a2959 100644 --- a/gems/yaml-merge/Gemfile +++ b/gems/yaml-merge/Gemfile @@ -19,13 +19,10 @@ git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" } gemspec structuredmerge_rust_dev = ENV.fetch('STRUCTUREDMERGE_RUST_DEV', '').strip -unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structuredmerge_rust_dev.downcase) +if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredmerge_rust_dev.downcase) gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') -end - -# CI current and coverage jobs require the published Rust host gem; this fails until it is released. -if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && - instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } +elsif ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? + # CI current and coverage jobs require the published Rust host gem; this fails until it is released. gem 'structuredmerge_host_prototype', '~> 0.2' end