Summary
scripts/version_and_commit.py:236 pushes the release commit with no retry, no
rebase and no classification of the rejection:
# Push to main
run_command(["git", "push", "origin", "main"])
run_command exits the process on a non-zero return code, so any concurrent
write to main ends the release — after the version bump has already been
committed inside the runner. The bump, the changelog and the tag are lost with
the runner, and the next run starts over from the same stale version.
The rust template hit this and fixed it in rust#31; the js template fixed it and
then went further in js#143. This template still has the original form.
Why this is not hypothetical
A downstream monorepo built from these templates
(link-foundation/browser-commander) has never published its Python package
because of this line. GET https://pypi.org/pypi/browser-commander/json returns
404. The release job assembles the changelog correctly and then dies at the
push, before the publish step:
Collecting into CHANGELOG.md under version 0.5.3
[main a8a3943] python: changelog for 0.5.3
13 files changed, 53 insertions(+), 56 deletions(-)
create mode 100644 python/CHANGELOG.md
delete mode 100644 python/changelog.d/21.added.md
... 11 more fragments deleted ...
! [rejected] HEAD -> main (non-fast-forward)
##[error]Process completed with exit code 1.
Twelve changelog fragments, from issues #21 through #83, are still sitting in
changelog.d/ as a result.
Note the mechanism, because it is subtler than "two jobs raced". actions/checkout
checks out github.sha — the commit that triggered the run, not the branch tip
at the moment the job starts. So even a perfectly serialised writer (one using
the repository-scoped main-writer concurrency group from the best-practices
document) begins with a tree that is one commit behind as soon as any earlier
writer has landed. Serialisation buys ordering, not freshness. In a single-language
template the same thing happens whenever a human, a bot, or a docs workflow
pushes to main while a release is running.
release.yml in this template also uses no main-writer group, so its writers
are not serialised at all.
Reproduction
No network and no GitHub needed:
#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp -d); cd "$tmp"
git init -q --bare origin.git
git clone -q origin.git seed && cd seed
git config user.email a@b.c && git config user.name a
echo 'version = "1.0.0"' > pyproject.toml
git add -A && git commit -qm init && git push -q origin HEAD:main && cd ..
trigger_sha=$(git -C seed rev-parse HEAD)
# Writer A (e.g. the JS release, or a human) lands first.
git clone -q origin.git a && cd a
git config user.email a@b.c && git config user.name a
echo x > a.txt && git add -A && git commit -qm "writer A"
git push -q origin HEAD:main && cd ..
# Writer B starts from the trigger SHA, exactly as actions/checkout leaves it.
git clone -q origin.git b && cd b
git config user.email a@b.c && git config user.name a
git checkout -q "$trigger_sha"
sed -i 's/1.0.0/1.0.1/' pyproject.toml
git add -A && git commit -qm "1.0.1"
echo "--- what version_and_commit.py does today ---"
git push origin HEAD:main || echo "EXIT $? -- release dies here, bump lost"
echo "--- what the fix does ---"
git pull -q --rebase origin main && git push -q origin HEAD:main && echo "landed"
Output of the first push:
! [rejected] HEAD -> main (fetch first)
error: failed to push some refs to '.../origin.git'
EXIT 1 -- release dies here, bump lost
Workaround for downstream users
Replace the bare push with a rebase-and-retry, but classify the rejection
first. This matters: a GH006/GH013 ruleset rejection also prints the word
rejected, and rebasing can never satisfy a rule. Retrying it burns the retry,
doubles the time the release takes to die, and leaves a log blaming a race that
never happened. That was the point of js#143.
Suggested fix
Add a scripts/git_push.py next to version_and_commit.py, transliterated from
the js template's scripts/push-failure-classifier.mjs, and call it from the one
push site. A working implementation, with 19 unit tests including one that
asserts the Python and Node pattern lists agree, is here:
The shape:
REPOSITORY_RULE_PATTERNS = (
"gh006", "gh013", "repository rule violations",
"changes must be made through a pull request", "protected branch", "push declined",
)
NON_FAST_FORWARD_PATTERNS = (
"[rejected]", "non-fast-forward", "fetch first", "updates were rejected",
)
def push_with_rebase_retry(runner=None, remote="origin", branch="main", attempts=3):
run = runner or default_runner
for attempt in range(1, attempts + 1):
result = run(["git", "push", remote, f"HEAD:{branch}"])
if result.returncode == 0:
return
if is_blocked_by_repository_rule(result):
raise PushFailedError(...) # a rebase can never fix this
if not is_non_fast_forward(result):
raise PushFailedError(...) # auth, network: report the real error
if attempt == attempts:
raise PushFailedError(...)
run(["git", "pull", "--rebase", remote, branch])
Two details worth keeping:
- Exclude the exception's own
message from the haystack used for the
non-fast-forward check. Subprocess wrappers produce generic messages, and a
caller that wrapped the failure could put the word "rejected" into a message of
its own making. The classification should rest on what git wrote to
stdout/stderr.
- If the release script also creates a tag, create it after the push
succeeds. A rebase retry rewrites the release commit, so a tag made beforehand
ends up on an orphaned commit — the rust template fixed this in rust#94, and it
becomes reachable here as soon as a retry exists.
Related: rust#31, rust#94, js#143. Found while fixing
link-foundation/browser-commander#85.
Summary
scripts/version_and_commit.py:236pushes the release commit with no retry, norebase and no classification of the rejection:
run_commandexits the process on a non-zero return code, so any concurrentwrite to
mainends the release — after the version bump has already beencommitted inside the runner. The bump, the changelog and the tag are lost with
the runner, and the next run starts over from the same stale version.
The rust template hit this and fixed it in rust#31; the js template fixed it and
then went further in js#143. This template still has the original form.
Why this is not hypothetical
A downstream monorepo built from these templates
(
link-foundation/browser-commander) has never published its Python packagebecause of this line.
GET https://pypi.org/pypi/browser-commander/jsonreturns404. The release job assembles the changelog correctly and then dies at the
push, before the publish step:
Twelve changelog fragments, from issues #21 through #83, are still sitting in
changelog.d/as a result.Note the mechanism, because it is subtler than "two jobs raced".
actions/checkoutchecks out
github.sha— the commit that triggered the run, not the branch tipat the moment the job starts. So even a perfectly serialised writer (one using
the repository-scoped
main-writerconcurrency group from the best-practicesdocument) begins with a tree that is one commit behind as soon as any earlier
writer has landed. Serialisation buys ordering, not freshness. In a single-language
template the same thing happens whenever a human, a bot, or a docs workflow
pushes to
mainwhile a release is running.release.ymlin this template also uses nomain-writergroup, so its writersare not serialised at all.
Reproduction
No network and no GitHub needed:
Output of the first push:
Workaround for downstream users
Replace the bare push with a rebase-and-retry, but classify the rejection
first. This matters: a GH006/GH013 ruleset rejection also prints the word
rejected, and rebasing can never satisfy a rule. Retrying it burns the retry,doubles the time the release takes to die, and leaves a log blaming a race that
never happened. That was the point of js#143.
Suggested fix
Add a
scripts/git_push.pynext toversion_and_commit.py, transliterated fromthe js template's
scripts/push-failure-classifier.mjs, and call it from the onepush site. A working implementation, with 19 unit tests including one that
asserts the Python and Node pattern lists agree, is here:
The shape:
Two details worth keeping:
messagefrom the haystack used for thenon-fast-forward check. Subprocess wrappers produce generic messages, and a
caller that wrapped the failure could put the word "rejected" into a message of
its own making. The classification should rest on what git wrote to
stdout/stderr.
succeeds. A rebase retry rewrites the release commit, so a tag made beforehand
ends up on an orphaned commit — the rust template fixed this in rust#94, and it
becomes reachable here as soon as a retry exists.
Related: rust#31, rust#94, js#143. Found while fixing
link-foundation/browser-commander#85.