Skip to content
rlsbl.commands.release_reconcile
On this page

Reconciles the refs and GitHub Releases with what this repository's records say it released: push the missing, re-point the explained, refuse the rest.

#rlsbl.commands.release_reconcile

#rlsbl.commands.release_reconcile

Reconcile a repository's published release metadata with what its own records say it released.

Two pieces of release metadata live OUTSIDE the commit graph and outside the working tree, so nothing about a checkout makes them true: the git refs on the remote (a version's tag, its ecosystem companions, the aliases a rename recorded) and the GitHub Release attached to each of them. A history rewrite moves the commits under them. A release interrupted after its candidate push never created them. An out-of-band git push --delete removes them. In every case the repository's own records say one thing and the forge says another.

rlsbl release reconcile observes both sides, judges every subject, and -- only when told to -- writes the difference.

The four explanation sources ----------------------------

A divergence is repaired only when something EXPLAINS it. Four records can, and the reconcile merges all four into one answer rather than resting on any single one:

  • safegit's rewrite journal (.git/safegit/rewrite-maps.jsonl) -- the

old-to-new commit map of the last rewrite. It was the whole spine of this command and is now one source among four. It lives under .git, so it does NOT survive a fresh clone.

  • The release record -- the archived release files, whose candidate_sha

is what each version's refs should point at. This is the authority for the TARGET, not merely a witness to a move.

  • The transition records -- release-commit-remap events (the same commit map, but

COMMITTED, so a fresh clone has it), boundary-alias events (a tag that legitimately duplicates another), and identity-transition events (a published identity that changed, and from which version).

  • The committed scrub archives (.rlsbl/scrubs/scrub-*.json) -- each

past scrub's own old-to-new map, committed to the repository and therefore present in a clone that never saw the journal.

The five verdicts -----------------

Every subject -- one git ref, or one version's GitHub Release -- falls into exactly one class:

  • materialize -- the record says it exists; the forge does not have it.
  • already-correct -- both sides agree. Nothing is done.
  • re-point-with-lease -- the forge holds a different commit AND a source

explains the difference. The force-push carries an explicit --force-with-lease captured from the value actually read off the remote, never a bare lease (a rewrite has already invalidated the remote-tracking refs a bare lease would consult, and tags carry no tracking information at all).

  • refuse-foreign -- the publication tripwire. The forge holds something

no source explains. One such subject aborts the entire reconcile: nothing is repaired anywhere, because a force-push over an unexplained divergence could destroy work, and a partial repair around it would be a reconcile that silently decided which half of an inconsistent world to trust.

  • refuse-identity-mismatch -- the target's

release_materialization_policy refuses. Go declares it: a Go tag IS the published artifact, so recreating one for a version released under a module path the repository has since changed would publish that version under the new identity for the first time, permanently.

Consent is file-driven ----------------------

--plan observes and writes reconcile-plan.toml beside the release records it reconciled -- .rlsbl/releases/ in a standalone repository, the releasable's own releases/ directory in a workspace, resolved by the same identity resolution the ref checks use, never assembled from the project root. That file IS the preview's output artifact, and it is written even when the plan is empty, so an apply on it is a clean no-op rather than an instruction to run the plan that was just run. --apply reads it, re-observes, and performs exactly the repairable items the plan named: a subject the fresh observation grew, or a planned subject whose verdict, lease or target moved, is a hard refusal naming it (see :func:check_plan_covers). --dry-run renders and writes nothing at all -- under --plan the plan file is not written, and under --apply the plan is checked and the writes are only described.

reconcile declares itself consequential as ONE command, so --plan prompts for consent too. That is deliberate: the two halves are one command, and the prompt is about running it at all. A per-half classification would make consent depend on a flag rather than on the command, which is exactly the shape the effects regime refuses.

Every function that shells out takes its git/gh runners from the caller (git=, gh=, ...). Both entry points -- the scrub flow and the standalone command -- pass their own module-level bindings, so one set of test doubles covers whichever path a test drives, and neither module has to reach into the other's namespace.

#tag_name_from_refname

python
def tag_name_from_refname(refname)

Return the tag name for a refs/tags/... refname, else None.

#snapshot_remote_refs

python
def snapshot_remote_refs(timeout=120, *, git=None)

Snapshot the remote's refs. Returns {refname: sha}.

Includes the peeled refs/tags/<name>^{} entries, so an annotated tag's COMMIT is available alongside its tag-object sha.

These are the only trustworthy lease expectations for a post-rewrite force-push: a bare --force-with-lease is useless once the rewrite has moved the remote-tracking refs, and tags carry no tracking information at all.

#push_ref_with_lease

python
def push_ref_with_lease(refname, expected_sha, target_sha, *, timeout, git=None)

Force-push one ref with an explicit lease expectation.

expected_sha is the remote value captured before the rewrite's effects were published (None when the ref did not exist remotely). target_sha is the value the remote should end up with; if the push is rejected but the remote already equals target_sha (a resumed run), the push is treated as done. Any other rejection is a hard error: the remote changed under us and force-pushing would destroy someone's work.

The push runs with --no-verify: these are tool-internal pushes and the pre-push hook exists to catch MANUAL pushes to release branches.

#push_rewritten_tags

python
def push_rewritten_tags(tags, remote_refs, *, push_timeout, git=None)

Force-push every rewritten tag with an explicit lease.

tags is a list of dicts with refname and new_sha (safegit's tag list shape, which the standalone reconcile also produces).

#_notes_for_tag

python
def _notes_for_tag(tag_name, version, *, ctx, project_root, workspace_projects, tag_prefix_index, extract_entry=None)

Resolve a tag's release notes from the owning project's CHANGELOG.md.

#_release_record_dir_for_tag

python
def _release_record_dir_for_tag(tag_name, *, ctx, project_root, tag_prefix_index)

The release-archive directory whose release record owns tag_name's version.

The same resolution :func:_notes_for_tag performs for the CHANGELOG, so the notes and the release commit a written Release carries describe the same project. Returns None when no project owns the tag -- the marker then has no release commit to project, which the caller reports rather than guesses at.

#update_github_releases

python
def update_github_releases(tags, *, ctx, project_root, workspace_projects, tag_prefix_index, gh=None, gh_installed=None, gh_auth=None, extract_entry=None)

Rewrite the GitHub Release document for every rewritten tag.

A GitHub Release follows its tag NAME, and by the time this step runs the tags have already been re-pointed -- so every Release is already attached to the rewritten commit. What does NOT follow the tag is the document: the notes, the rlsbl-ci-sha marker the publish workflow reads, and the pre-release flag. Those are written in place, from the one document :mod:rlsbl.release_publication decides.

Nothing is ever deleted. An earlier shape deleted each Release and created it again, which left a window in which a transient failure stranded a tag with no Release at all. There is no such window here: an edit that fails leaves the previous Release exactly where it was, and a re-run repairs it. A tag carrying NO Release gets one created -- the same materialize shape rlsbl release reconcile performs -- so a rewrite can close that gap too rather than only preserve it.

A tag that is not a version tag under any known scheme is skipped whole, without so much as a lookup: there is no version, therefore no document to write. Pre-release tags are first-class (PRERELEASE_INCLUSIVE).

The release commit comes from the release record, which the scrub's RELEASE_COMMITS_REMAPPED step has already moved through the same rewrite by the time this runs, so the marker names the rewritten commit. A version whose archive holds no release commit -- released before the release record existed, or recorded unrecoverable -- gets its document WITHOUT a marker, and the omission is stated on stderr rather than hidden.

Individual failures are warnings: a partially reconciled forge is better than an aborted reconcile that leaves the rest untouched, and re-running the command is idempotent.

Returns the number of Releases written (edited or created).

#ReconcileError

Raised when the reconcile cannot proceed safely.

#plan_path

python
def plan_path(releases_dir)

The reconcile plan file for a project whose release record is releases_dir.

#_same_commit

python
def _same_commit(a, b)

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

#Explanations

Everything the four sources say about how the world got this way.

commit_map is the merged old-to-new map; origins names which source contributed each entry, so a plan can say WHY a divergence is explained rather than merely that it is.

#resolve

python
def resolve(self, sha)

Follow sha through every recorded rewrite to its final commit.

Successive rewrites chain: a commit rewritten twice appears as the old side of one map entry and the new side of another. The walk is bounded by the map's own size and stops on a cycle, so a malformed record cannot spin here.

#_journal_map

python
def _journal_map()

The last rewrite's commit map from safegit's journal, plus its label.

#_scrub_archive_maps

python
def _scrub_archive_maps(releases_dirs)

Every committed scrub archive's own old-to-new map.

These are the source that survives a fresh clone: the archives are committed to the repository, while safegit's journal lives under .git.

#_transition_record_facts

python
def _transition_record_facts(transition_record_paths)

Release commit remaps, boundary aliases and identity transitions, merged.

#collect_explanations

python
def collect_explanations(releases_dirs, transition_record_paths)

Merge all four sources into one :class:Explanations.

Precedence is deliberate and narrow: an entry contributed by more than one source names the same pair of commits in both, so the maps agree wherever they overlap and the merge order only decides which label is reported. The committed records go on first and the journal last, so a divergence the journal also explains is attributed to it -- it is the most recent event.

#dangling_release_commits

python
def dangling_release_commits(releases_dir, *, git=None, cwd=None)

Archived versions whose release commit names a commit this repository lacks.

Returns {version: release commit}, empty when every release commit resolves.

This is the state an out-of-band rewrite leaves behind: the local tags followed the rewrite, the archives did not, and the commits they name were pruned. The release record is the authority for where a released ref belongs, so until this is repaired every released ref reads as disagreeing with it.

Only a RECORDED archive can be dangling. The other two fates name no commit at all and so can never name a missing one: an unrecoverable version shipped from a commit nothing can name, and a never_released one is a version number no release ever used.

One git rev-list --no-walk --ignore-missing answers for the whole release record, so a repository with a hundred versions pays one git call rather than a hundred. An archive that cannot be read is skipped rather than guessed at: :func:build_preview reads the same file and raises its own error naming it.

#heal_dangling_release_commits

python
def heal_dangling_release_commits(*, releases_dir, explanations, repo_root, dry_run=False, log=print)

Move the release record's stale release commits through the recorded rewrite.

The release commit half of detect-and-heal, and the counterpart to what the changelog side has done since scrubbing existed. It runs BEFORE the verdicts are computed, because the verdicts are computed AGAINST the release record: with the archives naming pruned commits, every released ref is classified refuse-foreign and the tripwire aborts the reconcile -- refusing precisely the repair the command exists to perform.

Returns {version: healed release commit}, which the caller passes to :func:build_preview as release_commit_overrides. Outside a dry run the archives on disk are rewritten (and committed) as well, so the two agree; under --dry-run nothing is written and the mapping is what keeps the preview truthful about a world that WOULD be healed first.

Three rules, none of them inferred:

  • a dangling release commit no record explains is a hard error naming the version

-- the heal is driven by the journal, a transition record release-commit-remap event or a committed scrub archive, never by resemblance;

  • the content check is refuse: this command did not perform the

rewrite, so it cannot state that a released tree changing is intended. rlsbl release scrub is the caller that can, and it declares so;

  • the rewritten archives and the transition record events beside them are committed,

because a rewritten read-only archive left in the working tree is breakage for every other command and every other session.

The heal is scoped to the release record being reconciled, not to every release record in the repository: a reconcile answers for one project's published metadata, and healing a sibling's archives (or being blocked by a content mismatch in one) would be a wider write than the command was asked for.

#Observation

The world, read once.

releases is the set of tag names carrying a GitHub Release, and releases_known says whether it could be read at all -- an unread listing is never treated as an empty one, because that would turn every released version into a Release to materialize.

#digest

python
def digest(self)

A digest of everything the verdicts were computed from.

Stamped into the plan file; the apply step re-observes and compares, so a plan can never be applied against a remote that moved under it.

#_local_tag_refs

python
def _local_tag_refs(git=None)

Local tag refs as {refname: sha}, including the peeled entries.

#list_releases

python
def list_releases(*, ctx, gh=None, gh_installed=None, gh_auth=None)

The tag names carrying a GitHub Release, in ONE listing.

Returns (tags, known). known is False when gh is unavailable or unauthenticated -- the Release half of the reconcile then reports itself as unanswerable instead of proposing to create every Release the repository has ever published.

A listing that comes back holding exactly :data:_RELEASE_LIST_LIMIT entries is refused: gh release list reports no total and offers no pagination, so a repository with more Releases than the cap would have the unlisted ones judged absent and proposed for creation.

#observe_world

python
def observe_world(*, ctx, git=None, gh=None, gh_installed=None, gh_auth=None, remote_timeout=120)

Read the remote refs, the local refs and the Release listing, once each.

#RefAction

What an apply would do to one subject, carried from observe to apply.

#_identity_refusal

python
def _identity_refusal(version, target, transitions)

The recorded identity transition that forbids materializing version.

A transition states that a published identity changed and FROM WHICH VERSION. Anything earlier than that version was published under the OLD identity, so recreating its refs now would publish it under the current one. Returns the offending event, or None.

#_ref_verdict

python
def _ref_verdict(*, refname, tag, version, release_commit, observation, explanations, target, archived)

Classify one git ref. Returns a :class:VerdictItem.

#_release_verdict

python
def _release_verdict(*, tag, version, release_commit, observation)

Classify one version's GitHub Release. Presence only, from the listing.

#build_preview

python
def build_preview(*, observation, explanations, target, ref_ctx, releases_dir, release_commit_overrides=None)

One merged preview over every subject this repository owns.

Subjects come from two places and are judged in one pass:

  • the release record -- every archived version's full ref set (its

primary tag, its ecosystem companions and its recorded aliases, all from expected_refs, the single authority), plus that version's GitHub Release;

  • the local tag namespace -- any tag the release record does not name that

nonetheless diverges from origin. Those are outside the release record's account of what was released, so they are never materialized; they are only classified, which is what makes the publication tripwire fire for a repository that has no archives at all.

A tag the transition record declares a non-version-tag is claimed too, for the same reason and by the same mechanism: :mod:rlsbl.tag_explanation is the one consultation over "is this tag explained?", and a tag an operator deliberately put OUTSIDE the version model is not a release this reconcile has an account of. Judging it would make the tripwire fire forever on a nightly marker or an imported vendor tag.

Two archive fates are skipped entirely, for different reasons:

  • an unrecoverable version has no commit, so there is nothing to

compare a ref against and nothing to create one at;

  • a never_released version was never released at all, so it owns no ref

origin could be wrong about and no GitHub Release that could be missing. Its would-be refs are CLAIMED even though no verdict is produced for them, so a tag carrying its name -- the phantom tag that is usually why such an archive exists -- never reaches the unarchived-tag pass below and can never fire the tripwire. rlsbl never recorded where that tag belongs, so it has nothing to say about where origin holds it; refusing would abort every reconcile on the repository forever.

release_commit_overrides is :func:heal_dangling_release_commits' answer: the release commits the release record WOULD carry once healed, keyed by version. Outside a dry run the archives already say the same thing (they were rewritten before this ran), so it changes nothing; under --dry-run, where nothing may be written, it is what keeps the preview from judging every released ref against a commit that no longer exists.

#refusals

python
def refusals(preview)

Every item whose verdict forbids the reconcile from writing anything.

#tripwire_error

python
def tripwire_error(preview)

The message a refusal aborts the whole reconcile with.

#render_plan

python
def render_plan(preview, digest, *, generated_by)

Serialize preview as the plan document.

#read_plan

python
def read_plan(path)

Read and validate the plan document at path.

The shape is strictspec's to decide (.strictspec/reconcile-plan.schema.toml); this raises :class:ReconcileError naming the file for anything the validator rejects, and for an absent file.

#check_plan_covers

python
def check_plan_covers(plan, preview, path)

Refuse an apply whose fresh observation names work the plan does not.

The plan file IS the consent, so the apply performs exactly the repairable items the operator read -- never a freshly derived set that happens to be larger. :func:check_plan_matches cannot answer this on its own: world_digest covers the REMOTE by design (it is the force-push lease material), so a purely LOCAL change between plan and apply -- a tag fetched, a tag created, a tag moved -- leaves the digest valid while the fresh preview grows a subject, or re-points an existing one at a different commit. Both are writes nobody previewed.

Three refusals, each naming what it saw:

  • a fresh actionable subject the plan does not name at all;
  • a planned subject whose verdict changed (both verdicts are named);
  • a planned subject whose lease or target commit moved.

A planned repairable item the fresh observation no longer names became correct on its own; those keys are RETURNED, so the caller can report them as no-ops rather than treat their absence as a mismatch.

#check_plan_matches

python
def check_plan_matches(plan, observation, path)

Refuse an apply whose plan was written against a different world.

#_release_publication_for

python
def _release_publication_for(action, *, changelog_path, releases_dir)

The Release document for one version, from the changelog and the release record.

#apply_item

python
def apply_item(item, *, ctx, releases_dir, changelog_path, push_timeout, git=None, gh=None, log=print)

Perform one verdict's actions. Called only outside a dry run.

#_resolve_identity

python
def _resolve_identity(ctx)

(target, ref_context, releases_dir) for the project being reconciled.

The same resolution the ref checks work from, so a reconcile names exactly the refs those checks look for.

A repository 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, and the release record-driven half is empty there anyway. What remains is the local-tag half -- the divergences the publication tripwire judges -- and those need no target at all.

#_changelog_path

python
def _changelog_path(ctx)

The CHANGELOG.md whose sections become recreated Release notes.

#run_cmd

python
def run_cmd(flags, *, ctx)

Reconcile this project's published refs and Releases with its records.

Both halves run through here. --plan writes the plan file (empty plan included) and performs no per-item apply; --apply reads that plan back, refuses when the remote or the plan's own subjects moved under it, and then performs what it named.

The command is consequential as a whole, so --plan prompts as well. That is deliberate rather than an oversight: consent is for running the command, and making it depend on which half was elected would put a flag in charge of whether a human is asked.

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