005 — tp release through a pull request when main is protected (#26)

Status: shipped (v0.4.0)

Why

tp release makes one commit on main (the rolled changelog, README markers, the WASM build where configured), tags it and pushes. A project at code-flow level 1 of the maturity ladder protects main against direct pushes, so the push is refused and the release rolls back. gobank hit this on its first protected release (2026-10-03). Whitelisting the releasing account for pushes would get the release through, but it makes the release the one commit on main nobody reviews, and the project wants tp release to stay the single way to ship while a review that has been switched on still happens.

What changes

  • tp release decides between two modes in Gather: direct (today's flow) when main on the release remote is not protected, pull request when it is (Forgejo API: the branch's protected flag) or when task-plus.yml declares maturity.code of 1 or more.
  • Pull request mode, first run: Execute steps 1 to 6 (dirty-tree commit, tag check, version update, CHANGELOG commit, WASM build) happen on a new branch release/vX.Y.Z cut from main; no tag is made. The branch is pushed and a pull request "Release vX.Y.Z" is opened against main, its body the version's CHANGELOG section and the release comment. The run prints the PR URL and stops with exit 0: merge it, then run tp release again.
  • Pull request mode, second run: tp release on main looks for the release PR by head branch. Merged: fetch, fast-forward main, confirm the CHANGELOG's top heading is the version and no tag exists, tag main HEAD, then steps 7 to 13 as today (push tag, mirrors, goreleaser, cleanup, install, docs deploy, post_release) and delete the release branch if the forge has not. Still open: print the URL, stop. Closed without merging: error.
  • Rollback: before the PR is opened, delete the release branch locally and remotely. After it is opened nothing on main has changed, so there is nothing to roll back; the PR is the human's to merge or close.
  • Only the tag is ever pushed directly; Forgejo branch protection does not cover tags. A tag protection rule on v*, if the project adds one, is outside this story.
  • docs/release-workflow.md gains a "Pull request mode" section with the two-run sequence; README command table unchanged.

Acceptance

main protected or maturity.code ≥ 1 release PR for the version tp release outcome
no — today's direct flow, unchanged
yes none release/vX.Y.Z created with the release commit, pushed, PR opened; URL printed; exit 0; no tag; main untouched
yes open URL printed; exit 0; nothing changed
yes merged, tag absent main fast-forwarded, tagged at HEAD, tag pushed, steps 7–13 run, release branch deleted
yes merged, tag present error "vX.Y.Z already released"; nothing changed
yes closed without merge error naming the PR; suggests deleting the branch to start again
yes, release remote is not Forgejo — error "pull request mode needs a Forgejo release remote"; nothing changed

Resuming after a failure in pull request mode:

Failure point State left Next tp release
before the branch is pushed nothing remote; local branch deleted starts afresh
branch pushed, PR not opened remote branch exists opens the PR for the existing branch
after tagging, during steps 8–13 tag on main as today: those steps are retried by hand or the release is complete as far as the tag

Out of scope

  • Merging the PR itself. Self-merge would make the review a formality; the human merges in the forge.
  • Waiting for CI on the PR (level 2); the second run simply finds the PR merged or not.
  • GitHub or GitLab release remotes in pull request mode.
  • Tag protection rules.
  • The level 2 "tag-only" release shape, where the version heading lands in the story's own PR and a release is only a tag. Recorded as a later option on the roadmap.

Decisions

  • ADR 0001: two runs rather than one run that polls for the merge: a release can wait hours for a reviewer, and a process that must stay alive across that is fragile; the branch and the PR are the whole state, so the second run needs nothing local.
  • The second run finds its work on the forge, not from a chosen version: a merged release PR whose version has no tag. So "merged, tag present" is not an error at the start of a run (that is just the next release); it is an error only when that version is chosen again.
  • Mode is detected from the forge, not only from maturity.code, so a protected main is respected even by a project that has not declared its level.

Done when

  • tests express the acceptance table and pass
  • spec updated (docs/release-workflow.md)
  • ADR written if a fork was resolved (ADR 0001)
  • roadmap ticked and stamped; changelog entry
  • issue closed with a link to the release (v0.4.0)