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
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
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
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
def _print_summary(results)Print a summary table of step results. Only called when at least one step failed.
#_resolve_context
def _resolve_context(ctx)Detect the monorepo / releasable context shared by both undo paths.
#_release_record_dir
def _release_record_dir(uc)The release archives for the project or releasable being undone.
#_find_latest_release
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
def _version_and_msg(uc, tag)Return (bare_version, expected_version_bump_message) for a tag.
#_build_tag_from_version
def _build_tag_from_version(uc, version)Build the release tag string for a given version (non-latest path).
#_classify_release_created_commit
def _classify_release_created_commit(subject, expected_msg)Return the shape of a release-created commit, or None if it is not one.
#_die
def _die(*lines)Print an operator-facing refusal and exit, having changed nothing.
#_release_commit
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
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
def _log_commits(range_spec)[(sha, subject)] for a git range, newest first.
#_collect_release_created_commits
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
def _commit_paths(sha)Every path a commit touches (empty for a merge, which shows no diff).
#_refuse_foreign_work_above_release_commit
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
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
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
def _build_plan(uc, flags, ctx)Compute the full UndoPlan for either path, before any mutation.
#_enforce_gate
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
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
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
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
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
#_execute_plan
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
def run_cmd(registry, args, flags, *, ctx)