Class: GemKit::Release::Changelog

Inherits:
Object
  • Object
show all
Defined in:
lib/gem_kit/release/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: gem_kit-release check validates the format, and refuses to release a version that has no section of its own.

changelog = GemKit::Release::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.



58
59
60
61
62
# File 'lib/gem_kit/release/changelog.rb', line 58

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.



55
56
57
# File 'lib/gem_kit/release/changelog.rb', line 55

def path
  @path
end

#releasesObject (readonly)

Returns the value of attribute releases.



55
56
57
# File 'lib/gem_kit/release/changelog.rb', line 55

def releases
  @releases
end

Class Method Details

.load(path = File.join(Dir.pwd, "CHANGELOG.md")) ⇒ Object

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



51
52
53
# File 'lib/gem_kit/release/changelog.rb', line 51

def self.load(path = File.join(Dir.pwd, "CHANGELOG.md"))
  new(File.exist?(path) ? File.read(path) : nil, path: path)
end

Instance Method Details

#find(version) ⇒ Object



70
71
72
73
# File 'lib/gem_kit/release/changelog.rb', line 70

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)


64
# File 'lib/gem_kit/release/changelog.rb', line 64

def missing? = @text.nil?

#problemsObject

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



77
78
79
80
81
# File 'lib/gem_kit/release/changelog.rb', line 77

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.



85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/gem_kit/release/changelog.rb', line 85

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

  release = find(version)
  return ["#{path} has no section for #{version} — run gem_kit-release 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



68
# File 'lib/gem_kit/release/changelog.rb', line 68

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

#unreleasedObject



66
# File 'lib/gem_kit/release/changelog.rb', line 66

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