Summary
scripts/cut-standards-release.sh aborts mid-write on bash 3.2 (the macOS system bash), leaving the release channel in a partially-cut state: the immutable release tag is created, the moving channel tag is not.
Hit on the very first real use of the script — cutting standards/v1.0.0 on 2026-09-13.
Reproduction
$ bash --version
GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)
$ bash scripts/cut-standards-release.sh cut v1.0.0 --commit 782e005f8359...
repo: petry-projects/.github
release: standards/v1.0.0 -> 782e005f8359
channel: standards/v1-stable -> 782e005f8359
decision: CREATE
creating immutable release standards/v1.0.0 at 782e005f8359...
moving channel standards/v1-stable onto 782e005f8359...
scripts/cut-standards-release.sh: line 275: ${out,,}: bad substitution
Cause
_gh_move_tag() (line ~275) lowercases the API error with ${out,,}:
out="$(gh api -X PATCH "repos/$repo/git/refs/tags/$tag" -f sha="$sha" -F force=true 2>&1)" && return 0
low="${out,,}" # <-- bash 4+ only
${var,,} is a bash 4.0 case-modification expansion. On bash 3.2 it is a syntax error, and under set -euo pipefail the script dies immediately.
The expansion is only used for case-insensitive matching of a "not found" / 404 message to decide PATCH-vs-POST — so the failure happens on the create path, which is exactly the path a first-ever cut takes.
Why the partial state is the real problem
The script's stated contract is that cutting is "a scripted, idempotent, clobber-refusing operation instead of a one-off hand action". On bash 3.2 it is not atomic: it created standards/v1.0.0 and then died before creating standards/v1-stable. Observed state afterwards:
standards/v1.0.0 -> annotated tag -> 782e005f8359... (created)
standards/v1-stable -> 404 (missing)
An immutable release that consumers cannot reach through the channel they pin. And because the release tag is clobber-refusing by design, a naive re-run does not repair it — it correctly reports NOOP on the release while the channel stays absent.
In this instance the channel was completed manually via the same POST /git/refs call the script's own fallback performs, and the result verifies:
$ bash scripts/cut-standards-release.sh resolve
current: standards/v1.0.0 (channel standards/v1-stable)
Acceptance criteria
- Runs on bash 3.2. Replace
${out,,} with a portable lowercase (tr '[:upper:]' '[:lower:]') or a case-insensitive match, and sweep the file for other bash 4+ constructs (${x^^}, declare -A, mapfile/readarray, &>>).
- A failed channel move does not leave a published release stranded. Either the cut is ordered/recoverable so a re-run completes the channel (the current
NOOP path should still ensure the channel is aligned, which the dry-run text already claims it does), or the failure is reported loudly enough that the operator knows a manual step remains. Silent partial success is the defect.
- A regression test runs the script under bash 3.2 semantics, or CI lints for bash 4+ constructs in
scripts/**. This script is operator-run from a workstation, so "CI uses newer bash" is not sufficient coverage — macOS is the likely execution environment.
- The existing published state is left intact.
standards/v1.0.0 and standards/v1-stable both resolve to 782e005f8359... today and must not be clobbered or re-pointed by the fix.
- Idempotency is preserved and proven: re-running the cut at the same commit stays
NOOP on the release and still converges the channel.
Out of scope
- Changing the release/channel naming or the N-1 policy (#1448).
- Automating when a cut happens — a human still decides that.
Dev Notes
- Same portability class as the
sed -i and date -u -d findings raised on #1742 and elsewhere: GNU-only constructs in scripts that are actually run on macOS. Worth checking whether scripts/lib/standards-release.sh (the pure core) has the same issue — the failure here was in the thin I/O glue, which is less well covered by tests/standards_release.bats.
canary-rollout.sh has a sibling _gh_move_tag; check whether it carries the same construct, since it is the pattern this script mirrored.
Observed 2026-09-13 cutting the first standards release (#1091 / PR #1094).
Summary
scripts/cut-standards-release.shaborts mid-write on bash 3.2 (the macOS system bash), leaving the release channel in a partially-cut state: the immutable release tag is created, the moving channel tag is not.Hit on the very first real use of the script — cutting
standards/v1.0.0on 2026-09-13.Reproduction
Cause
_gh_move_tag()(line ~275) lowercases the API error with${out,,}:${var,,}is a bash 4.0 case-modification expansion. On bash 3.2 it is a syntax error, and underset -euo pipefailthe script dies immediately.The expansion is only used for case-insensitive matching of a "not found" / 404 message to decide PATCH-vs-POST — so the failure happens on the create path, which is exactly the path a first-ever cut takes.
Why the partial state is the real problem
The script's stated contract is that cutting is "a scripted, idempotent, clobber-refusing operation instead of a one-off hand action". On bash 3.2 it is not atomic: it created
standards/v1.0.0and then died before creatingstandards/v1-stable. Observed state afterwards:An immutable release that consumers cannot reach through the channel they pin. And because the release tag is clobber-refusing by design, a naive re-run does not repair it — it correctly reports
NOOPon the release while the channel stays absent.In this instance the channel was completed manually via the same
POST /git/refscall the script's own fallback performs, and the result verifies:Acceptance criteria
${out,,}with a portable lowercase (tr '[:upper:]' '[:lower:]') or a case-insensitive match, and sweep the file for other bash 4+ constructs (${x^^},declare -A,mapfile/readarray,&>>).NOOPpath should still ensure the channel is aligned, which the dry-run text already claims it does), or the failure is reported loudly enough that the operator knows a manual step remains. Silent partial success is the defect.scripts/**. This script is operator-run from a workstation, so "CI uses newer bash" is not sufficient coverage — macOS is the likely execution environment.standards/v1.0.0andstandards/v1-stableboth resolve to782e005f8359...today and must not be clobbered or re-pointed by the fix.NOOPon the release and still converges the channel.Out of scope
Dev Notes
sed -ianddate -u -dfindings raised on #1742 and elsewhere: GNU-only constructs in scripts that are actually run on macOS. Worth checking whetherscripts/lib/standards-release.sh(the pure core) has the same issue — the failure here was in the thin I/O glue, which is less well covered bytests/standards_release.bats.canary-rollout.shhas a sibling_gh_move_tag; check whether it carries the same construct, since it is the pattern this script mirrored.Observed 2026-09-13 cutting the first standards release (#1091 / PR #1094).