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
def archived_release_path(releases_dir: str, version: str) -> strReturn 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
def archive_version(name: str) -> str | NoneThe 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
def is_release_version(version: str) -> boolTrue 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
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
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
| Field | Type | Default | |
|---|---|---|---|
bump | str | ||
include | list[str] | ||
exclude | list[str] | ||
targets | dict[str, dict] | field(default_factory=dict) | |
description | str | '' | |
context | str | '' | |
preid | str | '' | |
blog | bool | False | |
candidate_sha | `str | None` | None |
tree_hashes | `dict[str, str] | None` | None |
unrecoverable | `bool | None` | None |
never_released | `bool | None` | None |
shipped_as | `str | None` | None |
#get_releases_dir
def get_releases_dir(project_dir: str='.', *, releasable_dir: str | None=None) -> strReturn 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
def get_release_file_path(project_dir: str='.', *, releasable_dir: str | None=None) -> strReturn 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
def _field_is_blank(value) -> boolTrue 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
def is_pristine_release_file(content: str) -> boolTrue 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
def is_pristine_batch_release_file(content: str) -> boolTrue 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
def check_legacy_release_file(project_dir: str, releasable_dir: str | None) -> NoneHard-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
def _validate_release_config(data: dict, prefix: str='') -> ReleaseConfigValidate 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.
Raises ReleaseFileError for schema/validation failures. Returns a ReleaseConfig on success.
#_render_release_diags
def _render_release_diags(diags) -> strRender 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
def _strictspec_validate_release_document(raw: bytes) -> NoneValidate 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
def _bind_release_config(data: dict) -> ReleaseConfigBuild 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
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
def read_release_file(path: str) -> ReleaseConfigRead 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
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) -> strWrite 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_shaandtree_hashes, the ordinary case; - unrecoverable --
unrecoverable=Truewith 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=Truewith 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
def _check_release_commit(candidate_sha: str, tree_hashes: dict) -> NoneRefuse 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
def _release_commit_tree_table(tree_hashes: dict)Render the tree-hash map as a TOML table with quoted path keys.
#_fate_markers_in
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
def write_release_commit(path: str, *, candidate_sha: str, tree_hashes: dict) -> NoneAuthor 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
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
def write_unrecoverable_marker(path: str) -> NoneRecord 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
def strip_release_commit(path: str) -> boolRemove 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
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).
- No-op (returns []) if the versioned file doesn't exist.
- If unreleased.toml exists with content that differs from the versioned
file, warns on stderr and skips -- nothing is deleted.
- 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
def get_batch_release_file_path(workspace_root: str='.') -> strReturn the path to .rlsbl-monorepo/releases/unreleased.toml.
#read_batch_release_file
def read_batch_release_file(path: str) -> BatchReleaseConfigRead 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
def _bind_batch_release_config(data) -> BatchReleaseConfigValidate a parsed batch release document and build its config.
#RetryConfig
Configuration from a retry TOML file (.rlsbl/releases/retry.toml).
#discard_invalid_retry_file
def discard_invalid_retry_file(retry_path: str) -> NoneDelete 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
def get_retry_file_path(project_dir: str='.', *, releasable_dir: str | None=None) -> strReturn 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
def read_retry_file(path: str) -> RetryConfigRead and validate a retry TOML file.
Raises FileNotFoundError if the file doesn't exist. Raises ReleaseFileError for schema/validation failures.