Skip to content
rlsbl.release_backfill
On this page

The backfill engine: it reads a repository's tags, archives and changelog files, and decides per version whether to repair, materialize, adopt or leave alone.

#rlsbl.release_backfill

#rlsbl.release_backfill

The release-archive backfill engine: bring a repository's archives into the fate model.

A release archive (.rlsbl/releases/v{X.Y.Z}.toml, and the releasable-level equivalent) is the authoritative record of what a version shipped: the bump type, the description and context every later changelog regeneration reads back, and the RELEASE COMMIT -- the candidate_sha the version shipped from and the tree_hashes each released path carried. Repositories that predate any of that carry archives with no release commit, archives whose required fields are missing or were never answered (an archived scaffold still carrying bump = "" and description = ""), archives written before the strictspec format_version gate existed, released versions with no archive at all, and version tags no archive accounts for.

This module is what rlsbl release backfill runs, and what scripts/migrate_workspace_model.py calls in-process after it rewrites a workspace. It observes one repository and decides, per subject, exactly one verdict:

unexplained-tag A tag in the namespace that nothing accounts for. Listed FIRST, and a single one refuses the whole apply -- see :func:unexplained_error. adopt A tag that is one of the refs some version of this repository would own -- asked of expected_refs, never rendered here -- for a version no archive and no changelog file records. It gets an archive recording the release the tag is evidence of. materialize A version the changelog records as released, with no archive at all. repair An archive that exists and is incomplete: required fields missing or unanswered, the format_version gate missing, its fate missing, or all three. An empty required string is unanswered, not stated -- see :func:_answered. settled Nothing is proposed. An archive that already records one of the three fates and ANSWERS every required field is done -- which is what makes the pass idempotent.

The fate model, and the one fate this pass will not derive ----------------------------------------------------------

The three fates are recorded (a release commit), unrecoverable (it shipped, from a commit nothing can name) and never_released (the version NUMBER exists, no release does). This pass derives the first two, and NEVER the third: a never-released version has no tag and no version-bump commit by construction, which is indistinguishable from a released version whose commit is gone. Only an operator knows which it is, so never_released is DECLARED -- by writing the archive with never_released = true before running the pass. An archive that already declares it is settled and is never written to again.

The recovery chain ------------------

A reconstructed description comes from the first of these that yields one, and the archive records which:

  1. the version's GitHub Release body -- unless it carries no substantive

content, which auto-generated compare-link boilerplate is not;

  1. the version's CHANGELOG.md section;
  2. the commit subjects in the version's tag range;
  3. a placeholder that names the recovery obligation.

An operator who has reviewed the descriptions supplies them instead, through --overrides (see :func:read_overrides), which is applied before any of the chain runs.

#BackfillError

The repository cannot be backfilled as it stands.

Raised for a condition an operator must resolve before the pass can write anything: a workspace file that exists but does not load, an unexplained tag, an overrides file naming a version this repository does not have, a stash sitting in the repository. Nothing is written when one is raised.

#_answered

python
def _answered(value)

Whether a required field's value says anything.

An empty string is not an answer -- it is a scaffolded release file that was archived before anyone filled it in, which is how two workspaces ended up with recorded archives carrying bump = "" and description = "". The strict reader refuses those, so every ref set derived from them is underivable, and a completion pass that asked only whether the KEY existed walked past the very files it exists to repair.

Strings only, deliberately. An empty LIST is a real statement (exclude = [] says nothing is excluded), and calling it unanswered would re-plan it on every run, so the pass would never settle.

#_git

python
def _git(repo, args, *, timeout=GIT_TIMEOUT)

Run a read-only git command in repo; returns stdout stripped, or "".

#rev_parse

python
def rev_parse(repo, spec)

Resolve spec to an object hash, or None when it does not resolve.

#all_tags

python
def all_tags(repo)

#bump_commit_messages

python
def bump_commit_messages(scope, version)

