Release Workflow

task-plus automates the release process for Go projects. The workflow runs through five phases: Precheck, Gather, Ask, Check, and Execute. Every run ends with === Timings ===: the elapsed time of each phase and of each publishing step within Execute (Execute: goreleaser, Execute: deploy docs, …), then the total, so the slow steps are measured before they are tuned (#76).

Where the release stands

maturity.code picks the process: 0 trunk, 1 and up a batch (ADR 0006). tp release query reports the level, the one next step with who takes it, and what the checkout is not ready for (#108). An agent takes the steps that publish nothing; a human merges and runs anything that tags.

Level Checked out State Next step Who
trunk main commits since the last tag tp release on main: commit, tag, push human
trunk a story branch — merge it into main and tp release there, or tp release --merge human
batch story/<n>-<slug> no changes/<n>.<kind>.md and no release commits add the fragment agent
batch story/<n>-<slug> no release on the remote; not pushed, or no pull request tp release on the branch: makes the release commits, pushes it, opens its pull request into main (a lone release, story 030) agent
batch story/<n>-<slug> no release; another story's pull request into main open tp release on the branch: converts that lone release into a batch and joins it agent
batch story/<n>-<slug> behind its remote copy (a conversion's revert) tp release on the branch: fast-forwards it and walks on agent
batch story/<n>-<slug> release on the remote; not pushed, or no pull request into release tp release on the branch: pushes it, opens the PR agent
batch story/<n>-<slug> its PR open, into main or release review and merge it human
batch story/<n>-<slug> merged into release tp release on main human
batch main no release; a lone release's pull request 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
batch main .tp-release.yml lists stories tp release: merges them into release, checking each, makes the release commit, opens the release PR human
batch main stories in release, nothing listed tp release: the batch is those stories; makes the release commit, opens the release PR human
batch main PRs into release open, nothing listed merge or close them, or list the batch in .tp-release.yml; then tp release human
batch main release PR open merge it, then tp release on main (a run still waiting tags it itself) human
batch main merged into main, untagged tp release: tags and publishes human

Not ready, as query lists it:

  • any level: a dirty tree;
  • trunk: a base branch the forge protects;
  • batch: a hand-edited [Unreleased], a .tp-release-comment, a story branch not named story/<n>-<slug> or without its fragment (or, for a lone release, its release commits), commits not pushed.

A batch release, state by state

The sections below give each phase and process in detail.

Flowchart

flowchart TD
    A[Start] --> B[Precheck]
    B --> C

    subgraph Gather [Phase 2: Gather]
        C[Refuse a dirty tree; read tags, fork info]
    end

    C --> D

    subgraph Ask [Phase 3: Ask]
        F[Show the plan with every default]
        F --> F2{Proceed? Y/n/e}
        F2 -->|e| F3[Walk version, comment, push, goreleaser, install, deploy]
        F3 --> F
        F2 -->|n| F4[Nothing done]
    end

    F2 -->|Y| G

    subgraph Check [Phase 4: Check]
        G[Run fmt, vet, test]
    end

    G --> H

    subgraph Execute [Phase 5: Execute]
        H[Update CHANGELOG + commit] --> I[Git Tag]
        I --> J{WASM?}
        J -->|Yes| K[Build WASM + Re-tag]
        J -->|No| L[Git Push]
        K --> L
        L --> M{Binary?}
        M -->|Yes| N[Goreleaser]
        M -->|No| O[Cleanup]
        N --> O
        O --> P{Fork?}
        P -->|Yes| Q[Skip Install]
        P -->|No| R[Install]
        Q --> T{Deploy Docs?}
        R --> T
        T -->|Yes| U[Build + Deploy Docs]
        T -->|No| V{post_release?}
        U --> V
        V -->|Yes| W[Run post_release commands]
        V -->|No| S[Done]
        W --> S
    end

Phases

1. Precheck

Runs any configured precheck commands (e.g. task precheck). Fails fast before user interaction.

2. Gather

Read-only state probing:

  • Working tree: any change stops the run with "working tree is dirty; commit, stash or ignore before releasing" and the git status, in every mode and in --dry-run (story 013, #35). The release commits only what its own steps change. .tp-release-comment does not count.
  • Existing tags and retracted versions
  • Suggested next version (latest tag bumped per release_bump: major, minor or patch; minor by default; skips retracted versions)
  • Fork detection (go.mod vs git remote)
  • Goreleaser config detection
  • Forge detection (GitHub/GitLab/Forgejo) and release cleanup candidates
  • Release process: trunk or batch, by maturity.code (see below). In a batch the release remote's API token must be present (#70): a missing one is an error here, naming the env var(s) consulted, and a token the forge rejects fails on the pull request listing, both before anything is committed or pushed. tp check reports a maturity.code of 1 or more with no token_env on the release remote (#69).
  • In a batch, .tp-release.yml and .tp-release-inflight must be gitignored (#133): the run writes them mid-release, and a commit on the base branch would otherwise carry them. A checkout that does not ignore them is an error naming the files, before anything is written; any ignore rule will do, .git/info/exclude included, so the fix can wait for the story that carries it. A base branch found diverged from the remote only by commits adding those files is named as such, with the git reset --keep recipe that drops the commits and keeps the files untracked, instead of the generic divergence error.

3. Ask

Plan first (story 020): every default is decided, the plan is printed, and one question is asked — Proceed? [Y/n/e]. Y (or Enter) runs the plan as shown; n ends the run with nothing done; e walks the questions one by one, each defaulting to its value in the plan, then shows the plan and asks again. --yes takes the plan without the prompt. The plan lists each publishing step that applies to the project as yes or no; steps fixed by config (install:, pypi: true) are shown and not asked.

The questions of the walk, and their defaults:

  • Version (suggested or custom)
  • Release comment. A batch takes it from the fragments. On trunk the default is --comment, else the summary bullet already opening [Unreleased] in the CHANGELOG, else .tp-release-comment (saved by tp wt from the merged branch's last commit). That file's first line names the branch it was written for (branch: story/75-x); a run on another branch, or a file with no branch line, ignores it and says so (#82). The file is removed only once the Execute phase has completed on its branch; an abort, a failed check, a rollback or --dry-run leave it for the next run. The comment becomes the - summary bullet that opens the version's CHANGELOG section; a bullet already there is replaced, not joined (#30).
  • Roadmap items whose stories have all shipped: count each done (story 017)
  • Push, goreleaser, PyPI, cleanup, install, deploy — each where it applies

4. Check

In a repo of more than one go.mod, first runs gofmt -l over the whole tree (not vendor/, testdata/ or hidden directories) and stops on any file it lists, since go fmt ./... does not reach a nested module (#107). Then runs configured check commands (default: task check which runs fmt, vet, test) on the tree that will be tagged, once per tree (#78): after the release commit, with TP_VERSION set to the version, so a Taskfile can build what it ships as VERSION: '{{.TP_VERSION}}'. On trunk that is between the release commit and the tag; in a batch, on release after the last story's merge and the release commit, before the push (earlier stories are checked after each merge). The batch run records the tree in .tp-release-inflight, and the tag run, after fast-forwarding the base branch, skips the checks when the merged tree is that tree; a base that moved is checked again. A failure rolls back to before the release commit. Then it validates what the post-push steps will need, so a release never fails after the tag is public:

  • Deploy targets reachable and the docs build passing; the deploy reuses that build when the tree is unchanged, rather than building again.
  • Goreleaser (story 010): .goreleaser.yaml publishes to the release remote — same forge, release.<forge>.owner/name matching the remote (or absent when the release remote is origin, which goreleaser infers from), gitea_urls.api/download on the remote's host for a Forgejo — and the token goreleaser will read is set (GITHUB_TOKEN, GITLAB_TOKEN, or GITEA_TOKEN/the remote's token_env for a Forgejo). Any finding aborts the run with the block to paste; the token message says when secrets_provider is not configured. tp check reports the same findings in its Goreleaser section. --dry-run runs the validation too.

5. Execute

All mutations in order:

  1. (Nothing to commit: the tree was clean in Gather; the checks of phase 4 run after step 4, before the tag)
  2. Tag existence check
  3. Version update task (if configured)
  4. Roadmap items done and stories shipping (story 017, below), then CHANGELOG update + auto-commit
  5. WASM build (if configured)
  6. Git tag
  7. Git push
  8. Mirror sync (if mirrors: configured)
  9. Goreleaser (binary projects)
  10. Release cleanup (old releases)
  11. Local install (go install)
  12. Documentation deployment (configured targets: GitHub Pages, statichost.eu, etc.)
  13. Post-release commands (post_release:)

Process by code level

maturity.code in task-plus.yml alone picks the process (ADR 0006, #109). The levels follow the manual's code-flow ladder: 0 trunk, 1 batch, 2 gated (batch plus CI, #51).

maturity.code Release remote Base branch protected tp release
0 / unset any no trunk: commit, tag and push on the base branch
0 / unset Forgejo, GitLab yes error: run tp maturity code 1, or unprotect the base; nothing written
0 / unset Forgejo, GitLab forge unreachable trunk, with a warning
1 or more Forgejo, GitLab any batch (below)
1 or more GitHub — error: needs a Forgejo or GitLab release remote

On GitLab the pull request is a merge request, opened and read through the GitLab API with the remote's token_env or, by default, GITLAB_TOKEN (story 019). tp's output says "pull request" either way. The base branch is the release remote's default branch (main unless the remote's HEAD says otherwise).

Trunk off the base branch (#68)

A trunk release is made where the run stands. On a story branch that would tag a commit the base branch cannot see, so the run stops before anything is written and says so; --merge instead fast-forwards the base branch to the story branch and releases from there. Only a fast-forward: a base branch that has moved on is the human's to reconcile first.

Checked-out branch Base branch --merge tp release
base — any trunk release as above
story — no stop in Gather: run tp maturity code 1, or re-run with --merge; nothing written
story fast-forwardable to the story (remote and local) yes base checked out and fast-forwarded, then the trunk release from it; the story branch stays
story has commits the story lacks yes error in Gather: reconcile first; nothing written

--dry-run reports the row that applies.

Waiting for the merge (story 008)

merge_wait in task-plus.yml (default 10m; tp --init writes it) keeps a batch run alive for the merge of the pull request it opened (ADR 0003). The run polls the forge every 15 s for up to merge_wait. On a terminal one status line is rewritten in place each second with the time left (PR #N open, 7m45s left (Ctrl-C stops waiting)) and cleared before the outcome; piped or in CI the wait prints one line at the start and one at the end.

During the wait for the release PR Then
merged fetch; check out the base branch; fast-forward; run check again; tag and publish
still open at the deadline says the release is still in flight (.tp-release-inflight) and that tp release on the base branch, once the PR is merged, tags it, closes the issues and deletes release (#133); exit 0
closed without merging says so, exit 0; nothing tagged
Ctrl-C stops waiting; the PR stays open; exit 0
the forge cannot be read retried until the deadline; the error is reported with the timeout
check fails on the merged base branch error naming the merged PR; fix, then tp release on the base branch tags it

The wait for a story's pull request into release is the walk (story 023, below). With merge_wait: 0 the run does not wait. --dry-run says it would wait and polls nothing.

What the release ships (story 017)

A batch run reads ROADMAP.md, the stories and the forge's issues (tp roadmap):

Found In the release commit After the tag
a roadmap item whose issue is closed moved into the version's ### Roadmap list in CHANGELOG.md —
an open roadmap item whose stories (**Roadmap:** #N) have all shipped or ship now asked "count it done?"; yes moves it as above its issue closed with the release link
a story with **Status:** ready and an issue status stamped shipped (vX.Y.Z) its issue closed with the release link
a fragment changes/n.* gathered into the CHANGELOG #n closed with the release link

Issues are closed after the tag rather than by Closes #n in the pull request, which would fire on merge, before the version exists.

What the tag run must finish is written to .tp-release-inflight (gitignored, never committed): the version, the branch, the pull request, the issues to close and the batch's story branches. The tag run (the same run when merge_wait sees the merge, or the next tp release on the base branch) closes each listed issue with a comment linking the release, deletes the release branch and the story branches (below), and removes the file. Until then tp check names what is in flight. A file for another version is left alone with a warning; without one the tag run says which issues it is not closing. --dry-run prints the moves and writes nothing. A trunk release skips all of this.

Every run ends by comparing the working tree with what its outcome should leave (#152), one Left behind: line per difference and no deletion; the next run's Gather is the recovery. A run that walks on to another branch leaves the report to the run on that branch; --dry-run has none.

Run ended HEAD on .tp-release-inflight .tp-release.yml tag
released base branch not this version's absent present
in flight (release PR opened) base branch present, this version — absent
failed the starting branch — — —
with nothing to do the starting branch — — —
any — a present file git would commit is named (see tp check, #106)

Release branch and fragments (story 021)

At code level 1 and up stories are released as a batch (ADR 0005). Each story carries its CHANGELOG line as a fragment, changes/<n>.<kind>.md for issue #n, with kind one of added, changed, deprecated, removed, fixed, security; roadmap is the kind of a retired roadmap item (tp roadmap archive at this level, #142), rendered as - #n text in the version's ### Roadmap list and counted as no story and no change. Optional front matter orders it after other stories:

---
after: [46]
---
Forget an environment from the perf form

A lone release, and the batch on request (story 030)

release exists only when a batch was asked for (ADR 0007). While the remote has none, a story is a lone release: tp release on its branch makes the release commits there (the fragments gathered under the version, the README markers, the fragment files deleted), runs the checks, pushes the branch and opens its pull request straight into the base, titled by the fragment, the version's CHANGELOG section as its body. The merge is the release: tp release on the base tags it, and with merge_wait the story run itself waits for the merge and walks on to the base to tag. The cost follows the shape of the release:

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

A batch is asked for by tp release batch on the base branch, by a second story's tp release while a lone release is in flight, or by a .tp-release.yml naming another story. Opening one converts a lone release in flight, each step skipped when its state already holds, so a run that stopped resumes:

Conversion step State found Step
cut release absent on the remote pushed from the remote base
cut release present skipped
revert the story's CHANGELOG at the remote tip names an untagged version one Unprepare vX.Y.Z: <branch> joins the batch commit reverting tp's release commits, pushed; the fragment is back (forward-only: no force push)
revert the tip carries no release skipped
retarget pull request into the base base set to release (Forgejo PATCH /pulls/{n}, GitLab PUT …/merge_requests/{n})
retarget pull request into release skipped
any forge or push fails error naming the step; the rerun resumes from this table
On a story branch release on remote tp release
fragment, no pull request no the lone release above
its own pull request into main open, more commits, no new fragment no the release commits stand; checks; push; the pull request reused
its own pull request open, a new fragment no the release commits reverted (Unprepare) and remade for the new version; checks; push
another story's pull request into main open any the conversion, then as a story of the batch (below)
.tp-release.yml names another story no release cut, then as a story of the batch
behind its remote copy by a conversion's revert, no local commits — fast-forwarded, then as a story of the batch
behind and ahead of its remote copy — error: rebase onto the remote branch first
its own pull request into main open, or its tip prepared yes its own release commits reverted here, its pull request retargeted, then as a story of the batch
tp release batch on main Does
no release, no lone release cuts release from the remote base; names the next step
no release, a lone release open the conversion, then as above
release open says so and names the stories in it; nothing done
maturity.code 0 error: a batch is code level 1

A batch of one story ships as a batch, two merges: the long path, not a gap.

Once release is on the remote, tp release on a story branch runs the checks, pushes the story and opens its pull request into release, titled by the fragment. No version, no CHANGELOG edit.

On the base branch (or release), tp release is the batch run. The batch is listed in .tp-release.yml (gitignored): stories: [46, 47]. With no file, the batch is the stories already merged into release (their fragments on release, not on the base), which is the usual case once the forge merges the story PRs (#135). The stories are merged into release in after: order, ties by issue number, each --no-ff, followed by check, and release is pushed after each one that passes. Each check, the docs pre-check and the deploy read task-plus.yml from the tree they run on, not from the branch the run started on (#152). Then the release commit on release: the fragments gathered under the version, grouped by kind, (#n) on each line, and the fragment files deleted. Then the pull request Release vX.Y.Z from release to the base, and the wait or tag run as above.

.tp-release.yml State tp release on the base
absent stories in release, no open PR into it batch = those stories (#135): release commit; PR to base
absent stories in release, PRs into it open error naming the PRs: merge or close them, or list the batch to have tp merge it (the tag run deletes release, which would orphan them)
absent none in release, a lone release's pull request into the base open error naming it: merge it and tag, or tp release batch
absent none in release error suggesting stories: from the open PRs into release
listed none merged merge each, check, push; release commit; PR to base
listed some in release those skipped (already in release); the rest merged
listed a story's branch gone, its fragment changes/n.* in release counted in release and skipped (merged on the forge, branch deleted)
listed a story's branch carries an Update CHANGELOG for v… commit for an untagged version (#101) error before anything is merged: drop the commit, add a changes/<n>.<kind>.md fragment
listed a story conflicts stops naming it: merge release into the story, push, run again
listed check fails after a merge stops naming it; the merge is undone; release not pushed past it
listed after: cycle, a dependency neither listed nor with its fragment in the base or release, or no story/<n>-* branch and no fragment in release error before anything is merged
any release PR open reported, nothing else
any release PR merged, untagged tag run; release and the stories' branches deleted; .tp-release.yml removed
any release/* branches on the remote error naming them

The version follows from the fragments: any kind but fixed or security bumps minor, else patch; release_bump is not consulted. The comment is the fragments' first lines joined with ; , or with a space after a line that ends in ., ! or ? (#165). .tp-release-comment is not read.

The tag run deletes each story's branch on the remote and locally (#148), one line per branch, since tp's own merge into release is no forge merge and fires no delete-on-merge. The branch's tip being in the tagged base is the proof it is done: a branch with commits the release lacks is kept, and the line says so. A branch the forge already deleted on merge is passed over on the remote, and its local copy goes the same way (#162). Nothing here fails the release.

The tag run closes, with the release link, the issues of:

Source Closed
fragment changes/n.* #n
story **Status:** ready with an issue its issue
roadmap item counted done its issue
the same number from several sources once

Walking the stories (story 023)

With merge_wait (10m by default), tp release on a story branch waits for you to merge its pull request into release, then walks on. You review and merge each story on the forge, and the run takes you from one to the next. .tp-release.yml tells the walk how many stories the batch has, so when the last one lands the walk checks out the base branch and runs the batch there (#139): the plan is shown and Proceed? asked before the release commit, the push of release and the release pull request, and the run then waits for that pull request as any batch run does. Nothing is published without that answer (#113): Proceed? is No when stdin is closed, so an agent's non-interactive run on a story branch stops at the plan with nothing done. Merging one story at a time, you answer n until you are ready; merging them all in one sitting, the one run carries through to the tag. When you merge on the forge, the batch's own merge sees the story as already in release and skips it.

Story PR during the wait Stories in .tp-release.yml not yet in release Then
merged some checks out the first in after: order; pushes it, opens or reuses its PR, waits
merged none checks out the base branch and runs the batch: the plan, Proceed?, the release PR, the wait for its merge, the tag
merged none, Proceed? answered n nothing done; the checkout is left on the base branch, the stories stay in release
merged no .tp-release.yml stops: Next step (human): tp release on main (#135: the walk cannot know the batch)
open at the deadline, or Ctrl-C any stops on that story's branch; tp release there walks on
closed unmerged any stops; nothing tagged
— (already in release, no open PR) any skipped: on to the next

With merge_wait: 0, a story run opens its PR and stops, as before.

A rerun on the story branch picks the wait back up (#75). The commit that last passed check in this checkout is recorded in git config (branch.<name>.tp-checked), so only that exact commit skips the checks; a branch pushed by hand, or with commits since, is checked again.

Story PR into release Tip vs last checked Rerun
open same no checks; push; wait
open moved, or never checked here checks; push; wait
closed without merging any error naming the PR: reopen it on the forge to go on; no new PR
merged any walks on, as above
merged, and the story is in the base branch any stops before the checks: names the release it went out in and the tidy-up, git switch main && git branch -d story/<n>-<slug>; untagged, points at the tag run on the base (#166)

The release in progress: tp release query (story 024)

tp release query reports what the next tp release would ship and changes nothing. It fetches the release remote first (-offline skips the fetch and the forge) and always exits 0.

Section Holds
Mode trunk or batch, with the maturity.code that decided it (#108)
Last release the newest version tag, and the commits on the base branch since it
In the release batch: the fragments on release that are not yet on the base, one line per story; trunk: story branches merged into the base since the last tag
Listed, still to merge .tp-release.yml stories not yet in release, in after: order
Open, not in the release remote story/<n>-* branches in neither of the above and not yet in the base
Lone release a story branch whose tip names an untagged version, with its pull request into the base open and no release on the remote: vX.Y.Z on story/<n>-<slug>, pull request #N into main (story 030)
Carrying an untagged release commit other remote story/* branches whose tip names a version with no tag (#101): a leftover, not a lone release
In flight .tp-release-inflight's version and PR, with the PR's state from the forge (unknown (…) when the forge can't say)
Next version worked out as tp release would: from the fragments, else release_bump
Next step the one action that moves the release on, and who takes it: agent or human (#108; the table at the top)
Not ready what the checkout lacks, or carries that the level refuses (#108)

On trunk, a story whose branch the forge deleted after merging is counted only in the commits since the tag: nothing else records it.

Post-release commands

post_release: in task-plus.yml lists commands to run once the release is published (pushed, goreleaser assets uploaded, docs deployed), each in the project directory with TP_VERSION set to the new tag. Keep the work in a Taskfile task and name it here:

post_release:
  - task deploy:fetch

They run whenever the release is pushed, without a prompt. The tag is already public by then, so a failing command prints a warning and the rest still run; the release is not rolled back.

Staged deployment

tp release cuts and publishes one version; it has no release-candidate or staging step. A project that wants to deploy to a staging site first, canary, or promote on approval owns that in its deploy tooling and runs it from post_release (or a Taskfile task), where TP_VERSION names the release.

Pull mirrors

A Forgejo pull mirror refuses pushes, so it can't be one of remotes:. List it under mirrors: instead and, after the push, tp asks it to fetch now (POST /api/v1/repos/{owner}/{repo}/mirror-sync) rather than on its own interval:

mirrors:
  - {}                    # host and token_env from task-plus.local.yaml
  - repo: hum3/other      # owner/repo on the mirror host, if not the release remote's

host (Forgejo base URL; https:// if no scheme) and token_env default to mirror_host and mirror_token_env in task-plus.local.yaml, so the private host stays out of the repo. The token is fetched like any other (list it under secrets: if the project has that list).

The step never fails the release; each mirror prints one line:

Condition Prints
no host, or repo not owner/repo Skipping mirror sync: <reason>
no token_env, or it is unset Skipping mirror sync <mirror>: <reason>
host unreachable, or API error Skipping mirror sync <mirror>: <error>
--dry-run Would sync mirror <mirror>
synced Synced mirror <mirror>

tp check verifies each mirror (fetching its token like tp issue):

Mirror host says Finding
unreachable warning
API error, including repo not found error
repo is not a pull mirror error
mirrors a URL other than the release remote (host/owner/repo, ignoring scheme, case and .git) error
pull mirror of the release remote ok