Skip to content

fix(ci): re-run hygiene check on every bump-version push retry (#678) - #713

Merged
logbie merged 6 commits into
mainfrom
fix/678-bump-version-hygiene-on-retry
Aug 14, 2026
Merged

fix(ci): re-run hygiene check on every bump-version push retry (#678)#713
logbie merged 6 commits into
mainfrom
fix/678-bump-version-hygiene-on-retry

Conversation

@logbie

@logbie logbie commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #678.

The bump-version job writes directly to main, so it runs the static hygiene
gate immediately before pushing — its own comment says so, citing
REPOSITORY_HYGIENE.md §8. But the gate ran once, as a separate step, while the
push step retried up to five times, and each retry builds a new commit on a
new tree
:

git fetch origin "$BRANCH"
git reset --hard "origin/$BRANCH"
python scripts/bump_version.py --update-all

That tree — the concurrent merge's content plus a fresh bump — went out
unchecked. A concurrent merge carrying a hygiene violation, or a bump that
drifted against the new tree, reached main and was caught only by the next
run, after it had already landed on the branch this gate protects.

It takes a genuine race to hit, which is exactly why it would have gone
unnoticed until it mattered.

Changes

  • scripts/push_version_bump.sh (new) — the retry loop, extracted verbatim
    from the workflow, plus a hygiene re-check on the re-bumped tree before each
    subsequent push. A violation aborts with a ::error:: annotation rather
    than retrying past it: retrying would defeat the gate, and aborting leaves
    main un-bumped, which the next push to main recovers. The two shelled-out
    commands are overridable (BUMP_CMD, HYGIENE_CMD) with the production
    defaults baked in.
  • scripts/test_push_version_bump.sh (new) — 19 assertions across four
    cases, in the idiom of scripts/test_publish_spaces.sh.
  • .github/workflows/ci.ymlPush changes now calls the script;
    release-scripts runs the new test suite.
  • Dev diary entry.

The non-retry path is unchanged: still exactly one check, in the workflow step,
before the first attempt. The happy-path test asserts the loop makes zero
hygiene calls when no retry occurs.

Why this left 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 hazard
scripts/test_publish_spaces.sh already exists to close for the release
scripts, and the issue explicitly asked for a test in that spirit.

The test uses 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. git push is never stubbed — assertions are made against where the
remote tip actually moved. Only BUMP_CMD and HYGIENE_CMD are stubbed, as
recording fakes logging the HEAD each one saw, because the production versions
mutate the working repo and shell out to Cargo.

Test evidence

  • Risk class: R3 — release controls (root testing.md §5). This machinery
    writes directly to main, so negative and failure-path coverage is required,
    not optional.

  • Acceptance criteria → tests:

    • Retry re-runs hygiene on the re-bumped tree before pushingthe retry re-bumps and then re-checks hygiene (asserts call order), hygiene inspected the exact commit that was pushed (asserts the recorded HEAD equals
      the SHA that landed on the remote)
    • A violation on retry fails instead of pushingthe script fails when hygiene fails on a retry, nothing was pushed - the remote tip is exactly where it was, it aborts rather than retrying past the violation
    • Non-retry path unchangedno re-bump and no re-check when there is no retry (asserts zero hygiene invocations)
    • Give-up behaviour preservedthe script fails after five rejections,
      it emits the workflow error annotation
  • Red evidence: 6bab170 (test-only, ancestor of the fix). Against a
    faithful extraction of the buggy loop, 7 of 19 assertions failed. The decisive
    one:

    FAIL nothing was pushed - the remote tip is exactly where it was
           expected: 0e721debb32938a8cf5da424cc1b7bb75d3f0f02
           actual:   b169625d9ecf277cb4acd3d316750132847f2a32
    

    The old loop pushed the unchecked re-bumped tree to the remote. Observed, not
    argued.

  • Green evidence: e60b8c9 — 19/19 passing.

  • Unit/component: n/a — no Rust changed.

  • Integration/contract: ./scripts/test_push_version_bump.sh — 19/19, real
    git + real bare remote.

  • End-to-end: the bump-version job itself only runs on pushes to main;
    its retry path is now covered in PR CI by the above, which is the point of the
    change.

  • Security/migration/concurrency/performance/other: the failure path asserts
    the absence of a write (remote tip byte-identical), which is the property
    that matters for a release control.

  • Coverage: four cases — happy path, retry, hygiene-failure-on-retry,
    exhaustion.

  • Platforms: release-scripts runs on
    blacksmith-2vcpu-ubuntu-2404-arm. Authored and run locally on Windows (Git
    for Windows bash) as well; the only difference observed was git's CRLF warning
    in the temp repos, which does not affect assertions.

  • Not applicable, with reason: cargo fmt / clippy / cargo test — no
    Rust files touched. No Docs/ change — this is CI-internal machinery with no
    user-visible surface.

  • Rollback/recovery: revert the commit; the workflow returns to the inline
    loop. No external state changes. If the new gate ever fires spuriously, the
    branch is simply left un-bumped and the next push to main re-bumps it.

  • Residual risk: the first attempt still relies on the separate workflow
    step for its hygiene proof, so the script alone is not self-sufficient if it
    is ever called from somewhere else. python scripts/check_repo_hygiene.py --mode static passes on this tree.

🤖 Generated with Claude Code


Open in Devin Review

Summary by CodeRabbit

  • Bug Fixes

    • Improved version-bump publishing retries, including branch handling and recovery after rejected pushes.
    • Added repository hygiene checks before retrying, preventing invalid version-bump commits from being pushed.
    • Version-bump operations now report clear success, usage, hygiene, and retry-exhaustion outcomes.
  • Tests

    • Added integration coverage for successful pushes, retries, hygiene failures, unchanged remotes, and exhausted retry attempts.
    • CI now runs the version-bump retry test suite.

logbie and others added 3 commits August 14, 2026 11:14
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 14, 2026 16:18
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b87056c-244a-478e-abdd-6ee0ecd01e8a

📥 Commits

Reviewing files that changed from the base of the PR and between ea5ecb1 and 6c33cb3.

📒 Files selected for processing (4)
  • .github/workflows/ci.yml
  • History/dev-diary/2026/2026-08-14-issue-678-bump-version-hygiene-on-retry.md
  • scripts/push_version_bump.sh
  • scripts/test_push_version_bump.sh

📝 Walkthrough

Walkthrough

The release workflow now delegates version-bump retries to scripts/push_version_bump.sh. The script rechecks hygiene after rejected pushes and aborts on failure. A real-repository integration suite covers success, retry, failure, and exhaustion paths. CI runs the new suite.

Changes

Version bump retry hygiene

Layer / File(s) Summary
Retry script and hygiene validation
scripts/push_version_bump.sh, History/dev-diary/2026/...
The new script performs bounded retries, resets to the latest remote branch, re-bumps the version, validates hygiene before retry pushes, and reports failures. The diary documents the defect and fix.
Real-repository retry tests
scripts/test_push_version_bump.sh
The integration suite tests initial success, rejected-push recovery, hygiene failure, remote preservation, and exhaustion after five attempts.
Workflow integration
.github/workflows/ci.yml
The workflow runs the new test and replaces the inline retry loop with scripts/push_version_bump.sh, using the workflow branch name.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 6c33c

This change updates the version-bump retry path and adds targeted test coverage; no actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant CI as GitHub Actions
  participant Script as push_version_bump.sh
  participant Remote as Git remote
  participant Bump as bump_version.py
  participant Hygiene as check_repo_hygiene.py

  CI->>Script: Run with target branch
  Script->>Remote: Push version-bump commit
  Remote-->>Script: Accept or reject
  Script->>Remote: Fetch and reset after rejection
  Script->>Bump: Re-bump from latest remote tip
  Script->>Hygiene: Check re-bumped tree
  Hygiene-->>Script: Pass or fail
  Script->>Remote: Push validated retry commit
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the CI fix and the hygiene check on every version-bump retry.
Linked Issues check ✅ Passed The changes re-check hygiene on retries, abort on violations, preserve the first-attempt check, and add shell-level tests for issue #678.
Out of Scope Changes check ✅ Passed The workflow, retry script, integration tests, and development diary directly support the linked issue and stated objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/678-bump-version-hygiene-on-retry

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread .github/workflows/ci.yml
Comment on lines 706 to +715
- 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 }}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Sibling versioning.yml push path still has no retry or re-check

