kettle-jem 7.1.39 → 7.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +117 -1
  4. data/README.md +43 -44
  5. data/lib/kettle/jem/templates/.duel.rb.example +25 -0
  6. data/lib/kettle/jem/templates/.github/FUNDING.yml.example +0 -1
  7. data/lib/kettle/jem/templates/.github/FUNDING.yml.no-osc.example +0 -1
  8. data/lib/kettle/jem/templates/.github/workflows/auto-assign.yml.example +1 -1
  9. data/lib/kettle/jem/templates/.github/workflows/coverage.yml.example +1 -0
  10. data/lib/kettle/jem/templates/.github/workflows/perf.yml.example +186 -0
  11. data/lib/kettle/jem/templates/.github/workflows/truffleruby-22.3.yml.example +3 -19
  12. data/lib/kettle/jem/templates/.structuredmerge/kettle-jem.yml.example +33 -2
  13. data/lib/kettle/jem/templates/AGENTS.md.example +1 -0
  14. data/lib/kettle/jem/templates/CHANGELOG.transfer.md +6 -1
  15. data/lib/kettle/jem/templates/CONTRIBUTING.md.example +1 -0
  16. data/lib/kettle/jem/templates/README.md.example +17 -40
  17. data/lib/kettle/jem/templates/Rakefile.example +1 -0
  18. data/lib/kettle/jem/templates/gem.gemspec.example +10 -6
  19. data/lib/kettle/jem/templates/gemfiles/modular/cgi/vHEAD.gemfile.example +1 -1
  20. data/lib/kettle/jem/templates/gemfiles/modular/coverage.gemfile.example +1 -1
  21. data/lib/kettle/jem/templates/gemfiles/modular/debug.gemfile.example +1 -1
  22. data/lib/kettle/jem/templates/gemfiles/modular/documentation.gemfile.example +1 -1
  23. data/lib/kettle/jem/templates/gemfiles/modular/duel.gemfile.example +18 -0
  24. data/lib/kettle/jem/templates/gemfiles/modular/optional.gemfile.example +2 -2
  25. data/lib/kettle/jem/templates/gemfiles/modular/recording/r3/recording.gemfile.example +2 -2
  26. data/lib/kettle/jem/templates/gemfiles/modular/recording/r4/recording.gemfile.example +2 -2
  27. data/lib/kettle/jem/templates/gemfiles/modular/stringio/r2/v3.0.gemfile.example +1 -1
  28. data/lib/kettle/jem/templates/gemfiles/modular/stringio/r2.4/v0.0.2.gemfile.example +1 -1
  29. data/lib/kettle/jem/templates/gemfiles/modular/stringio/r3/v3.0.gemfile.example +1 -1
  30. data/lib/kettle/jem/templates/gemfiles/modular/stringio/r4/v3.0.gemfile.example +1 -1
  31. data/lib/kettle/jem/templates/gemfiles/modular/style.gemfile.example +1 -1
  32. data/lib/kettle/jem/templates/gemfiles/modular/templating.gemfile.example +1 -1
  33. data/lib/kettle/jem/templates/gemfiles/modular/webrick/vHEAD.gemfile.example +1 -1
  34. data/lib/kettle/jem/templates/mise.toml.example +1 -0
  35. data/lib/kettle/jem/templates/mise.toml.no-osc.example +1 -0
  36. data/lib/kettle/jem/templates/shim/Gemfile.example +1 -1
  37. data/lib/kettle/jem/templates/shim/gem.gemspec.example +2 -2
  38. data/lib/kettle/jem/version.rb +1 -1
  39. data/lib/kettle/jem.rb +527 -140
  40. data.tar.gz.sig +0 -0
  41. metadata +40 -16
  42. metadata.gz.sig +0 -0
data/lib/kettle/jem.rb CHANGED
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "find"
5
5
  require "English"
6
6
  require "digest"
7
+ require "date"
7
8
  require "json"
8
9
  require "net/http"
9
10
  require "open3"
@@ -25,6 +26,7 @@ require "kettle/dev"
25
26
  require "kettle/changelog"
26
27
  require "kettle/rb/compat_matrix"
27
28
  require "kettle/rb/gem_floors"
29
+ require "kettle/rb/ruby_series_buckets"
28
30
  require "kettle/rb/platform_support"
29
31
  require_relative "jem/version"
30
32
  require_relative "jem/license_txt_migrator"
@@ -44,7 +46,7 @@ module Kettle
44
46
  PACKAGE_NAME = "kettle-jem"
45
47
  CONTENT_RECIPE_TRANSPORT_VERSION = Ast::Merge::STRUCTURED_EDIT_TRANSPORT_VERSION
46
48
  TEMPLATE_SOURCE_APPLICATION_FINGERPRINT_VERSION = 1
47
- APPRAISALS_TEMPLATE_POLICY_FINGERPRINT_VERSION = 2
49
+ APPRAISALS_TEMPLATE_POLICY_FINGERPRINT_VERSION = 3
48
50
  GEMSPEC_TEMPLATE_POLICY_FINGERPRINT_VERSION = 2
49
51
  SPEC_HELPER_TEMPLATE_POLICY_FINGERPRINT_VERSION = 1
50
52
  VERSION_NAMESPACE_TEMPLATE_POLICY_FINGERPRINT_VERSION = 2
@@ -62,11 +64,11 @@ module Kettle
62
64
  # set in place before Bundler is allowed to evaluate that destination.
63
65
  TEMPLATE_MANAGED_DEPENDENCIES = [
64
66
  {name: PACKAGE_NAME, requirements: ["~> 7.1", ">= 7.1.28"], bootstrap: false},
65
- {name: "nomono", requirements: ["~> 1.1", ">= 1.1.14"], bootstrap: true},
66
- {name: "kettle-dev", requirements: ["~> 3.1", ">= 3.1.21"], bootstrap: true},
67
+ {name: "nomono", requirements: ["~> 1.1", ">= 1.1.17"], bootstrap: true},
68
+ {name: "kettle-dev", requirements: ["~> 3.1", ">= 3.1.26"], bootstrap: true},
67
69
  {
68
70
  name: "kettle-changelog",
69
- requirements: ["~> 1.0", ">= 1.0.13"],
71
+ requirements: ["~> 1.0", ">= 1.0.15"],
70
72
  bootstrap: true,
71
73
  ruby_requirement: ">= 4.0.0"
72
74
  }
@@ -88,6 +90,12 @@ module Kettle
88
90
  ].freeze
89
91
  OPENCOLLECTIVE_DISABLED_FILES = %w[.opencollective.yml .github/workflows/opencollective.yml].freeze
90
92
  OPT_IN_GITHUB_WORKFLOWS = %w[.github/workflows/discord-notifier.yml].freeze
93
+ # Templates that exist only for projects that set `workflows.duel: true`.
94
+ # While Duel is off, kettle-jem neither writes nor removes these paths, so a
95
+ # project's own files at them stay exactly as they are.
96
+ DUEL_TEMPLATE_PATHS = %w[.duel.rb .github/workflows/perf.yml gemfiles/modular/duel.gemfile].freeze
97
+ DUEL_MAIN_GEMFILE_EVAL_PATH = "gemfiles/modular/duel.gemfile"
98
+ DUEL_APPRAISAL_NAME = "perf"
91
99
  OBSOLETE_APPRAISAL_SPEC_EXEC_CMDS = [
92
100
  "env KETTLE_TEST_RUNNER=rspec kettle-test -I ../spec --options ../.rspec ../spec",
93
101
  "KETTLE_TEST_RUNNER=rspec kettle-test -I ../spec --options ../.rspec ../spec",
@@ -228,9 +236,10 @@ module Kettle
228
236
  "feature.dedicated_version_gem" => :boolean,
229
237
  "workflow.dep_heads" => :boolean,
230
238
  "workflow.jruby_94" => :boolean,
239
+ "workflow.duel" => :boolean,
231
240
  "version_gem.mode" => :enum
232
241
  }.freeze
233
- COPY_ONLY_WHEN_MISSING_TEMPLATE_PATHS = %w[REEK bin/setup].freeze
242
+ COPY_ONLY_WHEN_MISSING_TEMPLATE_PATHS = %w[REEK bin/setup .duel.rb].freeze
234
243
  MONOREPO_ROOT_TEMPLATE_PROFILE = "monorepo-root"
235
244
  MONOREPO_SUBGEM_PACKAGE_TEMPLATE_PROFILE = "monorepo-subgem-package"
236
245
  MONOREPO_SUBGEM_RELEASE_TEMPLATE_PROFILE = "monorepo-subgem-release"
@@ -574,14 +583,18 @@ module Kettle
574
583
  KJ|MIN_RUBY
575
584
  KJ|KETTLE_CHANGELOG_GEMFILE_DEPENDENCY
576
585
  KJ|OPENCOLLECTIVE_ORG
586
+ KJ|RAKE:DUEL_TASK
577
587
  KJ|RAKE:TOP_LEVEL_DOCS_TASK
578
588
  KJ|README:COPYRIGHT_NOTICE
579
589
  KJ|README:CORPORATE_SPONSORS
590
+ KJ|README:CORPORATE_SPONSORS_DETAIL
580
591
  KJ|README:FAMILY_INTRO_BACKEND_MATRIX
581
592
  KJ|README:LICENSE_BADGE
582
593
  KJ|README:LICENSE_COMPAT_BADGE
583
594
  KJ|README:LICENSE_EYE_WORKFLOW_BADGE
584
595
  KJ|README:DEV_TEST_STACK_TABLE
596
+ KJ|README:DUEL_BADGE
597
+ KJ|README:DUEL_REFS
585
598
  KJ|README:FOSSA_BADGE
586
599
  KJ|README:FOSSA_REFS
587
600
  KJ|README:H2_SYNOPSIS_LOGO_ROW
@@ -1493,6 +1506,7 @@ module Kettle
1493
1506
  README_LICENSE_EYE_WORKFLOW_LINK_LABELS = ["🚎15-🪪-wf", "🚎15-🪪-wfi"].freeze
1494
1507
  README_FOSSA_BADGE = "[![FOSSA Status][🧪fossa-img]][🧪fossa]"
