Class: Brute::Changelog

Inherits:
Object
  • Object
show all
Extended by:
GemKit::Deprecate
Defined in:
lib/brute/changelog.rb

Overview

A parser and linter for CHANGELOG.md, which follows Keep a Changelog.

The changelog is the one release artefact nothing else can regenerate, so it is the one most easily forgotten. Making it machine-checkable turns "did anyone write the changelog?" into a gate: bin/lint-changelog validates the format, and bin/release-gem refuses to push a version that has no section of its own.

changelog = Brute::Changelog.load
changelog.problems              # => [] when the format is clean
changelog.release_problems("4.1.0")

The shape it expects:

# Changelog

## [Unreleased]

## [4.1.0] - 2026-08-20

### Added

- Something that happened.

Defined Under Namespace

Classes: Release

Constant Summary collapse

SECTIONS =

The six change types Keep a Changelog defines. Anything else under a version is a typo or an invention, and both are worth catching.

%w[Added Changed Deprecated Removed Fixed Security].freeze
UNRELEASED =
"Unreleased"
HEADING =
/\A##\s+\[([^\]]+)\](?:\s+-\s+(.*))?\s*\z/
SUBHEADING =
/\A###\s+(.*?)\s*\z/
BULLET =
/\A[-*]\s+\S/
DATE =
/\A\d{4}-\d{2}-\d{2}\z/

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(text, path: "CHANGELOG.md") ⇒ Changelog

Returns a new instance of Changelog.



65
66
67
68
69
# File 'lib/brute/changelog.rb', line 65

def initialize(text, path: "CHANGELOG.md")
  @path     = path
  @text     = text
  @releases = text ? parse(text) : []
end

Instance Attribute Details

#pathObject (readonly)

Returns the value of attribute path.



62
63
64
# File 'lib/brute/changelog.rb', line 62

def path
  @path
end

#releasesObject (readonly)

Returns the value of attribute releases.



62
63
64
# File 'lib/brute/changelog.rb', line 62

def releases
  @releases
end

Class Method Details

.load(path = File.expand_path("../../CHANGELOG.md", __dir__)) ⇒ Object

Load the changelog sitting beside the gem (../../CHANGELOG.md).



58
59
60
# File 'lib/brute/changelog.rb', line 58

def self.load(path = File.expand_path("../../CHANGELOG.md", __dir__))
  new(File.exist?(path) ? File.read(path) : nil, path: path)
end

Instance Method Details

#find(version) ⇒ Object



77
78
79
80
# File 'lib/brute/changelog.rb', line 77

def find(version)
  target = Gem::Version.new(version.to_s)
  released.find { |release| Gem::Version.new(release.version) == target rescue false }
end

#missing?Boolean

Returns:

  • (Boolean)


71
# File 'lib/brute/changelog.rb', line 71

def missing? = @text.nil?

#problemsObject

Everything wrong with the file's format, as a list of human-readable problems. Empty means it lints clean.



84
85
86
87
88
# File 'lib/brute/changelog.rb', line 84

def problems
  return ["#{path} does not exist"] if missing?

  [*header_problems, *heading_problems, *ordering_problems, *content_problems]
end

#release_problems(version) ⇒ Object

Everything standing between this changelog and releasing version. Format problems count: a file nobody can parse is not documentation.



92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/brute/changelog.rb', line 92

def release_problems(version)
  return problems unless problems.empty?

  release = find(version)
  return ["#{path} has no section for #{version} — run bin/update-changelog"] if release.nil?
  return ["#{path} section for #{version} is empty"] if release.empty?

  newest = released.first
  if newest && newest.version != release.version
    return ["#{path} lists #{newest.version} above #{version}; the release being cut must come first"]
  end

  []
end

#releasedObject



75
# File 'lib/brute/changelog.rb', line 75

def released = releases.reject(&:unreleased?)

#unreleasedObject



73
# File 'lib/brute/changelog.rb', line 73

def unreleased = releases.find(&:unreleased?)