rlsbl v0.113.0 /rlsbl.commands.monorepo.batch_release
On this page

Batch release command for monorepo workspaces that validates listed items, determines topological order, and releases each sequentially.

#rlsbl.commands.monorepo.batch_release

#rlsbl.commands.monorepo.batch_release

Batch release command for monorepo workspaces that validates listed items, determines topological order, and releases each sequentially.

Reads .rlsbl-monorepo/releases/unreleased.toml, validates all listed items, determines topological release order, and releases each sequentially by delegating to the existing single-package release flow.

In explicit mode ([releasables.*] sections), iterates releasables in dependency order (a releasable's position = max topological position of its member packages). For each releasable, picks one representative member package and releases through it.

In implicit mode ([packages.*] sections), iterates packages directly in topological order (original behavior).

#_run_root_selfdoc

python
def _run_root_selfdoc(flags, workspace_root, log)

Detect and run selfdoc at the workspace root if selfdoc.json exists there.

Member-level selfdoc is handled by the per-member release flow. This function covers the case where selfdoc.json lives at the repo root only (e.g. a monorepo-wide docs site).

Runs selfdoc gen --no-auto-commit and selfdoc check, then commits any generated files so the tree is clean for per-member validation.

#_batch_release_flags

python
def _batch_release_flags(flags, **extra)

Per-item release flags for the batch loop.

#_commits_between

python
def _commits_between(start, end, workspace_root)

The commits in start..end, newest first; empty when unresolvable.

Bookkeeping for the batch drift guard, so it goes through effects.run directly rather than the release flow's mock-patched run -- the same rationale as :func:~rlsbl.commands.release.execute.head_sha. An unresolvable range yields no trail entries, and an unresolvable pin disables the guard outright, so neither can fabricate a foreign commit.

#_batch_release_trail

python
def _batch_release_trail(pending, inline_commits)

The union of every batch member's own release-commit trail.

Members still mid-flight keep their trail in their in-progress state file. A member whose release ran to completion inside its own call cleared that file, so the orchestrator records the commits that appeared across its call in inline_commits instead.

#_watch_and_verify_batch

python
def _watch_and_verify_batch(flags, last_sha, probe_specs, log)

The batch's watch tail: CI verdict, then the registry outcome check.

Both batch loops (releasable and package mode) end here so the two paths cannot drift. Under --watch the CI wait runs first -- a green run is what makes the registry question meaningful, since CI is what runs the publish jobs -- and every member is then probed for its own new version. A red CI exits with CI's own code: reporting missing artifacts on top of a failed publish adds nothing.

Under --no-watch NOTHING is probed, deliberately: the publish workflow is still in flight, so a probe would report every member as missing and fail every --no-watch batch. The run says so out loud instead (see :func:_announce_unverified_publication).

#_member_probe_spec

python
def _member_probe_spec(name, project_dir, workspace_root, state_path, log)

One member's (label, resolved_targets, version, tag, ctx) probe spec.

Built from the member's release state BEFORE pass 2 runs, because a member that finishes clears its state file -- by the time the batch reaches its watch tail there is nothing left to read the version and targets out of.

Targets are resolved through :func:resolve_member_context, the same single source of truth the release flow itself uses, so releasable-level publish_mode / targets inheritance applies exactly as it did during the release.

Returns None when no spec can be built: the state carries no version (an item that never entered the mutating phase), or the member's config cannot be resolved into targets. The second case is SAID OUT LOUD rather than swallowed, and never converted into a verdict -- the same stance :func:~rlsbl.commands.release.execute._probe_publication takes on an inconclusive probe. Raising instead would turn a finished, tagged, released batch into a crash over the bookkeeping for a check that has not run yet.

#_publish_batch_candidate

python
def _publish_batch_candidate(workspace_root, pending, flags, log, *, pin_sha=None, trail=())

Guard the tip, then publish the whole batch as ONE candidate push.

Pass 1 runs every member with ci-defer, which COMMITS but never pushes, so by the time this runs the branch tip carries every member's release commits and a single push publishes all of them together.

That single push is what makes the batch verifiable at all. The generated CI router filters paths against the push's own before-SHA, so a push per member carries a one-project diff: on the commit every tag would point at, all the other members' CI jobs conclude skipped, and their publish gates refuse a skipped check (correctly -- it proves nothing about that commit). One push whose diff spans every participating project triggers every project's CI job on exactly the commit all the tags land on.

Refuse-on-drift runs FIRST, so a ride-in is never published: the per-member guards only cover each member's own call, and a commit that landed between the last member and this push -- a concurrent session sharing the worktree, say -- would otherwise ship under every tag in the batch. It is a hard error naming the SHAs; nothing is rolled back and nothing has been pushed.

The empty-candidate-window guard then runs ONCE PER MEMBER, not against the union of the members' router filter patterns. The union would pass the moment ONE member's paths were touched, which is precisely the half-skipped batch this whole two-pass design exists to prevent: the batch CI gate applies each member's OWN publish-gate filter, so a member the window misses concludes skipped and blocks the entire batch after the full CI wait. Per-member reuses :func:_guard_empty_candidate_window verbatim -- the same call the single-release path makes, with that member's own state file, monorepo name, releasable and version -- and refuses from the diff, before the wait.

Returns the published SHA: the commit the CI gate verifies and every member's tag lands on.

#_batch_ci_gate

python
def _batch_ci_gate(workspace_root, flags, log, sha, pending)

Run ONE CI gate for the whole batch on the published candidate sha.

The batch's commits were published by a single push (:func:_publish_batch_candidate), so one CI verdict covers every member and each is then tagged on exactly this commit -- a commit whose CI genuinely ran for every participating project.

"Genuinely ran" is enforced, not assumed: the gate applies every participating project's OWN publish-gate check filter (:func:rlsbl.ci_checks.workspace_check_filters) to the candidate, so a member whose router job concluded skipped blocks the whole batch instead of being tagged for a version its publish gate will refuse.

Returns the verified SHA. Raises :class:ReleaseValidationError when CI is red, unresolved or unreachable -- the candidate stays on the branch untagged, nothing is finalized, and no version number is burnt.

#_resume_batch_item

python
def _resume_batch_item(name, project_dir, workspace_root, state_path, flags, verified_sha, log)

Second batch pass: finish one member on the verified candidate.

#read_state_for_resume

python
def read_state_for_resume(state_path)

#_releasable_release_order

python
def _releasable_release_order(batch_names, releasables, projects, graph)

Compute release order for releasables based on member topological positions.

A releasable's position is the maximum topological position of its member packages. This ensures that a releasable whose members depend on members of another releasable is released after the dependency.

Returns an ordered list of releasable names from the batch.

#_cmd_batch_release

python
def _cmd_batch_release(flags, project_root)

Execute a batch release of multiple monorepo packages or releasables.

#_resolve_fresh_plan

python
def _resolve_fresh_plan(workspace_root, batch_config, projects, plan_path, log)

Compute, persist, and commit a fresh resolved plan.

Called by the mode functions after item membership has been validated. A plan-less batch whose target tags already exist predates resolved-plan tracking -- that is a hard error (no guessing about partial legacy runs). Returns the BatchPlan.

#_abort_legacy_plan_less_batch

python
def _abort_legacy_plan_less_batch(batch_path, plan_path, detail)

Hard error for a plan-less batch file that was already (partly) released.

A batch unreleased.toml with no plan sidecar whose target tags already exist predates resolved-plan tracking: it was partially executed by an older rlsbl that never persisted base versions, so there is no safe way to decide which items still need releasing. Refuse to guess.

#_batch_release_releasables

python
def _batch_release_releasables(flags, workspace_root, batch_path, batch_config, projects, plan=None, plan_path=None)

Execute batch release in releasable mode.

#_batch_release_packages

python
def _batch_release_packages(flags, workspace_root, batch_path, batch_config, projects, plan=None, plan_path=None)

Execute batch release in package mode (implicit, original behavior).

#_abort_on_completed_plan

python
def _abort_on_completed_plan(plan, plan_path, batch_path, workspace_root, projects)

Every item in the resolved plan is already released: stop, nonzero.

This path used to archive the batch file AND the plan and return 0. Two things were wrong with that. It reported success for a run that released nothing. And because a plan is matched against a batch file by item set and bump intent only (base versions legitimately drift mid-resume, so they are deliberately not compared), a FRESH unreleased.toml written for the next batch validates against the stale plan -- and got renamed into the archive without a single release happening.

So the batch file is never touched here; it belongs to the operator. Only the plan sidecar is archived, which is exactly what unblocks the next run: with the stale plan gone, a re-run resolves a fresh plan against the surviving batch file.

Never returns.

#_absorb_member_exit_zero

python
def _absorb_member_exit_zero(name, project_dir, state_path, pending, *, dry_run, kind)

Interpret a SystemExit(0) escaping a member's release call.

A zero exit unwinds past the batch's own bookkeeping, so neither released nor pending learns about the member. The only reading under which that is safe is an in-progress state file: the member's release really is mid-flight and pass 2 finishes it on the verified candidate. With no state file the batch has NO evidence about what the member did -- it may have released, may have done nothing -- and continuing would either strand it or announce a completion nobody can substantiate. That is a hard error.

#_announce_batch_completion

python
def _announce_batch_completion(released, log, *, kind)

Print the completion line, or refuse when nothing was released.

An empty released list means the run produced no release at all -- every item was skipped as already-released, or a member vanished without accounting for itself. Announcing "Batch release complete" there is the same silent-success lie the exit-code family exists to kill.

#_plan_item_state_paths

python
def _plan_item_state_paths(plan, workspace_root, projects)

Yield (name, state_path) for every resolvable plan item.

One place decides where a plan item's release state lives, so the archive gate, the completed-plan abort and the pass-1 resume seeding all address the same file.

#_plan_items_in_progress

python
def _plan_items_in_progress(plan, workspace_root, projects)

Return the names of plan items with an on-disk in-progress.json.

A per-item release state file means that item's release did not run to completion (it crashed mid-flight or is mid-resume). A successful release clears its state file, so a lingering one is evidence of an unfinished release that the batch archive must not strand.

#_partition_stranded_items

python
def _partition_stranded_items(plan, workspace_root, projects)

Split the plan's in-progress items into batch-resumable and CI-sealed.

Returns (resumable, sealed): a name -> state_path mapping of items this batch can finish itself, and a list of names it must refuse.

The split is the CI gate, for the same reason a single release's resume splits there. Before CI_VERIFIED a member is just committed work on the branch: folding it into the batch candidate is exactly right -- one push publishes it with everyone else and one CI verdict covers them all. After CI_VERIFIED the release is SEALED to the commit CI judged; the tag and the CI-SHA marker address that commit, so re-gating it on a new batch candidate would move the ground under a verdict that already exists.

#_abort_on_sealed_items

python
def _abort_on_sealed_items(sealed, kind)

Refuse a batch that would have to re-gate a CI-verified member.

#_seed_stranded_pending

python
def _seed_stranded_pending(plan, workspace_root, projects, dry_run, kind)

Resolve which plan items pass 1 must SKIP and hand straight to pass 2.

A batch that died between its pass-1 commits and its pass-2 completion leaves every member holding an in-progress.json. Handing such a member back to release run is a guaranteed refusal -- that command's whole contract is that an in-progress release must be resumed, not restarted -- so the batch used to die on the state it had created itself, with no way to finish the group as a group.

Returns a name -> state_path mapping. Never returns for a sealed member.

#_archive_batch_if_complete

python
def _archive_batch_if_complete(batch_path, plan, workspace_root, projects, log, *, flags=None)

State-driven archive gate: finalize iff every plan item is released.

This is the idempotent guard around :func:_finalize_batch_file, and it runs at the batch-loop tail only: the batch file is consumed by the run that actually completes the batch, never by a later run that finds an already-complete plan (see :func:_abort_on_completed_plan -- that path must not consume an operator's fresh batch file). When no plan exists (dry-run never reaches here), it is a no-op.

Beyond the released-per-plan check, it refuses to archive when ANY plan item still has an in-progress.json on disk -- an unfinished/resumable release must never be silently archived away.

The finalize commit is then PUBLISHED (:func:_push_finalized_batch_file). Owning the push here rather than inside the file helper keeps the helper a pure rename-and-commit, and makes the push a visible step of the flow that owns batch completion -- so a failure is reported as an unresolved step with a remedy instead of as a failure of a release that already shipped.

#_announce_unpushed_finalize

python
def _announce_unpushed_finalize(exc, workspace_root)

Report the finalize push as an unresolved step, with its remedy.

Everything the batch releases is already tagged, published and on the remote by the time the archive gate runs, so a failure here is not a failed release and must not be reported as one -- re-running or resuming would find nothing to do. What it IS is the branch left one commit ahead of its remote, which the next release's preflight will meet. So it is stated out loud, on stderr (--quiet cannot swallow it), naming the one command that resolves it.

#_push_finalized_batch_file

python
def _push_finalized_batch_file(workspace_root, flags, log, *, pin_sha)

Publish the batch's own finalize commit -- the batch's last push.

Every other commit a batch creates reaches the remote: pass 1's members ride the single candidate push, and pass 2's per-member finalization is pushed by each member's own release. The archive commit is created after all of it, so without this step every repository that completed a batch stayed one commit ahead of its remote forever, and the remote's history was missing the document describing the release that just shipped.

Range-pinned like every other release push: the window pin_sha..HEAD must contain exactly the commit the finalize step created. A commit that is not the batch's own (a concurrent session sharing the worktree) is named and refused -- a release push never carries unreviewed work -- and an empty window (the batch file was already archived, or the commit failed) means there is nothing to publish.

push_if_needed is late-bound through the release package namespace, exactly like the candidate push, so one mock covers every push in a batch.

#_finalize_batch_file

python
def _finalize_batch_file(batch_path, log)

Rename the batch release file (and its plan sidecar) and lock them.

Idempotent: a no-op when the batch file has already been archived. The plan sidecar, when present, is archived alongside under the same timestamped stem so the two always travel together.

Search