1495
1508
  README_FOSSA_LINK_LABELS = ["🧪fossa", "🧪fossa-img"].freeze
1509
+ README_DUEL_BADGE = "[![Duel][⚔️duel-img]][⚔️duel]"
1496
1510
  LICENSE_EYE_COMPATIBILITY_LICENSES = %w[MIT].freeze
1497
1511
  SKYWALKING_EYES_INTEGRATION = "skywalking-eyes"
1498
1512
  README_OPEN_COLLECTIVE_FUNDING_BADGES = "[![OpenCollective Backers][🖇osc-backers-i]][🖇osc-backers] [![OpenCollective Sponsors][🖇osc-sponsors-i]][🖇osc-sponsors]"
@@ -1519,9 +1533,9 @@ module Kettle
1519
1533
  patreon
1520
1534
  polar
1521
1535
  thanks_dev
1522
- tidelift
1523
1536
  ].freeze
1524
- FUNDING_README_PLATFORMS = (FUNDING_YML_PLATFORMS + ["paypal"]).freeze
1537
+ # Tidelift remains supported for legacy funding configuration and cleanup.
1538
+ FUNDING_README_PLATFORMS = (FUNDING_YML_PLATFORMS + %w[paypal tidelift]).freeze
1525
1539
  FUNDING_DEFAULT_DISABLED_PLATFORMS = %w[issuehunt patreon polar].freeze
1526
1540
  README_FUNDING_BADGE_POLICIES = {
1527
1541
  "github" => {
@@ -1559,9 +1573,7 @@ module Kettle
1559
1573
  labels: ["🖇kofi", "🖇kofi-img"]
1560
1574
  },
1561
1575
  "tidelift" => {
1562
- badges: [
1563
- "[![Get help from me on Tidelift][🏙️entsup-tidelift-img]][🏙️entsup-tidelift]"
1564
- ],
1576
+ badges: ["[![Get help from me on Tidelift][🏙️entsup-tidelift-img]][🏙️entsup-tidelift]"],
1565
1577
  labels: ["🏙️entsup-tidelift", "🏙️entsup-tidelift-img"]
1566
1578
  }
1567
1579
  }.freeze
@@ -3201,6 +3213,7 @@ module Kettle
3201
3213
  custom_workflows: github_actions_custom_workflows(project_root, template_config, opencollective_disabled: opencollective_disabled)
3202
3214
  }
3203
3215
  standard_appraisal_gemfiles = github_actions_standard_appraisal_gemfiles(kettle_config)
3216
+ facts[:ci][:duel] = true if template_config["resolved_duel"]
3204
3217
  facts[:ci][:standard_appraisal_gemfiles] = standard_appraisal_gemfiles unless standard_appraisal_gemfiles.empty?
3205
3218
  facts[:ci][:opt_in_workflow_cleanups] = opt_in_workflows unless opt_in_workflows.empty?
3206
3219
  facts[:ci][:inactive_packaged_workflow_cleanups] = inactive_workflows unless inactive_workflows.empty?
@@ -3289,7 +3302,13 @@ module Kettle
3289
3302
  "yaml",
3290
3303
  "supplied_github_funding_yaml_synchronization",
3291
3304
  facts: %w[package funding]
3292
- )
3305
+ ).tap do |recipe|
3306
+ # Cold-start only: on a bootstrap run (no config yet, hence no
3307
+ # template source preferences) this recipe materializes a first
3308
+ # FUNDING.yml. Configured runs get FUNDING.yml from
3309
+ # template_source_application instead.
3310
+ recipe[:seed_if_empty] = true if facts[:kettle_config_bootstrap]
3311
+ end
3293
3312
  ]
3294
3313
  end
3295
3314
  if facts[:kettle_config_bootstrap]
@@ -3627,6 +3646,13 @@ module Kettle
3627
3646
  changed_files = changed_files_from_recipe_reports(recipe_reports)
3628
3647
  file_outcomes = template_file_outcomes(recipe_reports)
3629
3648
  diagnostics = recipe_reports.flat_map { |report| report[:diagnostics] }
3649
+ legacy_config_migration = legacy_kettle_config_migration(project_root)
3650
+ if legacy_config_migration
3651
+ diagnostics << {
3652
+ severity: "advisory",
3653
+ message: "kettle:jem legacy config #{LEGACY_KETTLE_CONFIG_PATH} will be moved to #{KETTLE_CONFIG_PATH} on apply"
3654
+ }
3655
+ end
3630
3656
  phase_reports = phase_reports_for(recipe_reports)
3631
3657
  decision_evaluations = recipe_reports.map { |report| report.fetch(:decision_evaluation) }
3632
3658
  prompt_requests = decision_evaluations.filter_map { |decision| decision[:prompt] if decision[:prompt_required] }
@@ -3670,6 +3696,7 @@ module Kettle
3670
3696
  }
3671
3697
  end
3672
3698
  emit_summary_event(events, report)
3699
+ report[:legacy_config_migration] = legacy_config_migration.merge(applied: false) if legacy_config_migration
3673
3700
  report
3674
3701
  end
3675
3702
 
@@ -4300,8 +4327,10 @@ module Kettle
4300
4327
  end
4301
4328
 
4302
4329
  def apply_project(project_root, env: ENV, run_options: {})
4330
+ legacy_config_migration = apply_legacy_kettle_config_migration!(project_root)
4303
4331
  events = event_stream_from_options(run_options)
4304
4332
  report = plan_project(project_root, env: env, run_options: run_options).merge(mode: "apply")
4333
+ report[:legacy_config_migration] = legacy_config_migration if legacy_config_migration
4305
4334
  file_work_workers = with_event_phase(events, "file_work_workers") { file_work_workers_for(env, run_options) }
4306
4335
  file_work_thread_workers = with_event_phase(events, "file_work_thread_workers") { file_work_thread_workers_for(env, run_options) }
4307
4336
  report[:file_work_workers] = file_work_workers
@@ -4333,6 +4362,9 @@ module Kettle
4333
4362
  report[:changed_files] = (report.fetch(:changed_files, []) + report.fetch(:post_apply_steps).flat_map do |step|
4334
4363
  reported_post_apply_changed_files(step)
4335
4364
  end).uniq.sort
4365
+ if legacy_config_migration
4366
+ report[:changed_files] = (report.fetch(:changed_files, []) + [LEGACY_KETTLE_CONFIG_PATH, KETTLE_CONFIG_PATH]).uniq.sort
4367
+ end
4336
4368
  report[:duplicate_drift] = if DecisionPolicy.value_to_boolean((run_options || {})[:defer_drift_check])
4337
4369
  {available: false, skipped: true, reason: "deferred_until_postprocessing"}
4338
4370
  else
@@ -5120,11 +5152,11 @@ module Kettle
5120
5152
  [
5121
5153
  {name: "appraisal2", source: %(gem "appraisal2", "~> 3.2", ">= 3.2.6"\n)},
5122
5154
  {name: "bundler-audit", source: %(gem "bundler-audit", "~> 0.9.3"\n)},
5123
- {name: "kettle-dev", source: %(gem "kettle-dev", "~> 3.1", ">= 3.1.21"\n)},
5124
- {name: "kettle-drift", source: %(gem "kettle-drift", "~> 1.0", ">= 1.0.17"\n)},
5125
- {name: "kettle-family", source: %(gem "kettle-family", "~> 1.3", ">= 1.3.29"\n)},
5155
+ {name: "kettle-dev", source: %(gem "kettle-dev", "~> 3.1", ">= 3.1.26"\n)},
5156
+ {name: "kettle-drift", source: %(gem "kettle-drift", "~> 1.0", ">= 1.0.19"\n)},
5157
+ {name: "kettle-family", source: %(gem "kettle-family", "~> 1.3", ">= 1.3.36"\n)},
5126
5158
  {name: PACKAGE_NAME, source: kettle_jem_dependency_source},
5127
- {name: "kettle-test", source: %(gem "kettle-test", "~> 2.0", ">= 2.0.27"\n)},
5159
+ {name: "kettle-test", source: %(gem "kettle-test", "~> 2.0", ">= 2.0.29"\n)},
5128
5160
  {name: "rake", source: %(gem "rake", "~> 13.0"\n)},
5129
5161
  {name: "rspec", source: %(gem "rspec", "~> 3.0"\n)},
5130
5162
  {name: "stone_checksums", source: %(gem "stone_checksums", "~> 1.0", ">= 1.0.9"\n)},
@@ -5638,6 +5670,7 @@ module Kettle
5638
5670
  "KJ|PROJECT_EMOJI" => "💎",
5639
5671
  "KJ|README:COPYRIGHT_NOTICE" => "",
5640
5672
  "KJ|README:CORPORATE_SPONSORS" => "",
5673
+ "KJ|README:CORPORATE_SPONSORS_DETAIL" => "",
5641
5674
  "KJ|README:LICENSE_BADGE" => "",
5642
5675
  "KJ|README:LICENSE_COMPAT_BADGE" => "",
5643
5676
  "KJ|README:DEV_TEST_STACK_TABLE" => "",
@@ -6025,6 +6058,7 @@ module Kettle
6025
6058
  "feature.dedicated_version_gem" => version_gem_mode == "dedicated" || File.file?(File.join(project_root, "lib", entrypoint, "version_gem.rb")),
6026
6059
  "workflow.dep_heads" => File.file?(File.join(project_root, ".github", "workflows", "dep-heads.yml")),
6027
6060
  "workflow.jruby_94" => File.file?(File.join(project_root, ".github", "workflows", "jruby-9.4.yml")),
6061
+ "workflow.duel" => duel_configured?(config) && duel_template_profile?(profile),
6028
6062
  "version_gem.mode" => version_gem_mode
6029
6063
  }
6030
6064
  end
@@ -6419,7 +6453,7 @@ module Kettle
6419
6453
  when "generated_block_sync"
6420
6454
  synchronize_managed_block(original, facts)
6421
6455
  when "github_funding_yml"
