diff --git a/README.md b/README.md index e77d6cb70..e7308ef4c 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,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. Its body is a summary of the release, then the version diff. `claude -p`, with no tools, writes the summary from the version diff and the title, number and body of each pull request merged since the last tag, following the prompt in [`scripts/release-summary.md`](scripts/release-summary.md). If `claude` fails or prints nothing, the script warns and uses GitHub's generated notes as the summary instead, with a note saying so. 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 b2654ccea..7bb5afe27 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -14,8 +14,14 @@ # release-summary.md beside this script. If it fails, the summary is GitHub's # generated notes instead, and the script warns and carries on. # +# 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. It exits 1 if it refuses, or if the PR's checks fail, in which case it +# leaves the PR open. set -euo pipefail # So a failure inside $(…) stops the script too. shopt -s inherit_errexit @@ -37,6 +43,7 @@ main() { fetch_main base=$(git rev-parse --verify 'origin/main^{commit}') + check_can_release "$version" "$tag" "$base" repo=$PWD work=$(mktemp -d) trap 'git -C "$repo" worktree remove --force "$work" 2>/dev/null || rm -rf "$work"' EXIT @@ -77,24 +84,89 @@ progress() { echo "release: $*" >&2 } +# refuse +refuse() { + progress "refusing to release $version: $*" + exit 1 +} + +# check_can_release +# Refuses unless is plain X.Y.Z and higher than the version in the +# manifest at , main's tip, no exists on origin or locally, and +# the latest CI run on main is on and succeeded. +check_can_release() { + local current short=${3:0:7} latest sha status conclusion + if ! [[ $1 =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + refuse "it is not a plain X.Y.Z version, such as 0.4.0" + fi + current=$(git show "$3:Cargo.toml" | package_field version) + if ! is_higher "$1" "$current"; then + refuse "it is not higher than $current on main" + fi + + # Origin first: fetching main brings in the tags on it, so a tag found + # locally may have come from origin. + if [ -n "$(git ls-remote --tags origin "refs/tags/$2")" ]; then + refuse "$2 already exists on origin" + fi + if git rev-parse --quiet --verify "refs/tags/$2" >/dev/null; then + refuse "$2 already exists locally" + 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" != "$3" ]; then + refuse "CI has not run on main at $short yet" + elif [ "$status" != completed ]; then + refuse "CI on main at $short is $status, not finished" + elif [ "$conclusion" != success ]; then + refuse "CI on main at $short 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 } +# package_field +# The value of in the [package] section of the manifest on stdin. +package_field() { + awk -F '"' -v key="$1" ' + /^\[/ { section = $0 } + section == "[package]" && $1 == key " = " { print $2; exit }' +} + # bump_version # Sets the package's version in Cargo.toml, and its entry's in Cargo.lock, to # , leaving every other line alone. # shellcheck disable=SC2016 # the $0 in the awk programs is awk's bump_version() { local name - name=$(awk '/^\[/ { section = $0 } section == "[package]" && /^name = / { print; exit }' Cargo.toml) + name=$(package_field name ": [{"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 '