.github/workflows/versioning.yml:44 performs a plain git push origin HEAD:${{ github.ref_name }} with a single preceding hygiene check and no retry loop. The extraction here does not change that workflow, so the two version-bump paths now differ in retry semantics. Worth confirming that is deliberate (versioning.yml presumably runs manually/scheduled where a concurrent-merge race is less likely) rather than an oversight of this refactor.

(Refers to lines 704-715)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a race in the bump-version CI job where a rejected git push triggers a re-bump on a new tree, but (previously) did not re-run the static hygiene gate before pushing the newly created commit to main. The retry loop is extracted into a script so it can be covered by a shell-level test suite that reproduces real non-fast-forward rejections with a real bare remote.

Changes:

  • Extracted the bump-version push retry loop into scripts/push_version_bump.sh, adding a hygiene re-check on every retry before pushing.
  • Added scripts/test_push_version_bump.sh to exercise happy path, retry path, hygiene-failure-on-retry, and exhaustion against real local git repos/remotes (stubbing only bump/hygiene commands).
  • Updated .github/workflows/ci.yml to call the new push script and to run the new test suite in the release-scripts job; added a dev diary entry describing the defect and fix.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
scripts/push_version_bump.sh New push retry helper that re-runs static hygiene after each re-bump before attempting the next push.
scripts/test_push_version_bump.sh New integration-style shell test suite using real git repos/remotes to validate retry + hygiene behavior.
.github/workflows/ci.yml Uses the new script in bump-version and runs the new tests in release-scripts.
History/dev-diary/2026/2026-08-14-issue-678-bump-version-hygiene-on-retry.md Documents the race, rationale for extracting to a script, and test evidence.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +32 to +36
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
Comment thread scripts/test_push_version_bump.sh Outdated
"::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"
@logbie