6422
- synchronize_github_funding_yml(original, facts)
6456
+ synchronize_github_funding_yml(original, facts, seed_if_empty: recipe[:seed_if_empty])
6423
6457
  when "github_actions_ci"
6424
6458
  synchronize_github_actions_ci(original, facts)
6425
6459
  when "github_actions_framework_ci"
@@ -6827,7 +6861,7 @@ module Kettle
6827
6861
 
6828
6862
  content = recipe_template_content(project_root, recipe, template_contents: template_contents)
6829
6863
  return finalize_github_workflow_template(content, facts) if strategy == "raw_copy" && github_workflow_template_recipe?(recipe)
6830
- return finalize_template_source_content(recipe, content) if strategy == "raw_copy"
6864
+ return finalize_template_source_content(recipe, content, facts: facts) if strategy == "raw_copy"
6831
6865
 
6832
6866
  resolved = recipe.fetch(:resolved_template_content) do
6833
6867
  resolve_template_tokens(
@@ -6882,7 +6916,7 @@ module Kettle
6882
6916
  return preserve_newer_github_workflow_action_pins(merged, original)
6883
6917
  end
6884
6918
 
6885
- return finalize_template_source_content(recipe, merged)
6919
+ return finalize_template_source_content(recipe, merged, facts: facts)
6886
6920
  end
6887
6921
  if strategy == "accept_template"
6888
6922
  accepted = finalize_accepted_template_source(recipe, resolved, original, facts: facts, project_root: project_root)
@@ -6902,7 +6936,7 @@ module Kettle
6902
6936
  )
6903
6937
  end
6904
6938
 
6905
- return finalize_template_source_content(recipe, accepted)
6939
+ return finalize_template_source_content(recipe, accepted, facts: facts)
6906
6940
  end
6907
6941
 
6908
6942
  resolved = finalize_github_workflow_template(resolved, facts) if github_workflow_template_recipe?(recipe)
@@ -6916,11 +6950,11 @@ module Kettle
6916
6950
  )
6917
6951
  end
6918
6952
 
6919
- finalize_template_source_content(recipe, resolved)
6953
+ finalize_template_source_content(recipe, resolved, facts: facts)
6920
6954
  end
6921
6955
 
6922
- def finalize_template_source_content(recipe, content)
6923
- return finalize_rubocop_config(content) if recipe.fetch(:target_path).to_s == ".rubocop.yml"
6956
+ def finalize_template_source_content(recipe, content, facts: nil)
6957
+ return finalize_rubocop_config(content, facts) if recipe.fetch(:target_path).to_s == ".rubocop.yml"
6924
6958
  return finalize_kettle_config_template_source(recipe, content) if recipe.fetch(:target_path).to_s == KETTLE_CONFIG_PATH
6925
6959
  return normalize_simplecov_template_source(content) if recipe.fetch(:target_path).to_s == ".simplecov"
6926
6960
  return normalize_spec_helper_simplecov_template_source(content) if recipe.fetch(:target_path).to_s == "spec/spec_helper.rb"
@@ -6934,9 +6968,77 @@ module Kettle
6934
6968
  assert_no_unresolved_template_tokens_in_yaml_values(resolved, KETTLE_CONFIG_PATH)
6935
6969
  end
6936
6970
 
6937
- def finalize_rubocop_config(content)
6971
+ def finalize_rubocop_config(content, facts = nil)
6938
6972
  finalized = remove_yaml_scalar_path(content, %w[AllCops TargetRubyVersion])
6939
- ensure_yaml_top_level_sequence_items(finalized, "plugins", ["rubocop-rspec"])
6973
+ finalized = ensure_yaml_top_level_sequence_items(finalized, "plugins", ["rubocop-rspec"])
6974
+ return finalized unless facts.to_h.dig(:ci, :duel)
6975
+
6976
+ ensure_rubocop_duel_config(finalized)
6977
+ end
6978
+
6979
+ # Duel's configuration is a dotfile, which RuboCop skips unless it is
6980
+ # included. Merging Include, as Exclude is, keeps RuboCop's own patterns.
6981
+ def ensure_rubocop_duel_config(content)
6982
+ merged = ensure_yaml_child_sequence_items(
6983
+ content,
6984
+ "inherit_mode",
6985
+ "merge",
6986
+ ["Include"],
6987
+ comment: "# Merge Include too, so .duel.rb (a dotfile) is added to the defaults"
6988
+ )
6989
+ ensure_yaml_child_sequence_items(
6990
+ merged,
6991
+ "AllCops",
6992
+ "Include",
6993
+ [".duel.rb"],
6994
+ comment: "# Duel's configuration is a dotfile, which RuboCop skips unless included"
6995
+ )
6996
+ end
6997
+
6998
+ # Ensures that the block sequence at parent_key.key holds items, adding
6999
+ # the parent mapping or the key when missing. The comment goes above the
7000
+ # added key, or above the added items when the key exists. A flow
7001
+ # sequence or mapping is left alone.
7002
+ def ensure_yaml_child_sequence_items(content, parent_key, key, items, comment:)
7003
+ document = Psych.parse_stream(content.to_s).children.first
7004
+ root = document&.root
7005
+ return content unless root.is_a?(Psych::Nodes::Mapping)
7006
+
7007
+ lines = content.to_s.lines
7008
+ parent_key_node, parent_node = root.children.each_slice(2).find do |candidate_key, _candidate_value|
7009
+ candidate_key.is_a?(Psych::Nodes::Scalar) && candidate_key.value.to_s == parent_key.to_s
7010
+ end
7011
+ unless parent_key_node
7012
+ lines << "\n" unless lines.empty? || lines.last.strip.empty?
7013
+ lines << "#{parent_key}:\n" << " #{comment}\n" << " #{key}:\n"
7014
+ items.each { |item| lines << " - #{item}\n" }
7015
+ return lines.join
7016
+ end
7017
+ return content unless parent_node.is_a?(Psych::Nodes::Mapping) && parent_node.style == Psych::Nodes::Mapping::BLOCK
7018
+ return content if parent_node.children.empty?
7019
+
7020
+ key_node, value_node = parent_node.children.each_slice(2).find do |candidate_key, _candidate_value|
7021
+ candidate_key.is_a?(Psych::Nodes::Scalar) && candidate_key.value.to_s == key.to_s
7022
+ end
7023
+ unless key_node
7024
+ indent = lines.fetch(parent_node.children.first.start_line)[/\A\s*/]
7025
+ added = ["#{indent}#{comment}\n", "#{indent}#{key}:\n", *items.map { |item| "#{indent} - #{item}\n" }]
7026
+ lines.insert(parent_key_node.start_line + 1, *added)
7027
+ return lines.join
7028
+ end
7029
+ return content unless value_node.is_a?(Psych::Nodes::Sequence) && value_node.style == Psych::Nodes::Sequence::BLOCK
7030
+ return content if value_node.children.empty?
7031
+
7032
+ existing = value_node.children.filter_map { |child| child.value.to_s if child.is_a?(Psych::Nodes::Scalar) }
7033
+ missing = items.map(&:to_s) - existing
7034
+ return content if missing.empty?
7035
+
7036
+ last = value_node.children.last
7037
+ indent = lines.fetch(last.start_line)[/\A\s*/]
7038
+ lines.insert(last.end_line + 1, "#{indent}#{comment}\n", *missing.map { |item| "#{indent}- #{item}\n" })
7039
+ lines.join
7040
+ rescue Psych::Exception
7041
+ content
6940
7042
  end
6941
7043
 
6942
7044
  def finalize_accepted_template_source(recipe, content, destination_content, facts:, project_root: nil)
@@ -6944,7 +7046,7 @@ module Kettle
6944
7046
  when :gemfile
6945
7047
  finalize_gemfile_template_source(recipe, content, destination_content, facts: facts, template_content: content)
6946
7048
  when :appraisals
6947
- merged = merge_appraisals_template_policy(content, facts: facts)
7049
+ merged = merge_appraisals_template_policy(content, facts: facts, target_path: recipe.fetch(:target_path))
6948
7050
  if recipe.fetch(:target_path).to_s == "Appraisal.root.gemfile"
6949
7051
  normalize_appraisal_root_templating_gate(merged, facts)
6950
7052
  else
@@ -7236,6 +7338,7 @@ module Kettle
7236
7338
  "FUNDING_TIDELIFT",
7237
7339
  keep: funding_platform_enabled?(facts.dig(:funding, :platforms), "tidelift")
7238
7340
  )
7341
+ processed = processed.gsub(/<!-- KJ:FUNDING_TIDELIFT:(?:START|END) -->\n?/, "")
7239
7342
  star_history_enabled = facts.dig(:repository, :star_history, :enabled) == true
7240
7343
  return processed if star_history_enabled
7241
7344
 
@@ -7252,6 +7355,12 @@ module Kettle
7252
7355
  open_collective_enabled = !facts.dig(:funding, :open_collective_disabled)
7253
7356
  processed = apply_markdown_conditional_block(content, "OPEN_COLLECTIVE", keep: open_collective_enabled)
7254
7357
  processed = apply_markdown_conditional_block(processed, "NO_OPEN_COLLECTIVE", keep: !open_collective_enabled)
7358
+ processed = apply_markdown_conditional_block(
7359
+ processed,
7360
+ "FUNDING_TIDELIFT",
7361
+ keep: funding_platform_enabled?(facts.dig(:funding, :platforms), "tidelift")
7362
+ )
7363
+ processed = processed.gsub(/<!-- KJ:FUNDING_TIDELIFT:(?:START|END) -->\n?/, "")
7255
7364
  funding_platforms = facts.dig(:funding, :platforms)
7256
7365
  if facts.dig(:funding, :open_collective_disabled)
