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
10 changes: 8 additions & 2 deletions .github/workflows/release-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -76,16 +79,19 @@ 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'
path = 'spec/fixtures/release_consumer/version.rb'
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions docs/changelog/base.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions docs/changelog/intridea.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
2 changes: 2 additions & 0 deletions docs/changelog/keep_a_changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
8 changes: 6 additions & 2 deletions lib/ruby-gem-release/changelog/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,23 @@ 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)
end
matches
end

def heading_prefix(content, position)
content[position, 6][/\A#+/]
end

def editable_lines(content)
excluded = excluded_lines(content)
offset = 0
Expand Down
21 changes: 13 additions & 8 deletions lib/ruby-gem-release/changelog/intridea.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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 }
Expand Down
11 changes: 6 additions & 5 deletions lib/ruby-gem-release/changelog/keep_a_changelog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module RubyGemRelease
module Changelog
class KeepAChangelog < Base
UNRELEASED = /\A## \[Unreleased\]\z/
UNRELEASED = /\A\#{1,6} \[Unreleased\]\z/

private

Expand All @@ -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
Expand All @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions spec/ruby_gem_release/changelog/intridea_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 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,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)
Expand Down
21 changes: 19 additions & 2 deletions spec/ruby_gem_release/workflow_github_release_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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' }
Expand All @@ -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('')
Expand All @@ -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]
Expand Down
Loading