rlsbl v0.113.0 /rlsbl.commands.release.execute
On this page

Release execution: version bump, commit, tag, push, GitHub Release creation, JSONL changelog finalization, rollback guard, and post-release hooks.

#rlsbl.commands.release.execute

#rlsbl.commands.release.execute

Release execution: version bump, commit, tag, push, GitHub Release creation, JSONL changelog finalization, and post-release hook invocation.

#ReleaseAbortError

Raised when the release must abort (e.g., unexpected dirty files).

#RollbackClobberError

Raised when rollback would destroy foreign commits or dirty files.

This prevents git reset --hard from silently discarding work created by concurrent sessions sharing the same worktree.

#ReleaseCIError

Raised when CI does not go green on the pushed release candidate.

Under main-as-candidate ordering this is a normal terminal state, not a corrupted one: the candidate commit is on the release branch, but no tag, no GitHub Release and no finalized changelog exist. The version is not burnt -- the fix lands forward on the same version and the release resumes.

#_ci_red_message

python
def _ci_red_message(*, version, tag, branch, candidate_sha, detail)

Remediation text for a red (or unreachable) CI verdict on the candidate.

This replaces the pre-C4 guidance ("re-run CI to green on this exact commit"), which was unfollowable whenever the failure was in the code at the tagged commit -- the common case. Under main-as-candidate ordering the correct remedy is always the same and always available: fix forward on the release branch, same version, then resume.

#_ci_not_run_message

python
def _ci_not_run_message(*, version, tag, branch, candidate_sha, detail)

Remediation for a candidate whose CI never RAN for this project.

Distinct from red and from timeout: CI concluded, and it concluded green -- for somebody else. The code at the candidate may be perfectly fine, so "fix the failure" is the wrong instruction. What the candidate lacks is a commit under this project's paths, which is what makes its CI job run at all, and what the publish gate will later demand evidence of.

#_ci_timeout_message

python
def _ci_timeout_message(*, version, tag, branch, candidate_sha, detail)

Remediation text for a CI wait that ran OUT OF TIME, not out of luck.

A timeout proves nothing about the candidate: the runs may still be in flight. Reporting it as a red verdict would send the operator to fix code that is very possibly fine, so it gets its own honest message.

#_probe_publication

python
def _probe_publication(resolved_targets, version, ctx, *, log, delays=None)

Probe every publishable target's registry for version.

Returns (missing, checked): the registry names still not serving the version after the retry budget, and every name that was probeable at all.

Targets whose publish_mode is none are skipped (nothing was meant to reach a registry), as are targets with no publication_probe capability -- an unprobeable target yields no evidence either way, and inventing a verdict from its silence is the exact move this check exists to replace.

delays defaults to :data:_PUBLICATION_PROBE_DELAYS, read at call time rather than bound as a default argument so a test that monkeypatches the module attribute actually collapses the budget for the wired-in call sites too (which pass no delays of their own).

#_verify_publication

python
def _verify_publication(resolved_targets, version, tag, ctx, *, log, delays=None)

Assert every publishable target's registry is serving version.

