Skip to content
rlsbl.commands.changelog_cmd
On this page

Changelog subcommands for adding new entries, amending released versions, and generating Markdown changelogs from JSONL sources.

#rlsbl.commands.changelog_cmd

#rlsbl.commands.changelog_cmd

Changelog subcommands for adding new entries, amending released versions, and generating Markdown changelogs from JSONL sources.

#_ResolvedContext

Carries project, releasable, and workspace info for changelog commands.

all_projects is mandatory and is every member of the workspace, not just this releasable's: file attribution is decided against the whole list (see :mod:rlsbl.ownership), so a context that carries only the members it cares about would answer "who owns this file?" with the wrong member.

#scope

python
def scope(self)

The ownership scope this changelog covers.

A releasable also claims its own state directory (.rlsbl-monorepo/releasables/<name>/): a commit that archives its release file or finalizes its changelog is about that releasable, and belongs to no member at all.

#is_releasable

python
def is_releasable(self)

#name

python
def name(self)

#get

python
def get(self, key, default=None)

#_resolve_workspace_project

python
def _resolve_workspace_project(project_root)

Resolve the WorkspaceProject for project_root, or None in standalone mode.

Also checks and exits if the project is non-releasable. Returns a _ResolvedContext with releasable info when in explicit mode.

#_check_project_scope

python
def _check_project_scope(resolved_commits, ws_context)

Verify all commits touch something this changelog's scope claims.

Hard error if any commit touches nothing in scope. Member ownership is single-owner: a commit touching only another member's directory belongs in that member's changelog, and a commit touching only root files belongs in the root member's. A releasable's scope additionally claims its own state directory, which belongs to no member, so a commit that only archives its release file or finalizes its changelog is in scope for it.

ws_context is what :func:_resolve_workspace_project returns: a :class:_ResolvedContext carrying the whole member list, or None in standalone mode, where there is no scope to check.

#_out_of_scope_remedy

python
def _out_of_scope_remedy(sha, scope)

What to do about a commit this changelog's scope does not cover.

Two cases, because there are two worlds. When another member owns the commit's files there is a directory to file the entry from, and the remedy names its owner. When NOTHING owns them -- every path the commit touches is tool-owned, rlsbl's own bookkeeping (a regenerated snapshot, a generated router) -- there is no owning member's directory anywhere, so telling the reader to go to one names a place that does not exist. Such a commit needs no changelog coverage at all, so the answer is to add no entry.

The sibling reader-side finding (:func:rlsbl.changelog.validate._out_of_scope_detail) splits on exactly the same condition, and both ask :func:~rlsbl.changelog.validate._foreign_owner_description who the owner is, so the two never disagree about whether one exists.

#_entry_ref

python
def _entry_ref(entry, ordinal)

Human-readable reference to an existing entry.

Prefers the entry's stable ULID id (survives unrelated edits to the file). Legacy entries without an id fall back to a 1-based ordinal, noted explicitly so the reader knows it is positional and unstable.

#_check_duplicate_commits

python
def _check_duplicate_commits(existing_entries, new_entry)

Check if any commits in new_entry already appear in existing entries.

Hard error (nothing is written, the process exits) when a commit appears in an existing entry with the SAME user_facing value and type. Allowed (the new entry IS written) when the type/user_facing differ: one commit may legitimately carry, say, both a feature and a fix -- validation bounds this via max_entries_per_commit.

Existing entries are named by their stable ULID id (see _entry_ref) so the message stays valid across unrelated edits to the file.

#_build_entry

python
def _build_entry(flags, resolved_commits)

Build and validate a ChangelogEntry from CLI flags and resolved commits.

Reads user_facing, description, type, and release_type from flags. Validates that user-facing entries have description and type. Returns a validated ChangelogEntry.

#_entry_is_committed

python
def _entry_is_committed(entry, unreleased_path)

True when HEAD's copy of unreleased_path already carries entry.

The authority on whether an entry was recorded is git, not the commit tool's exit status. Two changelog add runs at once append safely -- the append never reads the file back -- but their commits collide: whichever stages first carries BOTH lines, and the loser's commit then finds nothing left to stage and exits non-zero even though its entry is safely in the tree.

A repository git cannot answer for (no HEAD yet, the file untracked at HEAD) reads as not committed, which is the conservative direction: the caller retries and then says so.

#_commit_appended_entry

python
def _commit_appended_entry(entry, unreleased_path, commit_msg)

Commit the just-appended entry, judging the outcome by git.

Losing the commit race is benign and is reported as what it is; an entry that no commit carries is a hard error, because "added" was printed and the file is one tree-cleaning step away from losing it silently.

A commit the tool reports as made is taken at its word, as is the no-op it reports when the named file already matches HEAD -- that state means HEAD's copy holds this entry. Only a REPORTED failure is put to git, and only then is a retry spent.

#_regenerate_changelog_outputs

python
def _regenerate_changelog_outputs(ws_context, project_root, changes_dir)

Regenerate CHANGELOG.md via the single home resolver.

In explicit releasable mode, writes the canonical CHANGELOG.md into the releasable dir and regenerates the combined root CHANGELOG.md; otherwise writes the project-root CHANGELOG.md. Returns the list of output paths (for auto-commit).

#_resolve_changes_dir

python
def _resolve_changes_dir(ws_context, project_root)

Return the appropriate changes directory based on context.

In explicit releasable mode, returns the releasable's changes dir. Otherwise, returns the per-project changes dir.

#_derive_packages_from_commits

python
def _derive_packages_from_commits(resolved_commits, scope)

Derive the list of affected package names from commit file paths.

Single-owner attribution: each changed file names exactly one member, so a commit that touches pkg/inner no longer claims pkg as well. Only members inside scope are reported -- another releasable's packages never leak into this changelog -- and the result is sorted and deduplicated, or None when nothing in scope was touched.

The narrowing is deliberate and only affects what is derived: a manual broadening via rlsbl changelog edit stays exactly as written.

#_populate_packages_field

python
def _populate_packages_field(entry, resolved_commits, ws_context)

Auto-populate entry.packages in explicit releasable mode.

Releasable-scoped: only the current releasable's members are considered, so a commit touching sub-projects of another releasable does not leak those packages into this releasable's changelog entry. No-op outside explicit releasable mode (non-releasable context or no member projects).

#cmd_add

python
def cmd_add(flags, project_root)

Add a changelog entry.

Appends to unreleased.jsonl. Required flags: --commits, --description, --type (the latter two unless --no-user-facing).

Under --dry-run, all validation runs but nothing is written.

#_cmd_add_commit

python
def _cmd_add_commit(flags, project_root, ws_context, config, dry_run)

Add a changelog entry to unreleased.jsonl.

#cmd_generate

python
def cmd_generate(flags, project_root)

Generate CHANGELOG.md from JSONL changelog files.

#cmd_amend

python
def cmd_amend(flags, project_root)

Amend a released version's JSONL changelog by appending a new entry.

Unlocks the read-only versioned JSONL file, appends the entry, re-locks it, regenerates CHANGELOG.md, and optionally syncs GitHub Release notes.

Required flags:

  • --version: which released version to amend (e.g., "0.39.0")
  • --commits: comma-separated commit hashes

Optional flags:

  • --description and --type: required unless --no-user-facing is set
  • --no-user-facing: mark entry as non-user-facing
  • --no-validate-hashes: skip hash validation (for old/amended commits)

Under --dry-run, all validation runs but nothing is written: no JSONL append, no CHANGELOG.md regeneration, no GitHub Release sync, no commit.

#_resolve_selector_commits

python
def _resolve_selector_commits(commits_raw, *, require_resolvable)

Resolve a --commits selector string to the hashes it names.

Shared by changelog edit and changelog remove: both address an existing entry by the commits it covers, and both need an abbreviated hash expanded before it is compared against what the JSONL stores (always full SHAs).

require_resolvable is the one place they differ, and it is a statement about what each command is FOR rather than a tolerance setting:

  • changelog edit requires resolution. Editing an entry means keeping

it, so a hash git cannot find is a mistake to report, not a selection.

  • changelog remove does not. The entry a removal deletes is very often

the one whose commits a rebase or a scrub took out of the history -- that is precisely when the orphan check prints the removal remedy -- so an unresolvable hash is carried through as a literal selector and matched against the full SHAs the line stores. A hash that matches no entry is then reported as the no-match it is.

#_find_entry_matches

python
def _find_entry_matches(changes_dir, *, id_filter=None, search_set=frozenset())

Every entry an --id or --commits selector addresses.

Returns a list of (file_path, line_index, entry, version_or_none), unreleased.jsonl first and then the versioned files newest-first, so a caller reporting several matches lists them in a stable order. version is None for the unreleased file and the bare semver otherwise.

#_selector_description

python
def _selector_description(id_filter, resolved_search)

How the selector that produced a match set is named in a message.

#_refuse_no_match

python
def _refuse_no_match(id_filter, resolved_search)

Exit naming the selector that addressed no entry at all.

#_rewrite_entries

python
def _rewrite_entries(target_path, entries)

Atomically rewrite a JSONL file to hold exactly entries.

preserve_mode: a rewrite changes what the file SAYS, never what it is. Pinning 0o600 here (the mode the older mkstemp-based hand-rolled write happened to leave) turned an ordinary 0o644 changelog into an owner-only one the first time an edit or a removal touched it. A released file is relocked by :func:writable_jsonl around the call regardless.

#_finish_released_write

python
def _finish_released_write(ws_context, project_root, changes_dir, file_path, version, *, message, auto_commit, commit_msg)

The tail every write to a RELEASED version's JSONL shares.

Regenerates CHANGELOG.md at its canonical home, re-syncs that version's GitHub Release notes, and commits the JSONL plus everything the regeneration touched. message is the one line naming what just happened to the JSONL, printed ahead of the regeneration line.

#cmd_edit

python
def cmd_edit(flags, project_root)

Edit an existing changelog entry in unreleased or released JSONL files.

Finds the entry by commit hash, applies field changes, and rewrites the file atomically. For released files, temporarily unlocks the read-only file, regenerates CHANGELOG.md, and syncs GitHub Release notes.

This is a sparse update of one changelog entry, and the CLI declares it as one (update_of("changelog-entry", write_mode="sparse")). Two rules that used to be hand-rolled here are the framework's now and are NOT re-checked:

  • at least one property (--description / --type /

--user-facing) is refused at parse time by the update declaration;

  • at least one identity member (--commits / --id) is refused by

the entry-selection at-least-one constraint.

unset-type / unset-description carry ctx.unset(...): a cleared property is a WRITE of absence, which is why it cannot be read off the value (an untouched property delivers the same None).

Under --dry-run, all validation and entry matching runs but nothing is written: no file rewrite, no CHANGELOG.md regeneration, no GitHub Release sync, no commit.

#cmd_remove

python
def cmd_remove(flags, project_root)

Remove one changelog entry from unreleased.jsonl or a released JSONL file.

Selection is the same as changelog edit's -- --id or --commits, resolved by :func:_find_entry_matches -- with one deliberate difference: edit disambiguates several matches with --type, while a removal refuses them. Deleting the wrong line is not correctable by re-running with a better flag, so the ambiguity is reported with every match named and nothing is written.

The file is rewritten atomically without the removed line. A released file goes through the unlock/relock flow, regenerates CHANGELOG.md and re-syncs that version's GitHub Release notes, exactly as amend and edit do.

Under --dry-run the entry is located and printed but nothing is written: no file rewrite, no CHANGELOG.md regeneration, no GitHub Release sync, no commit.

#_parse_sha_map_lines

python
def _parse_sha_map_lines(lines)

Parse old_sha new_sha lines into a dict.

Accepts the format git's post-rewrite hook emits: each line is <old-sha> <new-sha> (optionally followed by extra fields which are ignored). Blank lines and lines starting with # are skipped. Returns {old_sha: new_sha}.

Hardened against being fed a raw git-filter-repo commit-map directly:

  • The literal old new header row git-filter-repo writes is skipped

rather than ingested as a junk {"old": "new"} mapping.

  • Rows whose target is the all-zeros null SHA (git-filter-repo's marker

for a pruned commit) are dropped with a warning. Keeping them would let a real hash be rewritten to nothing, corrupting the changelog entry.

#cmd_remap

python
def cmd_remap(flags, project_root)

Remap stale commit hashes in all JSONL changelog files.

Reads a mapping of old-SHA to new-SHA from one of three sources (--map-file, --from-journal, --stdin) and applies it to every JSONL file in the project's (or monorepo's) changelog dirs.

At least one source is required; no source is a hard error. Auto-commits with Autogenerated: true trailer.

#_sync_github_release

python
def _sync_github_release(version: str) -> None

Sync GitHub Release notes for a version (best-effort, warns on failure).

#_filter_dirty_files

python
def _filter_dirty_files(paths: list[str], repo_root: str) -> list[str]

Return the subset of paths that git reports as modified/untracked.

Paths are returned as absolute paths. Used by releasable-mode changelog generation where the generated files live outside the member project.

untracked="all" so a brand-new generated file inside a wholly untracked directory is reported by name: git's default collapses such a directory into one record, which names no file to commit.

#_get_generated_files

python
def _get_generated_files(project_path: str) -> list[str]

Return paths of files modified or created by generate_changelog.

Checks git status for CHANGELOG.md and .rlsbl/changes/*.md files. untracked="all" for the same reason as :func:_filter_dirty_files: on a project whose .rlsbl/changes/ is not tracked yet, the default output names only the directory, and every generated .md inside it would go uncommitted.

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • claudewheel A TUI Claude Code Launcher that lets you have more than one profile, manage sessions lifecycle, pick the exact CC version, model to use (even older unlisted ones), pick which GitHub account to use, etc.
  • dirstat Fast, single-binary directory statistics CLI: every file under a tree grouped by format, with counts, sizes, and lines of code, as a colored terminal table or as JSON
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • safegit git wrapper CLI that gives each commit its own temporary index and retries ref updates on conflict, so concurrent agents share one repository
  • saferm Command-line replacement for rm that archives every deletion with a mandatory reason and the context it ran in, so deleted files can be listed, inspected and restored
  • selfdoc Static Site Generator that builds a project's documentation site directly from its source code, so the docs can never drift from the code they describe, with SEO/AEO, first-class blog, search, and cross-project linking built in
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
  • wesktop A Python framework that turns an ASGI web app into a desktop application, serving it from a local Granian server and displaying it in a native OS window via pywebview
Search