diff --git a/gems/kettle-jem/CHANGELOG.md b/gems/kettle-jem/CHANGELOG.md index 31a8ff7ab..36dae2159 100644 --- a/gems/kettle-jem/CHANGELOG.md +++ b/gems/kettle-jem/CHANGELOG.md @@ -21,6 +21,8 @@ Please file a bug if you notice a violation of semantic versioning. ### Added - kettle-jem: corporate sponsor configuration now accepts the explicit target_href and image_src keys alongside legacy url and img_src, preserving existing configs while supporting direct, date-bounded static sponsors. +- kettle-jem: opt-in Duel performance regression guards (https://github.com/sferik/duel-ruby), off by default. With `workflows.duel: true`, the full template generates the Perf workflow (`.github/workflows/perf.yml`), `gemfiles/modular/duel.gemfile` (Duel and StackProf on Ruby 3.4 or later only), a `perf` appraisal, the root Gemfile wiring, a `rake duel` task with a stub for older Rubies, the RuboCop include for `.duel.rb`, and the Duel README badge. It creates `.duel.rb` only when it is missing. While Duel is off, a project's own `.duel.rb`, `perf.yml`, and `duel.gemfile` are neither written nor removed. +- kettle-jem: `actions/download-artifact` joins the template workflow pins, for the Perf workflow's Comment job. ### Changed diff --git a/gems/kettle-jem/README.md b/gems/kettle-jem/README.md index fe62a97f6..d5e406deb 100644 --- a/gems/kettle-jem/README.md +++ b/gems/kettle-jem/README.md @@ -366,6 +366,39 @@ workflows: Do not use freeze markers for any of these declarations. The configuration is the source of truth, and templating keeps the root bundle synchronized with it. +### Duel Performance Regression Guards + +[Duel](https://github.com/sferik/duel-ruby) runs a gem's benchmarks on the +baseline and on the change side by side, and fails if the change is slower. +It is off by default. To opt in, set: + +```yaml +workflows: + duel: true +``` + +With Duel on, the full template generates: + +| File | What it holds | +|------|---------------| +| `.github/workflows/perf.yml` | A read-only Duel job that compares each pull request with the main branch, each push to `main` with the commit before it, and, on a schedule, the last release. A separate Comment job, with only `pull-requests: write` and none of the branch's code, posts the report on the pull request. When the baseline's version file names another Ruby, the workflow installs that Ruby too. | +| `gemfiles/modular/duel.gemfile` | Duel and StackProf, on Ruby 3.4 or later only, so older Rubies install neither. | +| `Appraisals` | A `perf` appraisal, whose generated `gemfiles/perf.gemfile` the Perf workflow bundles. | +| `Gemfile` | `eval_gemfile "gemfiles/modular/duel.gemfile"`, for `rake duel` in local development. | +| `Rakefile` | `rake duel`, with a stub task that only warns where Duel isn't installed. | +| `.rubocop.yml` | `.duel.rb` in `AllCops/Include` (RuboCop skips dotfiles otherwise), merged with RuboCop's own patterns. | +| `README.md` | The Duel badge, at the end of the badge row. | +| `.duel.rb` | Duel's configuration, created only when it is missing. It runs each version on the Ruby that its own version file names, and watches the benchmarks, `.duel.rb`, `mise.toml`, and the Perf workflow as well as `lib/` and `ext/`. After that it is the project's own. | + +The benchmarks in `duel/` are the project's to write: see +[Write a benchmark](https://github.com/sferik/duel-ruby#write-a-benchmark). +The template has already done what `duel init` would, so there is no need to +run it. + +While Duel is off, kettle-jem neither writes nor removes `.duel.rb`, +`.github/workflows/perf.yml`, or `gemfiles/modular/duel.gemfile`, so a +project that has its own keeps them as they are. + ### Strategies | Strategy | Behavior | diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index b8c61fd03..4bf1adf2f 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -90,6 +90,12 @@ class Error < StandardError; end ].freeze OPENCOLLECTIVE_DISABLED_FILES = %w[.opencollective.yml .github/workflows/opencollective.yml].freeze OPT_IN_GITHUB_WORKFLOWS = %w[.github/workflows/discord-notifier.yml].freeze + # Templates that exist only for projects that set `workflows.duel: true`. + # While Duel is off, kettle-jem neither writes nor removes these paths, so a + # project's own files at them stay exactly as they are. + DUEL_TEMPLATE_PATHS = %w[.duel.rb .github/workflows/perf.yml gemfiles/modular/duel.gemfile].freeze + DUEL_MAIN_GEMFILE_EVAL_PATH = "gemfiles/modular/duel.gemfile" + DUEL_APPRAISAL_NAME = "perf" OBSOLETE_APPRAISAL_SPEC_EXEC_CMDS = [ "env KETTLE_TEST_RUNNER=rspec kettle-test -I ../spec --options ../.rspec ../spec", "KETTLE_TEST_RUNNER=rspec kettle-test -I ../spec --options ../.rspec ../spec", @@ -230,9 +236,10 @@ class Error < StandardError; end "feature.dedicated_version_gem" => :boolean, "workflow.dep_heads" => :boolean, "workflow.jruby_94" => :boolean, + "workflow.duel" => :boolean, "version_gem.mode" => :enum }.freeze - COPY_ONLY_WHEN_MISSING_TEMPLATE_PATHS = %w[REEK bin/setup].freeze + COPY_ONLY_WHEN_MISSING_TEMPLATE_PATHS = %w[REEK bin/setup .duel.rb].freeze MONOREPO_ROOT_TEMPLATE_PROFILE = "monorepo-root" MONOREPO_SUBGEM_PACKAGE_TEMPLATE_PROFILE = "monorepo-subgem-package" MONOREPO_SUBGEM_RELEASE_TEMPLATE_PROFILE = "monorepo-subgem-release" @@ -576,6 +583,7 @@ class Error < StandardError; end KJ|MIN_RUBY KJ|KETTLE_CHANGELOG_GEMFILE_DEPENDENCY KJ|OPENCOLLECTIVE_ORG + KJ|RAKE:DUEL_TASK KJ|RAKE:TOP_LEVEL_DOCS_TASK KJ|README:COPYRIGHT_NOTICE KJ|README:CORPORATE_SPONSORS @@ -585,6 +593,8 @@ class Error < StandardError; end KJ|README:LICENSE_COMPAT_BADGE KJ|README:LICENSE_EYE_WORKFLOW_BADGE KJ|README:DEV_TEST_STACK_TABLE + KJ|README:DUEL_BADGE + KJ|README:DUEL_REFS KJ|README:FOSSA_BADGE KJ|README:FOSSA_REFS KJ|README:H2_SYNOPSIS_LOGO_ROW @@ -1496,6 +1506,7 @@ def camelize(value) README_LICENSE_EYE_WORKFLOW_LINK_LABELS = ["🚎15-🪪-wf", "🚎15-🪪-wfi"].freeze README_FOSSA_BADGE = "[![FOSSA Status][🧪fossa-img]][🧪fossa]" README_FOSSA_LINK_LABELS = ["🧪fossa", "🧪fossa-img"].freeze + README_DUEL_BADGE = "[![Duel][⚔️duel-img]][⚔️duel]" LICENSE_EYE_COMPATIBILITY_LICENSES = %w[MIT].freeze SKYWALKING_EYES_INTEGRATION = "skywalking-eyes" README_OPEN_COLLECTIVE_FUNDING_BADGES = "[![OpenCollective Backers][🖇osc-backers-i]][🖇osc-backers] [![OpenCollective Sponsors][🖇osc-sponsors-i]][🖇osc-sponsors]" @@ -3198,6 +3209,7 @@ def discover_facts(project_root, env: ENV, run_options: {}) custom_workflows: github_actions_custom_workflows(project_root, template_config, opencollective_disabled: opencollective_disabled) } standard_appraisal_gemfiles = github_actions_standard_appraisal_gemfiles(kettle_config) + facts[:ci][:duel] = true if template_config["resolved_duel"] facts[:ci][:standard_appraisal_gemfiles] = standard_appraisal_gemfiles unless standard_appraisal_gemfiles.empty? facts[:ci][:opt_in_workflow_cleanups] = opt_in_workflows unless opt_in_workflows.empty? facts[:ci][:inactive_packaged_workflow_cleanups] = inactive_workflows unless inactive_workflows.empty? @@ -6042,6 +6054,7 @@ def transfer_changelog_context(project_root, env: ENV) "feature.dedicated_version_gem" => version_gem_mode == "dedicated" || File.file?(File.join(project_root, "lib", entrypoint, "version_gem.rb")), "workflow.dep_heads" => File.file?(File.join(project_root, ".github", "workflows", "dep-heads.yml")), "workflow.jruby_94" => File.file?(File.join(project_root, ".github", "workflows", "jruby-9.4.yml")), + "workflow.duel" => duel_configured?(config) && duel_template_profile?(profile), "version_gem.mode" => version_gem_mode } end @@ -6844,7 +6857,7 @@ def apply_template_source(project_root, recipe, original, facts: nil, env: ENV, content = recipe_template_content(project_root, recipe, template_contents: template_contents) return finalize_github_workflow_template(content, facts) if strategy == "raw_copy" && github_workflow_template_recipe?(recipe) - return finalize_template_source_content(recipe, content) if strategy == "raw_copy" + return finalize_template_source_content(recipe, content, facts: facts) if strategy == "raw_copy" resolved = recipe.fetch(:resolved_template_content) do resolve_template_tokens( @@ -6899,7 +6912,7 @@ def apply_template_source(project_root, recipe, original, facts: nil, env: ENV, return preserve_newer_github_workflow_action_pins(merged, original) end - return finalize_template_source_content(recipe, merged) + return finalize_template_source_content(recipe, merged, facts: facts) end if strategy == "accept_template" accepted = finalize_accepted_template_source(recipe, resolved, original, facts: facts, project_root: project_root) @@ -6919,7 +6932,7 @@ def apply_template_source(project_root, recipe, original, facts: nil, env: ENV, ) end - return finalize_template_source_content(recipe, accepted) + return finalize_template_source_content(recipe, accepted, facts: facts) end resolved = finalize_github_workflow_template(resolved, facts) if github_workflow_template_recipe?(recipe) @@ -6933,11 +6946,11 @@ def apply_template_source(project_root, recipe, original, facts: nil, env: ENV, ) end - finalize_template_source_content(recipe, resolved) + finalize_template_source_content(recipe, resolved, facts: facts) end - def finalize_template_source_content(recipe, content) - return finalize_rubocop_config(content) if recipe.fetch(:target_path).to_s == ".rubocop.yml" + def finalize_template_source_content(recipe, content, facts: nil) + return finalize_rubocop_config(content, facts) if recipe.fetch(:target_path).to_s == ".rubocop.yml" return finalize_kettle_config_template_source(recipe, content) if recipe.fetch(:target_path).to_s == KETTLE_CONFIG_PATH return normalize_simplecov_template_source(content) if recipe.fetch(:target_path).to_s == ".simplecov" return normalize_spec_helper_simplecov_template_source(content) if recipe.fetch(:target_path).to_s == "spec/spec_helper.rb" @@ -6951,9 +6964,77 @@ def finalize_kettle_config_template_source(recipe, content) assert_no_unresolved_template_tokens_in_yaml_values(resolved, KETTLE_CONFIG_PATH) end - def finalize_rubocop_config(content) + def finalize_rubocop_config(content, facts = nil) finalized = remove_yaml_scalar_path(content, %w[AllCops TargetRubyVersion]) - ensure_yaml_top_level_sequence_items(finalized, "plugins", ["rubocop-rspec"]) + finalized = ensure_yaml_top_level_sequence_items(finalized, "plugins", ["rubocop-rspec"]) + return finalized unless facts.to_h.dig(:ci, :duel) + + ensure_rubocop_duel_config(finalized) + end + + # Duel's configuration is a dotfile, which RuboCop skips unless it is + # included. Merging Include, as Exclude is, keeps RuboCop's own patterns. + def ensure_rubocop_duel_config(content) + merged = ensure_yaml_child_sequence_items( + content, + "inherit_mode", + "merge", + ["Include"], + comment: "# Merge Include too, so .duel.rb (a dotfile) is added to the defaults" + ) + ensure_yaml_child_sequence_items( + merged, + "AllCops", + "Include", + [".duel.rb"], + comment: "# Duel's configuration is a dotfile, which RuboCop skips unless included" + ) + end + + # Ensures that the block sequence at parent_key.key holds items, adding + # the parent mapping or the key when missing. The comment goes above the + # added key, or above the added items when the key exists. A flow + # sequence or mapping is left alone. + def ensure_yaml_child_sequence_items(content, parent_key, key, items, comment:) + document = Psych.parse_stream(content.to_s).children.first + root = document&.root + return content unless root.is_a?(Psych::Nodes::Mapping) + + lines = content.to_s.lines + parent_key_node, parent_node = root.children.each_slice(2).find do |candidate_key, _candidate_value| + candidate_key.is_a?(Psych::Nodes::Scalar) && candidate_key.value.to_s == parent_key.to_s + end + unless parent_key_node + lines << "\n" unless lines.empty? || lines.last.strip.empty? + lines << "#{parent_key}:\n" << " #{comment}\n" << " #{key}:\n" + items.each { |item| lines << " - #{item}\n" } + return lines.join + end + return content unless parent_node.is_a?(Psych::Nodes::Mapping) && parent_node.style == Psych::Nodes::Mapping::BLOCK + return content if parent_node.children.empty? + + key_node, value_node = parent_node.children.each_slice(2).find do |candidate_key, _candidate_value| + candidate_key.is_a?(Psych::Nodes::Scalar) && candidate_key.value.to_s == key.to_s + end + unless key_node + indent = lines.fetch(parent_node.children.first.start_line)[/\A\s*/] + added = ["#{indent}#{comment}\n", "#{indent}#{key}:\n", *items.map { |item| "#{indent} - #{item}\n" }] + lines.insert(parent_key_node.start_line + 1, *added) + return lines.join + end + return content unless value_node.is_a?(Psych::Nodes::Sequence) && value_node.style == Psych::Nodes::Sequence::BLOCK + return content if value_node.children.empty? + + existing = value_node.children.filter_map { |child| child.value.to_s if child.is_a?(Psych::Nodes::Scalar) } + missing = items.map(&:to_s) - existing + return content if missing.empty? + + last = value_node.children.last + indent = lines.fetch(last.start_line)[/\A\s*/] + lines.insert(last.end_line + 1, "#{indent}#{comment}\n", *missing.map { |item| "#{indent}- #{item}\n" }) + lines.join + rescue Psych::Exception + content end def finalize_accepted_template_source(recipe, content, destination_content, facts:, project_root: nil) @@ -6961,7 +7042,7 @@ def finalize_accepted_template_source(recipe, content, destination_content, fact when :gemfile finalize_gemfile_template_source(recipe, content, destination_content, facts: facts, template_content: content) when :appraisals - merged = merge_appraisals_template_policy(content, facts: facts) + merged = merge_appraisals_template_policy(content, facts: facts, target_path: recipe.fetch(:target_path)) if recipe.fetch(:target_path).to_s == "Appraisal.root.gemfile" normalize_appraisal_root_templating_gate(merged, facts) else @@ -7642,7 +7723,7 @@ def merge_config_template_source(recipe, template_content, destination_content, if file_type == :gemfile return finalize_gemfile_template_source(recipe, template_content, destination_content, facts: facts, template_content: template_content) end - return merge_appraisals_template_policy(template_content, facts: facts) if file_type == :appraisals + return merge_appraisals_template_policy(template_content, facts: facts, target_path: recipe.fetch(:target_path)) if file_type == :appraisals return finalize_github_workflow_template(prune_github_workflow_matrix_by_min_ruby(template_content, facts), facts) if github_workflow_template_recipe?(recipe) return template_content @@ -7651,7 +7732,7 @@ def merge_config_template_source(recipe, template_content, destination_content, if file_type == :gemfile return finalize_gemfile_template_source(recipe, destination_content, destination_content, facts: facts, template_content: template_content) end - return merge_appraisals_template_policy(destination_content, facts: facts) if file_type == :appraisals + return merge_appraisals_template_policy(destination_content, facts: facts, target_path: recipe.fetch(:target_path)) if file_type == :appraisals return destination_content end @@ -7698,7 +7779,7 @@ def merge_config_template_source(recipe, template_content, destination_content, output = merge_gemfile_eval_bucket_entries(template_content, output, destination_content: destination_content) return finalize_gemfile_template_source(recipe, output, destination_content, facts: facts, template_content: template_content) end - return merge_appraisals_template_policy(output, facts: facts) if file_type == :appraisals + return merge_appraisals_template_policy(output, facts: facts, target_path: recipe.fetch(:target_path)) if file_type == :appraisals output = finalize_github_workflow_template(prune_github_workflow_matrix_by_min_ruby(output, facts), facts) if github_workflow_template_recipe?(recipe) output = normalize_simplecov_template_source(output) if recipe.fetch(:target_path).to_s == ".simplecov" @@ -7759,6 +7840,7 @@ def finalize_gemfile_template_source(recipe, content, destination_content, facts output = preserve_destination_gemfile_source(output, destination_content) end output = inject_main_gemfile_recording_eval(output, facts) + output = inject_main_gemfile_duel_eval(output, facts) output = inject_main_gemfile_default_test_bundle(output, facts) output = remove_stale_main_gemfile_tree_sitter_language_pack(output, template_content) output = remove_stale_main_gemfile_direct_sibling_block(output, template_content) @@ -9412,6 +9494,34 @@ def inject_main_gemfile_recording_eval(content, facts) ensure_trailing_newline(lines.join.gsub(/\n{3,}/, "\n\n")) end + # Duel for local development (`rake duel`), from its modular Gemfile, which + # installs nothing below Ruby 3.4. CI loads it from the perf appraisal. + def inject_main_gemfile_duel_eval(content, facts) + return content unless facts.to_h.dig(:ci, :duel) + return content if gemfile_eval_paths(content).include?(DUEL_MAIN_GEMFILE_EVAL_PATH) + + lines = content.to_s.lines + insert_at = main_gemfile_duel_insertion_index(content) + unless insert_at + lines << "\n" unless lines.empty? || lines.last.strip.empty? + insert_at = lines.length + end + lines.insert(insert_at, "# Performance regression guards (Duel requires Ruby 3.4 or later)\n", %(eval_gemfile "#{DUEL_MAIN_GEMFILE_EVAL_PATH}"\n), "\n") + ensure_trailing_newline(lines.join.gsub(/\n{3,}/, "\n\n")) + end + + # After the optional dependencies, before the extracted standard library + # gems. + def main_gemfile_duel_insertion_index(content) + lines = content.to_s.lines + call = ruby_call_records(content, :eval_gemfile).find do |candidate| + ruby_string_argument(candidate).to_s == "gemfiles/modular/x_std_libs.gemfile" + end + return unless call + + gemfile_eval_comment_start_line(lines, call.location.start_line) - 1 + end + # The root Gemfile is the local-development bundle. Appraisals and CI use # their own Gemfiles, so a project that needs a framework or application # harness locally must declare that dependency separately in test_bundle. @@ -10507,11 +10617,12 @@ def ruby_bucket_path_segment?(segment) version.split(".", -1).all? { |part| !part.empty? && part.each_char.all? { |char| char.between?("0", "9") } } end - def merge_appraisals_template_policy(content, facts:) + def merge_appraisals_template_policy(content, facts:, target_path: nil) package_name = facts.dig(:package, :name).to_s if facts min_ruby = minimum_ruby_token(facts.dig(:ci, :test_min_ruby) || facts.dig(:rubygems, :min_ruby)) if facts with_framework_appraisals = merge_framework_matrix_appraisals(content, facts) with_standard_appraisal_gemfiles = inject_standard_appraisal_gemfiles(with_framework_appraisals, facts) + with_standard_appraisal_gemfiles = inject_duel_appraisal(with_standard_appraisal_gemfiles, facts) if target_path.to_s == "Appraisals" pruned = prune_appraisals_recording_entries(with_standard_appraisal_gemfiles, facts) pruned = prune_appraisals_below_min_ruby(pruned, min_ruby) pruned = remove_managed_stdlib_appraisal_gems(pruned) @@ -10666,6 +10777,26 @@ def runtime_heads_eval_gemfile_call(block) end end + # The perf appraisal, whose generated gemfiles/perf.gemfile the Perf + # workflow bundles: the gemspec's dependencies and Duel. An existing perf + # appraisal is the project's, and is kept as it is. + def inject_duel_appraisal(content, facts) + return content unless facts.to_h.dig(:ci, :duel) + return content if appraisal_call_records(content).any? { |record| record.fetch(:name) == DUEL_APPRAISAL_NAME } + + # (The comments are inside the block: Appraisals keeps no comment + # between blocks.) + block = <<~RUBY + appraise "#{DUEL_APPRAISAL_NAME}" do + # Performance regression guards, for .github/workflows/perf.yml + # (Duel requires Ruby 3.4 or later) + eval_gemfile "modular/duel.gemfile" + eval_gemfile "modular/x_std_libs.gemfile" + end + RUBY + ensure_trailing_newline([content.to_s.rstrip, block].reject(&:empty?).join("\n\n")) + end + def prune_appraisals_recording_entries(content, facts) remove_indexes = Set.new lines = content.to_s.lines @@ -14047,7 +14178,8 @@ def github_actions_custom_workflows(project_root, config = {}, opencollective_di next if opencollective_disabled && opencollective_disabled_file?(relative_path) next if inactive_packaged_workflow?(relative_path, config) next if generated_or_obsolete_github_workflow?(relative_path) && - !skip_packaged_workflow_template?(relative_path, config) + !skip_packaged_workflow_template?(relative_path, config) && + !skip_duel_template?(relative_path, config) relative_path end.sort @@ -14851,6 +14983,7 @@ def template_tokens(facts, funding) readme_family_intro_and_backend_matrix(facts.fetch(:readme_style, {})) tokens["KJ|README:DEV_TEST_STACK_TABLE"] = readme_dev_test_stack_table(package.fetch(:name).to_s) tokens.merge!(readme_fossa_template_tokens(facts.fetch(:readme_style, {}))) + tokens.merge!(duel_template_tokens(facts)) tokens.merge!(rubyforum_template_tokens(facts.fetch(:rubyforum, {}))) tokens.merge!(version_gem_template_tokens(facts)) tokens.merge!(shim_template_tokens(facts.fetch(:shim, {}))) @@ -14942,6 +15075,41 @@ def shim_template_tokens(shim) } end + # The README badge and the Rake task, for a project with Duel on. They are + # empty otherwise, which leaves its README and Rakefile as they were. + def duel_template_tokens(facts) + unless facts.dig(:ci, :duel) + return {"KJ|RAKE:DUEL_TASK" => "", "KJ|README:DUEL_BADGE" => "", "KJ|README:DUEL_REFS" => ""} + end + + { + "KJ|RAKE:DUEL_TASK" => rake_duel_task_token, + "KJ|README:DUEL_BADGE" => README_DUEL_BADGE, + "KJ|README:DUEL_REFS" => [ + "[⚔️duel]: https://github.com/sferik/duel-ruby", + "[⚔️duel-img]: https://raw.githubusercontent.com/sferik/duel-ruby/main/assets/badge.svg" + ].join("\n") + } + end + + def rake_duel_task_token + <<~RAKE + ### PERFORMANCE TASKS + # Duel's performance regression guards: rake duel compares this tree with + # the main branch (see .duel.rb and duel/). Duel requires Ruby 3.4 or + # later, so older Rubies get a stub task. + begin + require "duel/rake_task" + Duel::RakeTask.new + rescue LoadError + desc("(stub) duel is unavailable") + task("duel") do + warn("NOTE: duel isn't installed, or is disabled for \#{RUBY_VERSION} in the current environment") + end + end + RAKE + end + def readme_fossa_template_tokens(readme_style) project = readme_style.fetch(:fossa_project, "").to_s return {"KJ|README:FOSSA_BADGE" => "", "KJ|README:FOSSA_REFS" => ""} if project.empty? @@ -19067,6 +19235,7 @@ def template_runtime_config(config, facts, license: {}) result["ruby"] = {} unless result["ruby"].is_a?(Hash) result["ruby"]["test_minimum"] = config_test_min_ruby(result, facts.dig(:rubygems, :min_ruby)).to_s result["resolved_licenses"] = license[:spdx] + result["resolved_duel"] = true if duel_configured?(config) && duel_template_profile?(facts[:template_profile]) if facts[:template_profile].to_s == SHIM_TEMPLATE_PROFILE result["templates"] = default_template_config.merge(result["templates"].is_a?(Hash) ? result["templates"] : {}) result["templates"]["profile"] = SHIM_TEMPLATE_PROFILE @@ -19288,6 +19457,7 @@ def template_source_preference(project_root, template_root, entry, config, openc end return if source_path.to_s.empty? || target_path.to_s.empty? return if skip_packaged_workflow_template?(target_path, config, include_patterns: include_patterns) + return if skip_duel_template?(target_path, config) return if skip_disabled_integration_template?(target_path, config) return if skip_packaged_gemfile_template?(target_path, config, template_root_path: template_root.fetch(:path)) return if skip_packaged_license_template?(target_path, config) @@ -20029,6 +20199,27 @@ def appraisal_recording_enabled?(config) DecisionPolicy.value_to_boolean(workflows["recording"]) == true end + # Whether the project opts in to Duel's performance regression guards + # with `workflows.duel: true`. Duel is off by default. + def duel_configured?(config) + workflows = config["workflows"] + workflows = {} unless workflows.is_a?(Hash) + + DecisionPolicy.value_to_boolean(workflows["duel"]) == true + end + + # Duel's templates belong to a gem's full template: the monorepo and shim + # profiles have no Perf workflow to compare it in. + def duel_template_profile?(template_profile) + ["", FULL_TEMPLATE_PROFILE].include?(normalize_template_profile(template_profile)) + end + + # While Duel is off, its templates are neither written nor cleaned up, so + # a project's own .duel.rb, perf.yml, or duel.gemfile is left as it is. + def skip_duel_template?(target_path, config) + DUEL_TEMPLATE_PATHS.include?(target_path.to_s) && config["resolved_duel"] != true + end + def project_recording_enabled?(project_root, config) return true if appraisal_recording_enabled?(config) @@ -21088,6 +21279,7 @@ def github_actions_step_pins "irongut/CodeCoverageSummary" => "irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0", "marocchino/sticky-pull-request-comment" => "marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5", "actions/upload-artifact" => "actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2", + "actions/download-artifact" => "actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2", "amancevice/setup-code-climate" => "amancevice/setup-code-climate@0daf2985a225e8ac15975b4d233010e94d65b76a # v2", "actions/dependency-review-action" => "actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0", "github/codeql-action/init" => "github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1", diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.duel.rb.example b/gems/kettle-jem/lib/kettle/jem/templates/.duel.rb.example new file mode 100644 index 000000000..a40189d04 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.duel.rb.example @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Duel's configuration: https://github.com/sferik/duel-ruby/blob/main/docs/configuration.md +# +# Duel follows conventions, so set only what differs: benchmarks are in +# duel/**/*_duel.rb, each version's lib/ is first on the load path, a +# change is compared when it touches lib/ or ext/, and releases are +# tagged v*. +# +# kettle-jem creates this file when it is missing, and leaves it alone +# after that, so it is this project's to change. +Duel.configure do |c| + # Each version runs on the Ruby that its own version file names, so a + # branch that upgrades Ruby is compared on each version's own Ruby. + c.ruby = :version_file + # Compare a change that touches what's measured or how, not only lib/ and + # ext/: the benchmarks and this file, the Ruby that mise.toml names, and + # the Perf workflow. (Each version runs the candidate's benchmarks.) + c.watch = %w[lib ext duel .duel.rb mise.toml .github/workflows/perf.yml] +end diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/perf.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/perf.yml.example new file mode 100644 index 000000000..15933e687 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/perf.yml.example @@ -0,0 +1,175 @@ +name: Perf + +# Performance regression guards: Duel runs the benchmarks in duel/ on the +# baseline and on this code side by side, and fails if this code is slower. +# See .duel.rb and https://github.com/sferik/duel-ruby + +# The Duel job runs this branch's code, so it only reads. Commenting is a +# job of its own, which runs none of it. +permissions: + contents: read + +on: + pull_request: + push: + branches: + - 'main' + schedule: + - cron: "0 4 * * *" + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels a pull request's previous runs that have not yet completed. Every +# other run is a group of its own, so none is cancelled: each push to main +# is compared with the commit before it, and a cancelled run would skip +# that comparison. +concurrency: + group: "${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}" + cancel-in-progress: true + +jobs: + duel: + if: "github.event_name != 'push' || (!contains(github.event.head_commit.message, '[ci skip]') && !contains(github.event.head_commit.message, '[skip ci]'))" + name: Duel + runs-on: ubuntu-latest + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + # The perf appraisal: the gemspec's dependencies and Duel (Ruby 3.4 or later) + BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/perf.gemfile + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # the baseline comes from history, tags included + persist-credentials: false + + # .duel.rb sets c.ruby = :version_file, so each version runs on the Ruby + # that its own version file names (.ruby-version, .tool-versions, or + # mise.toml, the first that names one, as Duel reads them). When the + # baseline's names another Ruby than this one's, install that Ruby too, + # and link it where Duel finds it. The baseline is the one the Compare + # step uses: the main branch for a pull request, the commit before a + # push, the last release on a schedule (the newest v* tag before HEAD, + # as Duel finds it), and where HEAD left the default branch when run by + # hand. + - name: Find the baseline's Ruby + id: baseline + env: + BEFORE: ${{ github.event.before }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + case "$GITHUB_EVENT_NAME" in + pull_request) base="origin/$GITHUB_BASE_REF" ;; + push) base="$BEFORE" ;; + schedule) base=$(git describe --tags --abbrev=0 --match 'v*' HEAD^ 2>/dev/null) || base= ;; + *) base=$(git merge-base HEAD "origin/$DEFAULT_BRANCH" 2>/dev/null) || base= ;; + esac + ruby_of() { + for file in .ruby-version .tool-versions mise.toml .mise.toml; do + text=$(git show "$1:$file" 2>/dev/null) || continue + case "$file" in + .ruby-version) version=$(printf '%s\n' "$text" | awk 'NF { print $1; exit }') ;; + .tool-versions) version=$(printf '%s\n' "$text" | awk '$1 == "ruby" { print $2; exit }') ;; + *) version=$(printf '%s\n' "$text" | awk '/^\[/ { tools = ($0 ~ /^\[tools\]/) } tools && match($0, /^[ \t]*"?ruby"?[ \t]*=[ \t]*[[{]?[ \t]*(version[ \t]*=[ \t]*)?"[^"]+"/) { s = substr($0, RSTART, RLENGTH); sub(/"$/, "", s); sub(/.*"/, "", s); print s; exit }') ;; + esac + if [ -n "$version" ]; then + echo "$version" + return + fi + done + } + ruby= + if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then + ruby=$(ruby_of "$base") + [ "$ruby" = "$(ruby_of HEAD)" ] && ruby= + fi + echo "ruby=$ruby" >> "$GITHUB_OUTPUT" + + - name: Setup the baseline's Ruby + id: baseline-ruby + if: steps.baseline.outputs.ruby != '' + uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # v1.335.0 + with: + ruby-version: ${{ steps.baseline.outputs.ruby }} + # The bundle for this Ruby too, which the baseline's workers load. + # (Gems installed for one Ruby version don't load on another.) + bundler-cache: true + + - name: Link the baseline's Ruby where Duel finds it + if: steps.baseline.outputs.ruby != '' + env: + PREFIX: ${{ steps.baseline-ruby.outputs.ruby-prefix }} + run: | + mkdir -p ~/.rubies + ln -sfn "$PREFIX" ~/.rubies/ruby-"$("$PREFIX/bin/ruby" -e 'print RUBY_VERSION')" + + # The candidate's Ruby, from its version file (the same one Duel reads) + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # v1.335.0 + with: + bundler-cache: true + + # Against the main branch for a pull request, what was there before + # for a push, and the last release on a schedule (slow creep). Duel + # adds its report to the job's summary, and writes it to duel.md for + # the comment. + - name: Compare + env: + DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || '' }} + # (Run by hand, it compares anyway: with nothing changed, the code + # with itself, which checks the setup.) + DUEL_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }} + run: | + # (A branch's first push, or one that rewrote history, has no + # commit before it here to compare against.) + if [ "$GITHUB_EVENT_NAME" = push ] && ! git cat-file -e "$DUEL_BASE^{commit}" 2>/dev/null; then + echo "::notice::No commit before this push to compare against" + exit 0 + fi + bundle exec duel --markdown "$RUNNER_TEMP/duel.md" + + # The report, for the Comment job + - name: Keep the report + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: duel-report + path: ${{ runner.temp }}/duel.md + if-no-files-found: ignore # none when the comparison didn't run + retention-days: 1 + + # The report, as a comment on the pull request: one comment, updated on + # every push. This job checks nothing out and runs none of the branch's + # code, so it alone can write. (A pull request from a fork gets no + # comment, as its token can't write one: the Duel job's summary has the + # report.) + comment: + name: Comment + needs: duel + if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + permissions: + pull-requests: write # to comment on the pull request with the report + steps: + - name: Get the report + id: report + continue-on-error: true # the Duel job keeps no report when it fails before comparing + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: duel-report + path: ${{ runner.temp }}/report + + - name: Comment on the pull request + if: steps.report.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + marker="" + { echo "$marker"; cat "$RUNNER_TEMP/report/duel.md"; } > "$RUNNER_TEMP/comment.md" + ids=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id") + if [ -n "$ids" ]; then + gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/${ids%%$'\n'*}" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null + else + gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null + fi diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.structuredmerge/kettle-jem.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.structuredmerge/kettle-jem.yml.example index d172dbca8..3c8ad8618 100644 --- a/gems/kettle-jem/lib/kettle/jem/templates/.structuredmerge/kettle-jem.yml.example +++ b/gems/kettle-jem/lib/kettle/jem/templates/.structuredmerge/kettle-jem.yml.example @@ -265,6 +265,17 @@ workflows: # Include VCR/WebMock recording modular gemfiles in generated Appraisals. # Enable only for projects that actually require recording support. recording: false + # Duel performance regression guards (https://github.com/sferik/duel-ruby). + # Off by default. Set `duel: true` to generate the Perf workflow + # (.github/workflows/perf.yml), gemfiles/modular/duel.gemfile (loaded by + # the root Gemfile and a `perf` appraisal), the `rake duel` task, the + # RuboCop include for .duel.rb, and the Duel README badge. .duel.rb is + # created only when it is missing, and the benchmarks in duel/ are the + # project's own to write. Duel requires Ruby 3.4 or later: on older + # Rubies it is not installed, and `rake duel` only warns. While Duel is + # off, kettle-jem neither writes nor removes .duel.rb, perf.yml, or + # duel.gemfile, so a project's own copies stay as they are. + # duel: true # preset: framework # framework_matrix: # dimension: activerecord diff --git a/gems/kettle-jem/lib/kettle/jem/templates/CHANGELOG.transfer.md b/gems/kettle-jem/lib/kettle/jem/templates/CHANGELOG.transfer.md index 437c4f1b5..6805c660d 100644 --- a/gems/kettle-jem/lib/kettle/jem/templates/CHANGELOG.transfer.md +++ b/gems/kettle-jem/lib/kettle/jem/templates/CHANGELOG.transfer.md @@ -22,7 +22,8 @@ Available fields are `profile`, `topology`, `ruby.min`, `engine.jruby`, `feature.rubyforum`, `feature.rubyforum_project_tag`, `feature.corporate_sponsors`, `feature.organization_logo`, `feature.structuredmerge_driver`, `feature.dedicated_version_gem`, -`workflow.dep_heads`, `workflow.jruby_94`, and `version_gem.mode`. Boolean +`workflow.dep_heads`, `workflow.jruby_94`, `workflow.duel`, and +`version_gem.mode`. Boolean fields accept `true` or `false`; `ruby.min` accepts normal version comparisons; the other fields accept `=` or `!=`. @@ -46,6 +47,10 @@ the other fields accept `=` or `!=`. `engines:` and has no template-managed modular home (e.g. `sqlite3` on `jruby`). Review each entry and pick a resolution per the project's own engine support needs. +- kettle-jem-template-20261025-001 [if workflow.duel=true] - Duel performance regression guards: the + Perf workflow compares each pull request, each push to main, and, on a + schedule, the last release, and `rake duel` compares local changes, with + the benchmarks in `duel/`. ## Changed diff --git a/gems/kettle-jem/lib/kettle/jem/templates/README.md.example b/gems/kettle-jem/lib/kettle/jem/templates/README.md.example index 45a923600..fb6f67480 100644 --- a/gems/kettle-jem/lib/kettle/jem/templates/README.md.example +++ b/gems/kettle-jem/lib/kettle/jem/templates/README.md.example @@ -4,7 +4,7 @@ # {KJ|PROJECT_EMOJI} {KJ|README:TITLE} -[![Version][👽versioni]][👽version] [![Ruby Users Forum][✉️ruby-forum-top-img]][✉️ruby-forum] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] {KJ|README:LICENSE_BADGE} [![Total downloads][👽dl-ranki]][👽dl-rank] [![CodeCov Test Coverage][🏀codecovi]][🏀codecov] [![Coveralls Test Coverage][🏀coveralls-img]][🏀coveralls] [![QLTY Test Coverage][🏀qlty-covi]][🏀qlty-cov] [![QLTY Maintainability][🏀qlty-mnti]][🏀qlty-mnt] [![CI Heads][🚎3-hd-wfi]][🚎3-hd-wf] [![CI Runtime Dependencies @ HEAD][🚎12-crh-wfi]][🚎12-crh-wf] [![CI Current][🚎11-c-wfi]][🚎11-c-wf] [![CI Truffle Ruby][🚎9-t-wfi]][🚎9-t-wf] [![CI JRuby][🚎10-j-wfi]][🚎10-j-wf] [![Deps Locked][🚎13-🔒️-wfi]][🚎13-🔒️-wf] [![Deps Unlocked][🚎14-🔓️-wfi]][🚎14-🔓️-wf] [![CI Test Coverage][🚎2-cov-wfi]][🚎2-cov-wf] [![CI Style][🚎5-st-wfi]][🚎5-st-wf] {KJ|README:LICENSE_EYE_WORKFLOW_BADGE} {KJ|README:FOSSA_BADGE} +[![Version][👽versioni]][👽version] [![Ruby Users Forum][✉️ruby-forum-top-img]][✉️ruby-forum] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] {KJ|README:LICENSE_BADGE} [![Total downloads][👽dl-ranki]][👽dl-rank] [![CodeCov Test Coverage][🏀codecovi]][🏀codecov] [![Coveralls Test Coverage][🏀coveralls-img]][🏀coveralls] [![QLTY Test Coverage][🏀qlty-covi]][🏀qlty-cov] [![QLTY Maintainability][🏀qlty-mnti]][🏀qlty-mnt] [![CI Heads][🚎3-hd-wfi]][🚎3-hd-wf] [![CI Runtime Dependencies @ HEAD][🚎12-crh-wfi]][🚎12-crh-wf] [![CI Current][🚎11-c-wfi]][🚎11-c-wf] [![CI Truffle Ruby][🚎9-t-wfi]][🚎9-t-wf] [![CI JRuby][🚎10-j-wfi]][🚎10-j-wf] [![Deps Locked][🚎13-🔒️-wfi]][🚎13-🔒️-wf] [![Deps Unlocked][🚎14-🔓️-wfi]][🚎14-🔓️-wf] [![CI Test Coverage][🚎2-cov-wfi]][🚎2-cov-wf] [![CI Style][🚎5-st-wfi]][🚎5-st-wf] {KJ|README:LICENSE_EYE_WORKFLOW_BADGE} {KJ|README:FOSSA_BADGE} {KJ|README:DUEL_BADGE} `if ci_badges.map(&:color).detect { it != "green"}` ☝️ [let me know on Discord][✉️discord-invite] or [RubyForum][✉️ruby-forum], as I may have missed the notification. @@ -511,6 +511,7 @@ Thanks for RTFM. ☺️ [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat {KJ|README:LICENSE_REFS} {KJ|README:FOSSA_REFS} +{KJ|README:DUEL_REFS} [📄ilo-declaration]: https://www.ilo.org/declaration/lang--en/index.htm [📄ilo-declaration-img]: https://img.shields.io/badge/ILO_Fundamental_Principles-✓-259D6C.svg?style=flat [🚎yard-current]: http://rubydoc.info/gems/{KJ|GEM_NAME} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/Rakefile.example b/gems/kettle-jem/lib/kettle/jem/templates/Rakefile.example index 6256b5d86..eac92f441 100644 --- a/gems/kettle-jem/lib/kettle/jem/templates/Rakefile.example +++ b/gems/kettle-jem/lib/kettle/jem/templates/Rakefile.example @@ -153,6 +153,7 @@ if kettle_jem_selftest_unavailable_note end end +{KJ|RAKE:DUEL_TASK} ### RELEASE TASKS # Setup stone_checksums begin diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/duel.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/duel.gemfile.example new file mode 100644 index 000000000..7d13e8c9b --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/duel.gemfile.example @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# To retain during kettle-jem templating: +# {KJ|FREEZE_TOKEN}:freeze +# # ... your code +# {KJ|FREEZE_TOKEN}:unfreeze +# + +# Performance regression guards (see .duel.rb and duel/) +# Duel requires Ruby 3.4 or later, so older Rubies install neither gem. +if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4") + gem "duel", "~> 0.1", ">= 0.1.0" # ruby >= 3.4 + + # For profiles of regressions (StackProf runs on CRuby only) + platform :mri do + gem "stackprof", "~> 0.2", ">= 0.2.28" + end +end diff --git a/gems/kettle-jem/spec/integration/duel_template_spec.rb b/gems/kettle-jem/spec/integration/duel_template_spec.rb new file mode 100644 index 000000000..c53be35c8 --- /dev/null +++ b/gems/kettle-jem/spec/integration/duel_template_spec.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +RSpec.describe Kettle::Jem, "Duel performance regression guards" do + include_context "with isolated kettle-jem environment" + + let(:duel_paths) { %w[.duel.rb .github/workflows/perf.yml gemfiles/modular/duel.gemfile] } + let(:shared_paths) { %w[Appraisals Gemfile Rakefile .rubocop.yml README.md] } + + def duel_project(root, duel: nil, files: {}) + workflows = duel.nil? ? "" : "workflows:\n duel: #{duel}\n" + entries = (duel_paths + shared_paths).map { |path| " - #{path}\n" }.join + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.version = "1.0.0" + spec.summary = "Example gem" + spec.authors = ["Example Maintainer"] + spec.email = ["maintainer@example.com"] + spec.homepage = "https://github.com/example/example" + spec.licenses = ["MIT"] + spec.required_ruby_version = ">= 2.3" + end + RUBY + ".kettle-jem.yml" => "#{workflows}templates:\n root: packaged\n apply: true\n entries:\n#{entries}" + }.merge(files)) + end + + def report_for(result, path) + result.fetch(:recipe_reports).find do |report| + report.fetch(:relative_path) == path && report.fetch(:recipe_name).start_with?("template_source_application_") + end + end + + def with_project(name) + tmp_root = File.expand_path("../tmp", __dir__) + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-duel-#{name}", tmp_root) { |root| yield root } + end + + it "generates nothing for Duel while workflows.duel is absent" do + with_project("absent") do |root| + duel_project(root) + + plan = described_class.plan_project(root, env: {}) + described_class.apply_project(root, env: {}) + + expect(plan.fetch(:facts).fetch(:ci)).not_to have_key(:duel) + duel_paths.each do |path| + expect(report_for(plan, path)).to be_nil, path + expect(File).not_to exist(File.join(root, path)), path + end + expect(File.read(File.join(root, "Gemfile"))).not_to include("duel") + expect(File.read(File.join(root, "Appraisals"))).not_to include('appraise "perf"') + expect(File.read(File.join(root, "Rakefile"))).not_to include("duel") + expect(File.read(File.join(root, ".rubocop.yml"))).not_to include(".duel.rb") + expect(File.read(File.join(root, "README.md"))).not_to include("duel") + end + end + + it "renders the shared files identically whether workflows.duel is absent or false" do + outputs = %w[absent false].to_h do |setting| + with_project("output-#{setting}") do |root| + duel_project(root, duel: (setting == "false") ? false : nil) + described_class.apply_project(root, env: {}) + [setting, project_files(root, shared_paths)] + end + end + + expect(outputs.fetch("false")).to eq(outputs.fetch("absent")) + end + + it "leaves a project's own Duel files alone while workflows.duel is false" do + own = { + ".duel.rb" => "# frozen_string_literal: true\n\nDuel.configure { |c| c.watch = %w[lib] }\n", + ".github/workflows/perf.yml" => "name: Perf\n\npermissions:\n contents: read\n\non:\n push:\n branches: [main]\n\nconcurrency:\n group: perf\n cancel-in-progress: true\n\njobs:\n duel:\n runs-on: ubuntu-latest\n steps:\n - run: echo own\n", + "gemfiles/modular/duel.gemfile" => "# frozen_string_literal: true\n\ngem \"duel\"\n" + } + with_project("own-off") do |root| + duel_project(root, duel: false, files: own) + + plan = described_class.plan_project(root, env: {}) + apply = described_class.apply_project(root, env: {}) + + expect(plan.dig(:facts, :ci, :custom_workflows)).to include(".github/workflows/perf.yml") + expect(plan.dig(:facts, :ci, :inactive_packaged_workflow_cleanups).to_a).not_to include(".github/workflows/perf.yml") + expect(plan.dig(:facts, :templates, :inactive_packaged_template_cleanups).to_a).not_to include(target_path: "gemfiles/modular/duel.gemfile") + duel_paths.each { |path| expect(report_for(plan, path)).to be_nil, path } + expect(apply.fetch(:changed_files)).not_to include(*duel_paths) + expect(project_files(root, own.keys)).to eq(own.transform_keys(&:to_sym)) + end + end + + it "generates the Perf workflow, modular Gemfile, appraisal, Rake task, RuboCop include, and badge when workflows.duel is true" do + with_project("on") do |root| + duel_project(root, duel: true) + + plan = described_class.plan_project(root, env: {}) + described_class.apply_project(root, env: {}) + files = project_files(root, duel_paths + shared_paths) + + expect(plan.dig(:facts, :ci, :duel)).to be(true) + expect(plan.dig(:facts, :ci, :custom_workflows)).not_to include(".github/workflows/perf.yml") + + workflow = files.fetch(:".github/workflows/perf.yml") + expect(workflow).to eq(File.read(File.join(described_class::PACKAGED_TEMPLATE_ROOT, ".github/workflows/perf.yml.example"))) + workflow_yaml = YAML.safe_load(workflow) + expect(workflow_yaml.fetch("permissions")).to eq("contents" => "read") + expect(workflow_yaml.dig("jobs", "duel", "env", "BUNDLE_GEMFILE")).to eq("${{ github.workspace }}/gemfiles/perf.gemfile") + expect(workflow_yaml.dig("jobs", "duel")).not_to have_key("permissions") + expect(workflow_yaml.dig("jobs", "comment", "permissions")).to eq("pull-requests" => "write") + expect(workflow_yaml.dig("jobs", "comment", "steps").map { |step| step["uses"] }.compact).to all(start_with("actions/download-artifact@")) + checkout = workflow_yaml.dig("jobs", "duel", "steps").find { |step| step["uses"].to_s.start_with?("actions/checkout@") } + expect(checkout.fetch("with")).to include("fetch-depth" => 0, "persist-credentials" => false) + baseline_ruby = workflow_yaml.dig("jobs", "duel", "steps").find { |step| step["id"] == "baseline-ruby" } + expect(baseline_ruby.fetch("with")).to include("bundler-cache" => true) + %w[actions/checkout ruby/setup-ruby actions/upload-artifact actions/download-artifact].each do |action| + expect_pinned_action(workflow, action) + end + + expect(files.fetch(:"gemfiles/modular/duel.gemfile")).to include(%(if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4"))) + expect_gem_dependency_declared(files.fetch(:"gemfiles/modular/duel.gemfile"), "duel") + expect_gem_dependency_declared(files.fetch(:"gemfiles/modular/duel.gemfile"), "stackprof") + expect(files.fetch(:".duel.rb")).to include("c.ruby = :version_file") + expect(files.fetch(:Gemfile).scan(%(eval_gemfile "gemfiles/modular/duel.gemfile")).size).to eq(1) + expect(files.fetch(:Gemfile).index("gemfiles/modular/duel.gemfile")).to be < files.fetch(:Gemfile).index("gemfiles/modular/x_std_libs.gemfile") + expect(appraisals_eval_gemfile_paths(files.fetch(:Appraisals), "perf")).to eq(%w[modular/duel.gemfile modular/x_std_libs.gemfile]) + expect(files.fetch(:Rakefile)).to include(%(require "duel/rake_task"\n Duel::RakeTask.new\nrescue LoadError)) + expect(files.fetch(:Rakefile).index("### PERFORMANCE TASKS")).to be < files.fetch(:Rakefile).index("### RELEASE TASKS") + rubocop = YAML.safe_load(files.fetch(:".rubocop.yml")) + expect(rubocop.dig("inherit_mode", "merge")).to include("Exclude", "Include") + expect(rubocop.dig("AllCops", "Include")).to eq([".duel.rb"]) + expect(files.fetch(:"README.md").lines.find { |line| line.start_with?("[![Version]") }.rstrip).to end_with(described_class::README_DUEL_BADGE) + expect(files.fetch(:"README.md")).to include("[⚔️duel]: https://github.com/sferik/duel-ruby\n") + expect(files.fetch(:"README.md")).to include("[⚔️duel-img]: https://raw.githubusercontent.com/sferik/duel-ruby/main/assets/badge.svg\n") + + # (A second run appends README metadata whether or not Duel is on, so + # the README is checked for its badge only.) + stable_paths = duel_paths + shared_paths - ["README.md"] + second = described_class.apply_project(root, env: {}) + readme = File.read(File.join(root, "README.md")) + expect(second.fetch(:changed_files) & stable_paths).to be_empty + expect(project_files(root, stable_paths)).to eq(files.slice(*stable_paths.map(&:to_sym))) + expect(readme.scan(described_class::README_DUEL_BADGE).size).to eq(1) + expect(readme.scan("[⚔️duel]: ").size).to eq(1) + end + end + + it "keeps a project's own .duel.rb and perf appraisal when workflows.duel is true" do + own_config = "# frozen_string_literal: true\n\nDuel.configure { |c| c.library = \"example\" }\n" + own_appraisals = <<~RUBY + # frozen_string_literal: true + + appraise "perf" do + eval_gemfile "modular/duel.gemfile" + eval_gemfile "modular/redis.gemfile" + eval_gemfile "modular/x_std_libs.gemfile" + end + RUBY + with_project("own-on") do |root| + duel_project(root, duel: true, files: {".duel.rb" => own_config, "Appraisals" => own_appraisals}) + + plan = described_class.plan_project(root, env: {}) + described_class.apply_project(root, env: {}) + appraisals = File.read(File.join(root, "Appraisals")) + + expect(report_for(plan, ".duel.rb").dig(:metadata, :template_source_preference)).to include(strategy: "keep_destination", policy: "copy_only_when_missing") + expect(File.read(File.join(root, ".duel.rb"))).to eq(own_config) + expect(appraisals.scan('appraise "perf"').size).to eq(1) + expect(appraisals_eval_gemfile_paths(appraisals, "perf")).to include("modular/redis.gemfile") + end + end + + it "leaves Appraisal.root.gemfile without a perf appraisal" do + template = File.read(File.join(described_class::PACKAGED_TEMPLATE_ROOT, "Appraisal.root.gemfile.example")) + facts = {ci: {duel: true}} + + content = described_class.merge_appraisals_template_policy(template, facts: facts, target_path: "Appraisal.root.gemfile") + appraisals = described_class.merge_appraisals_template_policy(%(appraise "current" do\nend\n), facts: facts, target_path: "Appraisals") + + expect(content).not_to include('appraise "perf"') + expect(appraisals).to include('appraise "perf"') + end + + it "uses Duel only with the full template profile" do + config = {"workflows" => {"duel" => true}} + + expect(described_class.template_runtime_config(config, {template_profile: ""}).fetch("resolved_duel")).to be(true) + expect(described_class.template_runtime_config(config, {template_profile: "full"}).fetch("resolved_duel")).to be(true) + %w[monorepo-root monorepo-subgem-package monorepo-subgem-release monorepo-subgem-full].each do |profile| + expect(described_class.template_runtime_config(config, {template_profile: profile})).not_to have_key("resolved_duel"), profile + end + expect(described_class.duel_template_profile?("shim")).to be(false) + expect(described_class.template_runtime_config({"workflows" => {"duel" => "false"}}, {})).not_to have_key("resolved_duel") + end + + it "replays the Duel changelog entry only for a project with workflows.duel true" do + entry = described_class.changelog_transfer_entries(described_class::PACKAGED_TEMPLATE_ROOT).find do |candidate| + candidate.fetch(:key) == "kettle-jem-template-20261025-001" + end + + contexts = [nil, false, true].to_h do |setting| + with_project("transfer-#{setting.inspect}") do |root| + duel_project(root, duel: setting) + [setting, described_class.transfer_changelog_context(root)] + end + end + + expect(contexts.transform_values { |context| context.fetch("workflow.duel") }).to eq(nil => false, false => false, true => true) + expect(described_class.transfer_changelog_entry_applies?(entry, contexts.fetch(true))).to be(true) + expect(described_class.transfer_changelog_entry_applies?(entry, contexts.fetch(false))).to be(false) + end + + it "pins every action in the Perf workflow through the template pin table" do + workflow = File.read(File.join(described_class::PACKAGED_TEMPLATE_ROOT, ".github/workflows/perf.yml.example")) + pins = described_class.github_actions_step_pins.values + used = workflow.scan(/^\s+uses: (\S+@[0-9a-f]{40} # \S+)$/).flatten + + expect(used).not_to be_empty + expect(used - pins).to be_empty + end + + describe "the RuboCop include for .duel.rb" do + it "adds the parent mapping and the key when they are missing" do + content = described_class.ensure_rubocop_duel_config("plugins:\n - rubocop-rspec\n") + + expect(YAML.safe_load(content)).to include( + "inherit_mode" => {"merge" => ["Include"]}, + "AllCops" => {"Include" => [".duel.rb"]} + ) + end + + it "adds nothing that is already there" do + content = <<~YAML + inherit_mode: + merge: + - Exclude + - Include + + AllCops: + Include: + - '.duel.rb' + YAML + + expect(described_class.ensure_rubocop_duel_config(content)).to eq(content) + end + + it "leaves flow sequences alone" do + content = "inherit_mode:\n merge: [Exclude]\n\nAllCops:\n Include: ['**/*.rb']\n" + + expect(described_class.ensure_rubocop_duel_config(content)).to eq(content) + end + end +end