030 — A release branch is opened on request and a lone story merges straight to main (#182)

Status: in progress

Why

At code level 1 every story goes through release: tp release on the story branch cuts release from the base when the remote has none and opens the story's pull request into it; tp release on the base merges the stories, makes the release commits and opens a second pull request from release to the base. A release of one story is two pull requests and two merges where one would do. ADR 0006 recorded that as the price of a single process. The release branch earns its place only when there is more than one story to collect. The implicit cut is also where the forge-lag trouble of #174 came from.

The cost should follow the shape of the release, and every state the new shape can reach must have a tp release step out of it: a lone release in flight that turns out to need a second story is converted into a batch by tp, not waited on or closed by hand.

Stories in the release Pull requests Merges
1 1 (story → main) 1
n ≥ 2 n (story → release) + 1 (release → main) n + 1

Vocabulary

Term Meaning
lone release a story pull request into the base branch carrying its release commits; no release on the remote
batch release on the remote; stories' pull requests into it; one release pull request from it
release commits what the batch run commits on release today: the fragments gathered into the CHANGELOG and removed, the README markers, the version
conversion turning a lone release into a batch: cut release, undo the lone story's release commits, retarget its pull request

What changes

  • tp release on a story branch, no release on the remote, no lone release in flight: runs the checks, makes the release commits on the story branch (the version follows from its fragments), pushes, opens the pull request into the base branch. Nothing is cut. merge_wait and the tag run on the base work as now; the tag run reads the version from the CHANGELOG's top entry.
  • tp release again on that branch after more commits: with no new fragment the release commits stand and the run goes on to push and the pull request; with one, the release commits are redone the way a conversion undoes them (reverted in one Unprepare commit, then made again for the fragments now on the branch), so one mechanism serves both. No rewrite of history.
  • tp release on a story branch while a lone release is in flight: the second story makes it a batch, so the run performs the conversion, prints each step, then continues as a batch story (push, pull request into release).
  • tp release batch (new): on the base branch, opens a batch: cuts release from the remote base and pushes it; with a lone release in flight, performs the conversion first. Idempotent: with release already open it reports the stories in it and does nothing.
  • Conversion, step by step, each step skipped when its state already holds, so a rerun after a failure resumes: (1) cut release from the remote base; (2) on the lone story's branch, push a revert of its release commits, found as the commits that removed the story's fragment and the ones tp made with it, so the fragment is back and the CHANGELOG as on main (forward-only: no force push, other checkouts fast-forward); (3) retarget the open pull request to release (Forgejo PATCH /pulls/{n} base; GitLab PUT target_branch, new in internal/forge).
  • tp release on the lone story's branch after a conversion done from another checkout: fast-forwards the local branch to the remote, then runs as a batch story (its pull request is already open into release).
  • tp release on the base branch with release on the remote: as now.
  • tp release query: the next-step rows below; no row says wait.
  • docs/release-workflow.md, docs/maturity.md (the level 1 line), README; an ADR amending 0005 and 0006.

Acceptance

Checked out release on remote Lone release in flight Command Outcome
story/12-x no none tp release checks; release commits on the branch; push; pull request into main; merge_wait as now
story/12-x no none; its own pull request open, new commits, no new fragment tp release checks; release commits stand; push; "pull request #41 is open"
story/12-x no none; its own pull request open, a new fragment tp release release commits reverted (Unprepare v0.2.1) and remade for the new version; checks; push
story/12-x no none; .tp-release.yml names #11 too tp release a batch was asked for: release cut, then as a batch story (the walk of story 023 stands)
story/12-x no #40 from story/11-y tp release conversion (cut, revert on story/11-y, #40 → release), each step printed; then push and pull request into release
story/12-x yes — tp release as now: pull request into release
story/11-y yes its own #40, now into release, remote ahead by the revert tp release fast-forwards; "pull request #40 is open into release"; walk as now
main no none; a story merged, untagged tp release tags and publishes, as now; version from the CHANGELOG's top entry
main no none tp release batch release cut from origin/main and pushed; "stories now go into release: tp release on each story branch"
main no #40 tp release batch conversion, each step printed; then as the row above
main yes — tp release batch "release is already open on origin" and the stories in it; nothing done
main yes — tp release as now: the batch
Conversion step State found Step
cut release absent on the remote pushed from origin/main
cut release present skipped
revert story's fragment absent on the remote tip revert pushed
revert story's fragment present skipped
retarget pull request base main set to release
retarget pull request base release skipped
any forge or push fails error naming the step; the rerun resumes from the table
Level Checked out State tp release query next step Who
batch story/<n>-<slug> no release; fragment present; not pushed or no pull request tp release on the branch: release commits, push, pull request into main agent
batch story/<n>-<slug> its pull request into main open review and merge it human
batch story/<n>-<slug> another story's pull request into main open tp release on the branch: converts the lone release into a batch and joins it agent
batch story/<n>-<slug> remote ahead by a conversion's revert tp release on the branch: fast-forwards and walks on agent
batch main no release; a story's pull request into main open merge it, then tp release on main human
batch main no release; two or more story branches ready tp release batch, then tp release on each story branch agent

Out of scope

  • Rewriting a story branch (force push) to drop its release commits: the revert keeps every checkout fast-forwardable.
  • Converting a batch back into a lone release when a story is dropped from it: a batch of one ships as a batch (two merges), which is the long path, not a gap.
  • Taking .tp-release.yml as the only request to open a batch: it is local and gitignored, and the agent on a story branch needs the shared fact, which is the remote release branch. A file naming another story does count as a request, since the walk already relies on it.
  • Code level 2 (#51).

Decisions

Three forks, resolved in ADR 0007, which amends 0005 and 0006.

Where the lone story's release commits live. main refuses direct pushes, so the CHANGELOG, the fragment's removal and the version have to arrive by a merge.

Option Complexity Testability Domain clarity Operational risk
A. On the story branch, made by tp release there before its pull request the ADR 0002 path returns for the one-story case, plus the conversion real git in a temp dir, as the batch tests; a fake forge for the retarget a story's pull request is the release two stories racing for main conflict on the CHANGELOG — which the conversion resolves
B. On a release cut by tp release on main after the story merges none new; defers the cut as now the release commit is always reviewed on its own two merges for one story: no gain
C. Pushed straight to main by tp with an admin bypass of the protection a bypass per forge needs a forge protection becomes "except tp"; release loses its reason to exist a different ladder, not this story

Recommend A: the only option that meets the cost table.

How the release commits are undone in a conversion.

Option Complexity Testability Domain clarity Operational risk
R. A revert commit pushed on the story branch one git operation real git the branch's history says what happened none: forward-only, every checkout fast-forwards
F. Reset the remote branch below the release commits with --force-with-lease lease handling; the lone story's own checkout must detect the rewrite real git clean history a checkout that pushed meanwhile loses the lease; a non-tp pull diverges
K. Keep them; the batch run folds the untagged CHANGELOG entry back into fragments the inverse of gather parsing tests a version that never ships sits in the story's pull request the fold must understand every CHANGELOG shape

Recommend R: it is the one with no failure state of its own.

How the batch is requested. tp release batch as a subcommand beside tp release query, and implicitly by the second story's tp release, since two stories ready is the definition of a batch. The alternative, a --batch flag on the first story's run, hides a shared decision in one story's run and still needs the implicit path for the second story. Recommend the subcommand plus the implicit path.

Done when

  • tests express the acceptance tables and pass
  • spec updated (release-workflow.md, maturity.md)
  • ADR written (0007, amends 0005, 0006)
  • roadmap ticked and stamped; changelog entry
  • issue closed with a link to the release