Skip to content

The rehearsal gate screens three publication spellings, so $(cargo publish) and a curl at crates.io pass #458

Description

@vladimirrott

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.

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingclaimedSomeone has said in the thread that they are working on thishelp wantedExtra attention is neededmediumDifficulty: needs familiarity with one subsystem

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions