Skip to content
rlsbl.changelog.generate
On this page

Generate a Markdown CHANGELOG.md from structured JSONL changelog entries, grouping them by version with configurable formatting.

#rlsbl.changelog.generate

#rlsbl.changelog.generate

Generate a Markdown CHANGELOG.md from structured JSONL changelog entries, grouping them by version with configurable formatting.

Per-version file permission model:

  • x.y.z.jsonl -- the finalized, immutable changelog record. Locked to

0o444 at finalization; never rewritten in place.

  • x.y.z.md -- a regenerated derivative of the JSONL. Normally writable,

but a user or tool may reasonably lock it (mirroring the 444 .jsonl it sits beside). generate_version_file therefore compares content first and, when it must rewrite the .md, writes atomically (tmp-in-same-dir + os.replace) with the replacement carrying the file's ORIGINAL mode (never assuming 444), so a deliberate lock survives regeneration and a crash mid-write can never leave a truncated file.

#_base_version

python
def _base_version(version: str) -> str

Strip the pre-release suffix from a version string.

"0.43.0-alpha.0" -> "0.43.0" "1.0.0" -> "1.0.0"

#_is_prerelease

python
def _is_prerelease(version: str) -> bool

Return True if the version has a pre-release suffix.

#ArchiveMetadata

What a regenerated changelog section reads out of a release archive.

The absent-archive case is this dataclass with every field at its default: a version whose archive was never written renders its entries with no description, which is the state a pre-archive release legitimately leaves. An archive that EXISTS but cannot be read is not this -- it raises.

#_legacy_bump_error

python
def _legacy_bump_error(toml_path: str) -> ReleaseFileError | None

The legacy-bump diagnostic for an archive the schema just refused.

bump = "hotfix" was renamed to "infra", and the schema's enum now refuses the old value -- correctly, but with a diagnostic that names the enum rather than the migration. This re-reads the refused document (only on the error path, which already ends the read) and, when the cause really is a legacy bump, raises the message that names what to do about it.

#read_archive_metadata

python
def read_archive_metadata(project_path: str, version: str, *, releases_dir: str | None=None) -> ArchiveMetadata

Read one version's archived release file, through the VALIDATING reader.

Looks for v{version}.toml in releases_dir (default: <project_path>/.rlsbl/releases/; releasable releases archive under the releasable's own releases dir).

An ABSENT archive yields :data:_ABSENT_ARCHIVE -- empty metadata. That is a real state: a version released before rlsbl archived a release file per version has no archive, and its section renders without a description.

An archive that EXISTS and cannot be read is a HARD ERROR naming the file. This path used to parse the document raw and turn any failure -- bad TOML, a missing format_version gate, a field of the wrong type -- into an empty description, which changelog generate then committed over the version's real description in both CHANGELOG.md and the read-only per-version .md. Silently losing a shipped release's prose is the one thing that read must not do.

#generate_version_section

python
def generate_version_section(version: str, entries: list[ChangelogEntry], *, description: str='', context: str='', bump_type: str | None=None, never_released: bool=False) -> str

Generate markdown for one version section.

Only includes entries where user_facing=True. Groups by type under sub-headers (Breaking, Features, Fixes, Other). Empty groups are omitted. If no user-facing entries exist, emits a single "No user-facing changes." bullet.

If all entries share a release_type (e.g., "ota" or "build"), a marker is appended to the version heading.

When description is provided, it is added as a paragraph after the version heading and before the first type group. When context is provided, it is rendered as a collapsible <details> block after the description.

never_released renders :data:NEVER_RELEASED_NOTE between the heading and the description. The section is still produced in full: a version the release record records as never released can carry finalized changelog files, and dropping the section would lose them.

#_atomic_write_preserving_mode

python
def _atomic_write_preserving_mode(path: str, content: str) -> None

Atomically write content to path, preserving its EXACT mode.

Writes to a tmp file in the same directory and os.replaces it into place, so a crash mid-write can never leave a truncated file. os.replace is a directory operation, so it succeeds even when path itself is a read-only file -- there is no need to unlock the target first (unlike writable_jsonl, which relocks to 0o444). The replacement carries the target's ORIGINAL mode when the file exists (a per-version .md may be deliberately locked to any read-only mode; regeneration must not silently change it); a brand-new file uses the umask-derived default, matching the prior open(path, "w") behavior.

#generate_version_file

python
def generate_version_file(changes_dir: str, version: str, write_to_disk: bool=True, *, description: str='', context: str='', bump_type: str | None=None, never_released: bool=False) -> str

Read the JSONL file for a version, generate markdown, optionally write .md alongside it.

Returns the generated markdown text. When write_to_disk is False, computes the markdown without touching the filesystem (used to preview content before pre-checks).

When description and context are provided, they are passed through to generate_version_section() so release metadata appears in the output, and so is never_released.

#_deduplicate_entries

python
def _deduplicate_entries(entries: list[ChangelogEntry]) -> list[ChangelogEntry]

Remove duplicate entries based on commit hash sets.

Two entries are duplicates when they have the same set of commits. The first occurrence wins (preserves order).

#_generate_consolidated_section

python
def _generate_consolidated_section(stable_version: str, all_entries: list[ChangelogEntry], prerelease_versions: list[str], prerelease_entries_by_version: dict[str, list[ChangelogEntry]], *, description: str='', context: str='', bump_type: str | None=None, never_released: bool=False, never_released_prereleases: frozenset[str] | set[str] | tuple=()) -> str

Generate a consolidated section for a stable version with pre-release predecessors.

Produces:

  1. The stable heading (## 0.43.0) with ALL entries deduplicated
  2. A note listing the pre-release cycle
  3. Individual pre-release headings (### 0.43.0-alpha.0) with their entries

never_released annotates the stable heading; never_released_prereleases names the pre-release versions whose own archives record that fate, and annotates each of their sub-headings. A pre-release is claimed and abandoned the same way a stable version is, and its sub-section here is the only place it appears in CHANGELOG.md -- unannotated, it reads as a release that happened.

#_read_changelog_format

python
def _read_changelog_format(project_path: str) -> str

Read changelog_format from project config, defaulting to 'grouped'.

#generate_changelog

python
def generate_changelog(project_path: str, *, write_to_disk: bool=True, version_override: str | None=None, description: str='', context: str='', changes_dir_override: str | None=None, changelog_output_path: str | None=None, releases_dir_override: str | None=None, bump_type: str | None=None) -> str

Generate the complete CHANGELOG.md from .rlsbl/changes/ JSONL files.

  1. Reads changelog_format from config (only "grouped" supported).
  2. Reads unreleased.jsonl (if non-empty) for an Unreleased section.
  3. Reads all versioned JSONL files sorted newest-first.
  4. Generates per-version .md files alongside the JSONL files (when write_to_disk).
  5. Writes CHANGELOG.md at project root (when write_to_disk).
  6. Returns the generated content.

When write_to_disk is False, computes and returns the markdown content without modifying the filesystem. This lets callers preview the changelog before pre-release checks run, so an aborted release leaves a clean working tree.

When version_override is provided, the section heading is "## {version_override}" instead of "## Unreleased", and the section is emitted even when unreleased.jsonl is empty (that is what an infra release is). Versioned sections (from existing JSONL files) are unaffected. With version_override None and no unreleased entries, no section is emitted.

description and context are applied to the unreleased section only (the current release being prepared). Previously released version sections read their description and context from archived release files at .rlsbl/releases/v{version}.toml.

changes_dir_override overrides the default .rlsbl/changes/ path. Used in explicit releasable mode where the changes dir lives under the releasable directory.

changelog_output_path overrides the default CHANGELOG.md output location. Used in explicit releasable mode to write CHANGELOG.md into the releasable directory instead of the project root.

releases_dir_override overrides the default .rlsbl/releases/ path used for the archived-release-file metadata backfill (description, context, bump type of versioned sections). Used in explicit releasable mode where v{version}.toml archives live under the releasable directory.

bump_type is passed through to generate_version_section() for the unreleased section. For versioned sections, the bump type is read from the archived release file.

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