From ee1dd328dc53c304907dbae2352e620575f4c844 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Wed, 30 Sep 2026 02:31:36 +0000 Subject: [PATCH 1/2] Refuse a release that can't be cut, before pushing anything 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 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 --- README.md | 2 + scripts/release.sh | 71 +++++++++++++++++++++- tests/fakes/gh | 33 +++++++++++ tests/release.rs | 143 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 220e74d7e..ac5fe98b6 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,8 @@ scripts/release.sh 0.4.0 It works from `origin/main` in a temporary worktree, so your checked-out branch and uncommitted changes don't matter and aren't touched. It opens a pull request from `release-` that bumps the version in `Cargo.toml` and `Cargo.lock` and nothing else, with GitHub's generated notes for the pull requests merged since the last tag and the version diff as its body. It waits for the pull request's checks, merges it with a merge commit, then tags that merge commit `v` and pushes the tag. It prints a line on stderr for each step. If the checks fail, it stops before merging and leaves the pull request open. +Before it pushes anything, it refuses a release that can't be cut and says why: a version that isn't plain `X.Y.Z` (no leading `v`), a version that isn't higher than the one in `Cargo.toml` on `origin/main`, a `v` tag that already exists locally or on origin, or a latest CI run on `origin/main` that failed or hasn't finished. + The tag starts the release workflow, generated by [`dist`](https://github.com/axodotdev/cargo-dist) from `dist-workspace.toml`. It builds the Linux and macOS binaries and the installer, checks that the Linux binary starts on Rocky Linux 9 and in WSL2 with Ubuntu 24.04, and only then publishes the GitHub Release. It then publishes the same version to crates.io, skipping it if it is already there, and finally replaces the Release's body with notes generated from the merged pull requests. ## Credits and license diff --git a/scripts/release.sh b/scripts/release.sh index 053b14a35..53a4e350c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -10,8 +10,14 @@ # a temporary worktree, so the branch checked out where it runs and any # uncommitted changes there are neither used nor changed. # +# Before it pushes anything, it refuses a release that can't be cut: a version +# that isn't plain X.Y.Z, or isn't higher than the one on main, a v +# tag that already exists locally or on origin, or a latest CI run on main that +# didn't succeed. +# # Prints a progress line on stderr for each step. Exits 0 once the tag is -# pushed; if the PR's checks fail, it leaves the PR open and exits 1. +# pushed; if it refuses, or the PR's checks fail, leaving the PR open, it exits +# 1. set -euo pipefail # So a failure inside $(…) stops the script too. shopt -s inherit_errexit @@ -28,8 +34,12 @@ main() { tag=v$version branch=release-$version + if ! is_plain_semver "$version"; then + refuse "it is not a plain X.Y.Z version, such as 0.4.0" + fi fetch_main base=$(git rev-parse --verify 'origin/main^{commit}') + check_can_release "$base" repo=$PWD work=$(mktemp -d) trap 'git -C "$repo" worktree remove --force "$work" 2>/dev/null || rm -rf "$work"' EXIT @@ -69,6 +79,65 @@ progress() { echo "release: $*" >&2 } +# refuse +refuse() { + progress "refusing to release $version: $*" + exit 1 +} + +# is_plain_semver +is_plain_semver() { + [[ $1 =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] +} + +# check_can_release +# Refuses unless $version is higher than the version in the manifest at +# , main's tip, no $tag exists locally or on origin, and the latest CI +# run on main is on and succeeded. +check_can_release() { + local current latest sha status conclusion + current=$(git show "$1:Cargo.toml" | awk -F '"' ' + /^\[/ { section = $0 } + section == "[package]" && /^version = / { print $2; exit }') + if ! is_higher "$version" "$current"; then + refuse "it is not higher than $current on main" + fi + + if git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null; then + refuse "$tag already exists locally" + fi + if [ -n "$(git ls-remote --tags origin "refs/tags/$tag")" ]; then + refuse "$tag already exists on origin" + fi + + latest=$(gh run list --branch main --workflow ci.yml --event push --limit 1 \ + --json headSha,status,conclusion --jq '.[] | "\(.headSha) \(.status) \(.conclusion)"') + read -r sha status conclusion <<<"$latest" + if [ "$sha" != "$1" ]; then + refuse "CI has not run on main at ${1:0:7} yet" + elif [ "$status" != completed ]; then + refuse "CI on main at ${1:0:7} is $status, not finished" + elif [ "$conclusion" != success ]; then + refuse "CI on main at ${1:0:7} ended in $conclusion" + fi +} + +# is_higher +# Whether plain semver is higher than , part by part. +is_higher() { + local -a ours theirs + local i + IFS=. read -ra ours <<<"$1" + IFS=. read -ra theirs <<<"$2" + for i in 0 1 2; do + if ((ours[i] != theirs[i])); then + ((ours[i] > theirs[i])) + return + fi + done + return 1 +} + fetch_main() { git fetch --quiet origin +refs/heads/main:refs/remotes/origin/main } diff --git a/tests/fakes/gh b/tests/fakes/gh index a65bf7ba0..be1e2ff04 100644 --- a/tests/fakes/gh +++ b/tests/fakes/gh @@ -11,6 +11,7 @@ State lives in the JSON file named by $FAKE_GH_STATE: "mergeable"?, "unknown_polls"?}], "checks": {"": [{"name", "conclusion", "url", "pending_polls"?}]}, "statuses": {"": [{"context", "state", "url"}]}, + "runs"?: [{"branch", "workflow", "event", "headSha", "status", "conclusion"}], "on_ci_read"?: {"times", "script", "seen"}, "on_merge"?: {"times", "script"}, "refuse_merges"?: {"times", "error"}, @@ -43,6 +44,10 @@ generated notes: a line for each merged PR whose merge commit is reachable from `gh api repos//actions/runs?head_sha=` answers with the workflow runs on : its check runs, read as the check-runs endpoint reads them. +`gh run list` answers with the workflow runs in `runs`, newest first, that +match its `--branch`, `--workflow` and `--event`. Runs there are only ones a +test records with `gh fake run`: they are separate from the check runs. + `gh api graphql` answers the one query thirdshift reads a Spec's Tickets with: the sub-issues of issue `$number` (its `sub_issues`, in order), each with its state, its `labels`, how many sub-issues it has of its own, and the @@ -60,6 +65,8 @@ command): gh fake checks '' set the check runs on gh fake statuses '' set the commit statuses on + gh fake run '' record a workflow run, newer than + any recorded before it gh fake pr '' set a field of 's newest PR gh fake issue set issue OPEN or CLOSED gh fake on-ci-read '