7257
7366
  processed = remove_readme_badge_and_refs(
@@ -7624,7 +7733,7 @@ module Kettle
7624
7733
  if file_type == :gemfile
7625
7734
  return finalize_gemfile_template_source(recipe, template_content, destination_content, facts: facts, template_content: template_content)
7626
7735
  end
7627
- return merge_appraisals_template_policy(template_content, facts: facts) if file_type == :appraisals
7736
+ return merge_appraisals_template_policy(template_content, facts: facts, target_path: recipe.fetch(:target_path)) if file_type == :appraisals
7628
7737
  return finalize_github_workflow_template(prune_github_workflow_matrix_by_min_ruby(template_content, facts), facts) if github_workflow_template_recipe?(recipe)
7629
7738
 
7630
7739
  return template_content
@@ -7633,7 +7742,7 @@ module Kettle
7633
7742
  if file_type == :gemfile
7634
7743
  return finalize_gemfile_template_source(recipe, destination_content, destination_content, facts: facts, template_content: template_content)
7635
7744
  end
7636
- return merge_appraisals_template_policy(destination_content, facts: facts) if file_type == :appraisals
7745
+ return merge_appraisals_template_policy(destination_content, facts: facts, target_path: recipe.fetch(:target_path)) if file_type == :appraisals
7637
7746
 
7638
7747
  return destination_content
7639
7748
  end
@@ -7680,7 +7789,7 @@ module Kettle
7680
7789
  output = merge_gemfile_eval_bucket_entries(template_content, output, destination_content: destination_content)
7681
7790
  return finalize_gemfile_template_source(recipe, output, destination_content, facts: facts, template_content: template_content)
7682
7791
  end
7683
- return merge_appraisals_template_policy(output, facts: facts) if file_type == :appraisals
7792
+ return merge_appraisals_template_policy(output, facts: facts, target_path: recipe.fetch(:target_path)) if file_type == :appraisals
7684
7793
 
7685
7794
  output = finalize_github_workflow_template(prune_github_workflow_matrix_by_min_ruby(output, facts), facts) if github_workflow_template_recipe?(recipe)
7686
7795
  output = normalize_simplecov_template_source(output) if recipe.fetch(:target_path).to_s == ".simplecov"
@@ -7741,6 +7850,7 @@ module Kettle
7741
7850
  output = preserve_destination_gemfile_source(output, destination_content)
7742
7851
  end
7743
7852
  output = inject_main_gemfile_recording_eval(output, facts)
7853
+ output = inject_main_gemfile_duel_eval(output, facts)
7744
7854
  output = inject_main_gemfile_default_test_bundle(output, facts)
7745
7855
  output = remove_stale_main_gemfile_tree_sitter_language_pack(output, template_content)
7746
7856
  output = remove_stale_main_gemfile_direct_sibling_block(output, template_content)
@@ -9394,6 +9504,34 @@ module Kettle
9394
9504
  ensure_trailing_newline(lines.join.gsub(/\n{3,}/, "\n\n"))
9395
9505
  end
9396
9506
 
9507
+ # Duel for local development (`rake duel`), from its modular Gemfile, which
9508
+ # installs nothing below Ruby 3.4. CI loads it from the perf appraisal.
9509
+ def inject_main_gemfile_duel_eval(content, facts)
9510
+ return content unless facts.to_h.dig(:ci, :duel)
9511
+ return content if gemfile_eval_paths(content).include?(DUEL_MAIN_GEMFILE_EVAL_PATH)
9512
+
9513
+ lines = content.to_s.lines
9514
+ insert_at = main_gemfile_duel_insertion_index(content)
9515
+ unless insert_at
9516
+ lines << "\n" unless lines.empty? || lines.last.strip.empty?
9517
+ insert_at = lines.length
9518
+ end
9519
+ lines.insert(insert_at, "# Performance regression guards (Duel requires Ruby 3.4 or later)\n", %(eval_gemfile "#{DUEL_MAIN_GEMFILE_EVAL_PATH}"\n), "\n")
9520
+ ensure_trailing_newline(lines.join.gsub(/\n{3,}/, "\n\n"))
9521
+ end
9522
+
9523
+ # After the optional dependencies, before the extracted standard library
9524
+ # gems.
9525
+ def main_gemfile_duel_insertion_index(content)
9526
+ lines = content.to_s.lines
9527
+ call = ruby_call_records(content, :eval_gemfile).find do |candidate|
9528
+ ruby_string_argument(candidate).to_s == "gemfiles/modular/x_std_libs.gemfile"
9529
+ end
9530
+ return unless call
9531
+
9532
+ gemfile_eval_comment_start_line(lines, call.location.start_line) - 1
9533
+ end
9534
+
9397
9535
  # The root Gemfile is the local-development bundle. Appraisals and CI use
9398
9536
  # their own Gemfiles, so a project that needs a framework or application
9399
9537
  # harness locally must declare that dependency separately in test_bundle.
@@ -10489,14 +10627,16 @@ module Kettle
10489
10627
  version.split(".", -1).all? { |part| !part.empty? && part.each_char.all? { |char| char.between?("0", "9") } }
10490
10628
  end
10491
10629
 
10492
- def merge_appraisals_template_policy(content, facts:)
10630
+ def merge_appraisals_template_policy(content, facts:, target_path: nil)
10493
10631
  package_name = facts.dig(:package, :name).to_s if facts
10494
10632
  min_ruby = minimum_ruby_token(facts.dig(:ci, :test_min_ruby) || facts.dig(:rubygems, :min_ruby)) if facts
10495
10633
  with_framework_appraisals = merge_framework_matrix_appraisals(content, facts)
10496
10634
  with_standard_appraisal_gemfiles = inject_standard_appraisal_gemfiles(with_framework_appraisals, facts)
10635
+ with_standard_appraisal_gemfiles = inject_duel_appraisal(with_standard_appraisal_gemfiles, facts) if target_path.to_s == "Appraisals"
10497
10636
  pruned = prune_appraisals_recording_entries(with_standard_appraisal_gemfiles, facts)
10498
10637
  pruned = prune_appraisals_below_min_ruby(pruned, min_ruby)
10499
10638
  pruned = remove_managed_stdlib_appraisal_gems(pruned)
10639
+ pruned = remove_duplicate_runtime_heads_from_head_appraisal(pruned)
10500
10640
  pruned = migrate_legacy_byebug_appraisal_dependencies(pruned)
10501
10641
  pruned = remove_gemfile_dependency_lines(pruned, [package_name])
10502
10642
  remove_gemfile_percent_w_entries(pruned, [package_name])
@@ -10617,6 +10757,56 @@ module Kettle
10617
10757
  end
10618
10758
  end
10619
10759
 
10760
+ # Older generated Appraisals put runtime dependency HEADs in the `head`
10761
+ # appraisal alongside Ruby-version-compatible extracted stdlib versions.
10762
+ # The template tests those separately: `head` uses compatible versions,
10763
+ # while `dep-heads` tests runtime dependency HEADs. Remove the stale eval
10764
+ # only when the separate dependency-head appraisal is present.
10765
+ def remove_duplicate_runtime_heads_from_head_appraisal(content)
10766
+ parsed = appraisal_blocks(content)
10767
+ blocks = parsed.fetch(:blocks)
10768
+ head = blocks["head"]
10769
+ return content unless head && appraisal_x_stdlib_eval_gemfile_call(head)
10770
+ return content unless blocks["dep-heads"] && runtime_heads_eval_gemfile_call(blocks["dep-heads"])
10771
+
10772
+ call = runtime_heads_eval_gemfile_call(head)
10773
+ return content unless call
10774
+
10775
+ lines = head.lines
10776
+ first = call.location.start_line - 1
10777
+ last = ruby_node_source_end_line(call) - 1
10778
+ first -= 1 while first.positive? && gemfile_comment_line?(lines[first - 1])
10779
+ head = ensure_trailing_newline(lines.each_with_index.reject { |_line, index| (first..last).cover?(index) }.map(&:first).join.gsub(/\n{3,}/, "\n\n"))
10780
+ blocks["head"] = head
10781
+ ensure_trailing_newline(([parsed.fetch(:prelude).to_s.rstrip] + parsed.fetch(:order).map { |name| blocks.fetch(name).rstrip }).reject(&:empty?).join("\n\n"))
10782
+ end
10783
+
10784
+ def runtime_heads_eval_gemfile_call(block)
10785
+ ruby_call_records(block, :eval_gemfile).find do |call|
10786
+ ruby_string_argument(call).to_s.end_with?("modular/runtime_heads.gemfile")
10787
+ end
10788
+ end
10789
+
10790
+ # The perf appraisal, whose generated gemfiles/perf.gemfile the Perf
10791
+ # workflow bundles: the gemspec's dependencies and Duel. An existing perf
10792
+ # appraisal is the project's, and is kept as it is.
10793
+ def inject_duel_appraisal(content, facts)
10794
+ return content unless facts.to_h.dig(:ci, :duel)
10795
+ return content if appraisal_call_records(content).any? { |record| record.fetch(:name) == DUEL_APPRAISAL_NAME }
10796
+
10797
+ # (The comments are inside the block: Appraisals keeps no comment
10798
+ # between blocks.)
10799
+ block = <<~RUBY
10800
+ appraise "#{DUEL_APPRAISAL_NAME}" do
10801
+ # Performance regression guards, for .github/workflows/perf.yml
10802
+ # (Duel requires Ruby 3.4 or later)
10803
+ eval_gemfile "modular/duel.gemfile"
10804
+ eval_gemfile "modular/x_std_libs.gemfile"
10805
+ end
10806
+ RUBY
10807
+ ensure_trailing_newline([content.to_s.rstrip, block].reject(&:empty?).join("\n\n"))
10808
+ end
10809
+
10620
10810
  def prune_appraisals_recording_entries(content, facts)
10621
10811
  remove_indexes = Set.new
10622
10812
  lines = content.to_s.lines
@@ -11114,9 +11304,16 @@ module Kettle
11114
11304
  end
11115
11305
 
11116
11306
  def gemspec_standard_legacy_version_loader_block(entrypoint_require:, namespace:, min_ruby:)
11117
- legacy_require =
11307
+ legacy_else =
11118
11308
  if min_ruby >= REQUIRE_RELATIVE_MIN_RUBY
11119
- %(require_relative "lib/#{entrypoint_require}/version")
11309
+ <<~RUBY.chomp
11310
+ # Ruby < 3.1: Kernel.load(path, module) is unavailable, but Module#module_eval with file
11311
+ # metadata works on Ruby 2.2+. Anonymous loading matters beyond coverage: evaluating this
11312
+ # gemspec (e.g. by bundler/setup in a subprocess) must not define top-level constants,
11313
+ # which isolated-loading specs assert on.
11314
+ version_path = "\#{__dir__}/lib/#{entrypoint_require}/version.rb"
11315
+ Module.new.tap { |mod| mod.module_eval(File.read(version_path), version_path, 1) }::#{namespace}::Version::VERSION
11316
+ RUBY
11120
11317
  else
11121
11318
  <<~RUBY.chomp
11122
11319
  # NOTE: Use __FILE__ or __dir__ until removal of Ruby 1.x support
@@ -11124,6 +11321,7 @@ module Kettle
11124
11321
  lib = File.expand_path("lib", File.dirname(__FILE__))
11125
11322
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
11126
11323
  require "#{entrypoint_require}/version"
11324
+ #{namespace}::Version::VERSION
11127
11325
  RUBY
11128
11326
  end
11129
11327
 
@@ -11135,8 +11333,7 @@ module Kettle
11135
11333
  # See: https://github.com/panorama-ed/memo_wise/pull/397
11136
11334
  #{gemspec_modern_version_loader_expression(entrypoint_require: entrypoint_require, namespace: namespace)}
11137
11335
  else
11138
- #{legacy_require.gsub("\n", "\n ")}
11139
- #{namespace}::Version::VERSION
11336
+ #{legacy_else.gsub("\n", "\n ")}
11140
11337
  end
11141
11338
  RUBY
11142
11339
  end
@@ -13934,9 +14131,6 @@ module Kettle
13934
14131
  if !opencollective_disabled && funding_platform_enabled?(enabled_platforms, "open_collective")
13935
14132
  urls << github_funding_platform_urls("open_collective", [open_collective_org]).first
13936
14133
  end
13937
- if funding_platform_enabled?(enabled_platforms, "tidelift")
13938
- urls << github_funding_platform_urls("tidelift", ["rubygems/#{package_name}"]).first
13939
- end
13940
14134
 
13941
14135
  urls.compact.uniq.sort
13942
14136
  end
@@ -13974,8 +14168,6 @@ module Kettle
13974
14168
  "https://opencollective.com/#{handle}"
13975
14169
  when "thanks_dev"
13976
14170
  "https://thanks.dev/#{handle}"
13977
- when "tidelift"
13978
- "https://tidelift.com/funding/github/#{handle}"
13979
14171
  end
13980
14172
  end
13981
14173
  end
@@ -13996,7 +14188,8 @@ module Kettle
13996
14188
  next if opencollective_disabled && opencollective_disabled_file?(relative_path)
13997
14189
  next if inactive_packaged_workflow?(relative_path, config)
13998
14190
  next if generated_or_obsolete_github_workflow?(relative_path) &&
13999
- !skip_packaged_workflow_template?(relative_path, config)
14191
+ !skip_packaged_workflow_template?(relative_path, config) &&
14192
+ !skip_duel_template?(relative_path, config)
14000
14193
 
14001
14194
  relative_path
14002
14195
  end.sort
@@ -14149,7 +14342,7 @@ module Kettle
14149
14342
  path = kettle_jem_config_path(project_root)
14150
14343
  return {} unless File.exist?(path)
14151
14344
 
14152
- config = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
14345
+ config = YAML.safe_load_file(path, permitted_classes: [Date], aliases: false) || {}
14153
14346
  config = normalize_kettle_jem_config(config)
14154
14347
  validate_kettle_jem_config!(config)
14155
14348
  config
@@ -14175,9 +14368,38 @@ module Kettle
14175
14368
 
14176
14369
  def kettle_jem_config_path(project_root)
14177
14370
  canonical = File.join(project_root, KETTLE_CONFIG_PATH)
14371
+ legacy = File.join(project_root, LEGACY_KETTLE_CONFIG_PATH)
14372
+ if File.exist?(canonical) && File.exist?(legacy)
14373
+ raise Error, "Invalid kettle-jem config: both #{KETTLE_CONFIG_PATH} and legacy #{LEGACY_KETTLE_CONFIG_PATH} exist in #{project_root}. " \
14374
+ "The legacy file is silently ignored when both are present, so this combination is not supported. " \
14375
+ "Merge any unique settings from #{LEGACY_KETTLE_CONFIG_PATH} into #{KETTLE_CONFIG_PATH}, then delete #{LEGACY_KETTLE_CONFIG_PATH}."
14376
+ end
14178
14377
  return canonical if File.exist?(canonical)
14179
14378
 
14180
- File.join(project_root, LEGACY_KETTLE_CONFIG_PATH)
14379
+ legacy
14380
+ end
14381
+
14382
+ # Legacy-only projects are migrated to the canonical config path with MOVE
14383
+ # semantics: the content lands at .structuredmerge/kettle-jem.yml and the
14384
+ # legacy .kettle-jem.yml is removed, so there is never a stale second
14385
+ # config that the loader silently ignores.
14386
+ def legacy_kettle_config_migration(project_root)
14387
+ canonical = File.join(project_root, KETTLE_CONFIG_PATH)
14388
+ legacy = File.join(project_root, LEGACY_KETTLE_CONFIG_PATH)
14389
+ return unless File.exist?(legacy)
14390
+ return if File.exist?(canonical)
14391
+
14392
+ {from: LEGACY_KETTLE_CONFIG_PATH, to: KETTLE_CONFIG_PATH}
14393
+ end
14394
+
14395
+ def apply_legacy_kettle_config_migration!(project_root)
14396
+ migration = legacy_kettle_config_migration(project_root)
14397
+ return unless migration
14398
+
14399
+ canonical = File.join(project_root, KETTLE_CONFIG_PATH)
14400
+ FileUtils.mkdir_p(File.dirname(canonical))
14401
+ FileUtils.mv(File.join(project_root, LEGACY_KETTLE_CONFIG_PATH), canonical)
14402
+ migration.merge(applied: true)
14181
14403
  end
14182
14404
 
14183
14405
  def kettle_jem_config_relative_path(project_root)
@@ -14739,7 +14961,7 @@ module Kettle
14739
14961
  "KJ|CI:EXEC_CMD" => facts.dig(:ci, :exec_cmd).to_s,
14740
14962
  "KJ|CI:OS_MATRIX_ENTRIES" => github_actions_current_os_matrix_entries(facts),
14741
14963
  "KJ|CI:OS_TEST_STEPS" => github_actions_current_os_test_steps(facts),
14742
- "KJ|README:PLATFORM_SUPPORT_TABLE" => readme_platform_support_table(facts),
14964
+ "KJ|README:CURRENT_MRI_PLATFORMS" => readme_current_mri_platforms(facts),
14743
14965
  "KJ|RAKE:TOP_LEVEL_DOCS_TASK" => rake_top_level_docs_task_token(facts).rstrip,
14744
14966
  "KJ|GITHUB_ACTIONS:COVERAGE_UPLOAD_STEPS" => github_actions_coverage_steps(disabled_integrations: facts.dig(:integrations, :disabled))
14745
14967
  }.merge(
@@ -14771,6 +14993,7 @@ module Kettle
14771
14993
  readme_family_intro_and_backend_matrix(facts.fetch(:readme_style, {}))
14772
14994
  tokens["KJ|README:DEV_TEST_STACK_TABLE"] = readme_dev_test_stack_table(package.fetch(:name).to_s)
14773
14995
  tokens.merge!(readme_fossa_template_tokens(facts.fetch(:readme_style, {})))
14996
+ tokens.merge!(duel_template_tokens(facts))
14774
14997
  tokens.merge!(rubyforum_template_tokens(facts.fetch(:rubyforum, {})))
14775
14998
  tokens.merge!(version_gem_template_tokens(facts))
14776
14999
  tokens.merge!(shim_template_tokens(facts.fetch(:shim, {})))
@@ -14814,66 +15037,17 @@ module Kettle
14814
15037
  "#{package_name} / #{namespace}"
14815
15038
  end
14816
15039
 
14817
- # The README platform tier table, generated from the same facts that drive
14818
- # the workflows, so it cannot drift from CI again (spec slice 1038). Rows
14819
- # mirror the generated workflow set: versioned MRI workflows are Linux-only,
14820
- # the current.yml OS matrix covers the declared platforms for MRI current,
14821
- # and every engine lane outside current.yml is Linux-only. Engine capability
14822
- # notes come from Kettle::Rb::PlatformSupport and carry their citation.
14823
- def readme_platform_support_table(facts)
14824
- engines = facts.dig(:rubygems, :engines)
14825
- engines = DEFAULT_ENGINES if engines.nil? || engines.empty?
15040
+ # Keep the platform detail within Compatibility. The compatibility badge
15041
+ # table already carries engine/version detail; this adds the OS platforms
15042
+ # exercised by the current MRI lane.
15043
+ def readme_current_mri_platforms(facts)
14826
15044
  platforms = facts.dig(:ci, :platforms)
14827
15045
  platforms = DEFAULT_PLATFORMS if platforms.nil? || platforms.empty?
14828
- ruby_versions = facts.dig(:ci, :ruby_versions).to_a.map(&:to_s).reject(&:empty?)
15046
+ platform_names = platforms.map { |platform| (platform == "macos") ? "macOS" : platform.capitalize }
15047
+ return platform_names.first if platform_names.one?
15048
+ return platform_names.join(" and ") if platform_names.length == 2
14829
15049
 
14830
- parallel = "Parallel support"
14831
- unsupported = "Unsupported"
14832
- rows = []
14833
-
14834
- unless ruby_versions.empty?
14835
- rows << [
14836
- "MRI #{ruby_versions.first}-#{ruby_versions.last}",
14837
- parallel, unsupported, unsupported,
14838
- "Versioned Ruby workflows run on Linux."
14839
- ]
14840
- end
14841
- rows << [
14842
- "MRI current",
14843
- parallel,
14844
- platforms.include?("macos") ? parallel : unsupported,
14845
- platforms.include?("windows") ? parallel : unsupported,
14846
- "Current Ruby workflow covers the declared OS platforms."
14847
- ]
14848
- rows << [
14849
- "MRI head",
14850
- parallel, unsupported, unsupported,
14851
- "CI is allowed to fail while Ruby is in development."
14852
- ]
14853
-
14854
- (engines - ["ruby"]).each do |engine|
14855
- label = (engine == "jruby") ? "JRuby" : "TruffleRuby"
14856
- windows_entry = Kettle::Rb::PlatformSupport.engine_os_entry(engine, "windows")
14857
- note = if windows_entry && windows_entry.support == "unsupported"
14858
- "#{windows_entry.note} ([source](#{windows_entry.citation}))"
14859
- else
14860
- "Engine workflows run on Linux; macOS and Windows are not exercised by CI."
14861
- end
14862
- rows << ["#{label} (all CI lanes)", parallel, unsupported, unsupported, note]
14863
- end
14864
-
14865
- [
14866
- "Support tiers describe what this project's CI actually exercises, not what may",
14867
- "work in an untested environment:",
14868
- "",
14869
- "- **Parallel support**: CI runs the test suite with multiple workers available.",
14870
- "- **Unsupported**: the engine/platform combination is not covered by CI and is",
14871
- " not a project compatibility claim.",
14872
- "",
14873
- "| Ruby engine/version | Linux | macOS | Windows | Notes |",
14874
- "|---------------------|-------|-------|---------|-------|",
14875
- *rows.map { |row| "| #{row.join(" | ")} |" }
14876
- ].join("\n")
15050
+ platform_names[0...-1].join(", ") + ", and #{platform_names.last}"
14877
15051
  end
14878
15052
 
14879
15053
  def readme_dev_test_stack_table(package_name)
@@ -14911,6 +15085,41 @@ module Kettle
14911
15085
  }
14912
15086
  end
14913
15087
 
15088
+ # The README badge and the Rake task, for a project with Duel on. They are
15089
+ # empty otherwise, which leaves its README and Rakefile as they were.
15090
+ def duel_template_tokens(facts)
15091
+ unless facts.dig(:ci, :duel)
15092
+ return {"KJ|RAKE:DUEL_TASK" => "", "KJ|README:DUEL_BADGE" => "", "KJ|README:DUEL_REFS" => ""}
15093
+ end
15094
+
15095
+ {
15096
+ "KJ|RAKE:DUEL_TASK" => rake_duel_task_token,
15097
+ "KJ|README:DUEL_BADGE" => README_DUEL_BADGE,
15098
+ "KJ|README:DUEL_REFS" => [
15099
+ "[⚔️duel]: https://github.com/sferik/duel-ruby",
15100
+ "[⚔️duel-img]: https://raw.githubusercontent.com/sferik/duel-ruby/main/assets/badge.svg"
15101
+ ].join("\n")
15102
+ }
15103
+ end
15104
+
15105
+ def rake_duel_task_token
15106
+ <<~RAKE
15107
+ ### PERFORMANCE TASKS
15108
+ # Duel's performance regression guards: rake duel compares this tree with
15109
+ # the main branch (see .duel.rb and duel/). Duel requires Ruby 3.4 or
15110
+ # later, so older Rubies get a stub task.
15111
+ begin
15112
+ require "duel/rake_task"
15113
+ Duel::RakeTask.new
15114
+ rescue LoadError
15115
+ desc("(stub) duel is unavailable")
15116
+ task("duel") do
15117
+ warn("NOTE: duel isn't installed, or is disabled for \#{RUBY_VERSION} in the current environment")
15118
+ end
15119
+ end
15120
+ RAKE
15121
+ end
15122
+
14914
15123
  def readme_fossa_template_tokens(readme_style)
