Rechecked at ba1a7b8f on 2026-09-21. The defect is unchanged and both
floors still measure four. The attached patch no longer applies: ba1a7b8
(#446) and a3c1660 (#443) grew the file and the guard moved from :168 to
:319.
tests/release/release-rehearsal.test.sh is the required gate that keeps
scripts/release_rehearsal.sh from ever becoming a publisher. The control is
one grep, at the end of the file:
if grep -Eiq '(^|[[:space:]])(cargo|npm)[[:space:]]+publish|gh[[:space:]]+release[[:space:]]+create' "$rehearsal"; then
printf 'FAIL: rehearsal contains a publication command\n' >&2
exit 1
fi
It knows three spellings. I appended ten publication lines to the rehearsal
script, one at a time in a throwaway clone of 736f945, restoring between each,
and ran the gate after every one:
m() { bash "$R" || exit 1; ( cd "$T" && eval "$2" ) || { echo "$1: MUTATION COMMAND FAILED"; return; }
( cd "$T" && [ -n "$(git status --porcelain)" ] ) || { echo " !! $1 ANCHOR MISS"; return; }
bash "$G" "$1"; }
m P1 "printf 'cargo publish\n' >> scripts/release_rehearsal.sh"
m P2 "printf 'CARGO_BIN=cargo\n\"\$CARGO_BIN\" publish\n' >> scripts/release_rehearsal.sh"
...
where $G runs bash tests/release/release-rehearsal.test.sh and prints its
exit code and first FAIL/passed line.
appended to scripts/release_rehearsal.sh |
shipped gate |
cargo publish |
rc=1 FAIL |
CARGO_BIN=cargo + "$CARGO_BIN" publish |
rc=0 passed |
cargo_args=(publish) + cargo "${cargo_args[@]}" |
rc=0 passed |
npm publish --access public |
rc=1 FAIL |
gh release create v9.9.9 |
rc=1 FAIL |
gh api -X POST repos/o/r/releases |
rc=0 passed |
curl -X PUT https://crates.io/api/v1/crates/new |
rc=0 passed |
wget --post-file=x.crate https://crates.io/api/v1/crates/new |
rc=0 passed |
out=$(cargo publish --dry-run) |
rc=0 passed |
true && npm publish |
rc=1 FAIL |
Six of ten pass. The last green one is the cheapest to reach by accident:
$(cargo publish ...) puts cargo after a (, and the regex wants it at the
start of a line or after whitespace.
None of this is an exotic attack. A refactor that hoists the toolchain into
CARGO_BIN removes the tripwire while the board stays green, and nothing in
the gate says which property it lost.
The fix
Screen by allowlist rather than by spelling, in two halves, each with a floor so
a broken extraction fails instead of reporting a clean script.
- Every occurrence of the word
publish in the rehearsal has to be one of the
four reviewed lines: the help text, the --publish) case, its error message,
and the comment about crates already published on crates.io.
- Every invocation of
cargo, npm, gh, curl or wget in command
position has to carry a reviewed subcommand. Today that is exactly four
lines: cargo metadata, cargo package, cargo build, npm pack.
Check 1 catches an indirect spelling that names no tool ("$CARGO_BIN" publish).
Check 2 catches a call that never says "publish" (gh api -X POST, curl at a
registry). Prose that mentions a tool is not a call, so the help text's "Package
crates and npm setup" stays quiet.
Patch against 736f945. Needs a rebase at ba1a7b8: the guard moved from :168 to :319
diff --git a/tests/release/release-rehearsal.test.sh b/tests/release/release-rehearsal.test.sh
index 7014d90..87e22ea 100755
--- a/tests/release/release-rehearsal.test.sh
+++ b/tests/release/release-rehearsal.test.sh
@@ -168,9 +168,63 @@ if grep -Fq -- '--no-verify' "$release_workflow"; then
exit 1
fi
-if grep -Eiq '(^|[[:space:]])(cargo|npm)[[:space:]]+publish|gh[[:space:]]+release[[:space:]]+create' "$rehearsal"; then
- printf 'FAIL: rehearsal contains a publication command\n' >&2
- exit 1
-fi
+# The rehearsal must never gain the ability to publish. Screening three literal
+# spellings (`cargo publish`, `npm publish`, `gh release create`) left the
+# capability one refactor away: run the same tool through a variable, or reach a
+# registry with curl, and the gate stayed green. Screen by allowlist instead.
+# Both halves carry a floor, so an extraction that reads nothing fails loudly
+# instead of reporting a clean script.
+assert_rehearsal_cannot_publish() {
+ local script="$1"
+ local line lineno seen name
+ name="$(basename "$script")"
+
+ # 1. Every mention of publication is the help text, the --publish refusal,
+ # or the comment explaining the rehearsal-only crates.io patches. Four
+ # reviewed lines, four occurrences: the floor and the list move together.
+ seen=0
+ while IFS=: read -r lineno line; do
+ seen=$((seen + 1))
+ case "$line" in
+ *'never publishes packages, creates tags'*) continue ;;
+ *'--publish)'*) continue ;;
+ *'ERROR: release rehearsal never publishes'*) continue ;;
+ *'crates already published'*) continue ;;
+ esac
+ printf 'FAIL: %s:%s mentions publication and is not on the reviewed list: %s\n' \
+ "$name" "$lineno" "$line" >&2
+ return 1
+ done < <(grep -niE 'publish' "$script")
+ if [ "$seen" -lt 4 ]; then
+ printf 'FAIL: publication word screen read %s line(s) of %s; expected at least 4 (the screen is broken, not the script)\n' \
+ "$seen" "$name" >&2
+ return 1
+ fi
+
+ # 2. Every invocation of a tool that can publish carries a reviewed
+ # subcommand, where an invocation is the tool in command position: at the
+ # start of a line, or after a pipe, a `&&`, a `;` or a `$(`. This catches
+ # a spelling check 1 cannot see, such as `cargo "${args[@]}"` or a curl
+ # straight at a registry API. Prose that names a tool is not a call.
+ seen=0
+ while IFS=: read -r lineno line; do
+ seen=$((seen + 1))
+ case "$line" in
+ *'cargo metadata '*|*'cargo package '*|*'cargo build '*) continue ;;
+ *'npm pack '*) continue ;;
+ esac
+ printf 'FAIL: %s:%s invokes a tool that can publish with an unreviewed subcommand: %s\n' \
+ "$name" "$lineno" "$line" >&2
+ return 1
+ done < <(grep -nE '(^[[:space:]]*|[|&;(][[:space:]]*)(cargo|npm|gh|curl|wget)[[:space:]]' "$script" \
+ | grep -vE '^[0-9]+:[[:space:]]*#')
+ if [ "$seen" -lt 4 ]; then
+ printf 'FAIL: tool-invocation screen read %s line(s) of %s; expected at least 4 (the screen is broken, not the script)\n' \
+ "$seen" "$name" >&2
+ return 1
+ fi
+}
+
+assert_rehearsal_cannot_publish "$rehearsal"
printf 'Release rehearsal contract passed.\n'
What it does on the same grid
Same harness, same ten lines plus three that break the gate's own input:
| mutation |
patched gate |
cargo publish |
rc=1, names release_rehearsal.sh:150 |
"$CARGO_BIN" publish |
rc=1 |
cargo "${cargo_args[@]}" |
rc=1 |
npm publish --access public |
rc=1 |
gh release create v9.9.9 |
rc=1 |
gh api -X POST repos/o/r/releases |
rc=1 |
curl -X PUT https://crates.io/api/v1/crates/new |
rc=1 |
wget --post-file=x.crate https://crates.io/... |
rc=1 |
out=$(cargo publish --dry-run) |
rc=1 |
true && npm publish |
rc=1 |
| publication word grep respelled to match nothing |
rc=1, screen read 0 line(s) |
| tool list respelled to match nothing |
rc=1, screen read 0 line(s) |
$rehearsal pointed at a file that does not exist |
rc=1 |
Thirteen red, and green on the unmodified tree. bash -n clean,
shellcheck --severity=warning clean on 0.10.0.
Taking this
The patch above is a starting point, not a requirement: argue for a different
shape if you have one. What the change has to show is the grid, both directions.
Break the rehearsal and watch the gate go red; break the gate's own input and
watch it refuse rather than report success over nothing.
Two things to know before you start. tests/release/release-rehearsal.test.sh
has four other open items against it (#441, #442, #444 and the PRs attached to
them), all inside assert_action_pins; this block sits at the end of the file
and the hunks do not overlap, but expect a rebase. And the floor in check 1 is
four because the rehearsal has exactly four benign mentions of publication
today: if you change that script's prose, the list and the floor move together,
which is deliberate.
Difficulty
medium. The diff is one function. The work is the grid: ten publication
spellings in one direction, three broken-extraction cases in the other. No VM,
no provider key, no credentials.
Getting started
CONTRIBUTING.md
has the build and test commands. Run bash tests/release/release-rehearsal.test.sh
once first; it passes on a clean tree in a few seconds. No CLA and no copyright
waiver. The project is MIT.
tests/release/release-rehearsal.test.shis the required gate that keepsscripts/release_rehearsal.shfrom ever becoming a publisher. The control isone grep, at the end of the file:
It knows three spellings. I appended ten publication lines to the rehearsal
script, one at a time in a throwaway clone of
736f945, restoring between each,and ran the gate after every one:
where
$Grunsbash tests/release/release-rehearsal.test.shand prints itsexit code and first
FAIL/passedline.scripts/release_rehearsal.shcargo publishCARGO_BIN=cargo+"$CARGO_BIN" publishcargo_args=(publish)+cargo "${cargo_args[@]}"npm publish --access publicgh release create v9.9.9gh api -X POST repos/o/r/releasescurl -X PUT https://crates.io/api/v1/crates/newwget --post-file=x.crate https://crates.io/api/v1/crates/newout=$(cargo publish --dry-run)true && npm publishSix of ten pass. The last green one is the cheapest to reach by accident:
$(cargo publish ...)putscargoafter a(, and the regex wants it at thestart of a line or after whitespace.
None of this is an exotic attack. A refactor that hoists the toolchain into
CARGO_BINremoves the tripwire while the board stays green, and nothing inthe gate says which property it lost.
The fix
Screen by allowlist rather than by spelling, in two halves, each with a floor so
a broken extraction fails instead of reporting a clean script.
publishin the rehearsal has to be one of thefour reviewed lines: the help text, the
--publish)case, its error message,and the comment about crates already published on crates.io.
cargo,npm,gh,curlorwgetin commandposition has to carry a reviewed subcommand. Today that is exactly four
lines:
cargo metadata,cargo package,cargo build,npm pack.Check 1 catches an indirect spelling that names no tool (
"$CARGO_BIN" publish).Check 2 catches a call that never says "publish" (
gh api -X POST,curlat aregistry). Prose that mentions a tool is not a call, so the help text's "Package
crates and npm setup" stays quiet.
Patch against 736f945. Needs a rebase at ba1a7b8: the guard moved from :168 to :319
What it does on the same grid
Same harness, same ten lines plus three that break the gate's own input:
cargo publishrelease_rehearsal.sh:150"$CARGO_BIN" publishcargo "${cargo_args[@]}"npm publish --access publicgh release create v9.9.9gh api -X POST repos/o/r/releasescurl -X PUT https://crates.io/api/v1/crates/newwget --post-file=x.crate https://crates.io/...out=$(cargo publish --dry-run)true && npm publishscreen read 0 line(s)screen read 0 line(s)$rehearsalpointed at a file that does not existThirteen red, and green on the unmodified tree.
bash -nclean,shellcheck --severity=warningclean on 0.10.0.Taking this
The patch above is a starting point, not a requirement: argue for a different
shape if you have one. What the change has to show is the grid, both directions.
Break the rehearsal and watch the gate go red; break the gate's own input and
watch it refuse rather than report success over nothing.
Two things to know before you start.
tests/release/release-rehearsal.test.shhas four other open items against it (#441, #442, #444 and the PRs attached to
them), all inside
assert_action_pins; this block sits at the end of the fileand the hunks do not overlap, but expect a rebase. And the floor in check 1 is
four because the rehearsal has exactly four benign mentions of publication
today: if you change that script's prose, the list and the floor move together,
which is deliberate.
Difficulty
medium. The diff is one function. The work is the grid: ten publicationspellings in one direction, three broken-extraction cases in the other. No VM,
no provider key, no credentials.
Getting started
CONTRIBUTING.md
has the build and test commands. Run
bash tests/release/release-rehearsal.test.shonce first; it passes on a clean tree in a few seconds. No CLA and no copyright
waiver. The project is MIT.