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 @@ -2,6 +2,7 @@

## 0.5.2 (Next)

- [#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).
- Your contribution here.

## 0.5.1 (2026/11/10)
Expand Down
8 changes: 6 additions & 2 deletions docs/changelog/intridea.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ changelog = RubyGemRelease::Changelog::Intridea.new(path: 'CHANGELOG.md')

`placeholder:` defaults to `'- Your contribution here.'`. Set `placeholder: '* Your contribution here.'` for the alternative bullet. It must be a single nonempty line, configured identically for both phases.

## Sections

When the released section has headings one level below its release heading, `prepare_next!` carries those headings into the new Next section and adds the configured placeholder under each one. With no subsections, it creates the usual single placeholder. Old subsection notes are not copied.

## Release

During `release:validate`, placeholder-only or structural-only notes fail unless the selected release already has a heading. Invalid headings and missing or duplicate placeholders also fail.
During `release:validate`, placeholder-only or structural-only notes fail unless the selected release already has a heading. Invalid headings and missing placeholders also fail; multiple matching placeholders are allowed.

`changelog.release!(release)` replaces the single Next heading with the [Release](../release.md) version and date, removing exactly one placeholder from that section. Missing or duplicate headings and placeholders, or an existing release heading, fail without writing the file.
`changelog.release!(release)` replaces the Next heading with the [Release](../release.md) version and date, removing every matching placeholder from that section. Missing or duplicate headings, a missing placeholder, or an existing release heading fail without writing the file.

Placeholder-only sections and sections containing only headings, comments, or reference definitions also fail without writing. Add actual release notes to the Next section before retrying; older releases do not count. There is no empty-release opt-out. See [Release Notes](base.md#release) for what counts as content.

Expand Down
37 changes: 33 additions & 4 deletions lib/ruby-gem-release/changelog/intridea.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,12 @@ def update_next(version:, released_version:)
return if heading_positions(content, /\A\#{1,6} #{Regexp.escape(version)} \(Next\)\z/).one?

check_absent(content, UNRELEASED, 'unreleased heading already exists')
write_next_section(content, position, prefix, version, separator)
end

File.write(@path, content.insert(position, ["#{prefix} #{version} (Next)", '', @placeholder, '', ''].join(separator)))
def write_next_section(content, position, prefix, version, separator)
subsections = subsection_headings(content, position, prefix.length)
File.write(@path, content.insert(position, build_next_section(prefix, version, subsections, separator)))
end

def check_release_absent(content, version)
Expand All @@ -62,13 +66,38 @@ def release_notes(section, separator, version)
def remove_placeholder(section, separator)
positions = []
editable_lines(section) { |line, offset| positions << offset if line.chomp == @placeholder }
raise ArgumentError, "expected exactly one placeholder in #{@path}" unless positions.one?
raise ArgumentError, "expected at least one placeholder in #{@path}" if positions.empty?

position = positions.first
section.slice!(position, placeholder_length(section, position, separator))
positions.reverse_each { |position| section.slice!(position, placeholder_length(section, position, separator)) }
section
end

def subsection_headings(content, position, level)
section_end = section_end_position(content, position, level)
lines = content.lines
offsets = line_offsets(lines)
subsection_heading_lines(content, level).filter_map do |index|
lines[index].chomp if (position...section_end).cover?(offsets[index])
end
end

def subsection_heading_lines(content, level)
document(content).each.filter_map do |node|
node.source_position[:start_line] - 1 if node.type == :heading && node.header_level == level + 1
end
end

def build_next_section(prefix, version, subsections, separator)
lines = ["#{prefix} #{version} (Next)"]
if subsections.empty?
lines.push('', @placeholder, '', '')
else
subsections.each { |subsection| lines.push('', subsection, '', @placeholder) }
lines.push('', '')
end
lines.join(separator)
end

def placeholder_length(section, position, separator)
line_end = section.index("\n", position)
length = (line_end ? line_end + 1 : section.length) - position
Expand Down
48 changes: 33 additions & 15 deletions spec/ruby_gem_release/changelog/intridea_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,19 @@
expect(changelog.pending?).to be(true)
end

it 'rejects missing and duplicate placeholders during validation rather than skipping' do
['', "- Your contribution here.\n- Your contribution here.\n"].each do |notes|
content = "## 1.0.0 (Next)\n\n#{notes}"
File.write(path, content)
expect { described_class.new(path: path).pending? }.to raise_error(ArgumentError, /placeholder/)
expect(File.read(path)).to eq(content)
end
it 'ignores multiple placeholders during pending-note validation' do
content = "### 1.0.0 (Next)\n\n#### Features\n\n* Your contribution here.\n\n#### Fixes\n\n* Your contribution here.\n"
File.write(path, content)
changelog = described_class.new(path: path, placeholder: '* Your contribution here.')
expect(changelog.pending?).to be(false)
expect(File.read(path)).to eq(content)
end

it 'rejects a missing placeholder during pending-note validation' do
content = "## 1.0.0 (Next)\n\n- Change.\n"
File.write(path, content)
expect { described_class.new(path: path).pending? }.to raise_error(ArgumentError, /placeholder/)
expect(File.read(path)).to eq(content)
end

[1, 3, 4, 5, 6].each do |level|
Expand Down Expand Up @@ -73,6 +79,20 @@
expect(File.read(path)).to include("## 1.0.1 (Next)\n\n- Your contribution here.\n\n## 1.0.0 (2026/11/07)")
end

it 'removes multiple placeholders and carries subsections into the next section', :aggregate_failures do
content = "### 1.0.0 (Next)\n\n#### Features\n\n* Feature.\n* Your contribution here.\n\n#### Fixes\n\n* Fix.\n* Your contribution here.\n\n### 0.9.0 (2026/11/06)\n\n* Older change.\n"
File.write(path, content)
updater = described_class.new(path: path, placeholder: '* Your contribution here.')

updater.release!(release)
updater.release!(release)
expect(File.read(path)).to eq("### 1.0.0 (2026/11/07)\n\n#### Features\n\n* Feature.\n\n#### Fixes\n\n* Fix.\n\n### 0.9.0 (2026/11/06)\n\n* Older change.\n")

updater.prepare_next!(next_release)
updater.prepare_next!(next_release)
expect(File.read(path)).to eq("### 1.0.1 (Next)\n\n#### Features\n\n* Your contribution here.\n\n#### Fixes\n\n* Your contribution here.\n\n### 1.0.0 (2026/11/07)\n\n#### Features\n\n* Feature.\n\n#### Fixes\n\n* Fix.\n\n### 0.9.0 (2026/11/06)\n\n* Older change.\n")
end

it 'ignores fenced headings and placeholders while releasing an Intridea section' do
content = "## 1.0.0 (Next)\n\n```\n## Example\n- Your contribution here.\n```\n\n- Your contribution here.\n"
File.write(path, content)
Expand Down Expand Up @@ -103,13 +123,11 @@
expect(File.binread(path)).to eq(content)
end

it 'rejects missing or duplicate placeholders without modifying the file', :aggregate_failures do
['', "- Your contribution here.\n- Your contribution here.\n"].each do |section|
content = "## 1.0.0 (Next)\n\n#{section}"
File.write(path, content)
expect { described_class.new(path: path).release!(release) }
.to raise_error(ArgumentError, /placeholder/)
expect(File.read(path)).to eq(content)
end
it 'rejects a missing placeholder without modifying the file' do
content = "## 1.0.0 (Next)\n\n- Change.\n"
File.write(path, content)
expect { described_class.new(path: path).release!(release) }
.to raise_error(ArgumentError, /placeholder/)
expect(File.read(path)).to eq(content)
end
end
Loading