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

The Phase-A seam: a builder that hoists every read into one ordered, data-only plan, and an executor that issues each step's declared effect.

#rlsbl.commands.release.phase_a

#rlsbl.commands.release.phase_a

Phase A of a release: a plan the builder derives, and an executor that issues it.

Phase A is everything from VERSION_BUMPED through BRANCH_PUSHED -- the half of a release that happens before CI has judged anything, and therefore the half a preview can show in full. It is split in two:

  • :func:build_phase_a_plan -- the BUILDER. It reads (git, the working tree,

the target manifests, the release-state file), derives every operand, decides every idempotency skip, and returns a :class:PhaseAPlan: an ordered list of typed :class:PlanStep records whose payloads are plain data.

  • :func:execute_phase_a_plan -- the EXECUTOR. It walks the plan and issues

the declared effect for each step. It derives nothing and asks nothing: every value it needs is either already in the plan or arrives through a DECLARED result capture (below).

Why the split -------------

Under --dry-run every mutation is recorded on ctx.effects rather than performed, and -- by the contract -- an observe issued after the first recorded mutation returns the framework's stale carrier instead of a real answer. The old Phase A interleaved reads and writes freely (git status after the version bump, git log -1 before the commit, git rev-parse HEAD after it, git ls-remote before the push), so the very first recorded write poisoned every question that followed. That is why release run --dry-run used to print an empty would-do body: there was no arrangement of those calls that could survive its own preview.

Hoisting all ~20 of those reads into the builder -- which runs before anything is recorded -- removes the problem at the root rather than branching around it. The executor then issues a linear sequence of effects that record cleanly, so the preview walks Phase A end to end and stops exactly where the release itself stops being knowable: at the candidate push, whose verdict CI has not given yet.

Value threading ---------------

Exactly one value crosses the seam: candidate_sha, the commit the release publishes as its candidate. The commit step PRODUCES it; the state-save step, the candidate-window guard and the push step CONSUME it, as :class:StepRef placeholders the builder plants in their payloads. Four threads, one producer and three consumers, and the executor resolves them through :meth:_Executor._resolve.

Every consumer declares itself in its payload, so :meth:PhaseAPlan.consumers_of is the whole truth about who reads the value -- a handler that reached into self._values directly would thread the value without appearing in the plan, and the plan is what the preview renders.

A :class:StepRef is resolved from a per-step DECLARED result capture: an observe the builder names alongside the step (["git", "rev-parse", "HEAD"] for the commit). In live mode the executor runs it and threads the real value. In preview mode it runs NOTHING and threads the carrier the step's own recorded effect returned, so the push renders as git push ... «step N output» -- the framework's own brand for "the output of the step above".

Result captures are the executor's SOLE permitted reads. tests/ test_release_phase_a_seam.py scans this module and fails if any other read appears in it.

Rollback is NOT a plan step ---------------------------

A step that fails does not schedule its own undo. Phase-A execution runs inside the caller's existing revert handler (git reset --hard to the pre-release pin plus orphan-artifact cleanup), which is an executor-level concern and stays exactly where it was. A preview executes nothing and so needs no rollback at all.

#StepRef

A symbolic placeholder for a value a not-yet-executed step will yield.

name is the threaded value's name (only :data:CANDIDATE_SHA today). template is text with :data:SLOT marking where the resolved value goes -- the push step wants <sha>:refs/heads/main, not a bare SHA, and the state-save step wants a whole JSON document with the SHA inside it. In preview mode the template is NOT applied: the operand forwarded into the effect is the framework's carrier, which renders as its own brand.

#render

python
def render(self)

How this reference reads in the plan table.

#PlanStep

One typed, data-only record of work Phase A will do.

payload carries plain operands (paths, argv, content) and may contain :class:StepRef placeholders. capture is the step's DECLARED result capture: (name, argv, cwd), an observe the executor runs in live mode to resolve the value this step produces. summary is what the preview's plan table shows.

#PhaseAPlan

The ordered plan for one release's Phase A, plus what Phase B needs.

#produced_names

python
def produced_names(self)

Names of the values this plan's steps produce.

#consumers_of

python
def consumers_of(self, name)

Steps whose payload references the threaded value name.

#_git_answer

python
def _git_answer(argv, *, cwd)

A read-only git answer, or None when the builder cannot get one.

The builder runs AFTER the preflight -- deliberately: the preflight is not part of the plan, it executes its observes and records its mutations exactly as it always has, and its recorded effects render first. But that means a preview reaches the builder with mutations already recorded, and from that point the framework answers every observe with a stale carrier rather than a fact.

None is that case, and every call site here declares what it assumes when it cannot get an answer. The assumption is always the SAME one: that the release does the full piece of work rather than skipping it. A preview therefore shows the whole plan, and idempotency skips -- "the version is already bumped", "the remote is already at the candidate" -- stay what they have always been: live-mode facts about a release already partly done.

#_predicted_version_files

python
def _predicted_version_files(target, path)

The version files a target's writer is expected to touch.

A LOWER BOUND, deliberately: only the target adapter knows the full set (pypi also rewrites __version__ in the package source, and which file that is depends on the project layout). The builder predicts what it can so the plan table and the concurrent-change guard's expected set are right before anything is written; the executor widens both with the paths the writer actually reports (see :meth:_Executor._do_write_target_version).

#_refs_in

python
def _refs_in(payload)

Every threaded-value name referenced anywhere in a payload.

#render_plan_table

python
def render_plan_table(plan, *, indent='  ')

Render a Phase-A plan as a table, one line per step.

#_render_operand

python
def _render_operand(value)

Render a payload operand for a plan summary.

#BuildInputs

Everything the builder reads from, resolved by the caller.

A plain record rather than a pile of keyword arguments: the builder's whole contract is "derive the plan from these inputs and the state of the world", and naming the inputs in one place is what makes that contract checkable.

#build_phase_a_plan

python
def build_phase_a_plan(inp: BuildInputs) -> PhaseAPlan

Derive the Phase-A plan. Reads freely; mutates nothing.

Every read the old inline Phase A interleaved with its writes happens here, before the first effect is issued -- which is what lets a preview record the whole of Phase A instead of truncating at the first question asked after a recorded mutation.

#_retarget_candidate

python
def _retarget_candidate(steps)

Move the candidate_sha production onto the LAST commit step.

The snapshot commit, when there is one, lands after the release commit -- so it, not the release commit, is the branch tip the push publishes and CI judges. Exactly one step ever produces the value.

Returns True when the plan creates a commit at all.

#_expected_dirty_files

python
def _expected_dirty_files(inp, files_to_commit)

Paths the release itself is allowed to have dirtied, git-relative.

#_commit_is_needed

python
def _commit_is_needed(inp, files_to_commit, log)

Decide -- by reading -- whether Phase A still owes a release commit.

#_snapshot_step

python
def _snapshot_step(inp, log)

The monorepo-snapshot step, or None when the slot is not owed.

#_candidate_push_plan

python
def _candidate_push_plan(inp, *, plan_commits)

Derive the candidate push: whether it is owed, its argv, its window.

Every question here is asked BEFORE the plan is issued -- what the remote branch points at, whether it exists at all, what the local tip is -- so the executor's push is a single recorded effect with no probing around it.

#execute_phase_a_plan

python
def execute_phase_a_plan(plan, inp, *, preview)

Issue every step of plan. Derives nothing; reads only through captures.

Returns the resolved candidate_sha (a real SHA in live mode, the framework's carrier in preview mode, or None when the plan defers the push).

Rollback is deliberately absent: a failing step raises, and the caller's existing revert handler -- git reset --hard to the pre-release pin plus orphan-artifact cleanup -- owns the undo. A preview executed nothing and needs none.

#_Executor

Walks a :class:PhaseAPlan and issues each step's declared effect.

#_capture

python
def _capture(self, step)

Run a step's DECLARED result-capture observe and return its output.

The executor's only read OF THE WORLD, and it exists because a step's own result -- the SHA a commit creates, the porcelain a guard judges -- cannot be known before the step runs. In preview mode nothing is captured: the step issued a recorded effect, not a real one, so the honest stand-in is the carrier that effect returned (see :meth:_resolve).

#_resolve

python
def _resolve(self, operand)

Resolve a payload operand, threading :class:StepRef placeholders.

#run

python
def run(self)

#_settle

python
def _settle(self, step, issued)

Resolve a step's declared capture and thread whatever it produced.

Guard steps run their own capture inside their handler (the guard IS the reading), so they are settled there and skipped here.

#_issue

python
def _issue(self, step)

Issue one step's effect. Returns the carrier/result it produced.

#_record_written

python
def _record_written(self, base_path, relatives)

Remember the files a writer reported touching, git-root-relative.

#_do_record_candidate

python
def _do_record_candidate(self, step)

Write the candidate SHA into the release-state file.

The document is assembled as a TEMPLATE with :data:SLOT where the SHA goes, so the SHA reaches the write as a single operand. Under a preview that operand is the commit step's carrier, and the whole document is recorded as write: <state file> («step N output») -- a write that names the value it is waiting on instead of inventing one.

The load_release_state below is a LEDGER read, not a question about the world (see the note above :meth:_capture). It has to happen here rather than in the builder: run calls save_step for every marker as the walk goes, so the document on disk at this moment already carries markers that did not exist when the plan was built -- and writing back a build-time snapshot would erase them.

#_do_push_candidate

python
def _do_push_candidate(self, step)

Publish the candidate to the release branch.

The one step where the StepRef seam is visible from both sides. With a settled SHA the push goes through :func:~rlsbl.utils.push_if_needed, which keeps its own idempotent skip (a remote already at the candidate is not pushed again). With the carrier -- a preview, where the commit that would produce the SHA was recorded rather than made -- there is nothing to compare it against, so the push is issued straight onto the chokepoint with the carrier as its own argv operand. It records as git push --no-verify origin «step N output»: the push of a commit that does not exist, named by the step that would have created it.

Search