14915
15124
  project = readme_style.fetch(:fossa_project, "").to_s
14916
15125
  return {"KJ|README:FOSSA_BADGE" => "", "KJ|README:FOSSA_REFS" => ""} if project.empty?
@@ -15944,7 +16153,7 @@ module Kettle
15944
16153
  end
15945
16154
 
15946
16155
  def kettle_dev_local_gems(config)
15947
- gems = %w[kettle-dev kettle-family kettle-rb kettle-test kettle-soup-cover kettle-changelog]
16156
+ gems = %w[kettle-deps-floor kettle-dev kettle-family kettle-rb kettle-test kettle-soup-cover kettle-changelog]
15948
16157
  plugin_names = PluginLoader.normalize_plugin_names(plugin_names_from_config(config))
15949
16158
  gems.concat(plugin_names.select { |plugin_name| plugin_name.start_with?("kettle-") })
15950
16159
  gems.uniq.join(" ")
@@ -15953,7 +16162,7 @@ module Kettle
15953
16162
  def main_gemfile_kettle_family_gem(package_name)
15954
16163
  return "" if package_name.to_s == "kettle-family"
15955
16164
 
15956
- %(gem "kettle-family", "~> 1.3", ">= 1.3.29"\n)
16165
+ %(gem "kettle-family", "~> 1.3", ">= 1.3.36"\n)
15957
16166
  end
15958
16167
 
15959
16168
  def main_gemfile_nomono_bootstrap(package_name)
@@ -15966,7 +16175,7 @@ module Kettle
15966
16175
  end
15967
16176
 
15968
16177
  def nomono_gemfile_declaration
