Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 16 additions & 1 deletion docs/changelog/keep_a_changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions docs/workflow/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion examples/lifecycle_hooks/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
25 changes: 1 addition & 24 deletions examples/lifecycle_hooks/release_notes.rb
Original file line number Diff line number Diff line change
@@ -1,43 +1,20 @@
# 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

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}. " \
Expand Down
7 changes: 4 additions & 3 deletions examples/lifecycle_hooks/release_workflow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
65 changes: 63 additions & 2 deletions lib/ruby-gem-release/changelog/keep_a_changelog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,32 @@ 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

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:)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
35 changes: 15 additions & 20 deletions spec/fixtures/portable_consumer_hooks.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions spec/integration/workflow_portable_release_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
53 changes: 53 additions & 0 deletions spec/ruby_gem_release/changelog/keep_a_changelog_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<!-- pending -->\r\n\r\n## [0.9.0] - 2026-11-06\r\n\r\n- Older notes.\r\n"
File.write(path, content)
Expand Down
Loading