Every whole commit message a release of scope writes for version.

The release flow commits the version bump under one of two shapes, and which one is not a guess:

  • a RELEASABLE's bump commit names the releasable --

{name}: release v{version} (see rlsbl.commands.release's commit-message step);

  • everything else commits the release's own TAG STRING, which for a

standalone repository is v{version} and for a member is that member's spelling -- so the scope's own ref set supplies them.

The bare version is accepted too, because older flows wrote it. Every message is matched WHOLE, so core: release v1.2.3 can never be found by a sibling releasable looking for its own bump commit.

#find_bump_commits

python
def find_bump_commits(repo, messages)

Commits whose whole message is one of messages.

That commit IS the release candidate even when the tag that should point at it is missing.

#commit_subjects

python
def commit_subjects(repo, sha, predecessor_sha)

The subjects of the commits sha added over predecessor_sha.

Without a predecessor the range is open on the left, so the read is bounded by --max-count instead: an initial release would otherwise quote the entire history.

#tree_hashes_at

python
def tree_hashes_at(repo, sha, released_paths)

Tree hashes for the released paths at sha. Returns (trees, notes).

A declared path that does not exist at that commit is dropped with a note -- a workspace's member directories did not exist during its standalone era, and recording a tree for a path that was not there would be a fabrication. When nothing resolves, the root tree under "." is the honest record of what that commit released.

#Scope

One independently-versioned release-state location in a repository.

released_paths are the repo-relative paths whose trees the release commit records: ["."] for a standalone repository, one entry per member directory for a workspace releasable.

target and ref_ctx are what this scope asks the ref question with. They are not a second opinion about tag naming: they are the very inputs the release flow builds for a release of this scope, so :meth:tag_candidates and the refs a release creates are one derivation.

#tag_candidates

python
def tag_candidates(self, version)

Every tag spelling version is addressable under, in ref order.

expected_refs is the single authority for a version's ref set -- primary tag, the companion tags its members' ecosystems require, and the aliases this repository's own records attribute to it -- and this pass asks it rather than rendering a tag format of its own. A private rendering saw only the primary spelling, so every Go companion tag a monorepo release creates read as unexplained, and one unexplained tag refuses the whole apply.

Cached per version: the answer resolves each member's config, and the pass asks it several times per version.

#_expected

python
def _expected(self, version)

expected_refs for version, minus any part that is underivable.

COMPANION tags are the one part that can be unanswerable: they are collected from the members' own effective configs, and a repository this pass exists to repair may have a member (commonly a root member, which may not carry a .rlsbl/ of its own at all) whose config names no targets and no publish mode. The primary tag and the recorded aliases never depend on that.

So the question is asked again with NO member set -- the same authority, told there are no members to consult -- and the reason is recorded on :attr:undecidable, which the plan prints and the unexplained-tag refusal repeats. Nothing is guessed and nothing is silent: a companion tag whose derivation failed is then reported as an unexplained tag, with the three resolutions and the reason its scope could not account for it.

#tag_spellings

python
def tag_spellings(self)

The scope's ref set rendered as format strings, for the plan header.

Asked at the literal {version} placeholder rather than at a real version: every spelling any target produces is a plain concatenation around the version, so rendering at the placeholder yields exactly the format strings -- from the same authority as every decision below, instead of a display-only rendering that could disagree with them. No alias joins in, because {version} parses as no version.

#scope_target

python
def scope_target(dir_path, *, releasable_config_dir=None)

The target that answers ref questions for the project at dir_path.

The first detected target, exactly as rlsbl release reconcile resolves it. A directory with no detectable target still gets an answer rather than a refusal, and the answer is not a guess: ref naming has a defined default (v{version}, :class:~rlsbl.targets.base.BaseTarget's own tag_format) that holds independently of any ecosystem.

A config that cannot be read yields the same default. This is an observation of a repository whose state predates the current model -- the pass exists BECAUSE the project's records are incomplete -- so an unreadable config must not stop it from reading the rest of the namespace.

#discover_scopes

python
def discover_scopes(repo)

Enumerate the repository's release-state scopes.

Two repository shapes, and no third: a repository with no workspace file is a STANDALONE one with a single scope at the root; a WORKSPACE yields one scope per releasable, plus one per member that stands outside every releasable and still keeps its own .rlsbl/releases/ or .rlsbl/changes/. A workspace file that exists but does not load is a hard error carrying the loader's own message.

#archived_versions

python
def archived_versions(scope)

Map version -> archive path for every v{X}.toml in the scope.

#changelog_versions

python
def changelog_versions(scope)

Map version -> JSONL path for every finalized changelog file.

#ArchiveState

What an existing archive already carries, without validating its shape.

tomllib rather than the strictspec reader on purpose: the archives this pass repairs are exactly the ones the reader would reject (no format_version gate, a missing required field), so asking the reader first would refuse to look at the file the pass exists to fix.

present holds the required fields that are ANSWERED, which is not the same as the keys that exist: see :func:_answered.

#missing

python
def missing(self)

#settled_fate

python
def settled_fate(self)

#read_archive_state

python
def read_archive_state(path)

#body_is_substantive

python
def body_is_substantive(body)

Does body carry content, as opposed to auto-generated boilerplate?

Bullets, prose and blockquote openings are content. A heading with nothing under it, an HTML comment and the **Full Changelog**: ... compare link GitHub generates are not -- a body made of only those is ABSENT for the recovery chain's purposes and falls through to the next source.

#description_from_body

python
def description_from_body(body)

A one-line description from a Release body, or None.

Prefers the prose paragraph the notes open with. A body that opens straight into its bullets (the common shape for generated-then-edited notes) yields its first bullet instead, and one that opens with a blockquote yields that -- both are content by the same rule :func:body_is_substantive applies.

A body whose only content is a MARKDOWN TABLE -- an asset matrix, a platform list -- deliberately yields None even though :func:body_is_substantive calls it content. The two answer different questions: the body carries something (so it is not the boilerplate a body nobody authored consists of), but a table cell is data rather than a sentence about the release, and quoting one as the version's description would put a filename where a summary belongs. The chain then continues to the CHANGELOG.md section, and the archive records THAT as its source, so what the reader is told is what actually answered.

#lead_paragraph

python
def lead_paragraph(markdown)

The prose paragraph a CHANGELOG.md version section opens with, or None.

A version section is a description paragraph (when it has one) followed by ### Features / bullet groups. Everything from the first heading, bullet or details block onward is the generated part, so only the leading prose is a recovered description.

#_subject_description

python
def _subject_description(subjects)

A description built from a version's commit subjects, or None.

#Recovery

Where a reconstructed description may come from.

gh is the runner :func:rlsbl.release_publication.read_release_body drives, injected so the Release source can be exercised (and refused) without a network. use_gh False skips the source entirely, for an offline pass.

#release_body

python
def release_body(self, tag)

The Release body for tag, or None when it cannot be read.

Fails soft in every direction -- no gh, not authenticated, no Release for the tag, a network failure -- because it is the FIRST of three sources and an unavailable source must fall through to the next rather than abort the pass.

#recover_description

python
def recover_description(repo, scope, version, tag, *, recovery, sha, predecessor_sha)

Recover a version's description. Returns (description, source).

#derive_bump

python
def derive_bump(version, predecessor)

Derive the bump type from version arithmetic against the predecessor.

The HIGHEST-ORDER component that differs names the bump, and the SIZE of the difference is never read: 0.1.0 -> 0.4.0 is a minor exactly as 0.1.0 -> 0.2.0 is, because a gap in the archived history says nothing about how many releases crossed it. A version with no predecessor is measured against 0.0.0, so a first release of 0.1.0 derives minor. infra is a patch increment and therefore indistinguishable here -- a derived patch is the honest answer, not a guess at intent.

#detect_include

python
def detect_include(repo, scope)

Target names for a materialized archive, detected at backfill time.

A soft source: the historical target set is not recoverable, so a project whose config cannot answer contributes an empty list rather than stopping the pass. What it absorbs is exactly that -- a config that does not resolve and a directory that cannot be read. Every other failure propagates, ObserveWriteError above all: that one is the effects layer refusing a write attempted during an observation, a defect report about rlsbl itself, and swallowing it here would hide the very thing the no-writes screen exists to surface.

#Override

Override
FieldTypeDefault
descriptionstr
contextstr''

#read_overrides

python
def read_overrides(path)

Read an --overrides file: [versions."X.Y.Z"] tables.

Shape, and nothing else accepted::

[versions."0.1.0"] description = "What this release was." context = "Optional, multiline, why."

Every refusal is a hard error naming the offending key: an overrides file is reviewed text an operator wrote deliberately, so a typo that silently applied to nothing would be worse than one that stops the pass.

#VersionPlan

What this pass will do to one version's archive.

#changed

python
def changed(self)

#key

python
def key(self)

#UnexplainedTag

A tag in the namespace that nothing in this repository accounts for.

#key

python
def key(self)

#Plan

#changed_versions

python
def changed_versions(self)

#_scope_for_tag

python
def _scope_for_tag(scopes, tag)

The scope one of whose refs IS tag, with the version. Or None.

Answered by CONSTRUCTION -- deriving each scope's whole ref set at the version the tag parses as, and comparing -- rather than by pattern-matching the tag, so a scope can never claim a spelling it would not itself write.

#build_plan

python
def build_plan(repo, *, use_gh=True, gh=None, overrides=None)

Inspect the repository and decide every action, writing nothing.

#_probe_order

python
def _probe_order(scope, version, state)

Tag spellings to try for version, historical spelling first.

An archive recording shipped_as names the spelling the version ACTUALLY shipped under, from before a rename or a repository boundary moved it. It is tried first because it is a FACT about this version, where the scheme's spelling is only what today's scheme would produce.

#_resolutions

python
def _resolutions(entry, plan)

The three cheap resolutions, spelled out so each can be performed.

#build_preview

python
def build_preview(plan)

The plan as a :class:~rlsbl.preview_apply.Preview.

Unexplained tags come FIRST, because they are what refuses the apply: an operator reading the plan top-down sees the blocker before the work.

#undecidable_reasons

python
def undecidable_reasons(plan)

Every part of a scope's ref set that could not be derived, in order.

Empty for a repository whose members all resolve, which is the normal state. When it is not empty, a tag reported unexplained may be one of the spellings the underivable part would have accounted for, so the refusal repeats these reasons.

#unexplained_error

python
def unexplained_error(plan)

The message an unexplained tag refuses the whole apply with.

#stash_error

python
def stash_error(plan)

The message a present stash refuses the apply with.

#apply_version

python
def apply_version(vp)

Write one version's archive. Returns the path written.

#apply_item

python
def apply_item(item)

Perform one preview item. Only version items have anything to do.

#apply_plan

python
def apply_plan(plan)

Write every planned archive. Returns the repo-relative paths written.

#commit_message

python
def commit_message(plan, written)

One commit per run, naming the repo-relative scope it touched.

#render

python
def render(plan, out=None)

Print the plan, unexplained tags first, then the per-version verdicts.

#run

python
def run(repo, *, dry_run, use_gh=True, auto_commit=True, out=None, gh=None, overrides=None)

Observe, then render the plan or perform it. Returns an exit status.

0 when the repository is (or has been brought) fully accounted for, 1 when unexplained tags remain. Raises :class:BackfillError for a condition that stops the pass outright.

This is the entry scripts/migrate_workspace_model.py calls in-process after it rewrites a workspace, so the two halves share one output stream, one exit status and one dry-run decision.

Search