Skip to content

Cut a release with one command: bump PR, agent-written summary, merge and tag #150

Closed
6 of 6 issues completed

Description

@JacobStephens2

Problem Statement

Everything after a v<version> tag is automated. The dist release workflow builds the binaries and the installer, runs the Rocky Linux 9 and WSL2 smoke checks, publishes the GitHub Release, mirrors the version to crates.io and rewrites the Release notes (ADR-0003). Getting to the tag is still manual, as the README's Releasing section describes: open a PR that bumps the version in the manifest and lockfile, write a summary of what ships, wait for CI, merge, then tag the merge commit and push the tag. v0.2.0 (#60) and v0.3.0 (#100) were both cut this way.

The mechanical steps are easy to get wrong, for example tagging the wrong commit or forgetting to push the tag. The one step that needs judgment, the summary, only reaches the bump PR. The Release page, which is where users look, gets GitHub's generated list of PRs instead. That list is complete but flat. For v0.3.0 it had 20 lines in merge order, 13 of them site work that isn't in the binary, and nothing marked the Merge run as the headline.

Solution

A maintainer runs one command in their clone of this repo with the new version, e.g. release 0.4.0, and walks away. The command:

  1. Checks that the release can be cut, working from origin/main in a temporary worktree so the maintainer's checkout doesn't matter.
  2. Has an agent (claude -p, with no tools) write a summary of what ships, from the PRs merged since the last tag.
  3. Opens a PR that bumps only the version, with that summary as its body.
  4. Waits for CI, merges the PR with a merge commit, tags the merge commit v<version> and pushes the tag. The existing dist pipeline takes over from there.

The agent's summary then heads the published Release page, above GitHub's generated list and the install instructions.

The maintainer only chooses the version. Passing --review pauses once to show the summary before anything is pushed. Without it, the command runs straight through.

This is a tool for maintaining this repo, not a thirdshift subcommand. Nobody else releases thirdshift, and thirdshift help shouldn't list a command that only makes sense in this one checkout.

