From 10cbefd4f1c695b61c7b803a440b7c219cd430b1 Mon Sep 17 00:00:00 2001 From: Peter Boling Date: Mon, 4 May 2026 02:11:12 -0600 Subject: [PATCH 01/71] docs: align public launch positioning --- README.md | 55 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3f7544c..def0951 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,22 @@ -# Structured Merge Ruby +# StructuredMerge Ruby -Monorepo for the new Ruby implementation of the Structured Merge library -family. +Ruby implementation of the StructuredMerge contract. -This repository is a fresh implementation aligned to the current -cross-language spec and fixture corpus. The older Ruby gems in this workspace -remain reference material only. +This repository is one of four peer launch implementations: [Go](https://github.com/structuredmerge/structuredmerge-go), [TypeScript](https://github.com/structuredmerge/structuredmerge-typescript), [Rust](https://github.com/structuredmerge/structuredmerge-rust), and [Ruby](https://github.com/structuredmerge/structuredmerge-ruby). The language repos are not separate products. They consume the same public spec and shared fixture corpus so tools can choose the runtime surface that fits their environment. -Initial planned Ruby packages: +Project links: + +- Website: +- Implementations overview: +- Conformance model: +- Specification: +- Shared fixtures: + +## Workspace + +This is a Ruby monorepo for StructuredMerge packages. + +Initial packages: - `tree-haver` - `ast-merge` @@ -15,20 +24,28 @@ Initial planned Ruby packages: - `json-merge` - `toml-merge` - `yaml-merge` -- `typescript-merge` -- `rust-merge` -- `go-merge` -- `ruby-merge` +- source-family packages for TypeScript, Rust, Go, and Ruby cases + +## Conformance + +Integration tests should consume the shared fixture corpus from the sibling `../structuredmerge-fixtures` checkout. A ruleset, fixture, diagnostic shape, or review outcome should mean the same thing whether exercised through Go, TypeScript, Rust, or Ruby. + +Use the spec repository's conformance matrix for the current launch-readiness snapshot: + +- +- ## Development -This repository follows the same slice-by-slice conformance path used by the -TypeScript, Rust, and Go monorepos. +Standard repo tasks are exposed through `mise` and native Ruby tooling. + +Common checks: + +- `mise run check` +- `bundle exec rake` or package-specific tests + +Bundler path gems are the default isolation mechanism inside this monorepo. When this repository needs to consume sibling workspace projects outside the monorepo itself, prefer `nomono`-driven Bundler wiring rather than manual Ruby load-path changes. -Integration tests should consume the shared fixture corpus from the sibling -`../fixtures` repository rather than copying fixture data into this monorepo. +## Status -Bundler path gems are the default isolation mechanism inside this monorepo. -When this repository needs to consume sibling workspace projects outside the -monorepo itself, prefer `nomono`-driven Bundler wiring rather than manual Ruby -load-path changes. +Early implementation work. Public compatibility claims should be tied to shared fixtures and documented conformance status rather than runtime-specific assumptions. From 2f3220c88d288470ef2990bd658dc40a70002e78 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 4 May 2026 13:43:03 -0600 Subject: [PATCH 02/71] Align Ruby gem versions for fixture cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Gemfile.lock | 32 +++++++++---------- gems/ast-template/lib/ast/template/version.rb | 2 +- .../lib/commonmarker/merge/version.rb | 2 +- .../lib/kramdown/merge/version.rb | 2 +- .../lib/markdown/merge/version.rb | 2 +- gems/markly-merge/lib/markly/merge/version.rb | 2 +- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0465199..4991898 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,8 +7,8 @@ PATH PATH remote: gems/ast-template specs: - ast-template (0.0.0) - ast-merge (= 0.0.0) + ast-template (0.1.0) + ast-merge (= 0.1.0) PATH remote: gems/citrus-toml-merge @@ -20,9 +20,9 @@ PATH PATH remote: gems/commonmarker-merge specs: - commonmarker-merge (0.0.0) + commonmarker-merge (0.1.0) commonmarker (~> 2.2) - markdown-merge (= 0.0.0) + markdown-merge (= 0.1.0) PATH remote: gems/go-merge @@ -41,22 +41,22 @@ PATH PATH remote: gems/kramdown-merge specs: - kramdown-merge (0.0.0) + kramdown-merge (0.1.0) kramdown (~> 2.5) - markdown-merge (= 0.0.0) + markdown-merge (= 0.1.0) PATH remote: gems/markdown-merge specs: - markdown-merge (0.0.0) - ast-merge (= 0.0.0) - tree_haver (= 0.0.0) + markdown-merge (0.1.0) + ast-merge (= 0.1.0) + tree_haver (= 0.1.0) PATH remote: gems/markly-merge specs: - markly-merge (0.0.0) - markdown-merge (= 0.0.0) + markly-merge (0.1.0) + markdown-merge (= 0.1.0) markly (~> 0.9) PATH @@ -196,19 +196,19 @@ DEPENDENCIES CHECKSUMS ast-merge (0.1.0) - ast-template (0.0.0) + ast-template (0.1.0) citrus (3.0.2) sha256=4ec2412fc389ad186735f4baee1460f7900a8e130ffe3f216b30d4f9c684f650 citrus-toml-merge (0.1.0) commonmarker (2.8.1-x86_64-linux) sha256=c8f2e903c6ee4e2c7e280aa8b49ef37c53202d388e3a2ee06dfdccc8c6fb634f - commonmarker-merge (0.0.0) + commonmarker-merge (0.1.0) diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 go-merge (0.1.0) json-merge (0.1.0) kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa - kramdown-merge (0.0.0) - markdown-merge (0.0.0) + kramdown-merge (0.1.0) + markdown-merge (0.1.0) markly (0.16.0) sha256=6f70d79e385b1efc9e171f74c81628826259039fe6c778e03c3924c71dac5511 - markly-merge (0.0.0) + markly-merge (0.1.0) parslet (2.0.0) sha256=d45130695d39b43d7e6a91f4d2ec66b388a8d822bae38de9b4de9a5fbde1f606 parslet-toml-merge (0.1.0) prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 diff --git a/gems/ast-template/lib/ast/template/version.rb b/gems/ast-template/lib/ast/template/version.rb index aab6b9a..3b4bad5 100644 --- a/gems/ast-template/lib/ast/template/version.rb +++ b/gems/ast-template/lib/ast/template/version.rb @@ -2,6 +2,6 @@ module Ast module Template - VERSION = "0.0.0" + VERSION = "0.1.0" end end diff --git a/gems/commonmarker-merge/lib/commonmarker/merge/version.rb b/gems/commonmarker-merge/lib/commonmarker/merge/version.rb index 976007c..8df1b1f 100644 --- a/gems/commonmarker-merge/lib/commonmarker/merge/version.rb +++ b/gems/commonmarker-merge/lib/commonmarker/merge/version.rb @@ -2,6 +2,6 @@ module Commonmarker module Merge - VERSION = "0.0.0" + VERSION = "0.1.0" end end diff --git a/gems/kramdown-merge/lib/kramdown/merge/version.rb b/gems/kramdown-merge/lib/kramdown/merge/version.rb index 5bb1799..83251bc 100644 --- a/gems/kramdown-merge/lib/kramdown/merge/version.rb +++ b/gems/kramdown-merge/lib/kramdown/merge/version.rb @@ -2,6 +2,6 @@ module Kramdown module Merge - VERSION = "0.0.0" + VERSION = "0.1.0" end end diff --git a/gems/markdown-merge/lib/markdown/merge/version.rb b/gems/markdown-merge/lib/markdown/merge/version.rb index 0cf825c..fe1b447 100644 --- a/gems/markdown-merge/lib/markdown/merge/version.rb +++ b/gems/markdown-merge/lib/markdown/merge/version.rb @@ -2,6 +2,6 @@ module Markdown module Merge - VERSION = "0.0.0" + VERSION = "0.1.0" end end diff --git a/gems/markly-merge/lib/markly/merge/version.rb b/gems/markly-merge/lib/markly/merge/version.rb index 3f09bfa..11379ec 100644 --- a/gems/markly-merge/lib/markly/merge/version.rb +++ b/gems/markly-merge/lib/markly/merge/version.rb @@ -2,6 +2,6 @@ module Markly module Merge - VERSION = "0.0.0" + VERSION = "0.1.0" end end From 58e6f526f4cbafedb5c3efd4225ec2ea7058072a Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 4 May 2026 13:45:49 -0600 Subject: [PATCH 03/71] Add Ruby coverage for policy alignment slices Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spec/fixtures_integration_spec.rb | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/gems/ast-merge/spec/fixtures_integration_spec.rb b/gems/ast-merge/spec/fixtures_integration_spec.rb index 0a47322..d182c83 100644 --- a/gems/ast-merge/spec/fixtures_integration_spec.rb +++ b/gems/ast-merge/spec/fixtures_integration_spec.rb @@ -1120,6 +1120,9 @@ def execute_from(executions) ruby_gemspec_version_loader_policy_acceptance_fixture = diagnostics_fixture("ruby_gemspec_version_loader_policy_acceptance") project_facts_runtime_context_fixture = diagnostics_fixture("project_facts_runtime_context") ruby_gemspec_self_dependency_policy_acceptance_fixture = diagnostics_fixture("ruby_gemspec_self_dependency_policy_acceptance") + ruby_gemfile_self_dependency_policy_acceptance_fixture = diagnostics_fixture("ruby_gemfile_self_dependency_policy_acceptance") + ruby_appraisals_self_dependency_policy_acceptance_fixture = diagnostics_fixture("ruby_appraisals_self_dependency_policy_acceptance") + ruby_appraisals_min_ruby_prune_policy_acceptance_fixture = diagnostics_fixture("ruby_appraisals_min_ruby_prune_policy_acceptance") structured_edit_callable_destination_request_fixture = diagnostics_fixture("structured_edit_callable_destination_request") structured_edit_parity_selection_semantics_fixture = diagnostics_fixture("structured_edit_parity_selection_semantics") structured_edit_parity_match_semantics_fixture = diagnostics_fixture("structured_edit_parity_match_semantics") @@ -2429,6 +2432,48 @@ def execute_from(executions) end end + ruby_gemfile_self_dependency_policy_acceptance_fixture[:cases].each do |entry| + if entry[:label] == "delete-gemfile-self-dependencies-across-nesting" + final_content = entry.dig(:report_envelope, :report, :final_content) + expect(final_content).not_to include('gem "demo", "~> 1.0"') + expect(final_content).not_to include('path: "../dev/demo"') + expect(final_content).to include('# gem "demo", "~> 0"') + expect(final_content).to include('gem "fallback-gem"') + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :metadata, :operation)).to eq("delete") + end + if entry[:label] == "missing-project-identity-fails-closed" + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :status)).to eq("failed") + end + end + + ruby_appraisals_self_dependency_policy_acceptance_fixture[:cases].each do |entry| + if entry[:label] == "delete-appraisals-self-dependencies" + final_content = entry.dig(:report_envelope, :report, :final_content) + expect(final_content).not_to include('gem "demo"') + expect(final_content).to include('appraise("rails-6")') + expect(final_content).to include('gem "rspec" # Testing') + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :metadata, :operation)).to eq("delete") + end + if entry[:label] == "missing-project-identity-fails-closed" + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :status)).to eq("failed") + end + end + + ruby_appraisals_min_ruby_prune_policy_acceptance_fixture[:cases].each do |entry| + if entry[:label] == "delete-ruby-appraisals-below-min-ruby" + final_content = entry.dig(:report_envelope, :report, :final_content) + expect(final_content).not_to include("ruby-2-3") + expect(final_content).not_to include("ruby-2-7") + expect(final_content).not_to include("ruby-3-0") + expect(final_content).to include("ruby-3-2") + expect(final_content).to include('appraise "style"') + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :metadata, :operation)).to eq("delete") + end + if entry[:label] == "missing-min-ruby-fails-closed" + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :status)).to eq("failed") + end + end + structured_edit_provider_execution_request_fixture[:cases].each do |entry| execution_request = described_class.structured_edit_provider_execution_request( request: entry.dig(:execution_request, :request), From edb2ee7a6f2199143f54a40f0048425479328beb Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 4 May 2026 14:03:50 -0600 Subject: [PATCH 04/71] Align Ruby fixtures to runtime facts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/ast-merge/spec/fixtures_integration_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gems/ast-merge/spec/fixtures_integration_spec.rb b/gems/ast-merge/spec/fixtures_integration_spec.rb index d182c83..c449589 100644 --- a/gems/ast-merge/spec/fixtures_integration_spec.rb +++ b/gems/ast-merge/spec/fixtures_integration_spec.rb @@ -1118,7 +1118,7 @@ def execute_from(executions) ruby_gemspec_dependency_section_policy_acceptance_fixture = diagnostics_fixture("ruby_gemspec_dependency_section_policy_acceptance") ruby_gemspec_files_policy_acceptance_fixture = diagnostics_fixture("ruby_gemspec_files_policy_acceptance") ruby_gemspec_version_loader_policy_acceptance_fixture = diagnostics_fixture("ruby_gemspec_version_loader_policy_acceptance") - project_facts_runtime_context_fixture = diagnostics_fixture("project_facts_runtime_context") + runtime_facts_context_fixture = diagnostics_fixture("runtime_facts_context") ruby_gemspec_self_dependency_policy_acceptance_fixture = diagnostics_fixture("ruby_gemspec_self_dependency_policy_acceptance") ruby_gemfile_self_dependency_policy_acceptance_fixture = diagnostics_fixture("ruby_gemfile_self_dependency_policy_acceptance") ruby_appraisals_self_dependency_policy_acceptance_fixture = diagnostics_fixture("ruby_appraisals_self_dependency_policy_acceptance") @@ -2397,7 +2397,7 @@ def execute_from(executions) ) end - project_facts_runtime_context_fixture[:cases].each do |entry| + runtime_facts_context_fixture[:cases].each do |entry| report = described_class.content_recipe_execution_report( request: entry.dig(:report_envelope, :report, :request), final_content: entry.dig(:report_envelope, :report, :final_content), @@ -2409,8 +2409,8 @@ def execute_from(executions) expect(json_ready(described_class.content_recipe_execution_report_envelope(report))).to eq( json_ready(entry[:report_envelope]) ) - expect(entry.dig(:report_envelope, :report, :request, :runtime_context, :project_facts, :schema)).to eq( - "project_facts.v1" + expect(entry.dig(:report_envelope, :report, :request, :runtime_context, :facts, :schema)).to eq( + "runtime_facts.v1" ) end From 0c4486e99534847194c8fa1ba879854d89b07c46 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 4 May 2026 14:45:44 -0600 Subject: [PATCH 05/71] Add Ruby coverage for CHANGELOG normalization slice Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/ast-merge/spec/fixtures_integration_spec.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/gems/ast-merge/spec/fixtures_integration_spec.rb b/gems/ast-merge/spec/fixtures_integration_spec.rb index c449589..fb9a8ac 100644 --- a/gems/ast-merge/spec/fixtures_integration_spec.rb +++ b/gems/ast-merge/spec/fixtures_integration_spec.rb @@ -1123,6 +1123,7 @@ def execute_from(executions) ruby_gemfile_self_dependency_policy_acceptance_fixture = diagnostics_fixture("ruby_gemfile_self_dependency_policy_acceptance") ruby_appraisals_self_dependency_policy_acceptance_fixture = diagnostics_fixture("ruby_appraisals_self_dependency_policy_acceptance") ruby_appraisals_min_ruby_prune_policy_acceptance_fixture = diagnostics_fixture("ruby_appraisals_min_ruby_prune_policy_acceptance") + changelog_unreleased_normalization_acceptance_fixture = diagnostics_fixture("changelog_unreleased_normalization_acceptance") structured_edit_callable_destination_request_fixture = diagnostics_fixture("structured_edit_callable_destination_request") structured_edit_parity_selection_semantics_fixture = diagnostics_fixture("structured_edit_parity_selection_semantics") structured_edit_parity_match_semantics_fixture = diagnostics_fixture("structured_edit_parity_match_semantics") @@ -2474,6 +2475,19 @@ def execute_from(executions) end end + changelog_unreleased_normalization_acceptance_fixture[:cases].each do |entry| + if entry[:label] == "create-unreleased-section-from-supplied-entries" + final_content = entry.dig(:report_envelope, :report, :final_content) + expect(final_content.index("## Unreleased")).to be < final_content.index("## 1.2.0") + expect(final_content).to include("- Added native Markdown recipe boundary.") + expect(final_content).to include("- Existing release.") + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :metadata, :operation)).to eq("insert_or_replace_section") + end + if entry[:label] == "missing-entries-fails-closed" + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :status)).to eq("failed") + end + end + structured_edit_provider_execution_request_fixture[:cases].each do |entry| execution_request = described_class.structured_edit_provider_execution_request( request: entry.dig(:execution_request, :request), From e9aecf0fa6be4e5d2265c11d5614f403c0304bfd Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 4 May 2026 15:08:16 -0600 Subject: [PATCH 06/71] Add Ruby coverage for README metadata slice Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/ast-merge/spec/fixtures_integration_spec.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/gems/ast-merge/spec/fixtures_integration_spec.rb b/gems/ast-merge/spec/fixtures_integration_spec.rb index fb9a8ac..539d961 100644 --- a/gems/ast-merge/spec/fixtures_integration_spec.rb +++ b/gems/ast-merge/spec/fixtures_integration_spec.rb @@ -1124,6 +1124,7 @@ def execute_from(executions) ruby_appraisals_self_dependency_policy_acceptance_fixture = diagnostics_fixture("ruby_appraisals_self_dependency_policy_acceptance") ruby_appraisals_min_ruby_prune_policy_acceptance_fixture = diagnostics_fixture("ruby_appraisals_min_ruby_prune_policy_acceptance") changelog_unreleased_normalization_acceptance_fixture = diagnostics_fixture("changelog_unreleased_normalization_acceptance") + readme_supplied_metadata_synchronization_acceptance_fixture = diagnostics_fixture("readme_supplied_metadata_synchronization_acceptance") structured_edit_callable_destination_request_fixture = diagnostics_fixture("structured_edit_callable_destination_request") structured_edit_parity_selection_semantics_fixture = diagnostics_fixture("structured_edit_parity_selection_semantics") structured_edit_parity_match_semantics_fixture = diagnostics_fixture("structured_edit_parity_match_semantics") @@ -2488,6 +2489,20 @@ def execute_from(executions) end end + readme_supplied_metadata_synchronization_acceptance_fixture[:cases].each do |entry| + if entry[:label] == "sync-readme-heading-and-summary-from-supplied-metadata" + final_content = entry.dig(:report_envelope, :report, :final_content) + expect(final_content).to start_with("# Demo Toolkit\n") + expect(final_content).to include("A deterministic toolkit for structured merges.") + expect(final_content).to include("Destination usage.") + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :metadata, :consumed_context)).to eq("readme_metadata.title") + expect(entry.dig(:report_envelope, :report, :step_reports, 1, :metadata, :consumed_context)).to eq("readme_metadata.summary") + end + if entry[:label] == "missing-readme-metadata-fails-closed" + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :status)).to eq("failed") + end + end + structured_edit_provider_execution_request_fixture[:cases].each do |entry| execution_request = described_class.structured_edit_provider_execution_request( request: entry.dig(:execution_request, :request), From fbe4e086270512f98f2b69046204eee28bbb640c Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 4 May 2026 15:17:45 -0600 Subject: [PATCH 07/71] Add Ruby coverage for Markdown pruning slice Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ast-merge/spec/fixtures_integration_spec.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/gems/ast-merge/spec/fixtures_integration_spec.rb b/gems/ast-merge/spec/fixtures_integration_spec.rb index 539d961..f0875da 100644 --- a/gems/ast-merge/spec/fixtures_integration_spec.rb +++ b/gems/ast-merge/spec/fixtures_integration_spec.rb @@ -1125,6 +1125,7 @@ def execute_from(executions) ruby_appraisals_min_ruby_prune_policy_acceptance_fixture = diagnostics_fixture("ruby_appraisals_min_ruby_prune_policy_acceptance") changelog_unreleased_normalization_acceptance_fixture = diagnostics_fixture("changelog_unreleased_normalization_acceptance") readme_supplied_metadata_synchronization_acceptance_fixture = diagnostics_fixture("readme_supplied_metadata_synchronization_acceptance") + supplied_markdown_pruning_acceptance_fixture = diagnostics_fixture("supplied_markdown_pruning_acceptance") structured_edit_callable_destination_request_fixture = diagnostics_fixture("structured_edit_callable_destination_request") structured_edit_parity_selection_semantics_fixture = diagnostics_fixture("structured_edit_parity_selection_semantics") structured_edit_parity_match_semantics_fixture = diagnostics_fixture("structured_edit_parity_match_semantics") @@ -2503,6 +2504,22 @@ def execute_from(executions) end end + supplied_markdown_pruning_acceptance_fixture[:cases].each do |entry| + if entry[:label] == "prune-supplied-table-rows-and-reference-definitions" + final_content = entry.dig(:report_envelope, :report, :final_content) + expect(final_content).not_to include("Works with JRuby") + expect(final_content).not_to include("[jruby-9.4]:") + expect(final_content).not_to include("[jruby-head]:") + expect(final_content).to include("Works with MRI Ruby") + expect(final_content).to include("[ruby-3.2]:") + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :metadata, :deleted_rows)).to eq(1) + expect(entry.dig(:report_envelope, :report, :step_reports, 1, :metadata, :deleted_reference_definitions)).to eq(2) + end + if entry[:label] == "missing-prune-selectors-fails-closed" + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :status)).to eq("failed") + end + end + structured_edit_provider_execution_request_fixture[:cases].each do |entry| execution_request = described_class.structured_edit_provider_execution_request( request: entry.dig(:execution_request, :request), From 1aec8fb7a74648edaa201c54f80910b89c59facf Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 4 May 2026 15:20:41 -0600 Subject: [PATCH 08/71] Add Ruby coverage for selector deletion slice Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/ast-merge/spec/fixtures_integration_spec.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/gems/ast-merge/spec/fixtures_integration_spec.rb b/gems/ast-merge/spec/fixtures_integration_spec.rb index f0875da..5539521 100644 --- a/gems/ast-merge/spec/fixtures_integration_spec.rb +++ b/gems/ast-merge/spec/fixtures_integration_spec.rb @@ -1126,6 +1126,7 @@ def execute_from(executions) changelog_unreleased_normalization_acceptance_fixture = diagnostics_fixture("changelog_unreleased_normalization_acceptance") readme_supplied_metadata_synchronization_acceptance_fixture = diagnostics_fixture("readme_supplied_metadata_synchronization_acceptance") supplied_markdown_pruning_acceptance_fixture = diagnostics_fixture("supplied_markdown_pruning_acceptance") + supplied_source_selector_deletion_acceptance_fixture = diagnostics_fixture("supplied_source_selector_deletion_acceptance") structured_edit_callable_destination_request_fixture = diagnostics_fixture("structured_edit_callable_destination_request") structured_edit_parity_selection_semantics_fixture = diagnostics_fixture("structured_edit_parity_selection_semantics") structured_edit_parity_match_semantics_fixture = diagnostics_fixture("structured_edit_parity_match_semantics") @@ -2520,6 +2521,21 @@ def execute_from(executions) end end + supplied_source_selector_deletion_acceptance_fixture[:cases].each do |entry| + if entry[:label] == "delete-supplied-structural-owner-ranges" + final_content = entry.dig(:report_envelope, :report, :final_content) + expect(final_content).not_to include("kettle/scaffold") + expect(final_content).not_to include("task :scaffold") + expect(final_content).to include('require "bundler/gem_tasks"') + expect(final_content).to include("task :spec") + expect(final_content).not_to include("\n\n\n") + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :metadata, :deleted_ranges)).to eq(2) + end + if entry[:label] == "missing-delete-selectors-fails-closed" + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :status)).to eq("failed") + end + end + structured_edit_provider_execution_request_fixture[:cases].each do |entry| execution_request = described_class.structured_edit_provider_execution_request( request: entry.dig(:execution_request, :request), From 49e842d50e3646e791f6be5271d72f9f91e7f0ec Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 4 May 2026 22:22:48 -0600 Subject: [PATCH 09/71] Add Ruby coverage for YAML snippet synchronization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spec/fixtures_integration_spec.rb | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/gems/ast-merge/spec/fixtures_integration_spec.rb b/gems/ast-merge/spec/fixtures_integration_spec.rb index 5539521..aa0dd8d 100644 --- a/gems/ast-merge/spec/fixtures_integration_spec.rb +++ b/gems/ast-merge/spec/fixtures_integration_spec.rb @@ -1127,6 +1127,7 @@ def execute_from(executions) readme_supplied_metadata_synchronization_acceptance_fixture = diagnostics_fixture("readme_supplied_metadata_synchronization_acceptance") supplied_markdown_pruning_acceptance_fixture = diagnostics_fixture("supplied_markdown_pruning_acceptance") supplied_source_selector_deletion_acceptance_fixture = diagnostics_fixture("supplied_source_selector_deletion_acceptance") + supplied_yaml_snippet_synchronization_acceptance_fixture = diagnostics_fixture("supplied_yaml_snippet_synchronization_acceptance") structured_edit_callable_destination_request_fixture = diagnostics_fixture("structured_edit_callable_destination_request") structured_edit_parity_selection_semantics_fixture = diagnostics_fixture("structured_edit_parity_selection_semantics") structured_edit_parity_match_semantics_fixture = diagnostics_fixture("structured_edit_parity_match_semantics") @@ -2536,6 +2537,25 @@ def execute_from(executions) end end + supplied_yaml_snippet_synchronization_acceptance_fixture[:cases].each do |entry| + if entry[:label] == "apply-supplied-sections-and-scalar-pins" + final_content = entry.dig(:report_envelope, :report, :final_content) + expect(final_content).to include("concurrency:") + expect(final_content).to include("permissions:") + expect(final_content).to include("actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd") + expect(final_content).to include("ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92") + expect(final_content).not_to include("actions/checkout@v3") + expect(final_content).not_to include("ruby/setup-ruby@v1") + expect(final_content).to include("gemfiles/current.gemfile") + expect(final_content).to include('ruby-version: ${{ matrix.ruby }}') + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :metadata, :updated_sections)).to eq(2) + expect(entry.dig(:report_envelope, :report, :step_reports, 1, :metadata, :updated_scalars)).to eq(2) + end + if entry[:label] == "missing-yaml-updates-fails-closed" + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :status)).to eq("failed") + end + end + structured_edit_provider_execution_request_fixture[:cases].each do |entry| execution_request = described_class.structured_edit_provider_execution_request( request: entry.dig(:execution_request, :request), From 94f122ae29a69cc0e85f6714ecc3e5ad3f506462 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 4 May 2026 22:35:17 -0600 Subject: [PATCH 10/71] Add Ruby coverage for managed text blocks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spec/fixtures_integration_spec.rb | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/gems/ast-merge/spec/fixtures_integration_spec.rb b/gems/ast-merge/spec/fixtures_integration_spec.rb index aa0dd8d..5ef2df5 100644 --- a/gems/ast-merge/spec/fixtures_integration_spec.rb +++ b/gems/ast-merge/spec/fixtures_integration_spec.rb @@ -1128,6 +1128,7 @@ def execute_from(executions) supplied_markdown_pruning_acceptance_fixture = diagnostics_fixture("supplied_markdown_pruning_acceptance") supplied_source_selector_deletion_acceptance_fixture = diagnostics_fixture("supplied_source_selector_deletion_acceptance") supplied_yaml_snippet_synchronization_acceptance_fixture = diagnostics_fixture("supplied_yaml_snippet_synchronization_acceptance") + supplied_managed_text_block_replacement_acceptance_fixture = diagnostics_fixture("supplied_managed_text_block_replacement_acceptance") structured_edit_callable_destination_request_fixture = diagnostics_fixture("structured_edit_callable_destination_request") structured_edit_parity_selection_semantics_fixture = diagnostics_fixture("structured_edit_parity_selection_semantics") structured_edit_parity_match_semantics_fixture = diagnostics_fixture("structured_edit_parity_match_semantics") @@ -2556,6 +2557,27 @@ def execute_from(executions) end end + supplied_managed_text_block_replacement_acceptance_fixture[:cases].each do |entry| + if entry[:label] == "replace-existing-managed-text-block" + final_content = entry.dig(:report_envelope, :report, :final_content) + expect(final_content).to include('gem "debug", "~> 1.9"') + expect(final_content).to include('gem "irb", "~> 1.15"') + expect(final_content).not_to include("old-debug") + expect(final_content).to include('gem "rake"') + expect(final_content).to include('gem "rspec"') + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :metadata, :replaced_blocks)).to eq(1) + end + if entry[:label] == "append-missing-managed-text-block" + final_content = entry.dig(:report_envelope, :report, :final_content) + expect(final_content).to include("# <>") + expect(final_content).to include("# (no shunted dependencies)") + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :metadata, :appended_blocks)).to eq(1) + end + if entry[:label] == "missing-managed-block-updates-fails-closed" + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :status)).to eq("failed") + end + end + structured_edit_provider_execution_request_fixture[:cases].each do |entry| execution_request = described_class.structured_edit_provider_execution_request( request: entry.dig(:execution_request, :request), From da820dec9157cffb3897a72ea30c3bb8a4421ed3 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 4 May 2026 22:41:08 -0600 Subject: [PATCH 11/71] Add Ruby coverage for YAML placeholder backfill Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ast-merge/spec/fixtures_integration_spec.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/gems/ast-merge/spec/fixtures_integration_spec.rb b/gems/ast-merge/spec/fixtures_integration_spec.rb index 5ef2df5..cc641d0 100644 --- a/gems/ast-merge/spec/fixtures_integration_spec.rb +++ b/gems/ast-merge/spec/fixtures_integration_spec.rb @@ -1129,6 +1129,7 @@ def execute_from(executions) supplied_source_selector_deletion_acceptance_fixture = diagnostics_fixture("supplied_source_selector_deletion_acceptance") supplied_yaml_snippet_synchronization_acceptance_fixture = diagnostics_fixture("supplied_yaml_snippet_synchronization_acceptance") supplied_managed_text_block_replacement_acceptance_fixture = diagnostics_fixture("supplied_managed_text_block_replacement_acceptance") + supplied_yaml_placeholder_scalar_backfill_acceptance_fixture = diagnostics_fixture("supplied_yaml_placeholder_scalar_backfill_acceptance") structured_edit_callable_destination_request_fixture = diagnostics_fixture("structured_edit_callable_destination_request") structured_edit_parity_selection_semantics_fixture = diagnostics_fixture("structured_edit_parity_selection_semantics") structured_edit_parity_match_semantics_fixture = diagnostics_fixture("structured_edit_parity_match_semantics") @@ -2578,6 +2579,22 @@ def execute_from(executions) end end + supplied_yaml_placeholder_scalar_backfill_acceptance_fixture[:cases].each do |entry| + if entry[:label] == "backfill-placeholder-and-blank-scalars" + final_content = entry.dig(:report_envelope, :report, :final_content) + expect(final_content).to include('name: "demo-toolkit"') + expect(final_content).to include("namespace: 'Demo::Toolkit'") + expect(final_content).to include('homepage: "https://example.invalid/existing"') + expect(final_content).to include("# ENV: KJ_GEM_NAME") + expect(final_content).to include("# keep concrete value") + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :metadata, :updated_scalars)).to eq(2) + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :metadata, :preserved_scalars)).to eq(1) + end + if entry[:label] == "missing-yaml-scalar-backfills-fails-closed" + expect(entry.dig(:report_envelope, :report, :step_reports, 0, :status)).to eq("failed") + end + end + structured_edit_provider_execution_request_fixture[:cases].each do |entry| execution_request = described_class.structured_edit_provider_execution_request( request: entry.dig(:execution_request, :request), From 5fbd13a66b3bb1b1fd5c19b972f5fdf760f8523c Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 4 May 2026 23:08:55 -0600 Subject: [PATCH 12/71] Move content recipe builders to fixtures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/ast-merge/lib/ast/merge.rb | 45 -------- .../spec/fixtures_integration_spec.rb | 105 +++++++++++++----- 2 files changed, 77 insertions(+), 73 deletions(-) diff --git a/gems/ast-merge/lib/ast/merge.rb b/gems/ast-merge/lib/ast/merge.rb index d6a7dd0..3f08e7b 100644 --- a/gems/ast-merge/lib/ast/merge.rb +++ b/gems/ast-merge/lib/ast/merge.rb @@ -1040,51 +1040,6 @@ def structured_edit_provider_execution_request(request:, provider_family:, provi execution_request end - def content_recipe_execution_request(recipe_name:, recipe_version:, relative_path:, provider_family:, - template_content:, destination_content:, steps:, provider_backend: nil, runtime_context: nil, metadata: nil) - request = { - recipe_name: recipe_name.to_s, - recipe_version: recipe_version.to_s, - relative_path: relative_path.to_s, - provider_family: provider_family.to_s, - template_content: template_content.to_s, - destination_content: destination_content.to_s, - steps: deep_dup(steps) - } - request[:provider_backend] = provider_backend.to_s if provider_backend - request[:runtime_context] = deep_dup(runtime_context) if runtime_context - request[:metadata] = deep_dup(metadata) if metadata - request - end - - def content_recipe_execution_request_envelope(request) - { - kind: "content_recipe_execution_request", - version: STRUCTURED_EDIT_TRANSPORT_VERSION, - request: deep_dup(request) - } - end - - def content_recipe_execution_report(request:, final_content:, changed:, step_reports:, diagnostics:, metadata: nil) - report = { - request: deep_dup(request), - final_content: final_content.to_s, - changed: changed ? true : false, - step_reports: deep_dup(step_reports), - diagnostics: deep_dup(diagnostics) - } - report[:metadata] = deep_dup(metadata) if metadata - report - end - - def content_recipe_execution_report_envelope(report) - { - kind: "content_recipe_execution_report", - version: STRUCTURED_EDIT_TRANSPORT_VERSION, - report: deep_dup(report) - } - end - def structured_edit_provider_execution_request_envelope(execution_request) { kind: "structured_edit_provider_execution_request", diff --git a/gems/ast-merge/spec/fixtures_integration_spec.rb b/gems/ast-merge/spec/fixtures_integration_spec.rb index cc641d0..329b102 100644 --- a/gems/ast-merge/spec/fixtures_integration_spec.rb +++ b/gems/ast-merge/spec/fixtures_integration_spec.rb @@ -26,6 +26,55 @@ def json_ready(value) described_class.json_ready(value) end + def fixture_deep_dup(value) + Marshal.load(Marshal.dump(value)) + end + + def content_recipe_execution_request(recipe_name:, recipe_version:, relative_path:, provider_family:, + template_content:, destination_content:, steps:, provider_backend: nil, runtime_context: nil, metadata: nil) + request = { + recipe_name: recipe_name.to_s, + recipe_version: recipe_version.to_s, + relative_path: relative_path.to_s, + provider_family: provider_family.to_s, + template_content: template_content.to_s, + destination_content: destination_content.to_s, + steps: fixture_deep_dup(steps) + } + request[:provider_backend] = provider_backend.to_s if provider_backend + request[:runtime_context] = fixture_deep_dup(runtime_context) if runtime_context + request[:metadata] = fixture_deep_dup(metadata) if metadata + request + end + + def content_recipe_execution_request_envelope(request) + { + kind: "content_recipe_execution_request", + version: described_class::STRUCTURED_EDIT_TRANSPORT_VERSION, + request: fixture_deep_dup(request) + } + end + + def content_recipe_execution_report(request:, final_content:, changed:, step_reports:, diagnostics:, metadata: nil) + report = { + request: fixture_deep_dup(request), + final_content: final_content.to_s, + changed: changed ? true : false, + step_reports: fixture_deep_dup(step_reports), + diagnostics: fixture_deep_dup(diagnostics) + } + report[:metadata] = fixture_deep_dup(metadata) if metadata + report + end + + def content_recipe_execution_report_envelope(report) + { + kind: "content_recipe_execution_report", + version: described_class::STRUCTURED_EDIT_TRANSPORT_VERSION, + report: fixture_deep_dup(report) + } + end + def read_relative_file_tree(root) root = root.expand_path root.find.each_with_object({}) do |path, files| @@ -2224,7 +2273,7 @@ def execute_from(executions) ) content_recipe_execution_envelope_fixture[:cases].each do |entry| - request = described_class.content_recipe_execution_request( + request = content_recipe_execution_request( recipe_name: entry.dig(:request_envelope, :request, :recipe_name), recipe_version: entry.dig(:request_envelope, :request, :recipe_version), relative_path: entry.dig(:request_envelope, :request, :relative_path), @@ -2236,11 +2285,11 @@ def execute_from(executions) runtime_context: entry.dig(:request_envelope, :request, :runtime_context), metadata: entry.dig(:request_envelope, :request, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_request_envelope(request))).to eq( + expect(json_ready(content_recipe_execution_request_envelope(request))).to eq( json_ready(entry[:request_envelope]) ) - report = described_class.content_recipe_execution_report( + report = content_recipe_execution_report( request: entry.dig(:report_envelope, :report, :request), final_content: entry.dig(:report_envelope, :report, :final_content), changed: entry.dig(:report_envelope, :report, :changed), @@ -2248,13 +2297,13 @@ def execute_from(executions) diagnostics: entry.dig(:report_envelope, :report, :diagnostics), metadata: entry.dig(:report_envelope, :report, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_report_envelope(report))).to eq( + expect(json_ready(content_recipe_execution_report_envelope(report))).to eq( json_ready(entry[:report_envelope]) ) end single_file_readme_heading_section_acceptance_fixture[:cases].each do |entry| - request = described_class.content_recipe_execution_request( + request = content_recipe_execution_request( recipe_name: entry.dig(:request_envelope, :request, :recipe_name), recipe_version: entry.dig(:request_envelope, :request, :recipe_version), relative_path: entry.dig(:request_envelope, :request, :relative_path), @@ -2266,11 +2315,11 @@ def execute_from(executions) runtime_context: entry.dig(:request_envelope, :request, :runtime_context), metadata: entry.dig(:request_envelope, :request, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_request_envelope(request))).to eq( + expect(json_ready(content_recipe_execution_request_envelope(request))).to eq( json_ready(entry[:request_envelope]) ) - report = described_class.content_recipe_execution_report( + report = content_recipe_execution_report( request: entry.dig(:report_envelope, :report, :request), final_content: entry.dig(:report_envelope, :report, :final_content), changed: entry.dig(:report_envelope, :report, :changed), @@ -2278,13 +2327,13 @@ def execute_from(executions) diagnostics: entry.dig(:report_envelope, :report, :diagnostics), metadata: entry.dig(:report_envelope, :report, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_report_envelope(report))).to eq( + expect(json_ready(content_recipe_execution_report_envelope(report))).to eq( json_ready(entry[:report_envelope]) ) end native_structured_edit_recipe_steps_fixture[:cases].each do |entry| - report = described_class.content_recipe_execution_report( + report = content_recipe_execution_report( request: entry.dig(:report_envelope, :report, :request), final_content: entry.dig(:report_envelope, :report, :final_content), changed: entry.dig(:report_envelope, :report, :changed), @@ -2292,13 +2341,13 @@ def execute_from(executions) diagnostics: entry.dig(:report_envelope, :report, :diagnostics), metadata: entry.dig(:report_envelope, :report, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_report_envelope(report))).to eq( + expect(json_ready(content_recipe_execution_report_envelope(report))).to eq( json_ready(entry[:report_envelope]) ) end ruby_gemfile_signature_merge_acceptance_fixture[:cases].each do |entry| - report = described_class.content_recipe_execution_report( + report = content_recipe_execution_report( request: entry.dig(:report_envelope, :report, :request), final_content: entry.dig(:report_envelope, :report, :final_content), changed: entry.dig(:report_envelope, :report, :changed), @@ -2306,7 +2355,7 @@ def execute_from(executions) diagnostics: entry.dig(:report_envelope, :report, :diagnostics), metadata: entry.dig(:report_envelope, :report, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_report_envelope(report))).to eq( + expect(json_ready(content_recipe_execution_report_envelope(report))).to eq( json_ready(entry[:report_envelope]) ) end @@ -2318,7 +2367,7 @@ def execute_from(executions) expect(ruby_gemspec_native_boundary_report_fixture[:wrapper_required_behaviors].map { |entry| entry[:name] }).to include( "dependency_ruby_floor_comment_alignment" ) - request = described_class.content_recipe_execution_request( + request = content_recipe_execution_request( recipe_name: ruby_gemspec_native_boundary_report_fixture.dig(:example_native_recipe, :request, :recipe_name), recipe_version: ruby_gemspec_native_boundary_report_fixture.dig(:example_native_recipe, :request, :recipe_version), relative_path: ruby_gemspec_native_boundary_report_fixture.dig(:example_native_recipe, :request, :relative_path), @@ -2330,12 +2379,12 @@ def execute_from(executions) runtime_context: ruby_gemspec_native_boundary_report_fixture.dig(:example_native_recipe, :request, :runtime_context), metadata: ruby_gemspec_native_boundary_report_fixture.dig(:example_native_recipe, :request, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_request_envelope(request))).to eq( + expect(json_ready(content_recipe_execution_request_envelope(request))).to eq( json_ready(ruby_gemspec_native_boundary_report_fixture[:example_native_recipe]) ) ruby_gemspec_signature_merge_acceptance_fixture[:cases].each do |entry| - report = described_class.content_recipe_execution_report( + report = content_recipe_execution_report( request: entry.dig(:report_envelope, :report, :request), final_content: entry.dig(:report_envelope, :report, :final_content), changed: entry.dig(:report_envelope, :report, :changed), @@ -2343,13 +2392,13 @@ def execute_from(executions) diagnostics: entry.dig(:report_envelope, :report, :diagnostics), metadata: entry.dig(:report_envelope, :report, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_report_envelope(report))).to eq( + expect(json_ready(content_recipe_execution_report_envelope(report))).to eq( json_ready(entry[:report_envelope]) ) end ruby_gemspec_field_policy_acceptance_fixture[:cases].each do |entry| - report = described_class.content_recipe_execution_report( + report = content_recipe_execution_report( request: entry.dig(:report_envelope, :report, :request), final_content: entry.dig(:report_envelope, :report, :final_content), changed: entry.dig(:report_envelope, :report, :changed), @@ -2357,13 +2406,13 @@ def execute_from(executions) diagnostics: entry.dig(:report_envelope, :report, :diagnostics), metadata: entry.dig(:report_envelope, :report, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_report_envelope(report))).to eq( + expect(json_ready(content_recipe_execution_report_envelope(report))).to eq( json_ready(entry[:report_envelope]) ) end ruby_gemspec_dependency_section_policy_acceptance_fixture[:cases].each do |entry| - report = described_class.content_recipe_execution_report( + report = content_recipe_execution_report( request: entry.dig(:report_envelope, :report, :request), final_content: entry.dig(:report_envelope, :report, :final_content), changed: entry.dig(:report_envelope, :report, :changed), @@ -2371,13 +2420,13 @@ def execute_from(executions) diagnostics: entry.dig(:report_envelope, :report, :diagnostics), metadata: entry.dig(:report_envelope, :report, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_report_envelope(report))).to eq( + expect(json_ready(content_recipe_execution_report_envelope(report))).to eq( json_ready(entry[:report_envelope]) ) end ruby_gemspec_files_policy_acceptance_fixture[:cases].each do |entry| - report = described_class.content_recipe_execution_report( + report = content_recipe_execution_report( request: entry.dig(:report_envelope, :report, :request), final_content: entry.dig(:report_envelope, :report, :final_content), changed: entry.dig(:report_envelope, :report, :changed), @@ -2385,13 +2434,13 @@ def execute_from(executions) diagnostics: entry.dig(:report_envelope, :report, :diagnostics), metadata: entry.dig(:report_envelope, :report, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_report_envelope(report))).to eq( + expect(json_ready(content_recipe_execution_report_envelope(report))).to eq( json_ready(entry[:report_envelope]) ) end ruby_gemspec_version_loader_policy_acceptance_fixture[:cases].each do |entry| - report = described_class.content_recipe_execution_report( + report = content_recipe_execution_report( request: entry.dig(:report_envelope, :report, :request), final_content: entry.dig(:report_envelope, :report, :final_content), changed: entry.dig(:report_envelope, :report, :changed), @@ -2399,13 +2448,13 @@ def execute_from(executions) diagnostics: entry.dig(:report_envelope, :report, :diagnostics), metadata: entry.dig(:report_envelope, :report, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_report_envelope(report))).to eq( + expect(json_ready(content_recipe_execution_report_envelope(report))).to eq( json_ready(entry[:report_envelope]) ) end runtime_facts_context_fixture[:cases].each do |entry| - report = described_class.content_recipe_execution_report( + report = content_recipe_execution_report( request: entry.dig(:report_envelope, :report, :request), final_content: entry.dig(:report_envelope, :report, :final_content), changed: entry.dig(:report_envelope, :report, :changed), @@ -2413,7 +2462,7 @@ def execute_from(executions) diagnostics: entry.dig(:report_envelope, :report, :diagnostics), metadata: entry.dig(:report_envelope, :report, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_report_envelope(report))).to eq( + expect(json_ready(content_recipe_execution_report_envelope(report))).to eq( json_ready(entry[:report_envelope]) ) expect(entry.dig(:report_envelope, :report, :request, :runtime_context, :facts, :schema)).to eq( @@ -2422,7 +2471,7 @@ def execute_from(executions) end ruby_gemspec_self_dependency_policy_acceptance_fixture[:cases].each do |entry| - report = described_class.content_recipe_execution_report( + report = content_recipe_execution_report( request: entry.dig(:report_envelope, :report, :request), final_content: entry.dig(:report_envelope, :report, :final_content), changed: entry.dig(:report_envelope, :report, :changed), @@ -2430,7 +2479,7 @@ def execute_from(executions) diagnostics: entry.dig(:report_envelope, :report, :diagnostics), metadata: entry.dig(:report_envelope, :report, :metadata) ) - expect(json_ready(described_class.content_recipe_execution_report_envelope(report))).to eq( + expect(json_ready(content_recipe_execution_report_envelope(report))).to eq( json_ready(entry[:report_envelope]) ) if entry[:label] == "delete-active-self-dependencies-preserve-comments" From bb678417471c3591f19065c279e3083704309608 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 4 May 2026 23:45:21 -0600 Subject: [PATCH 13/71] Add kettle-jem vNext thin slice Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Gemfile | 1 + Gemfile.lock | 8 + gems/kettle-jem/kettle-jem.gemspec | 27 ++ gems/kettle-jem/lib/kettle-jem.rb | 3 + gems/kettle-jem/lib/kettle/jem.rb | 371 ++++++++++++++++++ gems/kettle-jem/lib/kettle/jem/version.rb | 7 + gems/kettle-jem/spec/fixtures/thin_slice.json | 40 ++ gems/kettle-jem/spec/spec_helper.rb | 14 + gems/kettle-jem/spec/thin_slice_spec.rb | 50 +++ spec/spec_helper.rb | 1 + 10 files changed, 522 insertions(+) create mode 100644 gems/kettle-jem/kettle-jem.gemspec create mode 100644 gems/kettle-jem/lib/kettle-jem.rb create mode 100644 gems/kettle-jem/lib/kettle/jem.rb create mode 100644 gems/kettle-jem/lib/kettle/jem/version.rb create mode 100644 gems/kettle-jem/spec/fixtures/thin_slice.json create mode 100644 gems/kettle-jem/spec/spec_helper.rb create mode 100644 gems/kettle-jem/spec/thin_slice_spec.rb diff --git a/Gemfile b/Gemfile index b01d370..8be53a6 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,7 @@ source "https://rubygems.org" gemspec path: "gems/tree_haver" gemspec path: "gems/ast-merge" gemspec path: "gems/ast-template" +gemspec path: "gems/kettle-jem" gemspec path: "gems/text-merge" gemspec path: "gems/json-merge" gemspec path: "gems/toml-merge" diff --git a/Gemfile.lock b/Gemfile.lock index 4991898..0a1a9b2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -38,6 +38,12 @@ PATH ast-merge (= 0.1.0) tree_haver (= 0.1.0) +PATH + remote: gems/kettle-jem + specs: + kettle-jem (0.1.0) + ast-merge (= 0.1.0) + PATH remote: gems/kramdown-merge specs: @@ -177,6 +183,7 @@ DEPENDENCIES commonmarker-merge! go-merge! json-merge! + kettle-jem! kramdown-merge! markdown-merge! markly-merge! @@ -204,6 +211,7 @@ CHECKSUMS diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 go-merge (0.1.0) json-merge (0.1.0) + kettle-jem (0.1.0) kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa kramdown-merge (0.1.0) markdown-merge (0.1.0) diff --git a/gems/kettle-jem/kettle-jem.gemspec b/gems/kettle-jem/kettle-jem.gemspec new file mode 100644 index 0000000..776e4ce --- /dev/null +++ b/gems/kettle-jem/kettle-jem.gemspec @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "lib/kettle/jem/version" + +Gem::Specification.new do |spec| + spec.name = "kettle-jem" + spec.version = Kettle::Jem::VERSION + spec.authors = ["Structured Merge Contributors"] + spec.email = ["opensource@structuredmerge.dev"] + + spec.summary = "RubyGems package templating wrapper for Structured Merge" + spec.description = "RubyGems-focused recipe-pack wrapper that shapes package facts into ast-merge transport." + spec.homepage = "https://github.com/structuredmerge/structuredmerge-ruby" + spec.licenses = ["AGPL-3.0-only", "PolyForm-Small-Business-1.0.0"] + spec.required_ruby_version = ">= 4.0.0" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + + root = __dir__ + spec.files = Dir.chdir(root) do + Dir.glob("lib/**/*", File::FNM_DOTMATCH).reject { |path| File.directory?(path) } + end + spec.require_paths = ["lib"] + + spec.add_dependency "ast-merge", "= #{Kettle::Jem::VERSION}" +end diff --git a/gems/kettle-jem/lib/kettle-jem.rb b/gems/kettle-jem/lib/kettle-jem.rb new file mode 100644 index 0000000..0081fd1 --- /dev/null +++ b/gems/kettle-jem/lib/kettle-jem.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "kettle/jem" diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb new file mode 100644 index 0000000..6471772 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -0,0 +1,371 @@ +# frozen_string_literal: true + +require "fileutils" +require "ast/merge" +require_relative "jem/version" + +module Kettle + module Jem + PACKAGE_NAME = "kettle-jem" + CONTENT_RECIPE_TRANSPORT_VERSION = Ast::Merge::STRUCTURED_EDIT_TRANSPORT_VERSION + MANAGED_BLOCK_OPEN = "# <> do not edit below this line" + MANAGED_BLOCK_CLOSE = "# <>" + + module_function + + def discover_facts(project_root) + gemspec_path = Dir.glob(File.join(project_root, "*.gemspec")).sort.first + raise ArgumentError, "no gemspec found in #{project_root}" unless gemspec_path + + gemspec = File.read(gemspec_path) + name = extract_gemspec_assignment(gemspec, "spec.name") || File.basename(gemspec_path, ".gemspec") + source_url = extract_metadata_value(gemspec, "source_code_uri") || + extract_gemspec_assignment(gemspec, "spec.homepage") + + facts = { + package: compact_hash( + ecosystem: "rubygems", + name: name, + slug: name, + description: extract_gemspec_assignment(gemspec, "spec.description") || + extract_gemspec_assignment(gemspec, "spec.summary"), + homepage_url: extract_gemspec_assignment(gemspec, "spec.homepage"), + source_url: source_url, + license_expression: Array(extract_gemspec_array(gemspec, "spec.licenses")).join(" OR "), + ), + rubygems: compact_hash( + gemspec_path: File.basename(gemspec_path), + namespace: classify_namespace(name), + min_ruby: extract_gemspec_assignment(gemspec, "spec.required_ruby_version"), + ), + } + funding = compact_hash(urls: funding_urls(project_root)) + facts[:funding] = funding unless funding.empty? + facts + end + + def recipe_pack(facts) + { + name: "kettle-jem-core", + version: 1, + ecosystem: "rubygems", + recipes: [ + recipe_entry("readme_metadata", "README.md", "markdown", "supplied_readme_metadata_synchronization", facts: %w[package funding readme]), + recipe_entry("changelog_unreleased", "CHANGELOG.md", "markdown", "changelog_unreleased_normalization", facts: %w[package changelog]), + recipe_entry("generated_block_sync", "gemfiles/modular/shunted.gemfile", "text", "supplied_managed_text_block_replacement", facts: %w[package generated_blocks]), + ], + } + end + + def plan_project(project_root) + facts = discover_facts(project_root) + pack = recipe_pack(facts) + files = read_project_files(project_root, pack) + recipe_reports = pack.fetch(:recipes).map do |recipe| + execute_recipe(project_root: project_root, recipe: recipe, facts: facts, files: files) + end + changed_files = recipe_reports.filter_map { |report| report[:relative_path] if report[:changed] }.sort + + { + mode: "plan", + ready: true, + facts: facts, + recipe_pack: pack, + recipe_reports: recipe_reports, + changed_files: changed_files, + diagnostics: recipe_reports.flat_map { |report| report[:diagnostics] }, + } + end + + def apply_project(project_root) + report = plan_project(project_root).merge(mode: "apply") + report.fetch(:recipe_reports).each do |recipe_report| + next unless recipe_report[:changed] + + path = File.join(project_root, recipe_report.fetch(:relative_path)) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, recipe_report.fetch(:final_content)) + end + report + end + + def content_recipe_execution_request(recipe_name:, recipe_version:, relative_path:, provider_family:, + template_content:, destination_content:, steps:, provider_backend: nil, runtime_context: nil, metadata: nil) + compact_hash( + recipe_name: recipe_name.to_s, + recipe_version: recipe_version.to_s, + relative_path: relative_path.to_s, + provider_family: provider_family.to_s, + provider_backend: provider_backend&.to_s, + template_content: template_content.to_s, + destination_content: destination_content.to_s, + steps: deep_dup(steps), + runtime_context: deep_dup(runtime_context || {}), + metadata: deep_dup(metadata || {}), + ) + end + + def content_recipe_execution_request_envelope(request) + { + kind: "content_recipe_execution_request", + version: CONTENT_RECIPE_TRANSPORT_VERSION, + request: deep_dup(request), + } + end + + def content_recipe_execution_report(request:, final_content:, changed:, step_reports:, diagnostics:, metadata: nil) + compact_hash( + request: deep_dup(request), + final_content: final_content.to_s, + changed: changed ? true : false, + step_reports: deep_dup(step_reports), + diagnostics: deep_dup(diagnostics), + metadata: deep_dup(metadata || {}), + ) + end + + def content_recipe_execution_report_envelope(report) + { + kind: "content_recipe_execution_report", + version: CONTENT_RECIPE_TRANSPORT_VERSION, + report: deep_dup(report), + } + end + + def synchronize_readme(content, facts) + package = facts.fetch(:package) + lines = content.to_s.split("\n", -1) + heading = "# #{package.fetch(:name)}" + h1_index = lines.index { |line| line.start_with?("# ") } + if h1_index + lines[h1_index] = heading + else + lines.unshift(heading, "") + end + replace_markdown_managed_block(lines.join("\n"), "kettle-jem:metadata", readme_metadata_block(facts)) + end + + def normalize_changelog(content, facts) + text = content.to_s + title = "# Changelog" + text = "#{title}\n\n#{text}" unless text.lines.first&.start_with?("# ") + return ensure_trailing_newline(text) if text.match?(/^##\s+\[?Unreleased\]?/i) + + lines = text.split("\n", -1) + insert_at = lines.index { |line| line.start_with?("## ") } || lines.length + section = [ + "## [Unreleased]", + "", + "### Added", + "", + "### Changed", + "", + "### Fixed", + "", + ] + lines.insert(insert_at, *section) + ensure_trailing_newline(lines.join("\n").gsub(/\n{3,}/, "\n\n")) + end + + def synchronize_managed_block(content, facts) + replacement = [ + MANAGED_BLOCK_OPEN, + "# package: #{facts.fetch(:package).fetch(:name)}", + "# generated by kettle-jem vNext", + MANAGED_BLOCK_CLOSE, + "", + ].join("\n") + replace_text_managed_block(content.to_s, replacement) + end + + def execute_recipe(project_root:, recipe:, facts:, files:) + relative_path = recipe.fetch(:target_path) + original = files.fetch(relative_path, "") + final = case recipe.fetch(:name) + when "readme_metadata" + synchronize_readme(original, facts) + when "changelog_unreleased" + normalize_changelog(original, facts) + when "generated_block_sync" + synchronize_managed_block(original, facts) + else + original + end + + request = content_recipe_execution_request( + recipe_name: recipe.fetch(:primitive), + recipe_version: "1", + relative_path: relative_path, + provider_family: recipe.fetch(:provider_family), + template_content: "", + destination_content: original, + steps: [content_recipe_step(recipe)], + runtime_context: facts, + metadata: { packaging_recipe: recipe.fetch(:name), project_root: project_root.to_s }, + ) + changed = final != original + step_report = content_recipe_step_report(recipe: recipe, request: request, original: original, final: final, changed: changed) + report = content_recipe_execution_report( + request: request, + final_content: final, + changed: changed, + step_reports: [step_report], + diagnostics: [], + metadata: { packaging_recipe: recipe.fetch(:name) }, + ) + + { + recipe_name: recipe.fetch(:name), + relative_path: relative_path, + changed: changed, + request_envelope: content_recipe_execution_request_envelope(request), + report_envelope: content_recipe_execution_report_envelope(report), + final_content: final, + diagnostics: [], + } + end + + def content_recipe_step(recipe) + { + step_id: recipe.fetch(:name), + step_kind: recipe.fetch(:primitive), + name: recipe.fetch(:name), + provider_family: recipe.fetch(:provider_family), + metadata: { target_path: recipe.fetch(:target_path) }, + } + end + + def content_recipe_step_report(recipe:, request:, original:, final:, changed:) + operation_profile = Ast::Merge.structured_edit_operation_profile( + operation_kind: recipe.fetch(:primitive), + known_operation_kind: true, + source_requirement: "destination_content", + destination_requirement: "relative_path", + replacement_source: "runtime_context", + captures_source_text: false, + supports_if_missing: true, + operation_family: "kettle-jem", + ) + result = Ast::Merge.structured_edit_result( + operation_kind: recipe.fetch(:primitive), + updated_content: final, + changed: changed, + operation_profile: operation_profile, + ) + application = Ast::Merge.structured_edit_application(request: request, result: result) + { + step_id: recipe.fetch(:name), + step_kind: recipe.fetch(:primitive), + status: changed ? "applied" : "unchanged", + changed: changed, + input_content: original, + output_content: final, + application: application, + diagnostics: [], + metadata: { target_path: recipe.fetch(:target_path) }, + } + end + + def read_project_files(project_root, pack) + pack.fetch(:recipes).to_h do |recipe| + relative_path = recipe.fetch(:target_path) + path = File.join(project_root, relative_path) + [relative_path, File.exist?(path) ? File.read(path) : ""] + end + end + + def recipe_entry(name, target_path, provider_family, primitive, facts:) + { + name: name, + target_path: target_path, + provider_family: provider_family, + primitive: primitive, + facts: facts, + selectors: [], + } + end + + def extract_gemspec_assignment(source, field) + match = source.match(/#{Regexp.escape(field)}\s*=\s*["']([^"']*)["']/) + match && match[1] + end + + def extract_gemspec_array(source, field) + match = source.match(/#{Regexp.escape(field)}\s*=\s*\[([^\]]*)\]/m) + return [] unless match + + match[1].scan(/["']([^"']+)["']/).flatten + end + + def extract_metadata_value(source, key) + match = source.match(/spec\.metadata\[\s*["']#{Regexp.escape(key)}["']\s*\]\s*=\s*["']([^"']*)["']/) + match && match[1] + end + + def funding_urls(project_root) + path = File.join(project_root, ".github", "FUNDING.yml") + return [] unless File.exist?(path) + + File.read(path).scan(%r{https?://\S+}).map { |url| url.delete_suffix("\"").delete_suffix("'") }.uniq.sort + end + + def classify_namespace(name) + name.to_s.split(/[-_]/).map { |part| part[0].to_s.upcase + part[1..].to_s }.join("::") + end + + def readme_metadata_block(facts) + package = facts.fetch(:package) + rows = [ + ["Package", package[:name]], + ["Description", package[:description]], + ["Homepage", package[:homepage_url]], + ["Source", package[:source_url]], + ["License", package[:license_expression]], + ].reject { |(_, value)| value.to_s.empty? } + + [ + "", + "| Field | Value |", + "|---|---|", + *rows.map { |field, value| "| #{field} | #{value} |" }, + "", + ].join("\n") + end + + def replace_markdown_managed_block(content, marker, replacement) + open = "" + close = "" + replace_between_markers(content, open, close, replacement) do + [content.rstrip, "", replacement, ""].join("\n") + end + end + + def replace_text_managed_block(content, replacement) + replace_between_markers(content, MANAGED_BLOCK_OPEN, MANAGED_BLOCK_CLOSE, replacement) do + [content.rstrip, replacement].reject(&:empty?).join("\n") + end + end + + def replace_between_markers(content, open_marker, close_marker, replacement) + open_index = content.index(open_marker) + close_index = content.index(close_marker) + return yield unless open_index && close_index && close_index >= open_index + + close_end = close_index + close_marker.length + close_end += 1 if content[close_end] == "\n" + "#{content[0...open_index]}#{replacement}\n#{content[close_end..]}" + end + + def ensure_trailing_newline(text) + text.end_with?("\n") ? text : "#{text}\n" + end + + def compact_hash(hash) + hash.reject { |_key, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) } + end + + def deep_dup(value) + Marshal.load(Marshal.dump(value)) + end + end +end diff --git a/gems/kettle-jem/lib/kettle/jem/version.rb b/gems/kettle-jem/lib/kettle/jem/version.rb new file mode 100644 index 0000000..99c0655 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Kettle + module Jem + VERSION = "0.1.0" + end +end diff --git a/gems/kettle-jem/spec/fixtures/thin_slice.json b/gems/kettle-jem/spec/fixtures/thin_slice.json new file mode 100644 index 0000000..6bb8520 --- /dev/null +++ b/gems/kettle-jem/spec/fixtures/thin_slice.json @@ -0,0 +1,40 @@ +{ + "case_id": "kettle-jem-vnext-readme-changelog-managed-block", + "ecosystem": "rubygems", + "inputs": { + "files": { + "example.gemspec": "# frozen_string_literal: true\n\nGem::Specification.new do |spec|\n spec.name = \"example\"\n spec.summary = \"Example gem\"\n spec.description = \"Example gem for kettle-jem\"\n spec.homepage = \"https://example.test\"\n spec.metadata[\"source_code_uri\"] = \"https://github.com/example/example\"\n spec.licenses = [\"MIT\"]\n spec.required_ruby_version = \">= 3.2\"\nend\n", + "README.md": "# Old Name\n\nExisting intro.\n", + "CHANGELOG.md": "# Changelog\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", + "gemfiles/modular/shunted.gemfile": "# frozen_string_literal: true\n\n# local additions stay above\n" + } + }, + "expected": { + "facts": { + "package": { + "ecosystem": "rubygems", + "name": "example", + "slug": "example", + "description": "Example gem for kettle-jem", + "homepage_url": "https://example.test", + "source_url": "https://github.com/example/example", + "license_expression": "MIT" + }, + "rubygems": { + "gemspec_path": "example.gemspec", + "namespace": "Example", + "min_ruby": ">= 3.2" + } + }, + "changed_files": [ + "CHANGELOG.md", + "README.md", + "gemfiles/modular/shunted.gemfile" + ], + "files": { + "README.md": "# example\n\nExisting intro.\n\n\n| Field | Value |\n|---|---|\n| Package | example |\n| Description | Example gem for kettle-jem |\n| Homepage | https://example.test |\n| Source | https://github.com/example/example |\n| License | MIT |\n\n", + "CHANGELOG.md": "# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", + "gemfiles/modular/shunted.gemfile": "# frozen_string_literal: true\n\n# local additions stay above\n# <> do not edit below this line\n# package: example\n# generated by kettle-jem vNext\n# <>\n" + } + } +} diff --git a/gems/kettle-jem/spec/spec_helper.rb b/gems/kettle-jem/spec/spec_helper.rb new file mode 100644 index 0000000..83ba1ce --- /dev/null +++ b/gems/kettle-jem/spec/spec_helper.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "json" +require "fileutils" +require "pathname" +require "tmpdir" +require "kettle-jem" + +RSpec.configure do |config| + config.disable_monkey_patching! + config.expect_with(:rspec) do |expectations| + expectations.syntax = :expect + end +end diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb new file mode 100644 index 0000000..4a8c8d1 --- /dev/null +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe Kettle::Jem do + def json_ready(value) + JSON.parse(JSON.generate(value), symbolize_names: true) + end + + def write_tree(root, files) + files.each do |relative_path, content| + path = File.join(root, relative_path.to_s) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + end + + def project_files(root, paths) + paths.to_h do |relative_path| + path = File.join(root, relative_path) + [relative_path.to_sym, File.exist?(path) ? File.read(path) : nil] + end + end + + let(:fixture_path) { Pathname(__dir__).join("fixtures/thin_slice.json") } + let(:fixture) { JSON.parse(fixture_path.read, symbolize_names: true) } + + it "plans and applies the RubyGems thin vertical slice" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-thin-slice", tmp_root) do |root| + write_tree(root, fixture.fetch(:inputs).fetch(:files)) + + plan = described_class.plan_project(root) + expect(json_ready(plan[:facts])).to eq(json_ready(fixture.fetch(:expected).fetch(:facts))) + expect(plan[:recipe_pack][:recipes].map { |recipe| recipe[:name] }).to eq(%w[ + readme_metadata + changelog_unreleased + generated_block_sync + ]) + expect(plan[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) + expect(plan[:recipe_reports].map { |report| report[:request_envelope][:kind] }.uniq).to eq(["content_recipe_execution_request"]) + expect(plan[:recipe_reports].map { |report| report[:report_envelope][:kind] }.uniq).to eq(["content_recipe_execution_report"]) + + apply = described_class.apply_project(root) + expect(apply[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) + expect(project_files(root, fixture.fetch(:expected).fetch(:files).keys.map(&:to_s))).to eq(fixture.fetch(:expected).fetch(:files)) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 11e8a24..12ca307 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "../gems/ast-merge/spec/spec_helper" +require_relative "../gems/kettle-jem/spec/spec_helper" require_relative "../gems/tree_haver/spec/spec_helper" require_relative "../gems/text-merge/spec/spec_helper" require_relative "../gems/json-merge/spec/spec_helper" From e0d190f04a8a79c51e32c6702956e3d338ae9826 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 00:09:54 -0600 Subject: [PATCH 14/71] Assert packaging contract in kettle-jem Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/spec/thin_slice_spec.rb | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 4a8c8d1..a8fce51 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -24,8 +24,19 @@ def project_files(root, paths) let(:fixture_path) { Pathname(__dir__).join("fixtures/thin_slice.json") } let(:fixture) { JSON.parse(fixture_path.read, symbolize_names: true) } + let(:contract_path) do + Pathname(__dir__).join("../../../../fixtures/packaging/thin-slice-contract.json").expand_path + end + let(:contract) { JSON.parse(contract_path.read, symbolize_names: true) } it "plans and applies the RubyGems thin vertical slice" do + expected_recipe_names = contract.fetch(:canonical_recipes).map { |recipe| recipe.fetch(:name).to_s } + expect(contract.fetch(:validated_ecosystems)).to include(fixture.fetch(:ecosystem)) + expect(fixture.fetch(:expected).fetch(:facts).keys).to include( + *contract.fetch(:required_fact_groups).map(&:to_sym), + contract.fetch(:ecosystem_fact_groups).fetch(:rubygems).to_sym + ) + tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) Dir.mktmpdir("kettle-jem-thin-slice", tmp_root) do |root| @@ -33,14 +44,14 @@ def project_files(root, paths) plan = described_class.plan_project(root) expect(json_ready(plan[:facts])).to eq(json_ready(fixture.fetch(:expected).fetch(:facts))) - expect(plan[:recipe_pack][:recipes].map { |recipe| recipe[:name] }).to eq(%w[ - readme_metadata - changelog_unreleased - generated_block_sync - ]) + expect(plan[:recipe_pack][:recipes].map { |recipe| recipe[:name] }).to eq(expected_recipe_names) expect(plan[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) - expect(plan[:recipe_reports].map { |report| report[:request_envelope][:kind] }.uniq).to eq(["content_recipe_execution_request"]) - expect(plan[:recipe_reports].map { |report| report[:report_envelope][:kind] }.uniq).to eq(["content_recipe_execution_report"]) + expect(plan[:recipe_reports].map { |report| report[:request_envelope][:kind] }.uniq).to eq( + [contract.fetch(:report_contract).fetch(:request_envelope_kind)] + ) + expect(plan[:recipe_reports].map { |report| report[:report_envelope][:kind] }.uniq).to eq( + [contract.fetch(:report_contract).fetch(:report_envelope_kind)] + ) apply = described_class.apply_project(root) expect(apply[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) From ba41b2f6f5336f2efec563d7650511cbd59cc015 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 00:37:32 -0600 Subject: [PATCH 15/71] Add kettle-jem funding metadata facts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 53 +++++++++++++++++-- gems/kettle-jem/spec/fixtures/thin_slice.json | 12 ++++- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 6471772..3bfbbb9 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "fileutils" +require "yaml" require "ast/merge" require_relative "jem/version" @@ -39,7 +40,7 @@ def discover_facts(project_root) min_ruby: extract_gemspec_assignment(gemspec, "spec.required_ruby_version"), ), } - funding = compact_hash(urls: funding_urls(project_root)) + funding = compact_hash(urls: funding_urls(project_root, gemspec)) facts[:funding] = funding unless funding.empty? facts end @@ -302,11 +303,53 @@ def extract_metadata_value(source, key) match && match[1] end - def funding_urls(project_root) + def funding_urls(project_root, gemspec_source) + urls = [extract_metadata_value(gemspec_source, "funding_uri")] path = File.join(project_root, ".github", "FUNDING.yml") - return [] unless File.exist?(path) + urls.concat(github_funding_urls(path)) if File.exist?(path) - File.read(path).scan(%r{https?://\S+}).map { |url| url.delete_suffix("\"").delete_suffix("'") }.uniq.sort + urls.compact.uniq.sort + end + + def github_funding_urls(path) + funding = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {} + return [] unless funding.is_a?(Hash) + + funding.flat_map do |platform, value| + github_funding_platform_urls(platform.to_s, Array(value).compact) + end + end + + def github_funding_platform_urls(platform, values) + values.filter_map do |value| + handle = value.to_s.strip.delete_prefix("@") + next if handle.empty? + + case platform + when "buy_me_a_coffee" + "https://www.buymeacoffee.com/#{handle}" + when "custom" + handle if handle.match?(%r{\Ahttps?://}) + when "github" + "https://github.com/sponsors/#{handle}" + when "issuehunt" + "https://issuehunt.io/u/#{handle}" + when "ko_fi" + "https://ko-fi.com/#{handle}" + when "liberapay" + "https://liberapay.com/#{handle}/donate" + when "open_collective" + "https://opencollective.com/#{handle}" + when "patreon" + "https://patreon.com/#{handle}" + when "polar" + "https://polar.sh/#{handle}" + when "thanks_dev" + "https://thanks.dev/#{handle}" + when "tidelift" + "https://tidelift.com/funding/github/#{handle}" + end + end end def classify_namespace(name) @@ -315,12 +358,14 @@ def classify_namespace(name) def readme_metadata_block(facts) package = facts.fetch(:package) + funding_urls = facts.fetch(:funding, {}).fetch(:urls, []) rows = [ ["Package", package[:name]], ["Description", package[:description]], ["Homepage", package[:homepage_url]], ["Source", package[:source_url]], ["License", package[:license_expression]], + ["Funding", funding_urls.join(", ")], ].reject { |(_, value)| value.to_s.empty? } [ diff --git a/gems/kettle-jem/spec/fixtures/thin_slice.json b/gems/kettle-jem/spec/fixtures/thin_slice.json index 6bb8520..2799827 100644 --- a/gems/kettle-jem/spec/fixtures/thin_slice.json +++ b/gems/kettle-jem/spec/fixtures/thin_slice.json @@ -3,7 +3,8 @@ "ecosystem": "rubygems", "inputs": { "files": { - "example.gemspec": "# frozen_string_literal: true\n\nGem::Specification.new do |spec|\n spec.name = \"example\"\n spec.summary = \"Example gem\"\n spec.description = \"Example gem for kettle-jem\"\n spec.homepage = \"https://example.test\"\n spec.metadata[\"source_code_uri\"] = \"https://github.com/example/example\"\n spec.licenses = [\"MIT\"]\n spec.required_ruby_version = \">= 3.2\"\nend\n", + "example.gemspec": "# frozen_string_literal: true\n\nGem::Specification.new do |spec|\n spec.name = \"example\"\n spec.summary = \"Example gem\"\n spec.description = \"Example gem for kettle-jem\"\n spec.homepage = \"https://example.test\"\n spec.metadata[\"source_code_uri\"] = \"https://github.com/example/example\"\n spec.metadata[\"funding_uri\"] = \"https://github.com/sponsors/example\"\n spec.licenses = [\"MIT\"]\n spec.required_ruby_version = \">= 3.2\"\nend\n", + ".github/FUNDING.yml": "github: [example]\nopen_collective: example\ncustom:\n - https://example.test/fund\n", "README.md": "# Old Name\n\nExisting intro.\n", "CHANGELOG.md": "# Changelog\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", "gemfiles/modular/shunted.gemfile": "# frozen_string_literal: true\n\n# local additions stay above\n" @@ -24,6 +25,13 @@ "gemspec_path": "example.gemspec", "namespace": "Example", "min_ruby": ">= 3.2" + }, + "funding": { + "urls": [ + "https://example.test/fund", + "https://github.com/sponsors/example", + "https://opencollective.com/example" + ] } }, "changed_files": [ @@ -32,7 +40,7 @@ "gemfiles/modular/shunted.gemfile" ], "files": { - "README.md": "# example\n\nExisting intro.\n\n\n| Field | Value |\n|---|---|\n| Package | example |\n| Description | Example gem for kettle-jem |\n| Homepage | https://example.test |\n| Source | https://github.com/example/example |\n| License | MIT |\n\n", + "README.md": "# example\n\nExisting intro.\n\n\n| Field | Value |\n|---|---|\n| Package | example |\n| Description | Example gem for kettle-jem |\n| Homepage | https://example.test |\n| Source | https://github.com/example/example |\n| License | MIT |\n| Funding | https://example.test/fund, https://github.com/sponsors/example, https://opencollective.com/example |\n\n", "CHANGELOG.md": "# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", "gemfiles/modular/shunted.gemfile": "# frozen_string_literal: true\n\n# local additions stay above\n# <> do not edit below this line\n# package: example\n# generated by kettle-jem vNext\n# <>\n" } From 0fdbe30a78dab31f61d7115b238b8f53bdfe5e95 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 00:41:47 -0600 Subject: [PATCH 16/71] Add kettle-jem Rakefile scaffold cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 157 +++++++++++++++++- gems/kettle-jem/spec/fixtures/thin_slice.json | 5 +- gems/kettle-jem/spec/thin_slice_spec.rb | 10 +- 3 files changed, 163 insertions(+), 9 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 3bfbbb9..93dd09f 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -54,6 +54,15 @@ def recipe_pack(facts) recipe_entry("readme_metadata", "README.md", "markdown", "supplied_readme_metadata_synchronization", facts: %w[package funding readme]), recipe_entry("changelog_unreleased", "CHANGELOG.md", "markdown", "changelog_unreleased_normalization", facts: %w[package changelog]), recipe_entry("generated_block_sync", "gemfiles/modular/shunted.gemfile", "text", "supplied_managed_text_block_replacement", facts: %w[package generated_blocks]), + recipe_entry( + "rakefile_scaffold_cleanup", + "Rakefile", + "generic_ast", + "supplied_source_selector_deletion", + provider_backend: "generic_structural_owners", + facts: %w[rubygems rakefile], + selectors: %w[rakefile_scaffold] + ), ], } end @@ -182,6 +191,7 @@ def synchronize_managed_block(content, facts) def execute_recipe(project_root:, recipe:, facts:, files:) relative_path = recipe.fetch(:target_path) original = files.fetch(relative_path, "") + deletion = recipe.fetch(:name) == "rakefile_scaffold_cleanup" ? delete_rakefile_scaffold(original) : nil final = case recipe.fetch(:name) when "readme_metadata" synchronize_readme(original, facts) @@ -189,6 +199,8 @@ def execute_recipe(project_root:, recipe:, facts:, files:) normalize_changelog(original, facts) when "generated_block_sync" synchronize_managed_block(original, facts) + when "rakefile_scaffold_cleanup" + deletion.fetch(:content) else original end @@ -198,14 +210,15 @@ def execute_recipe(project_root:, recipe:, facts:, files:) recipe_version: "1", relative_path: relative_path, provider_family: recipe.fetch(:provider_family), + provider_backend: recipe[:provider_backend], template_content: "", destination_content: original, steps: [content_recipe_step(recipe)], - runtime_context: facts, + runtime_context: recipe_runtime_context(recipe, facts, deletion), metadata: { packaging_recipe: recipe.fetch(:name), project_root: project_root.to_s }, ) changed = final != original - step_report = content_recipe_step_report(recipe: recipe, request: request, original: original, final: final, changed: changed) + step_report = content_recipe_step_report(recipe: recipe, request: request, original: original, final: final, changed: changed, deletion: deletion) report = content_recipe_execution_report( request: request, final_content: final, @@ -227,16 +240,28 @@ def execute_recipe(project_root:, recipe:, facts:, files:) end def content_recipe_step(recipe) - { + step = { step_id: recipe.fetch(:name), step_kind: recipe.fetch(:primitive), name: recipe.fetch(:name), provider_family: recipe.fetch(:provider_family), metadata: { target_path: recipe.fetch(:target_path) }, } + step[:provider_backend] = recipe[:provider_backend] if recipe[:provider_backend] + if recipe.fetch(:primitive) == "supplied_source_selector_deletion" + step[:step_kind] = "native_policy" + step[:policy] = { + policy_kind: "delete_supplied_structural_owners", + required_context: "delete_selectors", + operation: "delete", + selector_family: "structural_owner_range", + normalize_blank_lines: true, + } + end + step end - def content_recipe_step_report(recipe:, request:, original:, final:, changed:) + def content_recipe_step_report(recipe:, request:, original:, final:, changed:, deletion: nil) operation_profile = Ast::Merge.structured_edit_operation_profile( operation_kind: recipe.fetch(:primitive), known_operation_kind: true, @@ -263,7 +288,7 @@ def content_recipe_step_report(recipe:, request:, original:, final:, changed:) output_content: final, application: application, diagnostics: [], - metadata: { target_path: recipe.fetch(:target_path) }, + metadata: step_report_metadata(recipe, deletion), } end @@ -275,17 +300,39 @@ def read_project_files(project_root, pack) end end - def recipe_entry(name, target_path, provider_family, primitive, facts:) + def recipe_entry(name, target_path, provider_family, primitive, facts:, provider_backend: nil, selectors: []) { name: name, target_path: target_path, provider_family: provider_family, + provider_backend: provider_backend, primitive: primitive, facts: facts, - selectors: [], + selectors: selectors, } end + def recipe_runtime_context(recipe, facts, deletion) + context = deep_dup(facts) + if recipe.fetch(:primitive) == "supplied_source_selector_deletion" && deletion + context[:delete_selectors] = deletion.fetch(:delete_selectors) + end + context + end + + def step_report_metadata(recipe, deletion) + metadata = { target_path: recipe.fetch(:target_path) } + return metadata unless deletion + + metadata.merge( + policy_kind: "delete_supplied_structural_owners", + operation: "delete", + consumed_context: "delete_selectors", + deleted_ranges: deletion.fetch(:delete_selectors).length, + deleted_selector_ids: deletion.fetch(:delete_selectors).map { |selector| selector.fetch(:selector_id) }, + ) + end + def extract_gemspec_assignment(source, field) match = source.match(/#{Regexp.escape(field)}\s*=\s*["']([^"']*)["']/) match && match[1] @@ -377,6 +424,102 @@ def readme_metadata_block(facts) ].join("\n") end + def delete_rakefile_scaffold(content) + selectors = rakefile_scaffold_delete_selectors(content) + { + content: delete_line_ranges(content.to_s, selectors), + delete_selectors: selectors, + } + end + + def rakefile_scaffold_delete_selectors(content) + lines = content.to_s.lines + selectors = [] + lines.each_with_index do |line, index| + case line + when /\A\s*require\s+["']bundler\/gem_tasks["']\s*(?:#.*)?\n?\z/ + selectors << rakefile_selector( + "rakefile_scaffold_require_bundler_gem_tasks", + index + 1, + index + 1, + "wrapper_selected_scaffold_require" + ) + when /\A\s*require\s+["']rspec\/core\/rake_task["']\s*(?:#.*)?\n?\z/ + selectors << rakefile_selector( + "rakefile_scaffold_require_rspec_core_rake_task", + index + 1, + index + 1, + "wrapper_selected_scaffold_require" + ) + when /\A\s*require\s+["']rubocop\/rake_task["']\s*(?:#.*)?\n?\z/ + selectors << rakefile_selector( + "rakefile_scaffold_require_rubocop_rake_task", + index + 1, + index + 1, + "wrapper_selected_scaffold_require" + ) + when /\A\s*RSpec::Core::RakeTask\.new\b/ + selectors << rakefile_selector("rakefile_scaffold_rspec_task", index + 1, index + 1, + "wrapper_selected_scaffold_task") + when /\A\s*RuboCop::RakeTask\.new\b/ + selectors << rakefile_selector("rakefile_scaffold_rubocop_task", index + 1, index + 1, + "wrapper_selected_scaffold_task") + end + end + selectors.concat(rakefile_task_block_selectors(lines)) + selectors.sort_by { |selector| [selector.fetch(:start_line), selector.fetch(:end_line)] } + end + + def rakefile_task_block_selectors(lines) + selectors = [] + index = 0 + while index < lines.length + line = lines[index] + if line.match?(/\A\s*task\s+default:/) || line.match?(/\A\s*task\s+:default\b/) + end_index = rakefile_block_end(lines, index) + selectors << rakefile_selector("rakefile_scaffold_task_default", index + 1, end_index + 1, + "wrapper_selected_scaffold_task") + index = end_index + 1 + next + end + index += 1 + end + selectors + end + + def rakefile_block_end(lines, start_index) + return start_index unless lines[start_index].match?(/\bdo\b/) + + depth = 0 + (start_index...lines.length).each do |index| + stripped = lines[index].strip + depth += 1 if stripped.match?(/\bdo\b/) + return index if depth.positive? && stripped == "end" && (depth -= 1).zero? + return index if depth.zero? && index > start_index && !stripped.empty? + end + lines.length - 1 + end + + def rakefile_selector(selector_id, start_line, end_line, reason) + { + selector_id: selector_id, + selector_family: "structural_owner_range", + start_line: start_line, + end_line: end_line, + reason: reason, + } + end + + def delete_line_ranges(content, selectors) + lines = content.lines + selectors.sort_by { |selector| -selector.fetch(:start_line) }.each do |selector| + start_index = selector.fetch(:start_line) - 1 + end_index = selector.fetch(:end_line) - 1 + lines.slice!(start_index..end_index) + end + lines.join.gsub(/\n{3,}/, "\n\n") + end + def replace_markdown_managed_block(content, marker, replacement) open = "" close = "" diff --git a/gems/kettle-jem/spec/fixtures/thin_slice.json b/gems/kettle-jem/spec/fixtures/thin_slice.json index 2799827..d1466d0 100644 --- a/gems/kettle-jem/spec/fixtures/thin_slice.json +++ b/gems/kettle-jem/spec/fixtures/thin_slice.json @@ -7,7 +7,8 @@ ".github/FUNDING.yml": "github: [example]\nopen_collective: example\ncustom:\n - https://example.test/fund\n", "README.md": "# Old Name\n\nExisting intro.\n", "CHANGELOG.md": "# Changelog\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", - "gemfiles/modular/shunted.gemfile": "# frozen_string_literal: true\n\n# local additions stay above\n" + "gemfiles/modular/shunted.gemfile": "# frozen_string_literal: true\n\n# local additions stay above\n", + "Rakefile": "# frozen_string_literal: true\n\nrequire \"bundler/gem_tasks\"\nrequire \"rspec/core/rake_task\"\n\nRSpec::Core::RakeTask.new(:spec)\n\ntask default: :spec\n\n# Keep custom task.\ntask :custom do\n puts \"custom\"\nend\n" } }, "expected": { @@ -37,11 +38,13 @@ "changed_files": [ "CHANGELOG.md", "README.md", + "Rakefile", "gemfiles/modular/shunted.gemfile" ], "files": { "README.md": "# example\n\nExisting intro.\n\n\n| Field | Value |\n|---|---|\n| Package | example |\n| Description | Example gem for kettle-jem |\n| Homepage | https://example.test |\n| Source | https://github.com/example/example |\n| License | MIT |\n| Funding | https://example.test/fund, https://github.com/sponsors/example, https://opencollective.com/example |\n\n", "CHANGELOG.md": "# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", + "Rakefile": "# frozen_string_literal: true\n\n# Keep custom task.\ntask :custom do\n puts \"custom\"\nend\n", "gemfiles/modular/shunted.gemfile": "# frozen_string_literal: true\n\n# local additions stay above\n# <> do not edit below this line\n# package: example\n# generated by kettle-jem vNext\n# <>\n" } } diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index a8fce51..7886c37 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -44,7 +44,9 @@ def project_files(root, paths) plan = described_class.plan_project(root) expect(json_ready(plan[:facts])).to eq(json_ready(fixture.fetch(:expected).fetch(:facts))) - expect(plan[:recipe_pack][:recipes].map { |recipe| recipe[:name] }).to eq(expected_recipe_names) + recipe_names = plan[:recipe_pack][:recipes].map { |recipe| recipe[:name] } + expect(recipe_names.take(expected_recipe_names.length)).to eq(expected_recipe_names) + expect(recipe_names).to include("rakefile_scaffold_cleanup") expect(plan[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) expect(plan[:recipe_reports].map { |report| report[:request_envelope][:kind] }.uniq).to eq( [contract.fetch(:report_contract).fetch(:request_envelope_kind)] @@ -52,6 +54,12 @@ def project_files(root, paths) expect(plan[:recipe_reports].map { |report| report[:report_envelope][:kind] }.uniq).to eq( [contract.fetch(:report_contract).fetch(:report_envelope_kind)] ) + rakefile_report = plan[:recipe_reports].find { |report| report.fetch(:recipe_name) == "rakefile_scaffold_cleanup" } + expect(rakefile_report.dig(:request_envelope, :request, :runtime_context, :delete_selectors).length).to eq(4) + expect(rakefile_report.dig(:report_envelope, :report, :step_reports, 0, :metadata, :deleted_ranges)).to eq(4) + expect(rakefile_report.fetch(:final_content)).to include("task :custom") + expect(rakefile_report.fetch(:final_content)).not_to include("bundler/gem_tasks") + expect(rakefile_report.fetch(:final_content)).not_to include("RSpec::Core::RakeTask") apply = described_class.apply_project(root) expect(apply[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) From 08740039dda795411820e0937edb76836f49449f Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 00:44:17 -0600 Subject: [PATCH 17/71] Add kettle-jem GitHub Actions CI recipe Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 82 +++++++++++++++++++ gems/kettle-jem/spec/fixtures/thin_slice.json | 11 +++ gems/kettle-jem/spec/thin_slice_spec.rb | 5 ++ 3 files changed, 98 insertions(+) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 93dd09f..b5a6dda 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -42,6 +42,11 @@ def discover_facts(project_root) } funding = compact_hash(urls: funding_urls(project_root, gemspec)) facts[:funding] = funding unless funding.empty? + facts[:ci] = { + provider: "github_actions", + default_branch: "main", + ruby_versions: github_actions_ruby_versions(facts.fetch(:rubygems).fetch(:min_ruby, nil)), + } facts end @@ -54,6 +59,13 @@ def recipe_pack(facts) recipe_entry("readme_metadata", "README.md", "markdown", "supplied_readme_metadata_synchronization", facts: %w[package funding readme]), recipe_entry("changelog_unreleased", "CHANGELOG.md", "markdown", "changelog_unreleased_normalization", facts: %w[package changelog]), recipe_entry("generated_block_sync", "gemfiles/modular/shunted.gemfile", "text", "supplied_managed_text_block_replacement", facts: %w[package generated_blocks]), + recipe_entry( + "github_actions_ci", + ".github/workflows/ci.yml", + "yaml", + "supplied_github_actions_workflow_synchronization", + facts: %w[package rubygems ci] + ), recipe_entry( "rakefile_scaffold_cleanup", "Rakefile", @@ -199,6 +211,8 @@ def execute_recipe(project_root:, recipe:, facts:, files:) normalize_changelog(original, facts) when "generated_block_sync" synchronize_managed_block(original, facts) + when "github_actions_ci" + synchronize_github_actions_ci(original, facts) when "rakefile_scaffold_cleanup" deletion.fetch(:content) else @@ -399,6 +413,13 @@ def github_funding_platform_urls(platform, values) end end + def github_actions_ruby_versions(min_ruby) + floor = min_ruby.to_s[/\d+\.\d+/] || "3.1" + candidates = %w[3.1 3.2 3.3 3.4] + selected = candidates.select { |version| Gem::Version.new(version) >= Gem::Version.new(floor) } + selected.empty? ? [floor] : selected + end + def classify_namespace(name) name.to_s.split(/[-_]/).map { |part| part[0].to_s.upcase + part[1..].to_s }.join("::") end @@ -520,6 +541,67 @@ def delete_line_ranges(content, selectors) lines.join.gsub(/\n{3,}/, "\n\n") end + def synchronize_github_actions_ci(_content, facts) + package = facts.fetch(:package) + ci = facts.fetch(:ci) + ruby_versions = ci.fetch(:ruby_versions) + ruby_matrix = ruby_versions.map { |version| " - \"#{version}\"" }.join("\n") + + <<~YAML + name: CI + + permissions: + contents: read + + on: + push: + branches: + - "#{ci.fetch(:default_branch)}" + - "*-stable" + tags: + - "!*" # Do not execute on tags + pull_request: + branches: + - "*" + workflow_dispatch: + + concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + + jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }} + runs-on: ubuntu-latest + continue-on-error: ${{ endsWith(matrix.ruby, 'head') }} + strategy: + fail-fast: false + matrix: + ruby: + #{ruby_matrix} + rubygems: + - default + bundler: + - default + + steps: + - name: Checkout #{package.fetch(:name)} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: "${{ matrix.ruby }}" + rubygems: "${{ matrix.rubygems }}" + bundler: "${{ matrix.bundler }}" + bundler-cache: true + + - name: Tests + run: bundle exec rake + YAML + end + def replace_markdown_managed_block(content, marker, replacement) open = "" close = "" diff --git a/gems/kettle-jem/spec/fixtures/thin_slice.json b/gems/kettle-jem/spec/fixtures/thin_slice.json index d1466d0..09e5fc1 100644 --- a/gems/kettle-jem/spec/fixtures/thin_slice.json +++ b/gems/kettle-jem/spec/fixtures/thin_slice.json @@ -33,15 +33,26 @@ "https://github.com/sponsors/example", "https://opencollective.com/example" ] + }, + "ci": { + "provider": "github_actions", + "default_branch": "main", + "ruby_versions": [ + "3.2", + "3.3", + "3.4" + ] } }, "changed_files": [ + ".github/workflows/ci.yml", "CHANGELOG.md", "README.md", "Rakefile", "gemfiles/modular/shunted.gemfile" ], "files": { + ".github/workflows/ci.yml": "name: CI\n\npermissions:\n contents: read\n\non:\n push:\n branches:\n - \"main\"\n - \"*-stable\"\n tags:\n - \"!*\" # Do not execute on tags\n pull_request:\n branches:\n - \"*\"\n workflow_dispatch:\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n if: \"!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')\"\n name: Specs ${{ matrix.ruby }}\n runs-on: ubuntu-latest\n continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}\n strategy:\n fail-fast: false\n matrix:\n ruby:\n - \"3.2\"\n - \"3.3\"\n - \"3.4\"\n rubygems:\n - default\n bundler:\n - default\n\n steps:\n - name: Checkout example\n uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n - name: Setup Ruby & RubyGems\n uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n rubygems: \"${{ matrix.rubygems }}\"\n bundler: \"${{ matrix.bundler }}\"\n bundler-cache: true\n\n - name: Tests\n run: bundle exec rake\n", "README.md": "# example\n\nExisting intro.\n\n\n| Field | Value |\n|---|---|\n| Package | example |\n| Description | Example gem for kettle-jem |\n| Homepage | https://example.test |\n| Source | https://github.com/example/example |\n| License | MIT |\n| Funding | https://example.test/fund, https://github.com/sponsors/example, https://opencollective.com/example |\n\n", "CHANGELOG.md": "# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", "Rakefile": "# frozen_string_literal: true\n\n# Keep custom task.\ntask :custom do\n puts \"custom\"\nend\n", diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 7886c37..439cbec 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -46,6 +46,7 @@ def project_files(root, paths) expect(json_ready(plan[:facts])).to eq(json_ready(fixture.fetch(:expected).fetch(:facts))) recipe_names = plan[:recipe_pack][:recipes].map { |recipe| recipe[:name] } expect(recipe_names.take(expected_recipe_names.length)).to eq(expected_recipe_names) + expect(recipe_names).to include("github_actions_ci") expect(recipe_names).to include("rakefile_scaffold_cleanup") expect(plan[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) expect(plan[:recipe_reports].map { |report| report[:request_envelope][:kind] }.uniq).to eq( @@ -60,6 +61,10 @@ def project_files(root, paths) expect(rakefile_report.fetch(:final_content)).to include("task :custom") expect(rakefile_report.fetch(:final_content)).not_to include("bundler/gem_tasks") expect(rakefile_report.fetch(:final_content)).not_to include("RSpec::Core::RakeTask") + ci_report = plan[:recipe_reports].find { |report| report.fetch(:recipe_name) == "github_actions_ci" } + expect(ci_report.dig(:request_envelope, :request, :provider_family)).to eq("yaml") + expect(ci_report.fetch(:final_content)).to include("ruby/setup-ruby@") + expect(ci_report.fetch(:final_content)).to include("- \"3.2\"") apply = described_class.apply_project(root) expect(apply[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) From c7d5bf163b0a468e78bb335ef9503d3f667f1a79 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 00:46:09 -0600 Subject: [PATCH 18/71] Add kettle-jem framework matrix workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 177 +++++++++++++++--- gems/kettle-jem/spec/fixtures/thin_slice.json | 23 ++- gems/kettle-jem/spec/thin_slice_spec.rb | 5 + 3 files changed, 183 insertions(+), 22 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index b5a6dda..f44f22a 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -40,6 +40,7 @@ def discover_facts(project_root) min_ruby: extract_gemspec_assignment(gemspec, "spec.required_ruby_version"), ), } + kettle_config = kettle_jem_config(project_root) funding = compact_hash(urls: funding_urls(project_root, gemspec)) facts[:funding] = funding unless funding.empty? facts[:ci] = { @@ -47,35 +48,48 @@ def discover_facts(project_root) default_branch: "main", ruby_versions: github_actions_ruby_versions(facts.fetch(:rubygems).fetch(:min_ruby, nil)), } + framework_matrix = github_actions_framework_matrix(kettle_config) + facts[:ci][:framework_matrix] = framework_matrix unless framework_matrix.empty? facts end def recipe_pack(facts) + recipes = [ + recipe_entry("readme_metadata", "README.md", "markdown", "supplied_readme_metadata_synchronization", facts: %w[package funding readme]), + recipe_entry("changelog_unreleased", "CHANGELOG.md", "markdown", "changelog_unreleased_normalization", facts: %w[package changelog]), + recipe_entry("generated_block_sync", "gemfiles/modular/shunted.gemfile", "text", "supplied_managed_text_block_replacement", facts: %w[package generated_blocks]), + recipe_entry( + "github_actions_ci", + ".github/workflows/ci.yml", + "yaml", + "supplied_github_actions_workflow_synchronization", + facts: %w[package rubygems ci] + ), + ] + if facts.dig(:ci, :framework_matrix) + recipes << recipe_entry( + "github_actions_framework_ci", + ".github/workflows/framework-ci.yml", + "yaml", + "supplied_github_actions_framework_workflow_synchronization", + facts: %w[package rubygems ci] + ) + end + recipes << recipe_entry( + "rakefile_scaffold_cleanup", + "Rakefile", + "generic_ast", + "supplied_source_selector_deletion", + provider_backend: "generic_structural_owners", + facts: %w[rubygems rakefile], + selectors: %w[rakefile_scaffold] + ) + { name: "kettle-jem-core", version: 1, ecosystem: "rubygems", - recipes: [ - recipe_entry("readme_metadata", "README.md", "markdown", "supplied_readme_metadata_synchronization", facts: %w[package funding readme]), - recipe_entry("changelog_unreleased", "CHANGELOG.md", "markdown", "changelog_unreleased_normalization", facts: %w[package changelog]), - recipe_entry("generated_block_sync", "gemfiles/modular/shunted.gemfile", "text", "supplied_managed_text_block_replacement", facts: %w[package generated_blocks]), - recipe_entry( - "github_actions_ci", - ".github/workflows/ci.yml", - "yaml", - "supplied_github_actions_workflow_synchronization", - facts: %w[package rubygems ci] - ), - recipe_entry( - "rakefile_scaffold_cleanup", - "Rakefile", - "generic_ast", - "supplied_source_selector_deletion", - provider_backend: "generic_structural_owners", - facts: %w[rubygems rakefile], - selectors: %w[rakefile_scaffold] - ), - ], + recipes: recipes, } end @@ -213,6 +227,8 @@ def execute_recipe(project_root:, recipe:, facts:, files:) synchronize_managed_block(original, facts) when "github_actions_ci" synchronize_github_actions_ci(original, facts) + when "github_actions_framework_ci" + synchronize_github_actions_framework_ci(original, facts) when "rakefile_scaffold_cleanup" deletion.fetch(:content) else @@ -420,6 +436,53 @@ def github_actions_ruby_versions(min_ruby) selected.empty? ? [floor] : selected end + def kettle_jem_config(project_root) + path = File.join(project_root, ".kettle-jem.yml") + return {} unless File.exist?(path) + + config = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {} + config.is_a?(Hash) ? config : {} + end + + def github_actions_framework_matrix(config) + workflows = config["workflows"] + return {} unless workflows.is_a?(Hash) && workflows["preset"].to_s.strip.downcase == "framework" + + raw = workflows["framework_matrix"] + return {} unless raw.is_a?(Hash) + + dimension = raw["dimension"].to_s.strip + versions = raw["versions"] + pattern = raw["gemfile_pattern"].to_s.strip + return {} unless !dimension.empty? && versions.is_a?(Array) && !versions.empty? && !pattern.empty? + + normalized_versions = versions.map { |version| version.to_s.strip }.reject(&:empty?) + return {} if normalized_versions.empty? + + { + dimension: dimension, + versions: normalized_versions, + gemfile_pattern: pattern, + include: normalized_versions.map do |version| + gemfile = expand_framework_gemfile_pattern(pattern, version) + { framework_version: version, gemfile: framework_gemfile_path(gemfile) } + end, + } + end + + def expand_framework_gemfile_pattern(pattern, version) + replacement = if pattern.include?("_{version}") || pattern.include?("{version}_") + version.tr(".", "_") + else + version + end + pattern.gsub("{version}", replacement) + end + + def framework_gemfile_path(gemfile) + gemfile.include?("/") ? gemfile : "gemfiles/#{gemfile}" + end + def classify_namespace(name) name.to_s.split(/[-_]/).map { |part| part[0].to_s.upcase + part[1..].to_s }.join("::") end @@ -602,6 +665,78 @@ def synchronize_github_actions_ci(_content, facts) YAML end + def synchronize_github_actions_framework_ci(_content, facts) + ci = facts.fetch(:ci) + framework_matrix = ci.fetch(:framework_matrix) + ruby_matrix = ci.fetch(:ruby_versions).map { |version| " - \"#{version}\"" }.join("\n") + include_matrix = framework_matrix.fetch(:include).map do |entry| + [ + " - framework_version: \"#{entry.fetch(:framework_version)}\"", + " gemfile: \"#{entry.fetch(:gemfile)}\"", + ].join("\n") + end.join("\n") + dimension = framework_matrix.fetch(:dimension) + label = dimension.split(/[-_]/).map { |part| part[0].to_s.upcase + part[1..].to_s }.join(" ") + + <<~YAML + name: #{label} CI + + permissions: + contents: read + + on: + push: + branches: + - "#{ci.fetch(:default_branch)}" + - "*-stable" + tags: + - "!*" # Do not execute on tags + pull_request: + branches: + - "*" + workflow_dispatch: + + concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + + jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.framework_version }} + runs-on: ubuntu-latest + continue-on-error: ${{ endsWith(matrix.ruby, 'head') }} + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} + strategy: + fail-fast: false + matrix: + ruby: + #{ruby_matrix} + rubygems: + - default + bundler: + - default + include: + #{include_matrix} + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: "${{ matrix.ruby }}" + rubygems: "${{ matrix.rubygems }}" + bundler: "${{ matrix.bundler }}" + bundler-cache: true + + - name: Tests for ${{ matrix.ruby }}@${{ matrix.framework_version }} + run: bundle exec rake test + YAML + end + def replace_markdown_managed_block(content, marker, replacement) open = "" close = "" diff --git a/gems/kettle-jem/spec/fixtures/thin_slice.json b/gems/kettle-jem/spec/fixtures/thin_slice.json index 09e5fc1..7aac7ea 100644 --- a/gems/kettle-jem/spec/fixtures/thin_slice.json +++ b/gems/kettle-jem/spec/fixtures/thin_slice.json @@ -4,6 +4,7 @@ "inputs": { "files": { "example.gemspec": "# frozen_string_literal: true\n\nGem::Specification.new do |spec|\n spec.name = \"example\"\n spec.summary = \"Example gem\"\n spec.description = \"Example gem for kettle-jem\"\n spec.homepage = \"https://example.test\"\n spec.metadata[\"source_code_uri\"] = \"https://github.com/example/example\"\n spec.metadata[\"funding_uri\"] = \"https://github.com/sponsors/example\"\n spec.licenses = [\"MIT\"]\n spec.required_ruby_version = \">= 3.2\"\nend\n", + ".kettle-jem.yml": "workflows:\n preset: framework\n framework_matrix:\n dimension: rails\n versions:\n - \"7.0\"\n - \"7.1\"\n gemfile_pattern: rails_{version}\n", ".github/FUNDING.yml": "github: [example]\nopen_collective: example\ncustom:\n - https://example.test/fund\n", "README.md": "# Old Name\n\nExisting intro.\n", "CHANGELOG.md": "# Changelog\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", @@ -41,17 +42,37 @@ "3.2", "3.3", "3.4" - ] + ], + "framework_matrix": { + "dimension": "rails", + "versions": [ + "7.0", + "7.1" + ], + "gemfile_pattern": "rails_{version}", + "include": [ + { + "framework_version": "7.0", + "gemfile": "gemfiles/rails_7_0" + }, + { + "framework_version": "7.1", + "gemfile": "gemfiles/rails_7_1" + } + ] + } } }, "changed_files": [ ".github/workflows/ci.yml", + ".github/workflows/framework-ci.yml", "CHANGELOG.md", "README.md", "Rakefile", "gemfiles/modular/shunted.gemfile" ], "files": { + ".github/workflows/framework-ci.yml": "name: Rails CI\n\npermissions:\n contents: read\n\non:\n push:\n branches:\n - \"main\"\n - \"*-stable\"\n tags:\n - \"!*\" # Do not execute on tags\n pull_request:\n branches:\n - \"*\"\n workflow_dispatch:\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n if: \"!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')\"\n name: Specs ${{ matrix.ruby }}@${{ matrix.framework_version }}\n runs-on: ubuntu-latest\n continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}\n env:\n BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}\n strategy:\n fail-fast: false\n matrix:\n ruby:\n - \"3.2\"\n - \"3.3\"\n - \"3.4\"\n rubygems:\n - default\n bundler:\n - default\n include:\n - framework_version: \"7.0\"\n gemfile: \"gemfiles/rails_7_0\"\n - framework_version: \"7.1\"\n gemfile: \"gemfiles/rails_7_1\"\n\n steps:\n - name: Checkout\n uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n - name: Setup Ruby & RubyGems\n uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n rubygems: \"${{ matrix.rubygems }}\"\n bundler: \"${{ matrix.bundler }}\"\n bundler-cache: true\n\n - name: Tests for ${{ matrix.ruby }}@${{ matrix.framework_version }}\n run: bundle exec rake test\n", ".github/workflows/ci.yml": "name: CI\n\npermissions:\n contents: read\n\non:\n push:\n branches:\n - \"main\"\n - \"*-stable\"\n tags:\n - \"!*\" # Do not execute on tags\n pull_request:\n branches:\n - \"*\"\n workflow_dispatch:\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n if: \"!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')\"\n name: Specs ${{ matrix.ruby }}\n runs-on: ubuntu-latest\n continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}\n strategy:\n fail-fast: false\n matrix:\n ruby:\n - \"3.2\"\n - \"3.3\"\n - \"3.4\"\n rubygems:\n - default\n bundler:\n - default\n\n steps:\n - name: Checkout example\n uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n - name: Setup Ruby & RubyGems\n uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n rubygems: \"${{ matrix.rubygems }}\"\n bundler: \"${{ matrix.bundler }}\"\n bundler-cache: true\n\n - name: Tests\n run: bundle exec rake\n", "README.md": "# example\n\nExisting intro.\n\n\n| Field | Value |\n|---|---|\n| Package | example |\n| Description | Example gem for kettle-jem |\n| Homepage | https://example.test |\n| Source | https://github.com/example/example |\n| License | MIT |\n| Funding | https://example.test/fund, https://github.com/sponsors/example, https://opencollective.com/example |\n\n", "CHANGELOG.md": "# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 439cbec..fd0798b 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -47,6 +47,7 @@ def project_files(root, paths) recipe_names = plan[:recipe_pack][:recipes].map { |recipe| recipe[:name] } expect(recipe_names.take(expected_recipe_names.length)).to eq(expected_recipe_names) expect(recipe_names).to include("github_actions_ci") + expect(recipe_names).to include("github_actions_framework_ci") expect(recipe_names).to include("rakefile_scaffold_cleanup") expect(plan[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) expect(plan[:recipe_reports].map { |report| report[:request_envelope][:kind] }.uniq).to eq( @@ -65,6 +66,10 @@ def project_files(root, paths) expect(ci_report.dig(:request_envelope, :request, :provider_family)).to eq("yaml") expect(ci_report.fetch(:final_content)).to include("ruby/setup-ruby@") expect(ci_report.fetch(:final_content)).to include("- \"3.2\"") + framework_ci_report = plan[:recipe_reports].find { |report| report.fetch(:recipe_name) == "github_actions_framework_ci" } + expect(framework_ci_report.fetch(:final_content)).to include("name: Rails CI") + expect(framework_ci_report.fetch(:final_content)).to include("gemfiles/rails_7_0") + expect(framework_ci_report.fetch(:final_content)).to include("BUNDLE_GEMFILE") apply = described_class.apply_project(root) expect(apply[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) From 7a148b66ef41bf949b38358cae69a34271155960 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 00:48:36 -0600 Subject: [PATCH 19/71] Add kettle-jem workflow snippet merging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 76 +++++++++++++++++++ gems/kettle-jem/spec/fixtures/thin_slice.json | 6 ++ gems/kettle-jem/spec/thin_slice_spec.rb | 9 +++ 3 files changed, 91 insertions(+) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index f44f22a..9457d3a 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -47,6 +47,7 @@ def discover_facts(project_root) provider: "github_actions", default_branch: "main", ruby_versions: github_actions_ruby_versions(facts.fetch(:rubygems).fetch(:min_ruby, nil)), + custom_workflows: github_actions_custom_workflows(project_root), } framework_matrix = github_actions_framework_matrix(kettle_config) facts[:ci][:framework_matrix] = framework_matrix unless framework_matrix.empty? @@ -75,6 +76,15 @@ def recipe_pack(facts) facts: %w[package rubygems ci] ) end + facts.dig(:ci, :custom_workflows).to_a.each do |workflow_path| + recipes << recipe_entry( + "github_actions_workflow_snippets_#{workflow_recipe_slug(workflow_path)}", + workflow_path, + "yaml", + "supplied_github_actions_workflow_snippet_merge", + facts: %w[ci] + ) + end recipes << recipe_entry( "rakefile_scaffold_cleanup", "Rakefile", @@ -229,6 +239,8 @@ def execute_recipe(project_root:, recipe:, facts:, files:) synchronize_github_actions_ci(original, facts) when "github_actions_framework_ci" synchronize_github_actions_framework_ci(original, facts) + when /\Agithub_actions_workflow_snippets_/ + synchronize_github_actions_workflow_snippets(original) when "rakefile_scaffold_cleanup" deletion.fetch(:content) else @@ -436,6 +448,22 @@ def github_actions_ruby_versions(min_ruby) selected.empty? ? [floor] : selected end + def github_actions_custom_workflows(project_root) + workflow_root = File.join(project_root, ".github", "workflows") + return [] unless Dir.exist?(workflow_root) + + Dir.glob(File.join(workflow_root, "*.{yml,yaml}")).filter_map do |path| + relative_path = path.delete_prefix("#{project_root}/") + next if %w[.github/workflows/ci.yml .github/workflows/framework-ci.yml].include?(relative_path) + + relative_path + end.sort + end + + def workflow_recipe_slug(workflow_path) + workflow_path.gsub(/[^a-zA-Z0-9]+/, "_").gsub(/\A_+|_+\z/, "") + end + def kettle_jem_config(project_root) path = File.join(project_root, ".kettle-jem.yml") return {} unless File.exist?(path) @@ -737,6 +765,54 @@ def synchronize_github_actions_framework_ci(_content, facts) YAML end + def synchronize_github_actions_workflow_snippets(content) + updated = ensure_workflow_top_level_section( + content.to_s, + "permissions", + "permissions:\n contents: read\n\n", + before: "on" + ) + updated = ensure_workflow_top_level_section( + updated, + "concurrency", + "concurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\n", + before: "jobs" + ) + update_github_actions_pins(updated) + end + + def ensure_workflow_top_level_section(content, key, section, before:) + return content if content.match?(/^#{Regexp.escape(key)}:/) + + lines = content.lines + index = lines.index { |line| line.match?(/^#{Regexp.escape(before)}:/) } + if index + prepared_section = index.zero? || lines[index - 1].strip.empty? ? section : "\n#{section}" + lines.insert(index, prepared_section) + else + lines << "\n" unless lines.empty? || lines.last == "\n" + lines << section + end + lines.join + end + + def update_github_actions_pins(content) + github_actions_step_pins.reduce(content) do |updated, (action_prefix, pinned_value)| + updated.gsub(/^(\s*(?:-\s*)?uses:\s*)#{Regexp.escape(action_prefix)}@\S+(?:\s+#.*)?$/) do + "#{$1}#{pinned_value}" + end + end + end + + def github_actions_step_pins + { + "actions/checkout" => "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2", + "ruby/setup-ruby" => "ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0", + "coverallsapp/github-action" => "coverallsapp/github-action@0a51d2e0b5417d06e4ecceb534aec87defc53926 # main", + "codecov/codecov-action" => "codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0", + } + end + def replace_markdown_managed_block(content, marker, replacement) open = "" close = "" diff --git a/gems/kettle-jem/spec/fixtures/thin_slice.json b/gems/kettle-jem/spec/fixtures/thin_slice.json index 7aac7ea..20d62a6 100644 --- a/gems/kettle-jem/spec/fixtures/thin_slice.json +++ b/gems/kettle-jem/spec/fixtures/thin_slice.json @@ -6,6 +6,7 @@ "example.gemspec": "# frozen_string_literal: true\n\nGem::Specification.new do |spec|\n spec.name = \"example\"\n spec.summary = \"Example gem\"\n spec.description = \"Example gem for kettle-jem\"\n spec.homepage = \"https://example.test\"\n spec.metadata[\"source_code_uri\"] = \"https://github.com/example/example\"\n spec.metadata[\"funding_uri\"] = \"https://github.com/sponsors/example\"\n spec.licenses = [\"MIT\"]\n spec.required_ruby_version = \">= 3.2\"\nend\n", ".kettle-jem.yml": "workflows:\n preset: framework\n framework_matrix:\n dimension: rails\n versions:\n - \"7.0\"\n - \"7.1\"\n gemfile_pattern: rails_{version}\n", ".github/FUNDING.yml": "github: [example]\nopen_collective: example\ncustom:\n - https://example.test/fund\n", + ".github/workflows/custom-ci.yml": "name: Custom CI\non:\n push:\n branches: [main]\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n ruby: [\"3.2\", \"3.3\"]\n steps:\n - uses: actions/checkout@v3\n - uses: ruby/setup-ruby@v1\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n", "README.md": "# Old Name\n\nExisting intro.\n", "CHANGELOG.md": "# Changelog\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", "gemfiles/modular/shunted.gemfile": "# frozen_string_literal: true\n\n# local additions stay above\n", @@ -43,6 +44,9 @@ "3.3", "3.4" ], + "custom_workflows": [ + ".github/workflows/custom-ci.yml" + ], "framework_matrix": { "dimension": "rails", "versions": [ @@ -65,6 +69,7 @@ }, "changed_files": [ ".github/workflows/ci.yml", + ".github/workflows/custom-ci.yml", ".github/workflows/framework-ci.yml", "CHANGELOG.md", "README.md", @@ -73,6 +78,7 @@ ], "files": { ".github/workflows/framework-ci.yml": "name: Rails CI\n\npermissions:\n contents: read\n\non:\n push:\n branches:\n - \"main\"\n - \"*-stable\"\n tags:\n - \"!*\" # Do not execute on tags\n pull_request:\n branches:\n - \"*\"\n workflow_dispatch:\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n if: \"!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')\"\n name: Specs ${{ matrix.ruby }}@${{ matrix.framework_version }}\n runs-on: ubuntu-latest\n continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}\n env:\n BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}\n strategy:\n fail-fast: false\n matrix:\n ruby:\n - \"3.2\"\n - \"3.3\"\n - \"3.4\"\n rubygems:\n - default\n bundler:\n - default\n include:\n - framework_version: \"7.0\"\n gemfile: \"gemfiles/rails_7_0\"\n - framework_version: \"7.1\"\n gemfile: \"gemfiles/rails_7_1\"\n\n steps:\n - name: Checkout\n uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n - name: Setup Ruby & RubyGems\n uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n rubygems: \"${{ matrix.rubygems }}\"\n bundler: \"${{ matrix.bundler }}\"\n bundler-cache: true\n\n - name: Tests for ${{ matrix.ruby }}@${{ matrix.framework_version }}\n run: bundle exec rake test\n", + ".github/workflows/custom-ci.yml": "name: Custom CI\n\npermissions:\n contents: read\n\non:\n push:\n branches: [main]\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n ruby: [\"3.2\", \"3.3\"]\n steps:\n - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n", ".github/workflows/ci.yml": "name: CI\n\npermissions:\n contents: read\n\non:\n push:\n branches:\n - \"main\"\n - \"*-stable\"\n tags:\n - \"!*\" # Do not execute on tags\n pull_request:\n branches:\n - \"*\"\n workflow_dispatch:\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n if: \"!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')\"\n name: Specs ${{ matrix.ruby }}\n runs-on: ubuntu-latest\n continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}\n strategy:\n fail-fast: false\n matrix:\n ruby:\n - \"3.2\"\n - \"3.3\"\n - \"3.4\"\n rubygems:\n - default\n bundler:\n - default\n\n steps:\n - name: Checkout example\n uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n - name: Setup Ruby & RubyGems\n uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n rubygems: \"${{ matrix.rubygems }}\"\n bundler: \"${{ matrix.bundler }}\"\n bundler-cache: true\n\n - name: Tests\n run: bundle exec rake\n", "README.md": "# example\n\nExisting intro.\n\n\n| Field | Value |\n|---|---|\n| Package | example |\n| Description | Example gem for kettle-jem |\n| Homepage | https://example.test |\n| Source | https://github.com/example/example |\n| License | MIT |\n| Funding | https://example.test/fund, https://github.com/sponsors/example, https://opencollective.com/example |\n\n", "CHANGELOG.md": "# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index fd0798b..40968ca 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -49,6 +49,7 @@ def project_files(root, paths) expect(recipe_names).to include("github_actions_ci") expect(recipe_names).to include("github_actions_framework_ci") expect(recipe_names).to include("rakefile_scaffold_cleanup") + expect(recipe_names).to include(a_string_starting_with("github_actions_workflow_snippets_")) expect(plan[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) expect(plan[:recipe_reports].map { |report| report[:request_envelope][:kind] }.uniq).to eq( [contract.fetch(:report_contract).fetch(:request_envelope_kind)] @@ -70,6 +71,14 @@ def project_files(root, paths) expect(framework_ci_report.fetch(:final_content)).to include("name: Rails CI") expect(framework_ci_report.fetch(:final_content)).to include("gemfiles/rails_7_0") expect(framework_ci_report.fetch(:final_content)).to include("BUNDLE_GEMFILE") + custom_ci_report = plan[:recipe_reports].find do |report| + report.fetch(:relative_path) == ".github/workflows/custom-ci.yml" + end + expect(custom_ci_report.fetch(:final_content)).to include("permissions:") + expect(custom_ci_report.fetch(:final_content)).to include("concurrency:") + expect(custom_ci_report.fetch(:final_content)).to include("actions/checkout@de0fac2") + expect(custom_ci_report.fetch(:final_content)).to include("ruby/setup-ruby@e65c17") + expect(custom_ci_report.fetch(:final_content)).to include("ruby: [\"3.2\", \"3.3\"]") apply = described_class.apply_project(root) expect(apply[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) From 08a91502a8c928c3d857ea26986354c31404eae8 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 00:53:14 -0600 Subject: [PATCH 20/71] Add kettle-jem coverage workflow snippets Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 79 +++++++++++++++++++ gems/kettle-jem/spec/fixtures/thin_slice.json | 4 +- gems/kettle-jem/spec/thin_slice_spec.rb | 3 + 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 9457d3a..b910b2e 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -778,9 +778,85 @@ def synchronize_github_actions_workflow_snippets(content) "concurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\n", before: "jobs" ) + updated = append_github_actions_coverage_steps(updated) if github_actions_coverage_enabled?(updated) update_github_actions_pins(updated) end + def github_actions_coverage_enabled?(content) + content.match?(/K_SOUP_COV_DO:\s*["']?true["']?/) + end + + def append_github_actions_coverage_steps(content) + return content if content.include?("Upload coverage to Coveralls") || content.include?("Upload coverage to CodeCov") + + lines = content.lines + steps_index = lines.index { |line| line.match?(/^ steps:\s*$/) } + return content unless steps_index + + insert_index = lines.length + ((steps_index + 1)...lines.length).each do |index| + line = lines[index] + next if line.strip.empty? + next unless line.match?(/^\S|^ \S|^ \S/) && !line.match?(/^ /) + + insert_index = index + break + end + lines.insert(insert_index, github_actions_coverage_steps) + lines.join + end + + def github_actions_coverage_steps + <<~YAML.lines.map { |line| line.strip.empty? ? line : " #{line}" }.join + - name: Upload coverage to Coveralls + if: ${{ !env.ACT }} + uses: coverallsapp/github-action@0a51d2e0b5417d06e4ecceb534aec87defc53926 # main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: ${{ matrix.experimental != 'false' }} + + - name: Upload coverage to QLTY + if: ${{ !env.ACT }} + uses: qltysh/qlty-action/coverage@a19242102d17e497f437d7466aa01b528537e899 # v2.2.0 + with: + token: ${{secrets.QLTY_COVERAGE_TOKEN}} + files: coverage/.resultset.json + continue-on-error: ${{ matrix.experimental != 'false' }} + + - name: Upload coverage to CodeCov + if: ${{ !env.ACT }} + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + with: + use_oidc: true + fail_ci_if_error: false + files: coverage/lcov.info,coverage/coverage.xml + verbose: true + + - name: Code Coverage Summary Report + if: ${{ !env.ACT && github.event_name == 'pull_request' }} + uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0 + with: + filename: ./coverage/coverage.xml + badge: true + fail_below_min: true + format: markdown + hide_branch_rate: false + hide_complexity: true + indicators: true + output: both + thresholds: '100 100' + continue-on-error: ${{ matrix.experimental != 'false' }} + + - name: Add Coverage PR Comment + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + if: ${{ !env.ACT && github.event_name == 'pull_request' }} + with: + recreate: true + path: code-coverage-results.md + continue-on-error: ${{ matrix.experimental != 'false' }} + YAML + end + def ensure_workflow_top_level_section(content, key, section, before:) return content if content.match?(/^#{Regexp.escape(key)}:/) @@ -809,7 +885,10 @@ def github_actions_step_pins "actions/checkout" => "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2", "ruby/setup-ruby" => "ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0", "coverallsapp/github-action" => "coverallsapp/github-action@0a51d2e0b5417d06e4ecceb534aec87defc53926 # main", + "qltysh/qlty-action/coverage" => "qltysh/qlty-action/coverage@a19242102d17e497f437d7466aa01b528537e899 # v2.2.0", "codecov/codecov-action" => "codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0", + "irongut/CodeCoverageSummary" => "irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0", + "marocchino/sticky-pull-request-comment" => "marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4", } end diff --git a/gems/kettle-jem/spec/fixtures/thin_slice.json b/gems/kettle-jem/spec/fixtures/thin_slice.json index 20d62a6..60e6bb9 100644 --- a/gems/kettle-jem/spec/fixtures/thin_slice.json +++ b/gems/kettle-jem/spec/fixtures/thin_slice.json @@ -6,7 +6,7 @@ "example.gemspec": "# frozen_string_literal: true\n\nGem::Specification.new do |spec|\n spec.name = \"example\"\n spec.summary = \"Example gem\"\n spec.description = \"Example gem for kettle-jem\"\n spec.homepage = \"https://example.test\"\n spec.metadata[\"source_code_uri\"] = \"https://github.com/example/example\"\n spec.metadata[\"funding_uri\"] = \"https://github.com/sponsors/example\"\n spec.licenses = [\"MIT\"]\n spec.required_ruby_version = \">= 3.2\"\nend\n", ".kettle-jem.yml": "workflows:\n preset: framework\n framework_matrix:\n dimension: rails\n versions:\n - \"7.0\"\n - \"7.1\"\n gemfile_pattern: rails_{version}\n", ".github/FUNDING.yml": "github: [example]\nopen_collective: example\ncustom:\n - https://example.test/fund\n", - ".github/workflows/custom-ci.yml": "name: Custom CI\non:\n push:\n branches: [main]\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n ruby: [\"3.2\", \"3.3\"]\n steps:\n - uses: actions/checkout@v3\n - uses: ruby/setup-ruby@v1\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n", + ".github/workflows/custom-ci.yml": "name: Custom CI\nenv:\n K_SOUP_COV_DO: true\non:\n push:\n branches: [main]\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n ruby: [\"3.2\", \"3.3\"]\n steps:\n - uses: actions/checkout@v3\n - uses: ruby/setup-ruby@v1\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n", "README.md": "# Old Name\n\nExisting intro.\n", "CHANGELOG.md": "# Changelog\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", "gemfiles/modular/shunted.gemfile": "# frozen_string_literal: true\n\n# local additions stay above\n", @@ -78,7 +78,7 @@ ], "files": { ".github/workflows/framework-ci.yml": "name: Rails CI\n\npermissions:\n contents: read\n\non:\n push:\n branches:\n - \"main\"\n - \"*-stable\"\n tags:\n - \"!*\" # Do not execute on tags\n pull_request:\n branches:\n - \"*\"\n workflow_dispatch:\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n if: \"!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')\"\n name: Specs ${{ matrix.ruby }}@${{ matrix.framework_version }}\n runs-on: ubuntu-latest\n continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}\n env:\n BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}\n strategy:\n fail-fast: false\n matrix:\n ruby:\n - \"3.2\"\n - \"3.3\"\n - \"3.4\"\n rubygems:\n - default\n bundler:\n - default\n include:\n - framework_version: \"7.0\"\n gemfile: \"gemfiles/rails_7_0\"\n - framework_version: \"7.1\"\n gemfile: \"gemfiles/rails_7_1\"\n\n steps:\n - name: Checkout\n uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n - name: Setup Ruby & RubyGems\n uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n rubygems: \"${{ matrix.rubygems }}\"\n bundler: \"${{ matrix.bundler }}\"\n bundler-cache: true\n\n - name: Tests for ${{ matrix.ruby }}@${{ matrix.framework_version }}\n run: bundle exec rake test\n", - ".github/workflows/custom-ci.yml": "name: Custom CI\n\npermissions:\n contents: read\n\non:\n push:\n branches: [main]\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n ruby: [\"3.2\", \"3.3\"]\n steps:\n - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n", + ".github/workflows/custom-ci.yml": "name: Custom CI\nenv:\n K_SOUP_COV_DO: true\n\npermissions:\n contents: read\n\non:\n push:\n branches: [main]\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n ruby: [\"3.2\", \"3.3\"]\n steps:\n - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n - name: Upload coverage to Coveralls\n if: ${{ !env.ACT }}\n uses: coverallsapp/github-action@0a51d2e0b5417d06e4ecceb534aec87defc53926 # main\n with:\n github-token: ${{ secrets.GITHUB_TOKEN }}\n continue-on-error: ${{ matrix.experimental != 'false' }}\n\n - name: Upload coverage to QLTY\n if: ${{ !env.ACT }}\n uses: qltysh/qlty-action/coverage@a19242102d17e497f437d7466aa01b528537e899 # v2.2.0\n with:\n token: ${{secrets.QLTY_COVERAGE_TOKEN}}\n files: coverage/.resultset.json\n continue-on-error: ${{ matrix.experimental != 'false' }}\n\n - name: Upload coverage to CodeCov\n if: ${{ !env.ACT }}\n uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0\n with:\n use_oidc: true\n fail_ci_if_error: false\n files: coverage/lcov.info,coverage/coverage.xml\n verbose: true\n\n - name: Code Coverage Summary Report\n if: ${{ !env.ACT && github.event_name == 'pull_request' }}\n uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0\n with:\n filename: ./coverage/coverage.xml\n badge: true\n fail_below_min: true\n format: markdown\n hide_branch_rate: false\n hide_complexity: true\n indicators: true\n output: both\n thresholds: '100 100'\n continue-on-error: ${{ matrix.experimental != 'false' }}\n\n - name: Add Coverage PR Comment\n uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4\n if: ${{ !env.ACT && github.event_name == 'pull_request' }}\n with:\n recreate: true\n path: code-coverage-results.md\n continue-on-error: ${{ matrix.experimental != 'false' }}\n", ".github/workflows/ci.yml": "name: CI\n\npermissions:\n contents: read\n\non:\n push:\n branches:\n - \"main\"\n - \"*-stable\"\n tags:\n - \"!*\" # Do not execute on tags\n pull_request:\n branches:\n - \"*\"\n workflow_dispatch:\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n if: \"!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')\"\n name: Specs ${{ matrix.ruby }}\n runs-on: ubuntu-latest\n continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}\n strategy:\n fail-fast: false\n matrix:\n ruby:\n - \"3.2\"\n - \"3.3\"\n - \"3.4\"\n rubygems:\n - default\n bundler:\n - default\n\n steps:\n - name: Checkout example\n uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n - name: Setup Ruby & RubyGems\n uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n rubygems: \"${{ matrix.rubygems }}\"\n bundler: \"${{ matrix.bundler }}\"\n bundler-cache: true\n\n - name: Tests\n run: bundle exec rake\n", "README.md": "# example\n\nExisting intro.\n\n\n| Field | Value |\n|---|---|\n| Package | example |\n| Description | Example gem for kettle-jem |\n| Homepage | https://example.test |\n| Source | https://github.com/example/example |\n| License | MIT |\n| Funding | https://example.test/fund, https://github.com/sponsors/example, https://opencollective.com/example |\n\n", "CHANGELOG.md": "# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 40968ca..ca72119 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -78,6 +78,9 @@ def project_files(root, paths) expect(custom_ci_report.fetch(:final_content)).to include("concurrency:") expect(custom_ci_report.fetch(:final_content)).to include("actions/checkout@de0fac2") expect(custom_ci_report.fetch(:final_content)).to include("ruby/setup-ruby@e65c17") + expect(custom_ci_report.fetch(:final_content)).to include("Upload coverage to Coveralls") + expect(custom_ci_report.fetch(:final_content)).to include("qltysh/qlty-action/coverage@a192421") + expect(custom_ci_report.fetch(:final_content)).to include("Code Coverage Summary Report") expect(custom_ci_report.fetch(:final_content)).to include("ruby: [\"3.2\", \"3.3\"]") apply = described_class.apply_project(root) From 5ccfba0c0aed1c8657640e4fc85c7f6cccca22e0 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 00:54:42 -0600 Subject: [PATCH 21/71] Add kettle-jem generated coverage workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 114 +++++++++++++++++++++++- gems/kettle-jem/spec/thin_slice_spec.rb | 33 +++++++ 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index b910b2e..8aa0fe0 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -49,6 +49,8 @@ def discover_facts(project_root) ruby_versions: github_actions_ruby_versions(facts.fetch(:rubygems).fetch(:min_ruby, nil)), custom_workflows: github_actions_custom_workflows(project_root), } + coverage_config = github_actions_coverage_config(kettle_config) + facts[:ci][:coverage] = coverage_config unless coverage_config.empty? framework_matrix = github_actions_framework_matrix(kettle_config) facts[:ci][:framework_matrix] = framework_matrix unless framework_matrix.empty? facts @@ -76,6 +78,15 @@ def recipe_pack(facts) facts: %w[package rubygems ci] ) end + if facts.dig(:ci, :coverage) + recipes << recipe_entry( + "github_actions_coverage_ci", + ".github/workflows/coverage.yml", + "yaml", + "supplied_github_actions_coverage_workflow_synchronization", + facts: %w[package rubygems ci] + ) + end facts.dig(:ci, :custom_workflows).to_a.each do |workflow_path| recipes << recipe_entry( "github_actions_workflow_snippets_#{workflow_recipe_slug(workflow_path)}", @@ -239,6 +250,8 @@ def execute_recipe(project_root:, recipe:, facts:, files:) synchronize_github_actions_ci(original, facts) when "github_actions_framework_ci" synchronize_github_actions_framework_ci(original, facts) + when "github_actions_coverage_ci" + synchronize_github_actions_coverage_ci(original, facts) when /\Agithub_actions_workflow_snippets_/ synchronize_github_actions_workflow_snippets(original) when "rakefile_scaffold_cleanup" @@ -454,7 +467,7 @@ def github_actions_custom_workflows(project_root) Dir.glob(File.join(workflow_root, "*.{yml,yaml}")).filter_map do |path| relative_path = path.delete_prefix("#{project_root}/") - next if %w[.github/workflows/ci.yml .github/workflows/framework-ci.yml].include?(relative_path) + next if %w[.github/workflows/ci.yml .github/workflows/coverage.yml .github/workflows/framework-ci.yml].include?(relative_path) relative_path end.sort @@ -498,6 +511,22 @@ def github_actions_framework_matrix(config) } end + def github_actions_coverage_config(config) + workflows = config["workflows"] + return {} unless workflows.is_a?(Hash) + + raw = workflows["coverage"] + enabled = raw == true || (raw.is_a?(Hash) && raw.fetch("enabled", false) == true) + return {} unless enabled + + raw = {} unless raw.is_a?(Hash) + { + enabled: true, + command: raw.fetch("command", "rake test").to_s, + appraisal: raw.fetch("appraisal", "coverage").to_s, + } + end + def expand_framework_gemfile_pattern(pattern, version) replacement = if pattern.include?("_{version}") || pattern.include?("{version}_") version.tr(".", "_") @@ -765,6 +794,89 @@ def synchronize_github_actions_framework_ci(_content, facts) YAML end + def synchronize_github_actions_coverage_ci(_content, facts) + ci = facts.fetch(:ci) + coverage = ci.fetch(:coverage) + <<~YAML + name: Test Coverage + + permissions: + contents: read + pull-requests: write + id-token: write + + env: + K_SOUP_COV_MIN_BRANCH: 100 + K_SOUP_COV_MIN_LINE: 100 + K_SOUP_COV_MIN_HARD: true + K_SOUP_COV_FORMATTERS: "xml,rcov,lcov,tty" + K_SOUP_COV_DO: true + K_SOUP_COV_MULTI_FORMATTERS: true + K_SOUP_COV_COMMAND_NAME: "Test Coverage" + + on: + push: + branches: + - "#{ci.fetch(:default_branch)}" + - "*-stable" + tags: + - "!*" # Do not execute on tags + pull_request: + branches: + - "*" + workflow_dispatch: + + concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + + jobs: + coverage: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Code Coverage on ${{ matrix.ruby }}@current + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + fail-fast: false + matrix: + include: + - ruby: "ruby" + appraisal: "#{coverage.fetch(:appraisal)}" + exec_cmd: "#{coverage.fetch(:command)}" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: "${{ matrix.ruby }}" + rubygems: "${{ matrix.rubygems }}" + bundler: "${{ matrix.bundler }}" + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + continue-on-error: true + + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }}@current via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} + #{github_actions_coverage_steps} + YAML + end + def synchronize_github_actions_workflow_snippets(content) updated = ensure_workflow_top_level_section( content.to_s, diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index ca72119..f14b28a 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -88,4 +88,37 @@ def project_files(root, paths) expect(project_files(root, fixture.fetch(:expected).fetch(:files).keys.map(&:to_s))).to eq(fixture.fetch(:expected).fetch(:files)) end end + + it "generates a coverage workflow when configured" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-coverage-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + spec.required_ruby_version = ">= 3.2" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + workflows: + coverage: + enabled: true + appraisal: coverage + command: rake test + YAML + }) + + plan = described_class.plan_project(root) + coverage_report = plan[:recipe_reports].find { |report| report.fetch(:recipe_name) == "github_actions_coverage_ci" } + expect(coverage_report.dig(:request_envelope, :request, :provider_family)).to eq("yaml") + expect(coverage_report.fetch(:relative_path)).to eq(".github/workflows/coverage.yml") + expect(coverage_report.fetch(:final_content)).to include("name: Test Coverage") + expect(coverage_report.fetch(:final_content)).to include("K_SOUP_COV_DO: true") + expect(coverage_report.fetch(:final_content)).to include("bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }}") + expect(coverage_report.fetch(:final_content)).to include("Upload coverage to CodeCov") + expect(plan[:changed_files]).to include(".github/workflows/coverage.yml") + end + end end From 1bbab5e591bdfcaca3d4089eebddafb7f7ea84a3 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 01:12:10 -0600 Subject: [PATCH 22/71] Add kettle-jem obsolete workflow cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 54 +++++++++++++++++-- gems/kettle-jem/spec/fixtures/thin_slice.json | 6 +++ gems/kettle-jem/spec/thin_slice_spec.rb | 8 +++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 8aa0fe0..67b4d75 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -11,6 +11,7 @@ module Jem CONTENT_RECIPE_TRANSPORT_VERSION = Ast::Merge::STRUCTURED_EDIT_TRANSPORT_VERSION MANAGED_BLOCK_OPEN = "# <> do not edit below this line" MANAGED_BLOCK_CLOSE = "# <>" + OBSOLETE_GITHUB_WORKFLOWS = %w[ancient.yml legacy.yml supported.yml unsupported.yml main.yml hoary.yml].freeze module_function @@ -47,6 +48,7 @@ def discover_facts(project_root) provider: "github_actions", default_branch: "main", ruby_versions: github_actions_ruby_versions(facts.fetch(:rubygems).fetch(:min_ruby, nil)), + obsolete_workflows: github_actions_obsolete_workflows(project_root), custom_workflows: github_actions_custom_workflows(project_root), } coverage_config = github_actions_coverage_config(kettle_config) @@ -87,6 +89,15 @@ def recipe_pack(facts) facts: %w[package rubygems ci] ) end + facts.dig(:ci, :obsolete_workflows).to_a.each do |workflow_path| + recipes << recipe_entry( + "github_actions_obsolete_workflow_cleanup_#{workflow_recipe_slug(workflow_path)}", + workflow_path, + "file", + "supplied_obsolete_file_deletion", + facts: %w[ci] + ) + end facts.dig(:ci, :custom_workflows).to_a.each do |workflow_path| recipes << recipe_entry( "github_actions_workflow_snippets_#{workflow_recipe_slug(workflow_path)}", @@ -140,8 +151,12 @@ def apply_project(project_root) next unless recipe_report[:changed] path = File.join(project_root, recipe_report.fetch(:relative_path)) - FileUtils.mkdir_p(File.dirname(path)) - File.write(path, recipe_report.fetch(:final_content)) + if recipe_report.dig(:metadata, :delete_file) + FileUtils.rm_f(path) + else + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, recipe_report.fetch(:final_content)) + end end report end @@ -252,6 +267,8 @@ def execute_recipe(project_root:, recipe:, facts:, files:) synchronize_github_actions_framework_ci(original, facts) when "github_actions_coverage_ci" synchronize_github_actions_coverage_ci(original, facts) + when /\Agithub_actions_obsolete_workflow_cleanup_/ + "" when /\Agithub_actions_workflow_snippets_/ synchronize_github_actions_workflow_snippets(original) when "rakefile_scaffold_cleanup" @@ -280,7 +297,7 @@ def execute_recipe(project_root:, recipe:, facts:, files:) changed: changed, step_reports: [step_report], diagnostics: [], - metadata: { packaging_recipe: recipe.fetch(:name) }, + metadata: recipe_report_metadata(recipe), ) { @@ -290,6 +307,7 @@ def execute_recipe(project_root:, recipe:, facts:, files:) request_envelope: content_recipe_execution_request_envelope(request), report_envelope: content_recipe_execution_report_envelope(report), final_content: final, + metadata: recipe_report_metadata(recipe), diagnostics: [], } end @@ -355,6 +373,12 @@ def read_project_files(project_root, pack) end end + def recipe_report_metadata(recipe) + metadata = { packaging_recipe: recipe.fetch(:name) } + metadata[:delete_file] = true if recipe.fetch(:primitive) == "supplied_obsolete_file_deletion" + metadata + end + def recipe_entry(name, target_path, provider_family, primitive, facts:, provider_backend: nil, selectors: []) { name: name, @@ -377,6 +401,13 @@ def recipe_runtime_context(recipe, facts, deletion) def step_report_metadata(recipe, deletion) metadata = { target_path: recipe.fetch(:target_path) } + if recipe.fetch(:primitive) == "supplied_obsolete_file_deletion" + metadata.merge!( + policy_kind: "delete_obsolete_file", + operation: "delete", + deleted_file: recipe.fetch(:target_path), + ) + end return metadata unless deletion metadata.merge( @@ -467,12 +498,27 @@ def github_actions_custom_workflows(project_root) Dir.glob(File.join(workflow_root, "*.{yml,yaml}")).filter_map do |path| relative_path = path.delete_prefix("#{project_root}/") - next if %w[.github/workflows/ci.yml .github/workflows/coverage.yml .github/workflows/framework-ci.yml].include?(relative_path) + next if generated_or_obsolete_github_workflow?(relative_path) relative_path end.sort end + def github_actions_obsolete_workflows(project_root) + workflow_root = File.join(project_root, ".github", "workflows") + OBSOLETE_GITHUB_WORKFLOWS.filter_map do |workflow| + relative_path = ".github/workflows/#{workflow}" + path = File.join(workflow_root, workflow) + relative_path if File.exist?(path) + end.sort + end + + def generated_or_obsolete_github_workflow?(relative_path) + return true if %w[.github/workflows/ci.yml .github/workflows/coverage.yml .github/workflows/framework-ci.yml].include?(relative_path) + + OBSOLETE_GITHUB_WORKFLOWS.include?(File.basename(relative_path)) + end + def workflow_recipe_slug(workflow_path) workflow_path.gsub(/[^a-zA-Z0-9]+/, "_").gsub(/\A_+|_+\z/, "") end diff --git a/gems/kettle-jem/spec/fixtures/thin_slice.json b/gems/kettle-jem/spec/fixtures/thin_slice.json index 60e6bb9..e844b37 100644 --- a/gems/kettle-jem/spec/fixtures/thin_slice.json +++ b/gems/kettle-jem/spec/fixtures/thin_slice.json @@ -6,6 +6,7 @@ "example.gemspec": "# frozen_string_literal: true\n\nGem::Specification.new do |spec|\n spec.name = \"example\"\n spec.summary = \"Example gem\"\n spec.description = \"Example gem for kettle-jem\"\n spec.homepage = \"https://example.test\"\n spec.metadata[\"source_code_uri\"] = \"https://github.com/example/example\"\n spec.metadata[\"funding_uri\"] = \"https://github.com/sponsors/example\"\n spec.licenses = [\"MIT\"]\n spec.required_ruby_version = \">= 3.2\"\nend\n", ".kettle-jem.yml": "workflows:\n preset: framework\n framework_matrix:\n dimension: rails\n versions:\n - \"7.0\"\n - \"7.1\"\n gemfile_pattern: rails_{version}\n", ".github/FUNDING.yml": "github: [example]\nopen_collective: example\ncustom:\n - https://example.test/fund\n", + ".github/workflows/ancient.yml": "name: Ancient CI\n", ".github/workflows/custom-ci.yml": "name: Custom CI\nenv:\n K_SOUP_COV_DO: true\non:\n push:\n branches: [main]\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n ruby: [\"3.2\", \"3.3\"]\n steps:\n - uses: actions/checkout@v3\n - uses: ruby/setup-ruby@v1\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n", "README.md": "# Old Name\n\nExisting intro.\n", "CHANGELOG.md": "# Changelog\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", @@ -44,6 +45,9 @@ "3.3", "3.4" ], + "obsolete_workflows": [ + ".github/workflows/ancient.yml" + ], "custom_workflows": [ ".github/workflows/custom-ci.yml" ], @@ -68,6 +72,7 @@ } }, "changed_files": [ + ".github/workflows/ancient.yml", ".github/workflows/ci.yml", ".github/workflows/custom-ci.yml", ".github/workflows/framework-ci.yml", @@ -77,6 +82,7 @@ "gemfiles/modular/shunted.gemfile" ], "files": { + ".github/workflows/ancient.yml": null, ".github/workflows/framework-ci.yml": "name: Rails CI\n\npermissions:\n contents: read\n\non:\n push:\n branches:\n - \"main\"\n - \"*-stable\"\n tags:\n - \"!*\" # Do not execute on tags\n pull_request:\n branches:\n - \"*\"\n workflow_dispatch:\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n if: \"!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')\"\n name: Specs ${{ matrix.ruby }}@${{ matrix.framework_version }}\n runs-on: ubuntu-latest\n continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}\n env:\n BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}\n strategy:\n fail-fast: false\n matrix:\n ruby:\n - \"3.2\"\n - \"3.3\"\n - \"3.4\"\n rubygems:\n - default\n bundler:\n - default\n include:\n - framework_version: \"7.0\"\n gemfile: \"gemfiles/rails_7_0\"\n - framework_version: \"7.1\"\n gemfile: \"gemfiles/rails_7_1\"\n\n steps:\n - name: Checkout\n uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n - name: Setup Ruby & RubyGems\n uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n rubygems: \"${{ matrix.rubygems }}\"\n bundler: \"${{ matrix.bundler }}\"\n bundler-cache: true\n\n - name: Tests for ${{ matrix.ruby }}@${{ matrix.framework_version }}\n run: bundle exec rake test\n", ".github/workflows/custom-ci.yml": "name: Custom CI\nenv:\n K_SOUP_COV_DO: true\n\npermissions:\n contents: read\n\non:\n push:\n branches: [main]\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n ruby: [\"3.2\", \"3.3\"]\n steps:\n - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n - name: Upload coverage to Coveralls\n if: ${{ !env.ACT }}\n uses: coverallsapp/github-action@0a51d2e0b5417d06e4ecceb534aec87defc53926 # main\n with:\n github-token: ${{ secrets.GITHUB_TOKEN }}\n continue-on-error: ${{ matrix.experimental != 'false' }}\n\n - name: Upload coverage to QLTY\n if: ${{ !env.ACT }}\n uses: qltysh/qlty-action/coverage@a19242102d17e497f437d7466aa01b528537e899 # v2.2.0\n with:\n token: ${{secrets.QLTY_COVERAGE_TOKEN}}\n files: coverage/.resultset.json\n continue-on-error: ${{ matrix.experimental != 'false' }}\n\n - name: Upload coverage to CodeCov\n if: ${{ !env.ACT }}\n uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0\n with:\n use_oidc: true\n fail_ci_if_error: false\n files: coverage/lcov.info,coverage/coverage.xml\n verbose: true\n\n - name: Code Coverage Summary Report\n if: ${{ !env.ACT && github.event_name == 'pull_request' }}\n uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0\n with:\n filename: ./coverage/coverage.xml\n badge: true\n fail_below_min: true\n format: markdown\n hide_branch_rate: false\n hide_complexity: true\n indicators: true\n output: both\n thresholds: '100 100'\n continue-on-error: ${{ matrix.experimental != 'false' }}\n\n - name: Add Coverage PR Comment\n uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4\n if: ${{ !env.ACT && github.event_name == 'pull_request' }}\n with:\n recreate: true\n path: code-coverage-results.md\n continue-on-error: ${{ matrix.experimental != 'false' }}\n", ".github/workflows/ci.yml": "name: CI\n\npermissions:\n contents: read\n\non:\n push:\n branches:\n - \"main\"\n - \"*-stable\"\n tags:\n - \"!*\" # Do not execute on tags\n pull_request:\n branches:\n - \"*\"\n workflow_dispatch:\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n if: \"!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')\"\n name: Specs ${{ matrix.ruby }}\n runs-on: ubuntu-latest\n continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}\n strategy:\n fail-fast: false\n matrix:\n ruby:\n - \"3.2\"\n - \"3.3\"\n - \"3.4\"\n rubygems:\n - default\n bundler:\n - default\n\n steps:\n - name: Checkout example\n uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n - name: Setup Ruby & RubyGems\n uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n rubygems: \"${{ matrix.rubygems }}\"\n bundler: \"${{ matrix.bundler }}\"\n bundler-cache: true\n\n - name: Tests\n run: bundle exec rake\n", diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index f14b28a..a525ebc 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -48,6 +48,7 @@ def project_files(root, paths) expect(recipe_names.take(expected_recipe_names.length)).to eq(expected_recipe_names) expect(recipe_names).to include("github_actions_ci") expect(recipe_names).to include("github_actions_framework_ci") + expect(recipe_names).to include(a_string_starting_with("github_actions_obsolete_workflow_cleanup_")) expect(recipe_names).to include("rakefile_scaffold_cleanup") expect(recipe_names).to include(a_string_starting_with("github_actions_workflow_snippets_")) expect(plan[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) @@ -82,6 +83,13 @@ def project_files(root, paths) expect(custom_ci_report.fetch(:final_content)).to include("qltysh/qlty-action/coverage@a192421") expect(custom_ci_report.fetch(:final_content)).to include("Code Coverage Summary Report") expect(custom_ci_report.fetch(:final_content)).to include("ruby: [\"3.2\", \"3.3\"]") + obsolete_workflow_report = plan[:recipe_reports].find do |report| + report.fetch(:relative_path) == ".github/workflows/ancient.yml" + end + expect(obsolete_workflow_report.fetch(:metadata).fetch(:delete_file)).to be(true) + expect(obsolete_workflow_report.dig(:report_envelope, :report, :step_reports, 0, :metadata, :deleted_file)).to eq( + ".github/workflows/ancient.yml" + ) apply = described_class.apply_project(root) expect(apply[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) From adbaad9dc0a46a06cd9d7c5255c94ef37a43e1bc Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 01:14:00 -0600 Subject: [PATCH 23/71] Add kettle-jem FUNDING.yml sync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 26 +++++++++++++++++-- gems/kettle-jem/spec/fixtures/thin_slice.json | 7 +++-- gems/kettle-jem/spec/thin_slice_spec.rb | 4 +++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 67b4d75..f9ca531 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -42,7 +42,7 @@ def discover_facts(project_root) ), } kettle_config = kettle_jem_config(project_root) - funding = compact_hash(urls: funding_urls(project_root, gemspec)) + funding = compact_hash(urls: funding_urls(project_root, gemspec, name)) facts[:funding] = funding unless funding.empty? facts[:ci] = { provider: "github_actions", @@ -63,6 +63,13 @@ def recipe_pack(facts) recipe_entry("readme_metadata", "README.md", "markdown", "supplied_readme_metadata_synchronization", facts: %w[package funding readme]), recipe_entry("changelog_unreleased", "CHANGELOG.md", "markdown", "changelog_unreleased_normalization", facts: %w[package changelog]), recipe_entry("generated_block_sync", "gemfiles/modular/shunted.gemfile", "text", "supplied_managed_text_block_replacement", facts: %w[package generated_blocks]), + recipe_entry( + "github_funding_yml", + ".github/FUNDING.yml", + "yaml", + "supplied_github_funding_yaml_synchronization", + facts: %w[package funding] + ), recipe_entry( "github_actions_ci", ".github/workflows/ci.yml", @@ -261,6 +268,8 @@ def execute_recipe(project_root:, recipe:, facts:, files:) normalize_changelog(original, facts) when "generated_block_sync" synchronize_managed_block(original, facts) + when "github_funding_yml" + synchronize_github_funding_yml(original, facts) when "github_actions_ci" synchronize_github_actions_ci(original, facts) when "github_actions_framework_ci" @@ -436,10 +445,11 @@ def extract_metadata_value(source, key) match && match[1] end - def funding_urls(project_root, gemspec_source) + def funding_urls(project_root, gemspec_source, package_name) urls = [extract_metadata_value(gemspec_source, "funding_uri")] path = File.join(project_root, ".github", "FUNDING.yml") urls.concat(github_funding_urls(path)) if File.exist?(path) + urls << github_funding_platform_urls("tidelift", ["rubygems/#{package_name}"]).first urls.compact.uniq.sort end @@ -611,6 +621,18 @@ def readme_metadata_block(facts) ].join("\n") end + def synchronize_github_funding_yml(content, facts) + funding = YAML.safe_load(content.to_s, permitted_classes: [], aliases: false) || {} + funding = {} unless funding.is_a?(Hash) + funding = funding.each_with_object({}) do |(key, value), memo| + next if value.nil? || (value.respond_to?(:empty?) && value.empty?) + + memo[key.to_s] = value + end + funding["tidelift"] ||= "rubygems/#{facts.fetch(:package).fetch(:name)}" + YAML.dump(funding).sub(/\A---\n?/, "") + end + def delete_rakefile_scaffold(content) selectors = rakefile_scaffold_delete_selectors(content) { diff --git a/gems/kettle-jem/spec/fixtures/thin_slice.json b/gems/kettle-jem/spec/fixtures/thin_slice.json index e844b37..eec3bb6 100644 --- a/gems/kettle-jem/spec/fixtures/thin_slice.json +++ b/gems/kettle-jem/spec/fixtures/thin_slice.json @@ -34,7 +34,8 @@ "urls": [ "https://example.test/fund", "https://github.com/sponsors/example", - "https://opencollective.com/example" + "https://opencollective.com/example", + "https://tidelift.com/funding/github/rubygems/example" ] }, "ci": { @@ -72,6 +73,7 @@ } }, "changed_files": [ + ".github/FUNDING.yml", ".github/workflows/ancient.yml", ".github/workflows/ci.yml", ".github/workflows/custom-ci.yml", @@ -82,11 +84,12 @@ "gemfiles/modular/shunted.gemfile" ], "files": { + ".github/FUNDING.yml": "github:\n- example\nopen_collective: example\ncustom:\n- https://example.test/fund\ntidelift: rubygems/example\n", ".github/workflows/ancient.yml": null, ".github/workflows/framework-ci.yml": "name: Rails CI\n\npermissions:\n contents: read\n\non:\n push:\n branches:\n - \"main\"\n - \"*-stable\"\n tags:\n - \"!*\" # Do not execute on tags\n pull_request:\n branches:\n - \"*\"\n workflow_dispatch:\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n if: \"!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')\"\n name: Specs ${{ matrix.ruby }}@${{ matrix.framework_version }}\n runs-on: ubuntu-latest\n continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}\n env:\n BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}\n strategy:\n fail-fast: false\n matrix:\n ruby:\n - \"3.2\"\n - \"3.3\"\n - \"3.4\"\n rubygems:\n - default\n bundler:\n - default\n include:\n - framework_version: \"7.0\"\n gemfile: \"gemfiles/rails_7_0\"\n - framework_version: \"7.1\"\n gemfile: \"gemfiles/rails_7_1\"\n\n steps:\n - name: Checkout\n uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n - name: Setup Ruby & RubyGems\n uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n rubygems: \"${{ matrix.rubygems }}\"\n bundler: \"${{ matrix.bundler }}\"\n bundler-cache: true\n\n - name: Tests for ${{ matrix.ruby }}@${{ matrix.framework_version }}\n run: bundle exec rake test\n", ".github/workflows/custom-ci.yml": "name: Custom CI\nenv:\n K_SOUP_COV_DO: true\n\npermissions:\n contents: read\n\non:\n push:\n branches: [main]\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n ruby: [\"3.2\", \"3.3\"]\n steps:\n - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n - name: Upload coverage to Coveralls\n if: ${{ !env.ACT }}\n uses: coverallsapp/github-action@0a51d2e0b5417d06e4ecceb534aec87defc53926 # main\n with:\n github-token: ${{ secrets.GITHUB_TOKEN }}\n continue-on-error: ${{ matrix.experimental != 'false' }}\n\n - name: Upload coverage to QLTY\n if: ${{ !env.ACT }}\n uses: qltysh/qlty-action/coverage@a19242102d17e497f437d7466aa01b528537e899 # v2.2.0\n with:\n token: ${{secrets.QLTY_COVERAGE_TOKEN}}\n files: coverage/.resultset.json\n continue-on-error: ${{ matrix.experimental != 'false' }}\n\n - name: Upload coverage to CodeCov\n if: ${{ !env.ACT }}\n uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0\n with:\n use_oidc: true\n fail_ci_if_error: false\n files: coverage/lcov.info,coverage/coverage.xml\n verbose: true\n\n - name: Code Coverage Summary Report\n if: ${{ !env.ACT && github.event_name == 'pull_request' }}\n uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0\n with:\n filename: ./coverage/coverage.xml\n badge: true\n fail_below_min: true\n format: markdown\n hide_branch_rate: false\n hide_complexity: true\n indicators: true\n output: both\n thresholds: '100 100'\n continue-on-error: ${{ matrix.experimental != 'false' }}\n\n - name: Add Coverage PR Comment\n uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4\n if: ${{ !env.ACT && github.event_name == 'pull_request' }}\n with:\n recreate: true\n path: code-coverage-results.md\n continue-on-error: ${{ matrix.experimental != 'false' }}\n", ".github/workflows/ci.yml": "name: CI\n\npermissions:\n contents: read\n\non:\n push:\n branches:\n - \"main\"\n - \"*-stable\"\n tags:\n - \"!*\" # Do not execute on tags\n pull_request:\n branches:\n - \"*\"\n workflow_dispatch:\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n if: \"!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')\"\n name: Specs ${{ matrix.ruby }}\n runs-on: ubuntu-latest\n continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}\n strategy:\n fail-fast: false\n matrix:\n ruby:\n - \"3.2\"\n - \"3.3\"\n - \"3.4\"\n rubygems:\n - default\n bundler:\n - default\n\n steps:\n - name: Checkout example\n uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n - name: Setup Ruby & RubyGems\n uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n rubygems: \"${{ matrix.rubygems }}\"\n bundler: \"${{ matrix.bundler }}\"\n bundler-cache: true\n\n - name: Tests\n run: bundle exec rake\n", - "README.md": "# example\n\nExisting intro.\n\n\n| Field | Value |\n|---|---|\n| Package | example |\n| Description | Example gem for kettle-jem |\n| Homepage | https://example.test |\n| Source | https://github.com/example/example |\n| License | MIT |\n| Funding | https://example.test/fund, https://github.com/sponsors/example, https://opencollective.com/example |\n\n", + "README.md": "# example\n\nExisting intro.\n\n\n| Field | Value |\n|---|---|\n| Package | example |\n| Description | Example gem for kettle-jem |\n| Homepage | https://example.test |\n| Source | https://github.com/example/example |\n| License | MIT |\n| Funding | https://example.test/fund, https://github.com/sponsors/example, https://opencollective.com/example, https://tidelift.com/funding/github/rubygems/example |\n\n", "CHANGELOG.md": "# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", "Rakefile": "# frozen_string_literal: true\n\n# Keep custom task.\ntask :custom do\n puts \"custom\"\nend\n", "gemfiles/modular/shunted.gemfile": "# frozen_string_literal: true\n\n# local additions stay above\n# <> do not edit below this line\n# package: example\n# generated by kettle-jem vNext\n# <>\n" diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index a525ebc..d5adbb8 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -46,6 +46,7 @@ def project_files(root, paths) expect(json_ready(plan[:facts])).to eq(json_ready(fixture.fetch(:expected).fetch(:facts))) recipe_names = plan[:recipe_pack][:recipes].map { |recipe| recipe[:name] } expect(recipe_names.take(expected_recipe_names.length)).to eq(expected_recipe_names) + expect(recipe_names).to include("github_funding_yml") expect(recipe_names).to include("github_actions_ci") expect(recipe_names).to include("github_actions_framework_ci") expect(recipe_names).to include(a_string_starting_with("github_actions_obsolete_workflow_cleanup_")) @@ -68,6 +69,9 @@ def project_files(root, paths) expect(ci_report.dig(:request_envelope, :request, :provider_family)).to eq("yaml") expect(ci_report.fetch(:final_content)).to include("ruby/setup-ruby@") expect(ci_report.fetch(:final_content)).to include("- \"3.2\"") + funding_yml_report = plan[:recipe_reports].find { |report| report.fetch(:recipe_name) == "github_funding_yml" } + expect(funding_yml_report.fetch(:final_content)).to include("tidelift: rubygems/example") + expect(funding_yml_report.fetch(:final_content)).to include("open_collective: example") framework_ci_report = plan[:recipe_reports].find { |report| report.fetch(:recipe_name) == "github_actions_framework_ci" } expect(framework_ci_report.fetch(:final_content)).to include("name: Rails CI") expect(framework_ci_report.fetch(:final_content)).to include("gemfiles/rails_7_0") From c2ac3f8ec37bfa5f44e9a69a2eff7c4a619a6a0f Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 01:15:19 -0600 Subject: [PATCH 24/71] Add kettle-jem Open Collective funding policy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 23 +++++++++++++++---- gems/kettle-jem/spec/thin_slice_spec.rb | 30 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index f9ca531..50ebcdf 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -42,7 +42,9 @@ def discover_facts(project_root) ), } kettle_config = kettle_jem_config(project_root) - funding = compact_hash(urls: funding_urls(project_root, gemspec, name)) + opencollective_disabled = opencollective_disabled?(kettle_config) + funding = compact_hash(urls: funding_urls(project_root, gemspec, name, opencollective_disabled: opencollective_disabled)) + funding[:open_collective_disabled] = true if opencollective_disabled facts[:funding] = funding unless funding.empty? facts[:ci] = { provider: "github_actions", @@ -445,20 +447,22 @@ def extract_metadata_value(source, key) match && match[1] end - def funding_urls(project_root, gemspec_source, package_name) + def funding_urls(project_root, gemspec_source, package_name, opencollective_disabled: false) urls = [extract_metadata_value(gemspec_source, "funding_uri")] path = File.join(project_root, ".github", "FUNDING.yml") - urls.concat(github_funding_urls(path)) if File.exist?(path) + urls.concat(github_funding_urls(path, opencollective_disabled: opencollective_disabled)) if File.exist?(path) urls << github_funding_platform_urls("tidelift", ["rubygems/#{package_name}"]).first urls.compact.uniq.sort end - def github_funding_urls(path) + def github_funding_urls(path, opencollective_disabled: false) funding = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {} return [] unless funding.is_a?(Hash) funding.flat_map do |platform, value| + next [] if opencollective_disabled && platform.to_s == "open_collective" + github_funding_platform_urls(platform.to_s, Array(value).compact) end end @@ -541,6 +545,16 @@ def kettle_jem_config(project_root) config.is_a?(Hash) ? config : {} end + def opencollective_disabled?(config) + funding = config["funding"] + config_value = funding["open_collective"] if funding.is_a?(Hash) + falsey_config?(config_value) + end + + def falsey_config?(value) + %w[false no 0].include?(value.to_s.strip.downcase) + end + def github_actions_framework_matrix(config) workflows = config["workflows"] return {} unless workflows.is_a?(Hash) && workflows["preset"].to_s.strip.downcase == "framework" @@ -629,6 +643,7 @@ def synchronize_github_funding_yml(content, facts) memo[key.to_s] = value end + funding.delete("open_collective") if facts.fetch(:funding, {})[:open_collective_disabled] funding["tidelift"] ||= "rubygems/#{facts.fetch(:package).fetch(:name)}" YAML.dump(funding).sub(/\A---\n?/, "") end diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index d5adbb8..83442c3 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -133,4 +133,34 @@ def project_files(root, paths) expect(plan[:changed_files]).to include(".github/workflows/coverage.yml") end end + + it "removes Open Collective funding when disabled" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-opencollective-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + funding: + open_collective: false + YAML + ".github/FUNDING.yml" => <<~YAML, + github: [example] + open_collective: example + YAML + }) + + plan = described_class.plan_project(root) + expect(plan.dig(:facts, :funding, :open_collective_disabled)).to be(true) + expect(plan.dig(:facts, :funding, :urls)).not_to include("https://opencollective.com/example") + funding_report = plan[:recipe_reports].find { |report| report.fetch(:recipe_name) == "github_funding_yml" } + expect(funding_report.fetch(:final_content)).not_to include("open_collective") + expect(funding_report.fetch(:final_content)).to include("tidelift: rubygems/example") + end + end end From 2219365150e6a8a5cb053628f51fc2dd311c1575 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 01:17:50 -0600 Subject: [PATCH 25/71] Add kettle-jem Open Collective file cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 45 ++++++++++++++++++++++--- gems/kettle-jem/spec/thin_slice_spec.rb | 32 ++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 50ebcdf..400b2de 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -12,6 +12,8 @@ module Jem MANAGED_BLOCK_OPEN = "# <> do not edit below this line" MANAGED_BLOCK_CLOSE = "# <>" OBSOLETE_GITHUB_WORKFLOWS = %w[ancient.yml legacy.yml supported.yml unsupported.yml main.yml hoary.yml].freeze + OPENCOLLECTIVE_DISABLED_FILES = %w[.opencollective.yml .github/workflows/opencollective.yml].freeze + FILE_DELETION_PRIMITIVES = %w[supplied_obsolete_file_deletion supplied_disabled_opencollective_file_deletion].freeze module_function @@ -45,13 +47,15 @@ def discover_facts(project_root) opencollective_disabled = opencollective_disabled?(kettle_config) funding = compact_hash(urls: funding_urls(project_root, gemspec, name, opencollective_disabled: opencollective_disabled)) funding[:open_collective_disabled] = true if opencollective_disabled + open_collective_files = opencollective_disabled ? opencollective_disabled_files(project_root) : [] + funding[:open_collective_files] = open_collective_files unless open_collective_files.empty? facts[:funding] = funding unless funding.empty? facts[:ci] = { provider: "github_actions", default_branch: "main", ruby_versions: github_actions_ruby_versions(facts.fetch(:rubygems).fetch(:min_ruby, nil)), obsolete_workflows: github_actions_obsolete_workflows(project_root), - custom_workflows: github_actions_custom_workflows(project_root), + custom_workflows: github_actions_custom_workflows(project_root, opencollective_disabled: opencollective_disabled), } coverage_config = github_actions_coverage_config(kettle_config) facts[:ci][:coverage] = coverage_config unless coverage_config.empty? @@ -107,6 +111,15 @@ def recipe_pack(facts) facts: %w[ci] ) end + facts.dig(:funding, :open_collective_files).to_a.each do |relative_path| + recipes << recipe_entry( + "opencollective_disabled_file_cleanup_#{workflow_recipe_slug(relative_path)}", + relative_path, + "file", + "supplied_disabled_opencollective_file_deletion", + facts: %w[funding] + ) + end facts.dig(:ci, :custom_workflows).to_a.each do |workflow_path| recipes << recipe_entry( "github_actions_workflow_snippets_#{workflow_recipe_slug(workflow_path)}", @@ -280,6 +293,8 @@ def execute_recipe(project_root:, recipe:, facts:, files:) synchronize_github_actions_coverage_ci(original, facts) when /\Agithub_actions_obsolete_workflow_cleanup_/ "" + when /\Aopencollective_disabled_file_cleanup_/ + "" when /\Agithub_actions_workflow_snippets_/ synchronize_github_actions_workflow_snippets(original) when "rakefile_scaffold_cleanup" @@ -300,7 +315,7 @@ def execute_recipe(project_root:, recipe:, facts:, files:) runtime_context: recipe_runtime_context(recipe, facts, deletion), metadata: { packaging_recipe: recipe.fetch(:name), project_root: project_root.to_s }, ) - changed = final != original + changed = delete_file_recipe?(recipe) || final != original step_report = content_recipe_step_report(recipe: recipe, request: request, original: original, final: final, changed: changed, deletion: deletion) report = content_recipe_execution_report( request: request, @@ -386,7 +401,7 @@ def read_project_files(project_root, pack) def recipe_report_metadata(recipe) metadata = { packaging_recipe: recipe.fetch(:name) } - metadata[:delete_file] = true if recipe.fetch(:primitive) == "supplied_obsolete_file_deletion" + metadata[:delete_file] = true if delete_file_recipe?(recipe) metadata end @@ -419,6 +434,13 @@ def step_report_metadata(recipe, deletion) deleted_file: recipe.fetch(:target_path), ) end + if recipe.fetch(:primitive) == "supplied_disabled_opencollective_file_deletion" + metadata.merge!( + policy_kind: "delete_disabled_opencollective_file", + operation: "delete", + deleted_file: recipe.fetch(:target_path), + ) + end return metadata unless deletion metadata.merge( @@ -506,12 +528,13 @@ def github_actions_ruby_versions(min_ruby) selected.empty? ? [floor] : selected end - def github_actions_custom_workflows(project_root) + def github_actions_custom_workflows(project_root, opencollective_disabled: false) workflow_root = File.join(project_root, ".github", "workflows") return [] unless Dir.exist?(workflow_root) Dir.glob(File.join(workflow_root, "*.{yml,yaml}")).filter_map do |path| relative_path = path.delete_prefix("#{project_root}/") + next if opencollective_disabled && opencollective_disabled_file?(relative_path) next if generated_or_obsolete_github_workflow?(relative_path) relative_path @@ -533,6 +556,20 @@ def generated_or_obsolete_github_workflow?(relative_path) OBSOLETE_GITHUB_WORKFLOWS.include?(File.basename(relative_path)) end + def opencollective_disabled_files(project_root) + OPENCOLLECTIVE_DISABLED_FILES.select do |relative_path| + File.exist?(File.join(project_root, relative_path)) + end + end + + def opencollective_disabled_file?(relative_path) + OPENCOLLECTIVE_DISABLED_FILES.include?(relative_path.to_s) + end + + def delete_file_recipe?(recipe) + FILE_DELETION_PRIMITIVES.include?(recipe.fetch(:primitive)) + end + def workflow_recipe_slug(workflow_path) workflow_path.gsub(/[^a-zA-Z0-9]+/, "_").gsub(/\A_+|_+\z/, "") end diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 83442c3..85f3a81 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -153,14 +153,46 @@ def project_files(root, paths) github: [example] open_collective: example YAML + ".opencollective.yml" => <<~YAML, + collective: example + YAML + ".github/workflows/opencollective.yml" => <<~YAML, + name: Open Collective + on: + workflow_dispatch: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + YAML }) plan = described_class.plan_project(root) expect(plan.dig(:facts, :funding, :open_collective_disabled)).to be(true) + expect(plan.dig(:facts, :funding, :open_collective_files)).to eq( + [".opencollective.yml", ".github/workflows/opencollective.yml"] + ) expect(plan.dig(:facts, :funding, :urls)).not_to include("https://opencollective.com/example") + recipe_names = plan[:recipe_pack][:recipes].map { |recipe| recipe.fetch(:name) } + expect(recipe_names).to include("opencollective_disabled_file_cleanup_opencollective_yml") + expect(recipe_names).to include("opencollective_disabled_file_cleanup_github_workflows_opencollective_yml") + expect(recipe_names).not_to include("github_actions_workflow_snippets_github_workflows_opencollective_yml") funding_report = plan[:recipe_reports].find { |report| report.fetch(:recipe_name) == "github_funding_yml" } expect(funding_report.fetch(:final_content)).not_to include("open_collective") expect(funding_report.fetch(:final_content)).to include("tidelift: rubygems/example") + open_collective_reports = plan[:recipe_reports].select do |report| + report.fetch(:recipe_name).start_with?("opencollective_disabled_file_cleanup_") + end + expect(open_collective_reports.map { |report| report.fetch(:relative_path) }).to eq( + [".opencollective.yml", ".github/workflows/opencollective.yml"] + ) + expect(open_collective_reports).to all(satisfy { |report| report.fetch(:metadata).fetch(:delete_file) == true }) + + apply = described_class.apply_project(root) + expect(apply[:changed_files]).to include(".opencollective.yml", ".github/workflows/opencollective.yml") + expect(File).not_to exist(File.join(root, ".opencollective.yml")) + expect(File).not_to exist(File.join(root, ".github/workflows/opencollective.yml")) end end end From dc5d6a2542dea42aaeca10d0bd1b16cdf3507934 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 01:20:52 -0600 Subject: [PATCH 26/71] Add kettle-jem no-osc template preference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 84 +++++++++++++++++++++++++ gems/kettle-jem/spec/thin_slice_spec.rb | 45 +++++++++++++ 2 files changed, 129 insertions(+) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 400b2de..cf05c16 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -61,6 +61,8 @@ def discover_facts(project_root) facts[:ci][:coverage] = coverage_config unless coverage_config.empty? framework_matrix = github_actions_framework_matrix(kettle_config) facts[:ci][:framework_matrix] = framework_matrix unless framework_matrix.empty? + template_preferences = template_source_preferences(project_root, kettle_config, opencollective_disabled: opencollective_disabled) + facts[:templates] = { source_preferences: template_preferences } unless template_preferences.empty? facts end @@ -129,6 +131,17 @@ def recipe_pack(facts) facts: %w[ci] ) end + facts.dig(:templates, :source_preferences).to_a.each do |preference| + recipe = recipe_entry( + "template_source_preference_#{workflow_recipe_slug(preference.fetch(:target_path))}", + preference.fetch(:target_path), + "file", + "supplied_template_source_preference", + facts: %w[templates funding] + ) + recipe[:template_preference] = preference + recipes << recipe + end recipes << recipe_entry( "rakefile_scaffold_cleanup", "Rakefile", @@ -297,6 +310,8 @@ def execute_recipe(project_root:, recipe:, facts:, files:) "" when /\Agithub_actions_workflow_snippets_/ synchronize_github_actions_workflow_snippets(original) + when /\Atemplate_source_preference_/ + original when "rakefile_scaffold_cleanup" deletion.fetch(:content) else @@ -402,6 +417,7 @@ def read_project_files(project_root, pack) def recipe_report_metadata(recipe) metadata = { packaging_recipe: recipe.fetch(:name) } metadata[:delete_file] = true if delete_file_recipe?(recipe) + metadata[:template_source_preference] = deep_dup(recipe[:template_preference]) if recipe[:template_preference] metadata end @@ -422,6 +438,7 @@ def recipe_runtime_context(recipe, facts, deletion) if recipe.fetch(:primitive) == "supplied_source_selector_deletion" && deletion context[:delete_selectors] = deletion.fetch(:delete_selectors) end + context[:template_source_preference] = deep_dup(recipe[:template_preference]) if recipe[:template_preference] context end @@ -441,6 +458,13 @@ def step_report_metadata(recipe, deletion) deleted_file: recipe.fetch(:target_path), ) end + if recipe.fetch(:primitive) == "supplied_template_source_preference" + metadata.merge!( + policy_kind: "select_template_source", + operation: "select", + template_source_preference: deep_dup(recipe.fetch(:template_preference)), + ) + end return metadata unless deletion metadata.merge( @@ -592,6 +616,66 @@ def falsey_config?(value) %w[false no 0].include?(value.to_s.strip.downcase) end + def template_source_preferences(project_root, config, opencollective_disabled: false) + templates = config["templates"] + return [] unless templates.is_a?(Hash) + + root = templates.fetch("root", "template").to_s + entries = templates["entries"] + return [] unless entries.is_a?(Array) + + entries.filter_map do |entry| + template_source_preference(project_root, root, entry, opencollective_disabled: opencollective_disabled) + end + end + + def template_source_preference(project_root, template_root, entry, opencollective_disabled: false) + source_path, target_path = template_entry_paths(entry) + return nil if source_path.to_s.empty? || target_path.to_s.empty? + + selected_source = preferred_template_source(project_root, File.join(template_root, source_path), opencollective_disabled: opencollective_disabled) + return nil unless selected_source + + { + target_path: target_path, + configured_source: source_path, + selected_source: selected_source, + selection_reason: template_source_selection_reason(source_path, selected_source), + } + end + + def template_entry_paths(entry) + if entry.is_a?(Hash) + source_path = entry.fetch("source", entry["target"]).to_s + target_path = entry.fetch("target", source_path.sub(/\.example\z/, "")).to_s + [source_path, target_path] + else + source_path = entry.to_s + [source_path, source_path.sub(/\.example\z/, "")] + end + end + + def preferred_template_source(project_root, configured_source, opencollective_disabled: false) + base = configured_source.sub(/\.example\z/, "") + candidates = [] + candidates << "#{base}.no-osc.example" if opencollective_disabled + candidates << "#{base}.example" + candidates << configured_source + candidates.find { |relative_path| File.exist?(File.join(project_root, relative_path)) } + end + + def template_source_selection_reason(configured_source, selected_source) + if selected_source.end_with?(".no-osc.example") + "opencollective_disabled_no_osc_variant" + elsif selected_source.end_with?(".example") + "default_example_variant" + elsif selected_source == configured_source + "configured_source" + else + "fallback_source" + end + end + def github_actions_framework_matrix(config) workflows = config["workflows"] return {} unless workflows.is_a?(Hash) && workflows["preset"].to_s.strip.downcase == "framework" diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 85f3a81..cfb91e1 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -148,6 +148,12 @@ def project_files(root, paths) ".kettle-jem.yml" => <<~YAML, funding: open_collective: false + templates: + root: template + entries: + - README.md + - source: FUNDING.md.example + target: FUNDING.md YAML ".github/FUNDING.yml" => <<~YAML, github: [example] @@ -166,6 +172,19 @@ def project_files(root, paths) steps: - uses: actions/checkout@v3 YAML + "template/README.md.example" => <<~MARKDOWN, + # Example + + Open Collective enabled. + MARKDOWN + "template/README.md.no-osc.example" => <<~MARKDOWN, + # Example + + Open Collective disabled. + MARKDOWN + "template/FUNDING.md.example" => <<~MARKDOWN, + # Funding + MARKDOWN }) plan = described_class.plan_project(root) @@ -178,6 +197,32 @@ def project_files(root, paths) expect(recipe_names).to include("opencollective_disabled_file_cleanup_opencollective_yml") expect(recipe_names).to include("opencollective_disabled_file_cleanup_github_workflows_opencollective_yml") expect(recipe_names).not_to include("github_actions_workflow_snippets_github_workflows_opencollective_yml") + expect(plan.dig(:facts, :templates, :source_preferences)).to eq( + [ + { + target_path: "README.md", + configured_source: "README.md", + selected_source: "template/README.md.no-osc.example", + selection_reason: "opencollective_disabled_no_osc_variant", + }, + { + target_path: "FUNDING.md", + configured_source: "FUNDING.md.example", + selected_source: "template/FUNDING.md.example", + selection_reason: "default_example_variant", + }, + ] + ) + template_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_preference_README_md" + end + expect(template_report.fetch(:changed)).to be(false) + expect(template_report.dig(:metadata, :template_source_preference, :selected_source)).to eq( + "template/README.md.no-osc.example" + ) + expect(template_report.dig(:request_envelope, :request, :runtime_context, :template_source_preference, :selection_reason)).to eq( + "opencollective_disabled_no_osc_variant" + ) funding_report = plan[:recipe_reports].find { |report| report.fetch(:recipe_name) == "github_funding_yml" } expect(funding_report.fetch(:final_content)).not_to include("open_collective") expect(funding_report.fetch(:final_content)).to include("tidelift: rubygems/example") From ca2adf793ad26fc318b833a461c34f0db69fdca3 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 01:23:38 -0600 Subject: [PATCH 27/71] Add kettle-jem Open Collective env compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 43 ++++++++++++---- gems/kettle-jem/spec/thin_slice_spec.rb | 66 +++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index cf05c16..7fa833c 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -17,7 +17,7 @@ module Jem module_function - def discover_facts(project_root) + def discover_facts(project_root, env: ENV) gemspec_path = Dir.glob(File.join(project_root, "*.gemspec")).sort.first raise ArgumentError, "no gemspec found in #{project_root}" unless gemspec_path @@ -44,9 +44,11 @@ def discover_facts(project_root) ), } kettle_config = kettle_jem_config(project_root) - opencollective_disabled = opencollective_disabled?(kettle_config) + opencollective_policy = opencollective_policy(kettle_config, env) + opencollective_disabled = opencollective_policy.fetch(:disabled) funding = compact_hash(urls: funding_urls(project_root, gemspec, name, opencollective_disabled: opencollective_disabled)) funding[:open_collective_disabled] = true if opencollective_disabled + funding[:open_collective_disabled_source] = opencollective_policy[:source] if opencollective_disabled open_collective_files = opencollective_disabled ? opencollective_disabled_files(project_root) : [] funding[:open_collective_files] = open_collective_files unless open_collective_files.empty? facts[:funding] = funding unless funding.empty? @@ -160,8 +162,8 @@ def recipe_pack(facts) } end - def plan_project(project_root) - facts = discover_facts(project_root) + def plan_project(project_root, env: ENV) + facts = discover_facts(project_root, env: env) pack = recipe_pack(facts) files = read_project_files(project_root, pack) recipe_reports = pack.fetch(:recipes).map do |recipe| @@ -180,8 +182,8 @@ def plan_project(project_root) } end - def apply_project(project_root) - report = plan_project(project_root).merge(mode: "apply") + def apply_project(project_root, env: ENV) + report = plan_project(project_root, env: env).merge(mode: "apply") report.fetch(:recipe_reports).each do |recipe_report| next unless recipe_report[:changed] @@ -606,10 +608,33 @@ def kettle_jem_config(project_root) config.is_a?(Hash) ? config : {} end - def opencollective_disabled?(config) + def opencollective_disabled?(config, env: ENV) + opencollective_policy(config, env).fetch(:disabled) + end + + def opencollective_policy(config, env) funding = config["funding"] - config_value = funding["open_collective"] if funding.is_a?(Hash) - falsey_config?(config_value) + if funding.is_a?(Hash) && funding.key?("open_collective") + config_value = funding["open_collective"] + return { + disabled: falsey_config?(config_value), + source: "config.funding.open_collective", + value: config_value.to_s, + } + end + + env_falsey = opencollective_falsey_env(env) + return { disabled: true, source: "env.#{env_falsey.fetch(:key)}", value: env_falsey.fetch(:value).to_s } if env_falsey + + { disabled: false } + end + + def opencollective_falsey_env(env) + %w[OPENCOLLECTIVE_HANDLE FUNDING_ORG].each do |key| + value = env[key] + return { key: key, value: value } if falsey_config?(value) + end + nil end def falsey_config?(value) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index cfb91e1..f1e6bd5 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -42,7 +42,7 @@ def project_files(root, paths) Dir.mktmpdir("kettle-jem-thin-slice", tmp_root) do |root| write_tree(root, fixture.fetch(:inputs).fetch(:files)) - plan = described_class.plan_project(root) + plan = described_class.plan_project(root, env: {}) expect(json_ready(plan[:facts])).to eq(json_ready(fixture.fetch(:expected).fetch(:facts))) recipe_names = plan[:recipe_pack][:recipes].map { |recipe| recipe[:name] } expect(recipe_names.take(expected_recipe_names.length)).to eq(expected_recipe_names) @@ -95,7 +95,7 @@ def project_files(root, paths) ".github/workflows/ancient.yml" ) - apply = described_class.apply_project(root) + apply = described_class.apply_project(root, env: {}) expect(apply[:changed_files]).to eq(fixture.fetch(:expected).fetch(:changed_files)) expect(project_files(root, fixture.fetch(:expected).fetch(:files).keys.map(&:to_s))).to eq(fixture.fetch(:expected).fetch(:files)) end @@ -122,7 +122,7 @@ def project_files(root, paths) YAML }) - plan = described_class.plan_project(root) + plan = described_class.plan_project(root, env: {}) coverage_report = plan[:recipe_reports].find { |report| report.fetch(:recipe_name) == "github_actions_coverage_ci" } expect(coverage_report.dig(:request_envelope, :request, :provider_family)).to eq("yaml") expect(coverage_report.fetch(:relative_path)).to eq(".github/workflows/coverage.yml") @@ -187,8 +187,9 @@ def project_files(root, paths) MARKDOWN }) - plan = described_class.plan_project(root) + plan = described_class.plan_project(root, env: {}) expect(plan.dig(:facts, :funding, :open_collective_disabled)).to be(true) + expect(plan.dig(:facts, :funding, :open_collective_disabled_source)).to eq("config.funding.open_collective") expect(plan.dig(:facts, :funding, :open_collective_files)).to eq( [".opencollective.yml", ".github/workflows/opencollective.yml"] ) @@ -234,10 +235,65 @@ def project_files(root, paths) ) expect(open_collective_reports).to all(satisfy { |report| report.fetch(:metadata).fetch(:delete_file) == true }) - apply = described_class.apply_project(root) + apply = described_class.apply_project(root, env: {}) expect(apply[:changed_files]).to include(".opencollective.yml", ".github/workflows/opencollective.yml") expect(File).not_to exist(File.join(root, ".opencollective.yml")) expect(File).not_to exist(File.join(root, ".github/workflows/opencollective.yml")) end end + + it "honors falsey Open Collective environment variables when config is absent" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-opencollective-env-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".github/FUNDING.yml" => <<~YAML, + github: [example] + open_collective: example + YAML + ".opencollective.yml" => <<~YAML, + collective: example + YAML + }) + + plan = described_class.plan_project(root, env: { "OPENCOLLECTIVE_HANDLE" => "NO" }) + expect(plan.dig(:facts, :funding, :open_collective_disabled)).to be(true) + expect(plan.dig(:facts, :funding, :open_collective_disabled_source)).to eq("env.OPENCOLLECTIVE_HANDLE") + expect(plan.dig(:facts, :funding, :urls)).not_to include("https://opencollective.com/example") + expect(plan[:changed_files]).to include(".opencollective.yml") + end + end + + it "lets explicit Open Collective config override falsey environment variables" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-opencollective-config-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + funding: + open_collective: true + YAML + ".github/FUNDING.yml" => <<~YAML, + github: [example] + open_collective: example + YAML + }) + + plan = described_class.plan_project(root, env: { "FUNDING_ORG" => "0" }) + expect(plan.dig(:facts, :funding, :open_collective_disabled)).to be_nil + expect(plan.dig(:facts, :funding, :urls)).to include("https://opencollective.com/example") + end + end end From 921b5860ba1f3846ad1ec40bb9f70478eb5f106b Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 01:25:46 -0600 Subject: [PATCH 28/71] Add kettle-jem Open Collective org discovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 50 ++++++++++++++++++++++++- gems/kettle-jem/spec/thin_slice_spec.rb | 47 +++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 7fa833c..ef31526 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -46,9 +46,22 @@ def discover_facts(project_root, env: ENV) kettle_config = kettle_jem_config(project_root) opencollective_policy = opencollective_policy(kettle_config, env) opencollective_disabled = opencollective_policy.fetch(:disabled) - funding = compact_hash(urls: funding_urls(project_root, gemspec, name, opencollective_disabled: opencollective_disabled)) + open_collective_org = opencollective_org(project_root, env, opencollective_disabled: opencollective_disabled) + funding = compact_hash( + urls: funding_urls( + project_root, + gemspec, + name, + opencollective_disabled: opencollective_disabled, + open_collective_org: open_collective_org && open_collective_org.fetch(:org) + ) + ) funding[:open_collective_disabled] = true if opencollective_disabled funding[:open_collective_disabled_source] = opencollective_policy[:source] if opencollective_disabled + if open_collective_org + funding[:open_collective_org] = open_collective_org.fetch(:org) + funding[:open_collective_org_source] = open_collective_org.fetch(:source) + end open_collective_files = opencollective_disabled ? opencollective_disabled_files(project_root) : [] funding[:open_collective_files] = open_collective_files unless open_collective_files.empty? facts[:funding] = funding unless funding.empty? @@ -495,10 +508,11 @@ def extract_metadata_value(source, key) match && match[1] end - def funding_urls(project_root, gemspec_source, package_name, opencollective_disabled: false) + def funding_urls(project_root, gemspec_source, package_name, opencollective_disabled: false, open_collective_org: nil) urls = [extract_metadata_value(gemspec_source, "funding_uri")] path = File.join(project_root, ".github", "FUNDING.yml") urls.concat(github_funding_urls(path, opencollective_disabled: opencollective_disabled)) if File.exist?(path) + urls << github_funding_platform_urls("open_collective", [open_collective_org]).first unless opencollective_disabled urls << github_funding_platform_urls("tidelift", ["rubygems/#{package_name}"]).first urls.compact.uniq.sort @@ -637,6 +651,38 @@ def opencollective_falsey_env(env) nil end + def opencollective_org(project_root, env, opencollective_disabled: false) + return nil if opencollective_disabled + + env_org = opencollective_org_env(env) + return env_org if env_org + + opencollective_org_file(project_root) + end + + def opencollective_org_env(env) + %w[OPENCOLLECTIVE_HANDLE FUNDING_ORG].each do |key| + value = env[key].to_s.strip + next if value.empty? || falsey_config?(value) + + return { org: value, source: "env.#{key}" } + end + nil + end + + def opencollective_org_file(project_root) + path = File.join(project_root, ".opencollective.yml") + return nil unless File.exist?(path) + + config = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {} + return nil unless config.is_a?(Hash) + + org = config.fetch("collective", config["org"]).to_s.strip + return nil if org.empty? + + { org: org, source: ".opencollective.yml" } + end + def falsey_config?(value) %w[false no 0].include?(value.to_s.strip.downcase) end diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index f1e6bd5..ce1308f 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -265,6 +265,7 @@ def project_files(root, paths) plan = described_class.plan_project(root, env: { "OPENCOLLECTIVE_HANDLE" => "NO" }) expect(plan.dig(:facts, :funding, :open_collective_disabled)).to be(true) expect(plan.dig(:facts, :funding, :open_collective_disabled_source)).to eq("env.OPENCOLLECTIVE_HANDLE") + expect(plan.dig(:facts, :funding, :open_collective_org)).to be_nil expect(plan.dig(:facts, :funding, :urls)).not_to include("https://opencollective.com/example") expect(plan[:changed_files]).to include(".opencollective.yml") end @@ -296,4 +297,50 @@ def project_files(root, paths) expect(plan.dig(:facts, :funding, :urls)).to include("https://opencollective.com/example") end end + + it "discovers Open Collective org from environment before .opencollective.yml" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-opencollective-env-org-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".opencollective.yml" => <<~YAML, + collective: yaml-org + YAML + }) + + plan = described_class.plan_project(root, env: { "FUNDING_ORG" => "env-org" }) + expect(plan.dig(:facts, :funding, :open_collective_org)).to eq("env-org") + expect(plan.dig(:facts, :funding, :open_collective_org_source)).to eq("env.FUNDING_ORG") + expect(plan.dig(:facts, :funding, :urls)).to include("https://opencollective.com/env-org") + end + end + + it "discovers Open Collective org from .opencollective.yml when env is absent" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-opencollective-yaml-org-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".opencollective.yml" => <<~YAML, + org: yaml-org + YAML + }) + + plan = described_class.plan_project(root, env: {}) + expect(plan.dig(:facts, :funding, :open_collective_org)).to eq("yaml-org") + expect(plan.dig(:facts, :funding, :open_collective_org_source)).to eq(".opencollective.yml") + expect(plan.dig(:facts, :funding, :urls)).to include("https://opencollective.com/yaml-org") + end + end end From bb91ef14ab7d72a8e02685a998aad28c4baa6c73 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 01:28:26 -0600 Subject: [PATCH 29/71] Add kettle-jem Open Collective token projection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 17 ++++++++++++++++- gems/kettle-jem/spec/thin_slice_spec.rb | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index ef31526..156c481 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -76,8 +76,12 @@ def discover_facts(project_root, env: ENV) facts[:ci][:coverage] = coverage_config unless coverage_config.empty? framework_matrix = github_actions_framework_matrix(kettle_config) facts[:ci][:framework_matrix] = framework_matrix unless framework_matrix.empty? + template_facts = {} template_preferences = template_source_preferences(project_root, kettle_config, opencollective_disabled: opencollective_disabled) - facts[:templates] = { source_preferences: template_preferences } unless template_preferences.empty? + template_facts[:source_preferences] = template_preferences unless template_preferences.empty? + template_tokens = template_tokens(funding) + template_facts[:tokens] = template_tokens unless template_tokens.empty? + facts[:templates] = template_facts unless template_facts.empty? facts end @@ -155,6 +159,7 @@ def recipe_pack(facts) facts: %w[templates funding] ) recipe[:template_preference] = preference + recipe[:template_tokens] = facts.dig(:templates, :tokens) if facts.dig(:templates, :tokens) recipes << recipe end recipes << recipe_entry( @@ -433,6 +438,7 @@ def recipe_report_metadata(recipe) metadata = { packaging_recipe: recipe.fetch(:name) } metadata[:delete_file] = true if delete_file_recipe?(recipe) metadata[:template_source_preference] = deep_dup(recipe[:template_preference]) if recipe[:template_preference] + metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens] metadata end @@ -454,6 +460,7 @@ def recipe_runtime_context(recipe, facts, deletion) context[:delete_selectors] = deletion.fetch(:delete_selectors) end context[:template_source_preference] = deep_dup(recipe[:template_preference]) if recipe[:template_preference] + context[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens] context end @@ -479,6 +486,7 @@ def step_report_metadata(recipe, deletion) operation: "select", template_source_preference: deep_dup(recipe.fetch(:template_preference)), ) + metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens] end return metadata unless deletion @@ -683,6 +691,13 @@ def opencollective_org_file(project_root) { org: org, source: ".opencollective.yml" } end + def template_tokens(funding) + org = funding[:open_collective_org].to_s + return {} if org.empty? + + { "KJ|OPENCOLLECTIVE_ORG" => org } + end + def falsey_config?(value) %w[false no 0].include?(value.to_s.strip.downcase) end diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index ce1308f..7d8e6df 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -309,15 +309,32 @@ def project_files(root, paths) spec.summary = "Example gem" end RUBY + ".kettle-jem.yml" => <<~YAML, + templates: + root: template + entries: + - README.md + YAML ".opencollective.yml" => <<~YAML, collective: yaml-org YAML + "template/README.md.example" => <<~MARKDOWN, + # {KJ|OPENCOLLECTIVE_ORG} + MARKDOWN }) plan = described_class.plan_project(root, env: { "FUNDING_ORG" => "env-org" }) expect(plan.dig(:facts, :funding, :open_collective_org)).to eq("env-org") expect(plan.dig(:facts, :funding, :open_collective_org_source)).to eq("env.FUNDING_ORG") expect(plan.dig(:facts, :funding, :urls)).to include("https://opencollective.com/env-org") + expect(plan.dig(:facts, :templates, :tokens)).to eq("KJ|OPENCOLLECTIVE_ORG" => "env-org") + template_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_preference_README_md" + end + expect(template_report.dig(:metadata, :template_tokens)).to eq("KJ|OPENCOLLECTIVE_ORG" => "env-org") + expect(template_report.dig(:request_envelope, :request, :runtime_context, :template_tokens)).to eq( + "KJ|OPENCOLLECTIVE_ORG" => "env-org" + ) end end From d504dceecea38a2c5a2d7f472b2bcf514200268b Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 01:29:50 -0600 Subject: [PATCH 30/71] Add kettle-jem template token application Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 51 ++++++++++++++++-- gems/kettle-jem/spec/thin_slice_spec.rb | 72 +++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 156c481..78a637b 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -151,11 +151,12 @@ def recipe_pack(facts) ) end facts.dig(:templates, :source_preferences).to_a.each do |preference| + apply_template = preference.fetch(:apply, false) recipe = recipe_entry( - "template_source_preference_#{workflow_recipe_slug(preference.fetch(:target_path))}", + "#{apply_template ? "template_source_application" : "template_source_preference"}_#{workflow_recipe_slug(preference.fetch(:target_path))}", preference.fetch(:target_path), "file", - "supplied_template_source_preference", + apply_template ? "supplied_template_source_application" : "supplied_template_source_preference", facts: %w[templates funding] ) recipe[:template_preference] = preference @@ -332,19 +333,22 @@ def execute_recipe(project_root:, recipe:, facts:, files:) synchronize_github_actions_workflow_snippets(original) when /\Atemplate_source_preference_/ original + when /\Atemplate_source_application_/ + apply_template_source(project_root, recipe) when "rakefile_scaffold_cleanup" deletion.fetch(:content) else original end + template_content = recipe_template_content(project_root, recipe) request = content_recipe_execution_request( recipe_name: recipe.fetch(:primitive), recipe_version: "1", relative_path: relative_path, provider_family: recipe.fetch(:provider_family), provider_backend: recipe[:provider_backend], - template_content: "", + template_content: template_content, destination_content: original, steps: [content_recipe_step(recipe)], runtime_context: recipe_runtime_context(recipe, facts, deletion), @@ -434,6 +438,17 @@ def read_project_files(project_root, pack) end end + def recipe_template_content(project_root, recipe) + return "" unless %w[supplied_template_source_preference supplied_template_source_application].include?(recipe.fetch(:primitive)) + + path = File.join(project_root, recipe.fetch(:template_preference).fetch(:selected_source)) + File.read(path) + end + + def apply_template_source(project_root, recipe) + resolve_template_tokens(recipe_template_content(project_root, recipe), recipe.fetch(:template_tokens, {})) + end + def recipe_report_metadata(recipe) metadata = { packaging_recipe: recipe.fetch(:name) } metadata[:delete_file] = true if delete_file_recipe?(recipe) @@ -488,6 +503,14 @@ def step_report_metadata(recipe, deletion) ) metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens] end + if recipe.fetch(:primitive) == "supplied_template_source_application" + metadata.merge!( + policy_kind: "apply_template_source", + operation: "replace", + template_source_preference: deep_dup(recipe.fetch(:template_preference)), + ) + metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens] + end return metadata unless deletion metadata.merge( @@ -698,6 +721,16 @@ def template_tokens(funding) { "KJ|OPENCOLLECTIVE_ORG" => org } end + def resolve_template_tokens(content, tokens) + resolved = tokens.reduce(content.to_s) do |memo, (token, value)| + memo.gsub("{#{token}}", value.to_s) + end + unresolved = resolved.scan(/\{KJ\|[^}]+\}/).uniq.sort + return resolved if unresolved.empty? + + raise ArgumentError, "unresolved kettle-jem template tokens: #{unresolved.join(", ")}" + end + def falsey_config?(value) %w[false no 0].include?(value.to_s.strip.downcase) end @@ -710,12 +743,13 @@ def template_source_preferences(project_root, config, opencollective_disabled: f entries = templates["entries"] return [] unless entries.is_a?(Array) + apply_templates = templates["apply"] == true entries.filter_map do |entry| - template_source_preference(project_root, root, entry, opencollective_disabled: opencollective_disabled) + template_source_preference(project_root, root, entry, opencollective_disabled: opencollective_disabled, apply_templates: apply_templates) end end - def template_source_preference(project_root, template_root, entry, opencollective_disabled: false) + def template_source_preference(project_root, template_root, entry, opencollective_disabled: false, apply_templates: false) source_path, target_path = template_entry_paths(entry) return nil if source_path.to_s.empty? || target_path.to_s.empty? @@ -727,6 +761,7 @@ def template_source_preference(project_root, template_root, entry, opencollectiv configured_source: source_path, selected_source: selected_source, selection_reason: template_source_selection_reason(source_path, selected_source), + apply: template_entry_apply?(entry, apply_templates), } end @@ -741,6 +776,12 @@ def template_entry_paths(entry) end end + def template_entry_apply?(entry, apply_templates) + return entry["apply"] == true if entry.is_a?(Hash) && entry.key?("apply") + + apply_templates + end + def preferred_template_source(project_root, configured_source, opencollective_disabled: false) base = configured_source.sub(/\.example\z/, "") candidates = [] diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 7d8e6df..45c1f2d 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -205,12 +205,14 @@ def project_files(root, paths) configured_source: "README.md", selected_source: "template/README.md.no-osc.example", selection_reason: "opencollective_disabled_no_osc_variant", + apply: false, }, { target_path: "FUNDING.md", configured_source: "FUNDING.md.example", selected_source: "template/FUNDING.md.example", selection_reason: "default_example_variant", + apply: false, }, ] ) @@ -360,4 +362,74 @@ def project_files(root, paths) expect(plan.dig(:facts, :funding, :urls)).to include("https://opencollective.com/yaml-org") end end + + it "applies selected template content with projected tokens when configured" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-template-application-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + templates: + root: template + apply: true + entries: + - README.md + YAML + "README.md" => "# old\n", + ".opencollective.yml" => <<~YAML, + collective: yaml-org + YAML + "template/README.md.example" => <<~MARKDOWN, + # {KJ|OPENCOLLECTIVE_ORG} + MARKDOWN + }) + + plan = described_class.plan_project(root, env: {}) + template_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_application_README_md" + end + expect(template_report.fetch(:changed)).to be(true) + expect(template_report.dig(:request_envelope, :request, :template_content)).to eq("# {KJ|OPENCOLLECTIVE_ORG}\n") + expect(template_report.fetch(:final_content)).to eq("# yaml-org\n") + expect(template_report.dig(:metadata, :template_tokens)).to eq("KJ|OPENCOLLECTIVE_ORG" => "yaml-org") + + described_class.apply_project(root, env: {}) + expect(File.read(File.join(root, "README.md"))).to eq("# yaml-org\n") + end + end + + it "fails fast when template application leaves unresolved tokens" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-template-unresolved-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + templates: + root: template + apply: true + entries: + - README.md + YAML + "template/README.md.example" => <<~MARKDOWN, + # {KJ|UNKNOWN} + MARKDOWN + }) + + expect do + described_class.plan_project(root, env: {}) + end.to raise_error(ArgumentError, /unresolved kettle-jem template tokens: \{KJ\|UNKNOWN\}/) + end + end end From f5f7695354b7de2da36b1783a845b8d806d5dbfd Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 01:31:30 -0600 Subject: [PATCH 31/71] Add kettle-jem package template tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 19 +++++++++---- gems/kettle-jem/spec/thin_slice_spec.rb | 36 +++++++++++++++++++------ 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 78a637b..4ab95ec 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -79,8 +79,10 @@ def discover_facts(project_root, env: ENV) template_facts = {} template_preferences = template_source_preferences(project_root, kettle_config, opencollective_disabled: opencollective_disabled) template_facts[:source_preferences] = template_preferences unless template_preferences.empty? - template_tokens = template_tokens(funding) - template_facts[:tokens] = template_tokens unless template_tokens.empty? + unless template_preferences.empty? + template_tokens = template_tokens(facts, funding) + template_facts[:tokens] = template_tokens unless template_tokens.empty? + end facts[:templates] = template_facts unless template_facts.empty? facts end @@ -714,11 +716,18 @@ def opencollective_org_file(project_root) { org: org, source: ".opencollective.yml" } end - def template_tokens(funding) + def template_tokens(facts, funding) + package = facts.fetch(:package) + rubygems = facts.fetch(:rubygems) + tokens = { + "KJ|GEM_NAME" => package.fetch(:name).to_s, + "KJ|GEM_NAME_PATH" => package.fetch(:name).to_s.tr("-", "/"), + "KJ|NAMESPACE" => rubygems.fetch(:namespace).to_s, + } org = funding[:open_collective_org].to_s - return {} if org.empty? + tokens["KJ|OPENCOLLECTIVE_ORG"] = org unless org.empty? - { "KJ|OPENCOLLECTIVE_ORG" => org } + tokens.reject { |_, value| value.empty? } end def resolve_template_tokens(content, tokens) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 45c1f2d..9c54ce0 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -329,12 +329,17 @@ def project_files(root, paths) expect(plan.dig(:facts, :funding, :open_collective_org)).to eq("env-org") expect(plan.dig(:facts, :funding, :open_collective_org_source)).to eq("env.FUNDING_ORG") expect(plan.dig(:facts, :funding, :urls)).to include("https://opencollective.com/env-org") - expect(plan.dig(:facts, :templates, :tokens)).to eq("KJ|OPENCOLLECTIVE_ORG" => "env-org") + expect(plan.dig(:facts, :templates, :tokens)).to include( + "KJ|GEM_NAME" => "example", + "KJ|GEM_NAME_PATH" => "example", + "KJ|NAMESPACE" => "Example", + "KJ|OPENCOLLECTIVE_ORG" => "env-org" + ) template_report = plan[:recipe_reports].find do |report| report.fetch(:recipe_name) == "template_source_preference_README_md" end - expect(template_report.dig(:metadata, :template_tokens)).to eq("KJ|OPENCOLLECTIVE_ORG" => "env-org") - expect(template_report.dig(:request_envelope, :request, :runtime_context, :template_tokens)).to eq( + expect(template_report.dig(:metadata, :template_tokens)).to include("KJ|OPENCOLLECTIVE_ORG" => "env-org") + expect(template_report.dig(:request_envelope, :request, :runtime_context, :template_tokens)).to include( "KJ|OPENCOLLECTIVE_ORG" => "env-org" ) end @@ -386,7 +391,11 @@ def project_files(root, paths) collective: yaml-org YAML "template/README.md.example" => <<~MARKDOWN, - # {KJ|OPENCOLLECTIVE_ORG} + # {KJ|GEM_NAME} + + Namespace: {KJ|NAMESPACE} + Path: {KJ|GEM_NAME_PATH} + Funding: {KJ|OPENCOLLECTIVE_ORG} MARKDOWN }) @@ -395,12 +404,23 @@ def project_files(root, paths) report.fetch(:recipe_name) == "template_source_application_README_md" end expect(template_report.fetch(:changed)).to be(true) - expect(template_report.dig(:request_envelope, :request, :template_content)).to eq("# {KJ|OPENCOLLECTIVE_ORG}\n") - expect(template_report.fetch(:final_content)).to eq("# yaml-org\n") - expect(template_report.dig(:metadata, :template_tokens)).to eq("KJ|OPENCOLLECTIVE_ORG" => "yaml-org") + expect(template_report.dig(:request_envelope, :request, :template_content)).to include("{KJ|GEM_NAME}") + expect(template_report.fetch(:final_content)).to eq(<<~MARKDOWN) + # example + + Namespace: Example + Path: example + Funding: yaml-org + MARKDOWN + expect(template_report.dig(:metadata, :template_tokens)).to include( + "KJ|GEM_NAME" => "example", + "KJ|GEM_NAME_PATH" => "example", + "KJ|NAMESPACE" => "Example", + "KJ|OPENCOLLECTIVE_ORG" => "yaml-org" + ) described_class.apply_project(root, env: {}) - expect(File.read(File.join(root, "README.md"))).to eq("# yaml-org\n") + expect(File.read(File.join(root, "README.md"))).to eq(template_report.fetch(:final_content)) end end From df419dbc1f56a73593222c60b78501dc85a27a21 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 01:33:06 -0600 Subject: [PATCH 32/71] Add kettle-jem minimum Ruby template token Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 5 +++++ gems/kettle-jem/spec/thin_slice_spec.rb | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 4ab95ec..5f8981f 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -723,6 +723,7 @@ def template_tokens(facts, funding) "KJ|GEM_NAME" => package.fetch(:name).to_s, "KJ|GEM_NAME_PATH" => package.fetch(:name).to_s.tr("-", "/"), "KJ|NAMESPACE" => rubygems.fetch(:namespace).to_s, + "KJ|MIN_RUBY" => minimum_ruby_token(rubygems[:min_ruby]), } org = funding[:open_collective_org].to_s tokens["KJ|OPENCOLLECTIVE_ORG"] = org unless org.empty? @@ -730,6 +731,10 @@ def template_tokens(facts, funding) tokens.reject { |_, value| value.empty? } end + def minimum_ruby_token(requirement) + requirement.to_s[/\d+(?:\.\d+){1,2}/].to_s + end + def resolve_template_tokens(content, tokens) resolved = tokens.reduce(content.to_s) do |memo, (token, value)| memo.gsub("{#{token}}", value.to_s) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 9c54ce0..3b09ba5 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -143,6 +143,7 @@ def project_files(root, paths) Gem::Specification.new do |spec| spec.name = "example" spec.summary = "Example gem" + spec.required_ruby_version = ">= 3.2" end RUBY ".kettle-jem.yml" => <<~YAML, @@ -253,6 +254,7 @@ def project_files(root, paths) Gem::Specification.new do |spec| spec.name = "example" spec.summary = "Example gem" + spec.required_ruby_version = ">= 3.2" end RUBY ".github/FUNDING.yml" => <<~YAML, @@ -377,6 +379,7 @@ def project_files(root, paths) Gem::Specification.new do |spec| spec.name = "example" spec.summary = "Example gem" + spec.required_ruby_version = ">= 3.2" end RUBY ".kettle-jem.yml" => <<~YAML, @@ -395,6 +398,7 @@ def project_files(root, paths) Namespace: {KJ|NAMESPACE} Path: {KJ|GEM_NAME_PATH} + Ruby: {KJ|MIN_RUBY} Funding: {KJ|OPENCOLLECTIVE_ORG} MARKDOWN }) @@ -410,11 +414,13 @@ def project_files(root, paths) Namespace: Example Path: example + Ruby: 3.2 Funding: yaml-org MARKDOWN expect(template_report.dig(:metadata, :template_tokens)).to include( "KJ|GEM_NAME" => "example", "KJ|GEM_NAME_PATH" => "example", + "KJ|MIN_RUBY" => "3.2", "KJ|NAMESPACE" => "Example", "KJ|OPENCOLLECTIVE_ORG" => "yaml-org" ) From e8377d961e89402e4d2478f828c330c3d29bcd6f Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 01:47:48 -0600 Subject: [PATCH 33/71] Use token resolver for kettle-jem templates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Gemfile.lock | 1 + gems/kettle-jem/kettle-jem.gemspec | 1 + gems/kettle-jem/lib/kettle/jem.rb | 16 +++++++++++----- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0a1a9b2..2fb264e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -43,6 +43,7 @@ PATH specs: kettle-jem (0.1.0) ast-merge (= 0.1.0) + token-resolver (~> 1.0, >= 1.0.2) PATH remote: gems/kramdown-merge diff --git a/gems/kettle-jem/kettle-jem.gemspec b/gems/kettle-jem/kettle-jem.gemspec index 776e4ce..c830f80 100644 --- a/gems/kettle-jem/kettle-jem.gemspec +++ b/gems/kettle-jem/kettle-jem.gemspec @@ -24,4 +24,5 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "ast-merge", "= #{Kettle::Jem::VERSION}" + spec.add_dependency "token-resolver", "~> 1.0", ">= 1.0.2" end diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 5f8981f..f4cef95 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "fileutils" +require "token/resolver" require "yaml" require "ast/merge" require_relative "jem/version" @@ -14,6 +15,7 @@ module Jem OBSOLETE_GITHUB_WORKFLOWS = %w[ancient.yml legacy.yml supported.yml unsupported.yml main.yml hoary.yml].freeze OPENCOLLECTIVE_DISABLED_FILES = %w[.opencollective.yml .github/workflows/opencollective.yml].freeze FILE_DELETION_PRIMITIVES = %w[supplied_obsolete_file_deletion supplied_disabled_opencollective_file_deletion].freeze + TEMPLATE_TOKEN_CONFIG = Token::Resolver::Config.new(separators: ["|", ":"]).freeze module_function @@ -736,13 +738,17 @@ def minimum_ruby_token(requirement) end def resolve_template_tokens(content, tokens) - resolved = tokens.reduce(content.to_s) do |memo, (token, value)| - memo.gsub("{#{token}}", value.to_s) - end - unresolved = resolved.scan(/\{KJ\|[^}]+\}/).uniq.sort + resolver = Token::Resolver::Resolve.new(on_missing: :keep) + document = Token::Resolver::Document.new(content.to_s, config: TEMPLATE_TOKEN_CONFIG) + resolved = resolver.resolve(document, stringify_template_tokens(tokens)) + unresolved = Token::Resolver::Document.new(resolved, config: TEMPLATE_TOKEN_CONFIG).token_keys.grep(/\AKJ\|/).sort return resolved if unresolved.empty? - raise ArgumentError, "unresolved kettle-jem template tokens: #{unresolved.join(", ")}" + raise ArgumentError, "unresolved kettle-jem template tokens: #{unresolved.map { |token| "{#{token}}" }.join(", ")}" + end + + def stringify_template_tokens(tokens) + tokens.to_h.transform_keys(&:to_s).transform_values(&:to_s) end def falsey_config?(value) From 51a6b25db97038920ec20f32fa32cfae74533002 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 01:50:52 -0600 Subject: [PATCH 34/71] Add kettle-jem author template tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 40 ++++++++++++++++++++++++- gems/kettle-jem/spec/thin_slice_spec.rb | 17 +++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index f4cef95..873d398 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -45,6 +45,8 @@ def discover_facts(project_root, env: ENV) min_ruby: extract_gemspec_assignment(gemspec, "spec.required_ruby_version"), ), } + author = author_facts(gemspec) + facts[:author] = author unless author.empty? kettle_config = kettle_jem_config(project_root) opencollective_policy = opencollective_policy(kettle_config, env) opencollective_disabled = opencollective_policy.fetch(:disabled) @@ -726,7 +728,7 @@ def template_tokens(facts, funding) "KJ|GEM_NAME_PATH" => package.fetch(:name).to_s.tr("-", "/"), "KJ|NAMESPACE" => rubygems.fetch(:namespace).to_s, "KJ|MIN_RUBY" => minimum_ruby_token(rubygems[:min_ruby]), - } + }.merge(author_template_tokens(facts.fetch(:author, {}))) org = funding[:open_collective_org].to_s tokens["KJ|OPENCOLLECTIVE_ORG"] = org unless org.empty? @@ -737,6 +739,42 @@ def minimum_ruby_token(requirement) requirement.to_s[/\d+(?:\.\d+){1,2}/].to_s end + def author_facts(gemspec_source) + name = extract_gemspec_array(gemspec_source, "spec.authors").first.to_s.strip + email = extract_gemspec_array(gemspec_source, "spec.email").first.to_s.strip + compact_hash( + name: name, + given_names: author_given_names(name), + family_names: author_family_names(name), + email: email, + domain: email.split("@", 2)[1].to_s + ) + end + + def author_template_tokens(author) + { + "KJ|AUTHOR:NAME" => author[:name].to_s, + "KJ|AUTHOR:GIVEN_NAMES" => author[:given_names].to_s, + "KJ|AUTHOR:FAMILY_NAMES" => author[:family_names].to_s, + "KJ|AUTHOR:EMAIL" => author[:email].to_s, + "KJ|AUTHOR:DOMAIN" => author[:domain].to_s, + } + end + + def author_given_names(name) + parts = name.to_s.strip.split(/\s+/) + return "" if parts.size < 2 + + parts[0...-1].join(" ") + end + + def author_family_names(name) + parts = name.to_s.strip.split(/\s+/) + return "" if parts.size < 2 + + parts[-1] + end + def resolve_template_tokens(content, tokens) resolver = Token::Resolver::Resolve.new(on_missing: :keep) document = Token::Resolver::Document.new(content.to_s, config: TEMPLATE_TOKEN_CONFIG) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 3b09ba5..ccd065f 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -379,6 +379,8 @@ def project_files(root, paths) Gem::Specification.new do |spec| spec.name = "example" spec.summary = "Example gem" + spec.authors = ["Jane Q Public"] + spec.email = ["jane@example.test"] spec.required_ruby_version = ">= 3.2" end RUBY @@ -399,6 +401,11 @@ def project_files(root, paths) Namespace: {KJ|NAMESPACE} Path: {KJ|GEM_NAME_PATH} Ruby: {KJ|MIN_RUBY} + Author: {KJ|AUTHOR:NAME} + Given: {KJ|AUTHOR:GIVEN_NAMES} + Family: {KJ|AUTHOR:FAMILY_NAMES} + Email: {KJ|AUTHOR:EMAIL} + Domain: {KJ|AUTHOR:DOMAIN} Funding: {KJ|OPENCOLLECTIVE_ORG} MARKDOWN }) @@ -415,9 +422,19 @@ def project_files(root, paths) Namespace: Example Path: example Ruby: 3.2 + Author: Jane Q Public + Given: Jane Q + Family: Public + Email: jane@example.test + Domain: example.test Funding: yaml-org MARKDOWN expect(template_report.dig(:metadata, :template_tokens)).to include( + "KJ|AUTHOR:DOMAIN" => "example.test", + "KJ|AUTHOR:EMAIL" => "jane@example.test", + "KJ|AUTHOR:FAMILY_NAMES" => "Public", + "KJ|AUTHOR:GIVEN_NAMES" => "Jane Q", + "KJ|AUTHOR:NAME" => "Jane Q Public", "KJ|GEM_NAME" => "example", "KJ|GEM_NAME_PATH" => "example", "KJ|MIN_RUBY" => "3.2", From d2b84366e7e88b9cbfd6c2867259d0c771164820 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:01:20 -0600 Subject: [PATCH 35/71] Add kettle-jem author token overrides Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 48 +++++++++++++++---- gems/kettle-jem/spec/thin_slice_spec.rb | 64 +++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 873d398..c544609 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -28,6 +28,7 @@ def discover_facts(project_root, env: ENV) source_url = extract_metadata_value(gemspec, "source_code_uri") || extract_gemspec_assignment(gemspec, "spec.homepage") + kettle_config = kettle_jem_config(project_root) facts = { package: compact_hash( ecosystem: "rubygems", @@ -45,9 +46,8 @@ def discover_facts(project_root, env: ENV) min_ruby: extract_gemspec_assignment(gemspec, "spec.required_ruby_version"), ), } - author = author_facts(gemspec) + author = author_facts(gemspec, kettle_config, env) facts[:author] = author unless author.empty? - kettle_config = kettle_jem_config(project_root) opencollective_policy = opencollective_policy(kettle_config, env) opencollective_disabled = opencollective_policy.fetch(:disabled) open_collective_org = opencollective_org(project_root, env, opencollective_disabled: opencollective_disabled) @@ -739,18 +739,50 @@ def minimum_ruby_token(requirement) requirement.to_s[/\d+(?:\.\d+){1,2}/].to_s end - def author_facts(gemspec_source) - name = extract_gemspec_array(gemspec_source, "spec.authors").first.to_s.strip - email = extract_gemspec_array(gemspec_source, "spec.email").first.to_s.strip + def author_facts(gemspec_source, config, env) + token_config = token_config_values(config) + author_config = token_config["author"].is_a?(Hash) ? token_config["author"] : {} + derived_name = extract_gemspec_array(gemspec_source, "spec.authors").first + derived_email = extract_gemspec_array(gemspec_source, "spec.email").first + name = preferred_template_token_value(derived_name, author_config["name"], env, "KJ_AUTHOR_NAME").to_s + email = preferred_template_token_value(derived_email, author_config["email"], env, "KJ_AUTHOR_EMAIL").to_s + given_names = preferred_template_token_value(author_given_names(name), author_config["given_names"], env, "KJ_AUTHOR_GIVEN_NAMES") + family_names = preferred_template_token_value(author_family_names(name), author_config["family_names"], env, "KJ_AUTHOR_FAMILY_NAMES") + domain = preferred_template_token_value(email.split("@", 2)[1], author_config["domain"], env, "KJ_AUTHOR_DOMAIN") compact_hash( name: name, - given_names: author_given_names(name), - family_names: author_family_names(name), + given_names: given_names.to_s, + family_names: family_names.to_s, email: email, - domain: email.split("@", 2)[1].to_s + domain: domain.to_s ) end + def token_config_values(config) + raw = config.is_a?(Hash) ? config["tokens"] : nil + raw.is_a?(Hash) ? raw : {} + end + + def preferred_template_token_value(derived_value, config_value, env, env_key) + env_clean = env[env_key].to_s.strip + return env_clean if present_template_token_value?(env_clean) + + config_clean = config_value.to_s.strip + return config_clean if present_template_token_value?(config_clean) + return unless present_template_token_value?(derived_value) + + derived_value.to_s.strip + end + + def present_template_token_value?(value) + clean = value.to_s.strip + !clean.empty? && !token_placeholder?(clean) + end + + def token_placeholder?(value) + value.to_s.strip.match?(%r{\A\{KJ\|[A-Z][A-Z0-9_:]*\}\z}) + end + def author_template_tokens(author) { "KJ|AUTHOR:NAME" => author[:name].to_s, diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index ccd065f..6cecdad 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -447,6 +447,70 @@ def project_files(root, paths) end end + it "honors author template token config and environment overrides" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-author-token-override-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + spec.authors = ["Jane Q Public"] + spec.email = ["jane@example.test"] + end + RUBY + ".kettle-jem.yml" => <<~YAML, + tokens: + author: + name: Config Person + given_names: Config + family_names: Person + email: config@example.test + domain: config.example.test + templates: + root: template + apply: true + entries: + - README.md + YAML + "template/README.md.example" => <<~MARKDOWN, + Author: {KJ|AUTHOR:NAME} + Given: {KJ|AUTHOR:GIVEN_NAMES} + Family: {KJ|AUTHOR:FAMILY_NAMES} + Email: {KJ|AUTHOR:EMAIL} + Domain: {KJ|AUTHOR:DOMAIN} + MARKDOWN + }) + + plan = described_class.plan_project( + root, + env: { + "KJ_AUTHOR_NAME" => "Env A Writer", + "KJ_AUTHOR_EMAIL" => "env@example.test", + "KJ_AUTHOR_DOMAIN" => "env.example.test", + } + ) + template_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_application_README_md" + end + expect(template_report.fetch(:final_content)).to eq(<<~MARKDOWN) + Author: Env A Writer + Given: Config + Family: Person + Email: env@example.test + Domain: env.example.test + MARKDOWN + expect(template_report.dig(:metadata, :template_tokens)).to include( + "KJ|AUTHOR:DOMAIN" => "env.example.test", + "KJ|AUTHOR:EMAIL" => "env@example.test", + "KJ|AUTHOR:FAMILY_NAMES" => "Person", + "KJ|AUTHOR:GIVEN_NAMES" => "Config", + "KJ|AUTHOR:NAME" => "Env A Writer" + ) + end + end + it "fails fast when template application leaves unresolved tokens" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From 83a7e549ba68f372e1045852fa25758a95cd57a0 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:02:18 -0600 Subject: [PATCH 36/71] Add kettle-jem author ORCID token Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 5 ++++- gems/kettle-jem/spec/thin_slice_spec.rb | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index c544609..5589b42 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -749,12 +749,14 @@ def author_facts(gemspec_source, config, env) given_names = preferred_template_token_value(author_given_names(name), author_config["given_names"], env, "KJ_AUTHOR_GIVEN_NAMES") family_names = preferred_template_token_value(author_family_names(name), author_config["family_names"], env, "KJ_AUTHOR_FAMILY_NAMES") domain = preferred_template_token_value(email.split("@", 2)[1], author_config["domain"], env, "KJ_AUTHOR_DOMAIN") + orcid = preferred_template_token_value(nil, author_config["orcid"], env, "KJ_AUTHOR_ORCID") compact_hash( name: name, given_names: given_names.to_s, family_names: family_names.to_s, email: email, - domain: domain.to_s + domain: domain.to_s, + orcid: orcid.to_s ) end @@ -790,6 +792,7 @@ def author_template_tokens(author) "KJ|AUTHOR:FAMILY_NAMES" => author[:family_names].to_s, "KJ|AUTHOR:EMAIL" => author[:email].to_s, "KJ|AUTHOR:DOMAIN" => author[:domain].to_s, + "KJ|AUTHOR:ORCID" => author[:orcid].to_s, } end diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 6cecdad..7abaea1 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -468,6 +468,7 @@ def project_files(root, paths) family_names: Person email: config@example.test domain: config.example.test + orcid: "{KJ|AUTHOR:ORCID}" templates: root: template apply: true @@ -480,6 +481,7 @@ def project_files(root, paths) Family: {KJ|AUTHOR:FAMILY_NAMES} Email: {KJ|AUTHOR:EMAIL} Domain: {KJ|AUTHOR:DOMAIN} + ORCID: {KJ|AUTHOR:ORCID} MARKDOWN }) @@ -489,6 +491,7 @@ def project_files(root, paths) "KJ_AUTHOR_NAME" => "Env A Writer", "KJ_AUTHOR_EMAIL" => "env@example.test", "KJ_AUTHOR_DOMAIN" => "env.example.test", + "KJ_AUTHOR_ORCID" => "0000-0002-1825-0097", } ) template_report = plan[:recipe_reports].find do |report| @@ -500,13 +503,15 @@ def project_files(root, paths) Family: Person Email: env@example.test Domain: env.example.test + ORCID: 0000-0002-1825-0097 MARKDOWN expect(template_report.dig(:metadata, :template_tokens)).to include( "KJ|AUTHOR:DOMAIN" => "env.example.test", "KJ|AUTHOR:EMAIL" => "env@example.test", "KJ|AUTHOR:FAMILY_NAMES" => "Person", "KJ|AUTHOR:GIVEN_NAMES" => "Config", - "KJ|AUTHOR:NAME" => "Env A Writer" + "KJ|AUTHOR:NAME" => "Env A Writer", + "KJ|AUTHOR:ORCID" => "0000-0002-1825-0097" ) end end From ee1a850042e58a3797e583de75ccbe9519cc5ea5 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:03:44 -0600 Subject: [PATCH 37/71] Add kettle-jem forge user tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 38 ++++++++++++++++- gems/kettle-jem/spec/thin_slice_spec.rb | 57 +++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 5589b42..9893d77 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -16,6 +16,12 @@ module Jem OPENCOLLECTIVE_DISABLED_FILES = %w[.opencollective.yml .github/workflows/opencollective.yml].freeze FILE_DELETION_PRIMITIVES = %w[supplied_obsolete_file_deletion supplied_disabled_opencollective_file_deletion].freeze TEMPLATE_TOKEN_CONFIG = Token::Resolver::Config.new(separators: ["|", ":"]).freeze + FORGE_USER_ENV_KEYS = { + gh_user: "KJ_GH_USER", + gl_user: "KJ_GL_USER", + cb_user: "KJ_CB_USER", + sh_user: "KJ_SH_USER", + }.freeze module_function @@ -48,6 +54,8 @@ def discover_facts(project_root, env: ENV) } author = author_facts(gemspec, kettle_config, env) facts[:author] = author unless author.empty? + forge = forge_facts(kettle_config, env) + facts[:forge] = forge unless forge.empty? opencollective_policy = opencollective_policy(kettle_config, env) opencollective_disabled = opencollective_policy.fetch(:disabled) open_collective_org = opencollective_org(project_root, env, opencollective_disabled: opencollective_disabled) @@ -728,7 +736,11 @@ def template_tokens(facts, funding) "KJ|GEM_NAME_PATH" => package.fetch(:name).to_s.tr("-", "/"), "KJ|NAMESPACE" => rubygems.fetch(:namespace).to_s, "KJ|MIN_RUBY" => minimum_ruby_token(rubygems[:min_ruby]), - }.merge(author_template_tokens(facts.fetch(:author, {}))) + }.merge( + author_template_tokens(facts.fetch(:author, {})) + ).merge( + forge_template_tokens(facts.fetch(:forge, {})) + ) org = funding[:open_collective_org].to_s tokens["KJ|OPENCOLLECTIVE_ORG"] = org unless org.empty? @@ -796,6 +808,30 @@ def author_template_tokens(author) } end + def forge_facts(config, env) + token_config = token_config_values(config) + forge_config = token_config["forge"].is_a?(Hash) ? token_config["forge"] : {} + compact_hash( + gh_user: forge_user_value(forge_config, env, :gh_user).to_s, + gl_user: forge_user_value(forge_config, env, :gl_user).to_s, + cb_user: forge_user_value(forge_config, env, :cb_user).to_s, + sh_user: forge_user_value(forge_config, env, :sh_user).to_s + ) + end + + def forge_user_value(forge_config, env, key) + preferred_template_token_value(nil, forge_config[key.to_s], env, FORGE_USER_ENV_KEYS.fetch(key)) + end + + def forge_template_tokens(forge) + { + "KJ|GH:USER" => forge[:gh_user].to_s, + "KJ|GL:USER" => forge[:gl_user].to_s, + "KJ|CB:USER" => forge[:cb_user].to_s, + "KJ|SH:USER" => forge[:sh_user].to_s, + } + end + def author_given_names(name) parts = name.to_s.strip.split(/\s+/) return "" if parts.size < 2 diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 7abaea1..1699244 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -516,6 +516,63 @@ def project_files(root, paths) end end + it "honors forge user template token config and environment overrides" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-forge-token-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + tokens: + forge: + gh_user: config-gh + gl_user: config-gl + cb_user: "{KJ|CB:USER}" + sh_user: config-sh + templates: + root: template + apply: true + entries: + - README.md + YAML + "template/README.md.example" => <<~MARKDOWN, + GitHub: {KJ|GH:USER} + GitLab: {KJ|GL:USER} + Codeberg: {KJ|CB:USER} + SourceHut: {KJ|SH:USER} + MARKDOWN + }) + + plan = described_class.plan_project( + root, + env: { + "KJ_GH_USER" => "env-gh", + "KJ_CB_USER" => "env-cb", + } + ) + template_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_application_README_md" + end + expect(template_report.fetch(:final_content)).to eq(<<~MARKDOWN) + GitHub: env-gh + GitLab: config-gl + Codeberg: env-cb + SourceHut: config-sh + MARKDOWN + expect(template_report.dig(:metadata, :template_tokens)).to include( + "KJ|CB:USER" => "env-cb", + "KJ|GH:USER" => "env-gh", + "KJ|GL:USER" => "config-gl", + "KJ|SH:USER" => "config-sh" + ) + end + end + it "fails fast when template application leaves unresolved tokens" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From 10315c485c7dc26ebd7e6601b76d65acda6e1bf4 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:05:02 -0600 Subject: [PATCH 38/71] Add kettle-jem funding platform tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 44 ++++++++++++++++ gems/kettle-jem/spec/thin_slice_spec.rb | 69 +++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 9893d77..edb4b5d 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -22,6 +22,15 @@ module Jem cb_user: "KJ_CB_USER", sh_user: "KJ_SH_USER", }.freeze + FUNDING_TOKEN_ENV_KEYS = { + patreon: "KJ_FUNDING_PATREON", + kofi: "KJ_FUNDING_KOFI", + paypal: "KJ_FUNDING_PAYPAL", + buymeacoffee: "KJ_FUNDING_BUYMEACOFFEE", + polar: "KJ_FUNDING_POLAR", + liberapay: "KJ_FUNDING_LIBERAPAY", + issuehunt: "KJ_FUNDING_ISSUEHUNT", + }.freeze module_function @@ -68,6 +77,8 @@ def discover_facts(project_root, env: ENV) open_collective_org: open_collective_org && open_collective_org.fetch(:org) ) ) + funding_tokens = funding_platform_token_facts(kettle_config, env) + funding[:platform_tokens] = funding_tokens unless funding_tokens.empty? funding[:open_collective_disabled] = true if opencollective_disabled funding[:open_collective_disabled_source] = opencollective_policy[:source] if opencollective_disabled if open_collective_org @@ -740,6 +751,8 @@ def template_tokens(facts, funding) author_template_tokens(facts.fetch(:author, {})) ).merge( forge_template_tokens(facts.fetch(:forge, {})) + ).merge( + funding_template_tokens(funding) ) org = funding[:open_collective_org].to_s tokens["KJ|OPENCOLLECTIVE_ORG"] = org unless org.empty? @@ -832,6 +845,37 @@ def forge_template_tokens(forge) } end + def funding_platform_token_facts(config, env) + token_config = token_config_values(config) + funding_config = token_config["funding"].is_a?(Hash) ? token_config["funding"] : {} + compact_hash( + patreon: funding_platform_token_value(funding_config, env, :patreon).to_s, + kofi: funding_platform_token_value(funding_config, env, :kofi).to_s, + paypal: funding_platform_token_value(funding_config, env, :paypal).to_s, + buymeacoffee: funding_platform_token_value(funding_config, env, :buymeacoffee).to_s, + polar: funding_platform_token_value(funding_config, env, :polar).to_s, + liberapay: funding_platform_token_value(funding_config, env, :liberapay).to_s, + issuehunt: funding_platform_token_value(funding_config, env, :issuehunt).to_s + ) + end + + def funding_platform_token_value(funding_config, env, key) + preferred_template_token_value(nil, funding_config[key.to_s], env, FUNDING_TOKEN_ENV_KEYS.fetch(key)) + end + + def funding_template_tokens(funding) + platform_tokens = funding.fetch(:platform_tokens, {}) + { + "KJ|FUNDING:PATREON" => platform_tokens[:patreon].to_s, + "KJ|FUNDING:KOFI" => platform_tokens[:kofi].to_s, + "KJ|FUNDING:PAYPAL" => platform_tokens[:paypal].to_s, + "KJ|FUNDING:BUYMEACOFFEE" => platform_tokens[:buymeacoffee].to_s, + "KJ|FUNDING:POLAR" => platform_tokens[:polar].to_s, + "KJ|FUNDING:LIBERAPAY" => platform_tokens[:liberapay].to_s, + "KJ|FUNDING:ISSUEHUNT" => platform_tokens[:issuehunt].to_s, + } + end + def author_given_names(name) parts = name.to_s.strip.split(/\s+/) return "" if parts.size < 2 diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 1699244..e51bc4c 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -573,6 +573,75 @@ def project_files(root, paths) end end + it "honors funding platform template token config and environment overrides" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-funding-token-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + tokens: + funding: + patreon: config-patreon + kofi: config-kofi + paypal: "{KJ|FUNDING:PAYPAL}" + buymeacoffee: config-bmac + polar: config-polar + liberapay: config-liberapay + issuehunt: config-issuehunt + templates: + root: template + apply: true + entries: + - README.md + YAML + "template/README.md.example" => <<~MARKDOWN, + Patreon: {KJ|FUNDING:PATREON} + Ko-fi: {KJ|FUNDING:KOFI} + PayPal: {KJ|FUNDING:PAYPAL} + BuyMeACoffee: {KJ|FUNDING:BUYMEACOFFEE} + Polar: {KJ|FUNDING:POLAR} + Liberapay: {KJ|FUNDING:LIBERAPAY} + IssueHunt: {KJ|FUNDING:ISSUEHUNT} + MARKDOWN + }) + + plan = described_class.plan_project( + root, + env: { + "KJ_FUNDING_PATREON" => "env-patreon", + "KJ_FUNDING_PAYPAL" => "env-paypal", + } + ) + template_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_application_README_md" + end + expect(template_report.fetch(:final_content)).to eq(<<~MARKDOWN) + Patreon: env-patreon + Ko-fi: config-kofi + PayPal: env-paypal + BuyMeACoffee: config-bmac + Polar: config-polar + Liberapay: config-liberapay + IssueHunt: config-issuehunt + MARKDOWN + expect(template_report.dig(:metadata, :template_tokens)).to include( + "KJ|FUNDING:BUYMEACOFFEE" => "config-bmac", + "KJ|FUNDING:ISSUEHUNT" => "config-issuehunt", + "KJ|FUNDING:KOFI" => "config-kofi", + "KJ|FUNDING:LIBERAPAY" => "config-liberapay", + "KJ|FUNDING:PATREON" => "env-patreon", + "KJ|FUNDING:PAYPAL" => "env-paypal", + "KJ|FUNDING:POLAR" => "config-polar" + ) + end + end + it "fails fast when template application leaves unresolved tokens" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From 436dc8cf533256b28f69a6266abca6eaf485b93a Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:06:26 -0600 Subject: [PATCH 39/71] Add kettle-jem social tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 34 +++++++++++++++ gems/kettle-jem/spec/thin_slice_spec.rb | 57 +++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index edb4b5d..3496fe7 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -31,6 +31,12 @@ module Jem liberapay: "KJ_FUNDING_LIBERAPAY", issuehunt: "KJ_FUNDING_ISSUEHUNT", }.freeze + SOCIAL_TOKEN_ENV_KEYS = { + mastodon: "KJ_SOCIAL_MASTODON", + bluesky: "KJ_SOCIAL_BLUESKY", + linktree: "KJ_SOCIAL_LINKTREE", + devto: "KJ_SOCIAL_DEVTO", + }.freeze module_function @@ -65,6 +71,8 @@ def discover_facts(project_root, env: ENV) facts[:author] = author unless author.empty? forge = forge_facts(kettle_config, env) facts[:forge] = forge unless forge.empty? + social = social_facts(kettle_config, env) + facts[:social] = social unless social.empty? opencollective_policy = opencollective_policy(kettle_config, env) opencollective_disabled = opencollective_policy.fetch(:disabled) open_collective_org = opencollective_org(project_root, env, opencollective_disabled: opencollective_disabled) @@ -753,6 +761,8 @@ def template_tokens(facts, funding) forge_template_tokens(facts.fetch(:forge, {})) ).merge( funding_template_tokens(funding) + ).merge( + social_template_tokens(facts.fetch(:social, {})) ) org = funding[:open_collective_org].to_s tokens["KJ|OPENCOLLECTIVE_ORG"] = org unless org.empty? @@ -876,6 +886,30 @@ def funding_template_tokens(funding) } end + def social_facts(config, env) + token_config = token_config_values(config) + social_config = token_config["social"].is_a?(Hash) ? token_config["social"] : {} + compact_hash( + mastodon: social_token_value(social_config, env, :mastodon).to_s, + bluesky: social_token_value(social_config, env, :bluesky).to_s, + linktree: social_token_value(social_config, env, :linktree).to_s, + devto: social_token_value(social_config, env, :devto).to_s + ) + end + + def social_token_value(social_config, env, key) + preferred_template_token_value(nil, social_config[key.to_s], env, SOCIAL_TOKEN_ENV_KEYS.fetch(key)) + end + + def social_template_tokens(social) + { + "KJ|SOCIAL:MASTODON" => social[:mastodon].to_s, + "KJ|SOCIAL:BLUESKY" => social[:bluesky].to_s, + "KJ|SOCIAL:LINKTREE" => social[:linktree].to_s, + "KJ|SOCIAL:DEVTO" => social[:devto].to_s, + } + end + def author_given_names(name) parts = name.to_s.strip.split(/\s+/) return "" if parts.size < 2 diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index e51bc4c..e1b081f 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -642,6 +642,63 @@ def project_files(root, paths) end end + it "honors social template token config and environment overrides" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-social-token-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + tokens: + social: + mastodon: config-mastodon + bluesky: config-bluesky + linktree: "{KJ|SOCIAL:LINKTREE}" + devto: config-devto + templates: + root: template + apply: true + entries: + - README.md + YAML + "template/README.md.example" => <<~MARKDOWN, + Mastodon: {KJ|SOCIAL:MASTODON} + Bluesky: {KJ|SOCIAL:BLUESKY} + Linktree: {KJ|SOCIAL:LINKTREE} + Dev.to: {KJ|SOCIAL:DEVTO} + MARKDOWN + }) + + plan = described_class.plan_project( + root, + env: { + "KJ_SOCIAL_MASTODON" => "env-mastodon", + "KJ_SOCIAL_LINKTREE" => "env-linktree", + } + ) + template_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_application_README_md" + end + expect(template_report.fetch(:final_content)).to eq(<<~MARKDOWN) + Mastodon: env-mastodon + Bluesky: config-bluesky + Linktree: env-linktree + Dev.to: config-devto + MARKDOWN + expect(template_report.dig(:metadata, :template_tokens)).to include( + "KJ|SOCIAL:BLUESKY" => "config-bluesky", + "KJ|SOCIAL:DEVTO" => "config-devto", + "KJ|SOCIAL:LINKTREE" => "env-linktree", + "KJ|SOCIAL:MASTODON" => "env-mastodon" + ) + end + end + it "fails fast when template application leaves unresolved tokens" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From 49482743d28e09571fa510b7792ebc8530c3c378 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:13:51 -0600 Subject: [PATCH 40/71] Add kettle-jem license template tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 242 +++++++++++++++++++++++- gems/kettle-jem/spec/thin_slice_spec.rb | 71 +++++++ 2 files changed, 311 insertions(+), 2 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 3496fe7..f4b56e1 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -37,6 +37,44 @@ module Jem linktree: "KJ_SOCIAL_LINKTREE", devto: "KJ_SOCIAL_DEVTO", }.freeze + APACHE_LICENSE_COMPAT_CATEGORIES = { + "Apache-2.0" => :a, + "MIT" => :a, + "AGPL-3.0-only" => :x, + "PolyForm-Noncommercial-1.0.0" => :x, + "PolyForm-Small-Business-1.0.0" => :x, + "LicenseRef-Big-Time-Public-License" => :x, + }.freeze + APACHE_LICENSE_COMPAT_BADGE_DATA = { + a: { + alt: "Apache license compatibility: Category A", + label: "Apache_Compatible:_Category_A", + message: "\u2713", + color: "259D6C", + ref: "https://www.apache.org/legal/resolved.html#category-a", + }, + b: { + alt: "Apache license compatibility: Category B", + label: "Apache_Maybe_Compatible:_Category_B", + message: "?", + color: "D9A407", + ref: "https://www.apache.org/legal/resolved.html#category-b", + }, + x: { + alt: "Apache license compatibility: Category X", + label: "Apache_Incompatible:_Category_X", + message: "\u2717", + color: "C0392B", + ref: "https://www.apache.org/legal/resolved.html#category-x", + }, + unknown: { + alt: "Apache license compatibility: Unknown", + label: "Apache_Compatibility", + message: "Unknown", + color: "6C757D", + ref: "https://www.apache.org/legal/resolved.html", + }, + }.freeze module_function @@ -50,6 +88,8 @@ def discover_facts(project_root, env: ENV) extract_gemspec_assignment(gemspec, "spec.homepage") kettle_config = kettle_jem_config(project_root) + author = author_facts(gemspec, kettle_config, env) + license = license_facts(kettle_config, extract_gemspec_array(gemspec, "spec.licenses"), author_email: author[:email]) facts = { package: compact_hash( ecosystem: "rubygems", @@ -59,7 +99,7 @@ def discover_facts(project_root, env: ENV) extract_gemspec_assignment(gemspec, "spec.summary"), homepage_url: extract_gemspec_assignment(gemspec, "spec.homepage"), source_url: source_url, - license_expression: Array(extract_gemspec_array(gemspec, "spec.licenses")).join(" OR "), + license_expression: license[:expression], ), rubygems: compact_hash( gemspec_path: File.basename(gemspec_path), @@ -67,7 +107,6 @@ def discover_facts(project_root, env: ENV) min_ruby: extract_gemspec_assignment(gemspec, "spec.required_ruby_version"), ), } - author = author_facts(gemspec, kettle_config, env) facts[:author] = author unless author.empty? forge = forge_facts(kettle_config, env) facts[:forge] = forge unless forge.empty? @@ -111,6 +150,7 @@ def discover_facts(project_root, env: ENV) template_preferences = template_source_preferences(project_root, kettle_config, opencollective_disabled: opencollective_disabled) template_facts[:source_preferences] = template_preferences unless template_preferences.empty? unless template_preferences.empty? + facts[:license] = license unless license.empty? template_tokens = template_tokens(facts, funding) template_facts[:tokens] = template_tokens unless template_tokens.empty? end @@ -763,6 +803,8 @@ def template_tokens(facts, funding) funding_template_tokens(funding) ).merge( social_template_tokens(facts.fetch(:social, {})) + ).merge( + license_template_tokens(facts.fetch(:license, {})) ) org = funding[:open_collective_org].to_s tokens["KJ|OPENCOLLECTIVE_ORG"] = org unless org.empty? @@ -910,6 +952,202 @@ def social_template_tokens(social) } end + def license_facts(config, gemspec_licenses, author_email: nil) + licenses = resolved_licenses(config, gemspec_licenses) + primary = licenses.first + compat_category = license_compat_category(licenses) + compact_hash( + spdx: licenses, + expression: licenses.join(" OR "), + primary_spdx: primary, + license_md_content: license_md_content(licenses, author_email: author_email), + readme_license_intro: readme_license_intro(licenses, author_email: author_email), + readme_license_badge: license_badge(primary), + readme_license_compat_badge: license_compat_badge(compat_category), + readme_license_refs: readme_license_refs(primary, compat_category), + copyright_prefix: polyform_licenses?(licenses) ? "Required Notice: " : "" + ) + end + + def resolved_licenses(config, gemspec_licenses) + config_licenses = config.is_a?(Hash) ? config["licenses"] : nil + licenses = Array(config_licenses).map { |license| license.to_s.strip }.reject(&:empty?) + return licenses unless licenses.empty? + + licenses = Array(gemspec_licenses).map { |license| license.to_s.strip }.reject(&:empty?) + licenses.empty? ? ["MIT"] : licenses + end + + def license_template_tokens(license) + { + "KJ|LICENSE_MD_CONTENT" => license[:license_md_content].to_s, + "KJ|README:LICENSE_INTRO" => license[:readme_license_intro].to_s, + "KJ|LICENSE:PRIMARY_SPDX" => license[:primary_spdx].to_s, + "KJ|README:LICENSE_BADGE" => license[:readme_license_badge].to_s, + "KJ|README:LICENSE_COMPAT_BADGE" => license[:readme_license_compat_badge].to_s, + "KJ|README:LICENSE_REFS" => license[:readme_license_refs].to_s, + "KJ|COPYRIGHT_PREFIX" => license[:copyright_prefix].to_s, + } + end + + def license_md_content(licenses, author_email: nil) + content = <<~MARKDOWN.chomp + # License + + This project is made available under the following license#{"s" if licenses.size > 1}. + Choose the option that best fits your use case: + + #{licenses.map { |license| "- #{license_link(license)}" }.join("\n")} + MARKDOWN + guide_table = license_use_case_guide_table(licenses, author_email: author_email) + content += "\n\n## Use-case guide\n\n#{guide_table}" if guide_table + content += "\n\n#{license_contact_line(author_email, context: :license_md)}" if non_mit_licenses?(licenses) + content + end + + def readme_license_intro(licenses, author_email: nil) + return mit_readme_license_intro if licenses == ["MIT"] + + intro = "The gem is available under the following license#{"s" if licenses.size > 1}: " \ + "#{licenses.map { |license| license_link(license) }.join(", ")}.\n" \ + "See [LICENSE.md][#{paperclip_ref(:license)}] for details." + intro += "\n\n#{license_contact_line(author_email, context: :readme)}" if non_mit_licenses?(licenses) + guide_table = license_use_case_guide_table(licenses, author_email: author_email) + intro += "\n\n### License use-case guide\n\n#{guide_table}" if guide_table + intro + end + + def mit_readme_license_intro + "The gem is available as open source under the terms of\n" \ + "the #{license_link("MIT")} #{license_badge("MIT")}." + end + + def license_contact_line(author_email, context:) + if author_email.to_s.empty? + return "If none of the above licenses fit your use case, please contact the project maintainer to discuss a custom commercial license." if context == :license_md + + "If none of the available licenses suit your use case, please contact the project maintainer to discuss a custom commercial license." + elsif context == :license_md + "If none of the above licenses fit your use case, please [contact us](mailto:#{author_email}) to discuss a custom commercial license." + else + "If none of the available licenses suit your use case, please [contact us](mailto:#{author_email}) to discuss a custom commercial license." + end + end + + def readme_license_refs(primary, compat_category) + [ + "[#{paperclip_ref(:copyright_notice_explainer)}]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year", + "[#{paperclip_ref(:license)}]: LICENSE.md", + "[#{paperclip_ref(:license_ref)}]: #{license_badge_ref(primary)}", + "[#{paperclip_ref(:license_img)}]: #{license_badge_img(primary)}", + "[#{paperclip_ref(:license_compat)}]: #{license_compat_ref(compat_category)}", + "[#{paperclip_ref(:license_compat_img)}]: #{license_compat_img(compat_category)}", + ].join("\n") + end + + def spdx_basename(spdx_id) + spdx_id.to_s.sub(/\ALicenseRef-/, "") + end + + def license_link(spdx_id) + base = spdx_basename(spdx_id) + "[#{base}](#{base}.md)" + end + + def license_badge(spdx_id) + base = spdx_basename(spdx_id) + "[![License: #{base}][#{paperclip_ref(:license_img)}]][#{paperclip_ref(:license_ref)}]" + end + + def license_badge_ref(spdx_id) + "#{spdx_basename(spdx_id)}.md" + end + + def license_badge_img(spdx_id) + base = spdx_basename(spdx_id).gsub("-", "--").gsub("_", "__").tr(" ", "_") + "https://img.shields.io/badge/License-#{base}-259D6C.svg" + end + + def license_compat_category(licenses) + categories = Array(licenses).filter_map { |license| APACHE_LICENSE_COMPAT_CATEGORIES[license.to_s] }.uniq + return :a if categories.include?(:a) + return :b if categories.include?(:b) + return :x if categories.any? && categories.all?(:x) + + :unknown + end + + def license_compat_badge(category) + data = APACHE_LICENSE_COMPAT_BADGE_DATA.fetch(category) + "[![#{data.fetch(:alt)}][#{paperclip_ref(:license_compat_img)}]][#{paperclip_ref(:license_compat)}]" + end + + def license_compat_ref(category) + APACHE_LICENSE_COMPAT_BADGE_DATA.fetch(category).fetch(:ref) + end + + def license_compat_img(category) + data = APACHE_LICENSE_COMPAT_BADGE_DATA.fetch(category) + "https://img.shields.io/badge/#{data.fetch(:label)}-#{data.fetch(:message)}-#{data.fetch(:color)}.svg?style=flat&logo=Apache" + end + + def polyform_licenses?(licenses) + licenses.any? { |license| license.to_s.start_with?("PolyForm-") } + end + + def non_mit_licenses?(licenses) + licenses.any? { |license| license != "MIT" } + end + + def license_use_case_guide_table(licenses, author_email: nil) + has_floss_oss = licenses.include?("MIT") || licenses.include?("AGPL-3.0-only") + has_polyform = licenses.include?("PolyForm-Noncommercial-1.0.0") || licenses.include?("PolyForm-Small-Business-1.0.0") + has_big_time = licenses.include?("LicenseRef-Big-Time-Public-License") + return unless has_floss_oss && has_polyform && has_big_time + + rows = license_use_case_rows(licenses, author_email: author_email) + return if rows.empty? + + "| Use case | License |\n|---|---|\n" + + rows.map { |use_case, license| "| #{use_case} | #{license} |" }.join("\n") + end + + def license_use_case_rows(licenses, author_email: nil) + rows = [] + rows << ["FLOSS (free and open source)", license_link("MIT")] if licenses.include?("MIT") + rows << ["Copy-left open source", license_link("AGPL-3.0-only")] if licenses.include?("AGPL-3.0-only") + noncommercial_links = %w[PolyForm-Noncommercial-1.0.0 PolyForm-Small-Business-1.0.0 LicenseRef-Big-Time-Public-License] + .select { |license| licenses.include?(license) } + .map { |license| license_link(license) } + rows << ["Non-commercial (research, education, personal use)", noncommercial_links.join(" or ")] unless noncommercial_links.empty? + small_business_links = %w[PolyForm-Small-Business-1.0.0 LicenseRef-Big-Time-Public-License] + .select { |license| licenses.include?(license) } + .map { |license| license_link(license) } + rows << ["Small business commercial", small_business_links.join(" or ")] unless small_business_links.empty? + rows << ["Larger business commercial", large_business_license_cell(author_email)] if licenses.include?("LicenseRef-Big-Time-Public-License") + rows + end + + def large_business_license_cell(author_email) + cell = license_link("LicenseRef-Big-Time-Public-License") + if author_email.to_s.empty? + "#{cell} or contact us for a custom license" + else + "#{cell} or [contact us](mailto:#{author_email}) for a custom license" + end + end + + def paperclip_ref(name) + { + copyright_notice_explainer: "\u{1F4C4}copyright-notice-explainer", + license: "\u{1F4C4}license", + license_ref: "\u{1F4C4}license-ref", + license_img: "\u{1F4C4}license-img", + license_compat: "\u{1F4C4}license-compat", + license_compat_img: "\u{1F4C4}license-compat-img", + }.fetch(name) + end + def author_given_names(name) parts = name.to_s.strip.split(/\s+/) return "" if parts.size < 2 diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index e1b081f..1303e1c 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -699,6 +699,77 @@ def project_files(root, paths) end end + it "projects license template tokens from configured licenses" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-license-token-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + spec.authors = ["Jane Q Public"] + spec.email = ["jane@example.test"] + spec.licenses = ["MIT"] + end + RUBY + ".kettle-jem.yml" => <<~YAML, + licenses: + - AGPL-3.0-only + - PolyForm-Small-Business-1.0.0 + - LicenseRef-Big-Time-Public-License + templates: + root: template + apply: true + entries: + - LICENSE.md + YAML + "template/LICENSE.md.example" => <<~MARKDOWN, + Primary: {KJ|LICENSE:PRIMARY_SPDX} + Prefix: {KJ|COPYRIGHT_PREFIX} + Badge: {KJ|README:LICENSE_BADGE} + Compat: {KJ|README:LICENSE_COMPAT_BADGE} + Refs: + {KJ|README:LICENSE_REFS} + Intro: + {KJ|README:LICENSE_INTRO} + Content: + {KJ|LICENSE_MD_CONTENT} + MARKDOWN + }) + + plan = described_class.plan_project(root, env: {}) + template_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_application_LICENSE_md" + end + final_content = template_report.fetch(:final_content) + expect(plan.dig(:facts, :license, :spdx)).to eq( + ["AGPL-3.0-only", "PolyForm-Small-Business-1.0.0", "LicenseRef-Big-Time-Public-License"] + ) + expect(plan.dig(:facts, :package, :license_expression)).to eq( + "AGPL-3.0-only OR PolyForm-Small-Business-1.0.0 OR LicenseRef-Big-Time-Public-License" + ) + expect(final_content).to include("Primary: AGPL-3.0-only") + expect(final_content).to include("Prefix: Required Notice: ") + expect(final_content).to include("[AGPL-3.0-only](AGPL-3.0-only.md)") + expect(final_content).to include("[PolyForm-Small-Business-1.0.0](PolyForm-Small-Business-1.0.0.md)") + expect(final_content).to include("[Big-Time-Public-License](Big-Time-Public-License.md)") + expect(final_content).to include("Apache license compatibility: Category X") + expect(final_content).to include("## Use-case guide") + expect(final_content).to include("[contact us](mailto:jane@example.test)") + expect(template_report.dig(:metadata, :template_tokens)).to include( + "KJ|COPYRIGHT_PREFIX" => "Required Notice: ", + "KJ|LICENSE:PRIMARY_SPDX" => "AGPL-3.0-only" + ) + expect(template_report.dig(:metadata, :template_tokens, "KJ|LICENSE_MD_CONTENT")).to include( + "This project is made available under the following licenses." + ) + expect(template_report.dig(:metadata, :template_tokens, "KJ|README:LICENSE_REFS")).to include( + "AGPL-3.0-only.md" + ) + end + end + it "fails fast when template application leaves unresolved tokens" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From a5b4e015d99423585b0275dd125b7b80a7cb352a Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:28:43 -0600 Subject: [PATCH 41/71] Add kettle-jem project runtime tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 74 +++++++++++++++++++++++- gems/kettle-jem/spec/thin_slice_spec.rb | 76 +++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index f4b56e1..da5719d 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -16,6 +16,7 @@ module Jem OPENCOLLECTIVE_DISABLED_FILES = %w[.opencollective.yml .github/workflows/opencollective.yml].freeze FILE_DELETION_PRIMITIVES = %w[supplied_obsolete_file_deletion supplied_disabled_opencollective_file_deletion].freeze TEMPLATE_TOKEN_CONFIG = Token::Resolver::Config.new(separators: ["|", ":"]).freeze + EMPTY_TEMPLATE_TOKENS = %w[KJ|COPYRIGHT_PREFIX KJ|MIN_DIVERGENCE_THRESHOLD].freeze FORGE_USER_ENV_KEYS = { gh_user: "KJ_GH_USER", gl_user: "KJ_GL_USER", @@ -90,6 +91,15 @@ def discover_facts(project_root, env: ENV) kettle_config = kettle_jem_config(project_root) author = author_facts(gemspec, kettle_config, env) license = license_facts(kettle_config, extract_gemspec_array(gemspec, "spec.licenses"), author_email: author[:email]) + project_runtime = project_runtime_facts( + kettle_config, + env, + package_name: name, + source_url: source_url, + author_domain: author[:domain], + min_ruby: extract_gemspec_assignment(gemspec, "spec.required_ruby_version"), + version: extract_gemspec_assignment(gemspec, "spec.version") + ) facts = { package: compact_hash( ecosystem: "rubygems", @@ -151,6 +161,7 @@ def discover_facts(project_root, env: ENV) template_facts[:source_preferences] = template_preferences unless template_preferences.empty? unless template_preferences.empty? facts[:license] = license unless license.empty? + facts[:project_runtime] = project_runtime unless project_runtime.empty? template_tokens = template_tokens(facts, funding) template_facts[:tokens] = template_tokens unless template_tokens.empty? end @@ -793,8 +804,13 @@ def template_tokens(facts, funding) tokens = { "KJ|GEM_NAME" => package.fetch(:name).to_s, "KJ|GEM_NAME_PATH" => package.fetch(:name).to_s.tr("-", "/"), + "KJ|GEM_SHIELD" => shield_token(package.fetch(:name).to_s), + "KJ|GEM_MAJOR" => gem_major_token(facts.fetch(:project_runtime, {})[:version]), + "KJ|GH_ORG" => facts.fetch(:project_runtime, {})[:github_org].to_s, "KJ|NAMESPACE" => rubygems.fetch(:namespace).to_s, + "KJ|NAMESPACE_SHIELD" => shield_token(rubygems.fetch(:namespace).to_s), "KJ|MIN_RUBY" => minimum_ruby_token(rubygems[:min_ruby]), + "KJ|MIN_DEV_RUBY" => minimum_dev_ruby_token(rubygems[:min_ruby]), }.merge( author_template_tokens(facts.fetch(:author, {})) ).merge( @@ -805,17 +821,34 @@ def template_tokens(facts, funding) social_template_tokens(facts.fetch(:social, {})) ).merge( license_template_tokens(facts.fetch(:license, {})) + ).merge( + project_runtime_template_tokens(facts.fetch(:project_runtime, {})) ) org = funding[:open_collective_org].to_s tokens["KJ|OPENCOLLECTIVE_ORG"] = org unless org.empty? - tokens.reject { |_, value| value.empty? } + tokens.reject { |key, value| value.empty? && !EMPTY_TEMPLATE_TOKENS.include?(key) } end def minimum_ruby_token(requirement) requirement.to_s[/\d+(?:\.\d+){1,2}/].to_s end + def minimum_dev_ruby_token(requirement) + min_ruby = minimum_ruby_token(requirement) + return "" if min_ruby.empty? + + [Gem::Version.new(min_ruby), Gem::Version.new("2.3")].max.to_s + rescue ArgumentError + "2.3" + end + + def gem_major_token(version) + Gem::Version.new(version.to_s).segments.first.to_s + rescue ArgumentError + "0" + end + def author_facts(gemspec_source, config, env) token_config = token_config_values(config) author_config = token_config["author"].is_a?(Hash) ? token_config["author"] : {} @@ -952,6 +985,45 @@ def social_template_tokens(social) } end + def project_runtime_facts(config, env, package_name:, source_url:, author_domain:, min_ruby:, version:) + run_timestamp = Time.now + compact_hash( + freeze_token: config.dig("defaults", "freeze_token").to_s.empty? ? "kettle-jem" : config.dig("defaults", "freeze_token").to_s, + kettle_jem_version: VERSION, + template_run_date: run_timestamp.strftime("%Y-%m-%d"), + template_run_year: run_timestamp.year.to_s, + kettle_dev_gem: "kettle-dev", + yard_host: "#{package_name.to_s.tr("_", "-")}.#{author_domain.to_s.empty? ? "example.com" : author_domain}", + project_emoji: preferred_template_token_value(nil, config["project_emoji"], env, "KJ_PROJECT_EMOJI").to_s, + min_divergence_threshold: preferred_template_token_value(nil, config["min_divergence_threshold"], env, "KJ_MIN_DIVERGENCE_THRESHOLD").to_s, + min_dev_ruby: minimum_dev_ruby_token(min_ruby), + version: version.to_s, + github_org: github_org_from_url(source_url).to_s + ) + end + + def project_runtime_template_tokens(project_runtime) + { + "KJ|FREEZE_TOKEN" => project_runtime[:freeze_token].to_s, + "KJ|KETTLE_JEM_VERSION" => project_runtime[:kettle_jem_version].to_s, + "KJ|TEMPLATE_RUN_DATE" => project_runtime[:template_run_date].to_s, + "KJ|TEMPLATE_RUN_YEAR" => project_runtime[:template_run_year].to_s, + "KJ|KETTLE_DEV_GEM" => project_runtime[:kettle_dev_gem].to_s, + "KJ|YARD_HOST" => project_runtime[:yard_host].to_s, + "KJ|PROJECT_EMOJI" => project_runtime[:project_emoji].to_s, + "KJ|MIN_DIVERGENCE_THRESHOLD" => project_runtime[:min_divergence_threshold].to_s, + } + end + + def shield_token(value) + value.to_s.gsub("-", "--").gsub("_", "__").gsub("::", "%3A%3A").tr(" ", "_") + end + + def github_org_from_url(url) + match = url.to_s.match(%r{\Ahttps?://github\.com/([^/]+)/}) + match && match[1] + end + def license_facts(config, gemspec_licenses, author_email: nil) licenses = resolved_licenses(config, gemspec_licenses) primary = licenses.first diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 1303e1c..03c6623 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -770,6 +770,82 @@ def project_files(root, paths) end end + it "projects project runtime template tokens" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-project-runtime-token-slice", tmp_root) do |root| + write_tree(root, { + "example-gem.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example-gem" + spec.version = "2.4.6" + spec.summary = "Example gem" + spec.authors = ["Jane Q Public"] + spec.email = ["jane@example.test"] + spec.required_ruby_version = ">= 3.2" + spec.metadata["source_code_uri"] = "https://github.com/acme/example-gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + project_emoji: "🫖" + min_divergence_threshold: "{KJ|MIN_DIVERGENCE_THRESHOLD}" + defaults: + freeze_token: custom-freeze + templates: + root: template + apply: true + entries: + - README.md + YAML + "template/README.md.example" => <<~MARKDOWN, + Gem shield: {KJ|GEM_SHIELD} + Major: {KJ|GEM_MAJOR} + GitHub org: {KJ|GH_ORG} + Namespace shield: {KJ|NAMESPACE_SHIELD} + Min dev Ruby: {KJ|MIN_DEV_RUBY} + Freeze: {KJ|FREEZE_TOKEN} + Version: {KJ|KETTLE_JEM_VERSION} + Date: {KJ|TEMPLATE_RUN_DATE} + Year: {KJ|TEMPLATE_RUN_YEAR} + Dev gem: {KJ|KETTLE_DEV_GEM} + YARD: {KJ|YARD_HOST} + Emoji: {KJ|PROJECT_EMOJI} + Divergence: {KJ|MIN_DIVERGENCE_THRESHOLD} + MARKDOWN + }) + + plan = described_class.plan_project(root, env: { "KJ_MIN_DIVERGENCE_THRESHOLD" => "12" }) + template_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_application_README_md" + end + final_content = template_report.fetch(:final_content) + expect(final_content).to include("Gem shield: example--gem") + expect(final_content).to include("Major: 2") + expect(final_content).to include("GitHub org: acme") + expect(final_content).to include("Namespace shield: Example%3A%3AGem") + expect(final_content).to include("Min dev Ruby: 3.2") + expect(final_content).to include("Freeze: custom-freeze") + expect(final_content).to include("Version: #{Kettle::Jem::VERSION}") + expect(final_content).to include("Date: #{Time.now.strftime("%Y-%m-%d")}") + expect(final_content).to include("Year: #{Time.now.year}") + expect(final_content).to include("Dev gem: kettle-dev") + expect(final_content).to include("YARD: example-gem.example.test") + expect(final_content).to include("Emoji: 🫖") + expect(final_content).to include("Divergence: 12") + expect(template_report.dig(:metadata, :template_tokens)).to include( + "KJ|FREEZE_TOKEN" => "custom-freeze", + "KJ|GEM_MAJOR" => "2", + "KJ|GEM_SHIELD" => "example--gem", + "KJ|GH_ORG" => "acme", + "KJ|MIN_DEV_RUBY" => "3.2", + "KJ|MIN_DIVERGENCE_THRESHOLD" => "12", + "KJ|NAMESPACE_SHIELD" => "Example%3A%3AGem", + "KJ|PROJECT_EMOJI" => "🫖", + "KJ|YARD_HOST" => "example-gem.example.test" + ) + end + end + it "fails fast when template application leaves unresolved tokens" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From ac4c253edea9e4b0103075ace47c323b10ca294a Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:30:15 -0600 Subject: [PATCH 42/71] Add kettle-jem README logo tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 79 +++++++++++++++++++++++++ gems/kettle-jem/spec/thin_slice_spec.rb | 45 ++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index da5719d..8ca5793 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -17,6 +17,15 @@ module Jem FILE_DELETION_PRIMITIVES = %w[supplied_obsolete_file_deletion supplied_disabled_opencollective_file_deletion].freeze TEMPLATE_TOKEN_CONFIG = Token::Resolver::Config.new(separators: ["|", ":"]).freeze EMPTY_TEMPLATE_TOKENS = %w[KJ|COPYRIGHT_PREFIX KJ|MIN_DIVERGENCE_THRESHOLD].freeze + README_TOP_LOGO_MODE_DEFAULT = "org_and_project" + README_TOP_LOGO_MODES = %w[org project org_and_project].freeze + README_STATIC_TOP_LOGO_ROW = "[![Galtzo FLOSS Logo by Aboling0, CC BY-SA 4.0][🖼️galtzo-i]][🖼️galtzo-discord] [![ruby-lang Logo, Yukihiro Matsumoto, Ruby Visual Identity Team, CC BY-SA 2.5][🖼️ruby-lang-i]][🖼️ruby-lang]" + README_STATIC_TOP_LOGO_REFS = [ + "[🖼️galtzo-i]: https://logos.galtzo.com/assets/images/galtzo-floss/avatar-192px.svg", + "[🖼️galtzo-discord]: https://discord.gg/3qme4XHNKN", + "[🖼️ruby-lang-i]: https://logos.galtzo.com/assets/images/ruby-lang/avatar-192px.svg", + "[🖼️ruby-lang]: https://www.ruby-lang.org/", + ].join("\n").freeze FORGE_USER_ENV_KEYS = { gh_user: "KJ_GH_USER", gl_user: "KJ_GL_USER", @@ -162,6 +171,8 @@ def discover_facts(project_root, env: ENV) unless template_preferences.empty? facts[:license] = license unless license.empty? facts[:project_runtime] = project_runtime unless project_runtime.empty? + readme_logo = readme_logo_facts(kettle_config, package_name: name, github_org: project_runtime[:github_org]) + facts[:readme_logo] = readme_logo unless readme_logo.empty? template_tokens = template_tokens(facts, funding) template_facts[:tokens] = template_tokens unless template_tokens.empty? end @@ -823,6 +834,8 @@ def template_tokens(facts, funding) license_template_tokens(facts.fetch(:license, {})) ).merge( project_runtime_template_tokens(facts.fetch(:project_runtime, {})) + ).merge( + readme_logo_template_tokens(facts.fetch(:readme_logo, {})) ) org = funding[:open_collective_org].to_s tokens["KJ|OPENCOLLECTIVE_ORG"] = org unless org.empty? @@ -1024,6 +1037,72 @@ def github_org_from_url(url) match && match[1] end + def readme_logo_facts(config, package_name:, github_org:) + entries = readme_top_logo_entries(readme_top_logo_mode(config), org: github_org.to_s, gem_name: package_name.to_s) + compact_hash( + top_logo_mode: readme_top_logo_mode(config), + top_logo_row: [README_STATIC_TOP_LOGO_ROW, readme_top_logo_row(entries)].reject(&:empty?).join(" "), + top_logo_refs: [README_STATIC_TOP_LOGO_REFS, readme_top_logo_refs(entries)].reject(&:empty?).join("\n") + ) + end + + def readme_top_logo_mode(config) + raw_config = config.is_a?(Hash) ? config["readme"] : nil + readme_config = raw_config.is_a?(Hash) ? raw_config : {} + normalized = readme_config["top_logo_mode"].to_s.strip.downcase.tr("-", "_") + return README_TOP_LOGO_MODE_DEFAULT if normalized.empty? + return normalized if README_TOP_LOGO_MODES.include?(normalized) + + README_TOP_LOGO_MODE_DEFAULT + end + + def readme_top_logo_entries(mode, org:, gem_name:) + return [] if org.empty? + + entries = [] + if mode == "org" || mode == "org_and_project" + entries << { + label: org, + image_ref: "#{org}-i", + link_ref: org, + image_url: "https://logos.galtzo.com/assets/images/#{org}/avatar-192px.svg", + href: "https://github.com/#{org}", + } + end + if mode == "project" || mode == "org_and_project" + entries << { + label: gem_name, + image_ref: "#{gem_name}-i", + link_ref: gem_name, + image_url: "https://logos.galtzo.com/assets/images/#{org}/#{gem_name}/avatar-192px.svg", + href: "https://github.com/#{org}/#{gem_name}", + } + end + entries.uniq { |entry| [entry[:image_ref], entry[:link_ref], entry[:image_url], entry[:href]] } + end + + def readme_top_logo_row(entries) + entries.map do |entry| + "[![#{entry[:label]} Logo by Aboling0, CC BY-SA 4.0][🖼️#{entry[:image_ref]}]][🖼️#{entry[:link_ref]}]" + end.join(" ") + end + + def readme_top_logo_refs(entries) + entries.flat_map do |entry| + [ + "[🖼️#{entry[:image_ref]}]: #{entry[:image_url]}", + "[🖼️#{entry[:link_ref]}]: #{entry[:href]}", + ] + end.join("\n") + end + + def readme_logo_template_tokens(readme_logo) + { + "KJ|README:TOP_LOGO_ROW" => readme_logo[:top_logo_row].to_s, + "KJ|README:TOP_LOGO_REFS" => readme_logo[:top_logo_refs].to_s, + } + end + def license_facts(config, gemspec_licenses, author_email: nil) licenses = resolved_licenses(config, gemspec_licenses) primary = licenses.first diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 03c6623..d6be187 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -846,6 +846,51 @@ def project_files(root, paths) end end + it "projects README top logo template tokens" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-readme-logo-token-slice", tmp_root) do |root| + write_tree(root, { + "example-gem.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example-gem" + spec.summary = "Example gem" + spec.metadata["source_code_uri"] = "https://github.com/acme/example-gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + templates: + root: template + apply: true + entries: + - README.md + YAML + "template/README.md.example" => <<~MARKDOWN, + Row: + {KJ|README:TOP_LOGO_ROW} + Refs: + {KJ|README:TOP_LOGO_REFS} + MARKDOWN + }) + + plan = described_class.plan_project(root, env: {}) + template_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_application_README_md" + end + final_content = template_report.fetch(:final_content) + expect(final_content).to include("Galtzo FLOSS Logo") + expect(final_content).to include("ruby-lang Logo") + expect(final_content).to include("[![acme Logo by Aboling0, CC BY-SA 4.0][🖼️acme-i]][🖼️acme]") + expect(final_content).to include("[![example-gem Logo by Aboling0, CC BY-SA 4.0][🖼️example-gem-i]][🖼️example-gem]") + expect(final_content).to include("[🖼️acme-i]: https://logos.galtzo.com/assets/images/acme/avatar-192px.svg") + expect(final_content).to include("[🖼️example-gem]: https://github.com/acme/example-gem") + expect(template_report.dig(:metadata, :template_tokens)).to include( + "KJ|README:TOP_LOGO_REFS" => a_string_including("https://github.com/acme/example-gem"), + "KJ|README:TOP_LOGO_ROW" => a_string_including("example-gem Logo by Aboling0") + ) + end + end + it "fails fast when template application leaves unresolved tokens" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From 6b0e2749c7ce09949038c30d2839cedcc0cb0833 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:31:21 -0600 Subject: [PATCH 43/71] Add kettle-jem RuboCop template tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 49 +++++++++++++++++++++++++ gems/kettle-jem/spec/thin_slice_spec.rb | 40 ++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 8ca5793..ef9b177 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -26,6 +26,23 @@ module Jem "[🖼️ruby-lang-i]: https://logos.galtzo.com/assets/images/ruby-lang/avatar-192px.svg", "[🖼️ruby-lang]: https://www.ruby-lang.org/", ].join("\n").freeze + RUBOCOP_VERSION_MAP = [ + [Gem::Version.new("1.8"), "~> 0.1"], + [Gem::Version.new("1.9"), "~> 2.0"], + [Gem::Version.new("2.0"), "~> 4.0"], + [Gem::Version.new("2.1"), "~> 6.0"], + [Gem::Version.new("2.2"), "~> 8.0"], + [Gem::Version.new("2.3"), "~> 10.0"], + [Gem::Version.new("2.4"), "~> 12.0"], + [Gem::Version.new("2.5"), "~> 14.0"], + [Gem::Version.new("2.6"), "~> 16.0"], + [Gem::Version.new("2.7"), "~> 18.0"], + [Gem::Version.new("3.0"), "~> 20.0"], + [Gem::Version.new("3.1"), "~> 22.0"], + [Gem::Version.new("3.2"), "~> 24.0"], + [Gem::Version.new("3.3"), "~> 26.0"], + [Gem::Version.new("3.4"), "~> 28.0"], + ].freeze FORGE_USER_ENV_KEYS = { gh_user: "KJ_GH_USER", gl_user: "KJ_GL_USER", @@ -823,6 +840,8 @@ def template_tokens(facts, funding) "KJ|MIN_RUBY" => minimum_ruby_token(rubygems[:min_ruby]), "KJ|MIN_DEV_RUBY" => minimum_dev_ruby_token(rubygems[:min_ruby]), }.merge( + rubocop_template_tokens(rubygems[:min_ruby]) + ).merge( author_template_tokens(facts.fetch(:author, {})) ).merge( forge_template_tokens(facts.fetch(:forge, {})) @@ -1103,6 +1122,36 @@ def readme_logo_template_tokens(readme_logo) } end + def rubocop_template_tokens(min_ruby) + constraint, gem_name = rubocop_tokens_for(min_ruby_version(min_ruby)) + { + "KJ|RUBOCOP_LTS_CONSTRAINT" => constraint, + "KJ|RUBOCOP_RUBY_GEM" => gem_name, + } + end + + def rubocop_tokens_for(min_ruby) + fallback = RUBOCOP_VERSION_MAP.first + selected = nil + RUBOCOP_VERSION_MAP.reverse_each do |minimum, constraint| + next unless min_ruby && min_ruby >= minimum + + selected = [minimum, constraint] + break + end + selected ||= fallback + [selected[1], "rubocop-ruby#{selected[0].segments.join("_")}"] + end + + def min_ruby_version(requirement) + token = minimum_ruby_token(requirement) + return nil if token.empty? + + Gem::Version.new(token) + rescue ArgumentError + nil + end + def license_facts(config, gemspec_licenses, author_email: nil) licenses = resolved_licenses(config, gemspec_licenses) primary = licenses.first diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index d6be187..56aea29 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -891,6 +891,46 @@ def project_files(root, paths) end end + it "projects RuboCop LTS template tokens from minimum Ruby" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-rubocop-token-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + spec.required_ruby_version = ">= 3.1" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + templates: + root: template + apply: true + entries: + - gemfiles/modular/style.gemfile + YAML + "template/gemfiles/modular/style.gemfile.example" => <<~RUBY, + gem "rubocop-lts", "{KJ|RUBOCOP_LTS_CONSTRAINT}" + gem "{KJ|RUBOCOP_RUBY_GEM}" + RUBY + }) + + plan = described_class.plan_project(root, env: {}) + template_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_application_gemfiles_modular_style_gemfile" + end + expect(template_report.fetch(:final_content)).to eq(<<~RUBY) + gem "rubocop-lts", "~> 22.0" + gem "rubocop-ruby3_1" + RUBY + expect(template_report.dig(:metadata, :template_tokens)).to include( + "KJ|RUBOCOP_LTS_CONSTRAINT" => "~> 22.0", + "KJ|RUBOCOP_RUBY_GEM" => "rubocop-ruby3_1" + ) + end + end + it "fails fast when template application leaves unresolved tokens" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From 8b5aa26bae565d16199eafa33551a14fc892a972 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:37:37 -0600 Subject: [PATCH 44/71] Add kettle-jem packaged template root Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 49 ++++++++++++++++--- .../kettle/jem/templates/README.md.example | 10 ++++ gems/kettle-jem/spec/thin_slice_spec.rb | 42 ++++++++++++++++ 3 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/README.md.example diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index ef9b177..ed871de 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -15,6 +15,7 @@ module Jem OBSOLETE_GITHUB_WORKFLOWS = %w[ancient.yml legacy.yml supported.yml unsupported.yml main.yml hoary.yml].freeze OPENCOLLECTIVE_DISABLED_FILES = %w[.opencollective.yml .github/workflows/opencollective.yml].freeze FILE_DELETION_PRIMITIVES = %w[supplied_obsolete_file_deletion supplied_disabled_opencollective_file_deletion].freeze + PACKAGED_TEMPLATE_ROOT = File.expand_path("jem/templates", __dir__) TEMPLATE_TOKEN_CONFIG = Token::Resolver::Config.new(separators: ["|", ":"]).freeze EMPTY_TEMPLATE_TOKENS = %w[KJ|COPYRIGHT_PREFIX KJ|MIN_DIVERGENCE_THRESHOLD].freeze README_TOP_LOGO_MODE_DEFAULT = "org_and_project" @@ -553,7 +554,11 @@ def read_project_files(project_root, pack) def recipe_template_content(project_root, recipe) return "" unless %w[supplied_template_source_preference supplied_template_source_application].include?(recipe.fetch(:primitive)) - path = File.join(project_root, recipe.fetch(:template_preference).fetch(:selected_source)) + preference = recipe.fetch(:template_preference) + path = File.join( + preference.fetch(:source_root_path, project_root), + preference.fetch(:source_relative_path, preference.fetch(:selected_source)) + ) File.read(path) end @@ -1384,7 +1389,7 @@ def template_source_preferences(project_root, config, opencollective_disabled: f templates = config["templates"] return [] unless templates.is_a?(Hash) - root = templates.fetch("root", "template").to_s + root = template_root(project_root, templates) entries = templates["entries"] return [] unless entries.is_a?(Array) @@ -1398,16 +1403,44 @@ def template_source_preference(project_root, template_root, entry, opencollectiv source_path, target_path = template_entry_paths(entry) return nil if source_path.to_s.empty? || target_path.to_s.empty? - selected_source = preferred_template_source(project_root, File.join(template_root, source_path), opencollective_disabled: opencollective_disabled) + selected_source = preferred_template_source(template_root.fetch(:path), source_path, opencollective_disabled: opencollective_disabled) return nil unless selected_source - { + preference = { target_path: target_path, configured_source: source_path, - selected_source: selected_source, - selection_reason: template_source_selection_reason(source_path, selected_source), + selected_source: template_source_display_path(template_root, selected_source), + selection_reason: template_source_selection_reason(source_path, template_source_display_path(template_root, selected_source)), apply: template_entry_apply?(entry, apply_templates), } + if template_root.fetch(:kind) == "packaged" + preference[:source_relative_path] = selected_source + preference[:source_root] = template_root.fetch(:kind) + preference[:source_root_path] = template_root.fetch(:path) + end + preference + end + + def template_root(project_root, templates) + configured_root = templates["root"].to_s + if configured_root.empty? + local_root = File.join(project_root, "template") + return { kind: "project", path: local_root, display_prefix: "template" } if Dir.exist?(local_root) + + return { kind: "packaged", path: PACKAGED_TEMPLATE_ROOT } + end + + return { kind: "packaged", path: PACKAGED_TEMPLATE_ROOT } if configured_root == "packaged" + + path = configured_root.start_with?("/") ? configured_root : File.join(project_root, configured_root) + { kind: "project", path: path, display_prefix: configured_root } + end + + def template_source_display_path(template_root, selected_source) + prefix = template_root[:display_prefix].to_s + return selected_source if prefix.empty? + + File.join(prefix, selected_source) end def template_entry_paths(entry) @@ -1427,13 +1460,13 @@ def template_entry_apply?(entry, apply_templates) apply_templates end - def preferred_template_source(project_root, configured_source, opencollective_disabled: false) + def preferred_template_source(template_root, configured_source, opencollective_disabled: false) base = configured_source.sub(/\.example\z/, "") candidates = [] candidates << "#{base}.no-osc.example" if opencollective_disabled candidates << "#{base}.example" candidates << configured_source - candidates.find { |relative_path| File.exist?(File.join(project_root, relative_path)) } + candidates.find { |relative_path| File.exist?(File.join(template_root, relative_path)) } end def template_source_selection_reason(configured_source, selected_source) diff --git a/gems/kettle-jem/lib/kettle/jem/templates/README.md.example b/gems/kettle-jem/lib/kettle/jem/templates/README.md.example new file mode 100644 index 0000000..3d73e6f --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/README.md.example @@ -0,0 +1,10 @@ +# {KJ|GEM_NAME} + +{KJ|README:TOP_LOGO_ROW} + +{KJ|README:LICENSE_INTRO} + +Generated with {KJ|KETTLE_DEV_GEM} for [{KJ|YARD_HOST}](https://{KJ|YARD_HOST}). + +{KJ|README:LICENSE_REFS} +{KJ|README:TOP_LOGO_REFS} diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 56aea29..a58560c 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -447,6 +447,48 @@ def project_files(root, paths) end end + it "applies packaged template files when no project template root exists" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-packaged-template-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + spec.metadata["source_code_uri"] = "https://github.com/acme/example" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + templates: + apply: true + entries: + - README.md + YAML + }) + + plan = described_class.plan_project(root, env: {}) + template_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_application_README_md" + end + expect(template_report.dig(:metadata, :template_source_preference)).to include( + selected_source: "README.md.example", + source_relative_path: "README.md.example", + source_root: "packaged" + ) + expect(template_report.dig(:metadata, :template_source_preference, :source_root_path)).to end_with( + "lib/kettle/jem/templates" + ) + expect(template_report.dig(:request_envelope, :request, :template_content)).to include("# {KJ|GEM_NAME}") + expect(template_report.fetch(:final_content)).to include("# example") + expect(template_report.fetch(:final_content)).to include("Generated with kettle-dev") + expect(template_report.fetch(:final_content)).to include("https://github.com/acme/example") + + described_class.apply_project(root, env: {}) + expect(File.read(File.join(root, "README.md"))).to eq(template_report.fetch(:final_content)) + end + end + it "honors author template token config and environment overrides" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From 576acdc5cd964c67ec7dc081e50eaede9bcbb5f1 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:45:21 -0600 Subject: [PATCH 45/71] Package reference kettle-jem templates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kettle/jem/templates/.aiignore.example | 19 + .../kettle/jem/templates/.config/mise/env.sh | 26 + .../devcontainer-feature.json.example | 9 + .../apt-install/install.sh.example | 19 + .../.devcontainer/devcontainer.json.example | 27 + .../scripts/setup-tree-sitter.sh.example | 210 +++++++ .../kettle/jem/templates/.env.local.example | 65 ++ .../lib/kettle/jem/templates/.envrc.example | 4 + .../jem/templates/.envrc.no-osc.example | 4 + .../lib/kettle/jem/templates/.gemrc.example | 1 + .../templates/.git-hooks/commit-msg.example | 60 ++ .../commit-subjects-goalie.txt.example | 8 + .../footer-template.erb.txt.example | 16 + .../.git-hooks/prepare-commit-msg.example | 9 + .../templates/.github/.codecov.yml.example | 14 + .../jem/templates/.github/FUNDING.yml.example | 13 + .../.github/FUNDING.yml.no-osc.example | 13 + .../.github/copilot_instructions.md.example | 1 + .../templates/.github/dependabot.yml.example | 13 + .../.github/workflows/auto-assign.yml.example | 21 + .../workflows/codeql-analysis.yml.example | 70 +++ .../.github/workflows/coverage.yml.example | 133 +++++ .../.github/workflows/current.yml.example | 74 +++ .../.github/workflows/dep-heads.yml.example | 96 +++ .../workflows/dependency-review.yml.example | 20 + .../workflows/discord-notifier.yml.example | 39 ++ .../workflows/framework-ci.yml.example | 67 +++ .../.github/workflows/heads.yml.example | 95 +++ .../.github/workflows/jruby-9.1.yml.example | 77 +++ .../.github/workflows/jruby-9.2.yml.example | 77 +++ .../.github/workflows/jruby-9.3.yml.example | 77 +++ .../.github/workflows/jruby-9.4.yml.example | 77 +++ .../.github/workflows/jruby.yml.example | 77 +++ .../.github/workflows/license-eye.yml.example | 40 ++ .../.github/workflows/locked_deps.yml.example | 99 ++++ .../workflows/opencollective.yml.example | 40 ++ .../.github/workflows/ruby-2.3.yml.example | 67 +++ .../.github/workflows/ruby-2.4.yml.example | 67 +++ .../.github/workflows/ruby-2.5.yml.example | 67 +++ .../.github/workflows/ruby-2.6.yml.example | 67 +++ .../.github/workflows/ruby-2.7.yml.example | 67 +++ .../.github/workflows/ruby-3.0.yml.example | 72 +++ .../.github/workflows/ruby-3.1.yml.example | 70 +++ .../.github/workflows/ruby-3.2.yml.example | 67 +++ .../.github/workflows/ruby-3.3.yml.example | 67 +++ .../.github/workflows/ruby-3.4.yml.example | 67 +++ .../.github/workflows/style.yml.example | 74 +++ .../.github/workflows/templating.yml.example | 77 +++ .../.github/workflows/truffle.yml.example | 78 +++ .../workflows/truffleruby-22.3.yml.example | 78 +++ .../workflows/truffleruby-23.0.yml.example | 78 +++ .../workflows/truffleruby-23.1.yml.example | 78 +++ .../workflows/truffleruby-24.2.yml.example | 78 +++ .../workflows/truffleruby-25.0.yml.example | 78 +++ .../workflows/unlocked_deps.yml.example | 93 +++ .../kettle/jem/templates/.gitignore.example | 52 ++ .../jem/templates/.gitlab-ci.yml.example | 134 +++++ .../jem/templates/.kettle-jem.yml.example | 337 +++++++++++ .../jem/templates/.licenserc.yaml.example | 7 + .../jem/templates/.opencollective.yml.example | 3 + .../jem/templates/.qlty/qlty.toml.example | 79 +++ .../lib/kettle/jem/templates/.rspec.example | 9 + .../lib/kettle/jem/templates/.rubocop.example | 1 + .../kettle/jem/templates/.rubocop.yml.example | 35 ++ .../jem/templates/.rubocop_rspec.yml.example | 55 ++ .../kettle/jem/templates/.simplecov.example | 18 + .../kettle/jem/templates/.yardignore.example | 13 + .../kettle/jem/templates/.yardopts.example | 15 + .../kettle/jem/templates/AGENTS.md.example | 276 +++++++++ .../jem/templates/AGPL-3.0-only.md.example | 235 ++++++++ .../templates/Appraisal.root.gemfile.example | 16 + .../kettle/jem/templates/Appraisals.example | 131 +++++ .../Big-Time-Public-License.md.example | 99 ++++ .../kettle/jem/templates/CHANGELOG.md.example | 47 ++ .../kettle/jem/templates/CITATION.cff.example | 20 + .../jem/templates/CODE_OF_CONDUCT.md.example | 134 +++++ .../jem/templates/CONTRIBUTING.md.example | 235 ++++++++ .../kettle/jem/templates/FUNDING.md.example | 74 +++ .../jem/templates/FUNDING.md.no-osc.example | 63 ++ .../lib/kettle/jem/templates/Gemfile.example | 43 ++ .../kettle/jem/templates/LICENSE.md.example | 3 + .../lib/kettle/jem/templates/MIT.md.example | 21 + .../PolyForm-Noncommercial-1.0.0.md.example | 131 +++++ .../PolyForm-Small-Business-1.0.0.md.example | 121 ++++ .../kettle/jem/templates/README.md.example | 553 +++++++++++++++++- .../jem/templates/README.md.no-osc.example | 523 +++++++++++++++++ gems/kettle-jem/lib/kettle/jem/templates/REEK | 0 .../kettle/jem/templates/RUBOCOP.md.example | 71 +++ .../lib/kettle/jem/templates/Rakefile.example | 87 +++ .../kettle/jem/templates/SECURITY.md.example | 21 + .../kettle/jem/templates/bin/setup.example | 36 ++ .../jem/templates/certs/pboling.pem.example | 27 + .../kettle/jem/templates/gem.gemspec.example | 159 +++++ .../modular/benchmark/r4/v0.5.gemfile.example | 2 + .../modular/benchmark/vHEAD.gemfile.example | 2 + .../gemfiles/modular/coverage.gemfile.example | 14 + .../modular/coverage_local.gemfile.example | 20 + .../gemfiles/modular/debug.gemfile.example | 22 + .../modular/documentation.gemfile.example | 27 + .../documentation_local.gemfile.example | 18 + .../modular/erb/r2.3/default.gemfile.example | 6 + .../modular/erb/r2.6/v2.2.gemfile.example | 3 + .../modular/erb/r2/v3.0.gemfile.example | 1 + .../modular/erb/r3.1/v4.0.gemfile.example | 2 + .../modular/erb/r3/v5.0.gemfile.example | 1 + .../modular/erb/r4/v5.0.gemfile.example | 1 + .../modular/erb/vHEAD.gemfile.example | 2 + .../modular/mutex_m/r2.4/v0.1.gemfile.example | 3 + .../modular/mutex_m/r2/v0.3.gemfile.example | 2 + .../modular/mutex_m/r3/v0.3.gemfile.example | 2 + .../modular/mutex_m/r4/v0.3.gemfile.example | 2 + .../modular/mutex_m/vHEAD.gemfile.example | 2 + .../gemfiles/modular/optional.gemfile.example | 13 + .../recording/r2.3/recording.gemfile.example | 16 + .../recording/r2.4/recording.gemfile.example | 18 + .../recording/r2.5/recording.gemfile.example | 16 + .../recording/r3/recording.gemfile.example | 16 + .../recording/r4/recording.gemfile.example | 16 + .../modular/recording/vHEAD.gemfile.example | 2 + .../gemfiles/modular/rspec.gemfile.example | 4 + .../modular/runtime_heads.gemfile.example | 14 + .../gemfiles/modular/shunted.gemfile.example | 21 + .../stringio/r2.4/v0.0.2.gemfile.example | 4 + .../modular/stringio/r2/v3.0.gemfile.example | 5 + .../modular/stringio/r3/v3.0.gemfile.example | 5 + .../modular/stringio/r4/v3.0.gemfile.example | 5 + .../modular/stringio/vHEAD.gemfile.example | 2 + .../gemfiles/modular/style.gemfile.example | 28 + .../modular/style_local.gemfile.example | 24 + .../modular/templating.gemfile.example | 33 ++ .../modular/templating_local.gemfile.example | 34 ++ .../modular/x_std_libs.gemfile.example | 2 + .../x_std_libs/r2.3/libs.gemfile.example | 3 + .../x_std_libs/r2.4/libs.gemfile.example | 3 + .../x_std_libs/r2.6/libs.gemfile.example | 3 + .../x_std_libs/r2/libs.gemfile.example | 3 + .../x_std_libs/r3.1/libs.gemfile.example | 3 + .../x_std_libs/r3/libs.gemfile.example | 3 + .../x_std_libs/r4/libs.gemfile.example | 4 + .../modular/x_std_libs/vHEAD.gemfile.example | 4 + .../kettle/jem/templates/mise.toml.example | 27 + .../jem/templates/mise.toml.no-osc.example | 27 + .../kettle/jem/templates/parsers.toml.example | 19 + gems/kettle-jem/spec/thin_slice_spec.rb | 28 +- 144 files changed, 7514 insertions(+), 7 deletions(-) create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.aiignore.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.config/mise/env.sh create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/apt-install/devcontainer-feature.json.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/apt-install/install.sh.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/devcontainer.json.example create mode 100755 gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/scripts/setup-tree-sitter.sh.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.env.local.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.envrc.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.envrc.no-osc.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.gemrc.example create mode 100755 gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/commit-msg.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/commit-subjects-goalie.txt.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/footer-template.erb.txt.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/prepare-commit-msg.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/.codecov.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/FUNDING.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/FUNDING.yml.no-osc.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/copilot_instructions.md.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/dependabot.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/auto-assign.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/codeql-analysis.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/coverage.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/current.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/dep-heads.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/dependency-review.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/discord-notifier.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/framework-ci.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/heads.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.1.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.2.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.3.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.4.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/license-eye.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/locked_deps.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/opencollective.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.3.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.4.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.5.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.6.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.7.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.0.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.1.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.2.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.3.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.4.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/style.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/templating.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffle.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-22.3.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-23.0.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-23.1.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-24.2.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-25.0.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/unlocked_deps.yml.example create mode 100755 gems/kettle-jem/lib/kettle/jem/templates/.gitignore.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.gitlab-ci.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.kettle-jem.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.licenserc.yaml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.opencollective.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.qlty/qlty.toml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.rspec.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.rubocop.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.rubocop.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.rubocop_rspec.yml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.simplecov.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.yardignore.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/.yardopts.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/AGENTS.md.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/AGPL-3.0-only.md.example create mode 100755 gems/kettle-jem/lib/kettle/jem/templates/Appraisal.root.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/Appraisals.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/Big-Time-Public-License.md.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/CHANGELOG.md.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/CITATION.cff.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/CODE_OF_CONDUCT.md.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/CONTRIBUTING.md.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/FUNDING.md.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/FUNDING.md.no-osc.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/Gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/LICENSE.md.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/MIT.md.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/PolyForm-Noncommercial-1.0.0.md.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/PolyForm-Small-Business-1.0.0.md.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/README.md.no-osc.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/REEK create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/RUBOCOP.md.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/Rakefile.example create mode 100755 gems/kettle-jem/lib/kettle/jem/templates/SECURITY.md.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/bin/setup.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/certs/pboling.pem.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gem.gemspec.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/benchmark/r4/v0.5.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/benchmark/vHEAD.gemfile.example create mode 100755 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/coverage.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/coverage_local.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/debug.gemfile.example create mode 100755 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/documentation.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/documentation_local.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r2.3/default.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r2.6/v2.2.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r2/v3.0.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r3.1/v4.0.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r3/v5.0.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r4/v5.0.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/vHEAD.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r2.4/v0.1.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r2/v0.3.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r3/v0.3.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r4/v0.3.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/vHEAD.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/optional.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r2.3/recording.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r2.4/recording.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r2.5/recording.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r3/recording.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r4/recording.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/vHEAD.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/rspec.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/runtime_heads.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/shunted.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r2.4/v0.0.2.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r2/v3.0.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r3/v3.0.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r4/v3.0.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/vHEAD.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/style.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/style_local.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/templating.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/templating_local.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.3/libs.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.4/libs.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.6/libs.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2/libs.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r3.1/libs.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r3/libs.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r4/libs.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/vHEAD.gemfile.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/mise.toml.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/mise.toml.no-osc.example create mode 100644 gems/kettle-jem/lib/kettle/jem/templates/parsers.toml.example diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.aiignore.example b/gems/kettle-jem/lib/kettle/jem/templates/.aiignore.example new file mode 100644 index 0000000..df6bd8b --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.aiignore.example @@ -0,0 +1,19 @@ +# An .aiignore file follows the same syntax as a .gitignore file. +# .gitignore documentation: https://git-scm.com/docs/gitignore + +# you can ignore files +.DS_Store +*.log +*.tmp + +# or folders +.devcontainer/ +.qlty/ +.yardoc/ +dist/ +build/ +out/ +coverage/ +docs/ +pkg/ +results/ diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.config/mise/env.sh b/gems/kettle-jem/lib/kettle/jem/templates/.config/mise/env.sh new file mode 100644 index 0000000..f7b41f1 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.config/mise/env.sh @@ -0,0 +1,26 @@ +# shellcheck shell=bash + +prepend_unique_path_value() { + local value="$1" + local current="$2" + + if [[ -z "$current" ]]; then + printf '%s' "$value" + elif [[ ":$current:" == *":$value:"* ]]; then + printf '%s' "$current" + else + printf '%s:%s' "$value" "$current" + fi +} + +# Preserve existing values while prepending the project defaults needed by TreeHaver. +_tree_sitter_runtime_lib="${TREE_SITTER_RUNTIME_LIB:-/home/linuxbrew/.linuxbrew/Cellar/tree-sitter/0.26.6/lib/libtree-sitter.so}" +_tree_sitter_runtime_dir="$(dirname "${_tree_sitter_runtime_lib}")" +_tree_sitter_java_jars_dir="${TREE_SITTER_JAVA_JARS_DIR:-}" + +if [[ -n "${_tree_sitter_java_jars_dir}" ]]; then + _tree_sitter_java_jar="${_tree_sitter_java_jars_dir}/jtreesitter-0.26.0.jar" + export CLASSPATH="$(prepend_unique_path_value "${_tree_sitter_java_jar}" "${CLASSPATH:-}")" +fi + +export LD_LIBRARY_PATH="$(prepend_unique_path_value "${_tree_sitter_runtime_dir}" "${LD_LIBRARY_PATH:-}")" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/apt-install/devcontainer-feature.json.example b/gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/apt-install/devcontainer-feature.json.example new file mode 100644 index 0000000..bf4d0e4 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/apt-install/devcontainer-feature.json.example @@ -0,0 +1,9 @@ +{ + "name": "Apt Install Packages", + "id": "apt-install", + "version": "1.0.0", + "description": "More packages are needed", + "install": { + "script": "install.sh" + } +} \ No newline at end of file diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/apt-install/install.sh.example b/gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/apt-install/install.sh.example new file mode 100644 index 0000000..5c7ef2b --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/apt-install/install.sh.example @@ -0,0 +1,19 @@ +#!/bin/sh +set -e # Exit on error + +# Install basic development dependencies for Ruby & JRuby projects +apt-get update -y +apt-get install -y direnv default-jdk git zlib1g-dev build-essential libssl-dev libreadline-dev libyaml-dev libxml2-dev libxslt1-dev libcurl4-openssl-dev software-properties-common libffi-dev + +# Support for PostgreSQL (commented out by default) +# apt-get install -y postgresql libpq-dev + +# NOTE: Tree-sitter setup is NOT done here because the workspace is not mounted yet +# during the devcontainer build phase. Tree-sitter setup happens in postCreateCommand +# after the workspace is mounted. See devcontainer.json for details. +# This gem needs ALL grammars for top-level merging tool (handled by setup-tree-sitter.sh). + +echo "Basic apt packages installed. Tree-sitter will be set up after workspace mount." + +# Adds the direnv setup script to ~/.bashrc file (at the end) +echo 'eval "$(direnv hook bash)"' >> ~/.bashrc diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/devcontainer.json.example b/gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/devcontainer.json.example new file mode 100644 index 0000000..9fd8254 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/devcontainer.json.example @@ -0,0 +1,27 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/ruby +{ + "name": "Ruby", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/ruby:1-3-bookworm", + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "./apt-install": {} + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // First setup tree-sitter (auto-detects sudo requirement), then run bundle as the regular user + // Use ${containerWorkspaceFolder} to get the actual workspace path in the container + "postCreateCommand": "bash ${containerWorkspaceFolder}/.devcontainer/scripts/setup-tree-sitter.sh --workspace=${containerWorkspaceFolder} && bundle update --bundler", + // Configure tool-specific properties. + "customizations": { + "jetbrains": { + "backend": "RubyMine" + } + } + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/scripts/setup-tree-sitter.sh.example b/gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/scripts/setup-tree-sitter.sh.example new file mode 100755 index 0000000..a269696 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.devcontainer/scripts/setup-tree-sitter.sh.example @@ -0,0 +1,210 @@ +#!/bin/bash +set -e + +# Setup script for tree-sitter dependencies (Ubuntu/Debian) +# Works for both GitHub Actions and devcontainer environments +# +# Dual-Environment Design: +# - GitHub Actions: Runs as non-root user, auto-detects need for sudo +# - Devcontainer: Can run as root (apt-install feature) or non-root (postCreateCommand) +# - Auto-detection: Checks if running as root (id -u = 0), uses sudo if non-root +# +# Grammar building is delegated to tsdl (https://github.com/stackmystack/tsdl). +# Configure grammars and versions in parsers.toml at the project root. +# +# Options: +# --sudo: Force use of sudo (optional, auto-detected by default) +# --cli: Install tree-sitter-cli via npm (optional) +# --build: Build and install the tree-sitter C runtime from source when distro packages are missing (optional) +# --tsdl-version VERSION: Pin tsdl release version (default: v2.0.0) +# --workspace PATH: Workspace root path for informational/debugging purposes only + +SUDO="" +INSTALL_CLI=false +BUILD_FROM_SOURCE=false +TSDL_VERSION="v2.0.0" +WORKSPACE_ROOT="/workspaces/${PWD##*/}" + +while [[ $# -gt 0 ]]; do + case $1 in + --sudo) SUDO="sudo"; shift ;; + --cli) INSTALL_CLI=true; shift ;; + --build) BUILD_FROM_SOURCE=true; shift ;; + --tsdl-version) TSDL_VERSION="$2"; shift 2 ;; + --tsdl-version=*) TSDL_VERSION="${1#*=}"; shift ;; + --workspace) WORKSPACE_ROOT="$2"; shift 2 ;; + --workspace=*) WORKSPACE_ROOT="${1#*=}"; shift ;; + *) echo "Unknown option: $1" >&2; shift ;; + esac +done + +# Auto-detect if we need sudo (running as non-root) +if [ -z "$SUDO" ] && [ "$(id -u)" -ne 0 ]; then + SUDO="sudo" +fi + +echo "Configuration:" +echo " Workspace root: $WORKSPACE_ROOT (informational only)" +echo " Using sudo: $([ -n "$SUDO" ] && echo "yes" || echo "no")" +echo " Install CLI: $INSTALL_CLI" +echo " Build from source: $BUILD_FROM_SOURCE" +echo " tsdl version: $TSDL_VERSION" +echo "" + +have_cmd() { command -v "$1" >/dev/null 2>&1; } + +have_tree_sitter() { + [ -f /usr/include/tree-sitter/api.h ] && return 0 + [ -f /usr/local/include/tree-sitter/api.h ] && return 0 + [ -f /usr/local/include/tree-sitter/lib/include/api.h ] && return 0 + ldconfig -p 2>/dev/null | grep -q libtree-sitter && return 0 || return 1 +} + +install_tree_sitter_from_source() { + echo "[tree-sitter] Building runtime from source..." + tmpdir=$(mktemp -d /tmp/tree-sitter-src-XXXX) + trap 'rm -rf "$tmpdir"' EXIT + git clone --depth 1 https://github.com/tree-sitter/tree-sitter.git "$tmpdir" || return 1 + pushd "$tmpdir" >/dev/null || return 1 + if ! make; then + echo "[tree-sitter] ERROR: 'make' failed" >&2 + popd >/dev/null + return 1 + fi + $SUDO mkdir -p /usr/local/include/tree-sitter + $SUDO cp -r lib/include/* /usr/local/include/tree-sitter/ || true + $SUDO cp -a lib/libtree-sitter.* /usr/local/lib/ 2>/dev/null || true + have_cmd ldconfig && $SUDO ldconfig || true + popd >/dev/null + echo "[tree-sitter] Runtime installed to /usr/local." + return 0 +} + +install_tsdl() { + if have_cmd tsdl; then + echo "[tsdl] Already installed: $(tsdl --version)" + return 0 + fi + + echo "[tsdl] Installing tsdl ${TSDL_VERSION}..." + local arch + arch="$(uname -m)" + case "$arch" in + x86_64) arch="x64" ;; + aarch64) arch="arm64" ;; + armv7l) arch="arm" ;; + i686) arch="x86" ;; + *) echo "[tsdl] ERROR: Unsupported architecture: $arch" >&2; return 1 ;; + esac + + local os + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + case "$os" in + linux) os="linux" ;; + darwin) os="macos" ;; + *) echo "[tsdl] ERROR: Unsupported OS: $os" >&2; return 1 ;; + esac + + local url="https://github.com/stackmystack/tsdl/releases/download/${TSDL_VERSION}/tsdl-${os}-${arch}.gz" + local tmpbin + tmpbin=$(mktemp /tmp/tsdl-XXXX) + + if ! wget -q "$url" -O "${tmpbin}.gz"; then + echo "[tsdl] ERROR: Failed to download from $url" >&2 + return 1 + fi + gunzip -f "${tmpbin}.gz" + chmod +x "$tmpbin" + $SUDO mv "$tmpbin" /usr/local/bin/tsdl + echo "[tsdl] Installed: $(tsdl --version)" +} + +# --- 1. System dependencies --- +echo "Installing system dependencies..." +$SUDO apt-get update -y +if ! $SUDO apt-get install -y \ + build-essential \ + pkg-config \ + $( [ "$BUILD_FROM_SOURCE" = false ] && echo "libtree-sitter-dev" ) \ + wget \ + gcc \ + g++ \ + make \ + zlib1g-dev \ + libssl-dev \ + libreadline-dev \ + libyaml-dev \ + libxml2-dev \ + libxslt1-dev \ + libcurl4-openssl-dev \ + software-properties-common \ + libffi-dev; then + echo "ERROR: apt-get failed to install required packages." >&2 + exit 1 +fi + +# --- 2. Tree-sitter runtime --- +if [ "$BUILD_FROM_SOURCE" = true ]; then + echo "[tree-sitter] --build specified; building runtime from source." +fi + +if ! have_tree_sitter; then + if [ "$BUILD_FROM_SOURCE" = true ]; then + if ! install_tree_sitter_from_source; then + echo "[tree-sitter] ERROR: Failed to build runtime. Aborting." >&2 + exit 1 + fi + else + echo "[tree-sitter] ERROR: Runtime (headers/libs) not found." >&2 + echo "Install libtree-sitter-dev or re-run with --build." >&2 + exit 1 + fi +fi + +# --- 3. tree-sitter-cli (optional) --- +if [ "$INSTALL_CLI" = true ]; then + echo "Installing tree-sitter-cli via npm..." + $SUDO npm install -g tree-sitter-cli +else + echo "Skipping tree-sitter-cli (use --cli to install)" +fi + +# --- 4. Install tsdl and build grammars --- +install_tsdl + +echo "" +echo "Building tree-sitter grammars via tsdl..." +# Use parsers.toml from the project root if it exists, otherwise build defaults. +# tsdl automatically reads parsers.toml in the current directory. +if [ -f parsers.toml ]; then + echo "[tsdl] Using parsers.toml config" + $SUDO tsdl build --out-dir /usr/local/lib --progress plain +else + echo "[tsdl] No parsers.toml found; building default grammars: toml json bash rbs" + $SUDO tsdl build toml json bash rbs --out-dir /usr/local/lib --progress plain +fi + +$SUDO ldconfig || echo "WARNING: ldconfig failed" >&2 + +echo "" +echo "tree-sitter setup complete!" +echo "" +echo "Detected library paths:" + +if [ -f /usr/lib/x86_64-linux-gnu/libtree-sitter.so.0 ]; then + echo " TREE_SITTER_RUNTIME_LIB=/usr/lib/x86_64-linux-gnu/libtree-sitter.so.0" +elif [ -f /usr/lib/x86_64-linux-gnu/libtree-sitter.so ]; then + echo " TREE_SITTER_RUNTIME_LIB=/usr/lib/x86_64-linux-gnu/libtree-sitter.so" +elif [ -f /usr/lib/libtree-sitter.so.0 ]; then + echo " TREE_SITTER_RUNTIME_LIB=/usr/lib/libtree-sitter.so.0" +elif [ -f /usr/lib/libtree-sitter.so ]; then + echo " TREE_SITTER_RUNTIME_LIB=/usr/lib/libtree-sitter.so" +else + echo " WARNING: Could not find libtree-sitter runtime library!" +fi + +echo "" +echo "Grammar libraries:" +for lib in /usr/local/lib/libtree-sitter-*.so; do + [ -f "$lib" ] && echo " $lib" +done diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.env.local.example b/gems/kettle-jem/lib/kettle/jem/templates/.env.local.example new file mode 100644 index 0000000..742ae19 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.env.local.example @@ -0,0 +1,65 @@ +# +# DO NOT EDIT THIS FILE +# +# COPY THIS FILE TO .env.local +# +# That file is ignored by .gitignore. This file is not. +# mise loads .env.local via dotenvy, so use KEY=value lines (not shell `export` statements). +# +DEBUG=false # do not allow byebug statements (override in .env.local) +FLOSS_FUNDING_DEBUG=false # extra logging to help diagnose issues (override in .env.local) +AUTOGEN_FIXTURE_CLEANUP=false # autogenerated gem fixture cleanup after every RSpec run +GIT_HOOK_FOOTER_APPEND=false +GIT_HOOK_FOOTER_APPEND_DEBUG=false +GIT_HOOK_FOOTER_SENTINEL="⚡️ A message from a fellow meat-based-AI" + +# Tokens used by ci:act and CI helpers for reading workflow/pipeline status via APIs +# GitHub (either GITHUB_TOKEN or GH_TOKEN will be used; fine-grained recommended) +# - Scope/permissions: For fine-grained tokens, grant repository access (Read) and Actions: Read +# - For classic tokens, public repos need no scopes; private repos typically require repo +GITHUB_TOKEN="" +# Alternatively: +# GH_TOKEN="" + +# GitLab (either GITLAB_TOKEN or GL_TOKEN will be used) +# - Scope: read_api is sufficient to read pipelines +GITLAB_TOKEN="" +# Alternatively: +# GL_TOKEN="" + +# If this gem does not have an open source collective uncomment and set these to false. +# OPENCOLLECTIVE_HANDLE=false +# FUNDING_ORG=false + +# ── Kettle-Jem Token Replacements ────────────────────────────────────────────── +# Used by `apply_common_replacements` during templating. +# Set these to your own values. Blank/unset values leave the token unresolved. + +# ── Forge Users ──────────────────────────────────────────────────────────────── +# KJ_GH_USER= +# KJ_GL_USER= +# KJ_CB_USER= +# KJ_SH_USER= + +# ── Author Identity ─────────────────────────────────────────────────────────── +# KJ_AUTHOR_NAME= +# KJ_AUTHOR_GIVEN_NAMES= +# KJ_AUTHOR_FAMILY_NAMES= +# KJ_AUTHOR_EMAIL= +# KJ_AUTHOR_ORCID= +# KJ_AUTHOR_DOMAIN= + +# ── Funding Platforms ───────────────────────────────────────────────────────── +# KJ_FUNDING_PATREON= +# KJ_FUNDING_KOFI= +# KJ_FUNDING_PAYPAL= +# KJ_FUNDING_BUYMEACOFFEE= +# KJ_FUNDING_POLAR= +# KJ_FUNDING_LIBERAPAY= +# KJ_FUNDING_ISSUEHUNT= + +# ── Social / Community ──────────────────────────────────────────────────────── +# KJ_SOCIAL_MASTODON= +# KJ_SOCIAL_BLUESKY= +# KJ_SOCIAL_LINKTREE= +# KJ_SOCIAL_DEVTO= diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.envrc.example b/gems/kettle-jem/lib/kettle/jem/templates/.envrc.example new file mode 100644 index 0000000..2de3ea8 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.envrc.example @@ -0,0 +1,4 @@ +# This project uses mise.toml for environment management. +# Install/activate mise, or run commands with: +# mise exec -C /path/to/project -- +true diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.envrc.no-osc.example b/gems/kettle-jem/lib/kettle/jem/templates/.envrc.no-osc.example new file mode 100644 index 0000000..2de3ea8 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.envrc.no-osc.example @@ -0,0 +1,4 @@ +# This project uses mise.toml for environment management. +# Install/activate mise, or run commands with: +# mise exec -C /path/to/project -- +true diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.gemrc.example b/gems/kettle-jem/lib/kettle/jem/templates/.gemrc.example new file mode 100644 index 0000000..154cd47 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.gemrc.example @@ -0,0 +1 @@ +gem: --no-document diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/commit-msg.example b/gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/commit-msg.example new file mode 100755 index 0000000..cb458d2 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/commit-msg.example @@ -0,0 +1,60 @@ +#!/usr/bin/env ruby +# vim: set syntax=ruby + +# kettle-jem:freeze +# To retain chunks of comments & code during kettle-jem templating: +# Wrap custom sections with freeze markers (e.g., as above and below this comment chunk). +# kettle-jem will then preserve content between those markers across template runs. +# kettle-jem:unfreeze + +# Do not rely on Bundler; allow running outside a Bundler context +begin + require "rubygems" +rescue LoadError + # continue +end + +begin + # External gems + require "gitmoji/regex" + + full_text = File.read(ARGV[0]) + # Is the first character a GitMoji? + gitmoji_index = full_text =~ Gitmoji::Regex::REGEX + if gitmoji_index == 0 + exit(0) + else + denied = <<~EOM + Oh snap, think again... + + ______ _______ ___ _______ _______ _______ _______ ______ __ + | _ | | | | || || || || || | | | + | | || | ___| | || ___|| ||_ _|| ___|| _ || | + | |_||_ | |___ | || |___ | | | | | |___ | | | || | + | __ || ___| ___| || ___|| _| | | | ___|| |_| ||__| + | | | || |___ | || |___ | |_ | | | |___ | | __ + |___| |_||_______||_______||_______||_______| |___| |_______||______| |__| + + + Did you forget to add a relevant gitmoji? (see https://gitmoji.dev/ for tools) + In this project, a Gitmoji must be the first grapheme of the commit message. + What's a grapheme? + A symbol rendered to be visually identifiable as a single character, but which may be composed of multiple Unicode code points) + Must match: #{Gitmoji::Regex::REGEX} + #{"Found a gitmoji at character index #{gitmoji_index}... not good enough.\n" if gitmoji_index} + Example: git commit -m "✨ My excellent new feature" + + EOM + puts denied + exit(1) + end +rescue LoadError => e + failure = <<~EOM + gitmoji-regex gem not found: #{e.class}: #{e.message}. + Skipping gitmoji check and allowing commit to proceed. + Recommendation: add 'gitmoji-regex' to your development dependencies to enable this check. + + EOM + warn(failure) + exit(0) +end diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/commit-subjects-goalie.txt.example b/gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/commit-subjects-goalie.txt.example new file mode 100644 index 0000000..54b905a --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/commit-subjects-goalie.txt.example @@ -0,0 +1,8 @@ +🔖 Prepare release v +🔒️ Checksums for v + +# Lines beginning with # are ignored. +# This file is read by .git-hooks/prepare-commit-msg in each project. +# Each line of this file will be matched against the commit subject using `starts_with?`. +# If any `starts_with?` match the project script bin/prepare-commit-msg will run. +# 🔒️ Checksums for v is the standard commit message by stone_checksums. diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/footer-template.erb.txt.example b/gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/footer-template.erb.txt.example new file mode 100644 index 0000000..daa8fbf --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/footer-template.erb.txt.example @@ -0,0 +1,16 @@ +⚡️ A message from a fellow meat-based-AI ⚡️ +- [❤️] Finely-crafted open-source tools like <%= @gem_name %> (& many more) require time and effort. +- [❤️] Though I adore my work, it lacks financial sustainability. +- [❤️] Please, help me continue enhancing your tools by becoming a sponsor: + - [💲] https://liberapay.com/{KJ|FUNDING:LIBERAPAY}/donate + - [💲] https://github.com/sponsors/{KJ|GH:USER} + +<% if ENV["GIT_HOOK_FOOTER_APPEND_DEBUG"] == "true" %> + @pwd = <%= @pwd %> + @gemspecs = <%= @gemspecs %> + @spec = <%= @spec %> + @gemspec_path = <%= @gemspec_path %> + @gem_name <%= @gem_name %> + @spec_name <%= @spec_name %> + @content <%= @content %> +<% end %> diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/prepare-commit-msg.example b/gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/prepare-commit-msg.example new file mode 100644 index 0000000..91df8d0 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.git-hooks/prepare-commit-msg.example @@ -0,0 +1,9 @@ +#!/bin/sh + +# Fail on error and unset variables +set -eu + +# Run the generated wrapper directly so hook execution does not depend on shell activation. +# We are not using direnv exec here because mise and direnv can result in conflicting PATH settings: +# See: https://mise.jdx.dev/direnv.html +exec "kettle-commit-msg" "$@" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/.codecov.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/.codecov.yml.example new file mode 100644 index 0000000..085c1d1 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/.codecov.yml.example @@ -0,0 +1,14 @@ +codecov: + bot: "autobolt" + max_report_age: 24 + disable_default_path_fixes: no + require_ci_to_pass: yes + notify: + after_n_builds: 2 + wait_for_ci: yes +coverage: + status: + project: + default: + target: 100% + threshold: 1% diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/FUNDING.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/FUNDING.yml.example new file mode 100644 index 0000000..2442e04 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/FUNDING.yml.example @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +buy_me_a_coffee: "{KJ|FUNDING:BUYMEACOFFEE}" +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +github: ["{KJ|GH:USER}"] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +issuehunt: "{KJ|FUNDING:ISSUEHUNT}" # Replace with a single IssueHunt username +ko_fi: "{KJ|FUNDING:KOFI}" # Replace with a single Ko-fi username +liberapay: "{KJ|FUNDING:LIBERAPAY}" # Replace with a single Liberapay username +open_collective: "{KJ|OPENCOLLECTIVE_ORG}" +patreon: "{KJ|FUNDING:PATREON}" # Replace with a single Patreon username +polar: "{KJ|FUNDING:POLAR}" +thanks_dev: u/gh/{KJ|GH:USER} +tidelift: rubygems/{KJ|GEM_NAME} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/FUNDING.yml.no-osc.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/FUNDING.yml.no-osc.example new file mode 100644 index 0000000..d6014dd --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/FUNDING.yml.no-osc.example @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +buy_me_a_coffee: "{KJ|FUNDING:BUYMEACOFFEE}" +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +github: ["{KJ|GH:USER}"] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +issuehunt: "{KJ|FUNDING:ISSUEHUNT}" # Replace with a single IssueHunt username +ko_fi: "{KJ|FUNDING:KOFI}" # Replace with a single Ko-fi username +liberapay: "{KJ|FUNDING:LIBERAPAY}" # Replace with a single Liberapay username +open_collective: # Replace with a single Open Collective slug e.g., orgs/cloud-foundry +patreon: "{KJ|FUNDING:PATREON}" # Replace with a single Patreon username +polar: "{KJ|FUNDING:POLAR}" +thanks_dev: u/gh/{KJ|GH:USER} +tidelift: rubygems/{KJ|GEM_NAME} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/copilot_instructions.md.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/copilot_instructions.md.example new file mode 100644 index 0000000..3f2a7ef --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/copilot_instructions.md.example @@ -0,0 +1 @@ +See AGENTS.md for important information on tool use and project context. diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/dependabot.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/dependabot.yml.example new file mode 100644 index 0000000..956aa5a --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/dependabot.yml.example @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: bundler + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + ignore: + - dependency-name: "rubocop-lts" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/auto-assign.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/auto-assign.yml.example new file mode 100644 index 0000000..97d786e --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/auto-assign.yml.example @@ -0,0 +1,21 @@ +name: Auto Assign +on: + issues: + types: [opened] + pull_request: + types: [opened] +jobs: + run: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: 'Auto-assign issue' + uses: pozil/auto-assign-issue@39c06395cbac76e79afc4ad4e5c5c6db6ecfdd2e # v2.2.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + assignees: "{KJ|GH:USER}" + abortIfPreviousAssignees: true + allowSelfAssign: true + numOfAssignee: 1 \ No newline at end of file diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/codeql-analysis.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/codeql-analysis.yml.example new file mode 100644 index 0000000..d7b1a4a --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/codeql-analysis.yml.example @@ -0,0 +1,70 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ main, '*-stable' ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ main, '*-stable' ] + schedule: + - cron: '35 1 * * 5' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'ruby' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://git.io/codeql-language-support + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/coverage.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/coverage.yml.example new file mode 100644 index 0000000..b9ecbe3 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/coverage.yml.example @@ -0,0 +1,133 @@ +name: Test Coverage + +permissions: + contents: read + pull-requests: write + id-token: write + +env: + K_SOUP_COV_MIN_BRANCH: 100 + K_SOUP_COV_MIN_LINE: 100 + K_SOUP_COV_MIN_HARD: true + K_SOUP_COV_FORMATTERS: "xml,rcov,lcov,tty" + K_SOUP_COV_DO: true + K_SOUP_COV_MULTI_FORMATTERS: true + K_SOUP_COV_COMMAND_NAME: "Test Coverage" + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + coverage: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Code Coverage on ${{ matrix.ruby }}@current + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + fail-fast: false + matrix: + include: + # Coverage + - ruby: "ruby" + appraisal: "coverage" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: "${{ matrix.ruby }}" + rubygems: "${{ matrix.rubygems }}" + bundler: "${{ matrix.bundler }}" + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }}@current via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} + + # Do SaaS coverage uploads first + - name: Upload coverage to Coveralls + if: ${{ !env.ACT }} + uses: coverallsapp/github-action@0a51d2e0b5417d06e4ecceb534aec87defc53926 # main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: ${{ matrix.experimental != 'false' }} + + - name: Upload coverage to QLTY + if: ${{ !env.ACT }} + uses: qltysh/qlty-action/coverage@a19242102d17e497f437d7466aa01b528537e899 # v2.2.0 + with: + token: ${{secrets.QLTY_COVERAGE_TOKEN}} + files: coverage/.resultset.json + continue-on-error: ${{ matrix.experimental != 'false' }} + + # Build will fail here if coverage upload fails + # which will hopefully be noticed for the lack of code coverage comments + - name: Upload coverage to CodeCov + if: ${{ !env.ACT }} + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + with: + use_oidc: true + fail_ci_if_error: false # optional (default = false) + files: coverage/lcov.info,coverage/coverage.xml + verbose: true # optional (default = false) + + # Then PR comments + - name: Code Coverage Summary Report + if: ${{ !env.ACT && github.event_name == 'pull_request' }} + uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0 + with: + filename: ./coverage/coverage.xml + badge: true + fail_below_min: true + format: markdown + hide_branch_rate: false + hide_complexity: true + indicators: true + output: both + thresholds: '100 100' + continue-on-error: ${{ matrix.experimental != 'false' }} + + - name: Add Coverage PR Comment + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + if: ${{ !env.ACT && github.event_name == 'pull_request' }} + with: + recreate: true + path: code-coverage-results.md + continue-on-error: ${{ matrix.experimental != 'false' }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/current.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/current.yml.example new file mode 100644 index 0000000..05580b5 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/current.yml.example @@ -0,0 +1,74 @@ +# Targets the evergreen latest release of ruby +name: Current MRI + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby 4.0 + - ruby: "ruby" + appraisal: "current" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/dep-heads.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/dep-heads.yml.example new file mode 100644 index 0000000..7c38d96 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/dep-heads.yml.example @@ -0,0 +1,96 @@ +# Targets the evergreen latest release of ruby, truffleruby, and jruby +# and tests against the HEAD of runtime dependencies +name: Runtime Deps @ HEAD + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }}${{ matrix.name_extra || '' }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + fail-fast: true + matrix: + include: + # Ruby 3.4 + - ruby: "ruby" + appraisal: "dep-heads" + exec_cmd: "rake spec" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + # truffleruby-24.1 (targets Ruby 3.3 compatibility) + - ruby: "truffleruby" + appraisal: "dep-heads" + exec_cmd: "rake spec" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + # jruby-10.0 (targets Ruby 3.4 compatibility) + - ruby: "jruby" + appraisal: "dep-heads" + exec_cmd: "rake spec" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + if: ${{ !env.ACT || (! startsWith(matrix.ruby, 'jruby') && !startsWith(matrix.ruby, 'truffleruby')) }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + if: ${{ !env.ACT || (! startsWith(matrix.ruby, 'jruby') && !startsWith(matrix.ruby, 'truffleruby')) }} + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT || (! startsWith(matrix.ruby, 'jruby') && !startsWith(matrix.ruby, 'truffleruby')) }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && (!env.ACT || (! startsWith(matrix.ruby, 'jruby') && !startsWith(matrix.ruby, 'truffleruby'))) }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT || (! startsWith(matrix.ruby, 'jruby') && !startsWith(matrix.ruby, 'truffleruby')) }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/dependency-review.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/dependency-review.yml.example new file mode 100644 index 0000000..102d69a --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/dependency-review.yml.example @@ -0,0 +1,20 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: 'Dependency Review' + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/discord-notifier.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/discord-notifier.yml.example new file mode 100644 index 0000000..de846d7 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/discord-notifier.yml.example @@ -0,0 +1,39 @@ +name: Discord Notify + +on: + check_run: + types: [completed] + discussion: + types: [ created ] + discussion_comment: + types: [ created ] + fork: + gollum: + issues: + types: [ opened ] + issue_comment: + types: [ created ] + pull_request: + types: [ opened, reopened, closed ] + release: + types: [ published ] + watch: + types: [ started ] + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + notify: + if: false + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - name: Actions Status Discord + uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1.16.0 + if: always() + with: + webhook: ${{ secrets.DISCORD_WEBHOOK }} + status: ${{ job.status }} + username: GitHub Actions diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/framework-ci.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/framework-ci.yml.example new file mode 100644 index 0000000..4c7a5d7 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/framework-ci.yml.example @@ -0,0 +1,67 @@ +# Framework matrix CI workflow template. +# Generated by kettle-jem when workflows.preset is "framework". +# The matrix entries are populated from .kettle-jem.yml framework_matrix config. +name: Framework CI + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.framework_version }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} + strategy: + fail-fast: false + matrix: + ruby: + - "3.1" + - "3.2" + - "3.3" + - "3.4" + framework_version: [] + gemfile: [] + rubygems: + - default + bundler: + - default + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: "${{ matrix.ruby }}" + rubygems: "${{ matrix.rubygems }}" + bundler: "${{ matrix.bundler }}" + bundler-cache: true + + - name: Tests for ${{ matrix.ruby }}@${{ matrix.framework_version }} + run: bundle exec rake test diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/heads.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/heads.yml.example new file mode 100644 index 0000000..f4507be --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/heads.yml.example @@ -0,0 +1,95 @@ +name: Heads + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }}${{ matrix.name_extra || '' }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + fail-fast: true + matrix: + include: + # NOTE: Heads use default rubygems / bundler; their defaults are custom, unreleased, and from the future! + # ruby-head + - ruby: "ruby-head" + appraisal: "head" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + # truffleruby-head + - ruby: "truffleruby-head" + appraisal: "head" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + # jruby-head + - ruby: "jruby-head" + appraisal: "head" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + if: ${{ !env.ACT || (! startsWith(matrix.ruby, 'jruby') && !startsWith(matrix.ruby, 'truffleruby')) }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + if: ${{ !env.ACT || (! startsWith(matrix.ruby, 'jruby') && !startsWith(matrix.ruby, 'truffleruby')) }} + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT || (! startsWith(matrix.ruby, 'jruby') && !startsWith(matrix.ruby, 'truffleruby')) }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && (!env.ACT || (! startsWith(matrix.ruby, 'jruby') && !startsWith(matrix.ruby, 'truffleruby'))) }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT || (! startsWith(matrix.ruby, 'jruby') && !startsWith(matrix.ruby, 'truffleruby')) }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.1.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.1.yml.example new file mode 100644 index 0000000..413d643 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.1.yml.example @@ -0,0 +1,77 @@ +name: JRuby 9.1 + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # jruby-9.1 (targets Ruby 2.3 compatibility) + - ruby: "jruby-9.1" + appraisal: "ruby-2-3" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + if: ${{ !env.ACT }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + if: ${{ !env.ACT }} + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.2.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.2.yml.example new file mode 100644 index 0000000..7b8da59 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.2.yml.example @@ -0,0 +1,77 @@ +name: JRuby 9.2 + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # jruby-9.2 (targets Ruby 2.5 compatibility) + - ruby: "jruby-9.2" + appraisal: "ruby-2-5" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + if: ${{ !env.ACT }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + if: ${{ !env.ACT }} + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.3.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.3.yml.example new file mode 100644 index 0000000..3f772be --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.3.yml.example @@ -0,0 +1,77 @@ +name: JRuby 9.3 + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # jruby-9.3 (targets Ruby 2.6 compatibility) + - ruby: "jruby-9.3" + appraisal: "ruby-2-6" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + if: ${{ !env.ACT }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + if: ${{ !env.ACT }} + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.4.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.4.yml.example new file mode 100644 index 0000000..bd48eb8 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby-9.4.yml.example @@ -0,0 +1,77 @@ +name: JRuby 9.4 + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # jruby-9.4 (targets Ruby 3.1 compatibility) + - ruby: "jruby-9.4" + appraisal: "ruby-3-1" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + if: ${{ !env.ACT }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + if: ${{ !env.ACT }} + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby.yml.example new file mode 100644 index 0000000..458dd84 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/jruby.yml.example @@ -0,0 +1,77 @@ +name: Current JRuby + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # jruby-10.0 (targets Ruby 3.4 compatibility) + - ruby: "jruby" + appraisal: "current" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + if: ${{ !env.ACT }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + if: ${{ !env.ACT }} + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/license-eye.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/license-eye.yml.example new file mode 100644 index 0000000..eda9c10 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/license-eye.yml.example @@ -0,0 +1,40 @@ +name: Apache SkyWalking Eyes + +permissions: + contents: read + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + license-check: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check Dependencies' License + uses: apache/skywalking-eyes/dependency@61275cc80d0798a405cb070f7d3a8aaf7cf2c2c1 # v0.8.0 + with: + config: .licenserc.yaml + # Ruby packages declared as dependencies in gemspecs or Gemfiles are + # typically consumed as binaries; enable weak-compatibility + # so permissive and weak-copyleft combinations are treated as compatible. + flags: --weak-compatible diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/locked_deps.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/locked_deps.yml.example new file mode 100644 index 0000000..068de9e --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/locked_deps.yml.example @@ -0,0 +1,99 @@ +--- +# Lock/Unlock Deps Pattern +# +# Two often conflicting goals resolved! +# +# - unlocked_deps.yml +# - All runtime & dev dependencies, but does not have a `gemfiles/*.gemfile.lock` committed +# - Uses an Appraisal2 "unlocked_deps" gemfile, and the current MRI Ruby release +# - Know when new dependency releases will break local dev with unlocked dependencies +# - Broken workflow indicates that new releases of dependencies may not work +# +# - locked_deps.yml +# - All runtime & dev dependencies, and has a `Gemfile.lock` committed +# - Uses the project's main Gemfile, and the current MRI Ruby release +# - Matches what contributors and maintainers use locally for development +# - Broken workflow indicates that a new contributor will have a bad time +# +name: Deps Locked + +permissions: + contents: read + +env: + # Running coverage, but not validating minimum coverage, + # because it would be redundant with the coverage workflow. + # Also we can validate all output formats without breaking CodeCov, + # since we aren't submitting these reports anywhere. + K_SOUP_COV_MIN_BRANCH: 10 + K_SOUP_COV_MIN_LINE: 40 + K_SOUP_COV_MIN_HARD: false + K_SOUP_COV_FORMATTERS: "html,xml,rcov,lcov,json,tty" + K_SOUP_COV_DO: true + K_SOUP_COV_MULTI_FORMATTERS: true + K_SOUP_COV_COMMAND_NAME: "Test Coverage" + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Default rake task w/ main Gemfile.lock ${{ matrix.name_extra || '' }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + include: + # Ruby + - ruby: "ruby" + exec_cmd: "rake" + rubygems: latest + bundler: latest + experimental: false + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Checks the kitchen sink via ${{ matrix.exec_cmd }} + run: bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/opencollective.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/opencollective.yml.example new file mode 100644 index 0000000..b66cf04 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/opencollective.yml.example @@ -0,0 +1,40 @@ +name: Open Collective Backers + +on: + schedule: + # Run once a week on Sunday at 12:00 AM UTC + - cron: '0 0 * * 0' + workflow_dispatch: + +permissions: + contents: write + +jobs: + update-backers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ruby + rubygems: default + bundler: default + bundler-cache: true + + - name: README Update + env: + # Keep GITHUB_TOKEN for any tools/scripts expecting it, mapped to the same secret + GITHUB_TOKEN: ${{ secrets.README_UPDATER_TOKEN }} + README_UPDATER_TOKEN: ${{ secrets.README_UPDATER_TOKEN }} + REPO: ${{ github.repository }} + run: | + git config user.name 'autobolt' + git config user.email 'autobots@9thbit.net' + # Use the configured token for authenticated pushes + git remote set-url origin "https://x-access-token:${README_UPDATER_TOKEN}@github.com/${REPO}.git" + bin/kettle-readme-backers + # Push back to the same branch/ref that triggered the workflow (default branch for schedule) + git push origin HEAD diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.3.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.3.yml.example new file mode 100644 index 0000000..ead2a15 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.3.yml.example @@ -0,0 +1,67 @@ +name: Ruby 2.3 + +permissions: + contents: read + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby 2.3 + - ruby: "ruby-2.3" + appraisal: "ruby-2-3" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: "3.3.27" + bundler: "2.3.27" + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + continue-on-error: true + + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.4.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.4.yml.example new file mode 100644 index 0000000..feeb87a --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.4.yml.example @@ -0,0 +1,67 @@ +name: Ruby 2.4 + +permissions: + contents: read + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby 2.4 + - ruby: "ruby-2.4" + appraisal: "ruby-2-4" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: "3.3.27" + bundler: "2.3.27" + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + continue-on-error: true + + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.5.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.5.yml.example new file mode 100644 index 0000000..2ae913b --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.5.yml.example @@ -0,0 +1,67 @@ +name: Ruby 2.5 + +permissions: + contents: read + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby 2.5 + - ruby: "ruby-2.5" + appraisal: "ruby-2-5" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: "3.3.27" + bundler: "2.3.27" + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + continue-on-error: true + + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.6.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.6.yml.example new file mode 100644 index 0000000..a6607d2 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.6.yml.example @@ -0,0 +1,67 @@ +name: Ruby 2.6 + +permissions: + contents: read + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby 2.6 + - ruby: "ruby-2.6" + appraisal: "ruby-2-6" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: '3.4.22' + bundler: '2.4.22' + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + continue-on-error: true + + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.7.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.7.yml.example new file mode 100644 index 0000000..4a6e021 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-2.7.yml.example @@ -0,0 +1,67 @@ +name: Ruby 2.7 + +permissions: + contents: read + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby 2.7 + - ruby: "ruby-2.7" + appraisal: "ruby-2-7" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: '3.4.22' + bundler: '2.4.22' + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + continue-on-error: true + + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.0.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.0.yml.example new file mode 100644 index 0000000..69041d0 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.0.yml.example @@ -0,0 +1,72 @@ +name: Ruby 3.0 + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby 3.0 + - ruby: "ruby-3.0" + appraisal: "ruby-3-0" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: '3.5.23' + bundler: '2.5.23' + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + continue-on-error: true + + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.1.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.1.yml.example new file mode 100644 index 0000000..23175a3 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.1.yml.example @@ -0,0 +1,70 @@ +name: Ruby 3.1 + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby 3.1 + - ruby: "ruby-3.1" + appraisal: "ruby-3-1" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: '3.6.9' + bundler: '2.6.9' + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + continue-on-error: true + + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.2.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.2.yml.example new file mode 100644 index 0000000..d6333e2 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.2.yml.example @@ -0,0 +1,67 @@ +name: Ruby 3.2 + +permissions: + contents: read + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby 3.2 + - ruby: "ruby-3.2" + appraisal: "ruby-3-2" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + continue-on-error: true + + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} ${{ matrix.appraisal }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.3.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.3.yml.example new file mode 100644 index 0000000..a27530c --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.3.yml.example @@ -0,0 +1,67 @@ +name: Ruby 3.3 + +permissions: + contents: read + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby 3.3 + - ruby: "ruby-3.3" + appraisal: "ruby-3-3" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + continue-on-error: true + + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} ${{ matrix.appraisal }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.4.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.4.yml.example new file mode 100644 index 0000000..da856d9 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/ruby-3.4.yml.example @@ -0,0 +1,67 @@ +name: Ruby 3.4 + +permissions: + contents: read + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby 3.4 + - ruby: "ruby-3.4" + appraisal: "ruby-3-4" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + continue-on-error: true + + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} ${{ matrix.appraisal }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/style.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/style.yml.example new file mode 100644 index 0000000..3d9e0a9 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/style.yml.example @@ -0,0 +1,74 @@ +name: Style + +permissions: + contents: read + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + rubocop: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Style on ${{ matrix.ruby }}@current + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + fail-fast: false + matrix: + include: + # Style + - ruby: "ruby" + appraisal: "style" + exec_cmd: "rake rubocop_gradual:check" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Run ${{ matrix.appraisal }} checks via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} + + - name: Validate RBS Types + run: bundle exec appraisal ${{ matrix.appraisal }} bin/rbs validate diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/templating.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/templating.yml.example new file mode 100644 index 0000000..89665ee --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/templating.yml.example @@ -0,0 +1,77 @@ +# Targets the evergreen latest release of ruby +name: Templating + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Latest Ruby + - ruby: "ruby" + appraisal: "templating" + exec_cmd: "rake kettle:jem:selftest" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install tree-sitter library + uses: kettle-rb/ts-grammar-action@4b0c04d11ed5b85c67c0c60c6ecb590e81748ccb # v1.0.1 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Templating self-test for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffle.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffle.yml.example new file mode 100644 index 0000000..a23f09b --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffle.yml.example @@ -0,0 +1,78 @@ +name: Current TruffleRuby + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # NOTE: truffleruby does not support upgrading rubygems. + # truffleruby-33.0 (targets Ruby 3.3 compatibility) + - ruby: "truffleruby" + appraisal: "current" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + if: ${{ !env.ACT }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + if: ${{ !env.ACT }} + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-22.3.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-22.3.yml.example new file mode 100644 index 0000000..8b99204 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-22.3.yml.example @@ -0,0 +1,78 @@ +name: TruffleRuby 22.3 + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # NOTE: truffleruby does not support upgrading rubygems. + # truffleruby-22.3 (targets Ruby 3.0 compatibility) + - ruby: "truffleruby-22.3" + appraisal: "ruby-3-0" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + if: ${{ !env.ACT }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + if: ${{ !env.ACT }} + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-23.0.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-23.0.yml.example new file mode 100644 index 0000000..1c67db3 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-23.0.yml.example @@ -0,0 +1,78 @@ +name: TruffleRuby 23.0 + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # NOTE: truffleruby does not support upgrading rubygems. + # truffleruby-23.0 (targets Ruby 3.0 compatibility) + - ruby: "truffleruby-23.0" + appraisal: "ruby-3-0" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + if: ${{ !env.ACT }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + if: ${{ !env.ACT }} + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-23.1.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-23.1.yml.example new file mode 100644 index 0000000..f1ef9cb --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-23.1.yml.example @@ -0,0 +1,78 @@ +name: TruffleRuby 23.1 + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # NOTE: truffleruby does not support upgrading rubygems. + # truffleruby-23.1 (targets Ruby 3.2 compatibility) + - ruby: "truffleruby-23.1" + appraisal: "ruby-3-2" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + if: ${{ !env.ACT }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + if: ${{ !env.ACT }} + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-24.2.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-24.2.yml.example new file mode 100644 index 0000000..253574d --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-24.2.yml.example @@ -0,0 +1,78 @@ +name: TruffleRuby 24.2 + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # NOTE: truffleruby does not support upgrading rubygems. + # truffleruby-24.2 (targets Ruby 3.3 compatibility) + - ruby: "truffleruby-24.2" + appraisal: "ruby-3-3" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + if: ${{ !env.ACT }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + if: ${{ !env.ACT }} + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-25.0.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-25.0.yml.example new file mode 100644 index 0000000..0417736 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/truffleruby-25.0.yml.example @@ -0,0 +1,78 @@ +name: TruffleRuby 25.0 + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # NOTE: truffleruby does not support upgrading rubygems. + # truffleruby-25.0 (targets Ruby 3.3 compatibility) + - ruby: "truffleruby-25.0" + appraisal: "ruby-3-3" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + if: ${{ !env.ACT }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Ruby & RubyGems + if: ${{ !env.ACT }} + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' && !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + if: ${{ !env.ACT }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/unlocked_deps.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/unlocked_deps.yml.example new file mode 100644 index 0000000..a7bf257 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/unlocked_deps.yml.example @@ -0,0 +1,93 @@ +--- +# Lock/Unlock Deps Pattern +# +# Two often conflicting goals resolved! +# +# - unlocked_deps.yml +# - All runtime & dev dependencies, but does not have a `gemfiles/*.gemfile.lock` committed +# - Uses an Appraisal2 "unlocked_deps" gemfile, and the current MRI Ruby release +# - Know when new dependency releases will break local dev with unlocked dependencies +# - Broken workflow indicates that new releases of dependencies may not work +# +# - locked_deps.yml +# - All runtime & dev dependencies, and has a `Gemfile.lock` committed +# - Uses the project's main Gemfile, and the current MRI Ruby release +# - Matches what contributors and maintainers use locally for development +# - Broken workflow indicates that a new contributor will have a bad time +# +name: Deps Unlocked + +permissions: + contents: read + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + - '*-stable' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + name: Default rake task w/ unlocked deps ${{ matrix.name_extra || '' }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby + - ruby: "ruby" + appraisal: "unlocked_deps" + exec_cmd: "rake" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install tree-sitter library + uses: kettle-rb/ts-grammar-action@4b0c04d11ed5b85c67c0c60c6ecb590e81748ccb # v1.0.1 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: true + + - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt1 + run: bundle exec appraisal ${{ matrix.appraisal }} install + # Continue to the next step on failure + continue-on-error: true + + # Effectively an automatic retry of the previous step. + - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}" + id: bundleAppraisalAttempt2 + # If bundleAppraisalAttempt1 failed, try again here; Otherwise skip. + if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }} + run: bundle exec appraisal ${{ matrix.appraisal }} install + + - name: Run ${{ matrix.exec_cmd }} on ${{ matrix.ruby }}@${{ matrix.appraisal }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.gitignore.example b/gems/kettle-jem/lib/kettle/jem/templates/.gitignore.example new file mode 100755 index 0000000..7f02050 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.gitignore.example @@ -0,0 +1,52 @@ +# Build Artifacts +/pkg/ +/tmp/* +!/tmp/.gitignore +*.gem + +# Bundler +/vendor/bundle/ +/.bundle/ +/gemfiles/*.lock +/gemfiles/.bundle/ +/gemfiles/.bundle/config +/gemfiles/vendor/ +Appraisal.*.gemfile.lock + +# Specs +.rspec_status +/coverage/ +/spec/reports/ +/results/ +.output.txt + +# Documentation +/.yardoc/ +/_yardoc/ +/rdoc/ +/doc/ + +# Ruby Version Managers (RVM, rbenv, etc) +# Ignored because we currently use mise.toml +.rvmrc +.ruby-version +.ruby-gemset +.tool-versions + +# Benchmarking +/measurement/ + +# Debugger detritus +.byebug_history + +# Local environment overrides (KEY=value, loaded by mise via dotenvy) +.env.local + +# OS Detritus +.DS_Store + +# Editors +*~ + +# Sentinels +.floss_funding.*.lock diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.gitlab-ci.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.gitlab-ci.yml.example new file mode 100644 index 0000000..f5e3127 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.gitlab-ci.yml.example @@ -0,0 +1,134 @@ +# You can override the included template(s) by including variable overrides +# SAST customization: https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings +# Secret Detection customization: https://docs.gitlab.com/ee/user/application_security/secret_detection/#customizing-settings +# Dependency Scanning customization: https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings +# Container Scanning customization: https://docs.gitlab.com/ee/user/application_security/container_scanning/#customizing-the-container-scanning-settings +# Note that environment variables can be set in several places +# See https://docs.gitlab.com/ee/ci/variables/#cicd-variable-precedence +#stages: +# - test +#sast: +# stage: test +#include: +# - template: Security/SAST.gitlab-ci.yml + +default: + image: "ruby:${RUBY_VERSION}" + +variables: + BUNDLE_INSTALL_FLAGS: "--quiet --jobs=$(nproc) --retry=3" + BUNDLE_FROZEN: "false" # No lockfile! + BUNDLE_GEMFILE: Appraisal.root.gemfile + K_SOUP_COV_DEBUG: true + K_SOUP_COV_DO: true + K_SOUP_COV_HARD: true + K_SOUP_COV_MIN_BRANCH: 74 + K_SOUP_COV_MIN_LINE: 90 + K_SOUP_COV_VERBOSE: true + K_SOUP_COV_FORMATTERS: "tty" + K_SOUP_COV_MULTI_FORMATTERS: true + K_SOUP_COV_COMMAND_NAME: "RSpec Coverage" + +workflow: + rules: + # For merge requests, create a pipeline. + - if: '$CI_MERGE_REQUEST_IID' + # For the ` main ` branch, create a pipeline (this includes on schedules, pushes, merges, etc.). + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + # For tags, create a pipeline. + - if: '$CI_COMMIT_TAG' + +.test_template-current: &test_definition-current + stage: test + script: + # || true so we don't fail here, because it'll probably work even if the gem update fails + - gem update --silent --system > /dev/null 2>&1 || true + - mkdir -p vendor/bundle + - bundle config set path 'vendor/bundle' + - chmod +t -R vendor/bundle + - chmod o-w -R vendor/bundle + # Setup appraisal2 + - bundle install + # Bundle a specific appraisal + - bundle exec appraisal unlocked_deps bundle install + # Light smoke test + - bundle exec appraisal unlocked_deps bin/rake --tasks + # Run tests, skipping those that won't work in CI + - > + bundle exec appraisal unlocked_deps \ + bin/rspec spec \ + --tag \~ci_skip \ + --format progress \ + --format RspecJunitFormatter + cache: + key: ${CI_JOB_IMAGE} + paths: + - vendor/ruby + +.test_template-legacy: &test_definition-legacy + stage: test + script: + # RUBYGEMS_VERSION because we support EOL Ruby still... + # || true so we don't fail here, because it'll probably work even if the gem update fails + - gem install rubygems-update -v ${RUBYGEMS_VERSION} || true + # Actually updates both RubyGems and Bundler! + - update_rubygems + - mkdir -p vendor/bundle + - bundle config set path 'vendor/bundle' + - chmod +t -R vendor/bundle + - chmod o-w -R vendor/bundle + # Setup appraisal2 + - bundle install + # Bundle a specific appraisal + - bundle exec appraisal ${APPRAISAL} bundle install + # Light smoke test + - bundle exec appraisal ${APPRAISAL} bin/rake --tasks + # Run tests, skipping those that won't work in CI + - > + bundle exec appraisal ${APPRAISAL} \ + bin/rspec spec \ + --tag \~ci_skip \ + --format progress \ + --format RspecJunitFormatter + cache: + key: ${CI_JOB_IMAGE} + paths: + - vendor/ruby + +ruby-current: + variables: + K_SOUP_COV_DO: true + <<: *test_definition-current + parallel: + matrix: + - RUBY_VERSION: ["3.2", "3.3", "3.4"] + +ruby-ruby3_1: + variables: + RUBYGEMS_VERSION: "3.6.9" + APPRAISAL: ruby-3-1 + K_SOUP_COV_DO: false + <<: *test_definition-legacy + parallel: + matrix: + - RUBY_VERSION: ["3.1"] + +ruby-ruby3_0: + variables: + RUBYGEMS_VERSION: "3.5.23" + APPRAISAL: ruby-3-0 + K_SOUP_COV_DO: false + <<: *test_definition-legacy + parallel: + matrix: + - RUBY_VERSION: ["3.0"] + +ruby-ruby2_7: + variables: + RUBYGEMS_VERSION: "3.4.22" + APPRAISAL: ruby-2-7 + K_SOUP_COV_DO: false + <<: *test_definition-legacy + parallel: + matrix: + - RUBY_VERSION: ["2.7"] diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.kettle-jem.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.kettle-jem.yml.example new file mode 100644 index 0000000..71dcbf7 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.kettle-jem.yml.example @@ -0,0 +1,337 @@ +# kettle-jem configuration file +# +# This file is both configuration and field guide for templated gems. +# In most projects this comment block is the first place to look when you +# want to understand what kettle-jem will merge, what it will copy as-is, +# and which values you may want to customize. +# +# High-level structure: +# defaults - default merge behavior for files that use strategy: merge +# tokens - values for {KJ|...} placeholders used across template files +# patterns - glob-based overrides for files/directories +# files - exact per-file overrides using a nested directory tree +# +# Default behavior: +# If a file is NOT mentioned under files: and does NOT match a patterns: +# entry, kettle-jem will merge it using the format-aware merge gem for that +# file type. +# +# Strategies: +# merge: +# Resolve tokens, then merge template + destination using the appropriate +# AST-aware/text-aware merger for the file type. +# accept_template: +# Resolve tokens, then write the template result without merging. +# If the file is missing in the destination, it will be created from the template. +# keep_destination: +# Ignore this file during templating. +# No merge. No replacement. No creation when the destination file is missing. +# Use this when you want to permanently exclude a file from the template. +# raw_copy: +# Copy bytes exactly as they appear in the template. +# No token resolution. No merge. Use for immutable/binary assets such as certs. +# +# Merge options (used only with strategy: merge): +# preference: +# template - template wins when both sides define the same node/value +# destination - destination wins when both sides define the same node/value +# add_template_only_nodes: +# true - add nodes/entries that exist only in the template +# false - do not add template-only nodes +# freeze_token: +# Marker name used for frozen sections (default: kettle-jem) +# file_type: +# Optional explicit merge-engine hint for extensionless or ambiguous files. +# Supported values: ruby, gemfile, appraisals, gemspec, rakefile, +# yaml, markdown, bash, tool_versions, text, json, jsonc, toml, +# dotenv, rbs +# max_recursion_depth: +# Limit for recursive merging when a merger supports it +# +# Other per-file options (apply to any strategy): +# skip_unresolved_scan: +# true - do not scan this file for unresolved {KJ|...} tokens after writing. +# Use when a file legitimately contains {KJ|...} text as documentation +# or example content rather than as a template placeholder. +# +# Token values: +# Precedence is: +# 1) ENV variables +# 2) tokens: values in this file +# 3) safe automatic derivation (author fields only, where possible) +# +# Important merge note for tokens in THIS file: +# Existing destination values are respected. +# That means: +# - a real value you already set here stays in place on future runs +# - an empty string also counts as an explicit destination value +# If you want kettle-jem to re-seed a missing value on a later run, +# delete that key instead of leaving it blank. +# +# Safe automatic derivations currently implemented: +# AUTHOR:NAME <- first author from the gemspec +# AUTHOR:EMAIL <- first email from the gemspec +# AUTHOR:DOMAIN <- domain part of AUTHOR:EMAIL +# AUTHOR:GIVEN_NAMES <- AUTHOR:NAME minus the last word +# AUTHOR:FAMILY_NAMES <- last word of AUTHOR:NAME +# +# Everything else should be set explicitly here or via ENV. + +# This project's identifying emoji. +# Used as the leading emoji in the README H1 heading and in the gemspec +# summary and description. Every gem should have its own unique emoji. +# +# Set this to a single emoji that represents your gem. Examples: 🪙 🔑 🛠️ 🔮 +# This key is REQUIRED. kettle-jem will abort if it is absent. +# ENV override: KJ_PROJECT_EMOJI +project_emoji: "" + +# Engines this project supports. +# List the Ruby engines that your gem targets. kettle-jem uses this to decide +# which CI workflows, README compatibility rows, and badge references to +# include. Engines not listed here are pruned from generated files. +# +# Supported values: ruby, jruby, truffleruby +# Default (when key is absent): all three engines. +engines: + - ruby + - jruby + - truffleruby + +# Framework matrix workflows. +# When your gem tests against multiple versions of a framework (Rails, +# ActiveRecord, ActiveSupport, etc.), this section configures how +# kettle-jem generates the CI matrix strategy and gemfile references. +# +# preset: controls overall workflow strategy +# modern — Ruby-only matrix (default when absent) +# framework — Ruby × framework-version matrix +# legacy-compat — supported / unsupported / ancient workflow split +# +# framework_matrix (only used when preset: framework): +# dimension — framework name used in the matrix key and display +# (e.g. activerecord, rails, actionmailer) +# versions — ordered list of versions to test against +# gemfile_pattern — pattern for gemfile paths; {version} is replaced +# with each version string (dots replaced by underscores +# when the pattern contains an underscore placeholder). +# Resolved relative to gemfiles/ directory. +# Examples: +# "ar-{version}.x" → gemfiles/ar-7.0.x +# "rails_{version}" → gemfiles/rails_7_0.gemfile +# Use this when you need a simple 2D matrix: Ruby × one framework gem/version axis. +# For deeper or more complex matrices that are better modeled as Appraisals entries, +# prefer kettle-jem-appraisals instead. +# +# workflows: +# preset: framework +# framework_matrix: +# dimension: activerecord +# versions: +# - "7.0" +# - "7.1" +# - "7.2" +# - "8.0" +# gemfile_pattern: "ar-{version}.x" + +# README top logo mode. +# Controls whether the generated README header includes the GitHub org logo, +# the project logo, or both after the shared Galtzo and ruby-lang logos. +# Supported values: org, project, org_and_project +# Default (when key is absent): org_and_project +readme: + top_logo_mode: org + # Sections to preserve from the destination README during template merging. + # These section headings (case-insensitive) will keep the destination body + # instead of being overwritten by the template. Default when absent: + # - synopsis + # - configuration + # - basic usage + # + # Built-in aliases (destination → template): + # summary → synopsis, usage → basic usage, + # configuration options → configuration, setup → basic usage + # + # preserve_sections: + # - synopsis + # - configuration + # - basic usage + # - usage + # - summary + # + # Glob patterns for additional sections to preserve (e.g. Note:* headings). + # Default when absent: ["note:*"] + # + # preserve_patterns: + # - "note:*" + # - "setup*" + # + # Custom section aliases (destination heading → canonical template heading). + # Merged with built-in aliases above. + # + # section_aliases: + # howto: basic usage + # options: configuration + +# SPDX license identifiers for this project. +# List one or more licenses that apply to the gem. Users may use the software +# under the terms of any one license listed here (OR semantics, not AND). +# +# Accepted values (initial supported set): +# MIT - MIT License (open source, permissive) +# AGPL-3.0-only - GNU Affero General Public License v3 +# PolyForm-Noncommercial-1.0.0 - PolyForm Noncommercial License 1.0.0 +# PolyForm-Small-Business-1.0.0 - PolyForm Small Business License 1.0.0 +# LicenseRef-Big-Time-Public-License - Big Time Public License 2.0.2 +# +# Defaults to spec.licenses from the gemspec when this key is absent. +# +# Template behaviour: +# - Each listed license is copied as .md into the project. +# - A LICENSE.md index file is generated referencing all chosen licenses. +# - Any non-MIT license adds a "contact for commercial license" prompt. +# - When MIT (or AGPL), a PolyForm variant, AND LicenseRef-Big-Time-Public-License +# are all present, a use-case guide table is added to README and LICENSE.md. +licenses: + - MIT + +# Machine / automation accounts to exclude from generated copyright output. +# +# When kettle-jem collects copyright information via `git blame`, it filters +# out known GitHub/GitLab bot accounts automatically. Use this list to also +# exclude internal automation accounts that are not bots in the git sense +# (i.e. they do not follow the "[bot]" naming convention). +# +# Each entry is matched case-insensitively against the commit author name AND +# the commit author email, so you can list either form. +# +# Default (when key is absent or empty): ["autobolt"] +machine_users: + - autobolt + +# Self-test / templating CI threshold. +# Set to a number from 0 to 100 to fail `rake kettle:jem:selftest` once +# divergence exceeds that percent. Leave blank to report only. +# Divergence is currently measured as the percent of produced files that would +# change or be added during templating (100 - unchanged-file score). +# ENV override: KJ_MIN_DIVERGENCE_THRESHOLD +min_divergence_threshold: {KJ|MIN_DIVERGENCE_THRESHOLD} + +# Optional kettle-jem plugins. +# Each entry is a gem name. During bootstrap, kettle-jem ensures each plugin is +# added as a development dependency so it is available after bin/setup. +# During bundled templating, kettle-jem requires the plugin and calls +# `register_kettle_jem_plugin(registrar)` on its top-level handle. +# +# Phase callback names currently available: +# - config_sync +# - dev_container +# - github_workflows +# - quality_config +# - modular_gemfiles +# - spec_helper +# - environment_templates +# - remaining_files +# - git_hooks +# - license_files +# - duplicate_check +# +# Phase guarantees: +# - All plugin callbacks run during the bundled templating phase, after +# bin/setup and bundle install have completed. +# - kettle-jem, kettle-dev, and any plugin gems listed here are loadable. +# - kettle-jem's parser / merger runtime is available to callbacks. +# - `remaining_files` runs after the destination Rakefile, gemspec, README, +# and most template-managed files already exist on disk. +# - `duplicate_check` runs last, after all template writes are complete. +plugins: + - kettle-drift + +# Default merge options +defaults: + preference: "template" + add_template_only_nodes: true + freeze_token: "kettle-jem" + +# Token replacement values. +# +# General rules: +# - Empty strings are treated as unset. +# - Use the bare identifier/slug/handle expected by the inline comment. +# - Do NOT paste full URLs unless the comment explicitly says to. +# +# Tip: +# The author fields in a newly created destination config are normally seeded +# from the gemspec via safe derivation. After that, destination values win. +tokens: + forge: + gh_user: "pboling" # GitHub username only, no @, no URL. Used for GitHub Sponsors and profile links. ENV: KJ_GH_USER + gl_user: "pboling" # GitLab username only, no @, no URL. Used for profile links. ENV: KJ_GL_USER + cb_user: "pboling" # Codeberg username only, no @, no URL. Used for profile links. ENV: KJ_CB_USER + sh_user: "galtzo" # SourceHut username only, no leading ~, no URL. Used as https://sr.ht/~/. ENV: KJ_SH_USER + + author: + name: "Peter H. Boling" # Full display name. Example: Ada Lovelace. ENV: KJ_AUTHOR_NAME. Auto-seeded from gemspec authors.first + given_names: "Peter H." # Given/personal names only. Example: Ada. ENV: KJ_AUTHOR_GIVEN_NAMES. Auto-seeded when AUTHOR:NAME can be split + family_names: "Boling" # Family/surname only. Example: Lovelace. ENV: KJ_AUTHOR_FAMILY_NAMES. Auto-seeded when AUTHOR:NAME can be split + email: "floss@galtzo.com" # Primary public email address. Example: floss@galtzo.com. ENV: KJ_AUTHOR_EMAIL. Auto-seeded from gemspec email.first + domain: "galtzo.com" # Bare domain only, no scheme, no email. Example: galtzo.com. ENV: KJ_AUTHOR_DOMAIN. Auto-seeded from AUTHOR:EMAIL + orcid: "0009-0008-8519-441X" # ORCID identifier only, not the full URL. Example: 0000-0001-2345-6789. ENV: KJ_AUTHOR_ORCID + + funding: + patreon: "galtzo" # Patreon account slug only. Used as https://patreon.com/. ENV: KJ_FUNDING_PATREON + kofi: "pboling" # Ko-fi handle/slug only. Used as https://ko-fi.com/. ENV: KJ_FUNDING_KOFI + paypal: "peterboling" # PayPal.Me slug only. Used as https://www.paypal.com/paypalme/. ENV: KJ_FUNDING_PAYPAL + buymeacoffee: "pboling" # Buy Me a Coffee slug only. Used as https://www.buymeacoffee.com/. ENV: KJ_FUNDING_BUYMEACOFFEE + polar: "pboling" # Polar handle/slug only. Used as https://polar.sh/. ENV: KJ_FUNDING_POLAR + liberapay: "pboling" # Liberapay account slug only. Used as https://liberapay.com//donate. ENV: KJ_FUNDING_LIBERAPAY + issuehunt: "pboling" # IssueHunt identifier/handle only, not a URL. ENV: KJ_FUNDING_ISSUEHUNT + + social: + mastodon: "galtzo" # Local handle only for the instance assumed by the template link. Current template uses https://ruby.social/@. ENV: KJ_SOCIAL_MASTODON + bluesky: "galtzo.com" # Full Bluesky handle. Example: peterboling.dev or alice.bsky.social. Used as https://bsky.app/profile/. ENV: KJ_SOCIAL_BLUESKY + linktree: "galtzo" # Linktree username only. Used as https://linktr.ee/. ENV: KJ_SOCIAL_LINKTREE + devto: "galtzo" # DEV Community username only. Used as https://dev.to/. ENV: KJ_SOCIAL_DEVTO + +# Glob patterns evaluated in order (first match wins) +patterns: + - path: "certs/**" + strategy: raw_copy + +# Per-file configuration (nested directory structure) +# Only files that need overrides belong here. Everything else defaults to merge. +files: + AGENTS.md: + strategy: accept_template + .github: + copilot_instructions.md: + strategy: accept_template + gemfiles: + modular: + templating.gemfile: + strategy: accept_template + templating_local.gemfile: + strategy: accept_template + +# To override specific files, add entries like: +# +# files: +# README.md: +# strategy: accept_template +# +# SECURITY.md: +# strategy: keep_destination +# +# certs: +# pboling.pem: +# strategy: raw_copy +# +# .git-hooks: +# commit-msg: +# strategy: merge +# file_type: ruby +# +# Rakefile: +# strategy: merge +# preference: destination +# add_template_only_nodes: false diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.licenserc.yaml.example b/gems/kettle-jem/lib/kettle/jem/templates/.licenserc.yaml.example new file mode 100644 index 0000000..c169084 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.licenserc.yaml.example @@ -0,0 +1,7 @@ +header: + license: + spdx-id: "{KJ|LICENSE:PRIMARY_SPDX}" + +dependency: + files: + - Gemfile.lock diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.opencollective.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.opencollective.yml.example new file mode 100644 index 0000000..7122583 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.opencollective.yml.example @@ -0,0 +1,3 @@ +collective: "{KJ|OPENCOLLECTIVE_ORG}" +readme-backers-commit-subject: "💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜" +readme-osc-tag: "OPENCOLLECTIVE" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.qlty/qlty.toml.example b/gems/kettle-jem/lib/kettle/jem/templates/.qlty/qlty.toml.example new file mode 100644 index 0000000..9ae9cae --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.qlty/qlty.toml.example @@ -0,0 +1,79 @@ +# For a guide to configuration, visit https://qlty.sh/d/config +# Or for a full reference, visit https://qlty.sh/d/qlty-toml +config_version = "0" + +exclude_patterns = [ + "*_min.*", + "*-min.*", + "*.min.*", + "**/.yarn/**", + "**/*.d.ts", + "**/assets/**", + "**/bin/**", + "**/bower_components/**", + "**/build/**", + "**/cache/**", + "**/config/**", + "**/.devcontainer", + "**/db/**", + "**/deps/**", + "**/dist/**", + "**/doc/**", + "**/docs/**", + "**/extern/**", + "**/external/**", + "**/generated/**", + "**/Godeps/**", + "**/gradlew/**", + "**/mvnw/**", + "**/node_modules/**", + "**/protos/**", + "**/seed/**", + "**/target/**", + "**/templates/**", + "**/testdata/**", + "**/vendor/**", + ".github/workflows/codeql-analysis.yml" +] + +test_patterns = [ + "**/test/**", + "**/spec/**", + "**/*.test.*", + "**/*.spec.*", + "**/*_test.*", + "**/*_spec.*", + "**/test_*.*", + "**/spec_*.*", +] + +[smells] +mode = "comment" + +[smells.boolean_logic] +threshold = 4 +enabled = true + +[smells.file_complexity] +threshold = 55 +enabled = false + +[smells.return_statements] +threshold = 4 +enabled = true + +[smells.nested_control_flow] +threshold = 4 +enabled = true + +[smells.function_parameters] +threshold = 4 +enabled = true + +[smells.function_complexity] +threshold = 5 +enabled = true + +[smells.duplication] +enabled = true +threshold = 20 \ No newline at end of file diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.rspec.example b/gems/kettle-jem/lib/kettle/jem/templates/.rspec.example new file mode 100644 index 0000000..a43744c --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.rspec.example @@ -0,0 +1,9 @@ +--format progress +--color +--order random +--require spec_helper +--warnings +--format html +--out results/test_results.html +--format RspecJunitFormatter +--out results/test_results.xml diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.rubocop.example b/gems/kettle-jem/lib/kettle/jem/templates/.rubocop.example new file mode 100644 index 0000000..00dd72e --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.rubocop.example @@ -0,0 +1 @@ +--config .rubocop.yml diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.rubocop.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.rubocop.yml.example new file mode 100644 index 0000000..4452c70 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.rubocop.yml.example @@ -0,0 +1,35 @@ +inherit_gem: + rubocop-lts: config/rubygem_rspec.yml + +inherit_from: + - .rubocop_rspec.yml + +plugins: rubocop-on-rbs + +# This tells RuboCop to add the exclusions in this file +# to the default exclusions (and those from other inherited files), +# rather than overwriting them. +inherit_mode: + merge: + - Exclude + +AllCops: + Exclude: + - certs/**/* + - checksums/**/* + - docs/**/* + - examples/**/* + - fixtures/**/* + - gemfiles/*.gemfile + - pkg/**/* + - results/**/* + - spec/fixtures/**/* + - tmp/**/* + - vendor/**/* + - AGENTS.md + +RBS: + Enabled: true + +Layout/IndentationConsistency: + Exclude: ['*.md'] diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.rubocop_rspec.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.rubocop_rspec.yml.example new file mode 100644 index 0000000..fd9bd5c --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.rubocop_rspec.yml.example @@ -0,0 +1,55 @@ +RSpec/MultipleExpectations: + Enabled: false + +RSpec/NamedSubject: + Enabled: false + +RSpec/ExampleLength: + Enabled: false + +RSpec/VerifiedDoubles: + Enabled: false + +RSpec/MessageSpies: + Enabled: false + +RSpec/InstanceVariable: + Enabled: false + +RSpec/NestedGroups: + Enabled: false + +RSpec/ExpectInHook: + Enabled: false + +RSpec/DescribeClass: + Exclude: + - 'spec/examples/*' + - 'spec/integration/*' + - 'spec/system/*' + - 'spec/e2e/*' + +RSpec/MultipleMemoizedHelpers: + Enabled: false + +RSpec/SpecFilePathSuffix: + Exclude: + - 'spec/README.md' + +RSpec/NoExpectationExample: + Exclude: + - 'spec/README.md' + +RSpec/MultipleDescribes: + Exclude: + - 'spec/README.md' + +RSpec/RepeatedExampleGroupDescription: + Exclude: + - 'spec/README.md' + +RSpec/Output: + Exclude: + - 'spec/support/**/*' + - 'spec/config/**/*' + - 'spec/fixtures/**/*' diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.simplecov.example b/gems/kettle-jem/lib/kettle/jem/templates/.simplecov.example new file mode 100644 index 0000000..01d4920 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.simplecov.example @@ -0,0 +1,18 @@ +# {KJ|FREEZE_TOKEN}:freeze +# To retain chunks of comments & code during {KJ|GEM_NAME} templating: +# Wrap custom sections with freeze markers (e.g., as above and below this comment chunk). +# {KJ|GEM_NAME} will then preserve content between those markers across template runs. +# {KJ|FREEZE_TOKEN}:unfreeze + +require "kettle/soup/cover/config" + +# Minimum coverage thresholds are set by kettle-soup-cover. +# They are controlled by ENV variables loaded by `mise` from `mise.toml` +# (with optional machine-local overrides in `.env.local`). +# If the values for minimum coverage need to change, they should be changed both there, +# and in 2 places in .github/workflows/coverage.yml. +SimpleCov.start do + track_files "lib/**/*.rb" + track_files "lib/**/*.rake" + track_files "exe/*.rb" +end diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.yardignore.example b/gems/kettle-jem/lib/kettle/jem/templates/.yardignore.example new file mode 100644 index 0000000..ade54b7 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.yardignore.example @@ -0,0 +1,13 @@ +# Ignore built gem artifacts and package dir +bin/**/* +certs/**/* +checksums/**/* +gemfiles/**/* +pkg/**/* +spec/**/* +spec*/**/* +test/**/* +tmp/**/* +*.gem +# Also ignore yardoc cache +.yardoc/ diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.yardopts.example b/gems/kettle-jem/lib/kettle/jem/templates/.yardopts.example new file mode 100644 index 0000000..aca5ddc --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/.yardopts.example @@ -0,0 +1,15 @@ +--plugin timekeeper +--plugin fence +-e yard/fence/hoist.rb +--plugin yaml +--plugin junk +--plugin relative_markdown_links +--readme tmp/yard-fence/README.md +--charset utf-8 +--markup markdown +--markup-provider kramdown +--output docs +'lib/**/*.rb' +- +'tmp/yard-fence/*.md' +'tmp/yard-fence/*.txt' diff --git a/gems/kettle-jem/lib/kettle/jem/templates/AGENTS.md.example b/gems/kettle-jem/lib/kettle/jem/templates/AGENTS.md.example new file mode 100644 index 0000000..1cd6e1b --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/AGENTS.md.example @@ -0,0 +1,276 @@ +# AGENTS.md - Development Guide + +## 🎯 Project Overview + +This project is a **RubyGem** managed with the [kettle-rb](https://github.com/kettle-rb) toolchain. + +**Minimum Supported Ruby**: See the gemspec `required_ruby_version` constraint. +**Local Development Ruby**: See `mise.toml` for the version used in local development (typically the latest stable Ruby). + +### Modular Gemfile Architecture + +Gemfiles are split into modular components under `gemfiles/modular/`. Each component handles a specific concern (coverage, style, debug, etc.). The main `Gemfile` loads these modular components via `eval_gemfile`. +Gemfiles in the project, including modular ones, can utilize a `*_local.gemfile` counterpart pattern enabled via an ENV flag. This uses `nomono` to load sibling gems in the same workspace. + +## ⚠️ AI Agent Terminal Limitations + +### Use `mise` for Project Environment + +**CRITICAL**: The canonical project environment lives in `mise.toml`, with local overrides in `.env.local` loaded via `dotenvy`. + +⚠️ **Watch for trust prompts**: After editing `mise.toml` or `.env.local`, `mise` may require trust to be refreshed before commands can load the project environment. Until that trust step is handled, commands can appear hung or produce no output, which can look like terminal access is broken. + +**Recovery rule**: If a `mise exec` command goes silent or appears hung, assume `mise trust` is the first thing to check. Recover by running: + +```bash +mise trust -C /path/to/project +mise exec -C /path/to/project -- bundle exec kettle-test +``` + +Do this before spending time on unrelated debugging; in this workspace pattern, silent `mise` commands are usually a trust problem first. + +✅ **CORRECT** — Run self-contained commands with `mise exec`: + +```bash +mise exec -C /path/to/project -- bundle exec kettle-test +``` + +✅ **CORRECT** — If you need shell syntax first, load the environment in the same command: + +```bash +eval "$(mise env -C /path/to/project -s bash)" && bundle exec kettle-test +``` + +❌ **WRONG** — Do not rely on a previous command changing directories: + +```bash +cd /path/to/project +bundle exec rspec +``` + +❌ **WRONG** — A chained `cd` does not give directory-change hooks time to update the environment: + +```bash +cd /path/to/project && bundle exec rspec +``` + +### Prefer Internal Tools Over Terminal + +✅ **PREFERRED** — Use internal tools: + +- `grep_search` instead of `grep` command +- `file_search` instead of `find` command +- `read_file` instead of `cat` command +- `list_dir` instead of `ls` command +- `replace_string_in_file` or `create_file` instead of `sed` / manual editing + +❌ **AVOID** when possible: + +- `run_in_terminal` for information gathering + +Only use terminal for: + +- Running tests (`bundle exec kettle-test`) +- Installing dependencies (`bundle install`) +- Simple commands that do not require much shell escaping +- Running scripts (prefer writing a script over a complicated command with shell escaping) + +When you do run tests, keep the full output visible so you can inspect failures completely. + +## 🏗️ Architecture + +### Toolchain Dependencies + +This gem is part of the **kettle-rb** ecosystem. Key development tools: + +| Tool | Purpose | +|------|---------| +| `kettle-dev` | Development dependency: Rake tasks, release tooling, CI helpers | +| `kettle-test` | Test infrastructure: RSpec helpers, stubbed_env, timecop | +| `kettle-jem` | Template management and gem scaffolding | + +### Executables (from kettle-dev) + +| Executable | Purpose | +|-----------|---------| +| `kettle-release` | Full gem release workflow | +| `kettle-pre-release` | Pre-release validation | +| `kettle-changelog` | Changelog generation | +| `kettle-dvcs` | DVCS (git) workflow automation | +| `kettle-commit-msg` | Commit message validation | +| `kettle-check-eof` | EOF newline validation | + +## 📁 Project Structure + +``` +lib/ +├── / # Main library code +│ └── version.rb # Version constant (managed by kettle-release) +spec/ +├── fixtures/ # Test fixture files (NOT auto-loaded) +├── support/ +│ ├── classes/ # Helper classes for specs +│ └── shared_contexts/ # Shared RSpec contexts +├── spec_helper.rb # RSpec configuration (loaded by .rspec) +gemfiles/ +├── modular/ # Modular Gemfile components +│ ├── coverage.gemfile # SimpleCov dependencies +│ ├── debug.gemfile # Debugging tools +│ ├── documentation.gemfile # YARD/documentation +│ ├── optional.gemfile # Optional dependencies +│ ├── rspec.gemfile # RSpec testing +│ ├── style.gemfile # RuboCop/linting +│ └── x_std_libs.gemfile # Extracted stdlib gems +├── ruby_*.gemfile # Per-Ruby-version Appraisal Gemfiles +└── Appraisal.root.gemfile # Root Gemfile for Appraisal builds +.git-hooks/ +├── commit-msg # Commit message validation hook +├── prepare-commit-msg # Commit message preparation +├── commit-subjects-goalie.txt # Commit subject prefix filters +└── footer-template.erb.txt # Commit footer ERB template +``` + +## 🔧 Development Workflows + +### Running Commands + +Always make commands self-contained. Use `mise exec -C /home/pboling/src/kettle-rb/prism-merge -- ...` so the command gets the project environment in the same invocation. +If the command is complicated write a script in local tmp/ and then run the script. + +### Running Tests + +**Always run specs via `kettle-test`** (provided by `kettle-test`). It runs `bundle exec rspec`, +captures all output to `tmp/kettle-test/rspec-TIMESTAMP.log`, and prints a structured highlight block: +timing, seed, pass/fail count, failing examples, and SimpleCov coverage percentages. + +Full suite: + +```bash +mise exec -C /path/to/project -- bundle exec kettle-test +``` + +For single file, targeted, or partial spec runs the coverage threshold **must** be disabled. +Use the `K_SOUP_COV_MIN_HARD=false` environment variable to disable hard failure: + +```bash +mise exec -C /path/to/project -- env K_SOUP_COV_MIN_HARD=false bundle exec kettle-test spec/path/to/spec.rb +``` + +### Template Management (kettle-jem) + +Run the kettle-jem templater to sync project files with the latest template: + +```bash +# Standard run (quiet, non-interactive — the default) +mise exec -C /path/to/project -- bundle exec rake kettle:jem:install + +# Verbose output (see per-file detail) +mise exec -C /path/to/project -- env KETTLE_JEM_VERBOSE=true bundle exec rake kettle:jem:install + +# Interactive mode (prompt before each change) +mise exec -C /path/to/project -- bundle exec rake kettle:jem:install force=false +``` + +**Current defaults** (no flags needed): +- **quiet=true** — only phase summary lines shown; use `--verbose` or `KETTLE_JEM_VERBOSE=true` to opt out +- **force=true** — non-interactive; use `--interactive` or `force=false` to opt out +- **allowed=true** — env file changes auto-accepted; set `allowed=false` to require review + +### Building & Installing Locally + +To test local code changes across sibling repos, rebuild and reinstall the gem: + +```bash +cd /path/to/gem && rm -rf *.gem && SKIP_GEM_SIGNING=true gem build *.gemspec && gem install --force *.gem +``` + +- `SKIP_GEM_SIGNING=true` bypasses the PEM passphrase prompt for signed gemspecs. +- `--force` overwrites the currently installed version. +- Always rebuild **and** reinstall before verifying cross-repo behaviour. + +### Coverage Reports + +```bash +mise exec -C /path/to/project -- bin/rake coverage +mise exec -C /path/to/project -- bin/kettle-soup-cover -d +``` + +**Key ENV variables** (set in `mise.toml`, with local overrides in `.env.local`): +- `K_SOUP_COV_DO=true` – Enable coverage +- `K_SOUP_COV_MIN_LINE` – Line coverage threshold +- `K_SOUP_COV_MIN_BRANCH` – Branch coverage threshold +- `K_SOUP_COV_MIN_HARD=true` – Fail if thresholds not met + +### Code Quality + +```bash +mise exec -C /path/to/project -- bundle exec rake reek +mise exec -C /path/to/project -- bundle exec rubocop-gradual +``` + +### Releasing + +```bash +bin/kettle-pre-release # Validate everything before release +bin/kettle-release # Full release workflow +``` + +## 📝 Project Conventions + +### Freeze Block Preservation + +Template updates preserve custom code wrapped in freeze blocks: + +```ruby +# kettle-jem:freeze +# ... custom code preserved across template runs ... +# kettle-jem:unfreeze +``` + +### Modular Gemfile Architecture + +Gemfiles are split into modular components under `gemfiles/modular/`. Each component handles a specific concern (coverage, style, debug, etc.). The main `Gemfile` loads these modular components via `eval_gemfile`. + +### Forward Compatibility with `**options` + +**CRITICAL**: All constructors and public API methods that accept keyword arguments MUST include `**options` as the final parameter for forward compatibility. + +## 🧪 Testing Patterns + +### Test Infrastructure + +- Uses `kettle-test` for RSpec helpers (stubbed_env, block_is_expected, silent_stream, timecop) +- Uses `Dir.mktmpdir` for isolated filesystem tests +- Spec helper is loaded by `.rspec` — never add `require "spec_helper"` to spec files + +### Environment Variable Helpers + +```ruby +before do + stub_env("MY_ENV_VAR" => "value") +end + +before do + hide_env("HOME", "USER") +end +``` + +### Dependency Tags + +Use dependency tags to conditionally skip tests when optional dependencies are not available: + +```ruby +RSpec.describe SomeClass, :prism_merge do + # Skipped if prism-merge is not available +end +``` + +## 🚫 Common Pitfalls + +1. **NEVER pipe test output through `head`/`tail`** — Run tests without truncation so you can inspect the full output. +2. **README.md is mostly auto-generated by kettle-jem** — Only the following sections may be edited by hand or by agents: + - `## 🌻 Synopsis` + - `## ⚙️ Configuration` + - `## 🔧 Basic Usage` + + All other sections (badges, installation, FLOSS funding, security, contributing, versioning, license, etc.) are managed by the kettle-jem template and will be overwritten on the next templating run. Do not edit them. diff --git a/gems/kettle-jem/lib/kettle/jem/templates/AGPL-3.0-only.md.example b/gems/kettle-jem/lib/kettle/jem/templates/AGPL-3.0-only.md.example new file mode 100644 index 0000000..0c97efd --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/AGPL-3.0-only.md.example @@ -0,0 +1,235 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . diff --git a/gems/kettle-jem/lib/kettle/jem/templates/Appraisal.root.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/Appraisal.root.gemfile.example new file mode 100755 index 0000000..359db51 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/Appraisal.root.gemfile.example @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# kettle-jem:freeze +# To retain chunks of comments & code during kettle-jem templating: +# Wrap custom sections with freeze markers (e.g., as above and below this comment chunk). +# kettle-jem will then preserve content between those markers across template runs. +# kettle-jem:unfreeze + +source "https://gem.coop" + +# Appraisal Root Gemfile is for running appraisal to generate the Appraisal Gemfiles +# in gemfiles/*gemfile. +# On CI, we use it for the Appraisal-based builds. +# We do not load the standard Gemfile, as it is tailored for local development. + +gemspec diff --git a/gems/kettle-jem/lib/kettle/jem/templates/Appraisals.example b/gems/kettle-jem/lib/kettle/jem/templates/Appraisals.example new file mode 100644 index 0000000..bd2485c --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/Appraisals.example @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +# {KJ|FREEZE_TOKEN}:freeze +# To retain chunks of comments & code during {KJ|GEM_NAME} templating: +# Wrap custom sections with freeze markers (e.g., as above and below this comment chunk). +# {KJ|GEM_NAME} will then preserve content between those markers across template runs. +# {KJ|FREEZE_TOKEN}:unfreeze + +# HOW TO UPDATE APPRAISALS (will run rubocop_gradual's autocorrect afterward): +# bin/rake appraisal:update + +# Lock/Unlock Deps Pattern +# +# Two often conflicting goals resolved! +# +# - unlocked_deps.yml +# - All runtime & dev dependencies, but does not have a `gemfiles/*.gemfile.lock` committed +# - Uses an Appraisal2 "unlocked_deps" gemfile, and the current MRI Ruby release +# - Know when new dependency releases will break local dev with unlocked dependencies +# - Broken workflow indicates that new releases of dependencies may not work +# +# - locked_deps.yml +# - All runtime & dev dependencies, and has a `Gemfile.lock` committed +# - Uses the project's main Gemfile, and the current MRI Ruby release +# - Matches what contributors and maintainers use locally for development +# - Broken workflow indicates that a new contributor will have a bad time +# +appraise "unlocked_deps" do + # Seems to be an undeclared dependency of yard. + # /opt/hostedtoolcache/Ruby/4.0.0/x64/lib/ruby/gems/4.0.0/gems/yard-0.9.38/lib/yard/parser/ruby/legacy/irb/slex.rb:13: warning: irb/notifier is found in irb, which is not part of the default gems since Ruby 4.0.0. + # You can add irb to your Gemfile or gemspec to fix this error. + # rake aborted! + # LoadError: cannot load such file -- irb/notifier (LoadError) + # /opt/hostedtoolcache/Ruby/4.0.0/x64/bin/bundle:25:in '
' + # But it won't install on TruffleRuby, so it can't be part of modular gemfiles used there: + # An error occurred while installing psych (5.3.1), and Bundler cannot continue. + # + # In ruby_3_2.gemfile: + # irb was resolved to 1.16.0, which depends on + # rdoc was resolved to 7.0.3, which depends on + # psych + gem "irb", "~> 1.17" # ruby >= 2.7 + + eval_gemfile "modular/coverage.gemfile" + eval_gemfile "modular/documentation.gemfile" + eval_gemfile "modular/optional.gemfile" + eval_gemfile "modular/style.gemfile" + eval_gemfile "modular/x_std_libs.gemfile" +end + +# Used for head (nightly) releases of ruby, truffleruby, and jruby. +# Split into discrete appraisals if one of them needs a dependency locked discretely. +appraise "head" do + # Why is gem "cgi" here? See: https://github.com/vcr/vcr/issues/1057 + # gem "cgi", ">= 0.5" + eval_gemfile "modular/x_std_libs.gemfile" +end + +# Used for current releases of ruby, truffleruby, and jruby. +# Split into discrete appraisals if one of them needs a dependency locked discretely. +appraise "current" do + eval_gemfile "modular/x_std_libs.gemfile" +end + +# Test current Rubies against head versions of runtime dependencies +appraise "dep-heads" do + eval_gemfile "modular/runtime_heads.gemfile" +end + +appraise "ruby-2-3" do + eval_gemfile "modular/x_std_libs/r2.3/libs.gemfile" +end + +appraise "ruby-2-4" do + eval_gemfile "modular/x_std_libs/r2.4/libs.gemfile" +end + +appraise "ruby-2-5" do + eval_gemfile "modular/x_std_libs/r2.6/libs.gemfile" +end + +appraise "ruby-2-6" do + eval_gemfile "modular/x_std_libs/r2.6/libs.gemfile" +end + +appraise "ruby-2-7" do + eval_gemfile "modular/x_std_libs/r2/libs.gemfile" +end + +appraise "ruby-3-0" do + eval_gemfile "modular/x_std_libs/r3.1/libs.gemfile" +end + +appraise "ruby-3-1" do + eval_gemfile "modular/x_std_libs/r3.1/libs.gemfile" +end + +appraise "ruby-3-2" do + eval_gemfile "modular/x_std_libs/r3/libs.gemfile" +end + +appraise "ruby-3-3" do + eval_gemfile "modular/x_std_libs/r3/libs.gemfile" +end + +appraise "ruby-3-4" do + eval_gemfile "modular/x_std_libs/r3/libs.gemfile" +end + +# Only run security audit on the latest version of Ruby +appraise "audit" do + eval_gemfile "modular/x_std_libs.gemfile" +end + +# Only run coverage on the latest version of Ruby +appraise "coverage" do + eval_gemfile "modular/coverage.gemfile" + eval_gemfile "modular/optional.gemfile" + eval_gemfile "modular/x_std_libs.gemfile" +end + +# Only run linter on the latest version of Ruby (but, in support of oldest supported Ruby version) +appraise "style" do + eval_gemfile "modular/style.gemfile" + eval_gemfile "modular/x_std_libs.gemfile" +end + +appraise "templating" do + eval_gemfile "modular/templating.gemfile" + eval_gemfile "modular/x_std_libs.gemfile" +end diff --git a/gems/kettle-jem/lib/kettle/jem/templates/Big-Time-Public-License.md.example b/gems/kettle-jem/lib/kettle/jem/templates/Big-Time-Public-License.md.example new file mode 100644 index 0000000..8dba3d8 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/Big-Time-Public-License.md.example @@ -0,0 +1,99 @@ +# Big Time Public License + +Version 2.0.2 + + + +## Purpose + +These terms let you use and share this software for noncommercial purposes and in small business for free, while also guaranteeing that paid licenses for big businesses will be available on fair, reasonable, and nondiscriminatory terms. + +## Acceptance + +In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses. + +## Noncommercial Purposes + +You may use the software for any noncommercial purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, count as use for noncommercial purposes. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution counts as use for noncommercial purposes, regardless of the source of funding or obligations resulting from the funding. + +## Small Business + +You may use the software for the benefit of your company if it meets all these criteria: + +1. had fewer than 20 total individuals working as employees and independent contractors at all times during the last tax year + +2. earned less than $1,000,000 total revenue in the last tax year + +3. received less than $1,000,000 total debt, equity, and other investment in the last five tax years, counting investment in predecessor companies that reorganized into, merged with, or spun out your company + +All dollar figures are United States dollars as of 2019. Adjust for them inflation according to the United States Bureau of Labor Statistics' consumer price index for all urban consumers, United States city average, for all items, not seasonally adjusted, with 1982–1984=100 reference base. + +## Big Business + +You may use the software for the benefit of your company: + +1. for 128 days after your company stops qualifying under [Small Business](#small-business) + +2. indefinitely, if the licensor or their legal successor does not offer fair, reasonable, and nondiscriminatory terms for a commercial license for the software within 32 days of [written request](#how-to-request) and negotiate in good faith to conclude a deal + +## How to Request + +If this software includes an address for the licensor or an agent of the licensor in a standard place, such as in documentation, software package metadata, or an "about" page or screen, try to request a fair commercial license at that address. If this package includes both online and offline addresses, try online before offline. If you can't deliver a request that way, or this software doesn't include any addressees, spend one hour online researching an address, recording all your searches and inquiries as you go, and try any addresses that you find. If you can't find any addresses, or if those addresses also fail, that counts as failure to offer a fair commercial license by the licensor under [Big Business](#big-business). + +## Fair, Reasonable, and Nondiscriminatory Terms + +Fair, reasonable, and nondiscriminatory terms may license the software perpetually or for a term, and may or may not cover new versions of the software. If the licensor advertises license terms and a pricing structure for generally available commercial licenses, the licensor proposes license terms and a price as advertised, and a customer not affiliated with the licensor has bought a commercial license for the software on substantially equivalent terms in the past year, the proposal is fair, reasonable, and nondiscriminatory. + +## Copyright License + +The licensor grants you a copyright license to do everything with the software that would otherwise infringe the licensor's copyright in it for any purpose allowed by these terms. + +## Notices + +You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Patent License + +The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software. + +## Fair Use + +You may have "fair use" rights for the software under the law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms. + +**You** refers to the individual or entity agreeing to these terms. + +**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the software under these terms. + +**Use** means anything you do with the software requiring one of your licenses. diff --git a/gems/kettle-jem/lib/kettle/jem/templates/CHANGELOG.md.example b/gems/kettle-jem/lib/kettle/jem/templates/CHANGELOG.md.example new file mode 100644 index 0000000..9810eef --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/CHANGELOG.md.example @@ -0,0 +1,47 @@ +# Changelog + +[![SemVer 2.0.0][📌semver-img]][📌semver] [![Keep-A-Changelog 1.0.0][📗keep-changelog-img]][📗keep-changelog] + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog][📗keep-changelog], +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), +and [yes][📌major-versions-not-sacred], platform and engine support are part of the [public API][📌semver-breaking]. +Please file a bug if you notice a violation of semantic versioning. + +[📌semver]: https://semver.org/spec/v2.0.0.html +[📌semver-img]: https://img.shields.io/badge/semver-2.0.0-FFDD67.svg?style=flat +[📌semver-breaking]: https://github.com/semver/semver/issues/716#issuecomment-869336139 +[📌major-versions-not-sacred]: https://tom.preston-werner.com/2022/05/23/major-version-numbers-are-not-sacred.html +[📗keep-changelog]: https://keepachangelog.com/en/1.0.0/ +[📗keep-changelog-img]: https://img.shields.io/badge/keep--a--changelog-1.0.0-FFDD67.svg?style=flat + +## [Unreleased] +### Added +### Changed +### Deprecated +### Removed +### Fixed +### Security + +## [1.0.1] - 2025-08-24 +- TAG: [v1.0.1][1.0.1t] +- COVERAGE: 100.00% -- 130/130 lines in 7 files +- BRANCH COVERAGE: 96.00% -- 48/50 branches in 7 files +- 100% documented +### Fixed +- bugfix: oopsie + +## [1.0.0] - 2025-08-24 +- TAG: [v1.0.0][1.0.0t] +- COVERAGE: 100.00% -- 130/130 lines in 7 files +- BRANCH COVERAGE: 96.00% -- 48/50 branches in 7 files +- 100% documented +### Added +- Initial release + +[Unreleased]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/compare/v1.0.0...HEAD +[1.0.0]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/compare/a427c302df09cfe4253a7c8d400333f9a4c1a208...v1.0.0 +[1.0.0t]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/tags/v1.0.0 +[1.0.1]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/compare/v1.0.0...v1.0.1 +[1.0.1t]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/tags/v1.0.1 diff --git a/gems/kettle-jem/lib/kettle/jem/templates/CITATION.cff.example b/gems/kettle-jem/lib/kettle/jem/templates/CITATION.cff.example new file mode 100644 index 0000000..3ca783b --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/CITATION.cff.example @@ -0,0 +1,20 @@ +cff-version: 1.2.0 +title: "{KJ|GEM_NAME}" +message: >- + If you use this work and you want to cite it, + then you can use the metadata from this file. +type: software +authors: + - given-names: "{KJ|AUTHOR:GIVEN_NAMES}" + family-names: "{KJ|AUTHOR:FAMILY_NAMES}" + email: "{KJ|AUTHOR:EMAIL}" + affiliation: "{KJ|AUTHOR:DOMAIN}" + orcid: 'https://orcid.org/{KJ|AUTHOR:ORCID}' +identifiers: + - type: url + value: 'https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}' + description: "{KJ|GEM_NAME}" +repository-code: 'https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}' +abstract: >- + {KJ|GEM_NAME} +license: See license file diff --git a/gems/kettle-jem/lib/kettle/jem/templates/CODE_OF_CONDUCT.md.example b/gems/kettle-jem/lib/kettle/jem/templates/CODE_OF_CONDUCT.md.example new file mode 100644 index 0000000..7ad4c15 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/CODE_OF_CONDUCT.md.example @@ -0,0 +1,134 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[![Contact Maintainer][🚂maint-contact-img]][🚂maint-contact]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations +[🚂maint-contact]: http://www.railsbling.com/contact +[🚂maint-contact-img]: https://img.shields.io/badge/Contact-Maintainer-0093D0.svg?style=flat&logo=rubyonrails&logoColor=red diff --git a/gems/kettle-jem/lib/kettle/jem/templates/CONTRIBUTING.md.example b/gems/kettle-jem/lib/kettle/jem/templates/CONTRIBUTING.md.example new file mode 100644 index 0000000..6298dcf --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/CONTRIBUTING.md.example @@ -0,0 +1,235 @@ +# Contributing + +Bug reports and pull requests are welcome on [CodeBerg][📜src-cb], [GitLab][📜src-gl], or [GitHub][📜src-gh]. +This project should be a safe, welcoming space for collaboration, so contributors agree to adhere to +the [code of conduct][🤝conduct]. + +To submit a patch, please fork the project, create a patch with tests, and send a pull request. + +Remember to [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] if you make changes. + +## Developer Certificate of Origin + +In order to protect users of this project, we require all contributors to comply with the +[Developer Certificate of Origin](https://developercertificate.org/). +This ensures that all contributions are properly licensed and attributed. + +## Help out! + +Take a look at the open issues and pull requests, or use the gem and find something to improve. + +Follow these instructions: + +1. Join the Discord: [![Live Chat on Discord][✉️discord-invite-img]][✉️discord-invite] +2. Fork the repository +3. Create your feature branch (`git checkout -b my-new-feature`) +4. Make some fixes. +5. Commit your changes (`git commit -am 'Added some feature'`) +6. Push to the branch (`git push origin my-new-feature`) +7. Make sure to add tests for it. This is important, so it doesn't break in a future release. +8. Create new Pull Request. +9. Announce it in the channel for this org in the [Discord][✉️discord-invite]! + +## Executables vs Rake tasks + +Executables shipped by dependencies, such as {KJ|KETTLE_DEV_GEM}, and stone_checksums, are available +after running `bin/setup`. These include: + +- gem_checksums +- kettle-changelog +- kettle-commit-msg +- {KJ|KETTLE_DEV_GEM}-setup +- kettle-dvcs +- kettle-pre-release +- kettle-readme-backers +- kettle-release + +There are many Rake tasks available as well. You can see them by running: + +```shell +bin/rake -T +``` + +## Environment Variables for Local Development + +Below are the primary environment variables recognized by stone_checksums (and its integrated tools). Unless otherwise noted, set boolean values to the string "true" to enable. + +General/runtime +- DEBUG: Enable extra internal logging for this library (default: false) +- REQUIRE_BENCH: Enable `require_bench` to profile requires (default: false) +- CI: When set to true, adjusts default rake tasks toward CI behavior + +Coverage (kettle-soup-cover / SimpleCov) +- K_SOUP_COV_DO: Enable coverage collection (default: true in `mise.toml`) +- K_SOUP_COV_FORMATTERS: Comma-separated list of formatters (html, xml, rcov, lcov, json, tty) +- K_SOUP_COV_MIN_LINE: Minimum line coverage threshold (integer, e.g., 100) +- K_SOUP_COV_MIN_BRANCH: Minimum branch coverage threshold (integer, e.g., 100) +- K_SOUP_COV_MIN_HARD: Fail the run if thresholds are not met (true/false) +- K_SOUP_COV_MULTI_FORMATTERS: Enable multiple formatters at once (true/false) +- K_SOUP_COV_OPEN_BIN: Path to browser opener for HTML (empty disables auto-open) +- MAX_ROWS: Limit console output rows for simplecov-console (e.g., 1) + Tip: When running a single spec file locally, you may want `K_SOUP_COV_MIN_HARD=false` to avoid failing thresholds for a partial run. + +GitHub API and CI helpers +- GITHUB_TOKEN or GH_TOKEN: Token used by `ci:act` and release workflow checks to query GitHub Actions status at higher rate limits + +Releasing and signing +- SKIP_GEM_SIGNING: If set, skip gem signing during build/release +- GEM_CERT_USER: Username for selecting your public cert in `certs/.pem` (defaults to $USER) +- SOURCE_DATE_EPOCH: Reproducible build timestamp. + - `kettle-release` will set this automatically for the session. + - Not needed on bundler >= 2.7.0, as reproducible builds have become the default. + +Git hooks and commit message helpers (exe/kettle-commit-msg) +- GIT_HOOK_BRANCH_VALIDATE: Branch name validation mode (e.g., `jira`) or `false` to disable +- GIT_HOOK_FOOTER_APPEND: Append a footer to commit messages when goalie allows (true/false) +- GIT_HOOK_FOOTER_SENTINEL: Required when footer append is enabled — a unique first-line sentinel to prevent duplicates +- GIT_HOOK_FOOTER_APPEND_DEBUG: Extra debug output in the footer template (true/false) + +For a quick starting point, this repository’s `mise.toml` defines the shared defaults, and `.env.local` can override them locally. Copy `.env.local.example` to `.env.local`, use `KEY=value` lines, and either activate `mise` in your shell or run commands through `mise exec -C /path/to/project -- ...`. + +## Appraisals + +From time to time the [appraisal2][🚎appraisal2] gemfiles in `gemfiles/` will need to be updated. +They are created and updated with the commands: + +```console +bin/rake appraisal:update +``` + +If you need to reset all gemfiles/*.gemfile.lock files: + +```console +bin/rake appraisal:reset +``` + +When adding an appraisal to CI, check the [runner tool cache][🏃‍♂️runner-tool-cache] to see which runner to use. + +## Run Tests + +Run tests via `kettle-test` (provided by `kettle-test`). It runs RSpec, writes the full log to +`tmp/kettle-test/rspec-TIMESTAMP.log`, and prints a compact highlight block with timing, seed, +pass/fail count, failing example list, and SimpleCov coverage percentages. + +```console +bundle exec kettle-test +``` + +For targeted runs, disable the hard coverage threshold to avoid false failures: + +```console +K_SOUP_COV_MIN_HARD=false bundle exec kettle-test spec/path/to/spec.rb +``` + +### Spec organization (required) + +- One spec file per class/module. For each class or module under `lib/`, keep all of its unit tests in a single spec file under `spec/` that mirrors the path and file name exactly: `lib/{KJ|GEM_NAME_PATH}/my_class.rb` -> `spec/{KJ|GEM_NAME_PATH}/my_class_spec.rb`. +- Exception: Integration specs that intentionally span multiple classes. Place these under `spec/integration/` (or a clearly named integration folder), and do not directly mirror a single class. Name them after the scenario, not a class. + +## Lint It + +Run all the default tasks, which includes running the gradually autocorrecting linter, `rubocop-gradual`. + +```console +bundle exec rake +``` + +Or just run the linter. + +```console +bundle exec rake rubocop_gradual:autocorrect +``` + +For more detailed information about using RuboCop in this project, please see the [RUBOCOP.md](RUBOCOP.md) guide. This project uses `rubocop_gradual` instead of vanilla RuboCop, which requires specific commands for checking violations. + +### Important: Do not add inline RuboCop disables + +Never add `# rubocop:disable ...` / `# rubocop:enable ...` comments to code or specs (except when following the few existing `rubocop:disable` patterns for a rule already being disabled elsewhere in the code). Instead: + +- Prefer configuration-based exclusions when a rule should not apply to certain paths or files (e.g., via `.rubocop.yml`). +- When a violation is temporary, and you plan to fix it later, record it in `.rubocop_gradual.lock` using the gradual workflow: + - `bundle exec rake rubocop_gradual:autocorrect` (preferred) + - `bundle exec rake rubocop_gradual:force_update` (only when you cannot fix the violations immediately) + +As a general rule, fix style issues rather than ignoring them. For example, our specs should follow RSpec conventions like using `described_class` for the class under test. + +## Contributors + +Your picture could be here! + +[![Contributors][🖐contributors-img]][🖐contributors] + +Made with [contributors-img][🖐contrib-rocks]. + +Also see GitLab Contributors: [https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/graphs/main][🚎contributors-gl] + +## For Maintainers + +### One-time, Per-maintainer, Setup + +**IMPORTANT**: To sign a build, +a public key for signing gems will need to be picked up by the line in the +`gemspec` defining the `spec.cert_chain` (check the relevant ENV variables there). +All releases are signed releases. +See: [RubyGems Security Guide][🔒️rubygems-security-guide] + +NOTE: To build without signing the gem set `SKIP_GEM_SIGNING` to any value in the environment. + +### To release a new version: + +#### Automated process + +1. Update version.rb to contain the correct version-to-be-released. +2. Run `bundle exec kettle-changelog`. +3. Run `bundle exec kettle-release`. +4. Stay awake and monitor the release process for any errors, and answer any prompts. + +#### Manual process + +1. Run `bin/setup && bin/rake` as a "test, coverage, & linting" sanity check +2. Update the version number in `version.rb`, and ensure `CHANGELOG.md` reflects changes +3. Run `bin/setup && bin/rake` again as a secondary check, and to update `Gemfile.lock` +4. Run `bin/rake yard` to regenerate the docs site using the canonical docs task +5. Run `git commit -am "🔖 Prepare release v"` to commit the changes +6. Run `git push` to trigger the final CI pipeline before release, and merge PRs + - NOTE: Remember to [check the build][🧪build]. +7. Run `export GIT_TRUNK_BRANCH_NAME="$(git remote show origin | grep 'HEAD branch' | cut -d ' ' -f5)" && echo $GIT_TRUNK_BRANCH_NAME` +8. Run `git checkout $GIT_TRUNK_BRANCH_NAME` +9. Run `git pull origin $GIT_TRUNK_BRANCH_NAME` to ensure latest trunk code +10. Optional for older Bundler (< 2.7.0): Set `SOURCE_DATE_EPOCH` so `rake build` and `rake release` use the same timestamp and generate the same checksums + - If your Bundler is >= 2.7.0, you can skip this; builds are reproducible by default. + - Run `export SOURCE_DATE_EPOCH=$EPOCHSECONDS && echo $SOURCE_DATE_EPOCH` + - If the echo above has no output, then it didn't work. + - Note: `zsh/datetime` module is needed, if running `zsh`. + - In older versions of `bash` you can use `date +%s` instead, i.e. `export SOURCE_DATE_EPOCH=$(date +%s) && echo $SOURCE_DATE_EPOCH` +11. Run `bundle exec rake build` +12. Run `bin/gem_checksums` (more context [1][🔒️rubygems-checksums-pr], [2][🔒️rubygems-guides-pr]) + to create SHA-256 and SHA-512 checksums. This functionality is provided by the `stone_checksums` + [gem][💎stone_checksums]. + - The script automatically commits but does not push the checksums +13. Sanity check the SHA256, comparing with the output from the `bin/gem_checksums` command: + - `sha256sum pkg/-.gem` +14. Run `bundle exec rake release` which will create a git tag for the version, + push git commits and tags, and push the `.gem` file to the gem host configured in the gemspec. + +[📜src-gl]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/ +[📜src-cb]: https://codeberg.org/{KJ|GH_ORG}/{KJ|GEM_NAME} +[📜src-gh]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME} +[🧪build]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions +[🤝conduct]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/blob/main/CODE_OF_CONDUCT.md +[🖐contrib-rocks]: https://contrib.rocks +[🖐contributors]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/graphs/contributors +[🚎contributors-gl]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/graphs/main +[🖐contributors-img]: https://contrib.rocks/image?repo={KJ|GH_ORG}/{KJ|GEM_NAME} +[💎gem-coop]: https://gem.coop +[🔒️rubygems-security-guide]: https://guides.rubygems.org/security/#building-gems +[🔒️rubygems-checksums-pr]: https://github.com/rubygems/rubygems/pull/6022 +[🔒️rubygems-guides-pr]: https://github.com/rubygems/guides/pull/325 +[💎stone_checksums]: https://github.com/galtzo-floss/stone_checksums +[📗keep-changelog]: https://keepachangelog.com/en/1.0.0/ +[📗keep-changelog-img]: https://img.shields.io/badge/keep--a--changelog-1.0.0-FFDD67.svg?style=flat +[📌semver-breaking]: https://github.com/semver/semver/issues/716#issuecomment-869336139 +[📌major-versions-not-sacred]: https://tom.preston-werner.com/2022/05/23/major-version-numbers-are-not-sacred.html +[🚎appraisal2]: https://github.com/appraisal-rb/appraisal2 +[🏃‍♂️runner-tool-cache]: https://github.com/ruby/ruby-builder/releases/tag/toolcache +[✉️discord-invite]: https://discord.gg/3qme4XHNKN diff --git a/gems/kettle-jem/lib/kettle/jem/templates/FUNDING.md.example b/gems/kettle-jem/lib/kettle/jem/templates/FUNDING.md.example new file mode 100644 index 0000000..fdfb50a --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/FUNDING.md.example @@ -0,0 +1,74 @@ + + +Official Discord 👉️ [![Live Chat on Discord][✉️discord-invite-img]][✉️discord-invite] + +Many paths lead to being a sponsor or a backer of this project. Are you on such a path? + +[![OpenCollective Backers][🖇osc-backers-i]][🖇osc-backers] [![OpenCollective Sponsors][🖇osc-sponsors-i]][🖇osc-sponsors] [![Sponsor Me on Github][🖇sponsor-img]][🖇sponsor] [![Liberapay Goal Progress][⛳liberapay-img]][⛳liberapay] [![Donate on PayPal][🖇paypal-img]][🖇paypal] + +[![Buy me a coffee][🖇buyme-small-img]][🖇buyme] [![Donate on Polar][🖇polar-img]][🖇polar] [![Donate to my FLOSS efforts at ko-fi.com][🖇kofi-img]][🖇kofi] [![Donate to my FLOSS efforts using Patreon][🖇patreon-img]][🖇patreon] + +[⛳liberapay-img]: https://img.shields.io/liberapay/goal/{KJ|FUNDING:LIBERAPAY}.svg?logo=liberapay&color=a51611&style=flat +[⛳liberapay]: https://liberapay.com/{KJ|FUNDING:LIBERAPAY}/donate +[🖇osc-backers]: https://opencollective.com/{KJ|OPENCOLLECTIVE_ORG}#backer +[🖇osc-backers-i]: https://opencollective.com/{KJ|OPENCOLLECTIVE_ORG}/backers/badge.svg?style=flat +[🖇osc-sponsors]: https://opencollective.com/{KJ|OPENCOLLECTIVE_ORG}#sponsor +[🖇osc-sponsors-i]: https://opencollective.com/{KJ|OPENCOLLECTIVE_ORG}/sponsors/badge.svg?style=flat +[🖇sponsor-img]: https://img.shields.io/badge/Sponsor_Me!-{KJ|GH:USER}.svg?style=social&logo=github +[🖇sponsor]: https://github.com/sponsors/{KJ|GH:USER} +[🖇polar-img]: https://img.shields.io/badge/polar-donate-a51611.svg?style=flat +[🖇polar]: https://polar.sh/{KJ|FUNDING:POLAR} +[🖇kofi-img]: https://img.shields.io/badge/ko--fi-%E2%9C%93-a51611.svg?style=flat +[🖇kofi]: https://ko-fi.com/{KJ|FUNDING:KOFI} +[🖇patreon-img]: https://img.shields.io/badge/patreon-donate-a51611.svg?style=flat +[🖇patreon]: https://patreon.com/{KJ|FUNDING:PATREON} +[🖇buyme-small-img]: https://img.shields.io/badge/buy_me_a_coffee-%E2%9C%93-a51611.svg?style=flat +[🖇buyme]: https://www.buymeacoffee.com/{KJ|FUNDING:BUYMEACOFFEE} +[🖇paypal-img]: https://img.shields.io/badge/donate-paypal-a51611.svg?style=flat&logo=paypal +[🖇paypal]: https://www.paypal.com/paypalme/{KJ|FUNDING:PAYPAL} +[✉️discord-invite]: https://discord.gg/3qme4XHNKN +[✉️discord-invite-img]: https://img.shields.io/discord/1373797679469170758?style=flat + + + +# 🤑 A request for help + +Maintainers have teeth and need to pay their dentists. +After getting laid off in an RIF in March, and encountering difficulty finding a new one, +I began spending most of my time building open source tools. +I'm hoping to be able to pay for my kids' health insurance this month, +so if you value the work I am doing, I need your support. +Please consider sponsoring me or the project. + +To join the community or get help 👇️ Join the Discord. + +[![Live Chat on Discord][✉️discord-invite-img-ftb]][✉️discord-invite] + +To say "thanks!" ☝️ Join the Discord or 👇️ send money. + +[![Sponsor {KJ|GH_ORG}/{KJ|GEM_NAME} on Open Source Collective][🖇osc-all-bottom-img]][🖇osc] 💌 [![Sponsor me on GitHub Sponsors][🖇sponsor-bottom-img]][🖇sponsor] 💌 [![Sponsor me on Liberapay][⛳liberapay-bottom-img]][⛳liberapay] 💌 [![Donate on PayPal][🖇paypal-bottom-img]][🖇paypal] + +# Another Way to Support Open Source Software + +I’m driven by a passion to foster a thriving open-source community – a space where people can tackle complex problems, no matter how small. Revitalizing libraries that have fallen into disrepair, and building new libraries focused on solving real-world challenges, are my passions. I was recently affected by layoffs, and the tech jobs market is unwelcoming. I’m reaching out here because your support would significantly aid my efforts to provide for my family, and my farm (11 🐔 chickens, 2 🐶 dogs, 3 🐰 rabbits, 8 🐈‍ cats). + +If you work at a company that uses my work, please encourage them to support me as a corporate sponsor. My work on gems you use might show up in `bundle fund`. + +I’m developing a new library, [floss_funding][🖇floss-funding-gem], designed to empower open-source developers like myself to get paid for the work we do, in a sustainable way. Please give it a look. + +**[Floss-Funding.dev][🖇floss-funding.dev]: 👉️ No network calls. 👉️ No tracking. 👉️ No oversight. 👉️ Minimal crypto hashing. 💡 Easily disabled nags** + +[⛳liberapay-bottom-img]: https://img.shields.io/liberapay/goal/{KJ|FUNDING:LIBERAPAY}.svg?style=for-the-badge&logo=liberapay&color=a51611 +[🖇osc-all-img]: https://img.shields.io/opencollective/all/{KJ|OPENCOLLECTIVE_ORG} +[🖇osc-sponsors-img]: https://img.shields.io/opencollective/sponsors/{KJ|OPENCOLLECTIVE_ORG} +[🖇osc-backers-img]: https://img.shields.io/opencollective/backers/{KJ|OPENCOLLECTIVE_ORG} +[🖇osc-all-bottom-img]: https://img.shields.io/opencollective/all/{KJ|OPENCOLLECTIVE_ORG}?style=for-the-badge +[🖇osc-sponsors-bottom-img]: https://img.shields.io/opencollective/sponsors/{KJ|OPENCOLLECTIVE_ORG}?style=for-the-badge +[🖇osc-backers-bottom-img]: https://img.shields.io/opencollective/backers/{KJ|OPENCOLLECTIVE_ORG}?style=for-the-badge +[🖇osc]: https://opencollective.com/{KJ|OPENCOLLECTIVE_ORG} +[🖇sponsor-bottom-img]: https://img.shields.io/badge/Sponsor_Me!-{KJ|GH:USER}-blue?style=for-the-badge&logo=github +[🖇buyme-img]: https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20latte&emoji=&slug={KJ|FUNDING:BUYMEACOFFEE}&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff +[🖇paypal-bottom-img]: https://img.shields.io/badge/donate-paypal-a51611.svg?style=for-the-badge&logo=paypal&color=0A0A0A +[🖇floss-funding.dev]: https://floss-funding.dev +[🖇floss-funding-gem]: https://github.com/galtzo-floss/floss_funding +[✉️discord-invite-img-ftb]: https://img.shields.io/discord/1373797679469170758?style=for-the-badge diff --git a/gems/kettle-jem/lib/kettle/jem/templates/FUNDING.md.no-osc.example b/gems/kettle-jem/lib/kettle/jem/templates/FUNDING.md.no-osc.example new file mode 100644 index 0000000..7e7093a --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/FUNDING.md.no-osc.example @@ -0,0 +1,63 @@ + + +Official Discord 👉️ [![Live Chat on Discord][✉️discord-invite-img]][✉️discord-invite] + +Many paths lead to being a sponsor or a backer of this project. Are you on such a path? + +[![Sponsor Me on Github][🖇sponsor-img]][🖇sponsor] [![Liberapay Goal Progress][⛳liberapay-img]][⛳liberapay] [![Donate on PayPal][🖇paypal-img]][🖇paypal] + +[![Buy me a coffee][🖇buyme-small-img]][🖇buyme] [![Donate on Polar][🖇polar-img]][🖇polar] [![Donate to my FLOSS efforts at ko-fi.com][🖇kofi-img]][🖇kofi] [![Donate to my FLOSS efforts using Patreon][🖇patreon-img]][🖇patreon] + +[⛳liberapay-img]: https://img.shields.io/liberapay/goal/{KJ|FUNDING:LIBERAPAY}.svg?logo=liberapay&color=a51611&style=flat +[⛳liberapay]: https://liberapay.com/{KJ|FUNDING:LIBERAPAY}/donate +[🖇sponsor-img]: https://img.shields.io/badge/Sponsor_Me!-{KJ|GH:USER}.svg?style=social&logo=github +[🖇sponsor]: https://github.com/sponsors/{KJ|GH:USER} +[🖇polar-img]: https://img.shields.io/badge/polar-donate-a51611.svg?style=flat +[🖇polar]: https://polar.sh/{KJ|FUNDING:POLAR} +[🖇kofi-img]: https://img.shields.io/badge/ko--fi-%E2%9C%93-a51611.svg?style=flat +[🖇kofi]: https://ko-fi.com/{KJ|FUNDING:KOFI} +[🖇patreon-img]: https://img.shields.io/badge/patreon-donate-a51611.svg?style=flat +[🖇patreon]: https://patreon.com/{KJ|FUNDING:PATREON} +[🖇buyme-small-img]: https://img.shields.io/badge/buy_me_a_coffee-%E2%9C%93-a51611.svg?style=flat +[🖇buyme]: https://www.buymeacoffee.com/{KJ|FUNDING:BUYMEACOFFEE} +[🖇paypal-img]: https://img.shields.io/badge/donate-paypal-a51611.svg?style=flat&logo=paypal +[🖇paypal]: https://www.paypal.com/paypalme/{KJ|FUNDING:PAYPAL} +[✉️discord-invite]: https://discord.gg/3qme4XHNKN +[✉️discord-invite-img]: https://img.shields.io/discord/1373797679469170758?style=flat + + + +# 🤑 A request for help + +Maintainers have teeth and need to pay their dentists. +After getting laid off in an RIF in March, and encountering difficulty finding a new one, +I began spending most of my time building open source tools. +I'm hoping to be able to pay for my kids' health insurance this month, +so if you value the work I am doing, I need your support. +Please consider sponsoring me or the project. + +To join the community or get help 👇️ Join the Discord. + +[![Live Chat on Discord][✉️discord-invite-img-ftb]][✉️discord-invite] + +To say "thanks!" ☝️ Join the Discord or 👇️ send money. + +[![Sponsor me on GitHub Sponsors][🖇sponsor-bottom-img]][🖇sponsor] 💌 [![Sponsor me on Liberapay][⛳liberapay-bottom-img]][⛳liberapay] 💌 [![Donate on PayPal][🖇paypal-bottom-img]][🖇paypal] + +# Another Way to Support Open Source Software + +I’m driven by a passion to foster a thriving open-source community – a space where people can tackle complex problems, no matter how small. Revitalizing libraries that have fallen into disrepair, and building new libraries focused on solving real-world challenges, are my passions. I was recently affected by layoffs, and the tech jobs market is unwelcoming. I’m reaching out here because your support would significantly aid my efforts to provide for my family, and my farm (11 🐔 chickens, 2 🐶 dogs, 3 🐰 rabbits, 8 🐈‍ cats). + +If you work at a company that uses my work, please encourage them to support me as a corporate sponsor. My work on gems you use might show up in `bundle fund`. + +I’m developing a new library, [floss_funding][🖇floss-funding-gem], designed to empower open-source developers like myself to get paid for the work we do, in a sustainable way. Please give it a look. + +**[Floss-Funding.dev][🖇floss-funding.dev]: 👉️ No network calls. 👉️ No tracking. 👉️ No oversight. 👉️ Minimal crypto hashing. 💡 Easily disabled nags** + +[⛳liberapay-bottom-img]: https://img.shields.io/liberapay/goal/{KJ|FUNDING:LIBERAPAY}.svg?style=for-the-badge&logo=liberapay&color=a51611 +[🖇sponsor-bottom-img]: https://img.shields.io/badge/Sponsor_Me!-{KJ|GH:USER}-blue?style=for-the-badge&logo=github +[🖇buyme-img]: https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20latte&emoji=&slug={KJ|FUNDING:BUYMEACOFFEE}&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff +[🖇paypal-bottom-img]: https://img.shields.io/badge/donate-paypal-a51611.svg?style=for-the-badge&logo=paypal&color=0A0A0A +[🖇floss-funding.dev]: https://floss-funding.dev +[🖇floss-funding-gem]: https://github.com/galtzo-floss/floss_funding +[✉️discord-invite-img-ftb]: https://img.shields.io/discord/1373797679469170758?style=for-the-badge diff --git a/gems/kettle-jem/lib/kettle/jem/templates/Gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/Gemfile.example new file mode 100644 index 0000000..e1e76ac --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/Gemfile.example @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# {KJ|FREEZE_TOKEN}:freeze +# To retain chunks of comments & code during {KJ|GEM_NAME} templating: +# Wrap custom sections with freeze markers (e.g., as above and below this comment chunk). +# {KJ|GEM_NAME} will then preserve content between those markers across template runs. +# {KJ|FREEZE_TOKEN}:unfreeze + +source "https://gem.coop" + +git_source(:codeberg) { |repo_name| "https://codeberg.org/#{repo_name}" } +git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" } + +#### IMPORTANT ####################################################### +# Gemfile is for local development ONLY; Gemfile is NOT loaded in CI # +####################################################### IMPORTANT #### + +# Include dependencies from {KJ|GEM_NAME}.gemspec +gemspec + +# Templating (env-switched: KETTLE_RB_DEV=true for local paths) +eval_gemfile "gemfiles/modular/templating.gemfile" + +# Debugging +eval_gemfile "gemfiles/modular/debug.gemfile" + +# Code Coverage (env-switched: KETTLE_RB_DEV=true for local paths) +eval_gemfile "gemfiles/modular/coverage.gemfile" + +# Linting +eval_gemfile "gemfiles/modular/style.gemfile" + +# Documentation +eval_gemfile "gemfiles/modular/documentation.gemfile" + +# Optional +eval_gemfile "gemfiles/modular/optional.gemfile" + +### Std Lib Extracted Gems +eval_gemfile "gemfiles/modular/x_std_libs.gemfile" + +# See unlocked_deps appraisal for more details on irb inclusion +gem "irb", "~> 1.17" # ruby >= 2.7 diff --git a/gems/kettle-jem/lib/kettle/jem/templates/LICENSE.md.example b/gems/kettle-jem/lib/kettle/jem/templates/LICENSE.md.example new file mode 100644 index 0000000..ad301bb --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/LICENSE.md.example @@ -0,0 +1,3 @@ +{KJ|LICENSE_MD_CONTENT} + +{KJ|COPYRIGHT_PREFIX}Copyright (c) {KJ|TEMPLATE_RUN_YEAR} {KJ|AUTHOR:GIVEN_NAMES} {KJ|AUTHOR:FAMILY_NAMES} diff --git a/gems/kettle-jem/lib/kettle/jem/templates/MIT.md.example b/gems/kettle-jem/lib/kettle/jem/templates/MIT.md.example new file mode 100644 index 0000000..6d5d66d --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/MIT.md.example @@ -0,0 +1,21 @@ +The MIT License (MIT) + +See [LICENSE.md](LICENSE.md) for the copyright notice. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/gems/kettle-jem/lib/kettle/jem/templates/PolyForm-Noncommercial-1.0.0.md.example b/gems/kettle-jem/lib/kettle/jem/templates/PolyForm-Noncommercial-1.0.0.md.example new file mode 100644 index 0000000..1a71cb6 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/PolyForm-Noncommercial-1.0.0.md.example @@ -0,0 +1,131 @@ +# PolyForm Noncommercial License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the +software to do everything you might do with the software +that would otherwise infringe the licensor's copyright +in it for any permitted purpose. However, you may +only distribute the software according to [Distribution +License](#distribution-license) and make changes or new works +based on the software according to [Changes and New Works +License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license +to distribute copies of the software. Your license +to distribute covers distributing the software with +changes and new works permitted by [Changes and New Works +License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of +the software from you also gets a copy of these terms or the +URL for them above, as well as copies of any plain-text lines +beginning with `Required Notice:` that the licensor provided +with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for +the benefit of public knowledge, personal study, private +entertainment, hobby projects, amateur pursuits, or religious +observance, without any anticipated commercial application, +is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, +public research organization, public safety or health +organization, environmental protection organization, +or government institution is use for a permitted purpose +regardless of the source of funding or obligations resulting +from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/gems/kettle-jem/lib/kettle/jem/templates/PolyForm-Small-Business-1.0.0.md.example b/gems/kettle-jem/lib/kettle/jem/templates/PolyForm-Small-Business-1.0.0.md.example new file mode 100644 index 0000000..5b5790e --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/PolyForm-Small-Business-1.0.0.md.example @@ -0,0 +1,121 @@ +# PolyForm Small Business License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the +software to do everything you might do with the software +that would otherwise infringe the licensor's copyright +in it for any permitted purpose. However, you may +only distribute the software according to [Distribution +License](#distribution-license) and make changes or new works +based on the software according to [Changes and New Works +License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license +to distribute copies of the software. Your license +to distribute covers distributing the software with +changes and new works permitted by [Changes and New Works +License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of +the software from you also gets a copy of these terms or the +URL for them above, as well as copies of any plain-text lines +beginning with `Required Notice:` that the licensor provided +with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## Small Business + +Use of the software for the benefit of your company is use for +a permitted purpose if your company has fewer than 100 total +individuals working as employees and independent contractors, +and less than 1,000,000 USD (2019) total revenue in the prior +tax year. Adjust this revenue threshold for inflation according +to the United States Bureau of Labor Statistics' consumer price +index for all urban consumers, U.S. city average, for all items, +not seasonally adjusted, with 1982–1984=100 reference base. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. 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 3d73e6f..a88ea6d 100644 --- a/gems/kettle-jem/lib/kettle/jem/templates/README.md.example +++ b/gems/kettle-jem/lib/kettle/jem/templates/README.md.example @@ -1,10 +1,555 @@ -# {KJ|GEM_NAME} - {KJ|README:TOP_LOGO_ROW} +{KJ|README:TOP_LOGO_REFS} + +# {KJ|PROJECT_EMOJI} {KJ|NAMESPACE} + +[![Version][👽versioni]][👽version] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] {KJ|README:LICENSE_BADGE} [![Downloads Rank][👽dl-ranki]][👽dl-rank] [![Open Source Helpers][👽oss-helpi]][👽oss-help] [![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] [![CodeQL][🖐codeQL-img]][🖐codeQL] [![Apache SkyWalking Eyes License Compatibility Check][🚎15-🪪-wfi]][🚎15-🪪-wf] + +`if ci_badges.map(&:color).detect { it != "green"}` ☝️ [let me know][🖼️galtzo-discord], as I may have missed the [discord notification][🖼️galtzo-discord]. + +--- + +`if ci_badges.map(&:color).all? { it == "green"}` 👇️ send money so I can do more of this. FLOSS maintenance is now my full-time job. + +[![OpenCollective Backers][🖇osc-backers-i]][🖇osc-backers] [![OpenCollective Sponsors][🖇osc-sponsors-i]][🖇osc-sponsors] [![Sponsor Me on Github][🖇sponsor-img]][🖇sponsor] [![Liberapay Goal Progress][⛳liberapay-img]][⛳liberapay] [![Donate on PayPal][🖇paypal-img]][🖇paypal] [![Buy me a coffee][🖇buyme-small-img]][🖇buyme] [![Donate on Polar][🖇polar-img]][🖇polar] [![Donate at ko-fi.com][🖇kofi-img]][🖇kofi] + +
+ 👣 How will this project approach the September 2025 hostile takeover of RubyGems? 🚑️ + +I've summarized my thoughts in [this blog post](https://dev.to/galtzo/hostile-takeover-of-rubygems-my-thoughts-5hlo). + +
+ +## 🌻 Synopsis + + +## 💡 Info you can shake a stick at + +| Tokens to Remember | [![Gem name][⛳️name-img]][⛳️gem-name] [![Gem namespace][⛳️namespace-img]][⛳️gem-namespace] | +|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Works with JRuby | [![JRuby 9.1 Compat][💎jruby-9.1i]][🚎jruby-9.1-wf] [![JRuby 9.2 Compat][💎jruby-9.2i]][🚎jruby-9.2-wf] [![JRuby 9.3 Compat][💎jruby-9.3i]][🚎jruby-9.3-wf]
[![JRuby 9.4 Compat][💎jruby-9.4i]][🚎jruby-9.4-wf] [![JRuby current Compat][💎jruby-c-i]][🚎10-j-wf] [![JRuby HEAD Compat][💎jruby-headi]][🚎3-hd-wf] | +| Works with Truffle Ruby | [![Truffle Ruby 22.3 Compat][💎truby-22.3i]][🚎truby-22.3-wf] [![Truffle Ruby 23.0 Compat][💎truby-23.0i]][🚎truby-23.0-wf] [![Truffle Ruby 23.1 Compat][💎truby-23.1i]][🚎truby-23.1-wf]
[![Truffle Ruby 24.2 Compat][💎truby-24.2i]][🚎truby-24.2-wf] [![Truffle Ruby 25.0 Compat][💎truby-25.0i]][🚎truby-25.0-wf] [![Truffle Ruby current Compat][💎truby-c-i]][🚎9-t-wf] | +| Works with MRI Ruby 4 | [![Ruby 4.0 Compat][💎ruby-4.0i]][🚎11-c-wf] [![Ruby current Compat][💎ruby-c-i]][🚎11-c-wf] [![Ruby HEAD Compat][💎ruby-headi]][🚎3-hd-wf] | +| Works with MRI Ruby 3 | [![Ruby 3.0 Compat][💎ruby-3.0i]][🚎ruby-3.0-wf] [![Ruby 3.1 Compat][💎ruby-3.1i]][🚎ruby-3.1-wf] [![Ruby 3.2 Compat][💎ruby-3.2i]][🚎ruby-3.2-wf] [![Ruby 3.3 Compat][💎ruby-3.3i]][🚎ruby-3.3-wf] [![Ruby 3.4 Compat][💎ruby-3.4i]][🚎ruby-3.4-wf] | +| Works with MRI Ruby 2 | ![Ruby 2.0 Compat][💎ruby-2.0i] ![Ruby 2.1 Compat][💎ruby-2.1i] ![Ruby 2.2 Compat][💎ruby-2.2i]
[![Ruby 2.3 Compat][💎ruby-2.3i]][🚎ruby-2.3-wf] [![Ruby 2.4 Compat][💎ruby-2.4i]][🚎ruby-2.4-wf] [![Ruby 2.5 Compat][💎ruby-2.5i]][🚎ruby-2.5-wf] [![Ruby 2.6 Compat][💎ruby-2.6i]][🚎ruby-2.6-wf] [![Ruby 2.7 Compat][💎ruby-2.7i]][🚎ruby-2.7-wf] | +| Works with MRI Ruby 1 | ![Ruby 1.8 Compat][💎ruby-1.8i] ![Ruby 1.9 Compat][💎ruby-1.9i] | +| Support & Community | [![Join Me on Daily.dev's RubyFriends][✉️ruby-friends-img]][✉️ruby-friends] [![Live Chat on Discord][✉️discord-invite-img-ftb]][✉️discord-invite] [![Get help from me on Upwork][👨🏼‍🏫expsup-upwork-img]][👨🏼‍🏫expsup-upwork] [![Get help from me on Codementor][👨🏼‍🏫expsup-codementor-img]][👨🏼‍🏫expsup-codementor] | +| Source | [![Source on GitLab.com][📜src-gl-img]][📜src-gl] [![Source on CodeBerg.org][📜src-cb-img]][📜src-cb] [![Source on Github.com][📜src-gh-img]][📜src-gh] [![The best SHA: dQw4w9WgXcQ!][🧮kloc-img]][🧮kloc] | +| Documentation | [![Current release on RubyDoc.info][📜docs-cr-rd-img]][🚎yard-current] [![YARD on Galtzo.com][📜docs-head-rd-img]][🚎yard-head] [![Maintainer Blog][🚂maint-blog-img]][🚂maint-blog] [![GitLab Wiki][📜gl-wiki-img]][📜gl-wiki] [![GitHub Wiki][📜gh-wiki-img]][📜gh-wiki] | +| Compliance | {KJ|README:LICENSE_BADGE} {KJ|README:LICENSE_COMPAT_BADGE} [![📄ilo-declaration-img]][📄ilo-declaration] [![Security Policy][🔐security-img]][🔐security] [![Contributor Covenant 2.1][🪇conduct-img]][🪇conduct] [![SemVer 2.0.0][📌semver-img]][📌semver] | +| Style | [![Enforced Code Style Linter][💎rlts-img]][💎rlts] [![Keep-A-Changelog 1.0.0][📗keep-changelog-img]][📗keep-changelog] [![Gitmoji Commits][📌gitmoji-img]][📌gitmoji] [![Compatibility appraised by: appraisal2][💎appraisal2-img]][💎appraisal2] | +| Maintainer 🎖️ | [![Follow Me on LinkedIn][💖🖇linkedin-img]][💖🖇linkedin] [![Follow Me on Ruby.Social][💖🐘ruby-mast-img]][💖🐘ruby-mast] [![Follow Me on Bluesky][💖🦋bluesky-img]][💖🦋bluesky] [![Contact Maintainer][🚂maint-contact-img]][🚂maint-contact] [![My technical writing][💖💁🏼‍♂️devto-img]][💖💁🏼‍♂️devto] | +| `...` 💖 | [![Find Me on WellFound:][💖✌️wellfound-img]][💖✌️wellfound] [![Find Me on CrunchBase][💖💲crunchbase-img]][💖💲crunchbase] [![My LinkTree][💖🌳linktree-img]][💖🌳linktree] [![More About Me][💖💁🏼‍♂️aboutme-img]][💖💁🏼‍♂️aboutme] [🧊][💖🧊berg] [🐙][💖🐙hub] [🛖][💖🛖hut] [🧪][💖🧪lab] | + +### Compatibility + +Compatible with MRI Ruby {KJ|MIN_RUBY}+, and concordant releases of JRuby, and TruffleRuby. + +| 🚚 _Amazing_ test matrix was brought to you by | 🔎 appraisal2 🔎 and the color 💚 green 💚 | +|------------------------------------------------|--------------------------------------------------------| +| 👟 Check it out! | ✨ [github.com/appraisal-rb/appraisal2][💎appraisal2] ✨ | + +### Federated DVCS + +
+ Find this repo on federated forges (Coming soon!) + +| Federated [DVCS][💎d-in-dvcs] Repository | Status | Issues | PRs | Wiki | CI | Discussions | +|-------------------------------------------------|-----------------------------------------------------------------------|---------------------------|--------------------------|---------------------------|--------------------------|------------------------------| +| 🧪 [{KJ|GH_ORG}/{KJ|GEM_NAME} on GitLab][📜src-gl] | The Truth | [💚][🤝gl-issues] | [💚][🤝gl-pulls] | [💚][📜gl-wiki] | 🐭 Tiny Matrix | ➖ | +| 🧊 [{KJ|GH_ORG}/{KJ|GEM_NAME} on CodeBerg][📜src-cb] | An Ethical Mirror ([Donate][🤝cb-donate]) | [💚][🤝cb-issues] | [💚][🤝cb-pulls] | ➖ | ⭕️ No Matrix | ➖ | +| 🐙 [{KJ|GH_ORG}/{KJ|GEM_NAME} on GitHub][📜src-gh] | Another Mirror | [💚][🤝gh-issues] | [💚][🤝gh-pulls] | [💚][📜gh-wiki] | 💯 Full Matrix | [💚][gh-discussions] | +| 🎮️ [Discord Server][✉️discord-invite] | [![Live Chat on Discord][✉️discord-invite-img-ftb]][✉️discord-invite] | [Let's][✉️discord-invite] | [talk][✉️discord-invite] | [about][✉️discord-invite] | [this][✉️discord-invite] | [library!][✉️discord-invite] | + +
+ +[gh-discussions]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/discussions + +### Enterprise Support [![Tidelift](https://tidelift.com/badges/package/rubygems/{KJ|GEM_NAME})](https://tidelift.com/subscription/pkg/rubygems-{KJ|GEM_NAME}?utm_source=rubygems-{KJ|GEM_NAME}&utm_medium=referral&utm_campaign=readme) + +Available as part of the Tidelift Subscription. + +
+ Need enterprise-level guarantees? + +The maintainers of this and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source packages you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. + +[![Get help from me on Tidelift][🏙️entsup-tidelift-img]][🏙️entsup-tidelift] + +- 💡Subscribe for support guarantees covering _all_ your FLOSS dependencies +- 💡Tidelift is part of [Sonar][🏙️entsup-tidelift-sonar] +- 💡Tidelift pays maintainers to maintain the software you depend on!
📊`@`Pointy Haired Boss: An [enterprise support][🏙️entsup-tidelift] subscription is "[never gonna let you down][🧮kloc]", and *supports* open source maintainers + +Alternatively: + +- [![Live Chat on Discord][✉️discord-invite-img-ftb]][✉️discord-invite] +- [![Get help from me on Upwork][👨🏼‍🏫expsup-upwork-img]][👨🏼‍🏫expsup-upwork] +- [![Get help from me on Codementor][👨🏼‍🏫expsup-codementor-img]][👨🏼‍🏫expsup-codementor] + +
+ +## ✨ Installation + +Install the gem and add to the application's Gemfile by executing: + +```console +bundle add {KJ|GEM_NAME} +``` + +If bundler is not being used to manage dependencies, install the gem by executing: + +```console +gem install {KJ|GEM_NAME} +``` + +### 🔒 Secure Installation + +
+ For Medium or High Security Installations + +This gem is cryptographically signed and has verifiable [SHA-256 and SHA-512][💎SHA_checksums] checksums by +[stone_checksums][💎stone_checksums]. Be sure the gem you install hasn’t been tampered with +by following the instructions below. + +Add my public key (if you haven’t already; key expires 2045-04-29) as a trusted certificate: + +```console +gem cert --add <(curl -Ls https://raw.github.com/galtzo-floss/certs/main/pboling.pem) +``` + +You only need to do that once. Then proceed to install with: + +```console +gem install {KJ|GEM_NAME} -P HighSecurity +``` + +The `HighSecurity` trust profile will verify signed gems, and not allow the installation of unsigned dependencies. + +If you want to up your security game full-time: + +```console +bundle config set --global trust-policy MediumSecurity +``` + +`MediumSecurity` instead of `HighSecurity` is necessary if not all the gems you use are signed. + +NOTE: Be prepared to track down certs for signed gems and add them the same way you added mine. + +
+ +## ⚙️ Configuration + + +## 🔧 Basic Usage + + +## 🦷 FLOSS Funding + +While {KJ|GH_ORG} tools are free software and will always be, the project would benefit immensely from some funding. +Raising a monthly budget of... "dollars" would make the project more sustainable. + +We welcome both individual and corporate sponsors! We also offer a +wide array of funding channels to account for your preferences +(although currently [Open Collective][🖇osc] is our preferred funding platform). + +**If you're working in a company that's making significant use of {KJ|GH_ORG} tools we'd +appreciate it if you suggest to your company to become a {KJ|GH_ORG} sponsor.** + +You can support the development of {KJ|GH_ORG} tools via +[GitHub Sponsors][🖇sponsor], +[Liberapay][⛳liberapay], +[PayPal][🖇paypal], +[Open Collective][🖇osc] +and [Tidelift][🏙️entsup-tidelift]. + +| 📍 NOTE | +|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| If doing a sponsorship in the form of donation is problematic for your company
from an accounting standpoint, we'd recommend the use of Tidelift,
where you can get a support-like subscription instead. | + +### Open Collective for Individuals + +Support us with a monthly donation and help us continue our activities. [[Become a backer](https://opencollective.com/{KJ|GH_ORG}#backer)] + +NOTE: [kettle-readme-backers][kettle-readme-backers] updates this list every day, automatically. + + +No backers yet. Be the first! + + +### Open Collective for Organizations + +Become a sponsor and get your logo on our README on GitHub with a link to your site. [[Become a sponsor](https://opencollective.com/{KJ|GH_ORG}#sponsor)] + +NOTE: [kettle-readme-backers][kettle-readme-backers] updates this list every day, automatically. + + +No sponsors yet. Be the first! + + +[kettle-readme-backers]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/blob/main/exe/kettle-readme-backers + +### Another way to support open-source + +I’m driven by a passion to foster a thriving open-source community – a space where people can tackle complex problems, no matter how small. Revitalizing libraries that have fallen into disrepair, and building new libraries focused on solving real-world challenges, are my passions. I was recently affected by layoffs, and the tech jobs market is unwelcoming. I’m reaching out here because your support would significantly aid my efforts to provide for my family, and my farm (11 🐔 chickens, 2 🐶 dogs, 3 🐰 rabbits, 8 🐈‍ cats). + +If you work at a company that uses my work, please encourage them to support me as a corporate sponsor. My work on gems you use might show up in `bundle fund`. + +I’m developing a new library, [floss_funding][🖇floss-funding-gem], designed to empower open-source developers like myself to get paid for the work we do, in a sustainable way. Please give it a look. + +**[Floss-Funding.dev][🖇floss-funding.dev]: 👉️ No network calls. 👉️ No tracking. 👉️ No oversight. 👉️ Minimal crypto hashing. 💡 Easily disabled nags** + +[![OpenCollective Backers][🖇osc-backers-i]][🖇osc-backers] [![OpenCollective Sponsors][🖇osc-sponsors-i]][🖇osc-sponsors] [![Sponsor Me on Github][🖇sponsor-img]][🖇sponsor] [![Liberapay Goal Progress][⛳liberapay-img]][⛳liberapay] [![Donate on PayPal][🖇paypal-img]][🖇paypal] [![Buy me a coffee][🖇buyme-small-img]][🖇buyme] [![Donate on Polar][🖇polar-img]][🖇polar] [![Donate to my FLOSS efforts at ko-fi.com][🖇kofi-img]][🖇kofi] [![Donate to my FLOSS efforts using Patreon][🖇patreon-img]][🖇patreon] + +## 🔐 Security + +See [SECURITY.md][🔐security]. + +## 🤝 Contributing + +If you need some ideas of where to help, you could work on adding more code coverage, +or if it is already 💯 (see [below](#code-coverage)) check [issues][🤝gh-issues] or [PRs][🤝gh-pulls], +or use the gem and think about how it could be better. + +We [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] so if you make changes, remember to update it. + +See [CONTRIBUTING.md][🤝contributing] for more detailed instructions. + +### 🚀 Release Instructions + +See [CONTRIBUTING.md][🤝contributing]. + +### Code Coverage + +[![Coverage Graph][🏀codecov-g]][🏀codecov] + +[![Coveralls Test Coverage][🏀coveralls-img]][🏀coveralls] + +[![QLTY Test Coverage][🏀qlty-covi]][🏀qlty-cov] + +### 🪇 Code of Conduct + +Everyone interacting with this project's codebases, issue trackers, +chat rooms and mailing lists agrees to follow the [![Contributor Covenant 2.1][🪇conduct-img]][🪇conduct]. + +## 🌈 Contributors + +[![Contributors][🖐contributors-img]][🖐contributors] + +Made with [contributors-img][🖐contrib-rocks]. + +Also see GitLab Contributors: [https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/graphs/main][🚎contributors-gl] + +
+ ⭐️ Star History + + + + + + Star History Chart + + + +
+ +## 📌 Versioning + +This Library adheres to [![Semantic Versioning 2.0.0][📌semver-img]][📌semver]. +Violations of this scheme should be reported as bugs. +Specifically, if a minor or patch version is released that breaks backward compatibility, +a new version should be immediately released that restores compatibility. +Breaking changes to the public API will only be introduced with new major versions. + +> dropping support for a platform is both obviously and objectively a breaking change
+>—Jordan Harband ([@ljharb](https://github.com/ljharb), maintainer of SemVer) [in SemVer issue 716][📌semver-breaking] + +I understand that policy doesn't work universally ("exceptions to every rule!"), +but it is the policy here. +As such, in many cases it is good to specify a dependency on this library using +the [Pessimistic Version Constraint][📌pvc] with two digits of precision. + +For example: + +```ruby +spec.add_dependency("{KJ|GEM_NAME}", "~> {KJ|GEM_MAJOR}.0") +``` + +
+📌 Is "Platform Support" part of the public API? More details inside. + +SemVer should, IMO, but doesn't explicitly, say that dropping support for specific Platforms +is a *breaking change* to an API, and for that reason the bike shedding is endless. + +To get a better understanding of how SemVer is intended to work over a project's lifetime, +read this article from the creator of SemVer: + +- ["Major Version Numbers are Not Sacred"][📌major-versions-not-sacred] + +
+ +See [CHANGELOG.md][📌changelog] for a list of releases. + +## 📄 License + {KJ|README:LICENSE_INTRO} -Generated with {KJ|KETTLE_DEV_GEM} for [{KJ|YARD_HOST}](https://{KJ|YARD_HOST}). +### © Copyright + +See [LICENSE.md][📄license] for the official copyright notice. + +## 🤑 A request for help + +Maintainers have teeth and need to pay their dentists. +After getting laid off in an RIF in March, and encountering difficulty finding a new one, +I began spending most of my time building open source tools. +I'm hoping to be able to pay for my kids' health insurance this month, +so if you value the work I am doing, I need your support. +Please consider sponsoring me or the project. + +To join the community or get help 👇️ Join the Discord. +[![Live Chat on Discord][✉️discord-invite-img-ftb]][✉️discord-invite] + +To say "thanks!" ☝️ Join the Discord or 👇️ send money. + +[![Sponsor {KJ|GH_ORG}/{KJ|GEM_NAME} on Open Source Collective][🖇osc-all-bottom-img]][🖇osc] 💌 [![Sponsor me on GitHub Sponsors][🖇sponsor-bottom-img]][🖇sponsor] 💌 [![Sponsor me on Liberapay][⛳liberapay-bottom-img]][⛳liberapay] 💌 [![Donate on PayPal][🖇paypal-bottom-img]][🖇paypal] + +### Please give the project a star ⭐ ♥. + +Thanks for RTFM. ☺️ + +[⛳liberapay-img]: https://img.shields.io/liberapay/goal/{KJ|FUNDING:LIBERAPAY}.svg?logo=liberapay&color=a51611&style=flat +[⛳liberapay-bottom-img]: https://img.shields.io/liberapay/goal/{KJ|FUNDING:LIBERAPAY}.svg?style=for-the-badge&logo=liberapay&color=a51611 +[⛳liberapay]: https://liberapay.com/{KJ|FUNDING:LIBERAPAY}/donate +[🖇osc-all-img]: https://img.shields.io/opencollective/all/{KJ|GH_ORG} +[🖇osc-sponsors-img]: https://img.shields.io/opencollective/sponsors/{KJ|GH_ORG} +[🖇osc-backers-img]: https://img.shields.io/opencollective/backers/{KJ|GH_ORG} +[🖇osc-backers]: https://opencollective.com/{KJ|GH_ORG}#backer +[🖇osc-backers-i]: https://opencollective.com/{KJ|GH_ORG}/backers/badge.svg?style=flat +[🖇osc-sponsors]: https://opencollective.com/{KJ|GH_ORG}#sponsor +[🖇osc-sponsors-i]: https://opencollective.com/{KJ|GH_ORG}/sponsors/badge.svg?style=flat +[🖇osc-all-bottom-img]: https://img.shields.io/opencollective/all/{KJ|GH_ORG}?style=for-the-badge +[🖇osc-sponsors-bottom-img]: https://img.shields.io/opencollective/sponsors/{KJ|GH_ORG}?style=for-the-badge +[🖇osc-backers-bottom-img]: https://img.shields.io/opencollective/backers/{KJ|GH_ORG}?style=for-the-badge +[🖇osc]: https://opencollective.com/{KJ|GH_ORG} +[🖇sponsor-img]: https://img.shields.io/badge/Sponsor_Me!-{KJ|GH:USER}.svg?style=social&logo=github +[🖇sponsor-bottom-img]: https://img.shields.io/badge/Sponsor_Me!-{KJ|GH:USER}-blue?style=for-the-badge&logo=github +[🖇sponsor]: https://github.com/sponsors/{KJ|GH:USER} +[🖇polar-img]: https://img.shields.io/badge/polar-donate-a51611.svg?style=flat +[🖇polar]: https://polar.sh/{KJ|FUNDING:POLAR} +[🖇kofi-img]: https://img.shields.io/badge/ko--fi-%E2%9C%93-a51611.svg?style=flat +[🖇kofi]: https://ko-fi.com/{KJ|FUNDING:KOFI} +[🖇patreon-img]: https://img.shields.io/badge/patreon-donate-a51611.svg?style=flat +[🖇patreon]: https://patreon.com/{KJ|FUNDING:PATREON} +[🖇buyme-small-img]: https://img.shields.io/badge/buy_me_a_coffee-%E2%9C%93-a51611.svg?style=flat +[🖇buyme-img]: https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20latte&emoji=&slug={KJ|FUNDING:BUYMEACOFFEE}&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff +[🖇buyme]: https://www.buymeacoffee.com/{KJ|FUNDING:BUYMEACOFFEE} +[🖇paypal-img]: https://img.shields.io/badge/donate-paypal-a51611.svg?style=flat&logo=paypal +[🖇paypal-bottom-img]: https://img.shields.io/badge/donate-paypal-a51611.svg?style=for-the-badge&logo=paypal&color=0A0A0A +[🖇paypal]: https://www.paypal.com/paypalme/{KJ|FUNDING:PAYPAL} +[🖇floss-funding.dev]: https://floss-funding.dev +[🖇floss-funding-gem]: https://github.com/galtzo-floss/floss_funding +[✉️discord-invite]: https://discord.gg/3qme4XHNKN +[✉️discord-invite-img-ftb]: https://img.shields.io/discord/1373797679469170758?style=for-the-badge&logo=discord +[✉️ruby-friends-img]: https://img.shields.io/badge/daily.dev-%F0%9F%92%8E_Ruby_Friends-0A0A0A?style=for-the-badge&logo=dailydotdev&logoColor=white +[✉️ruby-friends]: https://app.daily.dev/squads/rubyfriends + +[✇bundle-group-pattern]: https://gist.github.com/pboling/4564780 +[⛳️gem-namespace]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME} +[⛳️namespace-img]: https://img.shields.io/badge/namespace-{KJ|NAMESPACE}-3C2D2D.svg?style=square&logo=ruby&logoColor=white +[⛳️gem-name]: https://bestgems.org/gems/{KJ|GEM_NAME} +[⛳️name-img]: https://img.shields.io/badge/name-{KJ|GEM_SHIELD}-3C2D2D.svg?style=square&logo=rubygems&logoColor=red +[⛳️tag-img]: https://img.shields.io/github/tag/{KJ|GH_ORG}/{KJ|GEM_NAME}.svg +[⛳️tag]: http://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/releases +[🚂maint-blog]: http://www.railsbling.com/tags/{KJ|GEM_NAME} +[🚂maint-blog-img]: https://img.shields.io/badge/blog-railsbling-0093D0.svg?style=for-the-badge&logo=rubyonrails&logoColor=orange +[🚂maint-contact]: http://www.railsbling.com/contact +[🚂maint-contact-img]: https://img.shields.io/badge/Contact-Maintainer-0093D0.svg?style=flat&logo=rubyonrails&logoColor=red +[💖🖇linkedin]: http://www.linkedin.com/in/peterboling +[💖🖇linkedin-img]: https://img.shields.io/badge/LinkedIn-Profile-0B66C2?style=flat&logo=newjapanprowrestling +[💖✌️wellfound]: https://wellfound.com/u/peter-boling +[💖✌️wellfound-img]: https://img.shields.io/badge/peter--boling-orange?style=flat&logo=wellfound +[💖💲crunchbase]: https://www.crunchbase.com/person/peter-boling +[💖💲crunchbase-img]: https://img.shields.io/badge/peter--boling-purple?style=flat&logo=crunchbase +[💖🐘ruby-mast]: https://ruby.social/@{KJ|SOCIAL:MASTODON} +[💖🐘ruby-mast-img]: https://img.shields.io/mastodon/follow/109447111526622197?domain=https://ruby.social&style=flat&logo=mastodon&label=Ruby%20@{KJ|SOCIAL:MASTODON} +[💖🦋bluesky]: https://bsky.app/profile/{KJ|SOCIAL:BLUESKY} +[💖🦋bluesky-img]: https://img.shields.io/badge/@{KJ|SOCIAL:BLUESKY}-0285FF?style=flat&logo=bluesky&logoColor=white +[💖🌳linktree]: https://linktr.ee/{KJ|SOCIAL:LINKTREE} +[💖🌳linktree-img]: https://img.shields.io/badge/{KJ|SOCIAL:LINKTREE}-purple?style=flat&logo=linktree +[💖💁🏼‍♂️devto]: https://dev.to/{KJ|SOCIAL:DEVTO} +[💖💁🏼‍♂️devto-img]: https://img.shields.io/badge/dev.to-0A0A0A?style=flat&logo=devdotto&logoColor=white +[💖💁🏼‍♂️aboutme]: https://about.me/peter.boling +[💖💁🏼‍♂️aboutme-img]: https://img.shields.io/badge/about.me-0A0A0A?style=flat&logo=aboutme&logoColor=white +[💖🧊berg]: https://codeberg.org/{KJ|CB:USER} +[💖🐙hub]: https://github.org/{KJ|GH:USER} +[💖🛖hut]: https://sr.ht/~{KJ|SH:USER}/ +[💖🧪lab]: https://gitlab.com/{KJ|GL:USER} +[👨🏼‍🏫expsup-upwork]: https://www.upwork.com/freelancers/~014942e9b056abdf86?mp_source=share +[👨🏼‍🏫expsup-upwork-img]: https://img.shields.io/badge/UpWork-13544E?style=for-the-badge&logo=Upwork&logoColor=white +[👨🏼‍🏫expsup-codementor]: https://www.codementor.io/peterboling?utm_source=github&utm_medium=button&utm_term=peterboling&utm_campaign=github +[👨🏼‍🏫expsup-codementor-img]: https://img.shields.io/badge/CodeMentor-Get_Help-1abc9c?style=for-the-badge&logo=CodeMentor&logoColor=white +[🏙️entsup-tidelift]: https://tidelift.com/subscription/pkg/rubygems-{KJ|GEM_NAME}?utm_source=rubygems-{KJ|GEM_NAME}&utm_medium=referral&utm_campaign=readme +[🏙️entsup-tidelift-img]: https://img.shields.io/badge/Tidelift_and_Sonar-Enterprise_Support-FD3456?style=for-the-badge&logo=sonar&logoColor=white +[🏙️entsup-tidelift-sonar]: https://blog.tidelift.com/tidelift-joins-sonar +[💁🏼‍♂️peterboling]: http://www.peterboling.com +[🚂railsbling]: http://www.railsbling.com +[📜src-gl-img]: https://img.shields.io/badge/GitLab-FBA326?style=for-the-badge&logo=Gitlab&logoColor=orange +[📜src-gl]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/ +[📜src-cb-img]: https://img.shields.io/badge/CodeBerg-4893CC?style=for-the-badge&logo=CodeBerg&logoColor=blue +[📜src-cb]: https://codeberg.org/{KJ|GH_ORG}/{KJ|GEM_NAME} +[📜src-gh-img]: https://img.shields.io/badge/GitHub-238636?style=for-the-badge&logo=Github&logoColor=green +[📜src-gh]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME} +[📜docs-cr-rd-img]: https://img.shields.io/badge/RubyDoc-Current_Release-943CD2?style=for-the-badge&logo=readthedocs&logoColor=white +[📜docs-head-rd-img]: https://img.shields.io/badge/YARD_on_Galtzo.com-HEAD-943CD2?style=for-the-badge&logo=readthedocs&logoColor=white +[📜gl-wiki]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/wikis/home +[📜gh-wiki]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/wiki +[📜gl-wiki-img]: https://img.shields.io/badge/wiki-examples-943CD2.svg?style=for-the-badge&logo=gitlab&logoColor=white +[📜gh-wiki-img]: https://img.shields.io/badge/wiki-examples-943CD2.svg?style=for-the-badge&logo=github&logoColor=white +[👽dl-rank]: https://bestgems.org/gems/{KJ|GEM_NAME} +[👽dl-ranki]: https://img.shields.io/gem/rd/{KJ|GEM_NAME}.svg +[👽oss-help]: https://www.codetriage.com/{KJ|GH_ORG}/{KJ|GEM_NAME} +[👽oss-helpi]: https://www.codetriage.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/badges/users.svg +[👽version]: https://bestgems.org/gems/{KJ|GEM_NAME} +[👽versioni]: https://img.shields.io/gem/v/{KJ|GEM_NAME}.svg +[🏀qlty-mnt]: https://qlty.sh/gh/{KJ|GH_ORG}/projects/{KJ|GEM_NAME} +[🏀qlty-mnti]: https://qlty.sh/gh/{KJ|GH_ORG}/projects/{KJ|GEM_NAME}/maintainability.svg +[🏀qlty-cov]: https://qlty.sh/gh/{KJ|GH_ORG}/projects/{KJ|GEM_NAME}/metrics/code?sort=coverageRating +[🏀qlty-covi]: https://qlty.sh/gh/{KJ|GH_ORG}/projects/{KJ|GEM_NAME}/coverage.svg +[🏀codecov]: https://codecov.io/gh/{KJ|GH_ORG}/{KJ|GEM_NAME} +[🏀codecovi]: https://codecov.io/gh/{KJ|GH_ORG}/{KJ|GEM_NAME}/graph/badge.svg +[🏀coveralls]: https://coveralls.io/github/{KJ|GH_ORG}/{KJ|GEM_NAME}?branch=main +[🏀coveralls-img]: https://coveralls.io/repos/github/{KJ|GH_ORG}/{KJ|GEM_NAME}/badge.svg?branch=main +[🖐codeQL]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/security/code-scanning +[🖐codeQL-img]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/codeql-analysis.yml/badge.svg +[🚎ruby-2.3-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.3.yml +[🚎ruby-2.3-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.3.yml/badge.svg +[🚎ruby-2.4-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.4.yml +[🚎ruby-2.4-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.4.yml/badge.svg +[🚎ruby-2.5-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.5.yml +[🚎ruby-2.5-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.5.yml/badge.svg +[🚎ruby-2.6-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.6.yml +[🚎ruby-2.6-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.6.yml/badge.svg +[🚎ruby-2.7-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.7.yml +[🚎ruby-2.7-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.7.yml/badge.svg +[🚎ruby-3.0-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.0.yml +[🚎ruby-3.0-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.0.yml/badge.svg +[🚎ruby-3.1-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.1.yml +[🚎ruby-3.1-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.1.yml/badge.svg +[🚎ruby-3.2-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.2.yml +[🚎ruby-3.2-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.2.yml/badge.svg +[🚎ruby-3.3-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.3.yml +[🚎ruby-3.3-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.3.yml/badge.svg +[🚎ruby-3.4-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.4.yml +[🚎ruby-3.4-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.4.yml/badge.svg +[🚎jruby-9.1-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.1.yml +[🚎jruby-9.1-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.1.yml/badge.svg +[🚎jruby-9.2-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.2.yml +[🚎jruby-9.2-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.2.yml/badge.svg +[🚎jruby-9.3-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.3.yml +[🚎jruby-9.3-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.3.yml/badge.svg +[🚎jruby-9.4-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.4.yml +[🚎jruby-9.4-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.4.yml/badge.svg +[🚎truby-22.3-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-22.3.yml +[🚎truby-22.3-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-22.3.yml/badge.svg +[🚎truby-23.0-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-23.0.yml +[🚎truby-23.0-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-23.0.yml/badge.svg +[🚎truby-23.1-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-23.1.yml +[🚎truby-23.1-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-23.1.yml/badge.svg +[🚎truby-24.2-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-24.2.yml +[🚎truby-24.2-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-24.2.yml/badge.svg +[🚎truby-25.0-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-25.0.yml +[🚎truby-25.0-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-25.0.yml/badge.svg +[🚎2-cov-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/coverage.yml +[🚎2-cov-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/coverage.yml/badge.svg +[🚎3-hd-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/heads.yml +[🚎3-hd-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/heads.yml/badge.svg +[🚎5-st-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/style.yml +[🚎5-st-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/style.yml/badge.svg +[🚎9-t-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffle.yml +[🚎9-t-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffle.yml/badge.svg +[🚎10-j-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby.yml +[🚎10-j-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby.yml/badge.svg +[🚎11-c-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/current.yml +[🚎11-c-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/current.yml/badge.svg +[🚎12-crh-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/dep-heads.yml +[🚎12-crh-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/dep-heads.yml/badge.svg +[🚎13-🔒️-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/locked_deps.yml +[🚎13-🔒️-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/locked_deps.yml/badge.svg +[🚎14-🔓️-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/unlocked_deps.yml +[🚎14-🔓️-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/unlocked_deps.yml/badge.svg +[🚎15-🪪-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/license-eye.yml +[🚎15-🪪-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/license-eye.yml/badge.svg +[💎ruby-1.8i]: https://img.shields.io/badge/Ruby-1.8_(%F0%9F%9A%ABCI)-AABBCC?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-1.9i]: https://img.shields.io/badge/Ruby-1.9_(%F0%9F%9A%ABCI)-AABBCC?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.0i]: https://img.shields.io/badge/Ruby-2.0_(%F0%9F%9A%ABCI)-AABBCC?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.1i]: https://img.shields.io/badge/Ruby-2.1_(%F0%9F%9A%ABCI)-AABBCC?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.2i]: https://img.shields.io/badge/Ruby-2.2_(%F0%9F%9A%ABCI)-AABBCC?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.3i]: https://img.shields.io/badge/Ruby-2.3-DF00CA?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.4i]: https://img.shields.io/badge/Ruby-2.4-DF00CA?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.5i]: https://img.shields.io/badge/Ruby-2.5-DF00CA?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.6i]: https://img.shields.io/badge/Ruby-2.6-DF00CA?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.7i]: https://img.shields.io/badge/Ruby-2.7-DF00CA?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-3.0i]: https://img.shields.io/badge/Ruby-3.0-CC342D?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-3.1i]: https://img.shields.io/badge/Ruby-3.1-CC342D?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-3.2i]: https://img.shields.io/badge/Ruby-3.2-CC342D?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-3.3i]: https://img.shields.io/badge/Ruby-3.3-CC342D?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-3.4i]: https://img.shields.io/badge/Ruby-3.4-CC342D?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-4.0i]: https://img.shields.io/badge/Ruby-4.0-CC342D?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-c-i]: https://img.shields.io/badge/Ruby-current-CC342D?style=for-the-badge&logo=ruby&logoColor=green +[💎ruby-headi]: https://img.shields.io/badge/Ruby-HEAD-CC342D?style=for-the-badge&logo=ruby&logoColor=blue +[💎truby-22.3i]: https://img.shields.io/badge/Truffle_Ruby-22.3-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink +[💎truby-23.0i]: https://img.shields.io/badge/Truffle_Ruby-23.0-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink +[💎truby-23.1i]: https://img.shields.io/badge/Truffle_Ruby-23.1-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink +[💎truby-24.2i]: https://img.shields.io/badge/Truffle_Ruby-24.2-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink +[💎truby-25.0i]: https://img.shields.io/badge/Truffle_Ruby-25.0-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink +[💎truby-c-i]: https://img.shields.io/badge/Truffle_Ruby-current-34BCB1?style=for-the-badge&logo=ruby&logoColor=green +[💎truby-headi]: https://img.shields.io/badge/Truffle_Ruby-HEAD-34BCB1?style=for-the-badge&logo=ruby&logoColor=blue +[💎jruby-9.1i]: https://img.shields.io/badge/JRuby-9.1-FBE742?style=for-the-badge&logo=ruby&logoColor=red +[💎jruby-9.2i]: https://img.shields.io/badge/JRuby-9.2-FBE742?style=for-the-badge&logo=ruby&logoColor=red +[💎jruby-9.3i]: https://img.shields.io/badge/JRuby-9.3-FBE742?style=for-the-badge&logo=ruby&logoColor=red +[💎jruby-9.4i]: https://img.shields.io/badge/JRuby-9.4-FBE742?style=for-the-badge&logo=ruby&logoColor=red +[💎jruby-c-i]: https://img.shields.io/badge/JRuby-current-FBE742?style=for-the-badge&logo=ruby&logoColor=green +[💎jruby-headi]: https://img.shields.io/badge/JRuby-HEAD-FBE742?style=for-the-badge&logo=ruby&logoColor=blue +[🤝gh-issues]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/issues +[🤝gh-pulls]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/pulls +[🤝gl-issues]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/issues +[🤝gl-pulls]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/merge_requests +[🤝cb-issues]: https://codeberg.org/{KJ|GH_ORG}/{KJ|GEM_NAME}/issues +[🤝cb-pulls]: https://codeberg.org/{KJ|GH_ORG}/{KJ|GEM_NAME}/pulls +[🤝cb-donate]: https://donate.codeberg.org/ +[🤝contributing]: CONTRIBUTING.md +[🏀codecov-g]: https://codecov.io/gh/{KJ|GH_ORG}/{KJ|GEM_NAME}/graphs/tree.svg +[🖐contrib-rocks]: https://contrib.rocks +[🖐contributors]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/graphs/contributors +[🖐contributors-img]: https://contrib.rocks/image?repo={KJ|GH_ORG}/{KJ|GEM_NAME} +[🚎contributors-gl]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/graphs/main +[🪇conduct]: CODE_OF_CONDUCT.md +[🪇conduct-img]: https://img.shields.io/badge/Contributor_Covenant-2.1-259D6C.svg +[📌pvc]: http://guides.rubygems.org/patterns/#pessimistic-version-constraint +[📌semver]: https://semver.org/spec/v2.0.0.html +[📌semver-img]: https://img.shields.io/badge/semver-2.0.0-259D6C.svg?style=flat +[📌semver-breaking]: https://github.com/semver/semver/issues/716#issuecomment-869336139 +[📌major-versions-not-sacred]: https://tom.preston-werner.com/2022/05/23/major-version-numbers-are-not-sacred.html +[📌changelog]: CHANGELOG.md +[📗keep-changelog]: https://keepachangelog.com/en/1.0.0/ +[📗keep-changelog-img]: https://img.shields.io/badge/keep--a--changelog-1.0.0-34495e.svg?style=flat +[📌gitmoji]: https://gitmoji.dev +[📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square +[🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ +[🧮kloc-img]: https://img.shields.io/badge/KLOC-5.053-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue +[🔐security]: SECURITY.md +[🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat {KJ|README:LICENSE_REFS} -{KJ|README:TOP_LOGO_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} +[🚎yard-head]: https://{KJ|YARD_HOST} +[💎stone_checksums]: https://github.com/galtzo-floss/stone_checksums +[💎SHA_checksums]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/tree/main/checksums +[💎rlts]: https://github.com/rubocop-lts/rubocop-lts +[💎rlts-img]: https://img.shields.io/badge/code_style_&_linting-rubocop--lts-34495e.svg?plastic&logo=ruby&logoColor=white +[💎appraisal2]: https://github.com/appraisal-rb/appraisal2 +[💎appraisal2-img]: https://img.shields.io/badge/appraised_by-appraisal2-34495e.svg?plastic&logo=ruby&logoColor=white +[💎d-in-dvcs]: https://railsbling.com/posts/dvcs/put_the_d_in_dvcs/ diff --git a/gems/kettle-jem/lib/kettle/jem/templates/README.md.no-osc.example b/gems/kettle-jem/lib/kettle/jem/templates/README.md.no-osc.example new file mode 100644 index 0000000..33ceabf --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/README.md.no-osc.example @@ -0,0 +1,523 @@ +{KJ|README:TOP_LOGO_ROW} + +{KJ|README:TOP_LOGO_REFS} + +# {KJ|PROJECT_EMOJI} {KJ|NAMESPACE} + +[![Version][👽versioni]][👽version] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] [![License: MIT][📄license-img]][📄license-ref] [![Downloads Rank][👽dl-ranki]][👽dl-rank] [![Open Source Helpers][👽oss-helpi]][👽oss-help] [![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] [![CodeQL][🖐codeQL-img]][🖐codeQL] [![Apache SkyWalking Eyes License Compatibility Check][🚎15-🪪-wfi]][🚎15-🪪-wf] + +`if ci_badges.map(&:color).detect { it != "green"}` ☝️ [let me know][🖼️galtzo-discord], as I may have missed the [discord notification][🖼️galtzo-discord]. + +--- + +`if ci_badges.map(&:color).all? { it == "green"}` 👇️ send money so I can do more of this. FLOSS maintenance is now my full-time job. + +[![Sponsor Me on Github][🖇sponsor-img]][🖇sponsor] [![Liberapay Goal Progress][⛳liberapay-img]][⛳liberapay] [![Donate on PayPal][🖇paypal-img]][🖇paypal] [![Buy me a coffee][🖇buyme-small-img]][🖇buyme] [![Donate on Polar][🖇polar-img]][🖇polar] [![Donate at ko-fi.com][🖇kofi-img]][🖇kofi] + +## 🌻 Synopsis + +
+ 👣 How will this project approach the September 2025 hostile takeover of RubyGems? 🚑️ + +I've summarized my thoughts in [this blog post](https://dev.to/galtzo/hostile-takeover-of-rubygems-my-thoughts-5hlo). + +
+ +## 💡 Info you can shake a stick at + +| Tokens to Remember | [![Gem name][⛳️name-img]][⛳️gem-name] [![Gem namespace][⛳️namespace-img]][⛳️gem-namespace] | +|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Works with JRuby | [![JRuby 9.1 Compat][💎jruby-9.1i]][🚎jruby-9.1-wf] [![JRuby 9.2 Compat][💎jruby-9.2i]][🚎jruby-9.2-wf] [![JRuby 9.3 Compat][💎jruby-9.3i]][🚎jruby-9.3-wf]
[![JRuby 9.4 Compat][💎jruby-9.4i]][🚎jruby-9.4-wf] [![JRuby current Compat][💎jruby-c-i]][🚎10-j-wf] [![JRuby HEAD Compat][💎jruby-headi]][🚎3-hd-wf] | +| Works with Truffle Ruby | [![Truffle Ruby 22.3 Compat][💎truby-22.3i]][🚎truby-22.3-wf] [![Truffle Ruby 23.0 Compat][💎truby-23.0i]][🚎truby-23.0-wf] [![Truffle Ruby 23.1 Compat][💎truby-23.1i]][🚎truby-23.1-wf]
[![Truffle Ruby 24.2 Compat][💎truby-24.2i]][🚎truby-24.2-wf] [![Truffle Ruby 25.0 Compat][💎truby-25.0i]][🚎truby-25.0-wf] [![Truffle Ruby current Compat][💎truby-c-i]][🚎9-t-wf] | +| Works with MRI Ruby 4 | [![Ruby 4.0 Compat][💎ruby-4.0i]][🚎11-c-wf] [![Ruby current Compat][💎ruby-c-i]][🚎11-c-wf] [![Ruby HEAD Compat][💎ruby-headi]][🚎3-hd-wf] | +| Works with MRI Ruby 3 | [![Ruby 3.0 Compat][💎ruby-3.0i]][🚎ruby-3.0-wf] [![Ruby 3.1 Compat][💎ruby-3.1i]][🚎ruby-3.1-wf] [![Ruby 3.2 Compat][💎ruby-3.2i]][🚎ruby-3.2-wf] [![Ruby 3.3 Compat][💎ruby-3.3i]][🚎ruby-3.3-wf] [![Ruby 3.4 Compat][💎ruby-3.4i]][🚎ruby-3.4-wf] | +| Works with MRI Ruby 2 | ![Ruby 2.0 Compat][💎ruby-2.0i] ![Ruby 2.1 Compat][💎ruby-2.1i] ![Ruby 2.2 Compat][💎ruby-2.2i]
[![Ruby 2.3 Compat][💎ruby-2.3i]][🚎ruby-2.3-wf] [![Ruby 2.4 Compat][💎ruby-2.4i]][🚎ruby-2.4-wf] [![Ruby 2.5 Compat][💎ruby-2.5i]][🚎ruby-2.5-wf] [![Ruby 2.6 Compat][💎ruby-2.6i]][🚎ruby-2.6-wf] [![Ruby 2.7 Compat][💎ruby-2.7i]][🚎ruby-2.7-wf] | +| Works with MRI Ruby 1 | ![Ruby 1.8 Compat][💎ruby-1.8i] ![Ruby 1.9 Compat][💎ruby-1.9i] | +| Support & Community | [![Join Me on Daily.dev's RubyFriends][✉️ruby-friends-img]][✉️ruby-friends] [![Live Chat on Discord][✉️discord-invite-img-ftb]][✉️discord-invite] [![Get help from me on Upwork][👨🏼‍🏫expsup-upwork-img]][👨🏼‍🏫expsup-upwork] [![Get help from me on Codementor][👨🏼‍🏫expsup-codementor-img]][👨🏼‍🏫expsup-codementor] | +| Source | [![Source on GitLab.com][📜src-gl-img]][📜src-gl] [![Source on CodeBerg.org][📜src-cb-img]][📜src-cb] [![Source on Github.com][📜src-gh-img]][📜src-gh] [![The best SHA: dQw4w9WgXcQ!][🧮kloc-img]][🧮kloc] | +| Documentation | [![Current release on RubyDoc.info][📜docs-cr-rd-img]][🚎yard-current] [![YARD on Galtzo.com][📜docs-head-rd-img]][🚎yard-head] [![Maintainer Blog][🚂maint-blog-img]][🚂maint-blog] [![GitLab Wiki][📜gl-wiki-img]][📜gl-wiki] [![GitHub Wiki][📜gh-wiki-img]][📜gh-wiki] | +| Compliance | [![License: MIT][📄license-img]][📄license-ref] [![Compatible with Apache Software Projects: Verified by SkyWalking Eyes][📄license-compat-img]][📄license-compat] [![📄ilo-declaration-img]][📄ilo-declaration] [![Security Policy][🔐security-img]][🔐security] [![Contributor Covenant 2.1][🪇conduct-img]][🪇conduct] [![SemVer 2.0.0][📌semver-img]][📌semver] | +| Style | [![Enforced Code Style Linter][💎rlts-img]][💎rlts] [![Keep-A-Changelog 1.0.0][📗keep-changelog-img]][📗keep-changelog] [![Gitmoji Commits][📌gitmoji-img]][📌gitmoji] [![Compatibility appraised by: appraisal2][💎appraisal2-img]][💎appraisal2] | +| Maintainer 🎖️ | [![Follow Me on LinkedIn][💖🖇linkedin-img]][💖🖇linkedin] [![Follow Me on Ruby.Social][💖🐘ruby-mast-img]][💖🐘ruby-mast] [![Follow Me on Bluesky][💖🦋bluesky-img]][💖🦋bluesky] [![Contact Maintainer][🚂maint-contact-img]][🚂maint-contact] [![My technical writing][💖💁🏼‍♂️devto-img]][💖💁🏼‍♂️devto] | +| `...` 💖 | [![Find Me on WellFound:][💖✌️wellfound-img]][💖✌️wellfound] [![Find Me on CrunchBase][💖💲crunchbase-img]][💖💲crunchbase] [![My LinkTree][💖🌳linktree-img]][💖🌳linktree] [![More About Me][💖💁🏼‍♂️aboutme-img]][💖💁🏼‍♂️aboutme] [🧊][💖🧊berg] [🐙][💖🐙hub] [🛖][💖🛖hut] [🧪][💖🧪lab] | + +### Compatibility + +Compatible with MRI Ruby {KJ|MIN_RUBY}+, and concordant releases of JRuby, and TruffleRuby. + + +| 🚚 _Amazing_ test matrix was brought to you by | 🔎 appraisal2 🔎 and the color 💚 green 💚 | +|------------------------------------------------|--------------------------------------------------------| +| 👟 Check it out! | ✨ [github.com/appraisal-rb/appraisal2][💎appraisal2] ✨ | + +### Federated DVCS + +
+ Find this repo on federated forges (Coming soon!) + +| Federated [DVCS][💎d-in-dvcs] Repository | Status | Issues | PRs | Wiki | CI | Discussions | +|-------------------------------------------------|-----------------------------------------------------------------------|---------------------------|--------------------------|---------------------------|--------------------------|------------------------------| +| 🧪 [{KJ|GH_ORG}/{KJ|GEM_NAME} on GitLab][📜src-gl] | The Truth | [💚][🤝gl-issues] | [💚][🤝gl-pulls] | [💚][📜gl-wiki] | 🐭 Tiny Matrix | ➖ | +| 🧊 [{KJ|GH_ORG}/{KJ|GEM_NAME} on CodeBerg][📜src-cb] | An Ethical Mirror ([Donate][🤝cb-donate]) | [💚][🤝cb-issues] | [💚][🤝cb-pulls] | ➖ | ⭕️ No Matrix | ➖ | +| 🐙 [{KJ|GH_ORG}/{KJ|GEM_NAME} on GitHub][📜src-gh] | Another Mirror | [💚][🤝gh-issues] | [💚][🤝gh-pulls] | [💚][📜gh-wiki] | 💯 Full Matrix | [💚][gh-discussions] | +| 🎮️ [Discord Server][✉️discord-invite] | [![Live Chat on Discord][✉️discord-invite-img-ftb]][✉️discord-invite] | [Let's][✉️discord-invite] | [talk][✉️discord-invite] | [about][✉️discord-invite] | [this][✉️discord-invite] | [library!][✉️discord-invite] | + +
+ +[gh-discussions]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/discussions + +### Enterprise Support [![Tidelift](https://tidelift.com/badges/package/rubygems/{KJ|GEM_NAME})](https://tidelift.com/subscription/pkg/rubygems-{KJ|GEM_NAME}?utm_source=rubygems-{KJ|GEM_NAME}&utm_medium=referral&utm_campaign=readme) + +Available as part of the Tidelift Subscription. + +
+ Need enterprise-level guarantees? + +The maintainers of this and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source packages you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. + +[![Get help from me on Tidelift][🏙️entsup-tidelift-img]][🏙️entsup-tidelift] + +- 💡Subscribe for support guarantees covering _all_ your FLOSS dependencies +- 💡Tidelift is part of [Sonar][🏙️entsup-tidelift-sonar] +- 💡Tidelift pays maintainers to maintain the software you depend on!
📊`@`Pointy Haired Boss: An [enterprise support][🏙️entsup-tidelift] subscription is "[never gonna let you down][🧮kloc]", and *supports* open source maintainers + +Alternatively: + +- [![Live Chat on Discord][✉️discord-invite-img-ftb]][✉️discord-invite] +- [![Get help from me on Upwork][👨🏼‍🏫expsup-upwork-img]][👨🏼‍🏫expsup-upwork] +- [![Get help from me on Codementor][👨🏼‍🏫expsup-codementor-img]][👨🏼‍🏫expsup-codementor] + +
+ +## ✨ Installation + +Install the gem and add to the application's Gemfile by executing: + +```console +bundle add {KJ|GEM_NAME} +``` + +If bundler is not being used to manage dependencies, install the gem by executing: + +```console +gem install {KJ|GEM_NAME} +``` + +### 🔒 Secure Installation + +
+ For Medium or High Security Installations + +This gem is cryptographically signed and has verifiable [SHA-256 and SHA-512][💎SHA_checksums] checksums by +[stone_checksums][💎stone_checksums]. Be sure the gem you install hasn’t been tampered with +by following the instructions below. + +Add my public key (if you haven’t already; key expires 2045-04-29) as a trusted certificate: + +```console +gem cert --add <(curl -Ls https://raw.github.com/galtzo-floss/certs/main/pboling.pem) +``` + +You only need to do that once. Then proceed to install with: + +```console +gem install {KJ|GEM_NAME} -P HighSecurity +``` + +The `HighSecurity` trust profile will verify signed gems, and not allow the installation of unsigned dependencies. + +If you want to up your security game full-time: + +```console +bundle config set --global trust-policy MediumSecurity +``` + +`MediumSecurity` instead of `HighSecurity` is necessary if not all the gems you use are signed. + +NOTE: Be prepared to track down certs for signed gems and add them the same way you added mine. + +
+ +## ⚙️ Configuration + + +## 🔧 Basic Usage + + +## 🦷 FLOSS Funding + +While {KJ|GH_ORG} tools are free software and will always be, the project would benefit immensely from some funding. +Raising a monthly budget of... "dollars" would make the project more sustainable. + +We welcome both individual and corporate sponsors! We also offer a +wide array of funding channels to account for your preferences. +Currently, [GitHub Sponsors][🖇sponsor], and [Liberapay][⛳liberapay] are our preferred funding platforms. + +**If you're working in a company that's making significant use of {KJ|GH_ORG} tools we'd +appreciate it if you suggest to your company to become a {KJ|GH_ORG} sponsor.** + +You can support the development of {KJ|GH_ORG} tools via +[GitHub Sponsors][🖇sponsor], +[Liberapay][⛳liberapay], +[PayPal][🖇paypal], +and [Tidelift][🏙️entsup-tidelift]. + +| 📍 NOTE | +|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| If doing a sponsorship in the form of donation is problematic for your company
from an accounting standpoint, we'd recommend the use of Tidelift,
where you can get a support-like subscription instead. | + +### Another way to support open-source + +I’m driven by a passion to foster a thriving open-source community – a space where people can tackle complex problems, no matter how small. Revitalizing libraries that have fallen into disrepair, and building new libraries focused on solving real-world challenges, are my passions. I was recently affected by layoffs, and the tech jobs market is unwelcoming. I’m reaching out here because your support would significantly aid my efforts to provide for my family, and my farm (11 🐔 chickens, 2 🐶 dogs, 3 🐰 rabbits, 8 🐈‍ cats). + +If you work at a company that uses my work, please encourage them to support me as a corporate sponsor. My work on gems you use might show up in `bundle fund`. + +I’m developing a new library, [floss_funding][🖇floss-funding-gem], designed to empower open-source developers like myself to get paid for the work we do, in a sustainable way. Please give it a look. + +**[Floss-Funding.dev][🖇floss-funding.dev]: 👉️ No network calls. 👉️ No tracking. 👉️ No oversight. 👉️ Minimal crypto hashing. 💡 Easily disabled nags** + +[![Sponsor Me on Github][🖇sponsor-img]][🖇sponsor] [![Liberapay Goal Progress][⛳liberapay-img]][⛳liberapay] [![Donate on PayPal][🖇paypal-img]][🖇paypal] [![Buy me a coffee][🖇buyme-small-img]][🖇buyme] [![Donate on Polar][🖇polar-img]][🖇polar] [![Donate to my FLOSS efforts at ko-fi.com][🖇kofi-img]][🖇kofi] [![Donate to my FLOSS efforts using Patreon][🖇patreon-img]][🖇patreon] + +## 🔐 Security + +See [SECURITY.md][🔐security]. + +## 🤝 Contributing + +If you need some ideas of where to help, you could work on adding more code coverage, +or if it is already 💯 (see [below](#code-coverage)) check [issues][🤝gh-issues] or [PRs][🤝gh-pulls], +or use the gem and think about how it could be better. + +We [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] so if you make changes, remember to update it. + +See [CONTRIBUTING.md][🤝contributing] for more detailed instructions. + +### 🚀 Release Instructions + +See [CONTRIBUTING.md][🤝contributing]. + +### Code Coverage + +[![Coverage Graph][🏀codecov-g]][🏀codecov] + +[![Coveralls Test Coverage][🏀coveralls-img]][🏀coveralls] + +[![QLTY Test Coverage][🏀qlty-covi]][🏀qlty-cov] + +### 🪇 Code of Conduct + +Everyone interacting with this project's codebases, issue trackers, +chat rooms and mailing lists agrees to follow the [![Contributor Covenant 2.1][🪇conduct-img]][🪇conduct]. + +## 🌈 Contributors + +[![Contributors][🖐contributors-img]][🖐contributors] + +Made with [contributors-img][🖐contrib-rocks]. + +Also see GitLab Contributors: [https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/graphs/main][🚎contributors-gl] + +
+ ⭐️ Star History + + + + + + Star History Chart + + + +
+ +## 📌 Versioning + +This Library adheres to [![Semantic Versioning 2.0.0][📌semver-img]][📌semver]. +Violations of this scheme should be reported as bugs. +Specifically, if a minor or patch version is released that breaks backward compatibility, +a new version should be immediately released that restores compatibility. +Breaking changes to the public API will only be introduced with new major versions. + +> dropping support for a platform is both obviously and objectively a breaking change
+>—Jordan Harband ([@ljharb](https://github.com/ljharb), maintainer of SemVer) [in SemVer issue 716][📌semver-breaking] + +I understand that policy doesn't work universally ("exceptions to every rule!"), +but it is the policy here. +As such, in many cases it is good to specify a dependency on this library using +the [Pessimistic Version Constraint][📌pvc] with two digits of precision. + +For example: + +```ruby +spec.add_dependency("{KJ|GEM_NAME}", "~> {KJ|GEM_MAJOR}.0") +``` + +
+📌 Is "Platform Support" part of the public API? More details inside. + +SemVer should, IMO, but doesn't explicitly, say that dropping support for specific Platforms +is a *breaking change* to an API, and for that reason the bike shedding is endless. + +To get a better understanding of how SemVer is intended to work over a project's lifetime, +read this article from the creator of SemVer: + +- ["Major Version Numbers are Not Sacred"][📌major-versions-not-sacred] + +
+ +See [CHANGELOG.md][📌changelog] for a list of releases. + +## 📄 License + +{KJ|README:LICENSE_INTRO} + +### © Copyright + +See [LICENSE.md][📄license] for the official copyright notice. + +## 🤑 A request for help + +Maintainers have teeth and need to pay their dentists. +After getting laid off in an RIF in March, and encountering difficulty finding a new one, +I began spending most of my time building open source tools. +I'm hoping to be able to pay for my kids' health insurance this month, +so if you value the work I am doing, I need your support. +Please consider sponsoring me or the project. + +To join the community or get help 👇️ Join the Discord. + +[![Live Chat on Discord][✉️discord-invite-img-ftb]][✉️discord-invite] + +To say "thanks!" ☝️ Join the Discord or 👇️ send money. + +[![Sponsor me on GitHub Sponsors][🖇sponsor-bottom-img]][🖇sponsor] 💌 [![Sponsor me on Liberapay][⛳liberapay-bottom-img]][⛳liberapay] 💌 [![Donate on PayPal][🖇paypal-bottom-img]][🖇paypal] + +### Please give the project a star ⭐ ♥. + +Thanks for RTFM. ☺️ + +[⛳liberapay-img]: https://img.shields.io/liberapay/goal/{KJ|FUNDING:LIBERAPAY}.svg?logo=liberapay&color=a51611&style=flat +[⛳liberapay-bottom-img]: https://img.shields.io/liberapay/goal/{KJ|FUNDING:LIBERAPAY}.svg?style=for-the-badge&logo=liberapay&color=a51611 +[⛳liberapay]: https://liberapay.com/{KJ|FUNDING:LIBERAPAY}/donate +[🖇sponsor-img]: https://img.shields.io/badge/Sponsor_Me!-{KJ|GH:USER}.svg?style=social&logo=github +[🖇sponsor-bottom-img]: https://img.shields.io/badge/Sponsor_Me!-{KJ|GH:USER}-blue?style=for-the-badge&logo=github +[🖇sponsor]: https://github.com/sponsors/{KJ|GH:USER} +[🖇polar-img]: https://img.shields.io/badge/polar-donate-a51611.svg?style=flat +[🖇polar]: https://polar.sh/{KJ|FUNDING:POLAR} +[🖇kofi-img]: https://img.shields.io/badge/ko--fi-%E2%9C%93-a51611.svg?style=flat +[🖇kofi]: https://ko-fi.com/{KJ|FUNDING:KOFI} +[🖇patreon-img]: https://img.shields.io/badge/patreon-donate-a51611.svg?style=flat +[🖇patreon]: https://patreon.com/{KJ|FUNDING:PATREON} +[🖇buyme-small-img]: https://img.shields.io/badge/buy_me_a_coffee-%E2%9C%93-a51611.svg?style=flat +[🖇buyme-img]: https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20latte&emoji=&slug={KJ|FUNDING:BUYMEACOFFEE}&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff +[🖇buyme]: https://www.buymeacoffee.com/{KJ|FUNDING:BUYMEACOFFEE} +[🖇paypal-img]: https://img.shields.io/badge/donate-paypal-a51611.svg?style=flat&logo=paypal +[🖇paypal-bottom-img]: https://img.shields.io/badge/donate-paypal-a51611.svg?style=for-the-badge&logo=paypal&color=0A0A0A +[🖇paypal]: https://www.paypal.com/paypalme/{KJ|FUNDING:PAYPAL} +[🖇floss-funding.dev]: https://floss-funding.dev +[🖇floss-funding-gem]: https://github.com/galtzo-floss/floss_funding +[✉️discord-invite]: https://discord.gg/3qme4XHNKN +[✉️discord-invite-img-ftb]: https://img.shields.io/discord/1373797679469170758?style=for-the-badge&logo=discord +[✉️ruby-friends-img]: https://img.shields.io/badge/daily.dev-%F0%9F%92%8E_Ruby_Friends-0A0A0A?style=for-the-badge&logo=dailydotdev&logoColor=white +[✉️ruby-friends]: https://app.daily.dev/squads/rubyfriends + +[✇bundle-group-pattern]: https://gist.github.com/pboling/4564780 +[⛳️gem-namespace]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME} +[⛳️namespace-img]: https://img.shields.io/badge/namespace-{KJ|NAMESPACE}-3C2D2D.svg?style=square&logo=ruby&logoColor=white +[⛳️gem-name]: https://bestgems.org/gems/{KJ|GEM_NAME} +[⛳️name-img]: https://img.shields.io/badge/name-{KJ|GEM_SHIELD}-3C2D2D.svg?style=square&logo=rubygems&logoColor=red +[⛳️tag-img]: https://img.shields.io/github/tag/{KJ|GH_ORG}/{KJ|GEM_NAME}.svg +[⛳️tag]: http://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/releases +[🚂maint-blog]: http://www.railsbling.com/tags/{KJ|GEM_NAME} +[🚂maint-blog-img]: https://img.shields.io/badge/blog-railsbling-0093D0.svg?style=for-the-badge&logo=rubyonrails&logoColor=orange +[🚂maint-contact]: http://www.railsbling.com/contact +[🚂maint-contact-img]: https://img.shields.io/badge/Contact-Maintainer-0093D0.svg?style=flat&logo=rubyonrails&logoColor=red +[💖🖇linkedin]: http://www.linkedin.com/in/peterboling +[💖🖇linkedin-img]: https://img.shields.io/badge/LinkedIn-Profile-0B66C2?style=flat&logo=newjapanprowrestling +[💖✌️wellfound]: https://wellfound.com/u/peter-boling +[💖✌️wellfound-img]: https://img.shields.io/badge/peter--boling-orange?style=flat&logo=wellfound +[💖💲crunchbase]: https://www.crunchbase.com/person/peter-boling +[💖💲crunchbase-img]: https://img.shields.io/badge/peter--boling-purple?style=flat&logo=crunchbase +[💖🐘ruby-mast]: https://ruby.social/@{KJ|SOCIAL:MASTODON} +[💖🐘ruby-mast-img]: https://img.shields.io/mastodon/follow/109447111526622197?domain=https://ruby.social&style=flat&logo=mastodon&label=Ruby%20@{KJ|SOCIAL:MASTODON} +[💖🦋bluesky]: https://bsky.app/profile/{KJ|SOCIAL:BLUESKY} +[💖🦋bluesky-img]: https://img.shields.io/badge/@{KJ|SOCIAL:BLUESKY}-0285FF?style=flat&logo=bluesky&logoColor=white +[💖🌳linktree]: https://linktr.ee/{KJ|SOCIAL:LINKTREE} +[💖🌳linktree-img]: https://img.shields.io/badge/{KJ|SOCIAL:LINKTREE}-purple?style=flat&logo=linktree +[💖💁🏼‍♂️devto]: https://dev.to/{KJ|SOCIAL:DEVTO} +[💖💁🏼‍♂️devto-img]: https://img.shields.io/badge/dev.to-0A0A0A?style=flat&logo=devdotto&logoColor=white +[💖💁🏼‍♂️aboutme]: https://about.me/peter.boling +[💖💁🏼‍♂️aboutme-img]: https://img.shields.io/badge/about.me-0A0A0A?style=flat&logo=aboutme&logoColor=white +[💖🧊berg]: https://codeberg.org/{KJ|CB:USER} +[💖🐙hub]: https://github.org/{KJ|GH:USER} +[💖🛖hut]: https://sr.ht/~{KJ|SH:USER}/ +[💖🧪lab]: https://gitlab.com/{KJ|GL:USER} +[👨🏼‍🏫expsup-upwork]: https://www.upwork.com/freelancers/~014942e9b056abdf86?mp_source=share +[👨🏼‍🏫expsup-upwork-img]: https://img.shields.io/badge/UpWork-13544E?style=for-the-badge&logo=Upwork&logoColor=white +[👨🏼‍🏫expsup-codementor]: https://www.codementor.io/peterboling?utm_source=github&utm_medium=button&utm_term=peterboling&utm_campaign=github +[👨🏼‍🏫expsup-codementor-img]: https://img.shields.io/badge/CodeMentor-Get_Help-1abc9c?style=for-the-badge&logo=CodeMentor&logoColor=white +[🏙️entsup-tidelift]: https://tidelift.com/subscription/pkg/rubygems-{KJ|GEM_NAME}?utm_source=rubygems-{KJ|GEM_NAME}&utm_medium=referral&utm_campaign=readme +[🏙️entsup-tidelift-img]: https://img.shields.io/badge/Tidelift_and_Sonar-Enterprise_Support-FD3456?style=for-the-badge&logo=sonar&logoColor=white +[🏙️entsup-tidelift-sonar]: https://blog.tidelift.com/tidelift-joins-sonar +[💁🏼‍♂️peterboling]: http://www.peterboling.com +[🚂railsbling]: http://www.railsbling.com +[📜src-gl-img]: https://img.shields.io/badge/GitLab-FBA326?style=for-the-badge&logo=Gitlab&logoColor=orange +[📜src-gl]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/ +[📜src-cb-img]: https://img.shields.io/badge/CodeBerg-4893CC?style=for-the-badge&logo=CodeBerg&logoColor=blue +[📜src-cb]: https://codeberg.org/{KJ|GH_ORG}/{KJ|GEM_NAME} +[📜src-gh-img]: https://img.shields.io/badge/GitHub-238636?style=for-the-badge&logo=Github&logoColor=green +[📜src-gh]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME} +[📜docs-cr-rd-img]: https://img.shields.io/badge/RubyDoc-Current_Release-943CD2?style=for-the-badge&logo=readthedocs&logoColor=white +[📜docs-head-rd-img]: https://img.shields.io/badge/YARD_on_Galtzo.com-HEAD-943CD2?style=for-the-badge&logo=readthedocs&logoColor=white +[📜gl-wiki]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/wikis/home +[📜gh-wiki]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/wiki +[📜gl-wiki-img]: https://img.shields.io/badge/wiki-examples-943CD2.svg?style=for-the-badge&logo=gitlab&logoColor=white +[📜gh-wiki-img]: https://img.shields.io/badge/wiki-examples-943CD2.svg?style=for-the-badge&logo=github&logoColor=white +[👽dl-rank]: https://bestgems.org/gems/{KJ|GEM_NAME} +[👽dl-ranki]: https://img.shields.io/gem/rd/{KJ|GEM_NAME}.svg +[👽oss-help]: https://www.codetriage.com/{KJ|GH_ORG}/{KJ|GEM_NAME} +[👽oss-helpi]: https://www.codetriage.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/badges/users.svg +[👽version]: https://bestgems.org/gems/{KJ|GEM_NAME} +[👽versioni]: https://img.shields.io/gem/v/{KJ|GEM_NAME}.svg +[🏀qlty-mnt]: https://qlty.sh/gh/{KJ|GH_ORG}/projects/{KJ|GEM_NAME} +[🏀qlty-mnti]: https://qlty.sh/gh/{KJ|GH_ORG}/projects/{KJ|GEM_NAME}/maintainability.svg +[🏀qlty-cov]: https://qlty.sh/gh/{KJ|GH_ORG}/projects/{KJ|GEM_NAME}/metrics/code?sort=coverageRating +[🏀qlty-covi]: https://qlty.sh/gh/{KJ|GH_ORG}/projects/{KJ|GEM_NAME}/coverage.svg +[🏀codecov]: https://codecov.io/gh/{KJ|GH_ORG}/{KJ|GEM_NAME} +[🏀codecovi]: https://codecov.io/gh/{KJ|GH_ORG}/{KJ|GEM_NAME}/graph/badge.svg +[🏀coveralls]: https://coveralls.io/github/{KJ|GH_ORG}/{KJ|GEM_NAME}?branch=main +[🏀coveralls-img]: https://coveralls.io/repos/github/{KJ|GH_ORG}/{KJ|GEM_NAME}/badge.svg?branch=main +[🖐codeQL]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/security/code-scanning +[🖐codeQL-img]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/codeql-analysis.yml/badge.svg +[🚎ruby-2.3-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.3.yml +[🚎ruby-2.3-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.3.yml/badge.svg +[🚎ruby-2.4-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.4.yml +[🚎ruby-2.4-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.4.yml/badge.svg +[🚎ruby-2.5-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.5.yml +[🚎ruby-2.5-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.5.yml/badge.svg +[🚎ruby-2.6-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.6.yml +[🚎ruby-2.6-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.6.yml/badge.svg +[🚎ruby-2.7-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.7.yml +[🚎ruby-2.7-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-2.7.yml/badge.svg +[🚎ruby-3.0-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.0.yml +[🚎ruby-3.0-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.0.yml/badge.svg +[🚎ruby-3.1-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.1.yml +[🚎ruby-3.1-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.1.yml/badge.svg +[🚎ruby-3.2-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.2.yml +[🚎ruby-3.2-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.2.yml/badge.svg +[🚎ruby-3.3-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.3.yml +[🚎ruby-3.3-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.3.yml/badge.svg +[🚎ruby-3.4-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.4.yml +[🚎ruby-3.4-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/ruby-3.4.yml/badge.svg +[🚎jruby-9.1-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.1.yml +[🚎jruby-9.1-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.1.yml/badge.svg +[🚎jruby-9.2-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.2.yml +[🚎jruby-9.2-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.2.yml/badge.svg +[🚎jruby-9.3-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.3.yml +[🚎jruby-9.3-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.3.yml/badge.svg +[🚎jruby-9.4-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.4.yml +[🚎jruby-9.4-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby-9.4.yml/badge.svg +[🚎truby-22.3-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-22.3.yml +[🚎truby-22.3-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-22.3.yml/badge.svg +[🚎truby-23.0-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-23.0.yml +[🚎truby-23.0-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-23.0.yml/badge.svg +[🚎truby-23.1-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-23.1.yml +[🚎truby-23.1-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-23.1.yml/badge.svg +[🚎truby-24.2-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-24.2.yml +[🚎truby-24.2-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-24.2.yml/badge.svg +[🚎truby-25.0-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-25.0.yml +[🚎truby-25.0-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffleruby-25.0.yml/badge.svg +[🚎2-cov-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/coverage.yml +[🚎2-cov-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/coverage.yml/badge.svg +[🚎3-hd-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/heads.yml +[🚎3-hd-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/heads.yml/badge.svg +[🚎5-st-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/style.yml +[🚎5-st-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/style.yml/badge.svg +[🚎9-t-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffle.yml +[🚎9-t-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/truffle.yml/badge.svg +[🚎10-j-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby.yml +[🚎10-j-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/jruby.yml/badge.svg +[🚎11-c-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/current.yml +[🚎11-c-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/current.yml/badge.svg +[🚎12-crh-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/dep-heads.yml +[🚎12-crh-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/dep-heads.yml/badge.svg +[🚎13-🔒️-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/locked_deps.yml +[🚎13-🔒️-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/locked_deps.yml/badge.svg +[🚎14-🔓️-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/unlocked_deps.yml +[🚎14-🔓️-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/unlocked_deps.yml/badge.svg +[🚎15-🪪-wf]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/license-eye.yml +[🚎15-🪪-wfi]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/actions/workflows/license-eye.yml/badge.svg +[💎ruby-1.8i]: https://img.shields.io/badge/Ruby-1.8_(%F0%9F%9A%ABCI)-AABBCC?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-1.9i]: https://img.shields.io/badge/Ruby-1.9_(%F0%9F%9A%ABCI)-AABBCC?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.0i]: https://img.shields.io/badge/Ruby-2.0_(%F0%9F%9A%ABCI)-AABBCC?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.1i]: https://img.shields.io/badge/Ruby-2.1_(%F0%9F%9A%ABCI)-AABBCC?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.2i]: https://img.shields.io/badge/Ruby-2.2_(%F0%9F%9A%ABCI)-AABBCC?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.3i]: https://img.shields.io/badge/Ruby-2.3-DF00CA?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.4i]: https://img.shields.io/badge/Ruby-2.4-DF00CA?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.5i]: https://img.shields.io/badge/Ruby-2.5-DF00CA?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.6i]: https://img.shields.io/badge/Ruby-2.6-DF00CA?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-2.7i]: https://img.shields.io/badge/Ruby-2.7-DF00CA?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-3.0i]: https://img.shields.io/badge/Ruby-3.0-CC342D?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-3.1i]: https://img.shields.io/badge/Ruby-3.1-CC342D?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-3.2i]: https://img.shields.io/badge/Ruby-3.2-CC342D?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-3.3i]: https://img.shields.io/badge/Ruby-3.3-CC342D?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-3.4i]: https://img.shields.io/badge/Ruby-3.4-CC342D?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-4.0i]: https://img.shields.io/badge/Ruby-4.0-CC342D?style=for-the-badge&logo=ruby&logoColor=white +[💎ruby-c-i]: https://img.shields.io/badge/Ruby-current-CC342D?style=for-the-badge&logo=ruby&logoColor=green +[💎ruby-headi]: https://img.shields.io/badge/Ruby-HEAD-CC342D?style=for-the-badge&logo=ruby&logoColor=blue +[💎truby-22.3i]: https://img.shields.io/badge/Truffle_Ruby-22.3-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink +[💎truby-23.0i]: https://img.shields.io/badge/Truffle_Ruby-23.0-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink +[💎truby-23.1i]: https://img.shields.io/badge/Truffle_Ruby-23.1-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink +[💎truby-24.2i]: https://img.shields.io/badge/Truffle_Ruby-24.2-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink +[💎truby-25.0i]: https://img.shields.io/badge/Truffle_Ruby-25.0-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink +[💎truby-c-i]: https://img.shields.io/badge/Truffle_Ruby-current-34BCB1?style=for-the-badge&logo=ruby&logoColor=green +[💎truby-headi]: https://img.shields.io/badge/Truffle_Ruby-HEAD-34BCB1?style=for-the-badge&logo=ruby&logoColor=blue +[💎jruby-9.1i]: https://img.shields.io/badge/JRuby-9.1-FBE742?style=for-the-badge&logo=ruby&logoColor=red +[💎jruby-9.2i]: https://img.shields.io/badge/JRuby-9.2-FBE742?style=for-the-badge&logo=ruby&logoColor=red +[💎jruby-9.3i]: https://img.shields.io/badge/JRuby-9.3-FBE742?style=for-the-badge&logo=ruby&logoColor=red +[💎jruby-9.4i]: https://img.shields.io/badge/JRuby-9.4-FBE742?style=for-the-badge&logo=ruby&logoColor=red +[💎jruby-c-i]: https://img.shields.io/badge/JRuby-current-FBE742?style=for-the-badge&logo=ruby&logoColor=green +[💎jruby-headi]: https://img.shields.io/badge/JRuby-HEAD-FBE742?style=for-the-badge&logo=ruby&logoColor=blue +[🤝gh-issues]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/issues +[🤝gh-pulls]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/pulls +[🤝gl-issues]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/issues +[🤝gl-pulls]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/merge_requests +[🤝cb-issues]: https://codeberg.org/{KJ|GH_ORG}/{KJ|GEM_NAME}/issues +[🤝cb-pulls]: https://codeberg.org/{KJ|GH_ORG}/{KJ|GEM_NAME}/pulls +[🤝cb-donate]: https://donate.codeberg.org/ +[🤝contributing]: CONTRIBUTING.md +[🏀codecov-g]: https://codecov.io/gh/{KJ|GH_ORG}/{KJ|GEM_NAME}/graphs/tree.svg +[🖐contrib-rocks]: https://contrib.rocks +[🖐contributors]: https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/graphs/contributors +[🖐contributors-img]: https://contrib.rocks/image?repo={KJ|GH_ORG}/{KJ|GEM_NAME} +[🚎contributors-gl]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/graphs/main +[🪇conduct]: CODE_OF_CONDUCT.md +[🪇conduct-img]: https://img.shields.io/badge/Contributor_Covenant-2.1-259D6C.svg +[📌pvc]: http://guides.rubygems.org/patterns/#pessimistic-version-constraint +[📌semver]: https://semver.org/spec/v2.0.0.html +[📌semver-img]: https://img.shields.io/badge/semver-2.0.0-259D6C.svg?style=flat +[📌semver-breaking]: https://github.com/semver/semver/issues/716#issuecomment-869336139 +[📌major-versions-not-sacred]: https://tom.preston-werner.com/2022/05/23/major-version-numbers-are-not-sacred.html +[📌changelog]: CHANGELOG.md +[📗keep-changelog]: https://keepachangelog.com/en/1.0.0/ +[📗keep-changelog-img]: https://img.shields.io/badge/keep--a--changelog-1.0.0-34495e.svg?style=flat +[📌gitmoji]: https://gitmoji.dev +[📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square +[🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ +[🧮kloc-img]: https://img.shields.io/badge/KLOC-4.076-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue +[🔐security]: SECURITY.md +[🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat +{KJ|README:LICENSE_REFS} +[📄license-compat]: https://dev.to/{KJ|SOCIAL:DEVTO}/how-to-check-license-compatibility-41h0 +[📄license-compat-img]: https://img.shields.io/badge/Apache_Compatible:_Category_A-%E2%9C%93-259D6C.svg?style=flat&logo=Apache +[📄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} +[🚎yard-head]: https://{KJ|YARD_HOST} +[💎stone_checksums]: https://github.com/galtzo-floss/stone_checksums +[💎SHA_checksums]: https://gitlab.com/{KJ|GH_ORG}/{KJ|GEM_NAME}/-/tree/main/checksums +[💎rlts]: https://github.com/rubocop-lts/rubocop-lts +[💎rlts-img]: https://img.shields.io/badge/code_style_&_linting-rubocop--lts-34495e.svg?plastic&logo=ruby&logoColor=white +[💎appraisal2]: https://github.com/appraisal-rb/appraisal2 +[💎appraisal2-img]: https://img.shields.io/badge/appraised_by-appraisal2-34495e.svg?plastic&logo=ruby&logoColor=white +[💎d-in-dvcs]: https://railsbling.com/posts/dvcs/put_the_d_in_dvcs/ diff --git a/gems/kettle-jem/lib/kettle/jem/templates/REEK b/gems/kettle-jem/lib/kettle/jem/templates/REEK new file mode 100644 index 0000000..e69de29 diff --git a/gems/kettle-jem/lib/kettle/jem/templates/RUBOCOP.md.example b/gems/kettle-jem/lib/kettle/jem/templates/RUBOCOP.md.example new file mode 100644 index 0000000..f15b980 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/RUBOCOP.md.example @@ -0,0 +1,71 @@ +# RuboCop Usage Guide + +## Overview + +A tale of two RuboCop plugin gems. + +### RuboCop Gradual + +This project uses `rubocop_gradual` instead of vanilla RuboCop for code style checking. The `rubocop_gradual` tool allows for gradual adoption of RuboCop rules by tracking violations in a lock file. + +### RuboCop LTS + +This project uses `rubocop-lts` to ensure, on a best-effort basis, compatibility with Ruby >= 1.9.2. +RuboCop rules are meticulously configured by the `rubocop-lts` family of gems to ensure that a project is compatible with a specific version of Ruby. See: https://rubocop-lts.gitlab.io for more. + +## Checking RuboCop Violations + +To check for RuboCop violations in this project, always use: + +```bash +bundle exec rake rubocop_gradual:check +``` + +**Do not use** the standard RuboCop commands like: +- `bundle exec rubocop` +- `rubocop` + +## Understanding the Lock File + +The `.rubocop_gradual.lock` file tracks all current RuboCop violations in the project. This allows the team to: + +1. Prevent new violations while gradually fixing existing ones +2. Track progress on code style improvements +3. Ensure CI builds don't fail due to pre-existing violations + +## Common Commands + +- **Check violations** + - `bundle exec rake rubocop_gradual` + - `bundle exec rake rubocop_gradual:check` +- **(Safe) Autocorrect violations, and update lockfile if no new violations** + - `bundle exec rake rubocop_gradual:autocorrect` +- **Force update the lock file (w/o autocorrect) to match violations present in code** + - `bundle exec rake rubocop_gradual:force_update` + +## Workflow + +1. Before submitting a PR, run `bundle exec rake rubocop_gradual:autocorrect` + a. or just the default `bundle exec rake`, as autocorrection is a pre-requisite of the default task. +2. If there are new violations, either: + - Fix them in your code + - Run `bundle exec rake rubocop_gradual:force_update` to update the lock file (only for violations you can't fix immediately) +3. Commit the updated `.rubocop_gradual.lock` file along with your changes + +## Never add inline RuboCop disables + +Do not add inline `rubocop:disable` / `rubocop:enable` comments anywhere in the codebase (including specs, except when following the few existing `rubocop:disable` patterns for a rule already being disabled elsewhere in the code). We handle exceptions in two supported ways: + +- Permanent/structural exceptions: prefer adjusting the RuboCop configuration (e.g., in `.rubocop.yml`) to exclude a rule for a path or file pattern when it makes sense project-wide. +- Temporary exceptions while improving code: record the current violations in `.rubocop_gradual.lock` via the gradual workflow: + - `bundle exec rake rubocop_gradual:autocorrect` (preferred; will autocorrect what it can and update the lock only if no new violations were introduced) + - If needed, `bundle exec rake rubocop_gradual:force_update` (as a last resort when you cannot fix the newly reported violations immediately) + +In general, treat the rules as guidance to follow; fix violations rather than ignore them. For example, RSpec conventions in this project expect `described_class` to be used in specs that target a specific class under test. + +## Benefits of rubocop_gradual + +- Allows incremental adoption of code style rules +- Prevents CI failures due to pre-existing violations +- Provides a clear record of code style debt +- Enables focused efforts on improving code quality over time diff --git a/gems/kettle-jem/lib/kettle/jem/templates/Rakefile.example b/gems/kettle-jem/lib/kettle/jem/templates/Rakefile.example new file mode 100644 index 0000000..88900ae --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/Rakefile.example @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# {KJ|FREEZE_TOKEN}:freeze +# To retain chunks of comments & code during {KJ|GEM_NAME} templating: +# Wrap custom sections with freeze markers (e.g., as above and below this comment chunk). +# {KJ|GEM_NAME} will then preserve content between those markers across template runs. +# {KJ|FREEZE_TOKEN}:unfreeze + +# {KJ|GEM_NAME} Rakefile v{KJ|KETTLE_JEM_VERSION} - {KJ|TEMPLATE_RUN_DATE} +# Ruby 2.3 (Safe Navigation) or higher required +# +# See LICENSE.md for license information. +# +# Copyright (c) {KJ|TEMPLATE_RUN_YEAR} {KJ|AUTHOR:NAME} ({KJ|AUTHOR:DOMAIN}) +# +# Expected to work in any project that uses Bundler. +# +# Sets up tasks for appraisal2, floss_funding, kettle-jem, kettle-dev, rspec, minitest, rubocop_gradual, reek, yard, and stone_checksums. +# +# rake appraisal:install # Install Appraisal gemfiles (initial setup... +# rake appraisal:reset # Delete Appraisal lockfiles (gemfiles/*.gemfile.lock) +# rake appraisal:update # Update Appraisal gemfiles and run RuboCop... +# rake bench # Run all benchmarks (alias for bench:run) +# rake bench:list # List available benchmark scripts +# rake bench:run # Run all benchmark scripts (skips on CI) +# rake build:generate_checksums # Generate both SHA256 & SHA512 checksums i... +# rake bundle:audit:check # Checks the Gemfile.lock for insecure depe... +# rake bundle:audit:update # Updates the bundler-audit vulnerability d... +# rake ci:act[opt] # Run 'act' with a selected workflow +# rake coverage # Run specs w/ coverage and open results in... +# rake default # Default tasks aggregator +# rake install # Build and install {KJ|GEM_NAME}-1.0.0.gem in... +# rake install:local # Build and install {KJ|GEM_NAME}-1.0.0.gem in... +# rake kettle:jem:install # Install {KJ|GEM_NAME} GitHub automation and ... +# rake kettle:jem:selftest # Self-test: template {KJ|GEM_NAME} against itse... +# rake kettle:jem:template # Template {KJ|GEM_NAME} files into the curren... +# rake reek # Check for code smells +# rake reek:update # Run reek and store the output into the RE... +# rake release[remote] # Create tag v1.0.0 and build and push kett... +# rake rubocop_gradual # Run RuboCop Gradual +# rake rubocop_gradual:autocorrect # Run RuboCop Gradual with autocorrect (onl... +# rake rubocop_gradual:autocorrect_all # Run RuboCop Gradual with autocorrect (saf... +# rake rubocop_gradual:check # Run RuboCop Gradual to check the lock file +# rake rubocop_gradual:force_update # Run RuboCop Gradual to force update the l... +# rake rubocop_gradual_debug # Run RuboCop Gradual +# rake rubocop_gradual_debug:autocorrect # Run RuboCop Gradual with autocorrect (onl... +# rake rubocop_gradual_debug:autocorrect_all # Run RuboCop Gradual with autocorrect (saf... +# rake rubocop_gradual_debug:check # Run RuboCop Gradual to check the lock file +# rake rubocop_gradual_debug:force_update # Run RuboCop Gradual to force update the l... +# rake spec # Run RSpec code examples +# rake test # Run tests +# rake yard # Generate YARD Documentation +# + +# :nocov: +require "bundler/gem_tasks" if !Dir[File.join(__dir__, "*.gemspec")].empty? +# :nocov: + +# Define a base default task early so other files can enhance it. +desc "Default tasks aggregator" +task :default do + puts "Default task complete." +end + +# External gems that define tasks - add here! +require "kettle/dev" + +### TEMPLATING TASKS +begin + require "kettle/jem" +rescue LoadError + desc("(stub) kettle:jem:selftest is unavailable") + task("kettle:jem:selftest") do + warn("NOTE: kettle-jem isn't installed, or is disabled for #{RUBY_VERSION} in the current environment") + end +end + +### RELEASE TASKS +# Setup stone_checksums +begin + require "stone_checksums" +rescue LoadError + desc("(stub) build:generate_checksums is unavailable") + task("build:generate_checksums") do + warn("NOTE: stone_checksums isn't installed, or is disabled for #{RUBY_VERSION} in the current environment") + end +end diff --git a/gems/kettle-jem/lib/kettle/jem/templates/SECURITY.md.example b/gems/kettle-jem/lib/kettle/jem/templates/SECURITY.md.example new file mode 100755 index 0000000..ee1be7d --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/SECURITY.md.example @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|----------|-----------| +| 1.latest | ✅ | + +## Security contact information + +To report a security vulnerability, please use the +[Tidelift security contact](https://tidelift.com/security). +Tidelift will coordinate the fix and disclosure. + +## Additional Support + +If you are interested in support for versions older than the latest release, +please consider sponsoring the project / maintainer @ https://liberapay.com/{KJ|FUNDING:LIBERAPAY}/donate, +or find other sponsorship links in the [README]. + +[README]: README.md diff --git a/gems/kettle-jem/lib/kettle/jem/templates/bin/setup.example b/gems/kettle-jem/lib/kettle/jem/templates/bin/setup.example new file mode 100644 index 0000000..9b22169 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/bin/setup.example @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +quiet=false +bundle_args=(install) + +for arg in "$@"; do + case "$arg" in + --quiet) + quiet=true + bundle_args+=(--quiet) + ;; + -h|--help) + cat <<'USAGE' +Usage: bin/setup [--quiet] + +Options: + --quiet Pass --quiet to bundle install and suppress shell tracing. +USAGE + exit 0 + ;; + *) + printf 'bin/setup: unknown option: %s\n' "$arg" >&2 + exit 2 + ;; + esac +done + +if [ "$quiet" != "true" ]; then + set -vx +fi + +bundle "${bundle_args[@]}" + +# Do any other automated setup that you need to do here diff --git a/gems/kettle-jem/lib/kettle/jem/templates/certs/pboling.pem.example b/gems/kettle-jem/lib/kettle/jem/templates/certs/pboling.pem.example new file mode 100644 index 0000000..d5c7e8b --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/certs/pboling.pem.example @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEgDCCAuigAwIBAgIBATANBgkqhkiG9w0BAQsFADBDMRUwEwYDVQQDDAxwZXRl +ci5ib2xpbmcxFTATBgoJkiaJk/IsZAEZFgVnbWFpbDETMBEGCgmSJomT8ixkARkW +A2NvbTAeFw0yNTA1MDQxNTMzMDlaFw00NTA0MjkxNTMzMDlaMEMxFTATBgNVBAMM +DHBldGVyLmJvbGluZzEVMBMGCgmSJomT8ixkARkWBWdtYWlsMRMwEQYKCZImiZPy +LGQBGRYDY29tMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAruUoo0WA +uoNuq6puKWYeRYiZekz/nsDeK5x/0IEirzcCEvaHr3Bmz7rjo1I6On3gGKmiZs61 +LRmQ3oxy77ydmkGTXBjruJB+pQEn7UfLSgQ0xa1/X3kdBZt6RmabFlBxnHkoaGY5 +mZuZ5+Z7walmv6sFD9ajhzj+oIgwWfnEHkXYTR8I6VLN7MRRKGMPoZ/yvOmxb2DN +coEEHWKO9CvgYpW7asIihl/9GMpKiRkcYPm9dGQzZc6uTwom1COfW0+ZOFrDVBuV +FMQRPswZcY4Wlq0uEBLPU7hxnCL9nKK6Y9IhdDcz1mY6HZ91WImNslOSI0S8hRpj +yGOWxQIhBT3fqCBlRIqFQBudrnD9jSNpSGsFvbEijd5ns7Z9ZMehXkXDycpGAUj1 +to/5cuTWWw1JqUWrKJYoifnVhtE1o1DZ+LkPtWxHtz5kjDG/zR3MG0Ula0UOavlD +qbnbcXPBnwXtTFeZ3C+yrWpE4pGnl3yGkZj9SMTlo9qnTMiPmuWKQDatAgMBAAGj +fzB9MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgSwMB0GA1UdDgQWBBQE8uWvNbPVNRXZ +HlgPbc2PCzC4bjAhBgNVHREEGjAYgRZwZXRlci5ib2xpbmdAZ21haWwuY29tMCEG +A1UdEgQaMBiBFnBldGVyLmJvbGluZ0BnbWFpbC5jb20wDQYJKoZIhvcNAQELBQAD +ggGBAJbnUwfJQFPkBgH9cL7hoBfRtmWiCvdqdjeTmi04u8zVNCUox0A4gT982DE9 +wmuN12LpdajxZONqbXuzZvc+nb0StFwmFYZG6iDwaf4BPywm2e/Vmq0YG45vZXGR +L8yMDSK1cQXjmA+ZBKOHKWavxP6Vp7lWvjAhz8RFwqF9GuNIdhv9NpnCAWcMZtpm +GUPyIWw/Cw/2wZp74QzZj6Npx+LdXoLTF1HMSJXZ7/pkxLCsB8m4EFVdb/IrW/0k +kNSfjtAfBHO8nLGuqQZVH9IBD1i9K6aSs7pT6TW8itXUIlkIUI2tg5YzW6OFfPzq +QekSkX3lZfY+HTSp/o+YvKkqWLUV7PQ7xh1ZYDtocpaHwgxe/j3bBqHE+CUPH2vA +0V/FwdTRWcwsjVoOJTrYcff8pBZ8r2MvtAc54xfnnhGFzeRHfcltobgFxkAXdE6p +DVjBtqT23eugOqQ73umLcYDZkc36vnqGxUBSsXrzY9pzV5gGr2I8YUxMqf6ATrZt +L9nRqA== +-----END CERTIFICATE----- diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gem.gemspec.example b/gems/kettle-jem/lib/kettle/jem/templates/gem.gemspec.example new file mode 100644 index 0000000..130ab29 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gem.gemspec.example @@ -0,0 +1,159 @@ +# coding: utf-8 +# frozen_string_literal: true + +# {KJ|FREEZE_TOKEN}:freeze +# To retain chunks of comments & code during {KJ|GEM_NAME} templating: +# Wrap custom sections with freeze markers (e.g., as above and below this comment chunk). +# {KJ|GEM_NAME} will then preserve content between those markers across template runs. +# {KJ|FREEZE_TOKEN}:unfreeze + +gem_version = + if RUBY_VERSION >= "3.1" # rubocop:disable Gemspec/RubyVersionGlobalsUsage + # Loading Version into an anonymous module allows version.rb to get code coverage from SimpleCov! + # See: https://github.com/simplecov-ruby/simplecov/issues/557#issuecomment-2630782358 + # See: https://github.com/panorama-ed/memo_wise/pull/397 + Module.new.tap { |mod| Kernel.load("#{__dir__}/lib/{KJ|GEM_NAME_PATH}/version.rb", mod) }::{KJ|NAMESPACE}::Version::VERSION + else + # NOTE: Use __FILE__ or __dir__ until removal of Ruby 1.x support + # __dir__ introduced in Ruby 1.9.1 + # lib = File.expand_path("../lib", __FILE__) + lib = File.expand_path("lib", __dir__) + $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) + require "{KJ|GEM_NAME_PATH}/version" + {KJ|NAMESPACE}::Version::VERSION + end + +Gem::Specification.new do |spec| + spec.name = "{KJ|GEM_NAME}" + spec.version = gem_version + spec.authors = ["{KJ|AUTHOR:NAME}"] + spec.email = ["{KJ|AUTHOR:EMAIL}"] + + spec.summary = "{KJ|PROJECT_EMOJI} " + spec.description = "{KJ|PROJECT_EMOJI} " + spec.homepage = "https://github.com/{KJ|GH_ORG}/{KJ|GEM_NAME}" + spec.licenses = ["MIT"] + spec.required_ruby_version = ">= 2.3.0" + + # Linux distros often package gems and securely certify them independent + # of the official RubyGem certification process. Allowed via ENV["SKIP_GEM_SIGNING"] + # Ref: https://gitlab.com/ruby-oauth/version_gem/-/issues/3 + # Hence, only enable signing if `SKIP_GEM_SIGNING` is not set in ENV. + # See CONTRIBUTING.md + unless ENV.include?("SKIP_GEM_SIGNING") + user_cert = "certs/#{ENV.fetch("GEM_CERT_USER", ENV["USER"])}.pem" + cert_file_path = File.join(__dir__, user_cert) + cert_chain = cert_file_path.split(",") + cert_chain.select! { |fp| File.exist?(fp) } + if cert_file_path && cert_chain.any? + spec.cert_chain = cert_chain + if $PROGRAM_NAME.end_with?("gem") && ARGV[0] == "build" + spec.signing_key = File.join(Gem.user_home, ".ssh", "gem-private_key.pem") + end + end + end + + spec.metadata["homepage_uri"] = "https://{KJ|YARD_HOST}/" + spec.metadata["source_code_uri"] = "#{spec.homepage}/tree/v#{spec.version}" + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/v#{spec.version}/CHANGELOG.md" + spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues" + spec.metadata["documentation_uri"] = "https://www.rubydoc.info/gems/#{spec.name}/#{spec.version}" + spec.metadata["funding_uri"] = "https://github.com/sponsors/{KJ|GH:USER}" + spec.metadata["wiki_uri"] = "#{spec.homepage}/wiki" + spec.metadata["news_uri"] = "https://www.railsbling.com/tags/#{spec.name}" + spec.metadata["discord_uri"] = "https://discord.gg/3qme4XHNKN" + spec.metadata["rubygems_mfa_required"] = "true" + + # Specify which files are part of the released package. + spec.files = Dir[ + # Code / tasks / data (NOTE: exe/ is specified via spec.bindir and spec.executables below) + "lib/**/*.rb", + "lib/**/*.rake", + # Signatures + "sig/**/*.rbs", + ] + + # Automatically included with gem package, no need to list again in files. + spec.extra_rdoc_files = Dir[ + # Files (alphabetical) + "CHANGELOG.md", + "CITATION.cff", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "FUNDING.md", + "LICENSE.txt", + "README.md", + "RUBOCOP.md", + "SECURITY.md", + ] + spec.rdoc_options += [ + "--title", + "#{spec.name} - #{spec.summary}", + "--main", + "README.md", + "--exclude", + "^sig/", + "--line-numbers", + "--inline-source", + "--quiet", + ] + spec.bindir = "exe" + # Listed files are the relative paths from bindir above. + spec.executables = [] + spec.require_paths = ["lib"] + + # Utilities + spec.add_dependency("version_gem", "~> 1.1", ">= 1.1.9") # ruby >= 2.2.0 + + # NOTE: It is preferable to list development dependencies in the gemspec due to increased + # visibility and discoverability. + # However, development dependencies in gemspec will install on + # all versions of Ruby that will run in CI. + # This gem, and its gemspec runtime dependencies, will install on Ruby down to {KJ|MIN_RUBY}. + # This gem, and its gemspec development dependencies, will install on Ruby down to {KJ|MIN_DEV_RUBY}. + # Thus, dev dependencies in gemspec must have + # + # required_ruby_version ">= {KJ|MIN_DEV_RUBY}" (or lower) + # + # Development dependencies that require strictly newer Ruby versions should be in a "gemfile", + # and preferably a modular one (see gemfiles/modular/*.gemfile). + + # Dev, Test, & Release Tasks + spec.add_development_dependency("{KJ|KETTLE_DEV_GEM}", "~> 2.0") # ruby >= 2.3.0 + + # Security + spec.add_development_dependency("bundler-audit", "~> 0.9.3") # ruby >= 2.0.0 + + # Tasks + spec.add_development_dependency("rake", "~> 13.0") # ruby >= 2.2.0 + + # Debugging + spec.add_development_dependency("require_bench", "~> 1.0", ">= 1.0.4") # ruby >= 2.2.0 + + # Testing + spec.add_development_dependency("appraisal2", "~> 3.0", ">= 3.0.6") # ruby >= 1.8.7, for testing against multiple versions of dependencies + spec.add_development_dependency("kettle-test", "~> 2.0", ">= 2.0.0") # ruby >= 2.3 + + # Releasing + spec.add_development_dependency("ruby-progressbar", "~> 1.13") # ruby >= 0 + spec.add_development_dependency("stone_checksums", "~> 1.0", ">= 1.0.3") # ruby >= 2.2.0 + + # Git integration (optional) + # The 'git' gem is optional; {KJ|GEM_NAME} falls back to shelling out to `git` if it is not present. + # The current release of the git gem depends on activesupport, which makes it too heavy to depend on directly + # spec.add_dependency("git", ">= 1.19.1") # ruby >= 2.3 + + # Development tasks + # The cake is a lie. erb v2.2, the oldest release, was never compatible with Ruby 2.3. + # This means we have no choice but to use the erb that shipped with Ruby 2.3 + # /opt/hostedtoolcache/Ruby/2.3.8/x64/lib/ruby/gems/2.3.0/gems/erb-2.2.2/lib/erb.rb:670:in `prepare_trim_mode': undefined method `match?' for "-":String (NoMethodError) + # spec.add_development_dependency("erb", ">= 2.2") # ruby >= 2.3.0, not SemVer, old rubies get dropped in a patch. + spec.add_development_dependency("gitmoji-regex", "~> 1.0", ">= 1.0.3") # ruby >= 2.3.0 + + # HTTP recording for deterministic specs + # In Ruby 3.5 (HEAD) the CGI library has been pared down, so we also need to depend on gem "cgi" for ruby@head + # This is done in the "head" appraisal. + # See: https://github.com/vcr/vcr/issues/1057 + # spec.add_development_dependency("vcr", ">= 4") # 6.0 claims to support ruby >= 2.3, but fails on ruby 2.4 + # spec.add_development_dependency("webmock", ">= 3") # Last version to support ruby >= 2.3 +end diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/benchmark/r4/v0.5.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/benchmark/r4/v0.5.gemfile.example new file mode 100644 index 0000000..dec6508 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/benchmark/r4/v0.5.gemfile.example @@ -0,0 +1,2 @@ +# Ruby >= 2.1 +gem "benchmark", "~> 0.5", ">= 0.5.0" # Removed from Std Lib in Ruby 4.0 diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/benchmark/vHEAD.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/benchmark/vHEAD.gemfile.example new file mode 100644 index 0000000..e1eb20c --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/benchmark/vHEAD.gemfile.example @@ -0,0 +1,2 @@ +# Ruby >= 2.1 +gem "benchmark", github: "ruby/benchmark", branch: "master" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/coverage.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/coverage.gemfile.example new file mode 100755 index 0000000..fb3dd08 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/coverage.gemfile.example @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# We run code coverage on the latest version of Ruby only. + +# Set KETTLE_RB_DEV=true for local development with path-based dependencies. +# When false (default / CI), remote released gems are used. +if ENV.fetch("KETTLE_RB_DEV", "false").casecmp("false").zero? + # Coverage (remote/released) + platform :mri do + gem "kettle-soup-cover", "~> 1.0", ">= 1.0.10", require: false + end +else + eval_gemfile "coverage_local.gemfile" +end diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/coverage_local.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/coverage_local.gemfile.example new file mode 100644 index 0000000..89b92c5 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/coverage_local.gemfile.example @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Local path overrides for development. +# Loaded by the associated non-local gemfile when KETTLE_RB_DEV != "false". + +require "nomono/bundler" unless defined?(Nomono) + +local_gems = %w[kettle-dev kettle-test kettle-soup-cover turbo_tests2] + +# export VENDORED_GEMS=kettle-dev,kettle-test,kettle-soup-cover,turbo_tests2 +platform :mri do + eval_nomono_gems( + gems: local_gems, + prefix: "KETTLE_RB", + path_env: "KETTLE_RB_DEV", + vendored_gems_env: "VENDORED_GEMS", + vendor_gem_dir_env: "VENDOR_GEM_DIR", + debug_env: "KETTLE_DEV_DEBUG", + ) +end diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/debug.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/debug.gemfile.example new file mode 100644 index 0000000..4e15b1a --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/debug.gemfile.example @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# To retain during kettle-jem templating: +# kettle-jem:freeze +# # ... your code +# kettle-jem:unfreeze +# + +# Ex-Standard Library gems +# irb is included in main Gemfile (and unlocked_deps Appraisal), so it can't be included here. +# gem "irb", "~> 1.15", ">= 1.15.2" # removed from stdlib in 3.5 + +platform :mri do + # Debugging - Ensure ENV["DEBUG"] == "true" to use debuggers within spec suite + # Use binding.break, binding.b, or debugger in code + gem "debug", ">= 1.1" # ruby >= 2.7 + + # Dev Console - Binding.pry - Irb replacement + # gem "pry", "~> 0.14" # ruby >= 2.0 +end + +gem "gem_bench", "~> 2.0", ">= 2.0.5" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/documentation.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/documentation.gemfile.example new file mode 100755 index 0000000..d2f5eb8 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/documentation.gemfile.example @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# To retain during kettle-jem templating: +# kettle-jem:freeze +# # ... your code +# kettle-jem:unfreeze +# + +# Documentation +# Documentation +gem "kramdown", "~> 2.5", ">= 2.5.1", require: false # Ruby >= 2.5 +gem "kramdown-parser-gfm", "~> 1.1", require: false # Ruby >= 2.3 +gem "yaml-converter", "~> 0.1", require: false # Ruby >= 3.2 +gem "yard", "~> 0.9", ">= 0.9.37", require: false +gem "yard-junk", "~> 0.1", ">= 0.1.0", require: false # Ruby >= 3.1 +gem "yard-relative_markdown_links", "~> 0.6", require: false + +if ENV.fetch("KETTLE_RB_DEV", "false").casecmp("false").zero? + gem "yard-fence", "~> 0.8", ">= 0.8.2", require: false # Ruby >= 3.2 + gem "yard-timekeeper", "~> 0.1", require: false + gem "yard-yaml", "~> 0.1", require: false +else + eval_gemfile "documentation_local.gemfile" +end + +# Std Lib extractions +gem "rdoc", "~> 6.11", require: false diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/documentation_local.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/documentation_local.gemfile.example new file mode 100644 index 0000000..aaedfb0 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/documentation_local.gemfile.example @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Local path overrides for development. +# Loaded by the associated non-local gemfile when KETTLE_RB_DEV != "false". + +require "nomono/bundler" unless defined?(Nomono) + +local_gems = %w[yard-fence yard-timekeeper] + +# export VENDORED_GEMS=yard-fence,yard-timekeeper +eval_nomono_gems( + gems: local_gems, + prefix: "KETTLE_RB", + path_env: "KETTLE_RB_DEV", + vendored_gems_env: "VENDORED_GEMS", + vendor_gem_dir_env: "VENDOR_GEM_DIR", + debug_env: "KETTLE_DEV_DEBUG", +) diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r2.3/default.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r2.3/default.gemfile.example new file mode 100644 index 0000000..ca868e8 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r2.3/default.gemfile.example @@ -0,0 +1,6 @@ +# The cake is a lie. +# erb v2.2, the oldest release, was never compatible with Ruby 2.3. +# In addition, erb does not follow SemVer, and old rubies get dropped in a patch. +# This means we have no choice but to use the erb that shipped with Ruby 2.3 +# /opt/hostedtoolcache/Ruby/2.3.8/x64/lib/ruby/gems/2.3.0/gems/erb-2.2.2/lib/erb.rb:670:in `prepare_trim_mode': undefined method `match?' for "-":String (NoMethodError) +# gem "erb", ">= 2.2" # ruby >= 2.3.0 diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r2.6/v2.2.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r2.6/v2.2.gemfile.example new file mode 100644 index 0000000..7cd8574 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r2.6/v2.2.gemfile.example @@ -0,0 +1,3 @@ +# Ruby >= 2.3.0 (claimed, but not true, minimum support is Ruby 2.4) +# Last version supporting Ruby <= 2.6 +gem "erb", "~> 2.2.2" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r2/v3.0.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r2/v3.0.gemfile.example new file mode 100644 index 0000000..c03bd8d --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r2/v3.0.gemfile.example @@ -0,0 +1 @@ +gem "erb", "~> 3.0" # ruby >= 2.7.0 diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r3.1/v4.0.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r3.1/v4.0.gemfile.example new file mode 100644 index 0000000..2e9046d --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r3.1/v4.0.gemfile.example @@ -0,0 +1,2 @@ +# last version compatible with Ruby 3.1 +gem "erb", "~> 4.0" # ruby >= 2.7.0 diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r3/v5.0.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r3/v5.0.gemfile.example new file mode 100644 index 0000000..97033fa --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r3/v5.0.gemfile.example @@ -0,0 +1 @@ +gem "erb", "~> 5.0" # ruby >= 3.2.0 diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r4/v5.0.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r4/v5.0.gemfile.example new file mode 100644 index 0000000..97033fa --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/r4/v5.0.gemfile.example @@ -0,0 +1 @@ +gem "erb", "~> 5.0" # ruby >= 3.2.0 diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/vHEAD.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/vHEAD.gemfile.example new file mode 100644 index 0000000..48fee42 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/erb/vHEAD.gemfile.example @@ -0,0 +1,2 @@ +# Ruby >= 3.2 +gem "erb", github: "ruby/erb", branch: "master" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r2.4/v0.1.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r2.4/v0.1.gemfile.example new file mode 100644 index 0000000..cabf980 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r2.4/v0.1.gemfile.example @@ -0,0 +1,3 @@ +# Ruby >= 0 +# Last version supporting Ruby <= 2.4 +gem "mutex_m", "~> 0.1" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r2/v0.3.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r2/v0.3.gemfile.example new file mode 100644 index 0000000..42e9d9b --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r2/v0.3.gemfile.example @@ -0,0 +1,2 @@ +# Ruby >= 2.5 +gem "mutex_m", "~> 0.2" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r3/v0.3.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r3/v0.3.gemfile.example new file mode 100644 index 0000000..42e9d9b --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r3/v0.3.gemfile.example @@ -0,0 +1,2 @@ +# Ruby >= 2.5 +gem "mutex_m", "~> 0.2" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r4/v0.3.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r4/v0.3.gemfile.example new file mode 100644 index 0000000..42e9d9b --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r4/v0.3.gemfile.example @@ -0,0 +1,2 @@ +# Ruby >= 2.5 +gem "mutex_m", "~> 0.2" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/vHEAD.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/vHEAD.gemfile.example new file mode 100644 index 0000000..8af3b6f --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/mutex_m/vHEAD.gemfile.example @@ -0,0 +1,2 @@ +# Ruby >= 2.5 (dependency of omniauth) +gem "mutex_m", github: "ruby/mutex_m", branch: "master" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/optional.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/optional.gemfile.example new file mode 100644 index 0000000..ec7063e --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/optional.gemfile.example @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# To retain during {KJ|GEM_NAME} templating: +# {KJ|FREEZE_TOKEN}:freeze +# # ... your code +# {KJ|FREEZE_TOKEN}:unfreeze +# + +# Optional dependencies are not depended on directly, but may be used if present. + +# Required for kettle-pre-release +# URL parsing with Unicode support (falls back to URI if not available) +gem "addressable", ">= 2.8", "< 3" # ruby >= 2.2 diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r2.3/recording.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r2.3/recording.gemfile.example new file mode 100644 index 0000000..0b7901f --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r2.3/recording.gemfile.example @@ -0,0 +1,16 @@ +# HTTP recording for deterministic specs +# It seems that somehow just having a newer version of appraisal installed breaks +# Ruby 2.3 and 2.4 even if their bundle specifies an older version, +# and as a result it can only be a dependency in the appraisals. +# Putting it here in recording.gemfile will satisfy the rubies that can load latest vcr. +# | An error occurred while loading spec_helper. +# | Failure/Error: require "vcr" +# | +# | NoMethodError: +# | undefined method `delete_prefix' for "CONTENT_LENGTH":String +# | # ./spec/config/vcr.rb:3:in `require' +# | # ./spec/config/vcr.rb:3:in `' +# | # ./spec/spec_helper.rb:8:in `require_relative' +# | # ./spec/spec_helper.rb:8:in `' +gem "vcr", "< 6" # 6.0.0 appears to have been released accidentally without changing required_ruby_version in the gemspec. +gem "webmock", "~> 3.0" # Last version to support ruby >= 2.3 diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r2.4/recording.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r2.4/recording.gemfile.example new file mode 100644 index 0000000..53f8e3d --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r2.4/recording.gemfile.example @@ -0,0 +1,18 @@ +# HTTP recording for deterministic specs +# It seems that somehow just having a newer version of appraisal installed breaks +# Ruby 2.3 and 2.4 even if their bundle specifies an older version, +# and as a result it can only be a dependency in the appraisals. +# Putting it here in recording.gemfile will satisfy the rubies that can load latest vcr. +# | An error occurred while loading spec_helper. +# | Failure/Error: require "vcr" +# | +# | NoMethodError: +# | undefined method `delete_prefix' for "CONTENT_LENGTH":String +# | # ./spec/config/vcr.rb:3:in `require' +# | # ./spec/config/vcr.rb:3:in `' +# | # ./spec/spec_helper.rb:8:in `require_relative' +# | # ./spec/spec_helper.rb:8:in `' +gem "vcr", "< 4" +gem "webmock", ">= 3" + +# For now turning off Ruby 2.4 testing. diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r2.5/recording.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r2.5/recording.gemfile.example new file mode 100644 index 0000000..506a0c6 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r2.5/recording.gemfile.example @@ -0,0 +1,16 @@ +# HTTP recording for deterministic specs +# It seems that somehow just having a newer version of appraisal installed breaks +# Ruby 2.3 and 2.4 even if their bundle specifies an older version, +# and as a result it can only be a dependency in the appraisals. +# Putting it here in recording.gemfile will satisfy the rubies that can load latest vcr. +# | An error occurred while loading spec_helper. +# | Failure/Error: require "vcr" +# | +# | NoMethodError: +# | undefined method `delete_prefix' for "CONTENT_LENGTH":String +# | # ./spec/config/vcr.rb:3:in `require' +# | # ./spec/config/vcr.rb:3:in `' +# | # ./spec/spec_helper.rb:8:in `require_relative' +# | # ./spec/spec_helper.rb:8:in `' +gem "vcr", ">= 6" +gem "webmock", ">= 3" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r3/recording.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r3/recording.gemfile.example new file mode 100644 index 0000000..506a0c6 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r3/recording.gemfile.example @@ -0,0 +1,16 @@ +# HTTP recording for deterministic specs +# It seems that somehow just having a newer version of appraisal installed breaks +# Ruby 2.3 and 2.4 even if their bundle specifies an older version, +# and as a result it can only be a dependency in the appraisals. +# Putting it here in recording.gemfile will satisfy the rubies that can load latest vcr. +# | An error occurred while loading spec_helper. +# | Failure/Error: require "vcr" +# | +# | NoMethodError: +# | undefined method `delete_prefix' for "CONTENT_LENGTH":String +# | # ./spec/config/vcr.rb:3:in `require' +# | # ./spec/config/vcr.rb:3:in `' +# | # ./spec/spec_helper.rb:8:in `require_relative' +# | # ./spec/spec_helper.rb:8:in `' +gem "vcr", ">= 6" +gem "webmock", ">= 3" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r4/recording.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r4/recording.gemfile.example new file mode 100644 index 0000000..506a0c6 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/r4/recording.gemfile.example @@ -0,0 +1,16 @@ +# HTTP recording for deterministic specs +# It seems that somehow just having a newer version of appraisal installed breaks +# Ruby 2.3 and 2.4 even if their bundle specifies an older version, +# and as a result it can only be a dependency in the appraisals. +# Putting it here in recording.gemfile will satisfy the rubies that can load latest vcr. +# | An error occurred while loading spec_helper. +# | Failure/Error: require "vcr" +# | +# | NoMethodError: +# | undefined method `delete_prefix' for "CONTENT_LENGTH":String +# | # ./spec/config/vcr.rb:3:in `require' +# | # ./spec/config/vcr.rb:3:in `' +# | # ./spec/spec_helper.rb:8:in `require_relative' +# | # ./spec/spec_helper.rb:8:in `' +gem "vcr", ">= 6" +gem "webmock", ">= 3" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/vHEAD.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/vHEAD.gemfile.example new file mode 100644 index 0000000..9c91207 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/recording/vHEAD.gemfile.example @@ -0,0 +1,2 @@ +gem "vcr", github: "vcr/vcr", branch: "master" +gem "webmock", github: "bblimke/webmock", branch: "master" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/rspec.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/rspec.gemfile.example new file mode 100644 index 0000000..72fdf8b --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/rspec.gemfile.example @@ -0,0 +1,4 @@ +gem "rspec_junit_formatter", + github: "pboling/rspec_junit_formatter", + branch: "main", + ref: "9e6bcaaedd49477b62878ed568dc4ada5cc7cb5e" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/runtime_heads.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/runtime_heads.gemfile.example new file mode 100644 index 0000000..49dc94f --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/runtime_heads.gemfile.example @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# To retain during {KJ|GEM_NAME} templating: +# {KJ|FREEZE_TOKEN}:freeze +# # ... your code +# {KJ|FREEZE_TOKEN}:unfreeze +# + +# Test against HEAD of runtime dependencies so we can proactively file bugs + +# Ruby >= 2.2 +gem "version_gem", github: "ruby-oauth/version_gem", branch: "main" + +eval_gemfile("x_std_libs/vHEAD.gemfile") diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/shunted.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/shunted.gemfile.example new file mode 100644 index 0000000..77b8ad5 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/shunted.gemfile.example @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# To retain during kettle-jem templating: +# kettle-jem:freeze +# # ... your code +# kettle-jem:unfreeze +# + +# Shunted development dependencies. +# +# These gems have been moved here from the gemspec because they require a +# newer Ruby than the project's effective dev floor: +# +# effective_dev_floor = max(gemspec required_ruby_version, 2.3) +# +# They are loaded via the Appraisals "unlocked" group so that CI can still +# test them on the Rubies they support, while older-Ruby CI jobs simply +# skip this gemfile. +# +# This file is regenerated by `rake template` — add custom gems inside a +# kettle-jem:freeze / kettle-jem:unfreeze block to preserve them. diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r2.4/v0.0.2.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r2.4/v0.0.2.gemfile.example new file mode 100644 index 0000000..94021cf --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r2.4/v0.0.2.gemfile.example @@ -0,0 +1,4 @@ +# !!WARNING!! +# NOT SEMVER +# Last version to support Ruby <= 2.5 +gem "stringio", ">= 0.0.2" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r2/v3.0.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r2/v3.0.gemfile.example new file mode 100644 index 0000000..e85bb18 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r2/v3.0.gemfile.example @@ -0,0 +1,5 @@ +# !!WARNING!! +# NOT SEMVER +# Version 3.0.7 dropped support for Ruby <= 2.7 +# Version 3.0.0 dropped support for Ruby <= 2.4 +gem "stringio", ">= 3.0" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r3/v3.0.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r3/v3.0.gemfile.example new file mode 100644 index 0000000..e85bb18 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r3/v3.0.gemfile.example @@ -0,0 +1,5 @@ +# !!WARNING!! +# NOT SEMVER +# Version 3.0.7 dropped support for Ruby <= 2.7 +# Version 3.0.0 dropped support for Ruby <= 2.4 +gem "stringio", ">= 3.0" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r4/v3.0.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r4/v3.0.gemfile.example new file mode 100644 index 0000000..e85bb18 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/r4/v3.0.gemfile.example @@ -0,0 +1,5 @@ +# !!WARNING!! +# NOT SEMVER +# Version 3.0.7 dropped support for Ruby <= 2.7 +# Version 3.0.0 dropped support for Ruby <= 2.4 +gem "stringio", ">= 3.0" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/vHEAD.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/vHEAD.gemfile.example new file mode 100644 index 0000000..5f2a741 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/stringio/vHEAD.gemfile.example @@ -0,0 +1,2 @@ +# Ruby >= 2.5 (dependency of omniauth) +gem "stringio", github: "ruby/stringio", branch: "master" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/style.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/style.gemfile.example new file mode 100644 index 0000000..89a1272 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/style.gemfile.example @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# To retain during {KJ|GEM_NAME} templating: +# {KJ|FREEZE_TOKEN}:freeze +# # ... your code +# {KJ|FREEZE_TOKEN}:unfreeze +# + +# We run rubocop on the latest version of Ruby, +# but in support of the oldest supported version of Ruby + +gem "reek", "~> 6.5" + +platform :mri do + # gem "rubocop", "~> 1.73", ">= 1.73.2" # constrained by standard + gem "rubocop-packaging", "~> 0.6", ">= 0.6.0" + gem "standard", ">= 1.50" + gem "rubocop-on-rbs", "~> 1.8" # ruby >= 3.1.0 + + if ENV.fetch("RUBOCOP_LTS_LOCAL", "false").casecmp("false").zero? + gem "rubocop-lts", "{KJ|RUBOCOP_LTS_CONSTRAINT}" + # "rubocop-lts-rspec" can't add here until other gems are updated. + gem "rubocop-rspec", "~> 3.6" + gem "{KJ|RUBOCOP_RUBY_GEM}" + else + eval_gemfile("style_local.gemfile") + end +end diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/style_local.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/style_local.gemfile.example new file mode 100644 index 0000000..bee7451 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/style_local.gemfile.example @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# We run rubocop on the latest version of Ruby, +# but in support of the oldest supported version of Ruby + +# Local path overrides for development. +# Loaded by the associated non-local gemfile when RUBOCOP_LTS_LOCAL != "false". + +require "nomono/bundler" unless defined?(Nomono) + +local_gems = %w[rubocop-lts rubocop-lts-rspec {KJ|RUBOCOP_RUBY_GEM} standard-rubocop-lts] + +# export VENDORED_GEMS=rubocop-lts,rubocop-lts-rspec,{KJ|RUBOCOP_RUBY_GEM},standard-rubocop-lts +platform :mri do + eval_nomono_gems( + gems: local_gems, + prefix: "RUBOCOP_LTS", + path_env: "RUBOCOP_LTS_LOCAL", + vendored_gems_env: "VENDORED_GEMS", + vendor_gem_dir_env: "VENDOR_GEM_DIR", + debug_env: "RUBOCOP_LTS_DEBUG", + root: ["src", "rubocop-lts"], + ) +end diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/templating.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/templating.gemfile.example new file mode 100644 index 0000000..788fa28 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/templating.gemfile.example @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +remote = ENV.fetch("KETTLE_RB_DEV", "false").casecmp("false").zero? + +# Cross-platform parsers +gem "parslet" +gem "prism", "~> 1.6" +gem "toml" +gem "unparser", "~> 0.8", ">= 0.8.1" + +platform :mri do + # MRI only Parsers: + # kettle-jem currently uses markly instead of commonmarker, but these can easily be swapped. + # gem "commonmarker" + gem "markly", "~> 0.15", ">= 0.15.3" + gem "rbs", ">= 3.10" # ruby >= 3.1.0 + + # MRI Backend + gem "ruby_tree_sitter", + "~> 2.1", + require: false # DO NOT LOAD, because conflicts with FFI + + if remote + # AST parsing for advanced templating. + # Kettle-rb's Merge Recipes (released) + gem "kettle-jem", "~> 1.0" + end +end + +# Set KETTLE_RB_DEV=true (or path) for local development with path-based dependencies. +# When false (default / CI), released gems are used and explicit parser pins are needed. +# Local path overrides — merge gems bring their own parser dependencies +eval_gemfile "templating_local.gemfile" unless remote diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/templating_local.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/templating_local.gemfile.example new file mode 100644 index 0000000..a123162 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/templating_local.gemfile.example @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Local path overrides for development. +# Loaded by the associated non-local gemfile when KETTLE_RB_DEV != "false". + +require "nomono/bundler" unless defined?(Nomono) + +local_gems = %w[ + tree_haver + ast-merge + bash-merge + kettle-drift + dotenv-merge + json-merge + markdown-merge + prism-merge + psych-merge + rbs-merge + toml-merge + markly-merge + kettle-jem +] + +# export VENDORED_GEMS=bash-merge,dotenv-merge,json-merge,jsonc-merge,kettle-drift,markdown-merge,prism-merge,psych-merge,rbs-merge,toml-merge,markly-merge,kettle-jem +platform :mri do + eval_nomono_gems( + gems: local_gems, + prefix: "KETTLE_RB", + path_env: "KETTLE_RB_DEV", + vendored_gems_env: "VENDORED_GEMS", + vendor_gem_dir_env: "VENDOR_GEM_DIR", + debug_env: "KETTLE_DEV_DEBUG", + ) +end diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs.gemfile.example new file mode 100644 index 0000000..6928708 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs.gemfile.example @@ -0,0 +1,2 @@ +### Std Lib Extracted Gems +eval_gemfile "x_std_libs/r4/libs.gemfile" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.3/libs.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.3/libs.gemfile.example new file mode 100644 index 0000000..2fee8b6 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.3/libs.gemfile.example @@ -0,0 +1,3 @@ +eval_gemfile "../../erb/r2.3/default.gemfile" +eval_gemfile "../../mutex_m/r2.4/v0.1.gemfile" +eval_gemfile "../../stringio/r2.4/v0.0.2.gemfile" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.4/libs.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.4/libs.gemfile.example new file mode 100644 index 0000000..c1bcbd8 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.4/libs.gemfile.example @@ -0,0 +1,3 @@ +eval_gemfile "../../erb/r2.6/v2.2.gemfile" +eval_gemfile "../../mutex_m/r2.4/v0.1.gemfile" +eval_gemfile "../../stringio/r2.4/v0.0.2.gemfile" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.6/libs.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.6/libs.gemfile.example new file mode 100644 index 0000000..beac38c --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.6/libs.gemfile.example @@ -0,0 +1,3 @@ +eval_gemfile "../../erb/r2.6/v2.2.gemfile" +eval_gemfile "../../mutex_m/r2/v0.3.gemfile" +eval_gemfile "../../stringio/r2/v3.0.gemfile" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2/libs.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2/libs.gemfile.example new file mode 100644 index 0000000..441c4f0 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2/libs.gemfile.example @@ -0,0 +1,3 @@ +eval_gemfile "../../erb/r2/v3.0.gemfile" +eval_gemfile "../../mutex_m/r2/v0.3.gemfile" +eval_gemfile "../../stringio/r2/v3.0.gemfile" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r3.1/libs.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r3.1/libs.gemfile.example new file mode 100644 index 0000000..bdab5bd --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r3.1/libs.gemfile.example @@ -0,0 +1,3 @@ +eval_gemfile "../../erb/r3.1/v4.0.gemfile" +eval_gemfile "../../mutex_m/r3/v0.3.gemfile" +eval_gemfile "../../stringio/r3/v3.0.gemfile" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r3/libs.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r3/libs.gemfile.example new file mode 100644 index 0000000..c293a3d --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r3/libs.gemfile.example @@ -0,0 +1,3 @@ +eval_gemfile "../../erb/r3/v5.0.gemfile" +eval_gemfile "../../mutex_m/r3/v0.3.gemfile" +eval_gemfile "../../stringio/r3/v3.0.gemfile" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r4/libs.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r4/libs.gemfile.example new file mode 100644 index 0000000..21f7e99 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r4/libs.gemfile.example @@ -0,0 +1,4 @@ +eval_gemfile "../../erb/r4/v5.0.gemfile" +eval_gemfile "../../mutex_m/r4/v0.3.gemfile" +eval_gemfile "../../stringio/r4/v3.0.gemfile" +eval_gemfile "../../benchmark/r4/v0.5.gemfile" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/vHEAD.gemfile.example b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/vHEAD.gemfile.example new file mode 100644 index 0000000..b38c6fa --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/vHEAD.gemfile.example @@ -0,0 +1,4 @@ +eval_gemfile "../erb/vHEAD.gemfile" +eval_gemfile "../mutex_m/vHEAD.gemfile" +eval_gemfile "../stringio/vHEAD.gemfile" +eval_gemfile "../benchmark/vHEAD.gemfile" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/mise.toml.example b/gems/kettle-jem/lib/kettle/jem/templates/mise.toml.example new file mode 100644 index 0000000..87a7c98 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/mise.toml.example @@ -0,0 +1,27 @@ +# Shared development environment for this gem. +# Local overrides belong in .env.local (loaded via dotenvy through mise). + +[env] +DEBUG = "false" +FLOSS_CFG_FUND_DEBUG = "false" +FLOSS_CFG_FUND_LOGFILE = "tmp/log/debug.log" +FUNDING_ORG = "{KJ|OPENCOLLECTIVE_ORG}" +KETTLE_DEV_DEBUG = "false" +KETTLE_TEST_SILENT = "true" +K_SOUP_COV_COMMAND_NAME = "Test Coverage" +K_SOUP_COV_DO = "true" +K_SOUP_COV_FORMATTERS = "html,xml,rcov,lcov,json,tty" +K_SOUP_COV_MIN_BRANCH = "76" +K_SOUP_COV_MIN_HARD = "true" +K_SOUP_COV_MIN_LINE = "92" +K_SOUP_COV_MULTI_FORMATTERS = "true" +K_SOUP_COV_OPEN_BIN = "" +MAX_ROWS = "1" +OPENCOLLECTIVE_HANDLE = "{KJ|OPENCOLLECTIVE_ORG}" +RUBOCOP_LTS_LOCAL = "false" +_.file = { path = ".env.local", redact = true } +_.path = ["exe", "bin"] +_.source = ".config/mise/env.sh" + +[tools] +ruby = "4.0.2" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/mise.toml.no-osc.example b/gems/kettle-jem/lib/kettle/jem/templates/mise.toml.no-osc.example new file mode 100644 index 0000000..5368c60 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/mise.toml.no-osc.example @@ -0,0 +1,27 @@ +# Shared development environment for this gem. +# Local overrides belong in .env.local (loaded via dotenvy through mise). + +[env] +DEBUG = "false" +FLOSS_CFG_FUND_DEBUG = "false" +FLOSS_CFG_FUND_LOGFILE = "tmp/log/debug.log" +FUNDING_ORG = "false" +KETTLE_DEV_DEBUG = "false" +KETTLE_TEST_SILENT = "true" +K_SOUP_COV_COMMAND_NAME = "Test Coverage" +K_SOUP_COV_DO = "true" +K_SOUP_COV_FORMATTERS = "html,xml,rcov,lcov,json,tty" +K_SOUP_COV_MIN_BRANCH = "76" +K_SOUP_COV_MIN_HARD = "true" +K_SOUP_COV_MIN_LINE = "92" +K_SOUP_COV_MULTI_FORMATTERS = "true" +K_SOUP_COV_OPEN_BIN = "" +MAX_ROWS = "1" +OPENCOLLECTIVE_HANDLE = "false" +RUBOCOP_LTS_LOCAL = "false" +_.file = { path = ".env.local", redact = true } +_.path = ["exe", "bin"] +_.source = ".config/mise/env.sh" + +[tools] +ruby = "4.0.2" diff --git a/gems/kettle-jem/lib/kettle/jem/templates/parsers.toml.example b/gems/kettle-jem/lib/kettle/jem/templates/parsers.toml.example new file mode 100644 index 0000000..9aefac4 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/templates/parsers.toml.example @@ -0,0 +1,19 @@ +# tsdl configuration — tree-sitter grammar versions +# https://github.com/stackmystack/tsdl +# +# Run: tsdl build --out-dir /usr/local/lib +# Or let .devcontainer/scripts/setup-tree-sitter.sh handle it. + +out-dir = "/usr/local/lib" + +[parsers] +json = "v0.24.8" +bash = "v0.25.1" + +[parsers.toml] +ref = "v0.7.0" +from = "https://github.com/tree-sitter-grammars/tree-sitter-toml" + +[parsers.rbs] +ref = "v0.2.2" +from = "https://github.com/joker1007/tree-sitter-rbs" diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index a58560c..8aa1e81 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -456,10 +456,31 @@ def project_files(root, paths) Gem::Specification.new do |spec| spec.name = "example" spec.summary = "Example gem" + spec.required_ruby_version = ">= 3.2" spec.metadata["source_code_uri"] = "https://github.com/acme/example" end RUBY ".kettle-jem.yml" => <<~YAML, + project_emoji: "💎" + tokens: + forge: + gh_user: acme + gl_user: acme + cb_user: acme + sh_user: acme + funding: + patreon: acme + kofi: acme + paypal: acme + buymeacoffee: acme + polar: acme + liberapay: acme + issuehunt: acme + social: + mastodon: "@acme@example.social" + bluesky: acme.example + linktree: acme + devto: acme templates: apply: true entries: @@ -479,9 +500,10 @@ def project_files(root, paths) expect(template_report.dig(:metadata, :template_source_preference, :source_root_path)).to end_with( "lib/kettle/jem/templates" ) - expect(template_report.dig(:request_envelope, :request, :template_content)).to include("# {KJ|GEM_NAME}") - expect(template_report.fetch(:final_content)).to include("# example") - expect(template_report.fetch(:final_content)).to include("Generated with kettle-dev") + expect(template_report.dig(:request_envelope, :request, :template_content)).to include("# {KJ|PROJECT_EMOJI} {KJ|NAMESPACE}") + expect(template_report.fetch(:final_content)).to include("# 💎 Example") + expect(template_report.fetch(:final_content)).to include("Compatible with MRI Ruby 3.2+") + expect(template_report.fetch(:final_content)).to include("https://patreon.com/acme") expect(template_report.fetch(:final_content)).to include("https://github.com/acme/example") described_class.apply_project(root, env: {}) From bf841d5de918d836879a5308ba7b377cd0c5196d Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:49:45 -0600 Subject: [PATCH 46/71] Bootstrap kettle-jem config from templates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 64 ++++++++++++++++++++++++- gems/kettle-jem/spec/thin_slice_spec.rb | 34 +++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index ed871de..7c5d25a 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -144,6 +144,8 @@ def discover_facts(project_root, env: ENV) min_ruby: extract_gemspec_assignment(gemspec, "spec.required_ruby_version"), ), } + bootstrap = kettle_config_bootstrap_facts(project_root, env) + facts[:kettle_config_bootstrap] = bootstrap if bootstrap facts[:author] = author unless author.empty? forge = forge_facts(kettle_config, env) facts[:forge] = forge unless forge.empty? @@ -218,6 +220,9 @@ def recipe_pack(facts) facts: %w[package rubygems ci] ), ] + if facts[:kettle_config_bootstrap] + recipes.unshift(kettle_config_bootstrap_recipe(facts.fetch(:kettle_config_bootstrap))) + end if facts.dig(:ci, :framework_matrix) recipes << recipe_entry( "github_actions_framework_ci", @@ -444,6 +449,8 @@ def execute_recipe(project_root:, recipe:, facts:, files:) "" when /\Agithub_actions_workflow_snippets_/ synchronize_github_actions_workflow_snippets(original) + when "kettle_config_bootstrap" + apply_kettle_config_bootstrap(project_root, recipe) when /\Atemplate_source_preference_/ original when /\Atemplate_source_application_/ @@ -552,7 +559,11 @@ def read_project_files(project_root, pack) end def recipe_template_content(project_root, recipe) - return "" unless %w[supplied_template_source_preference supplied_template_source_application].include?(recipe.fetch(:primitive)) + return "" unless %w[ + supplied_kettle_config_bootstrap + supplied_template_source_preference + supplied_template_source_application + ].include?(recipe.fetch(:primitive)) preference = recipe.fetch(:template_preference) path = File.join( @@ -566,11 +577,18 @@ def apply_template_source(project_root, recipe) resolve_template_tokens(recipe_template_content(project_root, recipe), recipe.fetch(:template_tokens, {})) end + def apply_kettle_config_bootstrap(project_root, recipe) + content = recipe_template_content(project_root, recipe) + tokens = stringify_template_tokens(recipe.fetch(:template_tokens, {})) + content.gsub("{KJ|MIN_DIVERGENCE_THRESHOLD}", tokens.fetch("KJ|MIN_DIVERGENCE_THRESHOLD", "")) + end + def recipe_report_metadata(recipe) metadata = { packaging_recipe: recipe.fetch(:name) } metadata[:delete_file] = true if delete_file_recipe?(recipe) metadata[:template_source_preference] = deep_dup(recipe[:template_preference]) if recipe[:template_preference] metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens] + metadata[:bootstrap_file] = true if recipe.fetch(:primitive) == "supplied_kettle_config_bootstrap" metadata end @@ -628,6 +646,14 @@ def step_report_metadata(recipe, deletion) ) metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens] end + if recipe.fetch(:primitive) == "supplied_kettle_config_bootstrap" + metadata.merge!( + policy_kind: "bootstrap_kettle_config", + operation: "create", + template_source_preference: deep_dup(recipe.fetch(:template_preference)), + ) + metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens] + end return metadata unless deletion metadata.merge( @@ -1399,6 +1425,42 @@ def template_source_preferences(project_root, config, opencollective_disabled: f end end + def kettle_config_bootstrap_facts(project_root, env) + return if File.exist?(File.join(project_root, ".kettle-jem.yml")) + + selected_source = preferred_template_source(PACKAGED_TEMPLATE_ROOT, ".kettle-jem.yml") + return unless selected_source + + { + template_preference: { + target_path: ".kettle-jem.yml", + configured_source: ".kettle-jem.yml", + selected_source: selected_source, + source_relative_path: selected_source, + source_root: "packaged", + source_root_path: PACKAGED_TEMPLATE_ROOT, + selection_reason: template_source_selection_reason(".kettle-jem.yml", selected_source), + apply: true, + }, + min_divergence_threshold: preferred_template_token_value(nil, nil, env, "KJ_MIN_DIVERGENCE_THRESHOLD").to_s, + } + end + + def kettle_config_bootstrap_recipe(bootstrap) + recipe = recipe_entry( + "kettle_config_bootstrap", + ".kettle-jem.yml", + "yaml", + "supplied_kettle_config_bootstrap", + facts: %w[kettle_config_bootstrap] + ) + recipe[:template_preference] = bootstrap.fetch(:template_preference) + recipe[:template_tokens] = { + "KJ|MIN_DIVERGENCE_THRESHOLD" => bootstrap.fetch(:min_divergence_threshold).to_s, + } + recipe + end + def template_source_preference(project_root, template_root, entry, opencollective_disabled: false, apply_templates: false) source_path, target_path = template_entry_paths(entry) return nil if source_path.to_s.empty? || target_path.to_s.empty? diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 8aa1e81..1d9accf 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -511,6 +511,40 @@ def project_files(root, paths) end end + it "bootstraps kettle config from packaged reference template when missing" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-config-bootstrap-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + }) + + plan = described_class.plan_project(root, env: { "KJ_MIN_DIVERGENCE_THRESHOLD" => "7" }) + bootstrap_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "kettle_config_bootstrap" + end + expect(bootstrap_report.fetch(:changed)).to be(true) + expect(bootstrap_report.fetch(:relative_path)).to eq(".kettle-jem.yml") + expect(bootstrap_report.dig(:metadata, :bootstrap_file)).to be(true) + expect(bootstrap_report.dig(:metadata, :template_source_preference)).to include( + selected_source: ".kettle-jem.yml.example", + source_relative_path: ".kettle-jem.yml.example", + source_root: "packaged" + ) + expect(bootstrap_report.fetch(:final_content)).to include("# kettle-jem configuration file") + expect(bootstrap_report.fetch(:final_content)).to include("min_divergence_threshold: 7") + expect(bootstrap_report.fetch(:final_content)).to include("# tokens - values for {KJ|...} placeholders used across template files") + + described_class.apply_project(root, env: { "KJ_MIN_DIVERGENCE_THRESHOLD" => "7" }) + expect(File.read(File.join(root, ".kettle-jem.yml"))).to eq(bootstrap_report.fetch(:final_content)) + end + end + it "honors author template token config and environment overrides" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From 4f547f38e5aa8ee2227de0b9886b683fb060530e Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:51:32 -0600 Subject: [PATCH 47/71] Add kettle-jem template strategy lookup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 75 +++++++++++++++++++++++-- gems/kettle-jem/spec/thin_slice_spec.rb | 47 ++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 7c5d25a..e2db0ef 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -16,6 +16,7 @@ module Jem OPENCOLLECTIVE_DISABLED_FILES = %w[.opencollective.yml .github/workflows/opencollective.yml].freeze FILE_DELETION_PRIMITIVES = %w[supplied_obsolete_file_deletion supplied_disabled_opencollective_file_deletion].freeze PACKAGED_TEMPLATE_ROOT = File.expand_path("jem/templates", __dir__) + SUPPORTED_TEMPLATE_STRATEGIES = %i[merge accept_template keep_destination raw_copy].freeze TEMPLATE_TOKEN_CONFIG = Token::Resolver::Config.new(separators: ["|", ":"]).freeze EMPTY_TEMPLATE_TOKENS = %w[KJ|COPYRIGHT_PREFIX KJ|MIN_DIVERGENCE_THRESHOLD].freeze README_TOP_LOGO_MODE_DEFAULT = "org_and_project" @@ -454,7 +455,7 @@ def execute_recipe(project_root:, recipe:, facts:, files:) when /\Atemplate_source_preference_/ original when /\Atemplate_source_application_/ - apply_template_source(project_root, recipe) + apply_template_source(project_root, recipe, original) when "rakefile_scaffold_cleanup" deletion.fetch(:content) else @@ -573,8 +574,14 @@ def recipe_template_content(project_root, recipe) File.read(path) end - def apply_template_source(project_root, recipe) - resolve_template_tokens(recipe_template_content(project_root, recipe), recipe.fetch(:template_tokens, {})) + def apply_template_source(project_root, recipe, original) + strategy = recipe.dig(:template_preference, :strategy).to_s + return original if strategy == "keep_destination" + + content = recipe_template_content(project_root, recipe) + return content if strategy == "raw_copy" + + resolve_template_tokens(content, recipe.fetch(:template_tokens, {})) end def apply_kettle_config_bootstrap(project_root, recipe) @@ -1421,7 +1428,14 @@ def template_source_preferences(project_root, config, opencollective_disabled: f apply_templates = templates["apply"] == true entries.filter_map do |entry| - template_source_preference(project_root, root, entry, opencollective_disabled: opencollective_disabled, apply_templates: apply_templates) + template_source_preference( + project_root, + root, + entry, + config, + opencollective_disabled: opencollective_disabled, + apply_templates: apply_templates + ) end end @@ -1461,13 +1475,14 @@ def kettle_config_bootstrap_recipe(bootstrap) recipe end - def template_source_preference(project_root, template_root, entry, opencollective_disabled: false, apply_templates: false) + def template_source_preference(project_root, template_root, entry, config, opencollective_disabled: false, apply_templates: false) source_path, target_path = template_entry_paths(entry) return nil if source_path.to_s.empty? || target_path.to_s.empty? selected_source = preferred_template_source(template_root.fetch(:path), source_path, opencollective_disabled: opencollective_disabled) return nil unless selected_source + strategy_config = template_strategy_config(config, target_path) preference = { target_path: target_path, configured_source: source_path, @@ -1475,6 +1490,7 @@ def template_source_preference(project_root, template_root, entry, opencollectiv selection_reason: template_source_selection_reason(source_path, template_source_display_path(template_root, selected_source)), apply: template_entry_apply?(entry, apply_templates), } + preference[:strategy] = strategy_config.fetch(:strategy).to_s if strategy_config if template_root.fetch(:kind) == "packaged" preference[:source_relative_path] = selected_source preference[:source_root] = template_root.fetch(:kind) @@ -1483,6 +1499,55 @@ def template_source_preference(project_root, template_root, entry, opencollectiv preference end + def template_strategy_config(config, target_path) + template_file_strategy_config(config, target_path) || template_pattern_strategy_config(config, target_path) + end + + def template_file_strategy_config(config, target_path) + files = config["files"] + return unless files.is_a?(Hash) + + current = files + target_path.to_s.delete_prefix("./").split("/").each do |part| + return unless current.is_a?(Hash) && current.key?(part) + + current = current[part] + end + return unless current.is_a?(Hash) && current.key?("strategy") + + template_strategy_entry(config, nil, current) + end + + def template_pattern_strategy_config(config, target_path) + patterns = config["patterns"] + return unless patterns.is_a?(Array) + + match = patterns.find do |entry| + entry.is_a?(Hash) && + File.fnmatch?(entry["path"].to_s, target_path.to_s, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH) + end + return unless match + + template_strategy_entry(config, match["path"].to_s, match) + end + + def template_strategy_entry(config, path, entry) + strategy = entry["strategy"].to_s.strip.downcase.to_sym + raise ArgumentError, "unknown kettle-jem template strategy: #{entry["strategy"]}" unless SUPPORTED_TEMPLATE_STRATEGIES.include?(strategy) + + result = { strategy: strategy } + result[:path] = path if path + if strategy == :merge + defaults = config["defaults"].is_a?(Hash) ? config["defaults"] : {} + result[:preference] = (entry.key?("preference") ? entry["preference"] : defaults["preference"]).to_s if entry.key?("preference") || defaults.key?("preference") + if entry.key?("add_template_only_nodes") || defaults.key?("add_template_only_nodes") + result[:add_template_only_nodes] = entry.key?("add_template_only_nodes") ? entry["add_template_only_nodes"] : defaults["add_template_only_nodes"] + end + result[:freeze_token] = (entry.key?("freeze_token") ? entry["freeze_token"] : defaults["freeze_token"]).to_s if entry.key?("freeze_token") || defaults.key?("freeze_token") + end + result + end + def template_root(project_root, templates) configured_root = templates["root"].to_s if configured_root.empty? diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 1d9accf..ff44911 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -545,6 +545,53 @@ def project_files(root, paths) end end + it "classifies template entries with files and patterns strategy config" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-template-strategy-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + patterns: + - path: "certs/**" + strategy: raw_copy + files: + README.md: + strategy: keep_destination + templates: + root: template + apply: true + entries: + - README.md + - source: certs/pboling.pem.example + target: certs/pboling.pem + YAML + "README.md" => "# destination\n", + "template/README.md.example" => "# {KJ|GEM_NAME}\n", + "template/certs/pboling.pem.example" => "raw {KJ|GEM_NAME}\n", + }) + + plan = described_class.plan_project(root, env: {}) + readme_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_application_README_md" + end + cert_report = plan[:recipe_reports].find do |report| + report.fetch(:recipe_name) == "template_source_application_certs_pboling_pem" + end + expect(readme_report.fetch(:changed)).to be(false) + expect(readme_report.fetch(:final_content)).to eq("# destination\n") + expect(readme_report.dig(:metadata, :template_source_preference)).to include(strategy: "keep_destination") + expect(cert_report.fetch(:changed)).to be(true) + expect(cert_report.fetch(:final_content)).to eq("raw {KJ|GEM_NAME}\n") + expect(cert_report.dig(:metadata, :template_source_preference)).to include(strategy: "raw_copy") + end + end + it "honors author template token config and environment overrides" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From 16ad6918b9d3c02d436f6ce3ea1ed98b8979874d Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 02:52:53 -0600 Subject: [PATCH 48/71] Use packaged RuboCop style template Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/spec/thin_slice_spec.rb | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index ff44911..d974ec2 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -1050,25 +1050,27 @@ def project_files(root, paths) RUBY ".kettle-jem.yml" => <<~YAML, templates: - root: template + root: packaged apply: true entries: - gemfiles/modular/style.gemfile YAML - "template/gemfiles/modular/style.gemfile.example" => <<~RUBY, - gem "rubocop-lts", "{KJ|RUBOCOP_LTS_CONSTRAINT}" - gem "{KJ|RUBOCOP_RUBY_GEM}" - RUBY }) plan = described_class.plan_project(root, env: {}) template_report = plan[:recipe_reports].find do |report| report.fetch(:recipe_name) == "template_source_application_gemfiles_modular_style_gemfile" end - expect(template_report.fetch(:final_content)).to eq(<<~RUBY) - gem "rubocop-lts", "~> 22.0" - gem "rubocop-ruby3_1" - RUBY + expect(template_report.dig(:metadata, :template_source_preference)).to include( + selected_source: "gemfiles/modular/style.gemfile.example", + source_relative_path: "gemfiles/modular/style.gemfile.example", + source_root: "packaged" + ) + expect(template_report.dig(:request_envelope, :request, :template_content)).to include( + "We run rubocop on the latest version of Ruby" + ) + expect(template_report.fetch(:final_content)).to include('gem "rubocop-lts", "~> 22.0"') + expect(template_report.fetch(:final_content)).to include('gem "rubocop-ruby3_1"') expect(template_report.dig(:metadata, :template_tokens)).to include( "KJ|RUBOCOP_LTS_CONSTRAINT" => "~> 22.0", "KJ|RUBOCOP_RUBY_GEM" => "rubocop-ruby3_1" From 28c2b27c21043482a60dddef0bae6d6031853f39 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:01:29 -0600 Subject: [PATCH 49/71] Add kettle-jem bundle gem system spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spec/system/bundle_gem_scaffold_spec.rb | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb diff --git a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb new file mode 100644 index 0000000..544879e --- /dev/null +++ b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "open3" + +RSpec.describe "bundle gem scaffold + kettle-jem", :system do + let(:tmp_root) { File.expand_path("../../tmp/system", __dir__) } + let(:sandbox_root) { File.join(tmp_root, "bundle-gem-system") } + let(:gem_root) { File.join(sandbox_root, "dummy-gem") } + let(:env) { { "KJ_MIN_DIVERGENCE_THRESHOLD" => "5" } } + + before do + FileUtils.rm_rf(sandbox_root) + FileUtils.mkdir_p(sandbox_root) + scaffold_bundle_gem! + normalize_scaffold_gemspec! + end + + after do + FileUtils.rm_rf(sandbox_root) + end + + def scaffold_bundle_gem! + stdout, stderr, status = Open3.capture3( + "bundle", + "gem", + "dummy-gem", + "--no-git", + "--no-ci", + "--no-mit", + "--no-coc", + "--no-ext", + "--test=rspec", + "--no-changelog", + "--no-linter", + "--no-github-username", + chdir: sandbox_root + ) + expect(status.success?).to be(true), "bundle gem failed\nstdout=#{stdout}\nstderr=#{stderr}" + end + + def normalize_scaffold_gemspec! + path = File.join(gem_root, "dummy-gem.gemspec") + content = File.read(path) + content = content.sub('spec.authors = ["TODO: Write your name"]', 'spec.authors = ["Test User"]') + content = content.sub('spec.email = ["TODO: Write your email address"]', 'spec.email = ["test@example.com"]') + content = content.sub( + 'spec.summary = "TODO: Write a short summary, because RubyGems requires one."', + 'spec.summary = "Dummy gem"' + ) + content = content.sub( + 'spec.description = "TODO: Write a longer description or delete this line."', + 'spec.description = "Dummy gem for kettle-jem system testing."' + ) + content = content.sub( + 'spec.homepage = "TODO: Put your gem\'s website or public repo URL here."', + 'spec.homepage = "https://github.com/acme/dummy-gem"' + ) + content = content.sub( + 'spec.metadata["source_code_uri"] = "TODO: Put your gem\'s public repo URL here."', + 'spec.metadata["source_code_uri"] = "https://github.com/acme/dummy-gem"' + ) + File.write(path, content) + end + + def enable_packaged_templates! + path = File.join(gem_root, ".kettle-jem.yml") + content = File.read(path) + content = content.sub('project_emoji: ""', 'project_emoji: "💎"') + content += <<~YAML + + templates: + root: packaged + apply: true + entries: + - README.md + - gemfiles/modular/style.gemfile + YAML + File.write(path, content) + end + + it "bootstraps config and applies selected packaged templates to a fresh scaffold" do + bootstrap = Kettle::Jem.apply_project(gem_root, env: env) + bootstrap_report = bootstrap.fetch(:recipe_reports).find do |report| + report.fetch(:recipe_name) == "kettle_config_bootstrap" + end + expect(bootstrap_report.fetch(:changed)).to be(true) + expect(File.read(File.join(gem_root, ".kettle-jem.yml"))).to include("# kettle-jem configuration file") + expect(File.read(File.join(gem_root, ".kettle-jem.yml"))).to include("min_divergence_threshold: 5") + expect(bootstrap.fetch(:changed_files)).to include( + ".github/FUNDING.yml", + ".github/workflows/ci.yml", + ".kettle-jem.yml", + "Rakefile" + ) + + enable_packaged_templates! + + apply = Kettle::Jem.apply_project(gem_root, env: env) + expect(apply.fetch(:changed_files)).to include( + "README.md", + "gemfiles/modular/style.gemfile" + ) + expect(File).to exist(File.join(gem_root, ".github/FUNDING.yml")) + expect(File).to exist(File.join(gem_root, ".github/workflows/ci.yml")) + + readme = File.read(File.join(gem_root, "README.md")) + expect(readme).to include("# 💎 Dummy::Gem") + expect(readme).to include("Compatible with MRI Ruby 3.2.0+") + expect(readme).to include("https://github.com/acme/dummy-gem") + + style_gemfile = File.read(File.join(gem_root, "gemfiles/modular/style.gemfile")) + expect(style_gemfile).to include('gem "rubocop-lts", "~> 24.0"') + expect(style_gemfile).to include('gem "rubocop-ruby3_2"') + + expect(File.read(File.join(gem_root, "Rakefile"))).not_to include("bundler/gem_tasks") + end +end From 1cef7408bc130ffd49cd52b47c30ac8ff20d9c9f Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:04:03 -0600 Subject: [PATCH 50/71] Plan kettle-jem packaged template inventory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 45 +++++++++++++++++++++++-- gems/kettle-jem/spec/thin_slice_spec.rb | 43 +++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index e2db0ef..a2ba073 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "fileutils" +require "find" require "token/resolver" require "yaml" require "ast/merge" @@ -1423,8 +1424,8 @@ def template_source_preferences(project_root, config, opencollective_disabled: f return [] unless templates.is_a?(Hash) root = template_root(project_root, templates) - entries = templates["entries"] - return [] unless entries.is_a?(Array) + entries = template_entries(project_root, root, templates) + return [] if entries.empty? apply_templates = templates["apply"] == true entries.filter_map do |entry| @@ -1439,6 +1440,46 @@ def template_source_preferences(project_root, config, opencollective_disabled: f end end + def template_entries(project_root, root, templates) + return templates["entries"] if templates["entries"].is_a?(Array) + return [] if templates.key?("entries") + + template_inventory_entries(project_root, root.fetch(:path)) + end + + def template_inventory_entries(project_root, template_root_path) + logical_paths = [] + Find.find(template_root_path) do |path| + next if File.directory?(path) + + relative_path = path.delete_prefix("#{template_root_path}/") + logical_path = relative_path + .sub(/\.no-osc\.example\z/, "") + .sub(/\.example\z/, "") + logical_paths << logical_path unless logical_path.empty? + end + + logical_paths.uniq.sort.map do |logical_path| + target_path = template_inventory_target_path(project_root, logical_path) + if target_path == logical_path + logical_path + else + { "source" => logical_path, "target" => target_path } + end + end + end + + def template_inventory_target_path(project_root, logical_path) + return ".env.local.example" if logical_path == ".env.local" + + if logical_path.end_with?(".gemspec") + existing_gemspec = Dir.glob(File.join(project_root, "*.gemspec")).sort.first + return File.basename(existing_gemspec) if existing_gemspec + end + + logical_path + end + def kettle_config_bootstrap_facts(project_root, env) return if File.exist?(File.join(project_root, ".kettle-jem.yml")) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index d974ec2..7693b6e 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -592,6 +592,49 @@ def project_files(root, paths) end end + it "plans packaged template inventory when entries are omitted" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-template-inventory-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + funding: + open_collective: false + patterns: + - path: "certs/**" + strategy: raw_copy + files: + AGENTS.md: + strategy: accept_template + templates: + root: packaged + YAML + }) + + plan = described_class.plan_project(root, env: {}) + preferences = plan.dig(:facts, :templates, :source_preferences) + expect(preferences.size).to be > 100 + + agents = preferences.find { |preference| preference.fetch(:target_path) == "AGENTS.md" } + cert = preferences.find { |preference| preference.fetch(:target_path) == "certs/pboling.pem" } + envrc = preferences.find { |preference| preference.fetch(:target_path) == ".envrc" } + env_local = preferences.find { |preference| preference.fetch(:target_path) == ".env.local.example" } + gemspec = preferences.find { |preference| preference.fetch(:target_path) == "example.gemspec" } + + expect(agents).to include(selected_source: "AGENTS.md.example", strategy: "accept_template") + expect(cert).to include(selected_source: "certs/pboling.pem.example", strategy: "raw_copy") + expect(envrc).to include(selected_source: ".envrc.no-osc.example") + expect(env_local).to include(configured_source: ".env.local", selected_source: ".env.local.example") + expect(gemspec).to include(configured_source: "gem.gemspec", selected_source: "gem.gemspec.example") + end + end + it "honors author template token config and environment overrides" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From b3307b32f7df5a688d767874dd8eb7e603eba391 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:07:46 -0600 Subject: [PATCH 51/71] Preserve README sections during template merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 151 +++++++++++++++++++++++- gems/kettle-jem/spec/thin_slice_spec.rb | 97 +++++++++++++++ 2 files changed, 247 insertions(+), 1 deletion(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index a2ba073..cda0620 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -22,6 +22,14 @@ module Jem EMPTY_TEMPLATE_TOKENS = %w[KJ|COPYRIGHT_PREFIX KJ|MIN_DIVERGENCE_THRESHOLD].freeze README_TOP_LOGO_MODE_DEFAULT = "org_and_project" README_TOP_LOGO_MODES = %w[org project org_and_project].freeze + README_DEFAULT_PRESERVE_SECTIONS = ["synopsis", "configuration", "basic usage"].freeze + README_DEFAULT_PRESERVE_PATTERNS = ["note:*"].freeze + README_SECTION_ALIASES = { + "summary" => "synopsis", + "usage" => "basic usage", + "configuration options" => "configuration", + "setup" => "basic usage", + }.freeze README_STATIC_TOP_LOGO_ROW = "[![Galtzo FLOSS Logo by Aboling0, CC BY-SA 4.0][🖼️galtzo-i]][🖼️galtzo-discord] [![ruby-lang Logo, Yukihiro Matsumoto, Ruby Visual Identity Team, CC BY-SA 2.5][🖼️ruby-lang-i]][🖼️ruby-lang]" README_STATIC_TOP_LOGO_REFS = [ "[🖼️galtzo-i]: https://logos.galtzo.com/assets/images/galtzo-floss/avatar-192px.svg", @@ -582,7 +590,16 @@ def apply_template_source(project_root, recipe, original) content = recipe_template_content(project_root, recipe) return content if strategy == "raw_copy" - resolve_template_tokens(content, recipe.fetch(:template_tokens, {})) + resolved = resolve_template_tokens(content, recipe.fetch(:template_tokens, {})) + if recipe.fetch(:target_path) == "README.md" && (strategy.empty? || strategy == "merge") + return merge_readme_template( + template_content: resolved, + destination_content: original, + preserve_config: recipe.dig(:template_preference, :readme_preserve_config) || {} + ) + end + + resolved end def apply_kettle_config_bootstrap(project_root, recipe) @@ -1419,6 +1436,136 @@ def falsey_config?(value) %w[false no 0].include?(value.to_s.strip.downcase) end + def merge_readme_template(template_content:, destination_content:, preserve_config: {}) + return template_content if destination_content.to_s.strip.empty? + + preserved = preserve_readme_sections(template_content, destination_content, preserve_config) + preserve_readme_h1(preserved, destination_content) + end + + def preserve_readme_sections(template_content, destination_content, preserve_config) + template_sections = markdown_sections(template_content) + destination_sections = markdown_sections(destination_content) + destination_lookup = destination_sections.to_h { |section| [section.fetch(:base), section] } + preserve_targets = readme_preserve_targets(template_sections, destination_lookup, preserve_config) + return template_content if preserve_targets.empty? + + lines = template_content.split("\n", -1) + template_sections.reverse_each do |section| + next unless preserve_targets.include?(section.fetch(:base)) + + destination_section = destination_lookup[section.fetch(:base)] || + aliased_readme_destination_section(section.fetch(:base), destination_lookup, preserve_config) + next unless destination_section + + replacement = "#{section.fetch(:heading)}\n#{destination_section.fetch(:body)}".split("\n", -1) + lines[section.fetch(:start)..section.fetch(:end)] = replacement + end + lines.join("\n") + end + + def preserve_readme_h1(merged_content, destination_content) + merged_h1 = markdown_sections(merged_content).find { |section| section.fetch(:level) == 1 } + destination_h1 = markdown_sections(destination_content).find { |section| section.fetch(:level) == 1 } + return merged_content unless merged_h1 && destination_h1 + return merged_content if semantic_readme_heading(destination_h1.fetch(:heading_text)) == semantic_readme_heading(merged_h1.fetch(:heading_text)) + + lines = merged_content.split("\n", -1) + lines[merged_h1.fetch(:start)] = destination_h1.fetch(:heading) + lines.join("\n") + end + + def markdown_sections(content) + lines = content.to_s.split("\n", -1) + headings = [] + in_fence = false + fence_marker = nil + lines.each_with_index do |line, index| + stripped = line.lstrip + if in_fence + if stripped.match?(/\A#{Regexp.escape(fence_marker)}\s*\z/) + in_fence = false + fence_marker = nil + end + next + end + if (fence = stripped.match(/\A(`{3,}|~{3,})/)) + in_fence = true + fence_marker = fence[1] + next + end + next unless (heading = line.match(/\A(\#{1,6})\s+(.+?)\s*#*\s*\z/)) + + headings << { + start: index, + level: heading[1].length, + heading: line, + heading_text: heading[2], + base: normalize_readme_heading(heading[2]), + } + end + + headings.each_with_index.map do |heading, index| + following = headings[(index + 1)..].to_a.find { |candidate| candidate.fetch(:level) <= heading.fetch(:level) } + branch_end = following ? following.fetch(:start) - 1 : lines.length - 1 + body = (lines[(heading.fetch(:start) + 1)..branch_end] || []).join("\n") + heading.merge(end: branch_end, body: body) + end + end + + def readme_preserve_targets(template_sections, destination_lookup, preserve_config) + sections = Array(preserve_config[:sections]).map { |section| normalize_readme_heading(section) } + sections = README_DEFAULT_PRESERVE_SECTIONS.dup if sections.empty? + patterns = Array(preserve_config[:patterns]).map { |pattern| pattern.to_s.strip.downcase } + patterns = README_DEFAULT_PRESERVE_PATTERNS.dup if patterns.empty? + aliases = preserve_config[:aliases] || README_SECTION_ALIASES + targets = sections.dup + template_sections.each do |section| + base = section.fetch(:base) + targets << base if patterns.any? { |pattern| File.fnmatch?(pattern, base, File::FNM_PATHNAME) } + end + aliases.each do |from, to| + targets << to if destination_lookup.key?(from) && targets.include?(to) + end + targets.uniq + end + + def aliased_readme_destination_section(template_base, destination_lookup, preserve_config) + aliases = preserve_config[:aliases] || README_SECTION_ALIASES + aliases.each do |from, to| + return destination_lookup[from] if to == template_base && destination_lookup.key?(from) + end + nil + end + + def readme_preserve_config(config) + readme = config["readme"] + return {} unless readme.is_a?(Hash) + + result = {} + result[:sections] = Array(readme["preserve_sections"]) if readme.key?("preserve_sections") + result[:patterns] = Array(readme["preserve_patterns"]) if readme.key?("preserve_patterns") + if readme["section_aliases"].is_a?(Hash) + result[:aliases] = README_SECTION_ALIASES.merge( + readme["section_aliases"].transform_keys { |key| normalize_readme_heading(key) } + .transform_values { |value| normalize_readme_heading(value) } + ) + end + result + end + + def normalize_readme_heading(text) + strip_readme_heading_adornment(text).strip.downcase + end + + def semantic_readme_heading(text) + strip_readme_heading_adornment(text).downcase.gsub(/[^\p{Alnum}\s]/u, "").squeeze(" ").strip + end + + def strip_readme_heading_adornment(text) + text.to_s.sub(/\A(?:\d\uFE0F?\u20E3|[^[:alnum:][:space:]])+[ \t]*/u, "") + end + def template_source_preferences(project_root, config, opencollective_disabled: false) templates = config["templates"] return [] unless templates.is_a?(Hash) @@ -1532,6 +1679,8 @@ def template_source_preference(project_root, template_root, entry, config, openc apply: template_entry_apply?(entry, apply_templates), } preference[:strategy] = strategy_config.fetch(:strategy).to_s if strategy_config + preserve_config = readme_preserve_config(config) + preference[:readme_preserve_config] = preserve_config if target_path == "README.md" && !preserve_config.empty? if template_root.fetch(:kind) == "packaged" preference[:source_relative_path] = selected_source preference[:source_root] = template_root.fetch(:kind) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 7693b6e..3f117a2 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -385,6 +385,9 @@ def project_files(root, paths) end RUBY ".kettle-jem.yml" => <<~YAML, + files: + README.md: + strategy: accept_template templates: root: template apply: true @@ -635,6 +638,100 @@ def project_files(root, paths) end end + it "preserves configured README sections during merge template application" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-readme-merge-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + readme: + preserve_sections: + - synopsis + - basic usage + - custom section + preserve_patterns: + - "note:*" + section_aliases: + usage: basic usage + templates: + root: template + apply: true + entries: + - README.md + YAML + "README.md" => <<~MARKDOWN, + # 1️⃣ Example + + ## Synopsis + + Destination synopsis. + + ## Usage + + Destination usage. + + ## Custom Section + + Destination custom. + + ## Note: Local + + Destination note. + + ## Installation + + Old install. + MARKDOWN + "template/README.md.example" => <<~MARKDOWN, + # 💎 Example + + ## 🌻 Synopsis + + Template synopsis. + + ## 🔧 Basic Usage + + Template usage. + + ## Custom Section + + Template custom. + + ## Note: Local + + Template note. + + ## Installation + + Template install. + MARKDOWN + }) + + apply = described_class.apply_project(root, env: {}) + readme_report = apply.fetch(:recipe_reports).find do |report| + report.fetch(:recipe_name) == "template_source_application_README_md" + end + final_content = readme_report.fetch(:final_content) + expect(final_content).to include("# 💎 Example") + expect(final_content).to include("## 🌻 Synopsis\n\nDestination synopsis.") + expect(final_content).to include("## 🔧 Basic Usage\n\nDestination usage.") + expect(final_content).to include("## Custom Section\n\nDestination custom.") + expect(final_content).to include("## Note: Local\n\nDestination note.") + expect(final_content).to include("## Installation\n\nTemplate install.") + expect(final_content).not_to include("Template synopsis.") + expect(final_content).not_to include("Template usage.") + expect(final_content).not_to include("Template custom.") + expect(final_content).not_to include("Template note.") + expect(File.read(File.join(root, "README.md"))).to eq(final_content) + end + end + it "honors author template token config and environment overrides" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From 1a13f2bd6aaa94427e24f0c864e423be8ac88c4f Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:09:02 -0600 Subject: [PATCH 52/71] Exercise README preservation in kettle-jem system spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spec/system/bundle_gem_scaffold_spec.rb | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb index 544879e..8a997a7 100644 --- a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb +++ b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb @@ -79,6 +79,28 @@ def enable_packaged_templates! File.write(path, content) end + def seed_destination_readme! + File.write(File.join(gem_root, "README.md"), <<~MARKDOWN) + # 1️⃣ Dummy::Gem + + ## Synopsis + + Destination synopsis from the scaffolded project. + + ## Usage + + Destination usage from the scaffolded project. + + ## Note: Local + + Destination note from the scaffolded project. + + ## Installation + + Old scaffold installation notes. + MARKDOWN + end + it "bootstraps config and applies selected packaged templates to a fresh scaffold" do bootstrap = Kettle::Jem.apply_project(gem_root, env: env) bootstrap_report = bootstrap.fetch(:recipe_reports).find do |report| @@ -95,6 +117,7 @@ def enable_packaged_templates! ) enable_packaged_templates! + seed_destination_readme! apply = Kettle::Jem.apply_project(gem_root, env: env) expect(apply.fetch(:changed_files)).to include( @@ -106,6 +129,9 @@ def enable_packaged_templates! readme = File.read(File.join(gem_root, "README.md")) expect(readme).to include("# 💎 Dummy::Gem") + expect(readme).to include("## 🌻 Synopsis\n\nDestination synopsis from the scaffolded project.") + expect(readme).to include("## 🔧 Basic Usage\n\nDestination usage from the scaffolded project.") + expect(readme).not_to include("Old scaffold installation notes.") expect(readme).to include("Compatible with MRI Ruby 3.2.0+") expect(readme).to include("https://github.com/acme/dummy-gem") From d05429824c2bd00289d69941458329c72ca968f4 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:23:45 -0600 Subject: [PATCH 53/71] Merge kettle-jem YAML and TOML templates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Gemfile.lock | 2 + gems/kettle-jem/kettle-jem.gemspec | 2 + gems/kettle-jem/lib/kettle/jem.rb | 43 ++++++++++ gems/kettle-jem/spec/thin_slice_spec.rb | 105 ++++++++++++++++++++++++ 4 files changed, 152 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index 2fb264e..40b5fd7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -44,6 +44,8 @@ PATH kettle-jem (0.1.0) ast-merge (= 0.1.0) token-resolver (~> 1.0, >= 1.0.2) + toml-merge (= 0.1.0) + yaml-merge (= 0.1.0) PATH remote: gems/kramdown-merge diff --git a/gems/kettle-jem/kettle-jem.gemspec b/gems/kettle-jem/kettle-jem.gemspec index c830f80..cbcb0da 100644 --- a/gems/kettle-jem/kettle-jem.gemspec +++ b/gems/kettle-jem/kettle-jem.gemspec @@ -25,4 +25,6 @@ Gem::Specification.new do |spec| spec.add_dependency "ast-merge", "= #{Kettle::Jem::VERSION}" spec.add_dependency "token-resolver", "~> 1.0", ">= 1.0.2" + spec.add_dependency "toml-merge", "= #{Kettle::Jem::VERSION}" + spec.add_dependency "yaml-merge", "= #{Kettle::Jem::VERSION}" end diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index cda0620..d8d09f2 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -3,7 +3,9 @@ require "fileutils" require "find" require "token/resolver" +require "toml-merge" require "yaml" +require "yaml/merge" require "ast/merge" require_relative "jem/version" @@ -18,6 +20,7 @@ module Jem FILE_DELETION_PRIMITIVES = %w[supplied_obsolete_file_deletion supplied_disabled_opencollective_file_deletion].freeze PACKAGED_TEMPLATE_ROOT = File.expand_path("jem/templates", __dir__) SUPPORTED_TEMPLATE_STRATEGIES = %i[merge accept_template keep_destination raw_copy].freeze + SUPPORTED_TEMPLATE_FILE_TYPES = %i[yaml toml markdown text].freeze TEMPLATE_TOKEN_CONFIG = Token::Resolver::Config.new(separators: ["|", ":"]).freeze EMPTY_TEMPLATE_TOKENS = %w[KJ|COPYRIGHT_PREFIX KJ|MIN_DIVERGENCE_THRESHOLD].freeze README_TOP_LOGO_MODE_DEFAULT = "org_and_project" @@ -598,10 +601,43 @@ def apply_template_source(project_root, recipe, original) preserve_config: recipe.dig(:template_preference, :readme_preserve_config) || {} ) end + return merge_config_template_source(recipe, resolved, original) if strategy.empty? || strategy == "merge" resolved end + def merge_config_template_source(recipe, template_content, destination_content) + file_type = template_file_type(recipe) + return template_content if destination_content.to_s.strip.empty? + + case file_type + when :yaml + merge_result = Yaml::Merge.merge_yaml(template_content, destination_content, "yaml") + when :toml + merge_result = Toml::Merge.merge_toml(template_content, destination_content, "toml") + else + return template_content + end + return merge_result.fetch(:output) if merge_result[:ok] + + diagnostics = merge_result.fetch(:diagnostics, []) + message = diagnostics.map { |diagnostic| diagnostic[:message] || diagnostic["message"] }.compact.join("; ") + raise ArgumentError, "failed to merge #{file_type} template #{recipe.fetch(:target_path)}: #{message}" + end + + def template_file_type(recipe) + configured = recipe.dig(:template_preference, :file_type).to_s + return configured.to_sym unless configured.empty? + + relative_path = recipe.fetch(:target_path).to_s + extension = File.extname(relative_path).downcase + return :yaml if extension.match?(/\A\.ya?ml\z/) || File.basename(relative_path).casecmp("citation.cff").zero? + return :toml if extension == ".toml" + return :markdown if extension.match?(/\A\.md(?:own)?\z/) + + :text + end + def apply_kettle_config_bootstrap(project_root, recipe) content = recipe_template_content(project_root, recipe) tokens = stringify_template_tokens(recipe.fetch(:template_tokens, {})) @@ -1679,6 +1715,7 @@ def template_source_preference(project_root, template_root, entry, config, openc apply: template_entry_apply?(entry, apply_templates), } preference[:strategy] = strategy_config.fetch(:strategy).to_s if strategy_config + preference[:file_type] = strategy_config.fetch(:file_type).to_s if strategy_config&.key?(:file_type) preserve_config = readme_preserve_config(config) preference[:readme_preserve_config] = preserve_config if target_path == "README.md" && !preserve_config.empty? if template_root.fetch(:kind) == "packaged" @@ -1727,6 +1764,12 @@ def template_strategy_entry(config, path, entry) result = { strategy: strategy } result[:path] = path if path + if entry.key?("file_type") + file_type = entry["file_type"].to_s.strip.downcase.tr("-", "_").to_sym + raise ArgumentError, "unknown kettle-jem template file_type: #{entry["file_type"]}" unless SUPPORTED_TEMPLATE_FILE_TYPES.include?(file_type) + + result[:file_type] = file_type + end if strategy == :merge defaults = config["defaults"].is_a?(Hash) ? config["defaults"] : {} result[:preference] = (entry.key?("preference") ? entry["preference"] : defaults["preference"]).to_s if entry.key?("preference") || defaults.key?("preference") diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 3f117a2..193827e 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -732,6 +732,111 @@ def project_files(root, paths) end end + it "merges YAML and TOML template applications with destination values" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-config-merge-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + files: + config: + explicit.yml: + strategy: merge + file_type: yaml + templates: + root: template + apply: true + entries: + - config/settings.yml + - config/tool.toml + - config/explicit.yml + YAML + "config/settings.yml" => <<~YAML, + engines: + - ruby + nested: + value: destination + version: 1 + YAML + "config/tool.toml" => <<~TOML, + title = "destination" + + [settings] + retries = 1 + TOML + "config/explicit.yml" => <<~YAML, + destination_only: keep + nested: + value: destination + YAML + "template/config/settings.yml.example" => <<~YAML, + engines: + - ruby + - jruby + nested: + template_only: true + value: template + version: 2 + YAML + "template/config/tool.toml.example" => <<~TOML, + title = "template" + + [settings] + retries = 3 + timeout = 30 + TOML + "template/config/explicit.yml.example" => <<~YAML, + nested: + value: template + template_only: true + template_only: added + YAML + }) + + apply = described_class.apply_project(root, env: {}) + yaml_report = apply.fetch(:recipe_reports).find do |report| + report.fetch(:recipe_name) == "template_source_application_config_settings_yml" + end + toml_report = apply.fetch(:recipe_reports).find do |report| + report.fetch(:recipe_name) == "template_source_application_config_tool_toml" + end + explicit_report = apply.fetch(:recipe_reports).find do |report| + report.fetch(:recipe_name) == "template_source_application_config_explicit_yml" + end + + expect(YAML.safe_load(yaml_report.fetch(:final_content))).to eq( + "engines" => ["ruby"], + "nested" => { + "template_only" => true, + "value" => "destination", + }, + "version" => 1 + ) + expect(toml_report.fetch(:final_content)).to eq(<<~TOML) + title = "destination" + + [settings] + retries = 1 + timeout = 30 + TOML + expect(YAML.safe_load(explicit_report.fetch(:final_content))).to eq( + "destination_only" => "keep", + "nested" => { + "template_only" => true, + "value" => "destination", + }, + "template_only" => "added" + ) + expect(explicit_report.dig(:metadata, :template_source_preference)).to include(file_type: "yaml") + end + end + it "honors author template token config and environment overrides" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From ec0147e37386d701a508ff2e2152bc6132751408 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:25:16 -0600 Subject: [PATCH 54/71] Support YAML mapping sequences in template merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/spec/thin_slice_spec.rb | 28 +++++++++++++++ gems/yaml-merge/lib/yaml/merge.rb | 35 +++++++++++++++---- .../spec/fixtures_integration_spec.rb | 31 ++++++++++++++++ 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 193827e..aee080c 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -753,10 +753,17 @@ def project_files(root, paths) root: template apply: true entries: + - .github/dependabot.yml - config/settings.yml - config/tool.toml - config/explicit.yml YAML + ".github/dependabot.yml" => <<~YAML, + updates: + - package-ecosystem: bundler + directory: / + version: 1 + YAML "config/settings.yml" => <<~YAML, engines: - ruby @@ -775,6 +782,14 @@ def project_files(root, paths) nested: value: destination YAML + "template/.github/dependabot.yml.example" => <<~YAML, + schedule: + interval: weekly + updates: + - package-ecosystem: github-actions + directory: / + version: 2 + YAML "template/config/settings.yml.example" => <<~YAML, engines: - ruby @@ -800,6 +815,9 @@ def project_files(root, paths) }) apply = described_class.apply_project(root, env: {}) + dependabot_report = apply.fetch(:recipe_reports).find do |report| + report.fetch(:recipe_name) == "template_source_application_github_dependabot_yml" + end yaml_report = apply.fetch(:recipe_reports).find do |report| report.fetch(:recipe_name) == "template_source_application_config_settings_yml" end @@ -810,6 +828,16 @@ def project_files(root, paths) report.fetch(:recipe_name) == "template_source_application_config_explicit_yml" end + expect(YAML.safe_load(dependabot_report.fetch(:final_content))).to eq( + "schedule" => { "interval" => "weekly" }, + "updates" => [ + { + "directory" => "/", + "package-ecosystem" => "bundler", + }, + ], + "version" => 1 + ) expect(YAML.safe_load(yaml_report.fetch(:final_content))).to eq( "engines" => ["ruby"], "nested" => { diff --git a/gems/yaml-merge/lib/yaml/merge.rb b/gems/yaml-merge/lib/yaml/merge.rb index e8c6b12..8107060 100644 --- a/gems/yaml-merge/lib/yaml/merge.rb +++ b/gems/yaml-merge/lib/yaml/merge.rb @@ -155,10 +155,11 @@ def validate_yaml_node(value, path) if scalar?(value) { ok: true, value: value } elsif value.is_a?(Array) - if value.all? { |item| scalar?(item) } - { ok: true, value: value } - else - unsupported_feature_result("Unsupported YAML sequence value at #{display_path(path)}. Only scalar sequences are supported.") + value.each_with_index.each_with_object({ ok: true, value: [] }) do |(item, index), memo| + validated = validate_yaml_node(item, "#{path}/#{index}") + return validated unless validated[:ok] + + memo[:value] << validated[:value] end elsif value.is_a?(Hash) value.keys.sort.each_with_object({ ok: true, value: {} }) do |key, memo| @@ -168,7 +169,7 @@ def validate_yaml_node(value, path) memo[:value][key] = validated[:value] end else - unsupported_feature_result("Unsupported YAML value at #{display_path(path)}. Only mappings, scalar values, and scalar sequences are supported.") + unsupported_feature_result("Unsupported YAML value at #{display_path(path)}. Only mappings, scalar values, and sequences are supported.") end end private_class_method :validate_yaml_node @@ -197,7 +198,7 @@ def render_yaml_scalar(value) def render_yaml_node(key, value, indent) prefix = " " * indent if value.is_a?(Array) - ["#{prefix}#{key}:"] + value.map { |item| "#{" " * (indent + 2)}- #{render_yaml_scalar(item)}" } + ["#{prefix}#{key}:"] + render_yaml_sequence(value, indent + 2) elsif value.is_a?(Hash) ["#{prefix}#{key}:"] + render_yaml_mapping(value, indent + 2) else @@ -213,6 +214,22 @@ def render_yaml_mapping(mapping, indent = 0) end private_class_method :render_yaml_mapping + def render_yaml_sequence(sequence, indent) + prefix = " " * indent + sequence.flat_map do |item| + if scalar?(item) + ["#{prefix}- #{render_yaml_scalar(item)}"] + elsif item.is_a?(Hash) + ["#{prefix}-"] + render_yaml_mapping(item, indent + 2) + elsif item.is_a?(Array) + ["#{prefix}-"] + render_yaml_sequence(item, indent + 2) + else + ["#{prefix}- #{render_yaml_scalar(item)}"] + end + end + end + private_class_method :render_yaml_sequence + def canonical_yaml(mapping) "#{render_yaml_mapping(mapping).join("\n")}\n" end @@ -224,7 +241,11 @@ def collect_yaml_owners(mapping, prefix = "") value = mapping[key] if value.is_a?(Array) [{ path: path, owner_kind: "key_value", match_key: key }] + - value.each_index.map { |index| { path: "#{path}/#{index}", owner_kind: "sequence_item" } } + value.each_with_index.flat_map do |item, index| + item_path = "#{path}/#{index}" + nested = item.is_a?(Hash) ? collect_yaml_owners(item, item_path) : [] + [{ path: item_path, owner_kind: "sequence_item" }] + nested + end elsif value.is_a?(Hash) [{ path: path, owner_kind: "mapping", match_key: key }] + collect_yaml_owners(value, path) else diff --git a/gems/yaml-merge/spec/fixtures_integration_spec.rb b/gems/yaml-merge/spec/fixtures_integration_spec.rb index cd694b9..f5e0283 100644 --- a/gems/yaml-merge/spec/fixtures_integration_spec.rb +++ b/gems/yaml-merge/spec/fixtures_integration_spec.rb @@ -97,6 +97,37 @@ def json_ready(value) ) end + it "merges mapping sequences with destination arrays winning conflicts" do + template = <<~YAML + updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + permissions: + contents: read + YAML + destination = <<~YAML + updates: + - package-ecosystem: bundler + directory: / + version: 2 + YAML + + result = described_class.merge_yaml(template, destination, "yaml", backend: "kreuzberg-language-pack") + expect(result[:ok]).to be(true) + expect(YAML.safe_load(result.fetch(:output))).to eq( + "permissions" => { "contents" => "read" }, + "updates" => [ + { + "directory" => "/", + "package-ecosystem" => "bundler", + }, + ], + "version" => 2 + ) + end + it "conforms to the slice-183 YAML polyglot backend feature profile fixtures" do fixture = read_json( fixtures_root.join( From 2b92ec06dbc6571f99ff993f61615f75958cacbf Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:26:35 -0600 Subject: [PATCH 55/71] Exercise packaged Dependabot YAML merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spec/system/bundle_gem_scaffold_spec.rb | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb index 8a997a7..013ed15 100644 --- a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb +++ b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb @@ -74,6 +74,7 @@ def enable_packaged_templates! apply: true entries: - README.md + - .github/dependabot.yml - gemfiles/modular/style.gemfile YAML File.write(path, content) @@ -101,6 +102,17 @@ def seed_destination_readme! MARKDOWN end + def seed_destination_dependabot! + FileUtils.mkdir_p(File.join(gem_root, ".github")) + File.write(File.join(gem_root, ".github/dependabot.yml"), <<~YAML) + updates: + - package-ecosystem: bundler + directory: "/" + schedule: + interval: daily + YAML + end + it "bootstraps config and applies selected packaged templates to a fresh scaffold" do bootstrap = Kettle::Jem.apply_project(gem_root, env: env) bootstrap_report = bootstrap.fetch(:recipe_reports).find do |report| @@ -118,9 +130,11 @@ def seed_destination_readme! enable_packaged_templates! seed_destination_readme! + seed_destination_dependabot! apply = Kettle::Jem.apply_project(gem_root, env: env) expect(apply.fetch(:changed_files)).to include( + ".github/dependabot.yml", "README.md", "gemfiles/modular/style.gemfile" ) @@ -135,6 +149,18 @@ def seed_destination_readme! expect(readme).to include("Compatible with MRI Ruby 3.2.0+") expect(readme).to include("https://github.com/acme/dummy-gem") + dependabot = YAML.safe_load(File.read(File.join(gem_root, ".github/dependabot.yml"))) + expect(dependabot).to eq( + "updates" => [ + { + "directory" => "/", + "package-ecosystem" => "bundler", + "schedule" => { "interval" => "daily" }, + }, + ], + "version" => 2 + ) + style_gemfile = File.read(File.join(gem_root, "gemfiles/modular/style.gemfile")) expect(style_gemfile).to include('gem "rubocop-lts", "~> 24.0"') expect(style_gemfile).to include('gem "rubocop-ruby3_2"') From d2eb799ab2e5191063c3407c11273381a7fea01e Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:28:14 -0600 Subject: [PATCH 56/71] Merge kettle-jem Ruby-family templates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Gemfile.lock | 1 + gems/kettle-jem/kettle-jem.gemspec | 1 + gems/kettle-jem/lib/kettle/jem.rb | 16 ++++++- gems/kettle-jem/spec/thin_slice_spec.rb | 59 +++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 40b5fd7..ad15d41 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -43,6 +43,7 @@ PATH specs: kettle-jem (0.1.0) ast-merge (= 0.1.0) + ruby-merge (= 0.1.0) token-resolver (~> 1.0, >= 1.0.2) toml-merge (= 0.1.0) yaml-merge (= 0.1.0) diff --git a/gems/kettle-jem/kettle-jem.gemspec b/gems/kettle-jem/kettle-jem.gemspec index cbcb0da..153619b 100644 --- a/gems/kettle-jem/kettle-jem.gemspec +++ b/gems/kettle-jem/kettle-jem.gemspec @@ -24,6 +24,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "ast-merge", "= #{Kettle::Jem::VERSION}" + spec.add_dependency "ruby-merge", "= #{Kettle::Jem::VERSION}" spec.add_dependency "token-resolver", "~> 1.0", ">= 1.0.2" spec.add_dependency "toml-merge", "= #{Kettle::Jem::VERSION}" spec.add_dependency "yaml-merge", "= #{Kettle::Jem::VERSION}" diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index d8d09f2..432c9ee 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -2,6 +2,7 @@ require "fileutils" require "find" +require "ruby/merge" require "token/resolver" require "toml-merge" require "yaml" @@ -20,7 +21,10 @@ module Jem FILE_DELETION_PRIMITIVES = %w[supplied_obsolete_file_deletion supplied_disabled_opencollective_file_deletion].freeze PACKAGED_TEMPLATE_ROOT = File.expand_path("jem/templates", __dir__) SUPPORTED_TEMPLATE_STRATEGIES = %i[merge accept_template keep_destination raw_copy].freeze - SUPPORTED_TEMPLATE_FILE_TYPES = %i[yaml toml markdown text].freeze + SUPPORTED_TEMPLATE_FILE_TYPES = %i[ruby gemfile appraisals gemspec rakefile yaml toml markdown text].freeze + RUBY_TEMPLATE_BASENAMES = %w[Gemfile Rakefile Appraisals Appraisal.root.gemfile .simplecov].freeze + RUBY_TEMPLATE_SUFFIXES = %w[.gemspec .gemfile].freeze + RUBY_TEMPLATE_EXTENSIONS = %w[.rb .rake].freeze TEMPLATE_TOKEN_CONFIG = Token::Resolver::Config.new(separators: ["|", ":"]).freeze EMPTY_TEMPLATE_TOKENS = %w[KJ|COPYRIGHT_PREFIX KJ|MIN_DIVERGENCE_THRESHOLD].freeze README_TOP_LOGO_MODE_DEFAULT = "org_and_project" @@ -611,6 +615,8 @@ def merge_config_template_source(recipe, template_content, destination_content) return template_content if destination_content.to_s.strip.empty? case file_type + when :ruby, :gemfile, :appraisals, :gemspec, :rakefile + merge_result = Ruby::Merge.merge_ruby(template_content, destination_content, "ruby") when :yaml merge_result = Yaml::Merge.merge_yaml(template_content, destination_content, "yaml") when :toml @@ -630,7 +636,15 @@ def template_file_type(recipe) return configured.to_sym unless configured.empty? relative_path = recipe.fetch(:target_path).to_s + basename = File.basename(relative_path) extension = File.extname(relative_path).downcase + return :gemfile if basename == "Gemfile" || basename.end_with?(".gemfile") + return :appraisals if basename.start_with?("Appraisals") || basename == "Appraisal.root.gemfile" + return :gemspec if basename.end_with?(".gemspec") + return :rakefile if basename == "Rakefile" || extension == ".rake" + return :ruby if RUBY_TEMPLATE_BASENAMES.include?(basename) || + RUBY_TEMPLATE_SUFFIXES.any? { |suffix| basename.end_with?(suffix) } || + RUBY_TEMPLATE_EXTENSIONS.include?(extension) return :yaml if extension.match?(/\A\.ya?ml\z/) || File.basename(relative_path).casecmp("citation.cff").zero? return :toml if extension == ".toml" return :markdown if extension.match?(/\A\.md(?:own)?\z/) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index aee080c..44296ad 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -865,6 +865,65 @@ def project_files(root, paths) end end + it "merges Ruby-family template applications with destination declarations" do + tmp_root = File.join(__dir__, "tmp") + FileUtils.mkdir_p(tmp_root) + Dir.mktmpdir("kettle-jem-ruby-merge-slice", tmp_root) do |root| + write_tree(root, { + "example.gemspec" => <<~RUBY, + Gem::Specification.new do |spec| + spec.name = "example" + spec.summary = "Example gem" + end + RUBY + ".kettle-jem.yml" => <<~YAML, + templates: + root: template + apply: true + entries: + - lib/example.rb + YAML + "lib/example.rb" => <<~RUBY, + require "set" + + class Existing + def keep + :destination + end + end + RUBY + "template/lib/example.rb.example" => <<~RUBY, + require "json" + + class Existing + def keep + :template + end + end + + class Added + def call + :template_only + end + end + RUBY + }) + + apply = described_class.apply_project(root, env: {}) + ruby_report = apply.fetch(:recipe_reports).find do |report| + report.fetch(:recipe_name) == "template_source_application_lib_example_rb" + end + final_content = ruby_report.fetch(:final_content) + + expect(final_content).to include('require "set"') + expect(final_content).not_to include('require "json"') + expect(final_content).to include("def keep\n :destination\n end") + expect(final_content).to include("class Added") + expect(final_content).to include(":template_only") + expect(File.read(File.join(root, "lib/example.rb"))).to eq(final_content) + end + end + it "honors author template token config and environment overrides" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) From 7f71b14cdc0b323f51ad8df70c8c2d504e7ebc76 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:34:43 -0600 Subject: [PATCH 57/71] Merge Ruby top-level DSL calls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/spec/thin_slice_spec.rb | 50 ++++++- gems/ruby-merge/lib/ruby/merge.rb | 128 +++++++++++++++++- .../spec/fixtures_integration_spec.rb | 46 +++++++ 3 files changed, 220 insertions(+), 4 deletions(-) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 44296ad..16b88c5 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -865,7 +865,7 @@ def project_files(root, paths) end end - it "merges Ruby-family template applications with destination declarations" do + it "merges Ruby-family template applications with destination declarations and DSL calls" do tmp_root = File.join(__dir__, "tmp") FileUtils.mkdir_p(tmp_root) Dir.mktmpdir("kettle-jem-ruby-merge-slice", tmp_root) do |root| @@ -881,8 +881,21 @@ def project_files(root, paths) root: template apply: true entries: + - Gemfile + - Rakefile - lib/example.rb YAML + "Gemfile" => <<~RUBY, + source "https://rubygems.org" + gem "rspec" + eval_gemfile "gemfiles/modular/style.gemfile" + RUBY + "Rakefile" => <<~RUBY, + desc "Default" + task :default do + puts "destination" + end + RUBY "lib/example.rb" => <<~RUBY, require "set" @@ -892,6 +905,23 @@ def keep end end RUBY + "template/Gemfile.example" => <<~RUBY, + source "https://gem.coop" + gemspec + eval_gemfile "gemfiles/modular/style.gemfile" + gem "rake" + RUBY + "template/Rakefile.example" => <<~RUBY, + desc "Default" + task :default do + puts "template" + end + + desc "CI" + task :ci do + sh "bundle exec rspec" + end + RUBY "template/lib/example.rb.example" => <<~RUBY, require "json" @@ -913,6 +943,12 @@ def call ruby_report = apply.fetch(:recipe_reports).find do |report| report.fetch(:recipe_name) == "template_source_application_lib_example_rb" end + gemfile_report = apply.fetch(:recipe_reports).find do |report| + report.fetch(:recipe_name) == "template_source_application_Gemfile" + end + rakefile_report = apply.fetch(:recipe_reports).find do |report| + report.fetch(:recipe_name) == "template_source_application_Rakefile" + end final_content = ruby_report.fetch(:final_content) expect(final_content).to include('require "set"') @@ -921,6 +957,18 @@ def call expect(final_content).to include("class Added") expect(final_content).to include(":template_only") expect(File.read(File.join(root, "lib/example.rb"))).to eq(final_content) + + gemfile_content = gemfile_report.fetch(:final_content) + expect(gemfile_content).to include('source "https://gem.coop"') + expect(gemfile_content).to include("gemspec") + expect(gemfile_content.scan('eval_gemfile "gemfiles/modular/style.gemfile"').size).to eq(1) + expect(gemfile_content).to include('gem "rspec"') + expect(gemfile_content).to include('gem "rake"') + + rakefile_content = rakefile_report.fetch(:final_content) + expect(rakefile_content.scan(/task\s+:default/).size).to eq(1) + expect(rakefile_content).to include('puts "destination"') + expect(rakefile_content).to include("task :ci") end end diff --git a/gems/ruby-merge/lib/ruby/merge.rb b/gems/ruby-merge/lib/ruby/merge.rb index 490e402..47c3d5c 100644 --- a/gems/ruby-merge/lib/ruby/merge.rb +++ b/gems/ruby-merge/lib/ruby/merge.rb @@ -13,6 +13,7 @@ module Merge DIRECTIVE_LINE = /\A(?::nocov:|[\w-]+:(?:freeze|unfreeze))\z/ MAGIC_COMMENT_PREFIXES = %w[coding encoding frozen_string_literal shareable_constant_value typed warn_indent].freeze REQUIRE_PATTERN = /^\s*require(?:_relative)?\s+["']([^"']+)["']/.freeze + DSL_CALL_PATTERN = /^\s*(?source|gemspec|git_source|gem|eval_gemfile|desc|task)\b/.freeze CLASS_PATTERN = /^\s*class\s+([A-Z]\w*(?:::\w+)*)/.freeze MODULE_PATTERN = /^\s*module\s+([A-Z]\w*(?:::\w+)*)/.freeze DEF_PATTERN = /^\s*def\s+(?:self\.)?([a-zA-Z_]\w*[!?=]?)/.freeze @@ -101,12 +102,16 @@ def merge_ruby(template_source, destination_source, dialect) } end - require_block = collect_ruby_require_entries(destination.dig(:analysis, :source)).map { |entry| entry[:text] }.join("\n").strip + destination_requires = collect_ruby_require_entries(destination.dig(:analysis, :source)) destination_declarations = collect_ruby_declaration_entries(destination.dig(:analysis, :source)) template_declarations = collect_ruby_declaration_entries(template.dig(:analysis, :source)) destination_paths = destination_declarations.to_h { |entry| [entry[:path], true] } + destination_dsl = collect_top_level_dsl_entries(destination.dig(:analysis, :source)) + template_dsl = collect_top_level_dsl_entries(template.dig(:analysis, :source)) sections = [] + require_block = destination_requires.map { |entry| entry[:text] }.join("\n").strip sections << require_block unless require_block.empty? + sections.concat(merge_top_level_dsl_entries(destination_dsl, template_dsl).map { |entry| entry[:text] }) sections.concat(destination_declarations.map { |entry| entry[:text] }) sections.concat( template_declarations.reject { |entry| destination_paths[entry[:path]] }.map { |entry| entry[:text] } @@ -354,10 +359,77 @@ def analyze_ruby_document(source) def collect_ruby_require_entries(source) normalize_source(source).split("\n").filter_map do |line| - next unless REQUIRE_PATTERN.match?(line) + match = REQUIRE_PATTERN.match(line) + next unless match + + { path: "/requires/#{match[1]}", text: line.rstrip } + end + end + + def collect_top_level_dsl_entries(source) + lines = normalize_source(source).split("\n") + entries = [] + pending_comments = [] + index = 0 + + while index < lines.length + line = lines[index] + stripped = line.strip + if comment_line?(line) + pending_comments << index + index += 1 + next + end + if stripped.empty? + pending_comments = [] + index += 1 + next + end + if REQUIRE_PATTERN.match?(line) || declaration_for_line(line) + pending_comments = [] + index += 1 + next + end + + match = DSL_CALL_PATTERN.match(line) + unless match + pending_comments = [] + index += 1 + next + end + + name = match[:name] + if name == "desc" && next_code_line_is_task?(lines, index + 1) + pending_comments << index + index += 1 + next + end - { text: line.rstrip } + start_index = pending_comments.first || index + finish_index = dsl_entry_finish_index(lines, index) + text = lines[start_index..finish_index].join("\n").strip + signature = dsl_entry_signature(name, line) + entries << { path: "/dsl/#{signature}", name: name, signature: signature, text: text } if signature + pending_comments = [] + index = finish_index + 1 end + + entries + end + + def merge_top_level_dsl_entries(destination_entries, template_entries) + destination_by_signature = destination_entries.to_h { |entry| [entry[:signature], entry] } + template_singletons = template_entries.select { |entry| dsl_singleton_entry?(entry) } + template_singleton_signatures = template_singletons.map { |entry| entry[:signature] }.to_h { |signature| [signature, true] } + result = [] + result.concat(template_singletons) + result.concat(destination_entries.reject { |entry| template_singleton_signatures[entry[:signature]] }) + result.concat( + template_entries.reject do |entry| + dsl_singleton_entry?(entry) || destination_by_signature[entry[:signature]] + end + ) + result end def collect_ruby_declaration_entries(source) @@ -446,6 +518,56 @@ def declaration_for_line(line) end end + def next_code_line_is_task?(lines, start_index) + lines[start_index..].to_a.each do |line| + next if line.strip.empty? || comment_line?(line) + + match = DSL_CALL_PATTERN.match(line) + return match && match[:name] == "task" + end + false + end + + def dsl_entry_finish_index(lines, start_index) + return start_index unless lines[start_index].match?(/\bdo\b/) + + depth = 0 + cursor = start_index + while cursor < lines.length + stripped = lines[cursor].strip + depth += stripped.scan(/\bdo\b/).length + depth += 1 if declaration_for_line(stripped) || stripped.match?(/\A(begin|if|unless|case|while|until|for)\b/) + depth -= 1 if stripped == "end" + return cursor if depth <= 0 && cursor > start_index + + cursor += 1 + end + lines.length - 1 + end + + def dsl_entry_signature(name, line) + case name + when "source", "gemspec" + name + when "git_source", "gem", "eval_gemfile", "task" + first_argument = line[/\b#{Regexp.escape(name)}\s*(?:\(|\s)\s*["']([^"']+)["']/, 1] || + line[/\b#{Regexp.escape(name)}\s*(?:\(|\s)\s*:([a-zA-Z_]\w*[!?=]?)/, 1] + first_argument ? "#{name}:#{normalize_dsl_argument(name, first_argument)}" : "#{name}:#{line.strip}" + when "desc" + "desc:#{line.strip}" + end + end + + def normalize_dsl_argument(name, argument) + return argument.gsub(%r{/r\d+/}, "/") if name == "eval_gemfile" + + argument + end + + def dsl_singleton_entry?(entry) + %w[source gemspec].include?(entry[:name]) + end + def surfaces_for_owner(owner_name:, comment_entries:) filtered_entries = comment_entries.filter { |entry| doc_comment_content?(entry[:raw]) } return [] if filtered_entries.empty? diff --git a/gems/ruby-merge/spec/fixtures_integration_spec.rb b/gems/ruby-merge/spec/fixtures_integration_spec.rb index f0b9ccf..e341724 100644 --- a/gems/ruby-merge/spec/fixtures_integration_spec.rb +++ b/gems/ruby-merge/spec/fixtures_integration_spec.rb @@ -107,6 +107,52 @@ def json_ready(value) ) ).to eq(json_ready(invalid_destination_fixture.dig(:expected, :diagnostics))) + gemfile_merge = RUBY_MERGE.merge_ruby( + <<~RUBY, + source "https://gem.coop" + gemspec + eval_gemfile "gemfiles/modular/style.gemfile" + gem "rake" + RUBY + <<~RUBY, + source "https://rubygems.org" + gem "rspec" + eval_gemfile "gemfiles/modular/style.gemfile" + RUBY + "ruby" + ) + expect(gemfile_merge[:ok]).to be(true) + expect(gemfile_merge[:output]).to include('source "https://gem.coop"') + expect(gemfile_merge[:output]).to include("gemspec") + expect(gemfile_merge[:output].scan('eval_gemfile "gemfiles/modular/style.gemfile"').size).to eq(1) + expect(gemfile_merge[:output]).to include('gem "rspec"') + expect(gemfile_merge[:output]).to include('gem "rake"') + + rakefile_merge = RUBY_MERGE.merge_ruby( + <<~RUBY, + desc "Default task" + task :default do + puts "template" + end + + desc "CI" + task :ci do + sh "bundle exec rspec" + end + RUBY + <<~RUBY, + desc "Default task" + task :default do + puts "destination" + end + RUBY + "ruby" + ) + expect(rakefile_merge[:ok]).to be(true) + expect(rakefile_merge[:output].scan(/task\s+:default/).size).to eq(1) + expect(rakefile_merge[:output]).to include('puts "destination"') + expect(rakefile_merge[:output]).to include("task :ci") + surfaces_analysis = RUBY_MERGE.parse_ruby(surfaces_fixture[:source], "ruby") expect(surfaces_analysis[:ok]).to be(true) expect(json_ready(RUBY_MERGE.ruby_discovered_surfaces(surfaces_analysis[:analysis]))).to eq( From f3b44bb554e7e884d3c2b5aed79eee5e019f5e25 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:36:46 -0600 Subject: [PATCH 58/71] Exercise packaged Gemfile merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb index 013ed15..ffb196e 100644 --- a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb +++ b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb @@ -75,6 +75,7 @@ def enable_packaged_templates! entries: - README.md - .github/dependabot.yml + - Gemfile - gemfiles/modular/style.gemfile YAML File.write(path, content) @@ -135,6 +136,7 @@ def seed_destination_dependabot! apply = Kettle::Jem.apply_project(gem_root, env: env) expect(apply.fetch(:changed_files)).to include( ".github/dependabot.yml", + "Gemfile", "README.md", "gemfiles/modular/style.gemfile" ) @@ -165,6 +167,13 @@ def seed_destination_dependabot! expect(style_gemfile).to include('gem "rubocop-lts", "~> 24.0"') expect(style_gemfile).to include('gem "rubocop-ruby3_2"') + gemfile = File.read(File.join(gem_root, "Gemfile")) + expect(gemfile).to include('source "https://gem.coop"') + expect(gemfile).not_to include('source "https://rubygems.org"') + expect(gemfile.scan(/^gemspec$/).size).to eq(1) + expect(gemfile.scan('eval_gemfile "gemfiles/modular/style.gemfile"').size).to eq(1) + expect(gemfile).to include('gem "irb"') + expect(File.read(File.join(gem_root, "Rakefile"))).not_to include("bundler/gem_tasks") end end From 1116980213be34e68abf8599dd477b03d75acb25 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:40:18 -0600 Subject: [PATCH 59/71] Exercise packaged Rakefile merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 7 +- .../spec/system/bundle_gem_scaffold_spec.rb | 11 ++- gems/ruby-merge/lib/ruby/merge.rb | 83 ++++++++++++++++++- .../spec/fixtures_integration_spec.rb | 82 ++++++++++++++++++ 4 files changed, 177 insertions(+), 6 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 432c9ee..18f57be 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -616,7 +616,12 @@ def merge_config_template_source(recipe, template_content, destination_content) case file_type when :ruby, :gemfile, :appraisals, :gemspec, :rakefile - merge_result = Ruby::Merge.merge_ruby(template_content, destination_content, "ruby") + merge_result = Ruby::Merge.merge_ruby( + template_content, + destination_content, + "ruby", + merge_template_requires: file_type == :rakefile + ) when :yaml merge_result = Yaml::Merge.merge_yaml(template_content, destination_content, "yaml") when :toml diff --git a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb index ffb196e..ed10e09 100644 --- a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb +++ b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb @@ -76,6 +76,7 @@ def enable_packaged_templates! - README.md - .github/dependabot.yml - Gemfile + - Rakefile - gemfiles/modular/style.gemfile YAML File.write(path, content) @@ -137,6 +138,7 @@ def seed_destination_dependabot! expect(apply.fetch(:changed_files)).to include( ".github/dependabot.yml", "Gemfile", + "Rakefile", "README.md", "gemfiles/modular/style.gemfile" ) @@ -174,6 +176,13 @@ def seed_destination_dependabot! expect(gemfile.scan('eval_gemfile "gemfiles/modular/style.gemfile"').size).to eq(1) expect(gemfile).to include('gem "irb"') - expect(File.read(File.join(gem_root, "Rakefile"))).not_to include("bundler/gem_tasks") + rakefile = File.read(File.join(gem_root, "Rakefile")) + expect(rakefile).to include('require "bundler/gem_tasks"') + expect(rakefile).to include('require "kettle/dev"') + expect(rakefile.scan(/^task\s+:default\b/).size).to eq(1) + expect(rakefile).to include('desc "Default tasks aggregator"') + expect(rakefile.index('desc "Default tasks aggregator"')).to be < rakefile.index("task :default do") + expect(rakefile.scan('task("kettle:jem:selftest")').size).to eq(1) + expect(rakefile.scan('task("build:generate_checksums")').size).to eq(1) end end diff --git a/gems/ruby-merge/lib/ruby/merge.rb b/gems/ruby-merge/lib/ruby/merge.rb index 47c3d5c..66ae3b8 100644 --- a/gems/ruby-merge/lib/ruby/merge.rb +++ b/gems/ruby-merge/lib/ruby/merge.rb @@ -13,7 +13,9 @@ module Merge DIRECTIVE_LINE = /\A(?::nocov:|[\w-]+:(?:freeze|unfreeze))\z/ MAGIC_COMMENT_PREFIXES = %w[coding encoding frozen_string_literal shareable_constant_value typed warn_indent].freeze REQUIRE_PATTERN = /^\s*require(?:_relative)?\s+["']([^"']+)["']/.freeze - DSL_CALL_PATTERN = /^\s*(?source|gemspec|git_source|gem|eval_gemfile|desc|task)\b/.freeze + DSL_CALL_PATTERN = /^(?source|gemspec|git_source|gem|eval_gemfile|desc|task)\b/.freeze + RAKEFILE_DEFAULT_TASK_COMMENT = "# Define a base default task early so other files can enhance it." + RAKEFILE_DEFAULT_TASK_DESC = 'desc "Default tasks aggregator"' CLASS_PATTERN = /^\s*class\s+([A-Z]\w*(?:::\w+)*)/.freeze MODULE_PATTERN = /^\s*module\s+([A-Z]\w*(?:::\w+)*)/.freeze DEF_PATTERN = /^\s*def\s+(?:self\.)?([a-zA-Z_]\w*[!?=]?)/.freeze @@ -87,7 +89,7 @@ def match_ruby_owners(template, destination) } end - def merge_ruby(template_source, destination_source, dialect) + def merge_ruby(template_source, destination_source, dialect, merge_template_requires: false) template = parse_ruby(template_source, dialect) return template unless template[:ok] @@ -103,13 +105,15 @@ def merge_ruby(template_source, destination_source, dialect) end destination_requires = collect_ruby_require_entries(destination.dig(:analysis, :source)) + template_requires = collect_ruby_require_entries(template.dig(:analysis, :source)) destination_declarations = collect_ruby_declaration_entries(destination.dig(:analysis, :source)) template_declarations = collect_ruby_declaration_entries(template.dig(:analysis, :source)) destination_paths = destination_declarations.to_h { |entry| [entry[:path], true] } destination_dsl = collect_top_level_dsl_entries(destination.dig(:analysis, :source)) template_dsl = collect_top_level_dsl_entries(template.dig(:analysis, :source)) sections = [] - require_block = destination_requires.map { |entry| entry[:text] }.join("\n").strip + requires = merge_template_requires ? merge_ruby_requires(destination_requires, template_requires) : destination_requires + require_block = requires.map { |entry| entry[:text] }.join("\n").strip sections << require_block unless require_block.empty? sections.concat(merge_top_level_dsl_entries(destination_dsl, template_dsl).map { |entry| entry[:text] }) sections.concat(destination_declarations.map { |entry| entry[:text] }) @@ -117,10 +121,12 @@ def merge_ruby(template_source, destination_source, dialect) template_declarations.reject { |entry| destination_paths[entry[:path]] }.map { |entry| entry[:text] } ) + output = "#{sections.join("\n\n").strip}\n" + { ok: true, diagnostics: [], - output: "#{sections.join("\n\n").strip}\n", + output: normalize_rakefile_default_task_scaffold(output), policies: [DESTINATION_WINS_ARRAY_POLICY] } end @@ -391,6 +397,17 @@ def collect_top_level_dsl_entries(source) next end + if line.match?(/\Abegin\b/) + start_index = pending_comments.first || index + finish_index = ruby_block_finish_index(lines, index) + text = lines[start_index..finish_index].join("\n").strip + signature = begin_block_signature(text) + entries << { path: "/dsl/#{signature}", name: "begin", signature: signature, text: text } + pending_comments = [] + index = finish_index + 1 + next + end + match = DSL_CALL_PATTERN.match(line) unless match pending_comments = [] @@ -432,6 +449,11 @@ def merge_top_level_dsl_entries(destination_entries, template_entries) result end + def merge_ruby_requires(destination_requires, template_requires) + destination_paths = destination_requires.to_h { |entry| [entry[:path], true] } + destination_requires + template_requires.reject { |entry| destination_paths[entry[:path]] } + end + def collect_ruby_declaration_entries(source) lines = normalize_source(source).split("\n") entries = [] @@ -531,6 +553,10 @@ def next_code_line_is_task?(lines, start_index) def dsl_entry_finish_index(lines, start_index) return start_index unless lines[start_index].match?(/\bdo\b/) + ruby_block_finish_index(lines, start_index) + end + + def ruby_block_finish_index(lines, start_index) depth = 0 cursor = start_index while cursor < lines.length @@ -545,6 +571,13 @@ def dsl_entry_finish_index(lines, start_index) lines.length - 1 end + def begin_block_signature(text) + require_path = text[/^\s*require(?:_relative)?\s+["']([^"']+)["']/, 1] + return "begin:require:#{require_path}" if require_path + + "begin:#{text.lines.first.to_s.strip}" + end + def dsl_entry_signature(name, line) case name when "source", "gemspec" @@ -568,6 +601,48 @@ def dsl_singleton_entry?(entry) %w[source gemspec].include?(entry[:name]) end + def normalize_rakefile_default_task_scaffold(content) + lines = normalize_source(content).split("\n") + desc_index = lines.find_index { |line| line.strip == RAKEFILE_DEFAULT_TASK_DESC } + return content unless desc_index + + comment_index = preceding_code_line_index(lines, desc_index - 1) + return content unless comment_index && lines[comment_index].strip == RAKEFILE_DEFAULT_TASK_COMMENT + + next_code_index = next_code_line_index(lines, desc_index + 1) + return content if next_code_index && lines[next_code_index].match?(/\Atask\s+:default\b/) + + task_index = lines.each_index.find { |index| lines[index].match?(/\Atask\s+:default\b/) } + return content unless task_index + + finish_index = dsl_entry_finish_index(lines, task_index) + task_block = lines[task_index..finish_index] + lines[task_index..finish_index] = [] + insertion_index = lines.find_index { |line| line.strip == RAKEFILE_DEFAULT_TASK_DESC } + 1 + insertion = task_block.dup + insertion << "" unless lines[insertion_index].to_s.strip.empty? + lines.insert(insertion_index, *insertion) + "#{lines.join("\n").sub(/\n+\z/, "")}\n" + end + + def preceding_code_line_index(lines, start_index) + start_index.downto(0) do |index| + next if lines[index].strip.empty? + + return index + end + nil + end + + def next_code_line_index(lines, start_index) + start_index.upto(lines.length - 1) do |index| + next if lines[index].strip.empty? + + return index + end + nil + end + def surfaces_for_owner(owner_name:, comment_entries:) filtered_entries = comment_entries.filter { |entry| doc_comment_content?(entry[:raw]) } return [] if filtered_entries.empty? diff --git a/gems/ruby-merge/spec/fixtures_integration_spec.rb b/gems/ruby-merge/spec/fixtures_integration_spec.rb index e341724..a2474a1 100644 --- a/gems/ruby-merge/spec/fixtures_integration_spec.rb +++ b/gems/ruby-merge/spec/fixtures_integration_spec.rb @@ -153,6 +153,88 @@ def json_ready(value) expect(rakefile_merge[:output]).to include('puts "destination"') expect(rakefile_merge[:output]).to include("task :ci") + relocated_rakefile_merge = RUBY_MERGE.merge_ruby( + <<~RUBY, + # Define a base default task early so other files can enhance it. + desc "Default tasks aggregator" + task :default do + puts "Default task complete." + end + + # External gems that define tasks - add here! + require "kettle/dev" + RUBY + <<~RUBY, + # Define a base default task early so other files can enhance it. + desc "Default tasks aggregator" + # External gems that define tasks - add here! + require "kettle/dev" + + task :default do + # :nocov: + puts "Default task complete." + # :nocov: + end + RUBY + "ruby" + ) + expect(relocated_rakefile_merge[:ok]).to be(true) + expect(relocated_rakefile_merge[:output].scan(/task\s+:default/).size).to eq(1) + expect(relocated_rakefile_merge[:output].scan("# :nocov:").size).to eq(2) + expect(relocated_rakefile_merge[:output]).to include('desc "Default tasks aggregator"') + expect(relocated_rakefile_merge[:output]).to include(<<~RUBY) + task :default do + # :nocov: + puts "Default task complete." + # :nocov: + end + RUBY + expect(relocated_rakefile_merge[:output].index('desc "Default tasks aggregator"')).to be < + relocated_rakefile_merge[:output].index("task :default do") + + rescue_task_merge = RUBY_MERGE.merge_ruby( + <<~RUBY, + begin + require "kettle/jem" + rescue LoadError + desc("(stub) kettle:jem:selftest is unavailable") + task("kettle:jem:selftest") do + warn("NOTE: not installed") + end + end + RUBY + <<~RUBY, + begin + require "kettle/jem" + rescue LoadError + # :nocov: + desc("(stub) kettle:jem:selftest is unavailable") + task("kettle:jem:selftest") do + warn("NOTE: not installed") + end + # :nocov: + end + RUBY + "ruby" + ) + expect(rescue_task_merge[:ok]).to be(true) + expect(rescue_task_merge[:output].scan('task("kettle:jem:selftest")').size).to eq(1) + expect(rescue_task_merge[:output].scan("# :nocov:").size).to eq(2) + + rakefile_require_merge = RUBY_MERGE.merge_ruby( + <<~RUBY, + require "kettle/dev" + RUBY + <<~RUBY, + require "bundler/setup" + RUBY + "ruby", + merge_template_requires: true + ) + expect(rakefile_require_merge[:ok]).to be(true) + expect(rakefile_require_merge[:output]).to include('require "bundler/setup"') + expect(rakefile_require_merge[:output]).to include('require "kettle/dev"') + surfaces_analysis = RUBY_MERGE.parse_ruby(surfaces_fixture[:source], "ruby") expect(surfaces_analysis[:ok]).to be(true) expect(json_ready(RUBY_MERGE.ruby_discovered_surfaces(surfaces_analysis[:analysis]))).to eq( From d477fb2f879fc81149dd06702fec4c26ddb67fbe Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:41:44 -0600 Subject: [PATCH 60/71] Check stable packaged template idempotency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb index ed10e09..2e52c1a 100644 --- a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb +++ b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb @@ -184,5 +184,11 @@ def seed_destination_dependabot! expect(rakefile.index('desc "Default tasks aggregator"')).to be < rakefile.index("task :default do") expect(rakefile.scan('task("kettle:jem:selftest")').size).to eq(1) expect(rakefile.scan('task("build:generate_checksums")').size).to eq(1) + + second_apply = Kettle::Jem.apply_project(gem_root, env: env) + expect(second_apply.fetch(:changed_files)).not_to include( + ".github/dependabot.yml", + "Gemfile" + ) end end From f16d584b1135d3a28d247da99b53bdf500eab10c Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:45:42 -0600 Subject: [PATCH 61/71] Tighten README heading preservation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 18f57be..f4e5ed3 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -1614,7 +1614,7 @@ def normalize_readme_heading(text) end def semantic_readme_heading(text) - strip_readme_heading_adornment(text).downcase.gsub(/[^\p{Alnum}\s]/u, "").squeeze(" ").strip + normalize_readme_heading(text) end def strip_readme_heading_adornment(text) From 65753b3a27c9ce0e297c2056dc72162c19b6309c Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 03:55:15 -0600 Subject: [PATCH 62/71] Align system sandbox with kettle-jem reference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb index 2e52c1a..6ecb913 100644 --- a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb +++ b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb @@ -4,20 +4,19 @@ require "open3" RSpec.describe "bundle gem scaffold + kettle-jem", :system do - let(:tmp_root) { File.expand_path("../../tmp/system", __dir__) } - let(:sandbox_root) { File.join(tmp_root, "bundle-gem-system") } + let(:sandbox_root) { File.expand_path("../../../tmp/sandbox", __dir__) } let(:gem_root) { File.join(sandbox_root, "dummy-gem") } let(:env) { { "KJ_MIN_DIVERGENCE_THRESHOLD" => "5" } } before do - FileUtils.rm_rf(sandbox_root) + FileUtils.rm_rf(gem_root) FileUtils.mkdir_p(sandbox_root) scaffold_bundle_gem! normalize_scaffold_gemspec! end after do - FileUtils.rm_rf(sandbox_root) + FileUtils.rm_rf(gem_root) end def scaffold_bundle_gem! From 6b0cada8724cb3f91729e70551a9dee27962ada2 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 04:07:05 -0600 Subject: [PATCH 63/71] Align selected template reruns with reference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 25 +++++++++++------ gems/kettle-jem/spec/fixtures/thin_slice.json | 2 +- .../spec/system/bundle_gem_scaffold_spec.rb | 27 ++++++++++++++++--- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index f4e5ed3..bc029e4 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -400,9 +400,7 @@ def synchronize_readme(content, facts) lines = content.to_s.split("\n", -1) heading = "# #{package.fetch(:name)}" h1_index = lines.index { |line| line.start_with?("# ") } - if h1_index - lines[h1_index] = heading - else + unless h1_index lines.unshift(heading, "") end replace_markdown_managed_block(lines.join("\n"), "kettle-jem:metadata", readme_metadata_block(facts)) @@ -613,6 +611,7 @@ def apply_template_source(project_root, recipe, original) def merge_config_template_source(recipe, template_content, destination_content) file_type = template_file_type(recipe) return template_content if destination_content.to_s.strip.empty? + return destination_content if destination_content == template_content case file_type when :ruby, :gemfile, :appraisals, :gemspec, :rakefile @@ -2005,17 +2004,27 @@ def rakefile_task_block_selectors(lines) while index < lines.length line = lines[index] if line.match?(/\A\s*task\s+default:/) || line.match?(/\A\s*task\s+:default\b/) - end_index = rakefile_block_end(lines, index) - selectors << rakefile_selector("rakefile_scaffold_task_default", index + 1, end_index + 1, - "wrapper_selected_scaffold_task") - index = end_index + 1 - next + unless rakefile_template_default_task?(lines, index) + end_index = rakefile_block_end(lines, index) + selectors << rakefile_selector("rakefile_scaffold_task_default", index + 1, end_index + 1, + "wrapper_selected_scaffold_task") + index = end_index + 1 + next + end end index += 1 end selectors end + def rakefile_template_default_task?(lines, task_index) + cursor = task_index - 1 + cursor -= 1 while cursor >= 0 && lines[cursor].strip.empty? + return false unless cursor >= 0 + + lines[cursor].strip == 'desc "Default tasks aggregator"' + end + def rakefile_block_end(lines, start_index) return start_index unless lines[start_index].match?(/\bdo\b/) diff --git a/gems/kettle-jem/spec/fixtures/thin_slice.json b/gems/kettle-jem/spec/fixtures/thin_slice.json index eec3bb6..20de3c6 100644 --- a/gems/kettle-jem/spec/fixtures/thin_slice.json +++ b/gems/kettle-jem/spec/fixtures/thin_slice.json @@ -89,7 +89,7 @@ ".github/workflows/framework-ci.yml": "name: Rails CI\n\npermissions:\n contents: read\n\non:\n push:\n branches:\n - \"main\"\n - \"*-stable\"\n tags:\n - \"!*\" # Do not execute on tags\n pull_request:\n branches:\n - \"*\"\n workflow_dispatch:\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n if: \"!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')\"\n name: Specs ${{ matrix.ruby }}@${{ matrix.framework_version }}\n runs-on: ubuntu-latest\n continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}\n env:\n BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}\n strategy:\n fail-fast: false\n matrix:\n ruby:\n - \"3.2\"\n - \"3.3\"\n - \"3.4\"\n rubygems:\n - default\n bundler:\n - default\n include:\n - framework_version: \"7.0\"\n gemfile: \"gemfiles/rails_7_0\"\n - framework_version: \"7.1\"\n gemfile: \"gemfiles/rails_7_1\"\n\n steps:\n - name: Checkout\n uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n - name: Setup Ruby & RubyGems\n uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n rubygems: \"${{ matrix.rubygems }}\"\n bundler: \"${{ matrix.bundler }}\"\n bundler-cache: true\n\n - name: Tests for ${{ matrix.ruby }}@${{ matrix.framework_version }}\n run: bundle exec rake test\n", ".github/workflows/custom-ci.yml": "name: Custom CI\nenv:\n K_SOUP_COV_DO: true\n\npermissions:\n contents: read\n\non:\n push:\n branches: [main]\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n ruby: [\"3.2\", \"3.3\"]\n steps:\n - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n - uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n - name: Upload coverage to Coveralls\n if: ${{ !env.ACT }}\n uses: coverallsapp/github-action@0a51d2e0b5417d06e4ecceb534aec87defc53926 # main\n with:\n github-token: ${{ secrets.GITHUB_TOKEN }}\n continue-on-error: ${{ matrix.experimental != 'false' }}\n\n - name: Upload coverage to QLTY\n if: ${{ !env.ACT }}\n uses: qltysh/qlty-action/coverage@a19242102d17e497f437d7466aa01b528537e899 # v2.2.0\n with:\n token: ${{secrets.QLTY_COVERAGE_TOKEN}}\n files: coverage/.resultset.json\n continue-on-error: ${{ matrix.experimental != 'false' }}\n\n - name: Upload coverage to CodeCov\n if: ${{ !env.ACT }}\n uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0\n with:\n use_oidc: true\n fail_ci_if_error: false\n files: coverage/lcov.info,coverage/coverage.xml\n verbose: true\n\n - name: Code Coverage Summary Report\n if: ${{ !env.ACT && github.event_name == 'pull_request' }}\n uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0\n with:\n filename: ./coverage/coverage.xml\n badge: true\n fail_below_min: true\n format: markdown\n hide_branch_rate: false\n hide_complexity: true\n indicators: true\n output: both\n thresholds: '100 100'\n continue-on-error: ${{ matrix.experimental != 'false' }}\n\n - name: Add Coverage PR Comment\n uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4\n if: ${{ !env.ACT && github.event_name == 'pull_request' }}\n with:\n recreate: true\n path: code-coverage-results.md\n continue-on-error: ${{ matrix.experimental != 'false' }}\n", ".github/workflows/ci.yml": "name: CI\n\npermissions:\n contents: read\n\non:\n push:\n branches:\n - \"main\"\n - \"*-stable\"\n tags:\n - \"!*\" # Do not execute on tags\n pull_request:\n branches:\n - \"*\"\n workflow_dispatch:\n\nconcurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\njobs:\n test:\n if: \"!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')\"\n name: Specs ${{ matrix.ruby }}\n runs-on: ubuntu-latest\n continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}\n strategy:\n fail-fast: false\n matrix:\n ruby:\n - \"3.2\"\n - \"3.3\"\n - \"3.4\"\n rubygems:\n - default\n bundler:\n - default\n\n steps:\n - name: Checkout example\n uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n\n - name: Setup Ruby & RubyGems\n uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0\n with:\n ruby-version: \"${{ matrix.ruby }}\"\n rubygems: \"${{ matrix.rubygems }}\"\n bundler: \"${{ matrix.bundler }}\"\n bundler-cache: true\n\n - name: Tests\n run: bundle exec rake\n", - "README.md": "# example\n\nExisting intro.\n\n\n| Field | Value |\n|---|---|\n| Package | example |\n| Description | Example gem for kettle-jem |\n| Homepage | https://example.test |\n| Source | https://github.com/example/example |\n| License | MIT |\n| Funding | https://example.test/fund, https://github.com/sponsors/example, https://opencollective.com/example, https://tidelift.com/funding/github/rubygems/example |\n\n", + "README.md": "# Old Name\n\nExisting intro.\n\n\n| Field | Value |\n|---|---|\n| Package | example |\n| Description | Example gem for kettle-jem |\n| Homepage | https://example.test |\n| Source | https://github.com/example/example |\n| License | MIT |\n| Funding | https://example.test/fund, https://github.com/sponsors/example, https://opencollective.com/example, https://tidelift.com/funding/github/rubygems/example |\n\n", "CHANGELOG.md": "# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", "Rakefile": "# frozen_string_literal: true\n\n# Keep custom task.\ntask :custom do\n puts \"custom\"\nend\n", "gemfiles/modular/shunted.gemfile": "# frozen_string_literal: true\n\n# local additions stay above\n# <> do not edit below this line\n# package: example\n# generated by kettle-jem vNext\n# <>\n" diff --git a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb index 6ecb913..9fd4421 100644 --- a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb +++ b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb @@ -184,10 +184,29 @@ def seed_destination_dependabot! expect(rakefile.scan('task("kettle:jem:selftest")').size).to eq(1) expect(rakefile.scan('task("build:generate_checksums")').size).to eq(1) - second_apply = Kettle::Jem.apply_project(gem_root, env: env) - expect(second_apply.fetch(:changed_files)).not_to include( + selected_template_paths = [ ".github/dependabot.yml", - "Gemfile" - ) + "Gemfile", + "gemfiles/modular/style.gemfile", + ] + before_second_apply = selected_template_paths.to_h do |relative_path| + [relative_path, File.read(File.join(gem_root, relative_path))] + end + + Kettle::Jem.apply_project(gem_root, env: env) + + expect(selected_template_paths.to_h { |relative_path| + [relative_path, File.read(File.join(gem_root, relative_path))] + }).to eq(before_second_apply) + + readme_after_second_apply = File.read(File.join(gem_root, "README.md")) + expect(readme_after_second_apply).to include("# 💎 Dummy::Gem") + expect(readme_after_second_apply).to include("## 🌻 Synopsis\n\nDestination synopsis from the scaffolded project.") + + rakefile_after_second_apply = File.read(File.join(gem_root, "Rakefile")) + expect(rakefile_after_second_apply).to include('require "bundler/gem_tasks"') + expect(rakefile_after_second_apply).to include('require "kettle/dev"') + expect(rakefile_after_second_apply.scan('task("kettle:jem:selftest")').size).to eq(1) + expect(rakefile_after_second_apply.scan('task("build:generate_checksums")').size).to eq(1) end end From 4d692d9b7edfbdb7af8533eb276ac411f797a43b Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 04:09:07 -0600 Subject: [PATCH 64/71] Use packaged LICENSE template in token spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/spec/thin_slice_spec.rb | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 16b88c5..065abc1 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -1244,23 +1244,11 @@ def call - PolyForm-Small-Business-1.0.0 - LicenseRef-Big-Time-Public-License templates: - root: template + root: packaged apply: true entries: - LICENSE.md YAML - "template/LICENSE.md.example" => <<~MARKDOWN, - Primary: {KJ|LICENSE:PRIMARY_SPDX} - Prefix: {KJ|COPYRIGHT_PREFIX} - Badge: {KJ|README:LICENSE_BADGE} - Compat: {KJ|README:LICENSE_COMPAT_BADGE} - Refs: - {KJ|README:LICENSE_REFS} - Intro: - {KJ|README:LICENSE_INTRO} - Content: - {KJ|LICENSE_MD_CONTENT} - MARKDOWN }) plan = described_class.plan_project(root, env: {}) @@ -1274,14 +1262,12 @@ def call expect(plan.dig(:facts, :package, :license_expression)).to eq( "AGPL-3.0-only OR PolyForm-Small-Business-1.0.0 OR LicenseRef-Big-Time-Public-License" ) - expect(final_content).to include("Primary: AGPL-3.0-only") - expect(final_content).to include("Prefix: Required Notice: ") expect(final_content).to include("[AGPL-3.0-only](AGPL-3.0-only.md)") expect(final_content).to include("[PolyForm-Small-Business-1.0.0](PolyForm-Small-Business-1.0.0.md)") expect(final_content).to include("[Big-Time-Public-License](Big-Time-Public-License.md)") - expect(final_content).to include("Apache license compatibility: Category X") expect(final_content).to include("## Use-case guide") - expect(final_content).to include("[contact us](mailto:jane@example.test)") + expect(final_content).to include("Required Notice: Copyright") + expect(final_content).to include("Jane Q Public") expect(template_report.dig(:metadata, :template_tokens)).to include( "KJ|COPYRIGHT_PREFIX" => "Required Notice: ", "KJ|LICENSE:PRIMARY_SPDX" => "AGPL-3.0-only" From b637e32f72314bd35c1b4c2773c907ac50e9e1ca Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 04:23:24 -0600 Subject: [PATCH 65/71] Use packaged FUNDING source preference templates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/spec/thin_slice_spec.rb | 53 ++++++++++--------------- 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 065abc1..082d9b2 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -150,7 +150,7 @@ def project_files(root, paths) funding: open_collective: false templates: - root: template + root: packaged entries: - README.md - source: FUNDING.md.example @@ -173,19 +173,6 @@ def project_files(root, paths) steps: - uses: actions/checkout@v3 YAML - "template/README.md.example" => <<~MARKDOWN, - # Example - - Open Collective enabled. - MARKDOWN - "template/README.md.no-osc.example" => <<~MARKDOWN, - # Example - - Open Collective disabled. - MARKDOWN - "template/FUNDING.md.example" => <<~MARKDOWN, - # Funding - MARKDOWN }) plan = described_class.plan_project(root, env: {}) @@ -199,30 +186,32 @@ def project_files(root, paths) expect(recipe_names).to include("opencollective_disabled_file_cleanup_opencollective_yml") expect(recipe_names).to include("opencollective_disabled_file_cleanup_github_workflows_opencollective_yml") expect(recipe_names).not_to include("github_actions_workflow_snippets_github_workflows_opencollective_yml") - expect(plan.dig(:facts, :templates, :source_preferences)).to eq( - [ - { - target_path: "README.md", - configured_source: "README.md", - selected_source: "template/README.md.no-osc.example", - selection_reason: "opencollective_disabled_no_osc_variant", - apply: false, - }, - { - target_path: "FUNDING.md", - configured_source: "FUNDING.md.example", - selected_source: "template/FUNDING.md.example", - selection_reason: "default_example_variant", - apply: false, - }, - ] + expect(plan.dig(:facts, :templates, :source_preferences)).to contain_exactly( + a_hash_including( + target_path: "README.md", + configured_source: "README.md", + selected_source: "README.md.no-osc.example", + source_relative_path: "README.md.no-osc.example", + source_root: "packaged", + selection_reason: "opencollective_disabled_no_osc_variant", + apply: false + ), + a_hash_including( + target_path: "FUNDING.md", + configured_source: "FUNDING.md.example", + selected_source: "FUNDING.md.no-osc.example", + source_relative_path: "FUNDING.md.no-osc.example", + source_root: "packaged", + selection_reason: "opencollective_disabled_no_osc_variant", + apply: false + ) ) template_report = plan[:recipe_reports].find do |report| report.fetch(:recipe_name) == "template_source_preference_README_md" end expect(template_report.fetch(:changed)).to be(false) expect(template_report.dig(:metadata, :template_source_preference, :selected_source)).to eq( - "template/README.md.no-osc.example" + "README.md.no-osc.example" ) expect(template_report.dig(:request_envelope, :request, :runtime_context, :template_source_preference, :selection_reason)).to eq( "opencollective_disabled_no_osc_variant" From 9159be295ea8ac025c5a1599d0e0b1a710737664 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 04:24:38 -0600 Subject: [PATCH 66/71] Use packaged cert strategy template Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/spec/thin_slice_spec.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/gems/kettle-jem/spec/thin_slice_spec.rb b/gems/kettle-jem/spec/thin_slice_spec.rb index 082d9b2..a4888a4 100644 --- a/gems/kettle-jem/spec/thin_slice_spec.rb +++ b/gems/kettle-jem/spec/thin_slice_spec.rb @@ -556,7 +556,7 @@ def project_files(root, paths) README.md: strategy: keep_destination templates: - root: template + root: packaged apply: true entries: - README.md @@ -564,10 +564,9 @@ def project_files(root, paths) target: certs/pboling.pem YAML "README.md" => "# destination\n", - "template/README.md.example" => "# {KJ|GEM_NAME}\n", - "template/certs/pboling.pem.example" => "raw {KJ|GEM_NAME}\n", }) + packaged_cert = File.read(File.join(__dir__, "../lib/kettle/jem/templates/certs/pboling.pem.example")) plan = described_class.plan_project(root, env: {}) readme_report = plan[:recipe_reports].find do |report| report.fetch(:recipe_name) == "template_source_application_README_md" @@ -579,7 +578,7 @@ def project_files(root, paths) expect(readme_report.fetch(:final_content)).to eq("# destination\n") expect(readme_report.dig(:metadata, :template_source_preference)).to include(strategy: "keep_destination") expect(cert_report.fetch(:changed)).to be(true) - expect(cert_report.fetch(:final_content)).to eq("raw {KJ|GEM_NAME}\n") + expect(cert_report.fetch(:final_content)).to eq(packaged_cert) expect(cert_report.dig(:metadata, :template_source_preference)).to include(strategy: "raw_copy") end end From 07e69cc8d40c861075c57938c14ce6ea0bf7490f Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 04:35:50 -0600 Subject: [PATCH 67/71] Apply omitted packaged template inventory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 36 ++++++++-- .../spec/system/bundle_gem_scaffold_spec.rb | 68 +++++++++++++++---- gems/ruby-merge/lib/ruby/merge.rb | 17 ++++- .../spec/fixtures_integration_spec.rb | 29 ++++++++ gems/yaml-merge/lib/yaml/merge.rb | 8 ++- .../spec/fixtures_integration_spec.rb | 18 +++++ 6 files changed, 152 insertions(+), 24 deletions(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index bc029e4..63d6010 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -129,8 +129,9 @@ def discover_facts(project_root, env: ENV) gemspec = File.read(gemspec_path) name = extract_gemspec_assignment(gemspec, "spec.name") || File.basename(gemspec_path, ".gemspec") - source_url = extract_metadata_value(gemspec, "source_code_uri") || - extract_gemspec_assignment(gemspec, "spec.homepage") + homepage_url = extract_gemspec_assignment(gemspec, "spec.homepage") + metadata_source_url = extract_metadata_value(gemspec, "source_code_uri") + source_url = concrete_github_url(metadata_source_url) || concrete_github_url(homepage_url) || metadata_source_url || homepage_url kettle_config = kettle_jem_config(project_root) author = author_facts(gemspec, kettle_config, env) @@ -151,7 +152,7 @@ def discover_facts(project_root, env: ENV) slug: name, description: extract_gemspec_assignment(gemspec, "spec.description") || extract_gemspec_assignment(gemspec, "spec.summary"), - homepage_url: extract_gemspec_assignment(gemspec, "spec.homepage"), + homepage_url: homepage_url, source_url: source_url, license_expression: license[:expression], ), @@ -595,7 +596,14 @@ def apply_template_source(project_root, recipe, original) content = recipe_template_content(project_root, recipe) return content if strategy == "raw_copy" - resolved = resolve_template_tokens(content, recipe.fetch(:template_tokens, {})) + resolved = resolve_template_tokens( + content, + recipe.fetch(:template_tokens, {}), + scan_unresolved: unresolved_template_scan?(recipe) + ) + rescue ArgumentError => e + raise ArgumentError, "#{recipe.fetch(:target_path)}: #{e.message}" + else if recipe.fetch(:target_path) == "README.md" && (strategy.empty? || strategy == "merge") return merge_readme_template( template_content: resolved, @@ -614,7 +622,9 @@ def merge_config_template_source(recipe, template_content, destination_content) return destination_content if destination_content == template_content case file_type - when :ruby, :gemfile, :appraisals, :gemspec, :rakefile + when :gemspec + return template_content + when :ruby, :gemfile, :appraisals, :rakefile merge_result = Ruby::Merge.merge_ruby( template_content, destination_content, @@ -1166,6 +1176,10 @@ def github_org_from_url(url) match && match[1] end + def concrete_github_url(url) + github_org_from_url(url) ? url.to_s : nil + end + def readme_logo_facts(config, package_name:, github_org:) entries = readme_top_logo_entries(readme_top_logo_mode(config), org: github_org.to_s, gem_name: package_name.to_s) compact_hash( @@ -1472,16 +1486,25 @@ def author_family_names(name) parts[-1] end - def resolve_template_tokens(content, tokens) + def resolve_template_tokens(content, tokens, scan_unresolved: true) resolver = Token::Resolver::Resolve.new(on_missing: :keep) document = Token::Resolver::Document.new(content.to_s, config: TEMPLATE_TOKEN_CONFIG) resolved = resolver.resolve(document, stringify_template_tokens(tokens)) + return resolved unless scan_unresolved + unresolved = Token::Resolver::Document.new(resolved, config: TEMPLATE_TOKEN_CONFIG).token_keys.grep(/\AKJ\|/).sort return resolved if unresolved.empty? raise ArgumentError, "unresolved kettle-jem template tokens: #{unresolved.map { |token| "{#{token}}" }.join(", ")}" end + def unresolved_template_scan?(recipe) + return false if recipe.fetch(:target_path).to_s == ".kettle-jem.yml" + return false if recipe.dig(:template_preference, :skip_unresolved_scan) + + true + end + def stringify_template_tokens(tokens) tokens.to_h.transform_keys(&:to_s).transform_values(&:to_s) end @@ -1782,6 +1805,7 @@ def template_strategy_entry(config, path, entry) result = { strategy: strategy } result[:path] = path if path + result[:skip_unresolved_scan] = true if entry["skip_unresolved_scan"] if entry.key?("file_type") file_type = entry["file_type"].to_s.strip.downcase.tr("-", "_").to_sym raise ArgumentError, "unknown kettle-jem template file_type: #{entry["file_type"]}" unless SUPPORTED_TEMPLATE_FILE_TYPES.include?(file_type) diff --git a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb index 9fd4421..a2bcf1a 100644 --- a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb +++ b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb @@ -6,7 +6,44 @@ RSpec.describe "bundle gem scaffold + kettle-jem", :system do let(:sandbox_root) { File.expand_path("../../../tmp/sandbox", __dir__) } let(:gem_root) { File.join(sandbox_root, "dummy-gem") } - let(:env) { { "KJ_MIN_DIVERGENCE_THRESHOLD" => "5" } } + let(:env) do + { + "FUNDING_ORG" => "acme", + "KJ_MIN_DIVERGENCE_THRESHOLD" => "5", + } + end + let(:expected_hidden_directories) do + %w[ + .config + .config/mise + .devcontainer + .devcontainer/apt-install + .devcontainer/scripts + .git-hooks + .github + .github/workflows + .idea + .qlty + ] + end + let(:expected_hidden_files) do + %w[ + .config/mise/env.sh + .devcontainer/apt-install/devcontainer-feature.json + .devcontainer/apt-install/install.sh + .devcontainer/devcontainer.json + .devcontainer/scripts/setup-tree-sitter.sh + .git-hooks/commit-msg + .git-hooks/footer-template.erb.txt + .git-hooks/prepare-commit-msg + .github/.codecov.yml + .github/copilot_instructions.md + .github/dependabot.yml + .github/workflows/templating.yml + .idea/.gitignore + .qlty/qlty.toml + ] + end before do FileUtils.rm_rf(gem_root) @@ -71,12 +108,6 @@ def enable_packaged_templates! templates: root: packaged apply: true - entries: - - README.md - - .github/dependabot.yml - - Gemfile - - Rakefile - - gemfiles/modular/style.gemfile YAML File.write(path, content) end @@ -134,13 +165,7 @@ def seed_destination_dependabot! seed_destination_dependabot! apply = Kettle::Jem.apply_project(gem_root, env: env) - expect(apply.fetch(:changed_files)).to include( - ".github/dependabot.yml", - "Gemfile", - "Rakefile", - "README.md", - "gemfiles/modular/style.gemfile" - ) + expect(apply.fetch(:changed_files)).to include(".github/dependabot.yml", "Gemfile", "Rakefile", "README.md") expect(File).to exist(File.join(gem_root, ".github/FUNDING.yml")) expect(File).to exist(File.join(gem_root, ".github/workflows/ci.yml")) @@ -184,9 +209,24 @@ def seed_destination_dependabot! expect(rakefile.scan('task("kettle:jem:selftest")').size).to eq(1) expect(rakefile.scan('task("build:generate_checksums")').size).to eq(1) + aggregate_failures "hidden packaged template directories" do + expected_hidden_directories.each do |relative_path| + expect(Dir).to exist(File.join(gem_root, relative_path)), "expected #{relative_path} to exist" + end + end + + aggregate_failures "hidden packaged template files" do + expected_hidden_files.each do |relative_path| + expect(File).to exist(File.join(gem_root, relative_path)), "expected #{relative_path} to exist" + end + end + selected_template_paths = [ + ".github/copilot_instructions.md", ".github/dependabot.yml", + ".qlty/qlty.toml", "Gemfile", + "certs/pboling.pem", "gemfiles/modular/style.gemfile", ] before_second_apply = selected_template_paths.to_h do |relative_path| diff --git a/gems/ruby-merge/lib/ruby/merge.rb b/gems/ruby-merge/lib/ruby/merge.rb index 66ae3b8..466e1cf 100644 --- a/gems/ruby-merge/lib/ruby/merge.rb +++ b/gems/ruby-merge/lib/ruby/merge.rb @@ -13,7 +13,7 @@ module Merge DIRECTIVE_LINE = /\A(?::nocov:|[\w-]+:(?:freeze|unfreeze))\z/ MAGIC_COMMENT_PREFIXES = %w[coding encoding frozen_string_literal shareable_constant_value typed warn_indent].freeze REQUIRE_PATTERN = /^\s*require(?:_relative)?\s+["']([^"']+)["']/.freeze - DSL_CALL_PATTERN = /^(?source|gemspec|git_source|gem|eval_gemfile|desc|task)\b/.freeze + DSL_CALL_PATTERN = /^(?source|gemspec|git_source|gem|eval_gemfile|platform|group|desc|task)\b/.freeze RAKEFILE_DEFAULT_TASK_COMMENT = "# Define a base default task early so other files can enhance it." RAKEFILE_DEFAULT_TASK_DESC = 'desc "Default tasks aggregator"' CLASS_PATTERN = /^\s*class\s+([A-Z]\w*(?:::\w+)*)/.freeze @@ -112,6 +112,8 @@ def merge_ruby(template_source, destination_source, dialect, merge_template_requ destination_dsl = collect_top_level_dsl_entries(destination.dig(:analysis, :source)) template_dsl = collect_top_level_dsl_entries(template.dig(:analysis, :source)) sections = [] + preamble = collect_ruby_preamble(destination.dig(:analysis, :source)) + sections << preamble unless preamble.empty? requires = merge_template_requires ? merge_ruby_requires(destination_requires, template_requires) : destination_requires require_block = requires.map { |entry| entry[:text] }.join("\n").strip sections << require_block unless require_block.empty? @@ -372,6 +374,17 @@ def collect_ruby_require_entries(source) end end + def collect_ruby_preamble(source) + lines = normalize_source(source).split("\n") + preamble = [] + lines.each do |line| + break unless line.strip.empty? || comment_line?(line) + + preamble << line.rstrip + end + preamble.join("\n").strip + end + def collect_top_level_dsl_entries(source) lines = normalize_source(source).split("\n") entries = [] @@ -582,7 +595,7 @@ def dsl_entry_signature(name, line) case name when "source", "gemspec" name - when "git_source", "gem", "eval_gemfile", "task" + when "git_source", "gem", "eval_gemfile", "platform", "group", "task" first_argument = line[/\b#{Regexp.escape(name)}\s*(?:\(|\s)\s*["']([^"']+)["']/, 1] || line[/\b#{Regexp.escape(name)}\s*(?:\(|\s)\s*:([a-zA-Z_]\w*[!?=]?)/, 1] first_argument ? "#{name}:#{normalize_dsl_argument(name, first_argument)}" : "#{name}:#{line.strip}" diff --git a/gems/ruby-merge/spec/fixtures_integration_spec.rb b/gems/ruby-merge/spec/fixtures_integration_spec.rb index a2474a1..3f732f2 100644 --- a/gems/ruby-merge/spec/fixtures_integration_spec.rb +++ b/gems/ruby-merge/spec/fixtures_integration_spec.rb @@ -128,6 +128,35 @@ def json_ready(value) expect(gemfile_merge[:output]).to include('gem "rspec"') expect(gemfile_merge[:output]).to include('gem "rake"') + modular_gemfile_merge = RUBY_MERGE.merge_ruby( + <<~RUBY, + gem "reek", "~> 6.5" + + platform :mri do + gem "rubocop-lts", "~> 23.0" + gem "rubocop-ruby2_3" + end + RUBY + <<~RUBY, + # frozen_string_literal: true + + # Destination style guidance. + + gem "reek", "~> 6.5" + + platform :mri do + gem "rubocop-lts", "~> 24.0" + gem "rubocop-ruby3_2" + end + RUBY + "ruby" + ) + expect(modular_gemfile_merge[:ok]).to be(true) + expect(modular_gemfile_merge[:output]).to include("# frozen_string_literal: true") + expect(modular_gemfile_merge[:output]).to include("# Destination style guidance.") + expect(modular_gemfile_merge[:output]).to include("platform :mri do") + expect(modular_gemfile_merge[:output]).to include('gem "rubocop-ruby3_2"') + rakefile_merge = RUBY_MERGE.merge_ruby( <<~RUBY, desc "Default task" diff --git a/gems/yaml-merge/lib/yaml/merge.rb b/gems/yaml-merge/lib/yaml/merge.rb index 8107060..563ba47 100644 --- a/gems/yaml-merge/lib/yaml/merge.rb +++ b/gems/yaml-merge/lib/yaml/merge.rb @@ -175,7 +175,7 @@ def validate_yaml_node(value, path) private_class_method :validate_yaml_node def scalar?(value) - value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false + value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false end private_class_method :scalar? @@ -185,7 +185,9 @@ def display_path(path) private_class_method :display_path def render_yaml_scalar(value) - if value.is_a?(String) + if value.nil? + "" + elsif value.is_a?(String) value.match?(/\A[A-Za-z0-9_.-]+\z/) ? value : JSON.generate(value) elsif value == true || value == false value ? "true" : "false" @@ -201,6 +203,8 @@ def render_yaml_node(key, value, indent) ["#{prefix}#{key}:"] + render_yaml_sequence(value, indent + 2) elsif value.is_a?(Hash) ["#{prefix}#{key}:"] + render_yaml_mapping(value, indent + 2) + elsif value.nil? + ["#{prefix}#{key}:"] else ["#{prefix}#{key}: #{render_yaml_scalar(value)}"] end diff --git a/gems/yaml-merge/spec/fixtures_integration_spec.rb b/gems/yaml-merge/spec/fixtures_integration_spec.rb index f5e0283..ed1f91b 100644 --- a/gems/yaml-merge/spec/fixtures_integration_spec.rb +++ b/gems/yaml-merge/spec/fixtures_integration_spec.rb @@ -128,6 +128,24 @@ def json_ready(value) ) end + it "merges null scalar mapping values from template YAML" do + template = <<~YAML + community_bridge: + github: [acme] + YAML + destination = <<~YAML + tidelift: rubygems/example + YAML + + result = described_class.merge_yaml(template, destination, "yaml", backend: "kreuzberg-language-pack") + expect(result[:ok]).to be(true) + expect(YAML.safe_load(result.fetch(:output))).to eq( + "community_bridge" => nil, + "github" => ["acme"], + "tidelift" => "rubygems/example" + ) + end + it "conforms to the slice-183 YAML polyglot backend feature profile fixtures" do fixture = read_json( fixtures_root.join( From 8b0f5fa71f942919a83ea202838e196ff1c65401 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 04:38:07 -0600 Subject: [PATCH 68/71] Preserve gemspec fields during template apply Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/kettle-jem/lib/kettle/jem.rb | 32 ++++++++++++++++++- .../spec/system/bundle_gem_scaffold_spec.rb | 9 ++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 63d6010..c922ca5 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -623,7 +623,7 @@ def merge_config_template_source(recipe, template_content, destination_content) case file_type when :gemspec - return template_content + return merge_gemspec_template_source(template_content, destination_content) when :ruby, :gemfile, :appraisals, :rakefile merge_result = Ruby::Merge.merge_ruby( template_content, @@ -645,6 +645,36 @@ def merge_config_template_source(recipe, template_content, destination_content) raise ArgumentError, "failed to merge #{file_type} template #{recipe.fetch(:target_path)}: #{message}" end + def merge_gemspec_template_source(template_content, destination_content) + replacements = gemspec_preserved_assignments(destination_content) + return template_content if replacements.empty? + + replacements.reduce(template_content.dup) do |content, (field, source_line)| + pattern = /^(\s*spec\.#{Regexp.escape(field)}\s*=\s*).*$/ + content.match?(pattern) ? content.sub(pattern, source_line.rstrip) : content + end + end + + def gemspec_preserved_assignments(source) + %w[ + name + authors + email + summary + description + homepage + licenses + required_ruby_version + executables + ].each_with_object({}) do |field, assignments| + line = source.to_s.lines.find { |candidate| candidate.match?(/^\s*spec\.#{Regexp.escape(field)}\s*=/) } + next unless line + next if line.include?("TODO:") + + assignments[field] = line + end + end + def template_file_type(recipe) configured = recipe.dig(:template_preference, :file_type).to_s return configured.to_sym unless configured.empty? diff --git a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb index a2bcf1a..5ffafe1 100644 --- a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb +++ b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb @@ -200,6 +200,15 @@ def seed_destination_dependabot! expect(gemfile.scan('eval_gemfile "gemfiles/modular/style.gemfile"').size).to eq(1) expect(gemfile).to include('gem "irb"') + gemspec = File.read(File.join(gem_root, "dummy-gem.gemspec")) + expect(gemspec.scan("Gem::Specification.new").size).to eq(1) + expect(gemspec).to include('spec.name = "dummy-gem"') + expect(gemspec).to include('spec.summary = "Dummy gem"') + expect(gemspec).to include('spec.description = "Dummy gem for kettle-jem system testing."') + expect(gemspec).to include('spec.homepage = "https://github.com/acme/dummy-gem"') + expect(gemspec).to include('spec.required_ruby_version = ">= 3.2.0"') + expect(gemspec).to include('spec.metadata["source_code_uri"] = "#{spec.homepage}/tree/v#{spec.version}"') + rakefile = File.read(File.join(gem_root, "Rakefile")) expect(rakefile).to include('require "bundler/gem_tasks"') expect(rakefile).to include('require "kettle/dev"') From 28e3bad36cf34e3ec1f11575e1b9bc0859ac42df Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 10:25:31 -0600 Subject: [PATCH 69/71] Add compact ruleset parser Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/ast-merge/lib/ast/merge.rb | 93 +++++++++++++++++++ .../spec/fixtures_integration_spec.rb | 30 ++++++ 2 files changed, 123 insertions(+) diff --git a/gems/ast-merge/lib/ast/merge.rb b/gems/ast-merge/lib/ast/merge.rb index 3f08e7b..fdaa3bb 100644 --- a/gems/ast-merge/lib/ast/merge.rb +++ b/gems/ast-merge/lib/ast/merge.rb @@ -10,6 +10,16 @@ module Merge REVIEW_TRANSPORT_VERSION = 1 STRUCTURED_EDIT_TRANSPORT_VERSION = 1 TEMPLATE_TOKEN_CONFIG = Token::Resolver::Config.new(separators: ["|", ":"]).freeze + COMPACT_RULESET_REQUIRED_DIRECTIVES = %w[format owners match read attach].freeze + COMPACT_RULESET_SINGLETON_DIRECTIVES = %w[format owners match read attach comment_style render].freeze + COMPACT_RULESET_REPEATABLE_KEYED_DIRECTIVES = %w[capability logical_owner repair surface delegate].freeze + COMPACT_RULESET_READ_VALUES = %w[source_augmented_portable_write native_read_portable_write native_mutation].freeze + COMPACT_RULESET_ATTACH_VALUES = %w[ + layout_only + tracker_layout_merge + augmenter_preferred_tracker_layout + normalize_tracked_layout_merge + ].freeze module_function @@ -28,6 +38,68 @@ def conformance_family_feature_profile_path(manifest, family) entry && deep_dup(entry[:path]) end + def parse_compact_ruleset(source) + ruleset = { directives: [], comments: [] } + diagnostics = [] + seen_directives = {} + seen_repeatable_keys = {} + + source.to_s.split("\n").each_with_index do |raw_line, index| + line_number = index + 1 + line = raw_line.strip + next if line.empty? + + if line.start_with?("#") + ruleset[:comments] << line + next + end + + name, *arguments = line.split(/\s+/) + path = line_number.to_s + unless compact_ruleset_identifier?(name) + diagnostics << compact_ruleset_diagnostic("invalid directive token #{name.inspect}", path) + next + end + unless compact_ruleset_known_directive?(name) + diagnostics << compact_ruleset_diagnostic("unknown directive #{name.inspect}", path) + next + end + if arguments.empty? + diagnostics << compact_ruleset_diagnostic("directive #{name.inspect} requires at least one argument", path) + next + end + + arguments.each do |argument| + next if %w[true false].include?(argument) || compact_ruleset_identifier?(argument) || compact_ruleset_token?(argument) + + diagnostics << compact_ruleset_diagnostic("invalid argument token #{argument.inspect}", path) + end + + if COMPACT_RULESET_SINGLETON_DIRECTIVES.include?(name) && seen_directives.key?(name) + diagnostics << compact_ruleset_diagnostic( + "repeated singleton directive #{name.inspect} first seen on line #{seen_directives.fetch(name)}", + path + ) + end + if COMPACT_RULESET_REPEATABLE_KEYED_DIRECTIVES.include?(name) + key = [name, arguments.fetch(0)] + diagnostics << compact_ruleset_diagnostic("repeated #{name.inspect} key #{arguments.fetch(0).inspect}", path) if seen_repeatable_keys[key] + seen_repeatable_keys[key] = true + end + diagnostics << compact_ruleset_diagnostic("unknown read value #{arguments.fetch(0).inspect}", path) if name == "read" && !COMPACT_RULESET_READ_VALUES.include?(arguments.fetch(0)) + diagnostics << compact_ruleset_diagnostic("unknown attach value #{arguments.fetch(0).inspect}", path) if name == "attach" && !COMPACT_RULESET_ATTACH_VALUES.include?(arguments.fetch(0)) + + seen_directives[name] = line_number + ruleset[:directives] << { name: name, arguments: arguments, line: line_number } + end + + COMPACT_RULESET_REQUIRED_DIRECTIVES.each do |required| + diagnostics << compact_ruleset_diagnostic("missing required directive #{required.inspect}") unless seen_directives.key?(required) + end + + diagnostics.empty? ? { ok: true, diagnostics: [], analysis: ruleset, policies: [] } : { ok: false, diagnostics: diagnostics, policies: [] } + end + def normalize_template_source_path(path) return path.delete_suffix(".no-osc.example") if path.end_with?(".no-osc.example") return path.delete_suffix(".example") if path.end_with?(".example") @@ -3347,6 +3419,27 @@ def diagnostic(severity, category, message, path: nil, review: nil) end private_class_method :diagnostic + def compact_ruleset_identifier?(value) + value.to_s.match?(/\A[A-Za-z][A-Za-z0-9_.-]*\z/) + end + private_class_method :compact_ruleset_identifier? + + def compact_ruleset_token?(value) + value.to_s.match?(/\A[\x21\x24-\x7e]+\z/) + end + private_class_method :compact_ruleset_token? + + def compact_ruleset_known_directive?(value) + COMPACT_RULESET_SINGLETON_DIRECTIVES.include?(value) || + COMPACT_RULESET_REPEATABLE_KEYED_DIRECTIVES.include?(value) + end + private_class_method :compact_ruleset_known_directive? + + def compact_ruleset_diagnostic(message, path = nil) + diagnostic("error", "configuration_error", message, path: path) + end + private_class_method :compact_ruleset_diagnostic + def join_comma(values) values.join(", ") end diff --git a/gems/ast-merge/spec/fixtures_integration_spec.rb b/gems/ast-merge/spec/fixtures_integration_spec.rb index 329b102..2e2b44d 100644 --- a/gems/ast-merge/spec/fixtures_integration_spec.rb +++ b/gems/ast-merge/spec/fixtures_integration_spec.rb @@ -85,6 +85,10 @@ def read_relative_file_tree(root) end end + def ruleset_fixture_paths + fixtures_root.join("rulesets").find.select { |path| path.file? && path.extname == ".smrules" } + end + def repo_temp_dir root = Pathname(__dir__).join("..", "..", "tmp").expand_path root.mkpath @@ -120,6 +124,32 @@ def execute_from(executions) ]).to eq(fixture[:categories]) end + it "parses shared compact ruleset fixtures" do + paths = ruleset_fixture_paths + expect(paths).not_to be_empty + + paths.each do |path| + result = described_class.parse_compact_ruleset(path.read) + expect(result[:ok]).to be(true), "#{path}: #{result[:diagnostics].inspect}" + expect(result.dig(:analysis, :directives)).not_to be_empty + end + end + + it "rejects malformed compact ruleset edges" do + cases = { + "missing-required" => "format json\nowners line_bound_statements\nmatch stable_path\nread native_read_portable_write\n", + "repeated-format" => "format json\nformat yaml\nowners line_bound_statements\nmatch stable_path\nread native_read_portable_write\nattach layout_only\n", + "unknown-read" => "format json\nowners line_bound_statements\nmatch stable_path\nread imaginary\nattach layout_only\n", + "unknown-directive" => "format json\nowners line_bound_statements\nmatch stable_path\nread native_read_portable_write\nattach layout_only\nmystery value\n" + } + + cases.each do |name, source| + result = described_class.parse_compact_ruleset(source) + expect(result[:ok]).to be(false), name + expect(result[:diagnostics]).not_to be_empty + end + end + it "conforms to the shared policy vocabulary and reporting fixtures" do policy_fixture = diagnostics_fixture("policy_vocabulary") reporting_fixture = diagnostics_fixture("policy_reporting") From 5fb2fc396ebd2f91dfdb73750f79c4b9fb0630ae Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 11:41:51 -0600 Subject: [PATCH 70/71] Preserve map key order during merges Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gems/json-merge/lib/json/merge.rb | 7 ++++++- gems/yaml-merge/lib/yaml/merge.rb | 11 ++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/gems/json-merge/lib/json/merge.rb b/gems/json-merge/lib/json/merge.rb index 6180676..64b1806 100644 --- a/gems/json-merge/lib/json/merge.rb +++ b/gems/json-merge/lib/json/merge.rb @@ -186,7 +186,7 @@ def collect_json_owners(value, path = "") def merge_json_values(template, destination) if template.is_a?(Hash) && destination.is_a?(Hash) - (template.keys | destination.keys).sort.each_with_object({}) do |key, merged| + ordered_merge_keys(template, destination).each_with_object({}) do |key, merged| if !template.key?(key) merged[key] = destination[key] elsif !destination.key?(key) @@ -201,6 +201,11 @@ def merge_json_values(template, destination) end private_class_method :merge_json_values + def ordered_merge_keys(template, destination) + template.keys + destination.keys.reject { |key| template.key?(key) } + end + private_class_method :ordered_merge_keys + def detect_trailing_comma(source) state = scanner_state source.each_char.with_index do |char, index| diff --git a/gems/yaml-merge/lib/yaml/merge.rb b/gems/yaml-merge/lib/yaml/merge.rb index 563ba47..be25df7 100644 --- a/gems/yaml-merge/lib/yaml/merge.rb +++ b/gems/yaml-merge/lib/yaml/merge.rb @@ -162,7 +162,7 @@ def validate_yaml_node(value, path) memo[:value] << validated[:value] end elsif value.is_a?(Hash) - value.keys.sort.each_with_object({ ok: true, value: {} }) do |key, memo| + value.keys.each_with_object({ ok: true, value: {} }) do |key, memo| validated = validate_yaml_node(value[key], "#{path}/#{key}") return validated unless validated[:ok] @@ -212,7 +212,7 @@ def render_yaml_node(key, value, indent) private_class_method :render_yaml_node def render_yaml_mapping(mapping, indent = 0) - mapping.keys.sort.flat_map do |key| + mapping.keys.flat_map do |key| render_yaml_node(key, mapping[key], indent) end end @@ -260,7 +260,7 @@ def collect_yaml_owners(mapping, prefix = "") private_class_method :collect_yaml_owners def merge_yaml_mappings(template, destination) - (template.keys | destination.keys).sort.each_with_object({}) do |key, merged| + ordered_merge_keys(template, destination).each_with_object({}) do |key, merged| if !template.key?(key) merged[key] = destination[key] elsif !destination.key?(key) @@ -274,6 +274,11 @@ def merge_yaml_mappings(template, destination) end private_class_method :merge_yaml_mappings + def ordered_merge_keys(template, destination) + template.keys + destination.keys.reject { |key| template.key?(key) } + end + private_class_method :ordered_merge_keys + def parse_error_result(message) { ok: false, diagnostics: [{ severity: "error", category: "parse_error", message: message }], policies: [] } end From e3d5b289dc43fa18a385f2981585e420670c9fb2 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Tue, 5 May 2026 12:25:01 -0600 Subject: [PATCH 71/71] Fix packaged scaffold hidden file expectations --- gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb index 5ffafe1..34ebea3 100644 --- a/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb +++ b/gems/kettle-jem/spec/system/bundle_gem_scaffold_spec.rb @@ -22,7 +22,6 @@ .git-hooks .github .github/workflows - .idea .qlty ] end @@ -40,7 +39,6 @@ .github/copilot_instructions.md .github/dependabot.yml .github/workflows/templating.yml - .idea/.gitignore .qlty/qlty.toml ] end