Skip to content
rlsbl.release_file
On this page

Release file reader and validator for file-based releases, parsing .rlsbl/releases/unreleased.toml for bump type and release metadata.

#rlsbl.release_file

#rlsbl.release_file

Release file reader and validator for file-based releases, parsing .rlsbl/releases/unreleased.toml for bump type and release metadata.

Instead of passing bump type on the CLI, the user creates .rlsbl/releases/unreleased.toml describing the release. This module reads and validates that file's internal consistency.

#archived_release_path

python
def archived_release_path(releases_dir: str, version: str) -> str

Return the archive path for version -- <releases_dir>/v{version}.toml.

The one place the archive filename is spelled; the writer, the reader and the release record all go through it.

#archive_version

python
def archive_version(name: str) -> str | None

The version name archives, or None when name is not an archive name.

The one recognizer for "is this file in a releases directory an archive?". Exported because the backfill pass reads the same directories and must agree with the release record about which files in them are archives -- it used to carry a looser pattern of its own, so it discovered, sorted and repaired "archives" the release record then ignored entirely.

#is_release_version

python
def is_release_version(version: str) -> bool

True when version is a version rlsbl's release flow could have named.

The version-level half of :func:archive_version, for callers holding a version string rather than an archive filename -- the JSONL changelog file lister, whose files are named {version}.jsonl by the same flow. It is what makes :func:archive_sort_key askable without catching its refusal: check first, then order.

#archive_sort_key

python
def archive_sort_key(version: str)

Ascending order key for an archived version.

The one ordering: by numeric major.minor.patch, then every pre-release of that base before the base itself, then by channel (alpha, beta, rc) and finally by the counter compared as a number.

Raises ValueError for a string that is not an archivable version. A caller sorts versions it discovered through :func:archive_version or the changelog file lister, both of which speak this same vocabulary, so an unparsable version there is a bug to surface rather than an ordering to guess at.

#list_archived_versions

python
def list_archived_versions(releases_dir: str) -> list[str]

List the versions archived in releases_dir, HIGHEST FIRST.

A pure filename scan: no archive is opened, so enumerating a repository's whole release history costs one listdir. This is deliberate -- the release record reads archives lazily, walking this list from the top and opening only the ones it actually has to answer with.

A missing or unreadable directory yields an empty list: "no releases are recorded here" is a real state (a project before its first release), not an error. Errors belong to the callers that READ an entry, not to the scan.

#ReleaseConfig

ReleaseConfig
FieldTypeDefault
bumpstr
includelist[str]
excludelist[str]
targetsdict[str, dict]field(default_factory=dict)
descriptionstr''
contextstr''
preidstr''
blogboolFalse
candidate_sha`strNone`None
tree_hashes`dict[str, str]None`None
unrecoverable`boolNone`None
never_released`boolNone`None
shipped_as`strNone`None

#get_releases_dir

python
def get_releases_dir(project_dir: str='.', *, releasable_dir: str | None=None) -> str

Return the directory holding release files (unreleased.toml family).

releasable_dir is the releasable's state directory (.rlsbl-monorepo/releasables/<name>/); when given, release files live in its releases/ subdirectory — the same home as in-progress.json/scrub-result.json — instead of the project's .rlsbl/releases/. This is the single derivation for the releases dir; the release state module delegates here.

#get_release_file_path

python
def get_release_file_path(project_dir: str='.', *, releasable_dir: str | None=None) -> str

Return the path to the release file (unreleased.toml).

Standalone projects: <project_dir>/.rlsbl/releases/unreleased.toml. Releasable releases: pass releasable_dir — the file is at .rlsbl-monorepo/releasables/<name>/releases/unreleased.toml.

#_field_is_blank

python
def _field_is_blank(value) -> bool

True if a release-file field is absent or an empty/whitespace string.

The scaffolder writes bump = "" and description = ""; an operator "fills in" the file by setting a real value. Anything non-string and non-None (e.g. a number) counts as filled.

#is_pristine_release_file

