Skip to content
rlsbl.commands.undo
On this page

Reverts a release from a plan computed upfront: the release commits to revert, the companion tags to delete, and the registry evidence verdict.

#rlsbl.commands.undo

#rlsbl.commands.undo

Undo command that reverts a release.

The undo flow is plan-driven: an :class:UndoPlan is computed UPFRONT (for both the latest-release path and the --version non-latest path) before anything is mutated. The plan enumerates the release-created commits to revert, companion tags to delete, whether a GitHub Release exists, and the registry evidence verdict. --dry-run prints the plan and exits without touching anything; a real run consumes the identical plan object.

The shape a release really leaves behind ----------------------------------------

Under main-as-candidate ordering the version-bump commit is the CANDIDATE: it is pushed untagged, CI judges it, and only then is it tagged -- so the tag sits at the BOTTOM of the release's commits and the finalization commits (changelog rename, release-file archive, per-version .md) sit ABOVE it. A resumed release moves the tag further still: after a fix-forward the verified commit is the tip at resume time, several commits above the bump.

So the release-created commits are found from the RELEASE RECORD, not by walking down from the tag: the archive for the version records the commit that was verified and tagged (the release commit), the predecessor's archive records where the previous release ended, and the release-created commits are the ones between those two whose subjects the release itself writes (the version bump and the finalization commits). Walking down from the tag and stopping at the first subject the release did not write collected ZERO commits on a resumed release, and undo reported success with the version files still bumped.

What is reverted, and what is repaired --------------------------------------

Reverted: the release-created commits at or below the release commit (always the version bump; also the finalization commits on the older shape where they sat below the tag). Repaired rather than reverted: the finalization ABOVE the release commit -- the changelog is un-finalized and CHANGELOG.md regenerated, and the archived release file is restored to unreleased.toml. Foreign work in between (a fix-forward that made CI green) is neither reverted nor repaired: it is somebody's actual work and it stays.

#UndoPlan

A fully-computed description of what an undo will do.

Computed once, before any mutation. --dry-run prints it and exits; a real run executes it. revert_shas is a list of (sha, subject) tuples ordered newest-first (the order they must be reverted in so each revert applies cleanly).

#_resolve_undo_changelog_paths

python
def _resolve_undo_changelog_paths(project_path, ws_root, releasable_name)

Resolve the changes dir, regenerate callable, and git-add paths for changelog restoration -- releasable-aware.

In explicit releasable mode the finalized JSONL lives in the releasable's changes dir and the canonical CHANGELOG.md in the releasable dir (plus the combined root CHANGELOG.md); otherwise everything is per-project.

#_resolve_undo_releases_dir

python
def _resolve_undo_releases_dir(project_path, ws_root, releasable_name)

Resolve the releases dir holding the release-file family -- releasable-aware.

In explicit releasable mode the archived v{x}.toml (and unreleased.toml) live under the releasable's own releases dir; otherwise per-project.

#_clear_release_state

python
def _clear_release_state(project_path, ws_root)

Clear any in-progress release state after a successful rollback.

A rolled-back release's preserved in-progress.json is meaningless and would hard-block the next rlsbl release run.

#_print_summary

python
def _print_summary(results)

Print a summary table of step results. Only called when at least one step failed.

#_resolve_context

python
def _resolve_context(ctx)

Detect the monorepo / releasable context shared by both undo paths.

#_release_record_dir

python
def _release_record_dir(uc)

The release archives for the project or releasable being undone.

#_find_latest_release

python
def _find_latest_release(uc)

The version undo reverts, and its tag -- selected from the RELEASE RECORD.

undo used to ask git describe --tags --abbrev=0 which release was the latest, so a release whose tag had already been half-deleted was invisible to the command whose whole job is deleting it, and a hand-made tag could nominate a release that never happened. The archives answer which version is latest; the tag is then derived by translation (:func:_build_tag_from_version).

The archive's release commit is NOT read here: everything after this point -- the commit walk, the tag deletion, the revert -- operates on the tag namespace, so undo is an observe-and-repair layer over tags in the same sense release reconcile is, and refusing to start because a release commit and a tag disagree would refuse exactly the repair the operator came for. The release commit IS read for the predecessor boundary, where it decides which commits belong to this release (see :func:_build_plan).

Its FATE is read, though, because the highest archive is not always the latest release: an archive recorded never_released is a version number no release ever used, so there is nothing there to undo. Taking the highest archive outright made undo select such a phantom, find no commit in it, and die -- leaving the real latest release un-undoable. The walk therefore descends past every never-released archive to the highest one that names a release. An archive that cannot be read at all does not silently shift the selection onto an older release: it is a hard error naming the file.

Returns (version, tag), or exits when nothing is recorded.

#_version_and_msg

python
def _version_and_msg(uc, tag)

Return (bare_version, expected_version_bump_message) for a tag.

#_build_tag_from_version

python
def _build_tag_from_version(uc, version)

Build the release tag string for a given version (non-latest path).

#_classify_release_created_commit

python
def _classify_release_created_commit(subject, expected_msg)

Return the shape of a release-created commit, or None if it is not one.

#_die

python
def _die(*lines)

Print an operator-facing refusal and exit, having changed nothing.

#_release_commit

python
def _release_commit(uc, version, tag)

The commit the RELEASE RECORD records version as having shipped from.

Read from the archive DIRECTLY rather than through :func:rlsbl.release_record.read_entry, which refuses when the version's tag and the release commit disagree. Undo is the repair path for exactly that state and is about to delete the tag, so a disagreement is reported as a warning and the ARCHIVE wins: it is written by the release flow, committed, and rewritten by rlsbl only through its own documented unlock paths, while a tag is a ref anyone can move.

#_predecessor_release_commit

python
def _predecessor_release_commit(uc, version)

The commit the release BELOW version shipped from, or None.

The boundary survives that release's tag being deleted, because it is read from the archive rather than from git describe.

#_log_commits

python
def _log_commits(range_spec)

[(sha, subject)] for a git range, newest first.

#_collect_release_created_commits

python
def _collect_release_created_commits(uc, version, tag, expected_msg)

The release-created commits, newest-first, located through the RELEASE RECORD.

The search range is <predecessor's release commit>..<this release's release commit>, both read from the archives. Inside it the version-bump commit is identified and every commit whose subject the release itself writes, from the bump up to the release commit, is collected; foreign commits in between (a fix-forward) are left alone, and the predecessor's own finalization commits are below the bump and so out of the collected set.

Returns (revert_shas, captured_finalize_changelog, captured_finalize_release_file, release commit). Refuses -- loudly, before anything is destroyed -- when the version-bump commit cannot be found: a silent "no release-created commits found" is how undo used to delete a tag and a GitHub Release while leaving the version files bumped.

#_commit_paths

python
def _commit_paths(sha)

Every path a commit touches (empty for a merge, which shows no diff).

#_refuse_foreign_work_above_release_commit

python
def _refuse_foreign_work_above_release_commit(release_commit, revert_shas, tag)

Refuse when work above the released commit endangers the revert.

A commit between the release commit and HEAD is safe when either it is entirely rlsbl's own bookkeeping (the finalization commits, and anything else in the tool-owned set) or it touches none of the files the revert touches -- a post-release hook writing its own notes cannot conflict with reverting a version bump. Anything else is refused BEFORE the first deletion: reverting the bump underneath somebody's edit of the same file is how a rollback ends up mid-conflict, and by then the tag and the GitHub Release are gone.

#_plan_companion_tags

python
def _plan_companion_tags(uc, tag, version)

Enumerate the version's non-primary refs to delete (releasable mode only).

Asks the same expected_refs authority the release's tag step asked when it CREATED them, so undo cannot leave behind a ref the release made. An alias a boundary-alias event records is included: it addresses the version being undone and was created alongside it.

A shipped_as-derived alias is EXCLUDED, and that is the whole reason the ref set states which aliases came from that field. It names the spelling the version ACTUALLY shipped under, from before a rename or a repository boundary moved -- a ref the release being undone never created, that consumers already resolve, and that the release record's own rule keeps where it is: neither moved nor deleted.

Degrades to an empty list on any failure, with the traceback printed. Undo is a repair path, and refusing to remove the primary tag because a member's config no longer resolves would strand the release entirely.

#_plan_gate

python
def _plan_gate(uc, ctx, version)

Run the registry evidence gate for the release (both paths).

Resolution goes through MemberContext so targets/paths inherit releasable-level config. resolved_targets is available on the context for future per-pipeline evidence checks, but the evidence gate currently operates per-target-object only.

#_build_plan

python
def _build_plan(uc, flags, ctx)

Compute the full UndoPlan for either path, before any mutation.

#_enforce_gate

python
def _enforce_gate(plan)

Refuse the undo when the evidence gate did not clear.

PUBLISHED -> route to release yank / release deprecate (no bypass). INCONCLUSIVE / all-inconclusive -> hard block (same semantics as the non-latest path). Applies to BOTH the latest and non-latest paths.

#_print_plan

python
def _print_plan(plan)

Print every write, deletion and commit the apply would perform.

The plan used to name only the reverts, while the apply also wrote and committed the audit record, the restored changelog and the restored release file -- three commits a preview never mentioned. Steps whose commit is conditional say so rather than being left out.

#_restore_changelog

python
def _restore_changelog(plan, uc, results)

Reconcile CHANGELOG.md with the restored changelog state.

Runs on BOTH paths. It first un-finalizes the version's JSONL (renaming {version}.jsonl back to unreleased.jsonl; a no-op when a revert already restored it, and the repair when it didn't -- e.g. the non-latest path or a finalize commit outside the collected release-created commits). It then ALWAYS regenerates CHANGELOG.md so the generated file matches the restored unreleased.jsonl -- reverting the finalize commit restores the JSONL but leaves CHANGELOG.md showing the released version rather than ## Unreleased. Commits only when git reports an actual change, so it is idempotent.

#_restore_release_file

python
def _restore_release_file(plan, uc, results)

Repair the release file when its finalize commit was NOT reverted.

unfinalize_release_file is a no-op when the file is already restored (e.g. the finalize commit was reverted), so this is safe on both paths.

#_stop_on_conflicted_revert

python
def _stop_on_conflicted_revert(plan, sha, subject, done, results)

End the undo where a revert conflicted -- committing and pushing nothing.

A failed revert used to record FAILED and CONTINUE, so the next git add / git commit in the changelog restore CONCLUDED the in-progress revert: conflict markers and all, folded into "chore: restore changelog after undo of ", and then pushed. The in-progress revert is aborted here instead, the operator is told exactly what already happened, and nothing further is written.

#_execute_plan

python
def _execute_plan(plan, uc, flags, ctx)

Execute a computed UndoPlan.

Writes the audit journal BEFORE any deletion, on both the latest and the --version path (both reach here). If the journal cannot be written the undo is refused outright: no GitHub Release, tag or commit is touched.

#run_cmd

python
def run_cmd(registry, args, flags, *, ctx)

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