15969
- %(gem "nomono", "~> 1.1", ">= 1.1.14", require: false # ruby >= 3.2.0)
16178
+ %(gem "nomono", "~> 1.1", ">= 1.1.17", require: false # ruby >= 3.2.0)
15970
16179
  end
15971
16180
 
15972
16181
  def local_gemfile_nomono_bootstrap(_package_name)
@@ -15974,11 +16183,16 @@ module Kettle
15974
16183
  end
15975
16184
 
15976
16185
  def nomono_bundler_bootstrap(lockfile_path)
16186
+ # Derive the activation floor from TEMPLATE_MANAGED_DEPENDENCIES (the same
16187
+ # source of truth as the main-Gemfile declaration) so the two can never
16188
+ # drift apart when the floor is bumped.
16189
+ nomono_managed = TEMPLATE_MANAGED_DEPENDENCIES.find { |dependency| dependency.fetch(:name) == "nomono" }
16190
+ nomono_floor_requirements = nomono_managed.fetch(:requirements).inspect
15977
16191
  <<~RUBY.rstrip
15978
16192
  # Bootstrapping nomono here cannot rely on a plain `gem "nomono", ...` line.
15979
16193
  # Bundler records that dependency during Gemfile evaluation, but it does not
15980
16194
  # activate that exact version before the immediate `require "nomono/bundler"`.
15981
- nomono_activation_requirements = ["~> 1.1", ">= 1.1.14"]
16195
+ nomono_activation_requirements = #{nomono_floor_requirements}
15982
16196
  nomono_requirement = Gem::Requirement.new(nomono_activation_requirements)
15983
16197
  nomono_already_activated = Gem.loaded_specs["nomono"]
15984
16198
  nomono_lockfile = File.expand_path(#{lockfile_path.inspect}, __dir__)
@@ -17503,7 +17717,8 @@ module Kettle
17503
17717
  sponsors = readme_corporate_sponsors(config, env)
17504
17718
  compact_hash(
17505
17719
  entries: sponsors,
17506
- row: readme_corporate_sponsors_row(sponsors)
17720
+ row: readme_corporate_sponsors_row(sponsors),
17721
+ detail: readme_corporate_sponsors_detail(sponsors)
17507
17722
  )
17508
17723
  end
17509
17724
 
@@ -17513,7 +17728,25 @@ module Kettle
17513
17728
  readme_config["corporate_sponsors"] || readme_config["sponsors"]
17514
17729
  )
17515
17730
  inherited = normalize_readme_corporate_sponsors_json(env["KETTLE_JEM_CORPORATE_SPONSORS_JSON"])
17516
- deduplicate_readme_corporate_sponsors(configured + inherited)
17731
+ sponsors = deduplicate_readme_corporate_sponsors(configured + inherited)
17732
+ today = Date.today
17733
+ sponsors
17734
+ .select { |sponsor| readme_corporate_sponsor_active?(sponsor, today) }
17735
+ .map do |sponsor|
17736
+ next sponsor unless sponsor[:members]
17737
+
17738
+ sponsor.merge(members: sponsor[:members].select { |member| readme_corporate_sponsor_active?(member, today) })
17739
+ end
17740
+ end
17741
+
17742
+ def readme_corporate_sponsor_active?(sponsor, today)
17743
+ since = sponsor[:since]
17744
+ return false if since && since > today
17745
+
17746
+ until_date = sponsor[:until]
17747
+ return false if until_date && until_date < today
17748
+
17749
+ true
17517
17750
  end
17518
17751
 
17519
17752
  def normalize_readme_corporate_sponsors_json(value)
@@ -17529,24 +17762,68 @@ module Kettle
17529
17762
  def normalize_readme_corporate_sponsors(value)
17530
17763
  Array(value).filter_map do |entry|
17531
17764
  unless entry.is_a?(Hash)
17532
- raise ArgumentError, "corporate sponsor entries must be mappings with name, url, and img_src"
17765
+ raise ArgumentError, "corporate sponsor entries must be mappings with name, target_href, and image_src (legacy url and img_src are also accepted)"
17533
17766
  end
17534
17767
 
17535
17768
  name = entry["name"].to_s.strip
17536
- url = entry["url"].to_s.strip
17537
- img_src = entry["img_src"].to_s.strip
17769
+ url = (entry["target_href"] || entry["url"]).to_s.strip
17770
+ img_src = (entry["image_src"] || entry["img_src"]).to_s.strip
17538
17771
  if name.empty? || url.empty? || img_src.empty?
17539
- raise ArgumentError, "corporate sponsor entries require name, url, and img_src"
17772
+ raise ArgumentError, "corporate sponsor entries require name, target_href, and image_src (legacy url and img_src are also accepted)"
17540
17773
  end
17541
17774
 
17542
17775
  {
17543
17776
  name: name,
17544
17777
  url: url,
17545
- img_src: img_src
17546
- }
17778
+ img_src: img_src,
17779
+ since: normalize_readme_corporate_sponsor_date(entry["since"], "since", name),
17780
+ until: normalize_readme_corporate_sponsor_date(entry["until"], "until", name),
17781
+ members: normalize_readme_corporate_sponsor_members(entry["members"], name)
17782
+ }.compact
17783
+ end
17784
+ end
17785
+
17786
+ # Members of an umbrella sponsor (e.g. the companies behind Alpha-Omega).
17787
+ # Members require name and url; img_src is optional because members render
17788
+ # as linked names in a caption, not as logos. Members may not themselves
17789
+ # have members — nesting stops at one level.
17790
+ def normalize_readme_corporate_sponsor_members(value, sponsor_name)
17791
+ return nil if value.nil?
17792
+
17793
+ Array(value).filter_map do |entry|
17794
+ unless entry.is_a?(Hash)
17795
+ raise ArgumentError, "corporate sponsor #{sponsor_name.inspect} members must be mappings with name and target_href (legacy url is also accepted)"
17796
+ end
17797
+ unless entry["members"].nil?
17798
+ raise ArgumentError, "corporate sponsor #{sponsor_name.inspect} members cannot themselves have members"
17799
+ end
17800
+
17801
+ name = entry["name"].to_s.strip
17802
+ url = (entry["target_href"] || entry["url"]).to_s.strip
17803
+ img_src = (entry["image_src"] || entry["img_src"]).to_s.strip
17804
+ if name.empty? || url.empty?
17805
+ raise ArgumentError, "corporate sponsor #{sponsor_name.inspect} members require name and target_href (legacy url is also accepted)"
17806
+ end
17807
+
17808
+ {
17809
+ name: name,
17810
+ url: url,
17811
+ img_src: img_src.empty? ? nil : img_src,
17812
+ since: normalize_readme_corporate_sponsor_date(entry["since"], "since", name),
17813
+ until: normalize_readme_corporate_sponsor_date(entry["until"], "until", name)
17814
+ }.compact
17547
17815
  end
17548
17816
  end
17549
17817
 
17818
+ def normalize_readme_corporate_sponsor_date(value, key, name)
17819
+ clean = value.to_s.strip
17820
+ return nil if clean.empty?
17821
+
17822
+ Date.iso8601(clean)
17823
+ rescue ArgumentError, TypeError
17824
+ raise ArgumentError, "corporate sponsor #{name.inspect} has invalid #{key} date #{clean.inspect} (expected ISO 8601, e.g. 2026-01-31)"
17825
+ end
17826
+
17550
17827
  def deduplicate_readme_corporate_sponsors(sponsors)
17551
17828
  sponsors
17552
17829
  .group_by { |sponsor| [sponsor[:name], sponsor[:url], sponsor[:img_src]] }
@@ -17569,6 +17846,32 @@ module Kettle
17569
17846
  %(<p><sub>#{label} #{logos} <a href="#-floss-funding">Become a sponsor</a></sub></p>)
17570
17847
  end
17571
17848
 
17849
+ # Markdown detail block for the FLOSS Funding section: one bullet per
17850
+ # active sponsor with its logo and linked name. Umbrella sponsors append a
17851
+ # "backed by" caption of linked member names (text links, not logos, so
17852
+ # the backing relationship stays explicit in every rendering context).
17853
+ def readme_corporate_sponsors_detail(sponsors)
17854
+ return "" if sponsors.empty?
17855
+
17856
+ lines = sponsors.map do |sponsor|
17857
+ name = sponsor.fetch(:name)
17858
+ url = sponsor.fetch(:url)
17859
+ img_src = sponsor.fetch(:img_src)
17860
+ line = [
17861
+ %(- <a href="#{html_attribute_escape(url)}">),
17862
+ %(<img alt="#{html_attribute_escape(name)}" src="#{html_attribute_escape(img_src)}" height="32"/>),
17863
+ %(</a> [#{name}](#{url}))
17864
+ ].join
17865
+ members = Array(sponsor[:members])
17866
+ unless members.empty?
17867
+ member_links = members.map { |member| "[#{member.fetch(:name)}](#{member.fetch(:url)})" }.join(", ")
17868
+ line = "#{line} — backed by #{member_links}"
17869
+ end
17870
+ line
17871
+ end
17872
+ lines.join("\n")
17873
+ end
17874
+
17572
17875
  def readme_top_logo_options(config)
17573
17876
  readme_logo_options_from_config(config, "top_logos", "top_logo_options", README_TOP_LOGO_DEFAULTS)
17574
17877
  end
@@ -17869,7 +18172,8 @@ module Kettle
17869
18172
 
17870
18173
  def readme_corporate_sponsor_template_tokens(readme_sponsors)
17871
18174
  {
17872
- "KJ|README:CORPORATE_SPONSORS" => readme_sponsors[:row].to_s
18175
+ "KJ|README:CORPORATE_SPONSORS" => readme_sponsors[:row].to_s,
18176
+ "KJ|README:CORPORATE_SPONSORS_DETAIL" => readme_sponsors[:detail].to_s
17873
18177
  }
17874
18178
  end
17875
18179
 
@@ -18946,6 +19250,7 @@ module Kettle
18946
19250
  result["ruby"] = {} unless result["ruby"].is_a?(Hash)
18947
19251
  result["ruby"]["test_minimum"] = config_test_min_ruby(result, facts.dig(:rubygems, :min_ruby)).to_s
18948
19252
  result["resolved_licenses"] = license[:spdx]
19253
+ result["resolved_duel"] = true if duel_configured?(config) && duel_template_profile?(facts[:template_profile])
18949
19254
  if facts[:template_profile].to_s == SHIM_TEMPLATE_PROFILE
18950
19255
  result["templates"] = default_template_config.merge(result["templates"].is_a?(Hash) ? result["templates"] : {})
18951
19256
  result["templates"]["profile"] = SHIM_TEMPLATE_PROFILE
@@ -19083,7 +19388,7 @@ module Kettle
19083
19388
  relative_path = path.delete_prefix("#{project_root}/")
19084
19389
  next unless preferred_template_source(PACKAGED_TEMPLATE_ROOT, relative_path)
19085
19390
 
19086
- {target_path: relative_path} if skip_packaged_gemfile_template?(relative_path, config, project_root: project_root)
19391
+ {target_path: relative_path} if skip_packaged_gemfile_template?(relative_path, config)
19087
19392
  end
19088
19393
  (gemfile_cleanups + disabled_integration_config_cleanups(project_root, config)).sort_by { |cleanup| cleanup.fetch(:target_path) }
19089
19394
  end
@@ -19167,8 +19472,9 @@ module Kettle
19167
19472
  end
19168
19473
  return if source_path.to_s.empty? || target_path.to_s.empty?
19169
19474
  return if skip_packaged_workflow_template?(target_path, config, include_patterns: include_patterns)
19475
+ return if skip_duel_template?(target_path, config)
19170
19476
  return if skip_disabled_integration_template?(target_path, config)
19171
- return if skip_packaged_gemfile_template?(target_path, config, project_root: project_root, template_root_path: template_root.fetch(:path))
19477
+ return if skip_packaged_gemfile_template?(target_path, config, template_root_path: template_root.fetch(:path))
19172
19478
  return if skip_packaged_license_template?(target_path, config)
19173
19479
  return if skip_packaged_version_gem_entrypoint_template?(project_root, source_path, target_path, config)
19174
19480
  return if template_root.fetch(:kind) == "packaged" && opencollective_disabled && opencollective_disabled_file?(target_path)
@@ -19404,10 +19710,18 @@ module Kettle
19404
19710
  Gem::Version.new(version) < min_ruby
19405
19711
  end
19406
19712
 
19407
- def skip_packaged_gemfile_template?(target_path, config, project_root: nil, template_root_path: PACKAGED_TEMPLATE_ROOT)
19713
+ def skip_packaged_gemfile_template?(target_path, config, template_root_path: PACKAGED_TEMPLATE_ROOT)
19408
19714
  return true if target_path.to_s.include?("gemfiles/modular/recording/") && !appraisal_recording_enabled?(config)
19409
19715
 
19410
- return false if packaged_gemfile_referenced_by_x_std_libs?(target_path, project_root: project_root, template_root_path: template_root_path)
19716
+ inventory = packaged_x_std_lib_template_inventory(template_root_path)
19717
+ x_std_bucket = inventory.fetch(:targets)[target_path.to_s.delete_prefix("gemfiles/modular/")]
19718
+ if x_std_bucket
19719
+ min_ruby = config_test_min_ruby(config)
19720
+ return false unless min_ruby
19721
+
19722
+ relevant = Kettle::Rb::RubySeriesBuckets.relevant_buckets(inventory.fetch(:buckets), minimum: min_ruby)
19723
+ return !relevant.include?(x_std_bucket)
19724
+ end
19411
19725
 
19412
19726
  version = packaged_gemfile_template_ruby_floor(target_path)
19413
19727
  return false unless version
@@ -19418,19 +19732,50 @@ module Kettle
19418
19732
  Gem::Version.new(version) < min_ruby
19419
19733
  end
19420
19734
 
19421
- def packaged_gemfile_referenced_by_x_std_libs?(target_path, project_root:, template_root_path:)
19422
- relative_path = target_path.to_s.delete_prefix("gemfiles/modular/")
19423
- return false unless relative_path.start_with?("x_std_libs/")
19735
+ def packaged_x_std_lib_template_inventory(template_root_path)
19736
+ root_key = File.expand_path(template_root_path.to_s)
19737
+ if root_key == File.expand_path(PACKAGED_TEMPLATE_ROOT)
19738
+ # Process-lifetime memo of an immutable inventory; recomputation is
19739
+ # idempotent, so a racing rebuild is harmless.
19740
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
19741
+ @packaged_x_std_lib_template_inventory ||= build_packaged_x_std_lib_template_inventory(root_key)
19742
+ return @packaged_x_std_lib_template_inventory
19743
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
19744
+ end
19745
+
19746
+ build_packaged_x_std_lib_template_inventory(root_key)
19747
+ end
19748
+
19749
+ def build_packaged_x_std_lib_template_inventory(template_root_path)
19750
+ modular_root = File.join(template_root_path.to_s, "gemfiles/modular")
19751
+ seeds = Dir.glob(File.join(modular_root, "x_std_libs/**/*.gemfile.example"))
19752
+ aggregate = File.join(modular_root, "x_std_libs.gemfile.example")
19753
+ seeds << aggregate if File.file?(aggregate)
19424
19754
 
19425
- aggregate_paths = [
19426
- File.join(template_root_path.to_s, "gemfiles/modular/x_std_libs.gemfile.example"),
19427
- File.join(project_root.to_s, "gemfiles/modular/x_std_libs.gemfile")
19428
- ].select { |path| File.file?(path) }
19429
- aggregate_paths.any? do |path|
19430
- gemfile_eval_paths(File.read(path)).include?(relative_path)
19755
+ queue = seeds
19756
+ visited = Set.new
19757
+ targets = {}
19758
+ buckets = Set.new
19759
+ until queue.empty?
19760
+ source = queue.pop
19761
+ next unless File.file?(source)
19762
+ next unless visited.add?(source)
19763
+
19764
+ relative = source.delete_prefix("#{modular_root}/").delete_suffix(".example")
19765
+ bucket = relative.split("/").find { |segment| segment.match?(/\Ar\d+(?:\.\d+)?\z/) }
19766
+ if bucket
19767
+ targets[relative] = bucket
19768
+ buckets << bucket
19769
+ end
19770
+
19771
+ gemfile_eval_paths(File.read(source)).each do |reference|
19772
+ resolved = File.expand_path(reference, File.dirname(source))
19773
+ candidate = "#{resolved}.example"
19774
+ queue << candidate if candidate.start_with?("#{modular_root}/") && File.file?(candidate)
19775
+ end
19431
19776
  end
19432
- rescue
19433
- false
19777
+
19778
+ {targets: targets, buckets: buckets.to_a}
19434
19779
  end
19435
19780
 
19436
19781
  def skip_disabled_integration_template?(target_path, config)
@@ -19869,6 +20214,27 @@ module Kettle
19869
20214
  DecisionPolicy.value_to_boolean(workflows["recording"]) == true
19870
20215
  end
19871
20216
 
20217
+ # Whether the project opts in to Duel's performance regression guards
20218
+ # with `workflows.duel: true`. Duel is off by default.
20219
+ def duel_configured?(config)
20220
+ workflows = config["workflows"]
20221
+ workflows = {} unless workflows.is_a?(Hash)
20222
+
20223
+ DecisionPolicy.value_to_boolean(workflows["duel"]) == true
20224
+ end
20225
+
20226
+ # Duel's templates belong to a gem's full template: the monorepo and shim
20227
+ # profiles have no Perf workflow to compare it in.
20228
+ def duel_template_profile?(template_profile)
20229
+ ["", FULL_TEMPLATE_PROFILE].include?(normalize_template_profile(template_profile))
20230
+ end
20231
+
20232
+ # While Duel is off, its templates are neither written nor cleaned up, so
20233
+ # a project's own .duel.rb, perf.yml, or duel.gemfile is left as it is.
20234
+ def skip_duel_template?(target_path, config)
20235
+ DUEL_TEMPLATE_PATHS.include?(target_path.to_s) && config["resolved_duel"] != true
20236
+ end
20237
+
19872
20238
  def project_recording_enabled?(project_root, config)
19873
20239
  return true if appraisal_recording_enabled?(config)
19874
20240
 
@@ -19954,8 +20320,9 @@ module Kettle
19954
20320
  end
19955
20321
  end
19956
20322
 
19957
- def synchronize_github_funding_yml(content, facts)
20323
+ def synchronize_github_funding_yml(content, facts, seed_if_empty: false)
19958
20324
  output = content.to_s
20325
+ output = github_funding_yml_seed(facts) if seed_if_empty && output.strip.empty?
19959
20326
  enabled_platforms = facts.fetch(:funding, {}).fetch(:platforms, {})
19960
20327
  enabled_platforms = enabled_platforms.merge("open_collective" => false) if facts.fetch(:funding, {})[:open_collective_disabled]
19961
20328
  FUNDING_YML_PLATFORMS.each do |platform|
@@ -19963,16 +20330,35 @@ module Kettle
19963
20330
 
19964
20331
  output = remove_top_level_yaml_key_lines(output, platform)
19965
20332
  end
19966
- tidelift_value = "rubygems/#{facts.fetch(:package).fetch(:name)}"
19967
- if funding_platform_enabled?(enabled_platforms, "tidelift") && !yaml_top_level_key_lines(output).key?("tidelift")
19968
- lines = output.lines
19969
- lines << "\n" unless lines.empty? || lines.last.to_s.strip.empty?
19970
- lines << "tidelift: #{tidelift_value}\n"
19971
- output = lines.join
19972
- end
20333
+ # Tidelift is in limbo post-Sonar acquisition with no URLs to direct
20334
+ # people to, so the platform is no longer emitted. Strip any lingering
20335
+ # tidelift key from previously templated destinations.
20336
+ output = remove_top_level_yaml_key_lines(output, "tidelift")
19973
20337
  ensure_trailing_newline(output)
19974
20338
  end
19975
20339
 
20340
+ # Build first-run FUNDING.yml content structurally from facts (Psych dump
20341
+ # of the known platform keys), so cold-start scaffolds get a valid file
20342
+ # without line-munging the commented packaged template — that template
20343
+ # still reaches destinations unchanged via template_source_application on
20344
+ # configured runs, comments intact.
20345
+ def github_funding_yml_seed(facts)
20346
+ funding = facts.fetch(:funding, {})
20347
+ platform_tokens = funding.fetch(:platform_tokens, {})
20348
+ github_user = github_org_from_url(facts.dig(:package, :source_url)).to_s
20349
+ values = {
20350
+ "buy_me_a_coffee" => platform_tokens[:buymeacoffee].to_s,
20351
+ "github" => github_user.empty? ? nil : [github_user],
20352
+ "ko_fi" => platform_tokens[:kofi].to_s,
20353
+ "liberapay" => platform_tokens[:liberapay].to_s,
20354
+ "open_collective" => funding[:open_collective_org].to_s,
20355
+ "thanks_dev" => github_user.empty? ? nil : "u/gh/#{github_user}"
20356
+ }.reject { |_key, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
20357
+ return "" if values.empty?
20358
+
20359
+ "# These are supported funding model platforms\n\n#{Psych.dump(values)}"
20360
+ end
20361
+
19976
20362
  def remove_top_level_yaml_key_lines(content, key)
19977
20363
  lines = content.to_s.lines
19978
20364
  document = Psych.parse_stream(content.to_s).children.first
@@ -20606,7 +20992,7 @@ module Kettle
20606
20992
  steps << <<~YAML
20607
20993
  - name: Upload coverage to QLTY
20608
20994
  if: ${{ !env.ACT }}
20609
- uses: qltysh/qlty-action/coverage@c9b09987143d1e4ac955f4803c7bea742102feb5 # v2.4.0
20995
+ uses: qltysh/qlty-action/coverage@ebcd284bf32e36a030afe15f1e136c7be08b9506 # v2.5.0
20610
20996
  with:
20611
20997
  oidc: true
20612
20998
  files: coverage/lcov.info
@@ -20649,7 +21035,7 @@ module Kettle
20649
21035
 
20650
21036
  - name: Add Coverage PR Comment
20651
21037
  uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
20652
- if: ${{ !env.ACT && github.event_name == 'pull_request' }}
21038
+ if: ${{ !env.ACT && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
20653
21039
  with:
20654
21040
  recreate: true
20655
21041
  path: code-coverage-results.md
@@ -20901,19 +21287,20 @@ module Kettle
20901
21287
  "actions/checkout" => "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1",
20902
21288
  "actions/cache" => "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0",
20903
21289
  "appraisal-rb/setup-ruby-flash" => "appraisal-rb/setup-ruby-flash@6f8ad36ba7488db591541483afb5464d01e8861b # v2.14",
20904
- "ruby/setup-ruby" => "ruby/setup-ruby@b1da4809b005e0178fe08a1d57317bffc00b7b4c # v1.332.0",
21290
+ "ruby/setup-ruby" => "ruby/setup-ruby@de492cf36cbab201753d720ce4f4e336cd688c94 # v1.336.0",
20905
21291
  "coverallsapp/github-action" => "coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2.3.8",
20906
- "qltysh/qlty-action/coverage" => "qltysh/qlty-action/coverage@c9b09987143d1e4ac955f4803c7bea742102feb5 # v2.4.0",
21292
+ "qltysh/qlty-action/coverage" => "qltysh/qlty-action/coverage@ebcd284bf32e36a030afe15f1e136c7be08b9506 # v2.5.0",
20907
21293
  "codecov/codecov-action" => "codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1",
20908
21294
  "irongut/CodeCoverageSummary" => "irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0",
20909
21295
  "marocchino/sticky-pull-request-comment" => "marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5",
20910
21296
  "actions/upload-artifact" => "actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2",
21297
+ "actions/download-artifact" => "actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2",
20911
21298
  "amancevice/setup-code-climate" => "amancevice/setup-code-climate@0daf2985a225e8ac15975b4d233010e94d65b76a # v2",
20912
21299
  "actions/dependency-review-action" => "actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0",
20913
21300
  "github/codeql-action/init" => "github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1",
20914
21301
  "github/codeql-action/autobuild" => "github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1",
20915
21302
  "github/codeql-action/analyze" => "github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1",
20916
- "pozil/auto-assign-issue" => "pozil/auto-assign-issue@bec9ebf443592887b8f9bed643afafdfd3f62f1d # v4.0.2",
21303
+ "pozil/auto-assign-issue" => "pozil/auto-assign-issue@d6e511896e5f1d2408e74db74416647d8ff80c8c # v4.1.0",
20917
21304
  "apache/skywalking-eyes/dependency" => "apache/skywalking-eyes/dependency@a196742f472feaffafea537ce5a2a4c3c53a8de4 # v0.9.0",
20918
21305
  "sarisia/actions-status-discord" => "sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1.16.0"
20919
21306
  }