From fdd8508f73ad5043f2c232e6a9b169a057f282f6 Mon Sep 17 00:00:00 2001 From: w-verify-release-version-hardening Date: Wed, 16 Sep 2026 05:57:00 -0700 Subject: [PATCH 1/2] release: make verify-release-version.sh refuse a silent artifact skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verify-release-version.sh ` with no binary path skipped its whole artifact arm — the `if [ -n "$BINARY" ]` guard — and still printed `PASS: everything states ` and exited 0. A full run prints 7 `ok:` lines; the skipping run printed 4, and nothing in the output or the exit code said the teeth were absent. Both real call sites (ci.yml:32, release.yml:180-181) pass a path, so the exposure was every manual or agent invocation — which is how the skip was found. The binary path is now required unless the caller opts out in writing: unchanged behaviour, summary names both arms --tree-only tree checks run, one loud ARTIFACT ARM NOT RUN line, PASS (TREE ONLY) summary, exit 0 exit 1 naming the arm that did not run --tree-only exit 1, contradictory The final line always states which arms ran, so the exit code is not the only evidence. Usage block and RELEASE.md updated; neither workflow call site is touched. --- RELEASE.md | 10 ++++++ scripts/verify-release-version.sh | 51 ++++++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 2f5f19938..61195376a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -197,6 +197,16 @@ first-class release artifact while `npm i maxplayer` answered `no binary for dar mac (#446), because the build matrix and the npm platform list were independent lists and nothing compared them. +`verify-release-version.sh` has two arms and says which ones ran. The tree arm reads the crate +version, the npm manifests, the payload pins and the release notes; the artifact arm inspects a built +binary's own `--version` line and build stamp. So it is invoked +`./scripts/verify-release-version.sh ` — both call sites in CI and the +release job pass a path, and the version without a leading `v`. A run with no binary path is a +refusal, not a pass: to check the tree alone, ask for it with +`./scripts/verify-release-version.sh --tree-only`, which prints a loud `ARTIFACT ARM NOT +RUN` line and a `PASS (TREE ONLY)` summary. Before that, a missing path silently skipped the artifact +arm and still printed `PASS: everything states ` with exit 0. + The trusted-publisher entry is the step no check can make: it lives on npmjs.com, needs an org admin, and must exist BEFORE the tag. Without it the publish job fails on that package — loudly, which is correct — but only after the packages ahead of it in the loop have published, and npm does not allow diff --git a/scripts/verify-release-version.sh b/scripts/verify-release-version.sh index 51cadcf7e..e90a9efb9 100755 --- a/scripts/verify-release-version.sh +++ b/scripts/verify-release-version.sh @@ -25,23 +25,58 @@ # and is not — and it is why `(unknown)` fails a release closed here even though the binary is # allowed to print it from a build path that genuinely had no git to read. # +# ── Which arms ran is part of the result (#1007 follow-up) ─────────────────────────────────────── +# This script has two arms: the TREE checks (crate version, npm manifests, payload pins, release +# notes) and the ARTIFACT check (the built binary's own `--version` line and build stamp). The +# artifact arm used to be skipped whenever no binary path was supplied — and the run still printed +# `PASS: everything states ` and exited 0. A gate whose teeth are absent must not read as a +# full pass, so a binary path is now REQUIRED unless the caller opts out in writing with +# `--tree-only`, and the final line always names the arms that actually ran. +# # Usage: -# ./scripts/verify-release-version.sh [path-to-binary] +# ./scripts/verify-release-version.sh # both arms (releases, CI) +# ./scripts/verify-release-version.sh --tree-only # tree arm only, says so loudly # e.g. ./scripts/verify-release-version.sh 0.1.0 result/bin/maxplayer +# ./scripts/verify-release-version.sh 0.1.0 --tree-only # # Pass the version WITHOUT a leading `v` — the workflow strips it from the tag. set -euo pipefail -VERSION="${1:-}" -BINARY="${2:-}" +USAGE="usage: verify-release-version.sh | verify-release-version.sh --tree-only" die() { echo "verify-release-version: $*" >&2; exit 1; } -[ -n "$VERSION" ] || die "usage: verify-release-version.sh [path-to-binary]" +VERSION="" +BINARY="" +TREE_ONLY=0 +for arg in "$@"; do + case "$arg" in + --tree-only) TREE_ONLY=1 ;; + -h|--help) echo "$USAGE"; exit 0 ;; + -*) die "unknown option '$arg' — $USAGE" ;; + *) + if [ -z "$VERSION" ]; then VERSION="$arg" + elif [ -z "$BINARY" ]; then BINARY="$arg" + else die "unexpected extra argument '$arg' — $USAGE" + fi + ;; + esac +done + +[ -n "$VERSION" ] || die "$USAGE" case "$VERSION" in v*) die "pass the version without a leading 'v' (got '$VERSION')" ;; esac + +# Fail closed on a missing artifact rather than skipping its arm: a run that inspected no binary +# proves nothing about the thing being shipped, and must not be mistakable for a full pass. +if [ -n "$BINARY" ] && [ "$TREE_ONLY" -eq 1 ]; then + die "--tree-only was given together with the binary path '$BINARY' — pick one: check the artifact, or declare in writing that you are not checking it" +fi +if [ -z "$BINARY" ] && [ "$TREE_ONLY" -eq 0 ]; then + die "the ARTIFACT arm did not run: no path to a built binary was given, so nothing here would have checked that the artifact reports $VERSION or carries a resolvable build stamp. Pass the binary — $USAGE — or opt out explicitly with --tree-only, which passes while naming the arm it skipped" +fi [ -f Cargo.toml ] || die "run from the repo root (Cargo.toml missing)" # Fail closed on the tools rather than skipping a check: a version check that silently did not run @@ -172,4 +207,10 @@ if [ -n "$BINARY" ]; then echo "ok: build stamp $stamp_sha resolves to a commit and is this tree's HEAD" fi -echo "PASS: everything states $VERSION" +TREE_ARM="tree (crate version, npm manifests, payload pins, release notes)" +if [ -n "$BINARY" ]; then + echo "PASS: everything states $VERSION — arms run: $TREE_ARM + artifact ($BINARY)" +else + echo "!! ARTIFACT ARM NOT RUN (--tree-only): no binary was inspected — nothing here says the built artifact reports $VERSION or carries a build stamp resolving to this commit" + echo "PASS (TREE ONLY): the tree states $VERSION — arms run: $TREE_ARM; artifact arm NOT RUN" +fi From c669bb59c40ee1ba1a23edd5c9e3c5f5c6a716ad Mon Sep 17 00:00:00 2001 From: w-verify-release-version-hardening Date: Wed, 16 Sep 2026 06:07:16 -0700 Subject: [PATCH 2/2] release: spell the opt-out --no-artifacts, as verify-release-surface.sh does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opt-out this script grew for its artifact arm was invented locally (`--tree-only`) while the repo already had one: `verify-release-surface.sh` takes `--no-artifacts` and prints ok: skipping the built-artifact check (--no-artifacts) — holding the tree surfaces only Two release gates with two names for the same decision is two things to learn and one of them to get wrong. So this script now takes `--no-artifacts`, and prints the skip in the same shape and the same place — where the artifact check would have run, never silently: ok: skipping the built-artifact check (--no-artifacts) — holding the tree-stated version only; nothing here binds a built artifact to or to this commit The summary closes `PASS (NO ARTIFACTS): … the built-artifact check did NOT run (--no-artifacts)`, so the arms that ran are on the line either way. Behaviour is otherwise unchanged: a path still runs both arms and exits 0, a bare version is still a refusal, and no call site is touched. --- RELEASE.md | 9 ++++---- scripts/verify-release-version.sh | 38 +++++++++++++++++++------------ 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 61195376a..298c87d49 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -202,10 +202,11 @@ version, the npm manifests, the payload pins and the release notes; the artifact binary's own `--version` line and build stamp. So it is invoked `./scripts/verify-release-version.sh ` — both call sites in CI and the release job pass a path, and the version without a leading `v`. A run with no binary path is a -refusal, not a pass: to check the tree alone, ask for it with -`./scripts/verify-release-version.sh --tree-only`, which prints a loud `ARTIFACT ARM NOT -RUN` line and a `PASS (TREE ONLY)` summary. Before that, a missing path silently skipped the artifact -arm and still printed `PASS: everything states ` with exit 0. +refusal, not a pass: to check the tree alone, ask for it by name with +`./scripts/verify-release-version.sh --no-artifacts` — the same opt-out flag +`verify-release-surface.sh` takes, printing the same `ok: skipping the built-artifact check +(--no-artifacts) — …` line and closing with `PASS (NO ARTIFACTS)`. Before that, a missing path +silently skipped the artifact arm and still printed `PASS: everything states ` with exit 0. The trusted-publisher entry is the step no check can make: it lives on npmjs.com, needs an org admin, and must exist BEFORE the tag. Without it the publish job fails on that package — loudly, which is diff --git a/scripts/verify-release-version.sh b/scripts/verify-release-version.sh index e90a9efb9..bca95da96 100755 --- a/scripts/verify-release-version.sh +++ b/scripts/verify-release-version.sh @@ -31,28 +31,33 @@ # artifact arm used to be skipped whenever no binary path was supplied — and the run still printed # `PASS: everything states ` and exited 0. A gate whose teeth are absent must not read as a # full pass, so a binary path is now REQUIRED unless the caller opts out in writing with -# `--tree-only`, and the final line always names the arms that actually ran. +# `--no-artifacts`, and the final line always names the arms that actually ran. +# +# The flag is `--no-artifacts`, spelled and printed the way `verify-release-surface.sh` already +# spells and prints it — same opt-out name, same `ok: skipping the built-artifact check +# (--no-artifacts) — …` line — so one house style covers both release gates and a reader who knows +# one knows the other. # # Usage: -# ./scripts/verify-release-version.sh # both arms (releases, CI) -# ./scripts/verify-release-version.sh --tree-only # tree arm only, says so loudly +# ./scripts/verify-release-version.sh # both arms (releases, CI) +# ./scripts/verify-release-version.sh --no-artifacts # tree arm only, and says so # e.g. ./scripts/verify-release-version.sh 0.1.0 result/bin/maxplayer -# ./scripts/verify-release-version.sh 0.1.0 --tree-only +# ./scripts/verify-release-version.sh 0.1.0 --no-artifacts # # Pass the version WITHOUT a leading `v` — the workflow strips it from the tag. set -euo pipefail -USAGE="usage: verify-release-version.sh | verify-release-version.sh --tree-only" +USAGE="usage: verify-release-version.sh " die() { echo "verify-release-version: $*" >&2; exit 1; } VERSION="" BINARY="" -TREE_ONLY=0 +NO_ARTIFACTS=0 for arg in "$@"; do case "$arg" in - --tree-only) TREE_ONLY=1 ;; + --no-artifacts) NO_ARTIFACTS=1 ;; -h|--help) echo "$USAGE"; exit 0 ;; -*) die "unknown option '$arg' — $USAGE" ;; *) @@ -71,11 +76,11 @@ esac # Fail closed on a missing artifact rather than skipping its arm: a run that inspected no binary # proves nothing about the thing being shipped, and must not be mistakable for a full pass. -if [ -n "$BINARY" ] && [ "$TREE_ONLY" -eq 1 ]; then - die "--tree-only was given together with the binary path '$BINARY' — pick one: check the artifact, or declare in writing that you are not checking it" +if [ -n "$BINARY" ] && [ "$NO_ARTIFACTS" -eq 1 ]; then + die "--no-artifacts was given together with the binary path '$BINARY' — pick one: check the artifact, or declare in writing that you are not checking it" fi -if [ -z "$BINARY" ] && [ "$TREE_ONLY" -eq 0 ]; then - die "the ARTIFACT arm did not run: no path to a built binary was given, so nothing here would have checked that the artifact reports $VERSION or carries a resolvable build stamp. Pass the binary — $USAGE — or opt out explicitly with --tree-only, which passes while naming the arm it skipped" +if [ -z "$BINARY" ] && [ "$NO_ARTIFACTS" -eq 0 ]; then + die "the ARTIFACT arm did not run: no path to a built binary was given, so nothing here would have checked that the artifact reports $VERSION or carries a resolvable build stamp. Pass the binary — $USAGE — or opt out explicitly with --no-artifacts, which passes while naming the arm it skipped" fi [ -f Cargo.toml ] || die "run from the repo root (Cargo.toml missing)" @@ -205,12 +210,17 @@ if [ -n "$BINARY" ]; then [ "$stamp_sha" = "$head_sha" ] \ || die "the artifact was built from $stamp_sha but this tree is at $head_sha — the binary and the source being packaged are different commits" echo "ok: build stamp $stamp_sha resolves to a commit and is this tree's HEAD" +else + # The skip is printed, never silent, and in the same words `verify-release-surface.sh` uses for + # its own `--no-artifacts` opt-out — the reader of either gate's log sees one house style. + echo "ok: skipping the built-artifact check (--no-artifacts) — holding the tree-stated version only; nothing here binds a built artifact to $VERSION or to this commit" fi +# The summary states which arms ran, so a reader does not have to infer it from the exit code — the +# inference that let a 4-line run and a 7-line run both read as `PASS: everything states `. TREE_ARM="tree (crate version, npm manifests, payload pins, release notes)" if [ -n "$BINARY" ]; then - echo "PASS: everything states $VERSION — arms run: $TREE_ARM + artifact ($BINARY)" + echo "PASS: everything states $VERSION — arms run: $TREE_ARM + built artifact ($BINARY)" else - echo "!! ARTIFACT ARM NOT RUN (--tree-only): no binary was inspected — nothing here says the built artifact reports $VERSION or carries a build stamp resolving to this commit" - echo "PASS (TREE ONLY): the tree states $VERSION — arms run: $TREE_ARM; artifact arm NOT RUN" + echo "PASS (NO ARTIFACTS): the tree states $VERSION — arms run: $TREE_ARM; the built-artifact check did NOT run (--no-artifacts)" fi