@@ -240,6 +240,16 @@ jobs:
240240 if : ${{ steps.ctx.outputs.mode == 'main' && steps.affected.outputs.count == '0' }}
241241 run : echo "No affected release projects on main; skipping version + publish."
242242
243+ # The branch tip can advance between checkout and the push below (e.g. a PR merges during npm install).
244+ # Fast-forward immediately before versioning so the release commit descends from the tip the push
245+ # will be compared against.
246+ - name : Sync to branch tip (dispatch)
247+ if : ${{ steps.ctx.outputs.mode == 'dispatch' && !inputs.dry-run }}
248+ shell : bash
249+ run : |
250+ set -euo pipefail
251+ git pull --ff-only origin "${GITHUB_REF_NAME}"
252+
243253 # Orchestrated release: bumps versions, generates changelogs, commits + tags in one shot.
244254 # The orchestrator does not accept --git-* flags (config-driven only); push is handled in the next step.
245255 # --skip-publish keeps publishing as a separate step below so the OIDC token-clearing logic still runs.
@@ -273,9 +283,11 @@ jobs:
273283 --skip-publish \
274284 --verbose
275285
286+ # --atomic: the branch update and the release tag land together or not at all, so a rejected
287+ # push can never leave a tag on the remote that points at an unreachable commit.
276288 - name : Push release commit and tags (dispatch)
277289 if : ${{ steps.ctx.outputs.mode == 'dispatch' && !inputs.dry-run }}
278- run : git push --follow-tags
290+ run : git push --atomic -- follow-tags origin "HEAD:${GITHUB_REF_NAME}"
279291
280292 - name : nx release version + changelog (dispatch, dry-run)
281293 if : ${{ steps.ctx.outputs.mode == 'dispatch' && inputs.dry-run }}
0 commit comments