logbie commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Review findings — all addressed

@copilot — confirmed and fixed, both comments. The loop re-bumped after the fifth rejection even though no push follows it, so it built a commit nothing would ever send. Not free either: the production BUMP_CMD shells out to cargo update and a locked cargo check on the fuzz crate.

Fixed in the latest commit — the loop breaks before the re-bump once the final attempt is reached. And you correctly predicted the test change: the exhaustion case now asserts 4 re-bumps rather than 5, with a matching assertion that hygiene also ran only 4 times (once per push that actually happens). I replaced the Push rejected on attempt 5 assertion — that line no longer prints, by design — with Push rejected on attempt 4 plus the existing ::error::… after 5 attempts annotation, which together still prove all five pushes were attempted. Suite is 20/20.

@devin-ai-integration — checked; the difference is real but benign, and deliberate. You are right that versioning.yml:44 was not changed. It does not share the defect, though:

The failure modes differ rather than one being weaker: a concurrent-merge race makes versioning.yml fail its push visibly and the operator re-runs it, which is acceptable for a manually-invoked job. The retry loop exists in ci.yml precisely because that job fires automatically on every push to main, where the race is real and an operator is not watching.

So I have left it alone — bringing it under the shared script would mean giving a manual workflow automatic retry semantics it does not need, which is a scope expansion rather than a fix. Worth a separate issue if the two paths should converge; I did not file one.

Copilot AI review requested due to automatic review settings August 14, 2026 17:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (2)

scripts/push_version_bump.sh:58

  • The final failure annotation hard-codes 5 even though ATTEMPTS exists; using the variable keeps the message consistent if the attempt count ever changes.
echo "::error::Failed to push version bump after 5 attempts (main kept advancing)."
exit 1

scripts/push_version_bump.sh:28

  • ATTEMPTS is defined but the loop and final error message still hard-code 5, so changing the attempt count in one place would silently desync behavior/message. Consider using ATTEMPTS as the single source of truth for the loop bounds and final error output.

This issue also appears on line 57 of the same file.

ATTEMPTS=5
for attempt in 1 2 3 4 5; do

Copilot AI review requested due to automatic review settings August 14, 2026 17:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (3)

scripts/push_version_bump.sh:57

  • The final failure message hard-codes "5 attempts" even though the script already has an ATTEMPTS variable; this can become misleading if the retry count is adjusted.
echo "::error::Failed to push version bump after 5 attempts (main kept advancing)."

scripts/push_version_bump.sh:53

  • The hygiene-failure annotation says "(attempt $attempt)", but the hygiene check happens between push attempts after a rejection; wording it as "after rejection on attempt" avoids confusion when debugging CI logs.
    echo "::error::Static hygiene check failed on the re-bumped tree (attempt $attempt); refusing to push."

scripts/push_version_bump.sh:29

  • ATTEMPTS is defined but the retry loop is hard-coded to 1 2 3 4 5, so changing ATTEMPTS later will silently desync the loop bounds from the break condition.

This issue also appears on line 57 of the same file.

ATTEMPTS=5
for attempt in 1 2 3 4 5; do
  if git push origin HEAD:"$BRANCH"; then

Copilot AI review requested due to automatic review settings August 14, 2026 18:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (2)

scripts/push_version_bump.sh:57

  • The final workflow annotation hard-codes both the attempt count and main. Since the script already defines ATTEMPTS and resolves $BRANCH, using them keeps the message accurate if either changes.
echo "::error::Failed to push version bump after 5 attempts (main kept advancing)."

scripts/push_version_bump.sh:39

  • The status message hard-codes main even though the script accepts an arbitrary <branch>. Using the resolved $BRANCH avoids misleading output if the script is reused for another branch.

This issue also appears on line 57 of the same file.

  echo "Push rejected on attempt $attempt (main advanced); re-bumping from latest tip..."

@logbie
logbie merged commit 7b3d27a into main Aug 14, 2026
20 checks passed
@logbie
logbie deleted the fix/678-bump-version-hygiene-on-retry branch August 14, 2026 18:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bump-version pushes to main without re-running the hygiene check when the first push is rejected

2 participants