python
def is_pristine_release_file(content: str) -> bool

True if content is a still-pristine single-project release scaffold.

Pristine means the operator has not filled in the release: either the file is empty/whitespace-only, or it parses as TOML with a blank bump and a blank description. Any filled bump/description -- or content that fails to parse as TOML -- is treated as operator data and reported non-pristine so release init refuses to clobber it.

#is_pristine_batch_release_file

python
def is_pristine_batch_release_file(content: str) -> bool

True if content is a still-pristine batch (monorepo) release scaffold.

Pristine means every [packages.<name>] / [releasables.<name>] section has a blank bump and blank description (the scaffold state). Empty/whitespace-only content is pristine. Any filled section, a non-table section entry, or unparseable content is non-pristine so monorepo release init refuses to clobber it.

#check_legacy_release_file

python
def check_legacy_release_file(project_dir: str, releasable_dir: str | None) -> None

Hard-error if a release file sits at the legacy member location.

Releasable release files used to live under the representative member's .rlsbl/releases/. A file found there in releasable mode must never be silently ignored (it would be skipped by the relocated read path and left behind as per-package residue).

Raises ReleaseFileError with a migration hint. No-op when releasable_dir is None (a standalone project).

#_validate_release_config

python
def _validate_release_config(data: dict, prefix: str='') -> ReleaseConfig

Validate release config fields from a parsed TOML dict.

Shared validation for both single-project and batch (per-package) release configs. The prefix is prepended to all error messages -- empty string for single-project, "[packages.] " for batch.

Raises ReleaseFileError for schema/validation failures. Returns a ReleaseConfig on success.

#_render_release_diags

python
def _render_release_diags(diags) -> str

Render strictspec diagnostics in rlsbl's release-file error style.

Each diagnostic contributes its rendered path, message, and stable code so the operator sees exactly which field failed and why.

#_strictspec_validate_release_document

python
def _strictspec_validate_release_document(raw: bytes) -> None

Validate the raw release-file document shape via the generated validator.

