Changelog fragments for Ruby projects with a hand-curated CHANGES.txt, as used by net-ssh, net-scp and net-sftp.
Contributors don't edit CHANGES.txt. Each pull request adds a fragment file to changelog.d/; the project's version bump (rake vbump:*) writes all fragments into a new === <version> section at the top of CHANGES.txt and deletes them. No merge conflicts on CHANGES.txt, and every PR states whether it changes anything users can see.
changelogd is a small gem (Ruby >= 2.6, stdlib only, no dependencies) with rake tasks, a Ruby API and a CLI, plus a GitHub Action that checks pull requests. It isn't on rubygems.org; projects use it through a Bundler git source.
A fragment is named <number>.<type>.txt, where <number> is the PR number (open the PR first, then add the fragment), and holds a single UTF-8 paragraph: the entry as it should read in CHANGES.txt, without the bullet or the BREAKING:/Security: prefix. Lines are joined with a space, so it can be wrapped.
| Type | Written as | Use for |
|---|---|---|
breaking-security |
* BREAKING: Security: <text> |
an incompatible change made for security |
breaking |
* BREAKING: <text> |
an incompatible change |
security |
* Security: <text> |
a security fix or hardening |
change |
* <text> |
any other user-visible fix or feature |
none |
nothing | PRs with no user-visible change (tests, CI, docs, refactors) |
rake "changelog:new[1058,change,Session#exec! now returns the exit status]"
# changelog.d/1058.change.txt -> * Session#exec! now returns the exit status [#1058]
echo 'Removed the `:paranoid` option; use `:verify_host_key`' > changelog.d/1050.breaking.txt
echo 'Reject packet lengths above 256 KB, as OpenSSH does [GHSA-pfqh-p9vh-wx4p]' > changelog.d/1060.security.txt
touch changelog.d/1061.none.txtReferences: if the text doesn't end with a reference list, [#<number>] is appended. A reference list is a bracketed, comma-separated list of #<number>, GHSA-xxxx-xxxx-xxxx or CVE-YYYY-NNNN items at the very end, such as [#985, #1056], [GHSA-pfqh-p9vh-wx4p] or [CVE-2024-12345, #1060]. Write the list yourself to reference issues or advisories; include the PR number in it if you want it shown. Anything else in brackets ([npezza93]) isn't a reference list, so the PR number is still appended.
Several entries from one PR take a numeric suffix: 1050.breaking.txt, 1050.breaking.2.txt, 1050.change.txt. rake changelog:new picks the next free one.
A fragment is rejected if its name doesn't match <number>.<type>[.<n>].txt with a known type, or if a non-none fragment is empty, has more than one paragraph, isn't valid UTF-8, still holds the TODO placeholder that changelog:new writes without a text, or starts with a bullet or with BREAKING:/Security: (the type adds those). The content of none fragments is ignored.
The changelog.d/ directory is what opts a project in. Keep a changelog.d/README.md in it (it's ignored, as are dotfiles) so the directory survives releases. Without the directory, releasing does nothing, so the same release tooling also works on old maintenance branches.
Releasing for a heading such as 8.0.3 or 8.0.3 rc1 writes === <heading> at the top of CHANGES.txt (after any text before the first === heading), followed by a blank line, the entries, and a blank line before the previous section. It then git rms all fragments, including none ones, so the deletions are staged with the version bump; untracked fragments are deleted from disk. Committing is left to the caller.
- Entries are grouped
BREAKING: Security:,BREAKING:,Security:, then the rest. - Within a group, fragments are ordered by PR number, newest first, then by suffix.
- Hand-written entries from an
=== Unreleasedsection at the top ofCHANGES.txt, if there is one, follow the fragments of their group in their written order; that section is replaced by the release section. An entry starts with*and may span several lines; its group comes from itsBREAKING: Security:/BREAKING:/Security:prefix. - A final release (
X.Y.Zwithout a pre-release suffix) with nothing to report still gets a section sayingNo changes since <previous version>., so every published version has a heading. A pre-release with nothing to report leavesCHANGES.txtalone. - The bullet comes from the first entry already in
CHANGES.txt(*in net-ssh,*in net-scp and net-sftp),*if there is none. - Releasing a heading that
CHANGES.txtalready has fails, unless no fragments or Unreleased entries are pending; then it only removes thenonefragments. That lets a hand-written section for the version stand.
Gemfile:
gem "changelogd", github: "net-ssh/changelogd", tag: "v1", group: :developmentgithub: needs Bundler 2. Bundler 1 (the default on Ruby 2.6) expands it to a git:// URL that GitHub no longer serves, so a project that still uses Bundler 1 anywhere needs git: "https://github.com/net-ssh/changelogd.git" instead. v1 is moved to each compatible release; bundle update changelogd picks up the latest.
Rakefile, with a change_version that rewrites version.rb for rake vbump:*:
begin
require "changelogd/rake"
rescue LoadError
# A git gem isn't on the load path under plain `rake`, as the release workflow runs it; bundler/setup could clash with the running rake.
require "bundler"
$LOAD_PATH.unshift(*Bundler.definition.specs["changelogd"].flat_map(&:full_require_paths))
require "changelogd/rake"
end
Changelogd::RakeTasks.new(version_file: "lib/net/ssh/version.rb")
def change_version
Rake::Task["changelog:check"].invoke # fail before touching version.rb
# ... write the new MAJOR/MINOR/TINY/PRE to lib/net/ssh/version.rb ...
Rake::Task["changelog:release"].invoke
endUnder bundle exec the first require works; under plain rake after bundle install (as the net-ssh-release bump workflows run it) the rescue finds the gem through Bundler without bundler/setup, which would fail when the running rake isn't the locked one. changelog:release reads the version file when it runs, so it sees the bumped version. The version file must define MAJOR, MINOR and TINY as integers and PRE as nil or a string, one per line; PRE = "rc1" gives the heading 8.0.3 rc1.
Then add changelog.d/README.md, the pull request check, and a short contributor note: a "Changelog" section in CONTRIBUTING.md and a comment in .github/pull_request_template.md. Contributors need the file name, the types, an example and the skip-changelog label; link here for the rest.
To use a local changelogd checkout, set BUNDLE_LOCAL__CHANGELOGD=../changelogd and BUNDLE_DISABLE_LOCAL_BRANCH_CHECK=true (Bundler's local overrides otherwise require a branch:), or the equivalent bundle config set --local local.changelogd ... settings.
# .github/workflows/changelog.yml
name: Changelog
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
jobs:
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# HEAD is the PR merged into the base branch; HEAD^1 is the base it was merged into.
fetch-depth: 2
- uses: net-ssh/changelogd@v1The action runs the CLI with the runner's Ruby, no Bundler needed. It validates every fragment and the === Unreleased section, and fails the PR if it
- edits
CHANGES.txt, or - doesn't add or modify a
changelog.d/<PR>.<type>[.<n>].txtfragment, unless it only toucheschangelog.d/.
A PR with the skip-changelog label, or opened by dependabot[bot], needs no fragment; fragments it does add are still validated. labeled/unlabeled in the trigger make the check re-run when a maintainer adds or removes the label.
| Input | Default | Description |
|---|---|---|
pr |
event's PR number | PR whose fragment is required |
base-ref |
HEAD^1 |
commit the PR is diffed against |
skip-label |
skip-changelog |
label that waives the fragment requirement |
skip-users |
dependabot[bot] |
space-separated PR authors that need no fragment |
working-directory |
. |
project root |
changes |
CHANGES.txt |
changelog file, relative to the root |
dir |
changelog.d |
fragment directory, relative to the root |
ruby-version |
runner's Ruby | install this Ruby with ruby/setup-ruby first |
require "changelogd/rake" and Changelogd::RakeTasks.new(version_file: ..., root: Dir.pwd, changes: "CHANGES.txt", dir: "changelog.d", bullet: nil) define:
| Task | Does |
|---|---|
rake "changelog:new[1058,change,Text]" |
creates a fragment; commas and spaces in the text are kept |
rake changelog:check |
validates the fragments and the Unreleased section |
rake changelog:preview |
prints the section the next release would write ([8.0.3] sets the heading) |
rake changelog:release |
writes the release section for the version file's version (or [<heading>]) |
Errors abort the task with exit status 1.
require "changelogd"
changelog = Changelogd.new(root, changes: "CHANGES.txt", dir: "changelog.d", bullet: nil)
changelog.check # => fragments; raises Changelogd::Error listing every problem
changelog.section("8.0.3") # => section text, or nil when there is nothing to release
changelog.release("8.0.3") # => writes it, removes the fragments; section or nil
changelog.new_fragment(1058, "change", "Text") # => path
Changelogd.heading_from_version_file("lib/net/ssh/version.rb") # => "8.0.3 rc1"bundle exec changelogd, or ruby exe/changelogd from a checkout:
changelogd [options] check
changelogd [options] require-fragment --pr N [--base-ref HEAD^1]
changelogd [options] section [HEADING | --version-file F] # preview, default heading: Unreleased
changelogd [options] release HEADING | --version-file F
changelogd [options] new N TYPE [TEXT...]
--root DIR project root (default: .)
--changes FILE changelog, relative to the root (default: CHANGES.txt)
--dir DIR fragment directory, relative to the root (default: changelog.d)
--bullet STR entry prefix (default: as in CHANGES.txt)
Exit status: 0 on success, 1 on invalid fragments or a failed check, 2 on a usage error. On GitHub Actions errors are printed as ::error:: annotations.
rake test, on Ruby 2.6 and newer. Releases bump Changelogd::VERSION and are tagged vX.Y.Z; the v1 tag is moved to the latest v1.x.y (git tag -f v1 && git push -f origin v1), so both net-ssh/changelogd@v1 and tag: "v1" Gemfiles get compatible fixes. Breaking changes go to v2.
MIT, see LICENSE.