From 6bab1708d080b309b304880a728d4e7af22bb048 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Fri, 14 Aug 2026 11:14:46 -0500 Subject: [PATCH 1/4] =?UTF-8?q?test:=20reproduce=20#678=20=E2=80=94=20hygi?= =?UTF-8?q?ene=20not=20re-run=20on=20bump-version=20push=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the bump-version push retry loop from .github/workflows/ci.yml into scripts/push_version_bump.sh verbatim (inline workflow shell cannot be tested) and adds a shell test suite against a real git repo and a real bare remote. The retry-path and hygiene-failure cases fail: on a retry the loop re-bumps onto whatever landed on main concurrently and pushes that new tree without re-running the hygiene check, so a concurrent violation reaches main unchecked. Co-Authored-By: Claude Opus 5 --- scripts/push_version_bump.sh | 39 +++++ scripts/test_push_version_bump.sh | 256 ++++++++++++++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100755 scripts/push_version_bump.sh create mode 100755 scripts/test_push_version_bump.sh diff --git a/scripts/push_version_bump.sh b/scripts/push_version_bump.sh new file mode 100755 index 00000000..181c6212 --- /dev/null +++ b/scripts/push_version_bump.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Push the post-merge version-bump commit to a branch, re-bumping on rejection. +# +# Extracted verbatim from the `bump-version` job in .github/workflows/ci.yml so +# the retry behaviour can be tested (inline workflow shell cannot be). +# +# Usage: scripts/push_version_bump.sh +# +# The two commands this shells out to are overridable so the tests can record +# and control them; the defaults are what CI runs: +# BUMP_CMD - re-derives the next version from the fresh tip and commits it +# HYGIENE_CMD - proves the tree about to be pushed is hygienic +set -eu + +BRANCH="${1:-${GITHUB_REF_NAME:-}}" +[ -n "$BRANCH" ] || { echo "usage: $0 " >&2; exit 64; } + +BUMP_CMD="${BUMP_CMD:-python scripts/bump_version.py --update-all}" +HYGIENE_CMD="${HYGIENE_CMD:-python scripts/check_repo_hygiene.py --mode static}" + +# The bump commit must fast-forward main. When another commit lands on main +# during this run (concurrent merges), the first push is rejected as +# non-fast-forward. Re-base the bump onto the new tip and retry. Because two +# concurrent runs can compute the same next version, we reset to the fresh tip +# and re-run the bump so the version is derived from the true current version +# instead of replaying a stale bump. +for attempt in 1 2 3 4 5; do + if git push origin HEAD:"$BRANCH"; then + echo "Pushed version bump on attempt $attempt" + exit 0 + fi + echo "Push rejected on attempt $attempt (main advanced); re-bumping from latest tip..." + git fetch origin "$BRANCH" + git reset --hard "origin/$BRANCH" + # shellcheck disable=SC2086 # intentional word splitting: command + args + $BUMP_CMD +done +echo "::error::Failed to push version bump after 5 attempts (main kept advancing)." +exit 1 diff --git a/scripts/test_push_version_bump.sh b/scripts/test_push_version_bump.sh new file mode 100755 index 00000000..c96cb500 --- /dev/null +++ b/scripts/test_push_version_bump.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# Tests for scripts/push_version_bump.sh. +# +# What is real and what is not +# ---------------------------- +# The script under test is release-control machinery: it is the last thing that +# runs before a commit lands on `main`, and the behaviour these tests verify is +# *what it does between a rejected push and the next one* - whether it re-bumps, +# whether it re-proves hygiene on the tree it is about to push, and whether a +# violation stops it. That is verified against a real git repository and a real +# bare remote in a temp dir: `git push` to a local bare repo is a genuine push, +# the rejections are genuine non-fast-forward rejections produced by actually +# advancing the remote from a second clone, and "nothing was pushed" is asserted +# by reading the remote's tip. +# +# The two boundaries that are stubbed are the commands the script shells out to +# by name - the version bumper and the hygiene checker - because the real ones +# rewrite the working repo's version files and shell out to Cargo. Both stubs +# are recording fakes: every invocation appends its name *and the HEAD it saw* +# to a log, so a test can assert on the order and count of the calls and on the +# exact tree each one inspected. That is what makes "hygiene ran on the +# re-bumped tree, before the push" an assertion rather than an assumption. +# +# Nothing about git is faked. The failure path asserts an absence - the remote +# tip is byte-identical to what it was before the run - because "exited +# non-zero" alone would not prove the bad tree stayed off `main`. +# +# Usage: scripts/test_push_version_bump.sh + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PUSH="$REPO_ROOT/scripts/push_version_bump.sh" + +PASS=0 +FAIL=0 + +ok() { PASS=$((PASS + 1)); echo " ok $1"; } +bad() { FAIL=$((FAIL + 1)); echo " FAIL $1"; } + +assert_eq() { # assert_eq + if [ "$1" = "$2" ]; then ok "$3"; else + bad "$3" + echo " expected: $1" + echo " actual: $2" + fi +} + +assert_contains() { # assert_contains + case "$1" in + *"$2"*) ok "$3" ;; + *) bad "$3"; echo " expected to contain: $2"; echo " actual: $1" ;; + esac +} + +assert_absent() { # assert_absent + case "$1" in + *"$2"*) bad "$3"; echo " expected NOT to contain: $2"; echo " actual: $1" ;; + *) ok "$3" ;; + esac +} + +assert_nonzero() { # assert_nonzero + if [ "$1" -ne 0 ]; then ok "$2"; else bad "$2"; echo " expected a non-zero exit, got 0"; fi +} + +# --------------------------------------------------------------------------- +# Sandbox: a real bare remote, the "CI checkout" that pushes to it, and a second +# clone standing in for whatever merges into main while the bump job is running. +# +# $SB/calls records every stub invocation as "\t". +# --------------------------------------------------------------------------- +make_stubs() { # make_stubs + local d="$1" + mkdir -p "$d/bin" + + # Stands in for `python scripts/bump_version.py --update-all`: bumps a version + # file and commits it, exactly as the real one does (it commits itself - the + # workflow pushes HEAD, not a staged tree). + cat > "$d/bin/bump" <<'STUB_BUMP' +#!/usr/bin/env bash +set -eu +printf 'bump\t%s\n' "$(git rev-parse HEAD)" >> "$CALL_LOG" +n="$(cat VERSION)" +echo "$((n + 1))" > VERSION +git add VERSION +git commit -q -m "Bump version to $((n + 1)) [skip ci]" +# Lets a test keep main moving under the job, so every attempt is rejected. +if [ -n "${ADVANCE_REMOTE_ON_BUMP:-}" ]; then "$SANDBOX/bin/advance-remote"; fi +exit 0 +STUB_BUMP + + # Stands in for `python scripts/check_repo_hygiene.py --mode static`. + cat > "$d/bin/hygiene" <<'STUB_HYGIENE' +#!/usr/bin/env bash +set -eu +printf 'hygiene\t%s\n' "$(git rev-parse HEAD)" >> "$CALL_LOG" +exit "${HYGIENE_EXIT:-0}" +STUB_HYGIENE + + # A concurrent merge landing on main from somewhere else. + cat > "$d/bin/advance-remote" <<'STUB_ADVANCE' +#!/usr/bin/env bash +set -eu +git -C "$SANDBOX/other" pull -q --ff-only origin main +git -C "$SANDBOX/other" commit -q --allow-empty -m "concurrent merge" +git -C "$SANDBOX/other" push -q origin main +STUB_ADVANCE + + chmod +x "$d/bin/bump" "$d/bin/hygiene" "$d/bin/advance-remote" +} + +new_env() { # new_env -> echoes a fresh sandbox dir + local d + d="$(mktemp -d)" + git init -q --bare -b main "$d/remote.git" + git init -q -b main "$d/work" + git -C "$d/work" config user.email "ci@example.invalid" + git -C "$d/work" config user.name "CI" + git -C "$d/work" remote add origin "$d/remote.git" + echo 0 > "$d/work/VERSION" + git -C "$d/work" add VERSION + git -C "$d/work" commit -q -m "seed" + git -C "$d/work" push -q origin main + git clone -q "$d/remote.git" "$d/other" + git -C "$d/other" config user.email "other@example.invalid" + git -C "$d/other" config user.name "Other" + : > "$d/calls" + make_stubs "$d" + echo "$d" +} + +# The bump the workflow's own "Bump version" step already made before the push +# step runs. Committed with plain git so it does not show up in the call log. +seed_bump() { # seed_bump + local d="$1" + echo 1 > "$d/work/VERSION" + git -C "$d/work" add VERSION + git -C "$d/work" commit -q -m "Bump version to 1 [skip ci]" +} + +advance_remote() { # advance_remote + SANDBOX="$1" "$1/bin/advance-remote" +} + +run_push() { # run_push -> writes $sandbox/out, returns exit code + local d="$1" + ( + cd "$d/work" || exit 1 + export SANDBOX="$d" CALL_LOG="$d/calls" + export BUMP_CMD="$d/bin/bump" HYGIENE_CMD="$d/bin/hygiene" + "$PUSH" main + ) > "$d/out" 2>&1 +} + +remote_tip() { git -C "$1/remote.git" rev-parse main; } +local_head() { git -C "$1/work" rev-parse HEAD; } +call_order() { cut -f1 "$1/calls" | tr '\n' ' ' | sed 's/ $//'; } +call_count() { awk -F'\t' -v n="$2" '$1 == n { c++ } END { print c + 0 }' "$1/calls"; } +call_head() { awk -F'\t' -v n="$2" '$1 == n { print $2 }' "$1/calls"; } + +# --------------------------------------------------------------------------- +# Happy path: nothing landed on main, so there is no retry and nothing to +# re-check. The one hygiene check the workflow already ran before this script +# still covers the tree that gets pushed. +# --------------------------------------------------------------------------- +echo "push_version_bump.sh: first push succeeds" + +SB="$(new_env)" +seed_bump "$SB" +run_push "$SB" +rc=$? +assert_eq "0" "$rc" "push succeeds on the first attempt ($SB/out)" +assert_contains "$(cat "$SB/out")" "Pushed version bump on attempt 1" \ + "the script reports the attempt it succeeded on" +assert_eq "$(local_head "$SB")" "$(remote_tip "$SB")" "the remote advances to the bump commit" +assert_eq "" "$(call_order "$SB")" "no re-bump and no re-check when there is no retry" +rm -rf "$SB" + +# --------------------------------------------------------------------------- +# Retry path: a concurrent merge lands on main, the first push is rejected, and +# the script re-bumps onto the new tip. That produces a NEW commit on a NEW tree +# - whatever landed on main concurrently, plus a fresh bump - and the hygiene +# check the job ran before the first attempt says nothing about it. It must be +# re-proven before this tree is pushed (REPOSITORY_HYGIENE.md §8). +# --------------------------------------------------------------------------- +echo "push_version_bump.sh: a retry re-proves hygiene on the re-bumped tree" + +SB="$(new_env)" +seed_bump "$SB" +advance_remote "$SB" +run_push "$SB" +rc=$? +assert_eq "0" "$rc" "push succeeds on the retry ($SB/out)" +assert_contains "$(cat "$SB/out")" "Push rejected on attempt 1" \ + "the first attempt was genuinely rejected" +assert_eq "bump hygiene" "$(call_order "$SB")" \ + "the retry re-bumps and then re-checks hygiene" +assert_eq "1" "$(call_count "$SB" hygiene)" "hygiene is checked once per retry" +assert_eq "$(local_head "$SB")" "$(remote_tip "$SB")" "the remote advances to the re-bumped commit" +# The load-bearing assertion: the tree hygiene inspected is the tree that was +# pushed, not the pre-retry one. +assert_eq "$(remote_tip "$SB")" "$(call_head "$SB" hygiene)" \ + "hygiene inspected the exact commit that was pushed" +rm -rf "$SB" + +# --------------------------------------------------------------------------- +# Failure path (R3): the concurrent merge carried a hygiene violation, so the +# re-bumped tree fails the check. Aborting leaves main un-bumped, which the next +# push to main recovers; retrying past the violation would defeat the gate. +# --------------------------------------------------------------------------- +echo "push_version_bump.sh: a hygiene violation on a retry aborts instead of pushing" + +SB="$(new_env)" +seed_bump "$SB" +advance_remote "$SB" +before="$(remote_tip "$SB")" +( + export HYGIENE_EXIT=1 + run_push "$SB" +) +rc=$? +assert_nonzero "$rc" "the script fails when hygiene fails on a retry" +assert_eq "$before" "$(remote_tip "$SB")" \ + "nothing was pushed - the remote tip is exactly where it was" +assert_eq "1" "$(call_count "$SB" hygiene)" "hygiene ran on the re-bumped tree" +assert_absent "$(cat "$SB/out")" "Push rejected on attempt 2" \ + "it aborts rather than retrying past the violation" +assert_contains "$(cat "$SB/out")" "hygiene" "the failure names what it refused to do" +rm -rf "$SB" + +# --------------------------------------------------------------------------- +# Exhaustion: main keeps moving under the job. Unchanged behaviour - five +# attempts, then a hard failure with the message CI greps for. +# --------------------------------------------------------------------------- +echo "push_version_bump.sh: five consecutive rejections give up" + +SB="$(new_env)" +seed_bump "$SB" +advance_remote "$SB" +( + export ADVANCE_REMOTE_ON_BUMP=1 + run_push "$SB" +) +rc=$? +assert_nonzero "$rc" "the script fails after five rejections" +assert_contains "$(cat "$SB/out")" \ + "::error::Failed to push version bump after 5 attempts (main kept advancing)." \ + "it emits the workflow error annotation" +assert_contains "$(cat "$SB/out")" "Push rejected on attempt 5" "all five attempts were made" +assert_eq "5" "$(call_count "$SB" bump)" "it re-bumped once per rejection" +rm -rf "$SB" + +echo +echo "passed: $PASS failed: $FAIL" +[ "$FAIL" -eq 0 ] From e60b8c9f0e718ec2c182f0df3472e0df56c18441 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Fri, 14 Aug 2026 11:15:55 -0500 Subject: [PATCH 2/4] fix(ci): re-run hygiene check on every bump-version push retry (#678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rejected push means main advanced. The loop resets to the fresh tip and re-bumps, producing a new commit on a new tree that the pre-push hygiene check never saw - and pushed it straight to main. A concurrent merge carrying a hygiene violation or version drift therefore reached main unchecked, contrary to REPOSITORY_HYGIENE.md §8. Re-run the static hygiene check inside the loop, after the re-bump and before the push, and abort on failure rather than retrying past the violation (which would defeat the gate). Aborting leaves main un-bumped; the next push to main recovers it. The non-retry path is unchanged: one check, before the first attempt. Wires the workflow up to scripts/push_version_bump.sh and runs its tests in the release-scripts job. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 31 ++++++++++--------------------- scripts/push_version_bump.sh | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab308a26..3953de84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,11 @@ jobs: persist-credentials: false - name: Test publish/backfill scripts run: ./scripts/test_publish_spaces.sh + # Same reasoning for the version-bump push: it only ever runs on pushes to + # main, writing directly to main, so its retry path is untestable in PR CI + # unless it is exercised here against a real local remote. + - name: Test version-bump push script + run: ./scripts/test_push_version_bump.sh clippy-and-test: name: Build, Test, Clippy @@ -701,29 +706,13 @@ jobs: - name: Static hygiene and version check before push run: python scripts/check_repo_hygiene.py --mode static + # The retry loop lives in a script so it can be tested (see + # scripts/test_push_version_bump.sh, run by the `release-scripts` job). + # Each retry re-bumps onto the new tip and re-runs the hygiene check + # above against that new tree before pushing it. - name: Push changes if: success() - run: | - set -eu - BRANCH="${{ github.ref_name }}" - # The bump commit must fast-forward main. When another commit lands on - # main during this run (concurrent merges), the first push is rejected - # as non-fast-forward. Re-base the bump onto the new tip and retry. - # Because two concurrent runs can compute the same next version, we - # reset to the fresh tip and re-run the bump so the version is derived - # from the true current version instead of replaying a stale bump. - for attempt in 1 2 3 4 5; do - if git push origin HEAD:"$BRANCH"; then - echo "Pushed version bump on attempt $attempt" - exit 0 - fi - echo "Push rejected on attempt $attempt (main advanced); re-bumping from latest tip..." - git fetch origin "$BRANCH" - git reset --hard "origin/$BRANCH" - python scripts/bump_version.py --update-all - done - echo "::error::Failed to push version bump after 5 attempts (main kept advancing)." - exit 1 + run: ./scripts/push_version_bump.sh "${{ github.ref_name }}" - name: Tag the new version if: success() diff --git a/scripts/push_version_bump.sh b/scripts/push_version_bump.sh index 181c6212..98ce7d2a 100755 --- a/scripts/push_version_bump.sh +++ b/scripts/push_version_bump.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Push the post-merge version-bump commit to a branch, re-bumping on rejection. # -# Extracted verbatim from the `bump-version` job in .github/workflows/ci.yml so -# the retry behaviour can be tested (inline workflow shell cannot be). +# Extracted from the `bump-version` job in .github/workflows/ci.yml so the retry +# behaviour can be tested (inline workflow shell cannot be). # # Usage: scripts/push_version_bump.sh # @@ -34,6 +34,18 @@ for attempt in 1 2 3 4 5; do git reset --hard "origin/$BRANCH" # shellcheck disable=SC2086 # intentional word splitting: command + args $BUMP_CMD + # The re-bump produced a new commit on a new tree - whatever landed on + # `$BRANCH` concurrently, plus a fresh bump. The check the job ran before the + # first attempt says nothing about that tree, so hygiene and version agreement + # must be re-proven here, immediately before this push + # (REPOSITORY_HYGIENE.md §8). + # shellcheck disable=SC2086 # intentional word splitting: command + args + if ! $HYGIENE_CMD; then + # Abort rather than retry: retrying past a violation would defeat the gate. + # This leaves the branch un-bumped, which the next push to it recovers. + echo "::error::Static hygiene check failed on the re-bumped tree (attempt $attempt); refusing to push." + exit 1 + fi done echo "::error::Failed to push version bump after 5 attempts (main kept advancing)." exit 1 From 1d37016f622810a414f71945de879af8a2d62145 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Fri, 14 Aug 2026 11:18:00 -0500 Subject: [PATCH 3/4] docs: dev diary for #678 bump-version hygiene-on-retry fix --- ...issue-678-bump-version-hygiene-on-retry.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 History/dev-diary/2026/2026-08-14-issue-678-bump-version-hygiene-on-retry.md diff --git a/History/dev-diary/2026/2026-08-14-issue-678-bump-version-hygiene-on-retry.md b/History/dev-diary/2026/2026-08-14-issue-678-bump-version-hygiene-on-retry.md new file mode 100644 index 00000000..955cb526 --- /dev/null +++ b/History/dev-diary/2026/2026-08-14-issue-678-bump-version-hygiene-on-retry.md @@ -0,0 +1,101 @@ +# 2026-08-14 — `bump-version` pushed to `main` unchecked on any push retry (#678) + +## Symptom + +None observed in production — and that is the point. The gap needs a genuine +race to surface: another commit must land on `main` between the `bump-version` +job's checkout and its push. Rare enough to go unnoticed, and the place it +surfaces is the branch the check exists to protect. + +Split out of the #674 review, where CodeRabbit raised it. Pre-existing; #674 did +not touch it. + +## Root cause + +`.github/workflows/ci.yml` ran the hygiene gate exactly once, as its own step, +with a comment stating the requirement plainly: + +```yaml +# This job writes directly to main, so it must prove hygiene and version +# agreement itself, immediately before pushing (REPOSITORY_HYGIENE.md §8). +- name: Static hygiene and version check before push + run: python scripts/check_repo_hygiene.py --mode static +``` + +The `Push changes` step then retried up to five times, and each retry rebuilt +the commit from scratch: + +```bash +git fetch origin "$BRANCH" +git reset --hard "origin/$BRANCH" +python scripts/bump_version.py --update-all +``` + +That is a **new commit on a new tree** — the concurrent merge's content plus a +fresh bump — and it went out without the gate running again. The step's own +comment was true only of the first attempt. A concurrent merge carrying a +hygiene violation, or a bump that drifted against the new tree, reached `main` +unchecked and was caught only by the *next* run, after it had landed. + +## The fix + +The retry loop moved to `scripts/push_version_bump.sh`, which re-runs the +hygiene check on the re-bumped tree before each subsequent push. A violation +**aborts**: + +```bash +if ! $HYGIENE_CMD; then + echo "::error::Static hygiene check failed on the re-bumped tree (attempt $attempt); refusing to push." + exit 1 +fi +``` + +Aborting, not skipping to the next attempt — retrying past a violation would +defeat the gate. Aborting leaves `main` un-bumped, which the next push to `main` +recovers. The non-retry path is unchanged: still exactly one check, in the +workflow step, before the first attempt. + +## Why it moved out of the YAML + +Inline workflow shell cannot be tested — it only ever executes in production, on +pushes to `main`, on the rare retry path. That is the same hazard +`scripts/test_publish_spaces.sh` exists to close for the release scripts, and it +gets the same treatment here. + +`scripts/test_push_version_bump.sh` runs the real script against a **real git +repository and a real bare remote**, with genuine non-fast-forward rejections +produced by a second clone committing to the remote mid-flight. Only the two +shelled-out commands are stubbed — `BUMP_CMD` and `HYGIENE_CMD`, as recording +fakes that log the HEAD each one saw — because the production versions mutate +the working repo and run Cargo. `git push` is never stubbed; the assertions are +made against where the remote tip actually moved. + +## Risk class + +**R3** — release controls (root `testing.md` §5), which requires negative and +failure-path coverage. The failure-path case asserts the *absence* of the write: +when hygiene fails on a retry, the script exits non-zero **and** the remote tip +is byte-identical to where it started. + +## Red evidence + +Against the faithful extraction of the buggy loop, 7 of 19 assertions failed. +The one that matters: + +```text +FAIL nothing was pushed - the remote tip is exactly where it was + expected: 0e721debb32938a8cf5da424cc1b7bb75d3f0f02 + actual: b169625d9ecf277cb4acd3d316750132847f2a32 +``` + +The old loop pushed `b169625` — the re-bumped tree carrying the concurrent +merge — to the remote despite hygiene rejecting it. That is the defect, observed +rather than argued. + +Red: `6bab170`. Green: `e60b8c9`. 19/19 after the fix. + +## Coverage added + +`Release Script Tests` now runs `scripts/test_push_version_bump.sh` alongside +`scripts/test_publish_spaces.sh`, so the retry path is exercised on every PR +rather than only during a real race on `main`. From 7b9f0629bbd895ef1b29acd4ea397a028ad50ee4 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Fri, 14 Aug 2026 12:07:55 -0500 Subject: [PATCH 4/4] fix(ci): skip the pointless re-bump after the final push rejection (#678) --- scripts/push_version_bump.sh | 7 +++++++ scripts/test_push_version_bump.sh | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/push_version_bump.sh b/scripts/push_version_bump.sh index 98ce7d2a..49f57041 100755 --- a/scripts/push_version_bump.sh +++ b/scripts/push_version_bump.sh @@ -24,11 +24,18 @@ HYGIENE_CMD="${HYGIENE_CMD:-python scripts/check_repo_hygiene.py --mode static}" # concurrent runs can compute the same next version, we reset to the fresh tip # and re-run the bump so the version is derived from the true current version # instead of replaying a stale bump. +ATTEMPTS=5 for attempt in 1 2 3 4 5; do if git push origin HEAD:"$BRANCH"; then echo "Pushed version bump on attempt $attempt" exit 0 fi + # After the final rejection there is no further push, so re-bumping would + # build a commit nothing will ever send - and the real bump shells out to + # Cargo, so that is not free. Give up here instead. + if [ "$attempt" -eq "$ATTEMPTS" ]; then + break + fi echo "Push rejected on attempt $attempt (main advanced); re-bumping from latest tip..." git fetch origin "$BRANCH" git reset --hard "origin/$BRANCH" diff --git a/scripts/test_push_version_bump.sh b/scripts/test_push_version_bump.sh index c96cb500..bbfb318e 100755 --- a/scripts/test_push_version_bump.sh +++ b/scripts/test_push_version_bump.sh @@ -247,8 +247,12 @@ assert_nonzero "$rc" "the script fails after five rejections" assert_contains "$(cat "$SB/out")" \ "::error::Failed to push version bump after 5 attempts (main kept advancing)." \ "it emits the workflow error annotation" -assert_contains "$(cat "$SB/out")" "Push rejected on attempt 5" "all five attempts were made" -assert_eq "5" "$(call_count "$SB" bump)" "it re-bumped once per rejection" +assert_contains "$(cat "$SB/out")" "Push rejected on attempt 4" "it kept retrying up to the last attempt" +# Five pushes are attempted, but only the first four are followed by another +# push - so the fifth rejection does not re-bump. Re-bumping there would build a +# commit nothing sends, and the real bump shells out to Cargo. +assert_eq "4" "$(call_count "$SB" bump)" "it re-bumps only when another push will follow" +assert_eq "4" "$(call_count "$SB" hygiene)" "it re-proves hygiene only for pushes that happen" rm -rf "$SB" echo