strictspec owns the DOCUMENT SHAPE: the format_version gate, field types, the bump/preid/mode enums, required fields, unknown-key rejection, include/exclude disjointness, the [targets.<name>]include reference, and the preid/bump couplings. Raises ReleaseFileError (rlsbl's native error style) when any diagnostic fires.

Consumer-native refinements that strictspec cannot express (whitespace-only description, the Flutter required-mode gate) stay in :func:_bind_release_config. There is no dual validation: any property strictspec owns is not re-checked natively on this path.

#_bind_release_config

python
def _bind_release_config(data: dict) -> ReleaseConfig

Build a ReleaseConfig from a shape-validated release document.

Assumes :func:_strictspec_validate_release_document already validated the document shape, so this applies only the consumer-native refinements and the field normalization (.strip()) before constructing the dataclass.

#_errors_name

python
def _errors_name(path: str)

Name path in every release-file error raised inside the block.

A diagnostic that says which FIELD failed but not which FILE is unactionable: a workspace holds one editable release file per releasable plus one archive per released version, and the reader always has the path in hand. The prefix is applied once, at the read boundary, so no raising site has to repeat it and no message carries it twice.

#read_release_file

python
def read_release_file(path: str) -> ReleaseConfig

Read and validate a single-project release TOML file.

The raw document shape is validated by the strictspec-generated validator (which requires a format_version gate) BEFORE tomlkit parsing; consumer-native refinements and dataclass construction happen after. Batch (monorepo) release files keep the native :func:_validate_release_config path -- their document shape is different and not yet strictspec-modeled.

Raises FileNotFoundError if the file doesn't exist. Raises ReleaseFileError, naming path, for schema/validation failures.

#write_archived_release_file

python
def write_archived_release_file(releases_dir: str, version: str, *, bump: str, include, exclude=(), description: str, context: str='', preid: str='', blog: bool=False, candidate_sha: str | None, tree_hashes: dict | None, unrecoverable: bool=False, never_released: bool=False, shipped_as: str | None=None, header_comments=None) -> str

Write v{version}.toml for a release that had no unreleased.toml.

A standalone release finalizes by RENAMING its release file to v{version}.toml, and every later changelog regeneration reads the version's description, context and bump type back out of that archive.

A batch release has no per-member release file -- its members' metadata lives in the workspace-level batch TOML, archived under a different name -- so nothing was ever written here, and regeneration silently stripped the description and context from the version's .md and its CHANGELOG.md section. Materializing the archive puts the metadata exactly where every reader already looks, rather than teaching each reader a second source.

The result is a complete, schema-valid release document (read_release_file accepts it, which matters because rlsbl release undo restores it as unreleased.toml), and read-only like every other archived release file.

candidate_sha and tree_hashes are the release commit: an archive that does not say which commit and tree the version shipped from is not a record of the release, so they are written with the file and the archive records its release commit from the instant it exists.

THE THREE FATES, and the only ways to write one:

  • recorded -- candidate_sha and tree_hashes, the ordinary case;
  • unrecoverable -- unrecoverable=True with both release-commit

arguments None: the backfill case where a SHIPPED version's commit could not be recovered from any source;

  • never released -- never_released=True with both release-commit

arguments None: the version NUMBER exists (a phantom tag's version, a version claimed and abandoned) but no release was ever published under it.

The three are exclusive and one is mandatory: an archive is never two of them and never none.

shipped_as names the historical tag spelling the version actually shipped under, when it differs from the scheme in effect today. Legal on a recorded and on an unrecoverable archive; refused on a never-released one, which shipped under nothing.

header_comments replaces the leading comment block (one list element per comment line) for a writer other than the release flow -- the backfill pass states there that the file was materialized after the fact.

Returns the path written.

#_check_release_commit

python
def _check_release_commit(candidate_sha: str, tree_hashes: dict) -> None

Refuse a malformed release commit before it reaches a file.

The schema rejects one on the way back IN; this rejects it on the way OUT, so a release never produces a read-only archive its own reader refuses.

#_release_commit_tree_table

python
def _release_commit_tree_table(tree_hashes: dict)

Render the tree-hash map as a TOML table with quoted path keys.

#_fate_markers_in

python
def _fate_markers_in(doc) -> list[str]

Which fate markers a parsed release document already carries.

PRESENCE, not truth, exactly as the schema's mutual-exclusion rule judges it: rlsbl writes a marker only when it is true, so unrecoverable = false beside a release commit is a hand-authored document either way, and reading it as "no marker" would let a writer produce the very document the schema refuses.

#write_release_commit

python
def write_release_commit(path: str, *, candidate_sha: str, tree_hashes: dict) -> None

Author the release commit into an already-written release file.

Used on the finalization path, where the archive is the operator's own unreleased.toml renamed to v{version}.toml: the release commit is added after the rename and BEFORE the file is chmodded read-only, so the archive is never observable as a writable file that already records its release commit, and never as a locked file that does not.

Refuses a document that already carries a fate marker. An archive has ONE fate, and a release commit written beside unrecoverable or never_released is a document the schema rejects and every later read raises on -- so the conflict is refused here, where the file is still intact, rather than discovered by whoever reads the archive next.

The document is otherwise preserved as written -- tomlkit round-trips the operator's comments, ordering and formatting -- so the archive still reads as the file the operator authored, plus the two fields the flow owns. Only the release-commit fields are cleared before they are re-authored: this is also the writer the release-commit remap goes through, and shipped_as (which is not a fate, and which no rewrite invalidates) must survive it.

#writable_release_file

python
def writable_release_file(path: str)

Temporarily clear the read-only bit on an archived release file.

Archives are chmodded 0o444 the instant they exist, so any later edit -- the backfill pass that records the release commit of a version shipped before release commits were recorded, or stamps the strictspec gate onto one written before the gate existed -- has to unlock, write, and relock. Restores the original permissions on the way out even when the body raises, and is a no-op on an already-writable file (the editable unreleased.toml takes the same path without being locked behind it).

#write_unrecoverable_marker

python
def write_unrecoverable_marker(path: str) -> None

Record on an already-written archive that its commit is unrecoverable.

The counterpart to :func:write_release_commit for the backfill pass: a released version with no tag under any recognized scheme and no version-bump commit in history cannot be recorded, and the archive says so permanently rather than being passed over in silence.

Refuses an archive whose fate is already settled the other way: one carrying a release commit (a recorded version is by definition not unrecoverable), and one carrying never_released (a version no release ever used did not ship from a commit that could be lost). Either combination would produce a two-fate document the schema refuses.

#strip_release_commit

python
def strip_release_commit(path: str) -> bool

Remove the release commit fields from a release file. True if anything changed.

The inverse of :func:write_release_commit, for release undo: the archive it restores as unreleased.toml must come back as an EDITABLE release file, and an editable file carrying a release commit is refused at the next release validation (the release commit is the flow's to author, never the operator's). Every other flow-owned field goes with them: the unrecoverable and never_released markers and shipped_as are each a statement about a version whose fate is already settled, meaningless on a file describing the next one.

#unfinalize_release_file

python
def unfinalize_release_file(releases_dir: str, version: str) -> list[str]

Reverse a release-file finalization: restore vX.Y.Z.toml to unreleased.toml.

Inverse of the finalization step in release run, which renames unreleased.toml to vX.Y.Z.toml and chmods it read-only (0o444).

  1. No-op (returns []) if the versioned file doesn't exist.
  2. If unreleased.toml exists with content that differs from the versioned

file, warns on stderr and skips -- nothing is deleted.

  1. Otherwise removes any stale unreleased.toml, makes the versioned file

writable, renames it back to unreleased.toml, and STRIPS the release commit the release wrote into it. The restored file is an editable pre-release file again, and one carrying a release commit is refused at the next release validation -- so leaving the release commit on would block the re-release of the very version the undo just freed.

Returns the list of changed file paths (for committing).

#BatchReleaseConfig

Configuration from a batch release TOML file (monorepo).

packages maps releasable names to their release configs, one entry per [releasables.<name>] section of the batch release file.

#get_batch_release_file_path

python
def get_batch_release_file_path(workspace_root: str='.') -> str

Return the path to .rlsbl-monorepo/releases/unreleased.toml.

#read_batch_release_file

python
def read_batch_release_file(path: str) -> BatchReleaseConfig

Read and validate a batch release TOML file.

Sections are [releasables.<name>], one per releasable being released.

Each section has the same fields as a single ReleaseConfig (bump, include, exclude, optional targets, description, context).

Raises FileNotFoundError if the file doesn't exist. Raises ReleaseFileError, naming path, for schema/validation failures.

#_bind_batch_release_config

python
def _bind_batch_release_config(data) -> BatchReleaseConfig

Validate a parsed batch release document and build its config.

#RetryConfig

Configuration from a retry TOML file (.rlsbl/releases/retry.toml).

#discard_invalid_retry_file

python
def discard_invalid_retry_file(retry_path: str) -> None

Delete a retry file that failed to parse.

An unparseable retry.toml is not recoverable state: leaving it on disk dirties the working tree and blocks the next rlsbl release run. Lives beside the retry-file readers (and out of the command registration module, which must stay free of effect calls for the effects-bypass lint).

#get_retry_file_path

python
def get_retry_file_path(project_dir: str='.', *, releasable_dir: str | None=None) -> str

Return the path to retry.toml (same releases-dir home as unreleased.toml).

Releasable releases (explicit monorepo mode): pass releasable_dir so the file lives under the releasable's own releases dir instead of the member's .rlsbl/releases/.

#read_retry_file

python
def read_retry_file(path: str) -> RetryConfig

Read and validate a retry TOML file.

Raises FileNotFoundError if the file doesn't exist. Raises ReleaseFileError for schema/validation failures.

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