A release verified its PROCESS -- CI green, tag pushed, publish workflow dispatched -- and then announced success. It never verified its OUTCOME, so a publish job that silently produced no artifact (a skipped matrix leg, a gate that refused, an upload that 4xx'd into a retry that never happened) ended as a green release with nothing on the registry. This is the outcome check: after CI has concluded, ask each registry whether the version it was supposed to publish is actually being served.

Exits nonzero naming every registry that is not.

**This runs on the --watch path only, deliberately.** The probe is meaningful exactly once CI has concluded, because CI is what runs the publish job. Under --no-watch the release returns while the publish workflow is still queued or running, so probing there would report every registry as missing the version and fail every release. That is an explicit mode choice, not a degradation: --watch verifies the outcome, --no-watch does not verify it and says so out loud (:func:_announce_unverified_publication).

#_verify_publication_members

python
def _verify_publication_members(specs, *, log, delays=None)

Batch form of :func:_verify_publication: one verdict per member.

specs is an iterable of (label, resolved_targets, version, tag, ctx) -- one entry per batch member, carrying that member's OWN resolved targets and its OWN new version. A batch publishes many packages from one candidate commit, and each lands on its own registry under its own version, so the outcome question is per member; there is no batch-wide "did it publish".

Every member is probed before anything is decided, so one missing artifact never hides another: a batch that half-published is reported whole. Exits nonzero naming every (member, registry) pair still not serving its version.

Like :func:_verify_publication, this belongs on the --watch path only -- see that function's docstring for why.

#_announce_unverified_publication

python
def _announce_unverified_publication(sha, log)

Say out loud that --no-watch left the publish outcome unverified.

The registry probe runs only after the CI wait (see :func:_verify_publication), so a --no-watch release ends with the publish workflow still in flight and nothing having asked the registry whether the artifact arrived. That is a legitimate mode -- but it must not look like the verified one, so the difference is stated rather than left to the absence of a message.

Goes to stderr so --quiet cannot swallow it, and names the command that resumes the verification the run skipped.

#_empty_candidate_window_message

python
def _empty_candidate_window_message(*, version, tag, branch, candidate_sha, base_sha, patterns, changed, pushing)

Remediation for a candidate whose push cannot trigger this project's CI.

Same terminal state as :func:_ci_not_run_message and the same remedy -- but reached BEFORE the push and before the CI wait, from the diff alone, so the operator learns it in a second instead of after a full CI cycle.

#_router_pattern_matches

python
def _router_pattern_matches(path, pattern)

Does repo-relative path match one dorny/paths-filter pattern?

The router emits two shapes: a directory globstar (packages/core/**, from the project's path) and arbitrary globs (from watch, plus the releasable's CHANGELOG artifact). The globstar is a prefix test -- picomatch matches direct children as well as nested ones -- and everything else goes through fnmatch, the same approximation file_matches_project uses for watch globs.

#_release_router_patterns

python
def _release_router_patterns(monorepo_root, monorepo_name, releasable_name)

Router filter patterns for every project the release's tag publishes.

The same set :func:rlsbl.ci_checks.release_check_filters builds for the CI gate: the releasing project, plus every member of its releasable in explicit mode. One tag publishes all of them, so every one of their CI jobs has to have run on the candidate.

#_git_read

python
def _git_read(args, *, cwd)

Run a read-only git command, or return None when it cannot answer.

Goes through effects.run rather than the release flow's late-bound run for the same reason :func:head_sha does: this is guard bookkeeping, and it must never consume a mock side effect or shift a call sequence in tests that stub the release's git calls.

None means "git declined" -- typically a SHA the local clone does not have (a remote head that was never fetched). That is not evidence about the window either way, so the caller falls through to the CI gate, which remains the authority. A missing git binary or an internal bug raises.

#_diff_names

python
def _diff_names(base_sha, head_sha_, *, cwd)

Repo-relative paths changed between two commits, or None if unknowable.

#_widened_window_base

python
def _widened_window_base(state_path, *, cwd)

The parent of the earliest commit this release created, or None.

Used when the candidate is ALREADY on the remote: no push is about to happen, so the CI run that will be examined is the one an earlier push triggered, and its own before-SHA is not knowable locally. The release's own commit trail is the best statement of what that push carried, so the window widens to start just before the version-bump commit.

#_guard_empty_candidate_window

python
def _guard_empty_candidate_window(*, candidate_sha, remote_head, needs_push, state_path, monorepo_root, monorepo_name, releasable_name, version, tag, branch, cwd, log)

Refuse a candidate whose diff window cannot trigger this project's CI.

The generated monorepo router gates each project's CI job on a dorny/paths-filter over the paths a push touched, computed against the push's own before-SHA. When the window matches none of a project's patterns its job concludes skipped, the publish gate refuses the skipped check, and the release deadlocks -- after a full CI wait, which is the expensive part. The most common way to land there is a resume: the candidate was already pushed, the fix commit touches somebody else's paths, and the new window no longer contains the version bump at all.

Only monorepo projects have a router, so a standalone repository (whose CI runs on every push) is not guarded. Neither is a branch with no remote head -- there is no before-SHA, hence no window to reason about.

Raises :class:ReleaseCIError: nothing is pushed, tagged or finalized, the state stays resumable, and the version is not burnt.

#_marker_reconcile_failure

python
def _marker_reconcile_failure(tag, marker, what, exc)

The message for a CI-SHA marker that could not be read or written.

#CiShaMarkerError

Raised when the CI-SHA marker cannot be reconciled onto a Release.

Fail-closed by design: a release whose gate marker is unknown must not proceed to the steps that act on the published Release.

#_reconcile_ci_sha_marker

python
def _reconcile_ci_sha_marker(tag, marker, notes_file, *, config, log)

Ensure an ALREADY-EXISTING GitHub Release carries the CI-SHA marker.

The marker used to be written only on the creation path, so a Release that pre-existed the notes write -- a resumed release, or one created out of band -- shipped without it and the publish gate fell back to $GITHUB_SHA, gating on whatever commit the workflow happened to see. The marker is now written unconditionally: created with the Release, or edited into the existing body here.

Idempotent: a body already carrying this exact marker is left untouched; a body carrying a DIFFERENT marker has it replaced, never duplicated.

A read or write failure raises :class:CiShaMarkerError. It used to print a warning and return False -- a value the caller discarded -- so a release whose gate marker was missing or stale completed and exited 0. The caller turns the raise into a recorded step failure plus a nonzero, resumable exit, and every step after this one is skipped: the gate reading the marker is what decides whether the tag may publish at all, so acting further on a Release whose marker is unknown is exactly the move to refuse.

#_track_release_commit

python
def _track_release_commit(state_path, sha=None, cwd=None)

Record a release commit SHA in the state file.

Called immediately after each commit_files() / commit_files_if_changed() invocation so the rollback guard can distinguish release-owned commits from foreign ones.

Best-effort: failures are silently ignored. When tracking fails (e.g., in test environments without a real git repo), the rollback guard treats all commits as foreign and refuses rollback -- the safe default.

If sha is not provided, reads HEAD via effects.run directly (bypasses the mock-patched run function used by the release flow, avoiding mock side-effect exhaustion in tests).

#_guard_rollback

python
def _guard_rollback(pre_release_sha, state_path, cwd=None)

Refuse rollback if foreign commits exist between pre_release_sha and HEAD.

Compares commits between pre_release_sha and HEAD against the release_commits list persisted in the state file. Any commit not in release_commits is a foreign commit (from a concurrent session).

Dirty files (uncommitted modifications) are NOT checked because the release flow itself writes version-bump and changelog files before committing them -- those dirty files are the expected rollback target, not concurrent work. Untracked files survive git reset --hard anyway.

Uses effects.run directly (not the mock-patched run function) to avoid consuming mock side-effect entries in tests.

Raises :class:RollbackClobberError with details and manual recovery instructions when rollback is unsafe.

#_is_push_timeout_exc

python
def _is_push_timeout_exc(exc)

True when a push failure was a timeout.

Both the candidate/branch push (via push_if_needed) and the tag push surface timeouts as :class:GitError whose message contains "timed out"; a raw :class:subprocess.TimeoutExpired counts too (belt-and-braces in case conversion is bypassed).

#_is_resumable_failure

python
def _is_resumable_failure(exc, branch_pushed, candidate_push_attempted, completed)

Decide whether a mutating-phase failure must SKIP rollback.

Rollback (git reset --hard to the pre-release commit) is only safe while nothing has reached the remote. Three states forbid it:

  • the candidate push succeeded (branch_pushed),
  • a prior run already recorded BRANCH_PUSHED,
  • the candidate push was attempted and TIMED OUT -- a timed-out push may

still have landed, so resetting would diverge from published history.

A non-timeout candidate-push failure (rejected ref, auth error) proves nothing landed, and still rolls back.

#head_sha

python
def head_sha(cwd=None)

Return HEAD's SHA, or None when it cannot be resolved.

Uses effects.run directly rather than the release flow's run: this is bookkeeping for the drift guard, and it must never consume a mock side effect (or shift a call sequence) in tests that stub the release's git calls. Same rationale as :func:_track_release_commit.

#ForeignCommitError

Raised when commits outside the release's own trail rode into it.

The forward twin of :class:RollbackClobberError: that guard refuses to DESTROY a concurrent session's commits, this one refuses to SHIP them.

#UnverifiedCandidateError

Raised when the CI-verified commit a resume must tag cannot be proven.

A resume that skips the candidate push and the CI gate does so because a prior attempt already recorded CI_VERIFIED. The tag it then creates is stamped "(CI-verified)", and the publish gate believes that claim, so the SHA it lands on must be the commit CI actually judged. When the recorded candidate is missing, unresolvable, or not in the current history, the claim cannot be made honestly and the release stops -- it never falls back to HEAD, which is exactly how an untested commit once got tagged, released and handed to the publish gate.

#guard_foreign_commits

python
def guard_foreign_commits(pin_sha, trail, cwd=None, *, phase)

Refuse to continue if commits in pin_sha..HEAD are not in trail.

A release pins HEAD when it starts and records every commit it creates in a trail. Anything in the pin range that is not in the trail arrived from somewhere else -- a concurrent session sharing the worktree, an editor's auto-commit, a hook. Releasing it would ship unreviewed work under this version's changelog, and the range is recomputed at run time, so a ride-in between two attempts used to join the release silently.

Fail-closed and by name: the error lists every foreign SHA with its subject, so the operator can decide whether to include the work (record it in the changelog and re-run) or move it aside. Nothing is rolled back.

phase names the checkpoint in the error text (entry / candidate push / CI gate / final push). Uses effects.run directly, not the mock-patched run, so the guard is never starved of a mock side effect in tests.

The batch orchestrator uses this directly with a workspace-level pin and the union of its members' trails; :func:_guard_foreign_commits is the single-release wrapper that reads the trail out of a state file.

#require_recorded_candidate

python
def require_recorded_candidate(state_path, cwd=None, *, version)

The CI-verified commit this release is sealed to, or a hard error.

Called only where the executor SKIPS the candidate push and the CI gate because an earlier attempt already recorded CI_VERIFIED. The tag that follows is stamped "(CI-verified)" and the publish gate trusts that claim, so the SHA must be the commit CI actually judged.

Three ways the claim can fail, all hard errors and none of them a fallback to HEAD:

  • no candidate_sha in the state file (the state was written by a

version that did not record it, or was hand-edited),

  • a candidate_sha this repository cannot resolve (history rewritten

under the release),

  • a candidate_sha that is not an ancestor of HEAD, which means the

branch no longer contains the verified commit at all.

Falling back to HEAD here is precisely how a commit with zero CI runs was tagged, released, and refused by the publish gate.

#_guard_foreign_commits

python
def _guard_foreign_commits(pin_sha, state_path, cwd=None, *, phase)

Single-release wrapper: the trail comes from the state file.

#_changelog_files_for_commit

python
def _changelog_files_for_commit(project_dir, git_root, *, releasable_cfg_dir, monorepo_root)

The generated CHANGELOG.md paths a release commits, git-root-relative.

One derivation, two callers: the Phase-A plan builder (for the release commit) and the changelog-finalization step (for the finalize commit).

#_bump_selfdoc_version_content

python
def _bump_selfdoc_version_content(project_dir, new_version)

The selfdoc.json content a version bump would produce, or None.

Pure: reads and derives, writes nothing. The Phase-A plan builder calls this so the write itself becomes a data-only plan step (a path and its finished bytes) that the executor issues without re-deriving anything.

#_git_toplevel

python
def _git_toplevel(cwd=None)

The git work-tree root for cwd.

Asks git first (mockable through the release flow's run, which is what tests stub). When git cannot answer -- a preview past its first recorded mutation replies with a stale carrier -- the answer is derived from the filesystem instead, by walking up for a .git entry. That walk is pure: no subprocess, no observe, and correct for worktrees too (whose .git is a file rather than a directory).

#_rel_to_git_root

python
def _rel_to_git_root(path, git_root)

Normalize path; make relative to git root if absolute.

#resolve_target_paths

python
def resolve_target_paths(project_dir='.', releasable_config_dir=None)

Build a dict mapping target names to their resolved paths.

Resolution goes through :func:rlsbl.member_context.resolve_member_context, which reads the merged config "targets" (supporting both plain strings and dicts with "name"/"path", with releasable-level inheritance when releasable_config_dir is given) and falls back to auto-detection.

Returns dict[str, str] mapping target name -> resolved directory path.

#resolve_release_targets

python
def resolve_release_targets(primary, flags, project_dir='.', *, config, releasable_config_dir=None)

Compute the effective set of secondary targets for this release.

Reads the baseline from config "release_targets" list. If absent, falls back to auto-detect (all targets that detect("."), with releasable-level inheritance when releasable_config_dir is given). Entries can be plain strings or dicts with "name" and optional "path".

The primary target is always excluded from the secondary set (it's handled separately by the main release flow).

Returns a dict mapping target name -> resolved directory path.

#_target_lockfile_syncs

python
def _target_lockfile_syncs(target_paths, log)

Which lockfile syncs a release owes, and what each one runs.

Pure: every question that decides whether a sync is owed -- does the lockfile exist, is its guard file present, is the tool on PATH, is the lockfile gitignored -- is asked here, before the release records or performs anything. The old inline version asked the gitignore question with a git check-ignore observe issued AFTER its own sync, which under a preview is a question asked after a recorded mutation: the framework answers with a stale carrier and the preview truncates on the reply.

Returns a list of dicts the plan carries verbatim::

{"cwd": ..., "cmd": [...], "lockfile": ..., "lockfile_path": ..., "timeout": ...}

#_sync_lockfiles

python
def _sync_lockfiles(target_paths, files_to_commit, log)

Re-sync lockfiles after version bumps so they stay consistent.

For each known lockfile found in a target directory, runs the corresponding sync command. If the lockfile is modified, its path is appended to files_to_commit so it is included in the release commit and not flagged by the unexpected-files guard.

Missing tools and sync failures are warnings, not errors.

#_publish_standalone_pipelines

python
def _publish_standalone_pipelines(ctx, target_paths, primary_path, new_version, state_path, log)

Publish every configured pipeline for a standalone / implicit-mode release.

Each pipeline publishes from its own linked target's path (target_paths[pipeline.target]) so multi-target projects whose targets live in distinct subdirectories publish the right artifacts. Targetless (target is None) deploy pipelines fall back to primary_path (the registry/root path).

Resume support: published_targets in the release state tracks completed pipelines so a re-run skips them. On failure, partial progress is persisted and a PostReleaseError is raised.

#archive_blog_body

python
def archive_blog_body(releases_dir, version)

Archive unreleased.md to v{version}.md during release finalization.

releases_dir is the resolved releases directory (member .rlsbl/releases/, or the releasable's releases/ dir in explicit releasable mode).

Returns the archived path if the file existed, None otherwise.

#collect_companion_tags

python
def collect_companion_tags(member_package_paths, workspace_root, version, primary_tag, releasable_config_dir=None)

Collect companion tags from all publishing member packages.

Iterates member packages in an explicit releasable, detects their targets, and collects companion tags (e.g. Go module proxy tags).

Guards:

  • Only meaningful in explicit releasable mode (caller checks).
  • Skips companion creation if the primary tag already contains a

/v pattern (Go-compatible), to avoid duplicate tags.

  • Skips publish-suppressed packages (same logic as

_sync_member_package_versions_plan, including releasable-level config inheritance).

Args:

  • member_package_paths: workspace-relative paths for member packages.
  • workspace_root: absolute path to the monorepo root.
  • version: version string being released.
  • primary_tag: the primary release tag string.
  • releasable_config_dir: optional path to the releasable's state

directory for config inheritance.

Returns:

  • List of companion tag strings (deduplicated, excluding the primary tag).

#_sync_member_package_versions_plan

python
def _sync_member_package_versions_plan(member_package_paths, monorepo_root, new_version, git_root, exclude_path=None, releasable_config_dir=None)

Which member packages a releasable version bump has to write, and where.

Pure: resolves each member's config and targets, validates that a declared target's manifest exists, and returns one entry per (member, target) pair::

{"package_path": ..., "target": ..., "path": ..., "files": [...]}

files are git-root-relative predictions from the target's declared version file -- a lower bound the executor widens with whatever the target's writer actually reports touching.

#_target_paths_from_resolved

python
def _target_paths_from_resolved(resolved_targets) -> dict

Map target name -> directory path from a resolved-targets list.

Deduplicates by target name (keeps the first occurrence for each name, matching the order of resolved_targets). A target served by multiple pipelines appears once.

#ReleaseState

All state needed by _run_release_mutating, grouped logically.

resolved_targets is the canonical list of publishable (target, pipeline) pairs (see :class:ResolvedTarget); exactly one of them is marked primary. The mutating flow derives the registry name, the registry-target instance (TARGETS[registry]), the primary path, the full target-path map, and the secondary-target map from this list (see :attr:primary and :func:_target_paths_from_resolved). No legacy scalar identity/path fields are stored.

#primary

python
def primary(self)

The primary :class:ResolvedTarget (the include[0] record).

The release flow requires exactly one primary record; a resolved list with none marked is a programming error (the primary name must be threaded through from the release file).

#_refuse_phase_b_in_preview

python
def _refuse_phase_b_in_preview(flags)

Structural backstop at the one door a preview must never open.

It used to guard the whole mutating phase, because a preview had no business anywhere inside it: release resume --dry-run once walked straight through and committed, tagged, pushed to the release branch, created a GitHub Release and dispatched the publish workflows.

Phase A is now previewable BY CONSTRUCTION -- every mutation in it is an effect, so a preview records it and nothing reaches the disk or the remote (tests/test_release_phase_a_seam.py pins that, including the fact that no ["git"] observe prefix exists that could let a push execute). What stays un-previewable is Phase B, which waits on CI and then publishes. So the backstop moved down to exactly that door: reaching it with --dry-run set means the seam return above was bypassed, which is a bug.

#_run_release_mutating

python
def _run_release_mutating(state: ReleaseState)

Inner release logic that runs under the advisory lock (mutating phase).

Search