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 releaseon a story branch, noreleaseon 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_waitand the tag run on the base work as now; the tag run reads the version from the CHANGELOG's top entry.tp releaseagain 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 oneUnpreparecommit, then made again for the fragments now on the branch), so one mechanism serves both. No rewrite of history.tp releaseon 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 intorelease).tp release batch(new): on the base branch, opens a batch: cutsreleasefrom the remote base and pushes it; with a lone release in flight, performs the conversion first. Idempotent: withreleasealready 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
releasefrom 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 onmain(forward-only: no force push, other checkouts fast-forward); (3) retarget the open pull request torelease(ForgejoPATCH /pulls/{n}base; GitLabPUTtarget_branch, new ininternal/forge). tp releaseon 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 intorelease).tp releaseon the base branch withreleaseon 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.ymlas 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 remotereleasebranch. 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