Skip to content

Release script refuses a release that can't be cut - #166

Merged
JacobStephens2 merged 4 commits into
mainfrom
issue-157
Sep 30, 2026
Merged

JacobStephens2 merged 4 commits into
mainfrom
issue-157

Conversation

@JacobStephens2

@JacobStephens2 JacobStephens2 commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

scripts/release.sh <version> now refuses a release that can't be cut, before the worktree is made or anything is pushed:

 release.sh 0.4.0
   fetch origin/main
+  check_can_release <version> <tag> <base>            # "release: refusing to release <v>: <reason>", exit 1
+    version matches ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$   else "not a plain X.Y.Z version"
+    version > Cargo.toml [package] version at origin/main, part by part as numbers
+                                                        else "not higher than <current> on main"
+    git ls-remote --tags origin refs/tags/v<version>    else "v<version> already exists on origin"
+    git rev-parse refs/tags/v<version>                  else "v<version> already exists locally"
+    gh run list --branch main --workflow ci.yml --event push --limit 1
+      headSha == origin/main tip                        else "CI has not run on main at <sha> yet"
+      status == completed                               else "CI on main at <sha> is <status>, not finished"
+      conclusion == success                             else "CI on main at <sha> ended in <conclusion>"
   temporary worktree → bump → push release-<version> → PR → wait → merge → tag

Origin's tags are checked before local ones because fetching main brings in the tags on it. A tag that came in that way is reported as being on origin.

 tests/fakes/gh
+  gh run list --branch/--workflow/--event   # newest matching run from state "runs"
+  gh fake run '<JSON>'                      # record a workflow run
 scripts/release.sh
+  package_field <key>                       # shared by the new version check and bump_version
 README.md  ## Releasing                     # one paragraph listing the refusals

Evidence

  • Before: cargo test --test release with the new tests: all fail. With no checks in the script, 0.1.0, v0.4.0 and red CI on main all went ahead and opened a PR.
    After: cargo test --test release passes 15 tests. Each refusal test asserts a non-zero exit, refusing to release plus the reason on stderr, origin's refs (for-each-ref) unchanged, and the fake gh's PR list unchanged:
    • not plain semver: v0.4.0, 0.4, abc, 0.4.0-rc.1, 01.4.0, 0.4.0.1 and ""
    • not higher than 0.1.0 on main: 0.1.0, 0.0.9 and 0.0.10. 0.10.0 still goes through, so versions compare as numbers, not text.
    • tag exists locally, on origin (on a stray commit) and on origin (on main, which the fetch pulls in)
    • latest CI run on main is completed failure, is in_progress, or is on an older commit than main's tip
  • On the final commit, cargo test --no-fail-fast passes every test except merge_run::ctrl_c_after_the_merge_finishes_the_post_merge_steps_and_exits_as_merged. That test failed in two full runs, both times on the harness's fixed 10-second "the agent never started" deadline (tests/support/mod.rs:225), which only trips under full-suite load. It passes 3 of 3 times on its own, and it uses neither gh run list nor gh fake run. cargo clippy --all-targets -- -D warnings and shellcheck scripts/release.sh are clean.

Merge Danger

Door: two-way

Blast Radius: maintainer-only

This only changes the maintainer's release script and the test fake. The thirdshift binary and the workflows are unchanged. At worst, a release that could be cut gets refused, and reverting this PR fixes that.

Unaddressed findings

Spec

  • A CI run must exist on main's tip, which is stricter than "the latest CI run didn't succeed". Kept on purpose. Without it, a green run on an older commit would let an untested main through, and the ticket's "unfinished" case covers a run that hasn't started yet.
  • A pre-release version in the manifest on main (e.g. 0.4.0-alpha) would fail the numeric comparison with an unclear error rather than a clean refusal. Pre-releases are out of scope for Cut a release with one command: bump PR, agent-written summary, merge and tag #150, and the manifest has always been plain X.Y.Z.
  • The CI query is pinned to ci.yml on push. That is how CI runs on main. If the workflow is renamed, the script refuses ("CI has not run") rather than going ahead.

Standards

  • refuse reads the global $version. It is a message helper like progress, and the version is fixed for the whole run.
  • assert_refused could be a method on Release. The file already keeps free helpers such as stderr, so it stays a function.
  • LATEST_RUN_LINE in the fake repeats the script's --jq. This follows the existing WORKFLOW_RUN_LINES pattern, so an unexpected query fails loudly.

Closes #157

🤖 Generated with Claude Code

JacobStephens2 and others added 4 commits September 30, 2026 02:31
The release script now refuses a version that isn't plain X.Y.Z, one not
higher than the manifest's on origin/main, a v<version> tag that exists
locally or on origin, and a latest CI run on main that isn't a success on
main's tip. The fake gh reports the latest workflow run on a branch through
`gh run list`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Check the tag on origin before locally, since fetching main brings in the
tags on it. Share the [package] field lookup with the version bump, pass the
checks their inputs as arguments, and drop setup the refusal tests don't need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the claude-written release summary (#167). The release tests
keep both sides' helpers: the CI-run and origin-refs helpers for the
refusals, and the fake claude helpers for the summary. The CI refusal
test's merge_pr call takes the body argument main added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JacobStephens2
JacobStephens2 merged commit c935a2b into main Sep 30, 2026
13 checks passed
@JacobStephens2
JacobStephens2 deleted the issue-157 branch September 30, 2026 03:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Release script refuses a release that can't be cut

1 participant