From 862cbe93be72391a570a436517846669c5fd702a Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sun, 4 Oct 2026 07:54:18 -0400 Subject: [PATCH 1/6] Add scripts/publish.sh: the owner's PyPI publish, with DRY_RUN and a read-back DRY_RUN=1 checks the checkout (clean, HEAD equal to origin/main), the version (X.Y.Z), the CHANGELOG heading and that PyPI does not have the version yet; builds the sdist and the wheel, checks them (twine check --strict, the vendored CLI's version), installs the wheel into a fresh environment and runs the smoke check, runs uv publish --dry-run, and records dist/SHA256SUMS and dist/COMMIT. It uploads nothing. The live run (under op run, UV_PUBLISH_TOKEN) uploads exactly the files the dry run built and checked, then reads the release back from PyPI for about five minutes and installs it into a fresh environment, which must print CLI_VERSION. A failed upload says to read back before any retry; a read-back that times out says to run it again and never that the upload did not land. Refs npstorey/typedstandards#135 (G0 D4 = A). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ru4PYga7Zf8HANgotZKs4u Signed-off-by: Nathan Storey --- scripts/publish.sh | 177 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100755 scripts/publish.sh diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..b6edce3 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Publish typedstandards to PyPI (typedstandards#135, G0 D4 = A). The owner runs this, in their own +# terminal, from a clean checkout of main at the release commit. It never prints the token. +# +# DRY_RUN=1 scripts/publish.sh +# Checks the checkout, the version and the CHANGELOG heading, and that PyPI does not have this +# version yet. Builds the sdist and the wheel into dist/, checks them, installs the wheel into +# a fresh environment and runs the smoke check there, then runs `uv publish --dry-run`. +# Prints each file's SHA-256 and the live command. Uploads nothing. +# +# op run --env-file=pypi.env -- scripts/publish.sh +# Uploads exactly the files the dry run built and checked: dist/ must match dist/SHA256SUMS +# and the commit the dry run recorded. Then reads the release back from PyPI for about five +# minutes, and installs it into a fresh environment, which must print CLI_VERSION. +# pypi.env holds a 1Password reference, never a value: +# UV_PUBLISH_TOKEN=op://// +# +# READ_BACK=1 scripts/publish.sh +# Only the read-back and the fresh-environment check, for a run that stopped after uploading. +# +# Overrides, for testing the script itself: RELEASE_REF (the ref HEAD must equal; default +# origin/main), PACKAGE and VERSION (default this package and its __version__), DIST (default +# dist), READ_BACK_SECONDS (default 300), CHECK_INSTALL=0 (skip the fresh-environment install). + +set -euo pipefail + +say() { printf 'publish: %s\n' "$*"; } +die() { printf 'publish: %s\n' "$*" >&2; exit 1; } + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +PACKAGE="${PACKAGE:-typedstandards}" +VERSION="${VERSION:-$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' src/typedstandards/__init__.py)}" +CLI_VERSION="$(sed -n 's/^CLI_VERSION = "\(.*\)"$/\1/p' src/typedstandards/__init__.py)" +DIST="${DIST:-dist}" +RELEASE_REF="${RELEASE_REF:-origin/main}" +READ_BACK_SECONDS="${READ_BACK_SECONDS:-300}" +CHECK_INSTALL="${CHECK_INSTALL:-1}" +PYPI="https://pypi.org" +SDIST="$PACKAGE-$VERSION.tar.gz" +WHEEL="$PACKAGE-$VERSION-py3-none-any.whl" + +sha256_of() { shasum -a 256 "$1" | cut -d' ' -f1; } + +# The JSON PyPI serves for this version, or nothing when it does not have it (yet). +pypi_release_json() { curl -sf --max-time 30 "$PYPI/pypi/$PACKAGE/$VERSION/json" || true; } + +# Exit 0 when PyPI lists every file in $DIST/SHA256SUMS with the same SHA-256; print what it lists. +pypi_matches_sums() { + local json + json="$(pypi_release_json)" + [ -n "$json" ] || { say "PyPI does not list $PACKAGE $VERSION yet"; return 1; } + JSON="$json" uv run --no-project --quiet python - "$DIST/SHA256SUMS" <<'PY' +import json, os, sys +listed = {f["filename"]: f["digests"]["sha256"] for f in json.loads(os.environ["JSON"])["urls"]} +ok = True +for line in open(sys.argv[1], encoding="utf-8"): + digest, name = line.split() + name = name.lstrip("*") + seen = listed.get(name) + state = "same SHA-256" if seen == digest else ("not listed yet" if seen is None else f"DIFFERENT SHA-256 {seen}") + print(f"publish: {name} {digest} PyPI: {state}") + ok = ok and seen == digest +sys.exit(0 if ok else 1) +PY +} + +read_back() { + [ -f "$DIST/SHA256SUMS" ] || die "$DIST/SHA256SUMS is missing: it is written by the dry run" + say "reading $PACKAGE $VERSION back from $PYPI, for up to $READ_BACK_SECONDS seconds" + local deadline=$((SECONDS + READ_BACK_SECONDS)) + until pypi_matches_sums; do + if [ "$SECONDS" -ge "$deadline" ]; then + say "PyPI has not listed every file with these hashes within $READ_BACK_SECONDS seconds." + say "An upload can take longer to appear; this alone does not mean it failed." + say "Run the read-back again: READ_BACK=1 scripts/publish.sh" + exit 1 + fi + sleep 15 + done + say "read back: PyPI lists $PACKAGE $VERSION with the SHA-256 of every file built and checked" + [ "$CHECK_INSTALL" = "1" ] || return 0 + say "installing $PACKAGE==$VERSION from PyPI into a fresh environment" + local tmp printed + tmp="$(mktemp -d)" + until printed="$(cd "$tmp" && env -u UV_PUBLISH_TOKEN uv run --no-project --isolated --refresh --quiet \ + --with "$PACKAGE==$VERSION" python -c 'import typedstandards; print("CLI_VERSION", typedstandards.CLI_VERSION)' 2>/dev/null)"; do + if [ "$SECONDS" -ge "$deadline" ]; then + say "the index has not served $PACKAGE==$VERSION to uv yet; the read-back above found it." + say "Run the read-back again in a few minutes: READ_BACK=1 scripts/publish.sh" + exit 1 + fi + sleep 15 + done + rm -rf "$tmp" + say "fresh environment: $printed" + [ "$printed" = "CLI_VERSION $CLI_VERSION" ] || die "expected CLI_VERSION $CLI_VERSION" +} + +if [ "${READ_BACK:-0}" = "1" ]; then + read_back + exit 0 +fi + +# --- the checks both the dry run and the live run make ------------------------------------- +for tool in git uv node npm curl shasum; do + command -v "$tool" >/dev/null || die "$tool is not on PATH" +done +[ -z "$(git status --porcelain --untracked-files=normal)" ] || die "the checkout has changes; publish from a clean checkout" +git fetch --quiet origin main +[ "$(git rev-parse HEAD)" = "$(git rev-parse "$RELEASE_REF")" ] || + die "HEAD $(git rev-parse --short HEAD) is not $RELEASE_REF $(git rev-parse --short "$RELEASE_REF"); publish from the release commit" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "__version__ is '$VERSION', not a release version (X.Y.Z)" +heading="$(grep -m1 -E "^## $VERSION — [0-9]{4}-[0-9]{2}-[0-9]{2}$" CHANGELOG.md || true)" +[ -n "$heading" ] || die "CHANGELOG.md has no heading '## $VERSION — '" +dated="${heading##* }" +status="$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "$PYPI/pypi/$PACKAGE/$VERSION/json")" +case "$status" in + 404) ;; + 200) die "PyPI already has $PACKAGE $VERSION: a version is uploaded once. To check it: READ_BACK=1 scripts/publish.sh" ;; + *) die "PyPI answered $status for $PACKAGE $VERSION; try again" ;; +esac +say "checkout $(git rev-parse --short HEAD) = $RELEASE_REF; $PACKAGE $VERSION (CHANGELOG dated $dated); not on PyPI yet" + +if [ "${DRY_RUN:-0}" = "1" ]; then + rm -rf "$DIST" + say "building the sdist, and the wheel from it (the build vendors @typedstandards/cli with npm ci)" + uv build --out-dir "$DIST" + [ "$(ls "$DIST" | sort)" = "$(printf '%s\n%s\n' "$SDIST" "$WHEEL" | sort)" ] || die "$DIST holds $(ls "$DIST" | tr '\n' ' '), not $SDIST and $WHEEL" + uv tool run --quiet twine check --strict "$DIST/$SDIST" "$DIST/$WHEEL" + WHEEL_PATH="$DIST/$WHEEL" CLI_VERSION="$CLI_VERSION" uv run --no-project --quiet python - <<'PY' +import json, os, sys, zipfile +names = zipfile.ZipFile(os.environ["WHEEL_PATH"]).namelist() +cli = "typedstandards/_vendor/node_modules/@typedstandards/cli/package.json" +if cli not in names: + sys.exit("publish: the wheel does not carry the vendored CLI") +version = json.loads(zipfile.ZipFile(os.environ["WHEEL_PATH"]).read(cli))["version"] +if version != os.environ["CLI_VERSION"]: + sys.exit(f"publish: the wheel vendors @typedstandards/cli {version}, not {os.environ['CLI_VERSION']}") +vendored = sum(1 for n in names if "/_vendor/node_modules/" in n) +print(f"publish: the wheel vendors @typedstandards/cli {version} ({vendored} files)") +PY + tmp="$(mktemp -d)" + uv venv --quiet "$tmp/venv" + uv pip install --quiet --python "$tmp/venv" "$DIST/$WHEEL" + say "smoke check from the installed wheel, with a throwaway seed" + env -u UV_PUBLISH_TOKEN TYPEDSTANDARDS_SIGNING_SEED_B64="$(openssl rand -base64 32)" "$tmp/venv/bin/python" scripts/smoke_wheel.py + rm -rf "$tmp" + (cd "$DIST" && shasum -a 256 "$SDIST" "$WHEEL" > SHA256SUMS) + git rev-parse HEAD > "$DIST/COMMIT" + # Without a token, uv publish --dry-run still checks the files and the command line. + env -u UV_PUBLISH_TOKEN uv publish --dry-run --token dry-run "$DIST/$SDIST" "$DIST/$WHEEL" + say "built and checked, at $(cat "$DIST/COMMIT"):" + sed 's/^/publish: /' "$DIST/SHA256SUMS" + say "DRY_RUN: nothing was uploaded. To upload these two files:" + say " op run --env-file=pypi.env -- scripts/publish.sh" + exit 0 +fi + +# --- the live run --------------------------------------------------------------------------- +[ -n "${UV_PUBLISH_TOKEN:-}" ] || die "UV_PUBLISH_TOKEN is not set: run through op run --env-file=pypi.env" +[ "$dated" = "$(date +%F)" ] || die "CHANGELOG.md dates $VERSION $dated, and today is $(date +%F): the heading names the publish day" +[ -f "$DIST/SHA256SUMS" ] && [ -f "$DIST/COMMIT" ] || die "run DRY_RUN=1 scripts/publish.sh first: the live run uploads what it built" +[ "$(cat "$DIST/COMMIT")" = "$(git rev-parse HEAD)" ] || die "$DIST was built at $(cat "$DIST/COMMIT"), not HEAD; run the dry run again" +(cd "$DIST" && shasum -a 256 -c SHA256SUMS) || die "$DIST does not match SHA256SUMS; run the dry run again" +say "uploading $SDIST and $WHEEL" +set +e +uv publish --check-url "$PYPI/simple/" "$DIST/$SDIST" "$DIST/$WHEEL" +code=$? +set -e +if [ "$code" -ne 0 ]; then + say "uv publish exited $code. An upload can land even when the command fails." + say "Before any retry, check what PyPI has: READ_BACK=1 scripts/publish.sh" + exit "$code" +fi +read_back From e38da729e96e6a05f5264c396404c21cd42708e2 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sun, 4 Oct 2026 07:58:26 -0400 Subject: [PATCH 2/6] publish.sh: stop the read-back at once on a different SHA-256 A file PyPI lists with a different SHA-256 is final, since a version cannot be uploaded twice. The read-back now exits 2 at once and says so, where it waited out its window and said the upload might still appear. A file not listed yet still waits, and a timeout still says to run the read-back again. Refs npstorey/typedstandards#135. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ru4PYga7Zf8HANgotZKs4u Signed-off-by: Nathan Storey --- scripts/publish.sh | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/scripts/publish.sh b/scripts/publish.sh index b6edce3..6c5062c 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -46,7 +46,8 @@ sha256_of() { shasum -a 256 "$1" | cut -d' ' -f1; } # The JSON PyPI serves for this version, or nothing when it does not have it (yet). pypi_release_json() { curl -sf --max-time 30 "$PYPI/pypi/$PACKAGE/$VERSION/json" || true; } -# Exit 0 when PyPI lists every file in $DIST/SHA256SUMS with the same SHA-256; print what it lists. +# Print what PyPI lists for each file in $DIST/SHA256SUMS. Exit 0 when every file is listed with the +# same SHA-256, 2 when any is listed with a different one, 1 when any is not listed yet. pypi_matches_sums() { local json json="$(pypi_release_json)" @@ -54,15 +55,16 @@ pypi_matches_sums() { JSON="$json" uv run --no-project --quiet python - "$DIST/SHA256SUMS" <<'PY' import json, os, sys listed = {f["filename"]: f["digests"]["sha256"] for f in json.loads(os.environ["JSON"])["urls"]} -ok = True +missing = different = False for line in open(sys.argv[1], encoding="utf-8"): digest, name = line.split() name = name.lstrip("*") seen = listed.get(name) state = "same SHA-256" if seen == digest else ("not listed yet" if seen is None else f"DIFFERENT SHA-256 {seen}") print(f"publish: {name} {digest} PyPI: {state}") - ok = ok and seen == digest -sys.exit(0 if ok else 1) + missing = missing or seen is None + different = different or (seen is not None and seen != digest) +sys.exit(2 if different else 1 if missing else 0) PY } @@ -70,7 +72,16 @@ read_back() { [ -f "$DIST/SHA256SUMS" ] || die "$DIST/SHA256SUMS is missing: it is written by the dry run" say "reading $PACKAGE $VERSION back from $PYPI, for up to $READ_BACK_SECONDS seconds" local deadline=$((SECONDS + READ_BACK_SECONDS)) - until pypi_matches_sums; do + while :; do + local rc=0 + pypi_matches_sums || rc=$? + [ "$rc" -eq 0 ] && break + if [ "$rc" -eq 2 ]; then + say "PyPI lists a file of $PACKAGE $VERSION with a different SHA-256 from the file built here," + say "so what PyPI serves is not this build. A version cannot be uploaded twice: do not retry." + say "Compare $DIST/SHA256SUMS with $PYPI/project/$PACKAGE/$VERSION/#files" + exit 2 + fi if [ "$SECONDS" -ge "$deadline" ]; then say "PyPI has not listed every file with these hashes within $READ_BACK_SECONDS seconds." say "An upload can take longer to appear; this alone does not mean it failed." From f9d771166c4dd5db8e538b00f6d10fed2b72dd3d Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sun, 4 Oct 2026 08:01:33 -0400 Subject: [PATCH 3/6] smoke_wheel.py: resolve the CLI entry before comparing it with the package The check compared the resolved package directory with the unresolved CLI entry, so it failed whenever the environment sat behind a symlink, as a venv under macOS's default temporary directory (/var -> /private/var) does. The wheel was correct; the check was not. Found by the publish script's dry run. Refs npstorey/typedstandards#135. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ru4PYga7Zf8HANgotZKs4u Signed-off-by: Nathan Storey --- scripts/smoke_wheel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/smoke_wheel.py b/scripts/smoke_wheel.py index 9401003..50007c6 100644 --- a/scripts/smoke_wheel.py +++ b/scripts/smoke_wheel.py @@ -33,7 +33,7 @@ def main() -> int: print(f"CLI_VERSION {typedstandards.CLI_VERSION}; the vendored CLI's --version prints {printed}") assert printed == typedstandards.CLI_VERSION - entry = typedstandards.cli_entry() + entry = typedstandards.cli_entry().resolve() assert entry.is_relative_to(package / "_vendor"), entry vendor = package / "_vendor" lock = json.loads((vendor / "package-lock.json").read_text(encoding="utf-8")) From 9edb78b317211696a6ed775a0bebc702b5a26d55 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sun, 4 Oct 2026 08:02:29 -0400 Subject: [PATCH 4/6] publish.sh: say that uv's dry run sends nothing, and show it uv publish --dry-run prints "Uploading" for each file while sending nothing. The dry run now says so before it runs uv, and afterwards checks that PyPI still answers 404 for the version. Refs npstorey/typedstandards#135. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ru4PYga7Zf8HANgotZKs4u Signed-off-by: Nathan Storey --- scripts/publish.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/publish.sh b/scripts/publish.sh index 6c5062c..e6bad8e 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -160,8 +160,13 @@ PY rm -rf "$tmp" (cd "$DIST" && shasum -a 256 "$SDIST" "$WHEEL" > SHA256SUMS) git rev-parse HEAD > "$DIST/COMMIT" - # Without a token, uv publish --dry-run still checks the files and the command line. + # uv publish --dry-run checks the files and the command line, and sends nothing. Its output says + # "Uploading" all the same, so the dry run then shows PyPI still without this version. + say "uv publish --dry-run (its output says \"Uploading\"; it sends nothing):" env -u UV_PUBLISH_TOKEN uv publish --dry-run --token dry-run "$DIST/$SDIST" "$DIST/$WHEEL" + status="$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "$PYPI/pypi/$PACKAGE/$VERSION/json")" + [ "$status" = "404" ] || die "after the dry run PyPI answers $status for $PACKAGE $VERSION, not 404; stop and check" + say "PyPI still answers 404 for $PACKAGE $VERSION" say "built and checked, at $(cat "$DIST/COMMIT"):" sed 's/^/publish: /' "$DIST/SHA256SUMS" say "DRY_RUN: nothing was uploaded. To upload these two files:" From d02e59ec423d6b10c5eb932590b52e59bb31cf96 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sun, 4 Oct 2026 08:40:00 -0400 Subject: [PATCH 5/6] Release 0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit __version__ 0.1.0, and CHANGELOG.md's Unreleased entries, verbatim, under "## 0.1.0 — 2026-10-04", the publish day the owner named. uv.lock is unchanged: `uv lock --check` passes, since the lock records no version for the project. It pins @typedstandards/cli 0.2.0 (CLI_VERSION), as the version tests check. Refs npstorey/typedstandards#135 (G0 D3 = A: one release, 0.1.0, after P2). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ru4PYga7Zf8HANgotZKs4u Signed-off-by: Nathan Storey --- CHANGELOG.md | 2 +- src/typedstandards/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba9dc38..e08d1f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.1.0 — 2026-10-04 - `sign`, `withdraw`, `attest`, `view` and `verify`: pass-throughs to `@typedstandards/cli` 0.2.0, vendored into the wheel at build time and run as a child process with the inherited environment. diff --git a/src/typedstandards/__init__.py b/src/typedstandards/__init__.py index 5440011..6668625 100644 --- a/src/typedstandards/__init__.py +++ b/src/typedstandards/__init__.py @@ -26,7 +26,7 @@ ) from .pin import Pinned, pin -__version__ = "0.1.0.dev0" +__version__ = "0.1.0" #: The version of @typedstandards/cli this release vendors (package.json pins it exactly). CLI_VERSION = "0.2.0" From 3db7f61ecfb5573a681a659afb4642bbfa53bd4e Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sun, 4 Oct 2026 09:11:22 -0400 Subject: [PATCH 6/6] publish.sh: refuse a token that does not start with "pypi-" An op:// reference that op run did not resolve, or a value kept with its quotes, was sent to PyPI as the token. The live run now refuses any UV_PUBLISH_TOKEN that does not start with "pypi-", right after checking that it is set, with a message naming the env file's one unquoted line and the op run command. The value is never printed. Asked by the seat at the P2r check. Refs npstorey/typedstandards#135. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ru4PYga7Zf8HANgotZKs4u Signed-off-by: Nathan Storey --- scripts/publish.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/publish.sh b/scripts/publish.sh index e6bad8e..f44f7f6 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -12,8 +12,10 @@ # Uploads exactly the files the dry run built and checked: dist/ must match dist/SHA256SUMS # and the commit the dry run recorded. Then reads the release back from PyPI for about five # minutes, and installs it into a fresh environment, which must print CLI_VERSION. -# pypi.env holds a 1Password reference, never a value: +# pypi.env holds a 1Password reference, never a value, on one unquoted line: # UV_PUBLISH_TOKEN=op://// +# The live run refuses a token that does not start with "pypi-", which is what an unresolved +# or quoted reference looks like. # # READ_BACK=1 scripts/publish.sh # Only the read-back and the fresh-environment check, for a run that stopped after uploading. @@ -176,6 +178,12 @@ fi # --- the live run --------------------------------------------------------------------------- [ -n "${UV_PUBLISH_TOKEN:-}" ] || die "UV_PUBLISH_TOKEN is not set: run through op run --env-file=pypi.env" +# A PyPI API token starts with "pypi-". An op:// reference that op run did not resolve, or a value +# kept with its quotes, would otherwise go to PyPI as the token. The value is never printed. +case "$UV_PUBLISH_TOKEN" in + pypi-*) ;; + *) die "UV_PUBLISH_TOKEN does not start with \"pypi-\", so it is not a PyPI API token: an op:// reference op run did not resolve, or a quoted value, reads this way. pypi.env holds one unquoted line, UV_PUBLISH_TOKEN=op:////; run: op run --env-file= -- scripts/publish.sh" ;; +esac [ "$dated" = "$(date +%F)" ] || die "CHANGELOG.md dates $VERSION $dated, and today is $(date +%F): the heading names the publish day" [ -f "$DIST/SHA256SUMS" ] && [ -f "$DIST/COMMIT" ] || die "run DRY_RUN=1 scripts/publish.sh first: the live run uploads what it built" [ "$(cat "$DIST/COMMIT")" = "$(git rev-parse HEAD)" ] || die "$DIST was built at $(cat "$DIST/COMMIT"), not HEAD; run the dry run again"