On this page
Repairs the release metadata a history rewrite leaves stale -- the remote's tags and their GitHub Releases -- from safegit's rewrite journal, fail-closed.
#rlsbl.commands.release_reconcile
#rlsbl.commands.release_reconcile
Reconcile release metadata with rewritten history: re-push moved tags and recreate their GitHub Releases from the safegit rewrite journal.
A history rewrite moves every commit it touches, which silently invalidates two pieces of release metadata that live OUTSIDE the commit graph:
- Tags still point at the rewritten commits locally, but the remote holds
the pre-rewrite ones, so git ls-remote --tags and the local repository disagree about what every released version is.
- GitHub Releases are attached to the remote tag, so they keep pointing at
commits that no longer exist in history.
rlsbl release scrub fixes both as part of its own flow. This module holds that logic so it is also available standalone, after ANY out-of-band rewrite -- a raw safegit scrub, a git filter-repo run, someone else's rewrite pulled into the repo. The standalone entry point is rlsbl release reconcile.
The reconcile is journal-driven and fail-closed. safegit writes every rewrite's old-to-new commit map to .git/safegit/rewrite-maps.jsonl; a tag is only re-pushed when the journal explains its divergence (the remote's commit maps to the local one). A divergence the journal does NOT explain is a hard error: force-pushing over it could destroy work.
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
def tag_name_from_refname(refname)Return the tag name for a refs/tags/... refname, else None.
#snapshot_remote_refs
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
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
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
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.
#recreate_github_releases
def recreate_github_releases(tags, *, ctx, project_root, workspace_projects, tag_prefix_index, gh=None, gh_installed=None, gh_auth=None, extract_entry=None)Recreate the GitHub Release for every rewritten tag that had one.
A Release is attached to the remote tag, so once the tag moves the Release still points at a commit that no longer exists. Each existing Release is deleted and recreated with notes taken from the owning project's CHANGELOG.md. Tags without a Release are left alone -- this reconciles, it does not publish.
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.
#ReconcileError
Raised when the reconcile cannot proceed safely.
#plan_reconcile
def plan_reconcile(commit_map, remote_refs, *, git=None)Decide which tags the rewrite moved, and refuse anything unexplained.
Returns (tags, skipped) where tags is the safegit-shaped list of {"refname", "new_sha"} records to re-push and skipped maps tag names to the reason they need no action.
Raises :class:ReconcileError when a tag diverges from the remote in a way the rewrite journal does NOT explain: that divergence was created by something other than this rewrite, and force-pushing over it could destroy work.
#run_cmd
def run_cmd(flags, *, ctx)Reconcile tags and GitHub Releases with a rewritten history.
Reads the last rewrite group from safegit's persisted journal, determines which tags that rewrite moved, force-pushes them with explicit leases, and recreates the GitHub Releases attached to them.