From e17253104cf724fa6920252e5bef58e68f23ae5d Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Wed, 11 Nov 2026 09:37:15 -0500 Subject: [PATCH] Support contribution templates in Keep a Changelog Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/changelog/keep_a_changelog.md | 17 ++++- docs/workflow/hooks.md | 5 +- examples/lifecycle_hooks/README.md | 2 +- examples/lifecycle_hooks/release_notes.rb | 25 +------ examples/lifecycle_hooks/release_workflow.rb | 7 +- .../changelog/keep_a_changelog.rb | 65 ++++++++++++++++++- spec/fixtures/portable_consumer_hooks.rb | 35 +++++----- .../workflow_portable_release_spec.rb | 1 + .../changelog/keep_a_changelog_spec.rb | 53 +++++++++++++++ 10 files changed, 158 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7273a22..5dd40fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - [#50](https://github.com/dblock/ruby-gem-release/pull/50): Add optional release spec helpers, local publication adapters, and a complete example for testing project-specific release customizations in isolated repositories - [@dblock](https://github.com/dblock). - [#51](https://github.com/dblock/ruby-gem-release/pull/51): Carry changelog subsections into the next release and remove multiple contribution placeholders - [@dblock](https://github.com/dblock). +- [#52](https://github.com/dblock/ruby-gem-release/pull/52): Support configured contribution placeholders and complete category templates in Keep a Changelog releases - [@dblock](https://github.com/dblock). - Your contribution here. ## 0.5.1 (2026/11/10) diff --git a/docs/changelog/keep_a_changelog.md b/docs/changelog/keep_a_changelog.md index 73a14c2..5c209aa 100644 --- a/docs/changelog/keep_a_changelog.md +++ b/docs/changelog/keep_a_changelog.md @@ -2,7 +2,7 @@ Updates [Keep a Changelog](https://keepachangelog.com/) files with `## [Unreleased]` and `## [VERSION] - YYYY-MM-DD` headings. See the [sample changelog](../../examples/keep_a_changelog/CHANGELOG.md) and [Rakefile](../../examples/keep_a_changelog/Rakefile). Inherits shared source handling from [Base](base.md). -Release headings can use any Markdown depth from `#` through `######`. Both phases preserve the matched heading's depth, leaving category headings and history unchanged. No configuration or changelog reformatting is required. +Release headings can use any Markdown depth from `#` through `######`. Both phases preserve the matched heading's depth. By default, category headings and history are unchanged; optional contribution templates clean empty categories and restore the configured template. ```ruby require 'ruby-gem-release/changelog' @@ -14,6 +14,21 @@ changelog = RubyGemRelease::Changelog::KeepAChangelog.new(path: 'CHANGELOG.md') `path:` defaults to `'CHANGELOG.md'`. The class owns file reading and writing. +## Contribution Templates + +Set `placeholder:` to ignore a project's contribution prompt during validation and remove matching lines when releasing. Set `sections:` to restore a complete category template for the next release, including categories that had no changes: + +```ruby +changelog = RubyGemRelease::Changelog::KeepAChangelog.new( + placeholder: '* Your contribution here.', + sections: %w[Added Changed Deprecated Removed Fixed Security] +) +``` + +With either option enabled, releasing removes empty category headings while preserving real notes, code examples, history, and comparison references. Validation uses the same cleanup in memory; placeholder-only notes are not a release. Next-section preparation adds each configured category in order with the configured placeholder, if any. Categories are one heading level below the release heading. + +Both options are optional: `placeholder:` defaults to `nil` and `sections:` to `[]`, retaining the existing unmodified-note behavior and empty next section. A placeholder must be one nonempty line, and section names must be unique nonempty lines. Existing next sections are reused rather than overwritten. + ## Release During `release:validate`, empty or structural-only notes fail unless the selected release already has a heading. Invalid headings or comparison references also fail. diff --git a/docs/workflow/hooks.md b/docs/workflow/hooks.md index a717f6a..ecc8dc0 100644 --- a/docs/workflow/hooks.md +++ b/docs/workflow/hooks.md @@ -4,15 +4,16 @@ Both the [standard](ruby_gem.md) and [semantic](semantic_ruby_gem.md) workflows ```ruby workflow.before(:validate) { |ctx| validate_project_notes(ctx) } -workflow.before(:prepare_release) { |ctx| remove_contribution_placeholders(ctx) } workflow.after(:prepare_release) { |ctx| update_stable_readme(ctx) } -workflow.after(:prepare_next) { |ctx| restore_template_and_development_readme(ctx) } +workflow.after(:prepare_next) { |ctx| update_development_readme(ctx) } workflow.install_tasks ``` For each stage, before hooks run in registration order, then the built-in operation, then after hooks in registration order if it succeeded. Hooks add behavior; they do not replace versioning, changelog, publication, Git, hosting, or validation. +Use the built-in [changelog contribution template options](../changelog/keep_a_changelog.md#contribution-templates) for placeholder removal and category restoration rather than implementing those operations as hooks. + ## Context Hooks receive an immutable execution context with the configured `ctx.workflow` and current `ctx.release`. `ctx.stage`, `ctx.hook`, and `ctx.hook_index` identify the stage, timing, and one-based position. Before capture, `ctx.release` is `nil`; semantic hooks see `ctx.analysis` and the selected version after validation. Next-version hooks see `ctx.release.next_version` after development preparation. diff --git a/examples/lifecycle_hooks/README.md b/examples/lifecycle_hooks/README.md index c9d3b94..61d7ead 100644 --- a/examples/lifecycle_hooks/README.md +++ b/examples/lifecycle_hooks/README.md @@ -1,6 +1,6 @@ # Lifecycle Hook Example -The [Rakefile](Rakefile) installs the [project workflow](release_workflow.rb), which uses [release hooks](release_notes.rb) to update this README and the changelog. +The [Rakefile](Rakefile) installs the [project workflow](release_workflow.rb), which uses [release hooks](release_notes.rb) to update this README and built-in changelog options to clean contribution placeholders and restore its six-category template. ## Stable Release diff --git a/examples/lifecycle_hooks/release_notes.rb b/examples/lifecycle_hooks/release_notes.rb index 34deac5..d61d622 100644 --- a/examples/lifecycle_hooks/release_notes.rb +++ b/examples/lifecycle_hooks/release_notes.rb @@ -1,11 +1,9 @@ # frozen_string_literal: true class ProjectReleaseNotes - CATEGORIES = %w[Added Changed Deprecated Removed Fixed Security].freeze STABLE_PARAGRAPH = /^## Stable Release\n\n[^\n]+$/ - def initialize(changelog: 'CHANGELOG.md', readme: 'README.md') - @changelog = changelog + def initialize(readme: 'README.md') @readme = readme end @@ -13,31 +11,10 @@ def validate!(_ctx) raise ArgumentError, 'expected exactly one stable release paragraph in README.md' unless File.read(@readme).scan(STABLE_PARAGRAPH).one? end - def clean_placeholders!(ctx) - return if ctx.workflow.changelog.released?(ctx.release.version) - - content = File.read(@changelog).sub(/(^## \[Unreleased\]\n)(.*?)(?=^## |\z)/m) do - heading = Regexp.last_match(1) - notes = Regexp.last_match(2).gsub(/^\* Your contribution here\.\n/, '') - notes = notes.gsub(/^### [^\n]+\n\n*(?=### |\z)/, '') - "#{heading}#{notes.rstrip}\n\n" - end - File.write(@changelog, content) - end - def update_stable_readme!(ctx) write_readme("You're reading the documentation for the stable release of the project, #{ctx.release.version}.") end - def restore_template!(_ctx) - content = File.read(@changelog) - empty_section = /^## \[Unreleased\]\n\n(?=## )/ - return unless content.match?(empty_section) - - categories = CATEGORIES.map { |category| "### #{category}\n\n* Your contribution here.\n\n" }.join - File.write(@changelog, content.sub(empty_section, "## [Unreleased]\n\n#{categories}")) - end - def update_development_readme!(ctx) release = ctx.release write_readme("You're reading the documentation for the next release of the project, which should be #{release.next_version}. " \ diff --git a/examples/lifecycle_hooks/release_workflow.rb b/examples/lifecycle_hooks/release_workflow.rb index 26b13ce..f1a95a2 100644 --- a/examples/lifecycle_hooks/release_workflow.rb +++ b/examples/lifecycle_hooks/release_workflow.rb @@ -8,7 +8,10 @@ module ProjectReleaseWorkflow def self.build(**) workflow = RubyGemRelease::Workflow::RubyGem.new( version: RubyGemRelease::Version::RubyConstant.new(path: 'lib/example/version.rb', constant: 'VERSION'), - changelog: RubyGemRelease::Changelog::KeepAChangelog.new, + changelog: RubyGemRelease::Changelog::KeepAChangelog.new( + placeholder: '* Your contribution here.', + sections: %w[Added Changed Deprecated Removed Fixed Security] + ), ** ) add_hooks(workflow) @@ -18,9 +21,7 @@ def self.build(**) def self.add_hooks(workflow) notes = ProjectReleaseNotes.new workflow.before(:validate) { |ctx| notes.validate!(ctx) } - workflow.before(:prepare_release) { |ctx| notes.clean_placeholders!(ctx) } workflow.after(:prepare_release) { |ctx| notes.update_stable_readme!(ctx) } - workflow.after(:prepare_next) { |ctx| notes.restore_template!(ctx) } workflow.after(:prepare_next) { |ctx| notes.update_development_readme!(ctx) } end end diff --git a/lib/ruby-gem-release/changelog/keep_a_changelog.rb b/lib/ruby-gem-release/changelog/keep_a_changelog.rb index 451f682..7988534 100644 --- a/lib/ruby-gem-release/changelog/keep_a_changelog.rb +++ b/lib/ruby-gem-release/changelog/keep_a_changelog.rb @@ -5,8 +5,24 @@ module Changelog class KeepAChangelog < Base UNRELEASED = /\A\#{1,6} \[Unreleased\]\z/ + def initialize(path: 'CHANGELOG.md', placeholder: nil, sections: []) + validate_template(placeholder, sections) + super(path: path) + @placeholder = placeholder + @sections = sections.dup + end + private + def validate_template(placeholder, sections) + raise ArgumentError, 'placeholder must be a single nonempty line' unless placeholder.nil? || single_line?(placeholder) + raise ArgumentError, 'sections must be an array of unique nonempty names' unless sections.is_a?(Array) && sections.all? { |name| single_line?(name) } && sections.uniq == sections + end + + def single_line?(value) + value.is_a?(String) && !value.strip.empty? && !value.match?(/[\r\n]/) + end + def release_pattern(version) /\A\#{1,6} \[#{Regexp.escape(version)}\] - \d{4}-\d{2}-\d{2}\z/ end @@ -14,7 +30,7 @@ def release_pattern(version) def unreleased_notes(content, separator) reference = unreleased_reference(content) comparison(reference[2]) if reference - super + clean_notes(super, separator, heading_prefix(content, heading(content, UNRELEASED)).length) end def update_release(version:, date:) @@ -23,6 +39,7 @@ def update_release(version:, date:) separator = newline(content) position = heading(content, UNRELEASED) check_absent(content, /\A\#{1,6} \[#{Regexp.escape(version)}\] - \d{4}-\d{2}-\d{2}\z/, 'release heading already exists') + clean_release_section(content, position, separator) content[position...line_end(content, position, separator)] = "#{heading_prefix(content, position)} [#{version}] - #{date.strftime('%Y-%m-%d')}" release_references(content, version, separator) check_release_notes(content, position, separator, version) @@ -41,7 +58,51 @@ def update_next(version:, released_version:) check_absent(content, UNRELEASED, 'duplicate unreleased headings') - File.write(@path, content.insert(position, ["#{prefix} [Unreleased]", '', ''].join(separator))) + File.write(@path, content.insert(position, next_section(prefix, separator))) + end + + def clean_release_section(content, position, separator) + level = heading_prefix(content, position).length + start = line_end(content, position, separator) + finish = section_end_position(content, position, level) + content[start...finish] = clean_notes(content[start...finish], separator, level) + end + + def clean_notes(section, separator, level) + return section unless @placeholder || @sections.any? + + positions = [] + editable_lines(section) { |line, offset| positions << offset if @placeholder && line.chomp == @placeholder } + positions.reverse_each { |offset| remove_line(section, offset, separator) } + remove_empty_categories(section, separator, level) + section + end + + def remove_empty_categories(section, separator, level) + heading_positions(section, /\A\#{#{level + 1}} /).reverse_each do |offset| + finish = section_end_position(section, offset, level + 1) + start = section.index(separator, offset) || section.length + remove_line(section, offset, separator) unless notes?(document(section[start...finish])) + end + end + + def remove_line(section, offset, separator) + finish = section.index(separator, offset) + length = (finish ? finish + separator.length : section.length) - offset + length += separator.length if section[0...offset].end_with?(separator * 2) && section[offset + length, separator.length] == separator + section.slice!(offset, length) + end + + def next_section(prefix, separator) + raise ArgumentError, 'category headings cannot be deeper than level six' if prefix.length == 6 && @sections.any? + + lines = ["#{prefix} [Unreleased]", ''] + if @sections.empty? + lines.push(@placeholder, '') if @placeholder + else + @sections.each { |name| lines.push("#{prefix}# #{name}", '').concat(@placeholder ? [@placeholder, ''] : []) } + end + lines.push('').join(separator) end def references(content, label) diff --git a/spec/fixtures/portable_consumer_hooks.rb b/spec/fixtures/portable_consumer_hooks.rb index 4f675a1..c7c8c5c 100644 --- a/spec/fixtures/portable_consumer_hooks.rb +++ b/spec/fixtures/portable_consumer_hooks.rb @@ -1,27 +1,22 @@ # frozen_string_literal: true require_relative 'portable_consumer_support' -require_relative 'release_notes' +require_relative 'release_workflow' -PortableConsumer.install do |workflow| - RubyGemRelease::Workflow::Hooks::STAGES.each do |stage| - %i[before after].each do |timing| - workflow.public_send(timing, stage) do |ctx| - PortableConsumer.record('hook', stage: ctx.stage, timing: ctx.hook, index: ctx.hook_index, **PortableConsumer.context(ctx)) - end +PortableConsumer.configure_network +workflow = ProjectReleaseWorkflow.build(**PortableConsumer.workflow_options(nil).except(:version, :changelog)) +RubyGemRelease::Workflow::Hooks::STAGES.each do |stage| + %i[before after].each do |timing| + workflow.public_send(timing, stage) do |ctx| + PortableConsumer.record('hook', stage: ctx.stage, timing: ctx.hook, index: ctx.hook_index, **PortableConsumer.context(ctx)) end - - desc 'Verify the prepared package' - task :test do - raise 'Unexpected prepared version' unless RubyGemRelease::Package::RubyGem.new.version == ENV.fetch('RELEASE_VERSION') - end - - PortableConsumer.record('invocation', task: ARGV.last) end - notes = ProjectReleaseNotes.new - workflow.before(:validate) { |ctx| notes.validate!(ctx) } - workflow.before(:prepare_release) { |ctx| notes.clean_placeholders!(ctx) } - workflow.after(:prepare_release) { |ctx| notes.update_stable_readme!(ctx) } - workflow.after(:prepare_next) { |ctx| notes.restore_template!(ctx) } - workflow.after(:prepare_next) { |ctx| notes.update_development_readme!(ctx) } end + +desc 'Verify the prepared package' +task :test do + raise 'Unexpected prepared version' unless RubyGemRelease::Package::RubyGem.new.version == ENV.fetch('RELEASE_VERSION') +end + +PortableConsumer.record('invocation', task: ARGV.last) +workflow.install_tasks diff --git a/spec/integration/workflow_portable_release_spec.rb b/spec/integration/workflow_portable_release_spec.rb index 288e609..1dd2b3a 100644 --- a/spec/integration/workflow_portable_release_spec.rb +++ b/spec/integration/workflow_portable_release_spec.rb @@ -146,6 +146,7 @@ def events before do FileUtils.cp('examples/lifecycle_hooks/release_notes.rb', File.join(checkout, 'release_notes.rb')) + FileUtils.cp('examples/lifecycle_hooks/release_workflow.rb', File.join(checkout, 'release_workflow.rb')) File.write(File.join(checkout, 'README.md'), "# Example Project\n\n## Stable Release\n\nDevelopment documentation.\n") File.write(File.join(checkout, 'CHANGELOG.md'), "# Changelog\n\n## [Unreleased]\n\n### Added\n\n* Change.\n* Your contribution here.\n\n### Fixed\n\n* Your contribution here.\n") path = File.join(checkout, 'example.gemspec') diff --git a/spec/ruby_gem_release/changelog/keep_a_changelog_spec.rb b/spec/ruby_gem_release/changelog/keep_a_changelog_spec.rb index e377414..13c71cf 100644 --- a/spec/ruby_gem_release/changelog/keep_a_changelog_spec.rb +++ b/spec/ruby_gem_release/changelog/keep_a_changelog_spec.rb @@ -14,6 +14,59 @@ after { FileUtils.remove_entry(directory) } + ["\n", "\r\n"].each do |separator| + it "strips placeholders and empty categories, then restores every configured category with #{separator.inspect} endings" do + categories = %w[Added Changed Deprecated Removed Fixed Security] + notes = "### Added\n\n- Feature.\n* Your contribution here.\n\n### Fixed\n\n* Your contribution here.\n\n" + reference = "[Unreleased]: https://example.com/compare/v0.9.0...HEAD\n" + content = "## [Unreleased]\n\n#{notes}#{reference}".gsub("\n", separator) + File.write(path, content) + updater = described_class.new(path: path, placeholder: '* Your contribution here.', sections: categories) + expect(updater.pending?).to be(true) + expect(File.binread(path)).to eq(content) + updater.release!(release) + released = File.binread(path) + expect(released).to include("### Added#{separator}#{separator}- Feature.") + expect(released).not_to include('Your contribution here.', '### Fixed') + expect(released).to include("[Unreleased]: https://example.com/compare/v1.0.0...HEAD#{separator}") + 2.times { updater.prepare_next!(next_release) } + template = categories.map { |name| "### #{name}#{separator}#{separator}* Your contribution here.#{separator}#{separator}" }.join + expect(File.binread(path)).to eq("## [Unreleased]#{separator}#{separator}#{template}#{released}") + expect(updater.pending?).to be(false) + expect { updater.release!(release.with_version('1.0.1')) }.to raise_error(ArgumentError, /empty release notes/) + expect(File.binread(path)).to eq("## [Unreleased]#{separator}#{separator}#{template}#{released}") + end + end + + it 'ignores placeholder text in code and preserves categories with actual code notes' do + content = "## [Unreleased]\n\n### Added\n\n```\n* Your contribution here.\n```\n\n### Fixed\n\n* Your contribution here.\n" + File.write(path, content) + updater = described_class.new(path: path, placeholder: '* Your contribution here.') + expect(updater.pending?).to be(true) + updater.release!(release) + expect(File.read(path)).to include("```\n* Your contribution here.\n```") + expect(File.read(path)).not_to include('### Fixed') + end + + it 'supports a plain placeholder or a category template without requiring both options' do + File.write(path, "### [1.0.0] - 2026-11-07\n\n- Feature.\n") + described_class.new(path: path, sections: %w[Added Fixed]).prepare_next!(next_release) + expect(File.read(path)).to start_with("### [Unreleased]\n\n#### Added\n\n#### Fixed\n\n") + File.write(path, "## [1.0.0] - 2026-11-07\n\n- Feature.\n") + described_class.new(path: path, placeholder: '- Next change.').prepare_next!(next_release) + expect(File.read(path)).to start_with("## [Unreleased]\n\n- Next change.\n\n") + end + + it 'rejects malformed configuration and category headings deeper than Markdown supports without writing' do + [{ placeholder: '' }, { placeholder: "one\ntwo" }, { sections: %w[Added Added] }, { sections: ["Added\nFixed"] }].each do |options| + expect { described_class.new(**options) }.to raise_error(ArgumentError) + end + content = "###### [1.0.0] - 2026-11-07\n\n- Feature.\n" + File.write(path, content) + expect { described_class.new(path: path, sections: ['Added']).prepare_next!(next_release) }.to raise_error(ArgumentError, /level six/) + expect(File.read(path)).to eq(content) + end + it 'detects pending notes without rewriting source or counting historical notes' do content = "### [Unreleased]\r\n\r\n#### Fixed\r\n\r\n\r\n\r\n## [0.9.0] - 2026-11-06\r\n\r\n- Older notes.\r\n" File.write(path, content)