cb6866b2c6
`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
141 lines
5.8 KiB
Bash
Executable File
141 lines
5.8 KiB
Bash
Executable File
#!/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
|