Skip to content
rlsbl.release_record
On this page

The archived release files, read as the authoritative record of what this project released -- committed, so they survive what a tag namespace does not, with every read error naming the backfill command that repairs it.

#rlsbl.release_record

#rlsbl.release_record

The release record: the archived release files, read as the authoritative record of what this project has released.

Every completed release archives its release file to <releases_dir>/v{X.Y.Z}.toml and writes the RELEASE COMMIT into it -- the candidate_sha CI verified and the tree_hashes each released path shipped -- or, for a version whose commit could not be recovered at all, the unrecoverable marker. Those archives, not the git tags, are what rlsbl now asks when it needs to know what was released.

The three fates ---------------

Every archive is in exactly ONE of three states, and every read dispatches on which:

  • recorded -- candidate_sha plus tree_hashes. It shipped, and

rlsbl knows the commit and the trees it shipped from.

  • unrecoverable -- unrecoverable = true. It shipped, and the commit is

unrecoverable from any source. The version still HAS consumers and real refs; only rlsbl's knowledge of where it came from is gone.

  • never released -- never_released = true. The version NUMBER exists

in the record -- a phantom tag's version, a version claimed and abandoned -- but no release was ever published under it.

The third is not a degraded second: a never-released version is not a release, so every read that asks what this project RELEASED skips it. It is not the latest release, it does not bound the unreleased range, release undo does not select it, its refs are never demanded as missing, and release reconcile never plans a deletion of a tag carrying its name. Its changelog section is still rendered, annotated as never released, because a phantom version can have finalized changelog files and hiding them would lose the record.

Why not tags ------------

git describe --tags --abbrev=0 answers "the newest tag reachable from HEAD", which is a different question from "the newest release contained in this checkout", and it answers it from a namespace anyone can write. A tag that was deleted, moved, or never created makes a released version vanish from the answer; a tag created by hand makes an unreleased one appear. The archive is written by the release flow, is rewritten by rlsbl only through its own documented unlock paths, and is committed -- so it survives exactly what tags do not. (Its local file mode is hygiene, not the guarantee: git records no read-only bit, so a fresh clone's archives are writable.)

The two questions, and their two different answers --------------------------------------------------

The release record deliberately answers two questions differently, because they are different questions:

  • What bounds the unreleased range? The highest archived version whose

candidate_sha is an ANCESTOR of this checkout -- :func:nearest_release_commit. A release that exists but is not in this history cannot bound a range computed from this history.

  • What is the latest release? The absolute highest archived version --

:func:latest_release_fact -- annotated when the checkout does not contain it. A fact about the project is not silently rewritten into a fact about the checkout; it is stated, with the discrepancy visible.

Reading is lazy and highest-first. :func:rlsbl.release_file.list_archived_versions costs one listdir and opens nothing; this module opens archives one at a time walking down from the top and stops at the first answer. In the ordinary case -- a checkout that contains the latest release -- that is exactly one file. (Measured on rlsbl's own 224 archives: reading all of them through read_release_file takes ~340ms, so eagerly loading the release record on every command was never an option.)

The four read errors --------------------

They fire where the release record is READ FOR USE, never while scanning:

  • Disagreement -- the version's tag exists locally and points at a commit

other than the release commit. Something moved one of them; the release record will not guess which.

  • Indeterminable -- ancestry cannot be decided (a missing object, a

truncated history). Not the same as "no", and not treated as one.

  • No fate at all -- an archive carrying none of the three: no release

commit, no unrecoverable marker, no never_released marker. That is one written before release commits were recorded and never backfilled. The error prints the complete single-version recovery.

  • Empty release record, tagged repository -- no archive at all, yet the tag

namespace carries tags that parse under this project's version-tag scheme. That is a project that HAS released and was never backfilled, and reading its empty release record would report the entire history as unreleased. A project with no version tags is a project before its first release, and its empty release record is the correct answer -- so the two states are told apart by the tags, and only the first one raises.

Who does NOT go through these -----------------------------

rlsbl release backfill -- the remedy the fourth error names -- reads archives and tags directly through :mod:rlsbl.release_backfill and never calls the guarded reads, so it can run on exactly the repository the guard refuses. So does rlsbl release reconcile, whose observe layer is the tag namespace itself. Neither needs a bypass, and neither is given one: the structure is what keeps them clear.

#ReleaseRecordEntry

One archived release, read for use, in exactly one of three fates.

  • recorded -- candidate_sha names the commit it shipped from;
  • unrecoverable -- it shipped, from a commit nothing can name;
  • never_released -- the version NUMBER exists, no release does.

Exactly one holds. An archive carrying none of them raises rather than being constructed; one carrying two is refused by the schema.

shipped_as is orthogonal to the fate: the historical tag spelling this version ACTUALLY shipped under, when it differs from the scheme in effect today. None when the archive records none, and always None on a never-released entry -- a version nothing shipped shipped under no name.

#recorded

python
def recorded(self) -> bool

#LatestReleaseFact

The latest release this project has, and whether the checkout has it.

version names the highest archived version that was actually RELEASED: an archive recorded never_released is a version number no release ever used, so it cannot be the latest release. The ones skipped that way are named in never_released_above, highest first, so a display can say what it passed over rather than silently reporting an older version.

in_checkout is None when the question could not be asked at all: there is no release yet, or the latest one is unrecoverable and so has no commit to look for.

#state

python
def state(self) -> str | None

The fate of the archive version names: the machine-readable form.

"recorded", "unrecoverable", or None when the project has no release at all. A never-released archive never appears here -- it is not a release, and it is reported through never_released_above.

#label

python
def label(self) -> str

The display string, annotated when the checkout predates the release.

"0.117.2" when the checkout contains it, "0.117.2 (not in this checkout's history)" when it does not, "(none)" when the project has released nothing. A version archived above it but never released is named too, because otherwise the display silently reports an older version than the highest archive and looks stale.

#tag_for_version

python
def tag_for_version(tag_glob: str | None, version: str) -> str

Translate a version into its tag name under tag_glob's scheme.

A tag glob is built by replacing {version} with * (see :mod:rlsbl.tag_glob), so putting the version back where the * is reverses the construction for every scheme rlsbl uses: v* -> v1.2.3, mylib@v* -> [email protected], pkg/dir/v* -> pkg/dir/v1.2.3. None means the standalone scheme.

This is tag TRANSLATION, not version selection: it names the tag a version would carry, and never decides which version is current.

#_same_commit

python
def _same_commit(a: str, b: str) -> bool

Do two git object names denote the same commit, allowing abbreviation?

#_resolve_ref

python
def _resolve_ref(ref: str, cwd: str | None, *, timeout: int=10) -> str | None

Resolve ref to a full commit SHA, or None when it does not resolve.

#_missing_release_commit_error

python
def _missing_release_commit_error(version: str, path: str, tag_glob: str | None, cwd: str | None) -> ReleaseRecordError

Build the MISSING-RELEASE-COMMIT error, naming the command that fixes it.

The recovery used to be printed here as a four-step manual procedure -- unlock the 0444 archive, hand-write candidate_sha and a [tree_hashes] table, relock, re-run. rlsbl release backfill does exactly that, correctly, for every archive in the repository at once, so the message names the command instead of asking an operator to reproduce it by hand.

The tag observation stays: it is EVIDENCE about this particular version -- what the backfill will find, or why it will find nothing -- and it is what tells an operator whether to expect a recorded fate or an unrecoverable one.

#_scheme_tags

python
def _scheme_tags(tag_glob: str | None, cwd: str | None, *, timeout: int=10) -> list[str]

Local tags matching this project's version-tag scheme, highest first.

Two filters, because neither alone is the scheme: the glob selects the project's own namespace (v*, lib@v*, pkg/dir/v*), and :func:~rlsbl.tag_glob.parse_version_tag keeps only what really parses as a version under one of the three schemes -- so vNext and lib@vlatest match the glob and are still not version tags.

An unanswerable listing (no git, a timeout, a preview past its first recorded mutation) yields the empty list, the same reading :func:_resolve_ref gives an unanswerable resolve: the caller's guard then declines to fire rather than accusing a repository on evidence it could not read.

#_unbackfilled_release_record_error

python
def _unbackfilled_release_record_error(releases_dir: str, tag_glob: str | None, tags: list[str]) -> ReleaseRecordError

Build the EMPTY-RELEASE RECORD error for a repository that has clearly released.

The remedy is the whole-repository backfill rather than the single-version recovery the missing-release-commit error prints: there is no archive to edit here, and the versions to materialize are however many the repository shipped.

#_require_backfilled_release_record

python
def _require_backfilled_release_record(releases_dir: str, versions: list[str], tag_glob: str | None, cwd: str | None) -> None

Refuse an empty release record in a repository whose tags say it has released.

A no-op the instant the release record holds anything: a repository mid-backfill, or one whose archives predate release-commit recording, is the missing-release-commit error's business, not this one's.

#version_is_archived

python
def version_is_archived(releases_dir: str, version: str) -> bool

Does the release record record version as released?

A scan, not a read: the archive's mere existence is the record that the release completed, and answering it opens no file. The read errors belong to the callers that go on to USE the entry.

#read_entry

python
def read_entry(releases_dir: str, version: str, *, tag_glob: str | None=None, cwd: str | None=None) -> ReleaseRecordEntry

Read one archived release FOR USE, with its read errors live.

Raises :class:~rlsbl.errors.ReleaseRecordError when the archive is in NONE of the three fates -- no release commit, no unrecoverable marker and no never_released marker -- and when the version's tag exists locally but points somewhere other than the release commit. Raises FileNotFoundError when there is no archive for version at all -- that is a caller error, since callers reach here from the enumeration.

#nearest_release_commit

python
def nearest_release_commit(releases_dir: str, *, tag_glob: str | None=None, cwd: str | None=None, head: str='HEAD') -> ReleaseRecordEntry | None

The highest archived release whose commit this checkout CONTAINS.

This is what bounds every unreleased-range and coverage computation. The walk is highest-first and stops at the first version whose candidate_sha is an ancestor of head, so the ordinary case opens one archive. A version in either commitless fate is skipped: an unrecoverable one has no commit to bound with, and a never_released one was never a release at all, so neither can bound a range and their neighbours do it instead.

Returns None when the release record records nothing this checkout contains: a project before its first release, or a checkout that predates every release it knows about. "Before its first release" is required to look like it -- an empty release record in a repository carrying version tags raises instead of widening the range to the whole history.

Raises :class:~rlsbl.errors.ReleaseRecordError for any of the read errors, including an ancestry git cannot decide.

#unreleased_range

python
def unreleased_range(releases_dir: str, *, tag_glob: str | None=None, cwd: str | None=None) -> str

The git log range spec for this checkout's unreleased commits.

<candidate_sha>..HEAD when the release record records a release this checkout contains, and HEAD when it does not -- the same shape the tag-based predecessor produced, computed from the release record instead of from git describe.

#release_at_commit

python
def release_at_commit(releases_dir: str, sha: str, *, tag_glob: str | None=None, cwd: str | None=None) -> ReleaseRecordEntry | None

The release sha IS, or None when it shipped no version.

Asked by displays that want to label a commit with its release (rlsbl watch, which previously used git describe --exact-match and so labelled from the tag namespace).

Costs ONE archive read, not a scan: the highest release contained in sha's own history is sha itself exactly when sha is a released candidate, so :func:nearest_release_commit answered at sha either names it or names an earlier release -- and an earlier one means sha shipped nothing.

#latest_release_fact

python
def latest_release_fact(releases_dir: str, *, tag_glob: str | None=None, cwd: str | None=None, head: str='HEAD') -> LatestReleaseFact

The project's latest release, and whether this checkout contains it.

The absolute highest archived version that was actually RELEASED -- not the highest one in this history, and not a higher archive recorded never_released, which is a version number no release ever used. Those are skipped and named in the fact's never_released_above, so a display can say what it passed over. When the checkout does not contain the release's commit, the fact still names that version and records the discrepancy, so a display can annotate it rather than quietly reporting an older release as the latest.

The "no release yet" fact is reported only for a repository that looks like one: an empty release record under a tagged version namespace raises rather than reporting (none) for a project that has plainly released.

#require_checkout_contains_latest

python
def require_checkout_contains_latest(releases_dir: str, *, tag_glob: str | None=None, cwd: str | None=None, head: str='HEAD') -> None

Refuse to prepare a release on a history missing the latest release.

Releasing from a checkout that does not contain the latest release's candidate would ship a version built on top of a history the previous release is not in: the new release would silently revert it, and its changelog range would cover commits that already shipped.

A no-op when the release record records nothing, and when the latest release is unrecoverable -- there is no commit to require. A version recorded never_released is not the latest release at all, so it never reaches here: requiring a checkout to contain a release that never happened would refuse every release forever.

#releases_dir_for_changes_dir

python
def releases_dir_for_changes_dir(changes_dir: str) -> str

The releases directory that pairs with a changelog changes directory.

.rlsbl/changes/ -> .rlsbl/releases/, and a releasable's <releasable>/changes/ -> <releasable>/releases/. Both layouts put the two directories side by side, and every caller that already resolved a changes dir gets its release record from here rather than re-deriving the path.

Search