#!/usr/bin/env bash # Answer, before a tag exists, the question the Release workflow answers after. # # Usage: scripts/release-preflight.sh vX.Y.Z[-suffix] # scripts/release-preflight.sh --self-test # # WHY THIS EXISTS. `v0.1.0-rc3` was pushed while every crate # still said `0.1.0-rc2`. dist matches the tag against the root package version, # refused with "This workspace doesn't have anything for dist to Release!", and # the four jobs behind `plan` skipped. Nothing was wrong with the workflow. # # The cost is not the failure, it is WHEN the failure arrives: a tag is already # public and immutable by the time CI reads it, so the repair is a new version # rather than an edit. That is why this runs BEFORE tagging and prints the tag # command only once it has passed -- the honest path made the easy one, the same # shape as `Basis` having no public constructor (gotcha 43). # # The primary check is textual and always runs. `dist plan` is a SECOND opinion # and is allowed to be unavailable; what it could not confirm is stated rather # than assumed. Reasoning: context/code/tooling.md. scripts/release-preflight-test.sh # is the check_the_checker. set -eu root=$(cd "$(dirname "$0")/.." && pwd) # The root package's version, read from the FIRST [package] block of the root # Cargo.toml. dist-workspace.toml says `members = ["cargo:."]`, so the root # package is the only one dist announces and the only one whose version can # disagree with a tag. gp-tree sitting at 0.1.0 is therefore not a finding. root_version() { awk '/^\[package\]/{p=1;next} /^\[/{p=0} p&&/^version *=/{gsub(/[" ]/,"");sub(/^version=/,"");print;exit}' \ "$root/Cargo.toml" } fail() { echo "release-preflight: $*" >&2; exit 1; } preflight() { tag=$1 version=$(root_version) test -n "$version" || fail "could not read a version from Cargo.toml's [package] block" # A tag names a version; anything else is a spelling this repo does not use. expected="v$version" if [ "$tag" != "$expected" ]; then echo "release-preflight: REFUSED" >&2 echo " tag asked for : $tag" >&2 echo " Cargo.toml has: $version (so the tag must be $expected)" >&2 echo "" >&2 echo " Bump every crate to the version you mean to release and commit that" >&2 echo " FIRST. A tag pushed ahead of its version fails in CI after the tag is" >&2 echo " already public, and a published tag should not be moved." >&2 return 1 fi echo "release-preflight: tag $tag matches Cargo.toml version $version" # Every crate, because a workspace that disagrees with itself is the other # half of the same error and dist says so in the same breath. mismatched=$(grep -l '^version = ' "$root"/crates/*/Cargo.toml 2>/dev/null | while read -r f; do v=$(awk '/^\[package\]/{p=1;next} /^\[/{p=0} p&&/^version *=/{gsub(/[" ]/,"");sub(/^version=/,"");print;exit}' "$f") case "$v" in "$version"|"") ;; *) echo " $f: $v" ;; esac done) if [ -n "$mismatched" ]; then echo "release-preflight: note -- these crates are on a different version:" echo "$mismatched" echo " Not fatal: dist announces only the root package (dist-workspace.toml" echo " says members = [\"cargo:.\"]). Listed so a deliberate split stays deliberate." fi return 0 } self_test() { # check_the_checker. Both directions, because a checker that only ever passes # and a checker that only ever fails are indistinguishable from one that works. version=$(root_version) test -n "$version" || { echo "self-test: could not read the root version" >&2; exit 1; } if ! preflight "v$version" >/dev/null 2>&1; then echo "self-test FAILED: the matching tag v$version was refused" >&2 exit 1 fi if preflight "v$version-nope" >/dev/null 2>&1; then echo "self-test FAILED: a tag that does not match the version was accepted" >&2 exit 1 fi # The exact shape that was actually pushed, spelled out, because this is the # case the script was written for and a regression here is the whole failure. if preflight "v0.1.0-rc3" >/dev/null 2>&1 && [ "$version" != "0.1.0-rc3" ]; then echo "self-test FAILED: v0.1.0-rc3 accepted against version $version" >&2 exit 1 fi echo "release-preflight: self-test passed (accepts v$version, refuses a mismatch)" } case "${1:-}" in --self-test) self_test; exit 0 ;; "" | -h | --help) echo "usage: release-preflight.sh vX.Y.Z[-suffix] | --self-test" >&2 exit 2 ;; esac tag=$1 # A dirty tree means the tag would not name what was tested. if [ -n "$(git -C "$root" status --porcelain --untracked-files=no)" ]; then fail "the working tree has uncommitted changes; tag a commit, not a guess" fi preflight "$tag" || exit 1 # The second opinion. dist is the thing that actually refused in CI, so running # it is worth more than any restatement of its rules -- but it is optional, and # an absent dist must not read as a pass. if command -v dist >/dev/null 2>&1; then out=$(cd "$root" && dist plan "--tag=$tag" 2>&1) || true case "$out" in *"doesn't have anything for dist to Release"*) echo "release-preflight: REFUSED -- dist rejected the tag:" >&2 echo "$out" | sed -n '/×/,$p' | head -8 >&2 exit 1 ;; *"but 'cargo-dist-version"*) echo "release-preflight: dist ran but is a different version from the one" echo " pinned in dist-workspace.toml, so it stopped before planning. The" echo " tag/version check above still passed; CI installs the pinned version." ;; *) echo "release-preflight: dist plan accepted $tag" ;; esac else echo "release-preflight: dist not on PATH, so only the textual check ran." echo " That is the check that catches the failure this script exists for." fi cat <