Files
improvise/scripts/release-preflight.sh
T
Edward Langley cb6866b2c6 fix(scripts): one shell and one environment for every checker (improvise-f9qf)
`scripts/pr-reviews-test.sh` passed 19/19 locally and 2/19 in CI on the same
commit, because a multi-assignment `local` is not left-to-right on bash 5
while macOS's bash 3.2 tolerates it. The obvious half of the fix is pinning
the shell. The half that decides the design is not.

.envrc DOES already put a nix bash 5.3.15 on PATH, which is CI's semantics --
and it is not enough. `pkgs.mkShell` exports `name`, `out`, `outputs`,
`system`, `shell`, `builder`, `stdenv`, `phases` and about twenty more
generically-named variables that CI does not have, so a script reading one by
accident under `set -u` is SILENT in the dev shell and fatal in CI. That is
exactly what happened: the broken clause read `$name`, and in the dev shell
`$name` was `nix-shell-env`. Measured at 051f541^ on one machine:

  macOS /bin/bash 3.2                             19 passed, 0 failed
  direnv exec . ./scripts/pr-reviews-test.sh      19 passed, 0 failed
  the same, with `name` unset                      2 passed, 17 failed

Two reasons for green, one of them the correct interpreter. A dev shell is a
third environment, not a reproduction of CI.

A nix shebang was measured and declined, on availability rather than speed:
warm, `nix shell nixpkgs#bash --command true` is 61.8ms against an ambient
5.1ms, and `nix-shell -p bash --run true` -- the manpage form -- is 675.2ms,
132x, inside git's merge machinery for beads-merge-driver.sh. But only the
`kb` job installs nix; build, test, wasm, js, harness-lint and browser have
none, so a nix shebang needs an installer step on three more jobs to reach a
bash those runners already ship. zsh is not a shebang change and the runner
does not have it either. Both declined in context/code/tooling.md section 5.

What lands instead pins interpreter and environment together, in one place:
scripts/checkers.sh is the one entry point for every check_the_checker, used
verbatim by CI and by the local gate. It pins a bash >= 5 (ambient first, nix
only when the ambient one is older, exit 3 when neither), scrubs the
environment to an allowlist, and refuses any shell shebang in scripts/ that
is not `#!/usr/bin/env bash`. The pinned bash goes on the front of PATH so
inner invocations get it too -- which is where the fault actually lived.

The second undetected split is closed with it: the four `#!/bin/sh` and one
`#!/usr/bin/env sh` scripts are now bash, and the six places that ran a
script through an explicit `sh` -- overriding the callee's own shebang, which
is the same disease -- now invoke it directly.

Verification, injected against the real script: restoring pr-reviews'
multi-assignment `local` leaves `sh scripts/pr-reviews-test.sh` at 22/22 and
`direnv exec . ./scripts/pr-reviews-test.sh` at 22/22, while
`./scripts/checkers.sh pr-reviews` reports 2 passed, 20 failed, exit 1 --
matching the 2/19 CI actually reported. scripts/checkers-test.sh is twelve
cases, each injecting rather than asserting; the pair that carries it runs
one fixture both ways, so the `set -u` case is a test of the runner and not
of `set -u`.

The old local gate was `for t in scripts/*-test.sh; do sh "$t"; done`, which
overrides every shebang with /bin/sh -- so it ran every suite under bash 3.2
in sh-mode regardless of what the file said. It is replaced by
`./scripts/checkers.sh`.

Workspace suite unchanged at 1689 passing over 38 binaries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 15:50:14 -07:00

141 lines
5.8 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <<EOF
Preflight passed. To cut the release:
git tag -a $tag -m "$tag"
git push gh $tag
Push the tag to gh; the Release workflow triggers on the tag push there.
EOF