diff --git a/.github/workflows/release-integration.yml b/.github/workflows/release-integration.yml index 943a918..9c9c7c9 100644 --- a/.github/workflows/release-integration.yml +++ b/.github/workflows/release-integration.yml @@ -42,6 +42,9 @@ jobs: require_relative 'lib/ruby-gem-release/version/ruby_constant' version = RubyGemRelease::Version::RubyConstant.new(path: 'lib/ruby-gem-release/version.rb', constant: 'VERSION') version.set(ENV.fetch('INPUT_VERSION')) + path = 'CHANGELOG.md' + content = File.read(path).sub("# Changelog\n", "# Changelog\n\n## Releases\n") + File.write(path, content.gsub(/^## (?=\d)/, '### ')) RUBY bundle exec rake release:before_release ruby - <<'RUBY' @@ -76,6 +79,9 @@ jobs: require_relative 'lib/ruby-gem-release/version/ruby_constant' version = RubyGemRelease::Version::RubyConstant.new(path: 'lib/ruby-gem-release/version.rb', constant: 'VERSION') version.set(ENV.fetch('INPUT_VERSION')) + path = 'CHANGELOG.md' + content = File.read(path).sub("# Changelog\n", "# Changelog\n\n## Releases\n") + File.write(path, content.gsub(/^## (?=\d)/, '### ')) RUBY bundle exec rake release:before_release ruby - <<'RUBY' @@ -83,9 +89,9 @@ jobs: content = File.read(path) File.write(path, content.sub('9000.0.0', ENV.fetch('INPUT_VERSION'))) RUBY - grep -q '^## 9000.0.1 (' CHANGELOG.md + grep -q '^### 9000.0.1 (' CHANGELOG.md bump: | printf '%s\n%s\n' "$RELEASE_PREVIOUS_VERSION" "$RELEASE_DATE" | cmp - "$RUNNER_TEMP/release-context" bundle exec rake release:after_release - grep -q '^## 9000.0.2 (Next)$' CHANGELOG.md + grep -q '^### 9000.0.2 (Next)$' CHANGELOG.md dry-run: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d165f9..8da235f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.2.0 (Next) - [#15](https://github.com/dblock/ruby-gem-release/pull/15): Add a Marketplace-ready composite action sharing Ruby release tasks, document action setup and publication, and pin workflow examples to `v0.2.0` - [@dblock](https://github.com/dblock). +- [#16](https://github.com/dblock/ruby-gem-release/pull/16): Preserve release heading depths from `#` through `######` in both changelog formats, without reformatting consumer files - [@dblock](https://github.com/dblock). - Your contribution here. ## 0.1.0 (2026/11/07) diff --git a/docs/changelog/base.md b/docs/changelog/base.md index fcb4bb0..58910c3 100644 --- a/docs/changelog/base.md +++ b/docs/changelog/base.md @@ -17,3 +17,5 @@ Shared implementation for [`Intridea`](intridea.md) and [`KeepAChangelog`](keep_ ## Source Preservation Uses `Commonmarker` to identify top-level headings and exclude code, HTML, and quoted blocks. Updates edit the original source rather than rendering the parsed document again, preserving unrelated formatting. Unix and Windows line endings are supported; mixed line endings or standalone carriage returns are rejected. + +Hash-style release headings support depths 1 through 6, and their hash count is preserved. Headings inside nested blocks remain excluded. Multiple matching release headings are rejected even when their depths differ. diff --git a/docs/changelog/intridea.md b/docs/changelog/intridea.md index 260b681..cc5dcff 100644 --- a/docs/changelog/intridea.md +++ b/docs/changelog/intridea.md @@ -2,6 +2,8 @@ Updates changelog files with `## VERSION (Next)` and `## VERSION (YYYY/MM/DD)` headings, such as this project's [CHANGELOG.md](../../CHANGELOG.md). Inherits the shared source handling from [Base](base.md). +Release headings can use any Markdown depth from `#` through `######`, including `### VERSION (Next)`. Both phases preserve the matched heading's depth; no configuration or changelog reformatting is required. Deeper subsection headings stay inside the release section, which ends at the next heading of equal or shallower depth. + ```ruby require 'ruby-gem-release/changelog' diff --git a/docs/changelog/keep_a_changelog.md b/docs/changelog/keep_a_changelog.md index 757fea6..715561d 100644 --- a/docs/changelog/keep_a_changelog.md +++ b/docs/changelog/keep_a_changelog.md @@ -2,6 +2,8 @@ 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. + ```ruby require 'ruby-gem-release/changelog' diff --git a/lib/ruby-gem-release/changelog/base.rb b/lib/ruby-gem-release/changelog/base.rb index 6ab74e5..7645924 100644 --- a/lib/ruby-gem-release/changelog/base.rb +++ b/lib/ruby-gem-release/changelog/base.rb @@ -27,12 +27,12 @@ def heading(content, pattern) matches.first end - def heading_positions(content, pattern) + def heading_positions(content, pattern, max_level: 6) lines = content.lines offsets = line_offsets(lines) matches = [] document(content).each do |node| - next unless node.type == :heading && node.header_level == 2 + next unless node.type == :heading && node.header_level <= max_level index = node.source_position[:start_line] - 1 matches << offsets[index] if pattern.match?(lines[index].chomp) @@ -40,6 +40,10 @@ def heading_positions(content, pattern) matches end + def heading_prefix(content, position) + content[position, 6][/\A#+/] + end + def editable_lines(content) excluded = excluded_lines(content) offset = 0 diff --git a/lib/ruby-gem-release/changelog/intridea.rb b/lib/ruby-gem-release/changelog/intridea.rb index 5789be0..417b0d5 100644 --- a/lib/ruby-gem-release/changelog/intridea.rb +++ b/lib/ruby-gem-release/changelog/intridea.rb @@ -3,7 +3,7 @@ module RubyGemRelease module Changelog class Intridea < Base - UNRELEASED = /\A## \d+\.\d+\.\d+ \(Next\)\z/ + UNRELEASED = /\A\#{1,6} \d+\.\d+\.\d+ \(Next\)\z/ PLACEHOLDER = '- Your contribution here.' def initialize(path: 'CHANGELOG.md', placeholder: PLACEHOLDER) @@ -20,11 +20,11 @@ def update_release(version:, date:) content = File.read(@path) separator = newline(content) position = heading(content, UNRELEASED) + prefix = heading_prefix(content, position) check_release_absent(content, version) - line_end = line_end(content, position, separator) - section_end = heading_positions(content, /\A## /).find { |offset| offset > position } || content.length - content[position...section_end] = "## #{version} (#{date.strftime('%Y/%m/%d')})" + - remove_placeholder(content[line_end...section_end]) + section_end = section_end_position(content, position, prefix.length) + content[position...section_end] = "#{prefix} #{version} (#{date.strftime('%Y/%m/%d')})" + + remove_placeholder(content[line_end(content, position, separator)...section_end]) File.write(@path, content) end @@ -33,21 +33,26 @@ def update_next(version:, released_version:) validate_version(released_version) content = File.read(@path) separator = newline(content) - pattern = %r{\A## #{Regexp.escape(released_version)} \(\d{4}/\d{2}/\d{2}\)\z} + pattern = %r{\A\#{1,6} #{Regexp.escape(released_version)} \(\d{4}/\d{2}/\d{2}\)\z} position = heading(content, pattern) + prefix = heading_prefix(content, position) check_absent(content, UNRELEASED, 'unreleased heading already exists') - File.write(@path, content.insert(position, ["## #{version} (Next)", '', @placeholder, '', ''].join(separator))) + File.write(@path, content.insert(position, ["#{prefix} #{version} (Next)", '', @placeholder, '', ''].join(separator))) end def check_release_absent(content, version) - check_absent(content, %r{\A## #{Regexp.escape(version)} \(\d{4}/\d{2}/\d{2}\)\z}, 'release heading already exists') + check_absent(content, %r{\A\#{1,6} #{Regexp.escape(version)} \(\d{4}/\d{2}/\d{2}\)\z}, 'release heading already exists') end def line_end(content, position, separator) content.index(separator, position) || raise(ArgumentError, "heading must end with a newline in #{@path}") end + def section_end_position(content, position, level) + heading_positions(content, /\A\#{1,6} /, max_level: level).find { |offset| offset > position } || content.length + end + def remove_placeholder(section) positions = [] editable_lines(section) { |line, offset| positions << offset if line.chomp == @placeholder } diff --git a/lib/ruby-gem-release/changelog/keep_a_changelog.rb b/lib/ruby-gem-release/changelog/keep_a_changelog.rb index abba7b8..0422c43 100644 --- a/lib/ruby-gem-release/changelog/keep_a_changelog.rb +++ b/lib/ruby-gem-release/changelog/keep_a_changelog.rb @@ -3,7 +3,7 @@ module RubyGemRelease module Changelog class KeepAChangelog < Base - UNRELEASED = /\A## \[Unreleased\]\z/ + UNRELEASED = /\A\#{1,6} \[Unreleased\]\z/ private @@ -12,11 +12,11 @@ def update_release(version:, date:) content = File.read(@path) separator = newline(content) position = heading(content, UNRELEASED) - check_absent(content, /\A## \[#{Regexp.escape(version)}\] - \d{4}-\d{2}-\d{2}\z/, 'release heading already exists') + check_absent(content, /\A\#{1,6} \[#{Regexp.escape(version)}\] - \d{4}-\d{2}-\d{2}\z/, 'release heading already exists') line_end = content.index(separator, position) raise ArgumentError, "heading must end with a newline in #{@path}" unless line_end - content[position...line_end] = "## [#{version}] - #{date.strftime('%Y-%m-%d')}" + content[position...line_end] = "#{heading_prefix(content, position)} [#{version}] - #{date.strftime('%Y-%m-%d')}" release_references(content, version, separator) File.write(@path, content) end @@ -26,12 +26,13 @@ def update_next(version:, released_version:) validate_version(released_version) content = File.read(@path) separator = newline(content) - pattern = /\A## \[#{Regexp.escape(released_version)}\] - \d{4}-\d{2}-\d{2}\z/ + pattern = /\A\#{1,6} \[#{Regexp.escape(released_version)}\] - \d{4}-\d{2}-\d{2}\z/ position = heading(content, pattern) + prefix = heading_prefix(content, position) check_absent(content, UNRELEASED, 'unreleased heading already exists') check_next_reference(content, released_version) - File.write(@path, content.insert(position, ['## [Unreleased]', '', ''].join(separator))) + File.write(@path, content.insert(position, ["#{prefix} [Unreleased]", '', ''].join(separator))) end def references(content, label) diff --git a/spec/ruby_gem_release/changelog/intridea_spec.rb b/spec/ruby_gem_release/changelog/intridea_spec.rb index 755eb57..9d6b31e 100644 --- a/spec/ruby_gem_release/changelog/intridea_spec.rb +++ b/spec/ruby_gem_release/changelog/intridea_spec.rb @@ -14,6 +14,25 @@ after { FileUtils.remove_entry(directory) } + [1, 3, 4, 5, 6].each do |level| + it "preserves level #{level} headings across release and next-section preparation" do # rubocop:disable RSpec/ExampleLength -- Exercise both phases and unchanged history. + prefix = '#' * level + history = "#{prefix} 0.9.0 (2026/11/06)\n\n* Older change.\n" + File.write(path, "#{prefix} 1.0.0 (Next)\n\n* Change.\n* Your contribution here.\n\n#{history}") + updater = described_class.new(path: path, placeholder: '* Your contribution here.') + updater.release!(release) + updater.prepare_next!(next_release) + expect(File.read(path)).to eq("#{prefix} 1.0.1 (Next)\n\n* Your contribution here.\n\n#{prefix} 1.0.0 (2026/11/07)\n\n* Change.\n\n#{history}") + end + end + + it 'keeps deeper subsections inside the release and stops at a shallower heading' do + history = "## History\n\n### 0.9.0 (2026/11/06)\n\n- Your contribution here.\n" + File.write(path, "### 1.0.0 (Next)\n\n#### Added\n\n- Change.\n- Your contribution here.\n\n#{history}") + described_class.new(path: path).release!(release) + expect(File.read(path)).to eq("### 1.0.0 (2026/11/07)\n\n#### Added\n\n- Change.\n\n#{history}") + end + it 'releases an Intridea section and opens the next section', :aggregate_failures do # rubocop:disable RSpec/ExampleLength -- Check both phases. File.write(path, "# Changelog\n\n## 1.0.0 (Next)\n\n- Change.\n- Your contribution here.\n") updater = described_class.new(path: path) 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 81850c6..04d7ec9 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,25 @@ after { FileUtils.remove_entry(directory) } + [1, 3, 6].each do |level| + it "preserves level #{level} headings across release and next-section preparation" do # rubocop:disable RSpec/ExampleLength -- Exercise both phases and unchanged history. + prefix = '#' * level + history = "#{prefix} [0.9.0] - 2026-11-06\n\n- Older change.\n" + File.write(path, "#{prefix} [Unreleased]\n\n- Change.\n\n#{history}") + updater = described_class.new(path: path) + updater.release!(release) + updater.prepare_next!(next_release) + expect(File.read(path)).to eq("#{prefix} [Unreleased]\n\n#{prefix} [1.0.0] - 2026-11-07\n\n- Change.\n\n#{history}") + end + end + + it 'rejects ambiguous release headings at different depths without writing', :aggregate_failures do + content = "## [Unreleased]\n\n### [Unreleased]\n" + File.write(path, content) + expect { described_class.new(path: path).release!(release) }.to raise_error(ArgumentError, /exactly one/) + expect(File.read(path)).to eq(content) + end + it 'releases Keep a Changelog and opens the next section', :aggregate_failures do # rubocop:disable RSpec/ExampleLength -- Check both phases. File.write(path, "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature.\n") updater = described_class.new(path: path) diff --git a/spec/ruby_gem_release/workflow_github_release_spec.rb b/spec/ruby_gem_release/workflow_github_release_spec.rb index 1bb8d63..b92c13f 100644 --- a/spec/ruby_gem_release/workflow_github_release_spec.rb +++ b/spec/ruby_gem_release/workflow_github_release_spec.rb @@ -32,6 +32,10 @@ def consumer_rakefile File.read('examples/keep_a_changelog/Rakefile') end + def consumer_changelog + File.read('examples/keep_a_changelog/CHANGELOG.md') + end + before do FileUtils.mkdir_p(File.join(checkout, 'lib/example')) File.write(File.join(checkout, 'lib/example/version.rb'), "module Example\n VERSION = '0.1.0'\nend\n") @@ -45,7 +49,7 @@ def consumer_rakefile spec.files = ['lib/example/version.rb'] end RUBY - FileUtils.cp('examples/keep_a_changelog/CHANGELOG.md', checkout) + File.write(File.join(checkout, 'CHANGELOG.md'), consumer_changelog) File.write(File.join(checkout, 'Rakefile'), "#{consumer_rakefile}\n#{remote_write_guard}") run_git('init', '--bare', '--quiet', '-b', 'master', remote) run_git('init', '--quiet', '-b', 'master', checkout) @@ -100,7 +104,7 @@ def run_phase(name) end end - shared_examples 'a local release lifecycle' do + shared_examples 'a local release lifecycle' do |next_heading = '## [Unreleased]'| it 'runs the documented callbacks, preserving the exact artifact without remote writes', :aggregate_failures do # rubocop:disable RSpec/ExampleLength -- Exercise separate workflow processes and a real consumer. initial = run_git('--git-dir', remote, 'rev-parse', 'master') run_phase('Configure Release Inputs') if steps.any? { |item| item['name'] == 'Configure Release Inputs' } @@ -114,6 +118,7 @@ def run_phase(name) expect(File).to exist(File.join(directory, 'release.bundle')) run_phase('Prepare Next Development Iteration') expect(File.read(File.join(checkout, 'lib/example/version.rb'))).to include("VERSION = '0.1.1'") + expect(File.read(File.join(checkout, 'CHANGELOG.md'))).to include(next_heading) expect(run_git('--git-dir', remote, 'rev-parse', 'master')).to eq(initial) expect(run_git('--git-dir', remote, 'tag')).to eq('') expect(run_git('-C', checkout, 'status', '--porcelain')).to eq('') @@ -122,6 +127,18 @@ def run_phase(name) it_behaves_like 'a local release lifecycle' + context 'with third-level Intridea release headings' do + def consumer_rakefile + super.sub('Changelog::KeepAChangelog.new', "Changelog::Intridea.new(placeholder: '* Your contribution here.')") + end + + def consumer_changelog + "### 0.1.0 (Next)\n\n* Change.\n* Your contribution here.\n" + end + + it_behaves_like 'a local release lifecycle', '### 0.1.1 (Next)' + end + context 'with detailed manual callbacks' do def consumer_rakefile File.read('docs/usage.md').split('## Custom Callbacks').last[/```ruby\n(.*?)```/m, 1]