User Stories

  1. As the maintainer, I want to cut a release with one command given the version, so that I don't have to repeat the bump, PR, merge and tag steps by hand.
  2. As the maintainer, I want to choose the version myself, so that the semver judgment stays mine.
  3. As the maintainer, I want the command to refuse a version that isn't valid semver, so that a typo can't become a tag.
  4. As the maintainer, I want the command to refuse a version that isn't strictly higher than the one on origin/main, so that I can't release backwards or re-release.
  5. As the maintainer, I want the command to refuse when the v<version> tag already exists locally or on origin, so that a published release is never retagged.
  6. As the maintainer, I want the command to refuse when the latest CI run on origin/main isn't green, so that I don't start a release from a broken main.
  7. As the maintainer, I want the command to work from origin/main in a temporary worktree, so that my current branch, uncommitted changes and unpushed commits neither block it nor leak into the release.
  8. As the maintainer, I want the bump PR to change only the version in the manifest and lockfile, so that a release PR never carries unrelated changes the way Release 0.2.0 #60 did.
  9. As the maintainer, I want the bump PR on a release-<version> branch, like the earlier release PRs, so that release branches are easy to recognise.
  10. As the maintainer, I want an agent to write the PR's summary from the PRs merged since the last tag, so that the release gets a readable headline without me writing one.
  11. As the maintainer, I want the agent to get only what the command collects (the version diff, and the title, number and body of each PR merged since the last tag), with no tools, so that its output depends only on that input and it can't take actions.
  12. As the maintainer, I want the summary to lead with what changes for someone running thirdshift (new commands, and new or changed behaviour), so that users see what matters to them first.
  13. As the maintainer, I want site, docs and CI work covered in one short grouped line, so that it doesn't bury the changes that ship in the binary.
  14. As the maintainer, I want every change in the summary referenced by its PR number, so that readers can follow up.
  15. As the maintainer, I want the agent told not to claim anything the PR titles and bodies don't say, so that the release notes stay accurate.
  16. As the maintainer, I want the agent's prompt kept as a file in the repo next to the command, so that I can refine how releases are summarised in a normal PR.
  17. As the maintainer, I want the PR body to be the headline paragraph, then the grouped changes, then the version diff, with the diff written by the command rather than the agent, so that the diff is always exact.
  18. As the maintainer, if the agent fails (non-zero exit, empty output, not logged in), I want the command to warn, use GitHub's generated notes as the summary and carry on, so that a release never hangs on prose.
  19. As the maintainer, I want that fallback noted in the PR body as well as the terminal, so that I know to edit the body afterwards if I care to.
  20. As the maintainer, I want --review to print the summary and ask [y]es / [e]dit / [n]o before anything is pushed, so that I can check the text that will be public when I want to.
  21. As the maintainer, I want [e]dit to open the summary in my $EDITOR and use what I save, so that I can fix a detail without starting over.
  22. As the maintainer, I want [n]o to stop before pushing anything, so that declining leaves no branch, PR or tag behind.
  23. As the maintainer, I want the command to run without stopping when I don't pass --review, so that the usual release is walk-away.
  24. As the maintainer, I want the command to wait for the bump PR's checks, so that I don't have to watch CI myself.
  25. As the maintainer, if the PR's checks fail, I want the command to stop before merging and leave the PR open, so that I can see what broke and fix it.
  26. As the maintainer, I want the bump PR merged with a merge commit and that merge commit tagged v<version>, as with v0.2.0 and v0.3.0, so that the tag points at exactly what CI tested on main.
  27. As the maintainer, I want the command to push the tag, so that the dist release workflow starts without another step.
  28. As the maintainer, if the command is interrupted (terminal closed, Ctrl-C, network drop), I want running it again with the same version to pick up the existing branch or PR and carry on, so that an interruption never needs manual cleanup.
  29. As the maintainer, on a re-run after the tag was already pushed, I want the command to say the release is already tagged rather than fail obscurely, so that I know there's nothing left to do.
  30. As the maintainer, I want the command to say which step it's on as it goes (checks, summary, PR opened with its URL, waiting for CI, merged, tagged), so that I can tell where it is when I glance back at the terminal.
  31. As a user reading the GitHub Release page, I want the release's summary above the generated list of PRs, so that I learn what the release is about before I read the full list.
  32. As a user reading the GitHub Release page, I want the generated list of PRs and the install instructions still there, so that nothing that was there before is lost.
  33. As the maintainer, if the tagged commit has no bump PR with a summary (say, a tag pushed by hand), I want the Release body to be the generated notes and install instructions exactly as today, so that a hand-cut release still publishes correctly.
  34. As the maintainer, I want the README's Releasing section to describe the one command and its flag, so that the documented procedure matches the tool.

Implementation Decisions

  • A bash script for maintaining the repo, not a thirdshift subcommand. It follows the precedent of the site's publish script and runs with the maintainer's own gh login. A workflow_dispatch workflow was rejected: a tag pushed with the workflow's GITHUB_TOKEN doesn't trigger other workflows, so dist's release workflow wouldn't start without a PAT or App token, and that workflow is generated and can't be edited to get around it. cargo-release was rejected because it commits the bump straight to main, with no PR and no CI on the exact commit that gets tagged.
  • Interface: <script> <version> [--review]. The version is plain semver with no v, and the tag is v<version>. It prints progress to stderr and exits 0 once the tag is pushed or when the release is already tagged. It exits non-zero when it refuses, when the maintainer declines at the review prompt, when CI fails or on any other failure.
  • Order of work: preconditions → collect inputs → agent summary (falling back to the generated notes) → optional review → push branch and open PR → wait for checks → merge with a merge commit → tag the merge commit → push the tag.
  • Preconditions, all checked before anything is pushed:
    • the version is valid semver
    • it is strictly higher than the version in the manifest on origin/main
    • no v<version> tag exists locally or on origin
    • the latest CI run on origin/main succeeded
  • Resume: for a version whose branch or PR already exists, the command skips to the first step not yet done. If the tag already exists on origin and points at the merged bump, it reports that and exits 0. This relaxes the tag refusal for that one case only.
  • Agent call: claude -p with tools disabled. The prompt comes from a prompt file in the repo, followed by the collected input. The PRs "merged since the last tag" are the PRs whose merge commits are reachable from origin/main but not from the most recent v* tag. The bump PR's own entry is left out.
  • PR body layout: the agent's summary (headline paragraph, then the grouped changes with PR references), then the version diff written by the command. The summary needs a marker the release-notes step can find reliably, such as a heading or comment delimiters. The exact marker is an implementation choice.
  • Fallback: when the agent fails, the summary section holds GitHub's generated notes for the range, preceded by a one-line note that the agent summary was unavailable. The same warning goes to stderr.
  • Release notes hook: the existing post-announce release-notes workflow is one of our own hooks, not generated by dist (ADR-0003 keeps custom steps on dist's job hooks), so it can be changed. It finds the PR whose merge commit is the tagged commit, takes the summary section from its body and puts it above the generated notes and the install instructions. If there is no such PR or no summary section, the body is exactly what it is today. It keeps building the body from the plan and the PR, never from the Release's current body, so a re-run doesn't repeat content.
  • Where the extraction logic lives: where practical, finding the bump PR for a tag and pulling out its summary lives in the repo's script code, called by the workflow, so the same test seam covers it rather than leaving the logic in untested workflow YAML.
  • README: the Releasing section becomes the one command, --review, and a sentence on resuming, alongside the existing description of what the tag then starts.
  • No CONTEXT.md change and no ADR. Releasing isn't part of thirdshift's domain, and nothing here is hard to reverse.

Testing Decisions

  • One seam: the script as a black box. Tests run the script against a bare local origin, with the repo's fake gh and fake claude on PATH. They assert only on what can be observed from outside: the origin's branches, commits and tags, the PRs and merges recorded in the fake gh's state, the input the fake claude received, the exit code and the stderr lines.
  • Prior art: the site deploy tests, which run the publish script against a local origin with the user's git config kept out, and the Run tests that use the fake gh and fake claude. The fake gh already makes real merge commits in a bare origin, and reports check results and PRs. Extend it as needed, for example for the latest CI run on a branch, the generate-notes API and finding the PR for a commit, rather than adding a second fake.
  • Cases to cover:
    • a full release: bump PR opened with the agent summary and diff, checks waited on, merge commit made, tag on the merge commit, tag pushed
    • each refusal: bad semver, not higher, tag exists, main CI red. Each leaves origin untouched.
    • agent failure falls back to the generated notes, with the warning in the body and on stderr
    • --review with y, e (using a scripted $EDITOR) and n. n pushes nothing.
    • failing PR checks stop before merging and leave the PR open
    • resume after the PR was opened, and after the merge but before the tag
    • a re-run when the tag is already pushed reports it and exits 0
    • summary extraction for the Release body: summary found, no bump PR, and a PR without a summary section
  • Out of the test seam: the generated dist release workflow itself, and the real claude's prose. The prompt file is reviewed by reading it, not tested.
  • Linux-only where it depends on GNU tools, as the site deploy tests are.

Out of Scope

  • Having the command choose or suggest the version, e.g. from conventional commits.
  • A thirdshift release subcommand, or releasing any repo other than this one.
  • Running the release from GitHub Actions (workflow_dispatch), or any new token or secret.
  • A CHANGELOG file (there is none, by design).
  • Pre-releases and release candidates.
  • Changes to the dist config, the generated release workflow, the smoke checks or the crates.io publish.
  • Branch protection or rulesets on main.

Further Notes

  • Settled in a grilling session on 2026-09-29/30. The questions and answers are in that session.
  • The value the agent adds, using v0.3.0 as the example: GitHub's generated notes listed 20 PRs in merge order, 13 of them site work, with the Merge run spread over six lines. The summary written by hand in Release 0.3.0 #100 ("the headline is the Merge run…") is what the agent should reproduce.
  • claude is already a prerequisite for anyone who runs thirdshift, so the command adds no new tool for the maintainer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions