Skip to content

Commit 01fa10d

Browse files
authored
fix(sync): bounded retry on force-with-lease rejection in commit_and_push_translations_branch (#70)
* fix(sync): bounded retry on force-with-lease rejection (#63) commit_and_push_translations_branch pushed translation branches with a single git push --force-with-lease and failed fast when the lease was rejected by a concurrent advance, failing the whole daily run on a transient race. Retry the push up to 3 times, running git fetch for the branch between attempts so the local ref reflects the advanced remote before re-attempting, and surface the existing rejection error unchanged once the attempts are exhausted (fail closed). The bats fixture gains an optional injection cap so a test can simulate a transient rejection; the fail-fast test is replaced by two covering retry-then- succeed and fail-after-exhaustion. * fix(sync): keep the force-with-lease retry safe with --force-if-includes The bounded retry fetched then re-pushed with --force-with-lease, which after the fetch could overwrite a concurrent commit that was never integrated. Add --force-if-includes (Git 2.30+) so the retry still rejects an unintegrated remote advance: a spurious rejection retries to success, a real concurrent commit fails closed instead of being silently overwritten. Guard force-if-includes support with a clear "requires Git 2.30+" error, and cover it plus the not-overwritten behavior with bats tests. * fix(sync): fail fast when the inter-attempt fetch fails The retry loop discarded git fetch failures, so an auth/network fetch failure left the tracking ref stale and was misreported as a concurrent-advance rejection. Surface the real cause and return the fetch exit code instead. Cover it with a bats test via an opt-in fetch-failure flag on the push hook. * fix(sync): pipefail-safe capability probes + route sync-local through the helper Address review feedback on the force-with-lease retry: - The force-with-lease/force-if-includes probes piped `git push -h` (exit 129) into grep, which under `set -o pipefail` (the workflows enable it) reported "not supported" even when the flag was present. Compute the probe from a captured `git push -h 2>&1 || true` variable via a shared git_push_help_mentions helper (no pipe). Test shims exit 129 like real git, the unsupported probes run under pipefail, and a positive test asserts both probes pass under pipefail. - The sync-local job now calls commit_and_push_translations_branch instead of a bare `git push --force-with-lease`, so it gets the same retry + force-if-includes. - Reword the injection-arg doc, capture stderr in the not-overwritten test, and dedup the two capability shims into install_git_without_flag.
1 parent fc7bc90 commit 01fa10d

4 files changed

Lines changed: 198 additions & 37 deletions

File tree

.github/workflows/assets/lib.sh

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,28 @@ update_translations_submodule() {
203203
fi
204204
}
205205

206+
# `git push -h` exits 129, so capture the help text first (|| true) and grep it
207+
# separately. Piping straight into grep would fail the probe under `set -o
208+
# pipefail` (which the workflows enable) even when the flag is present.
209+
git_push_help_mentions() {
210+
local help
211+
help="$(git push -h 2>&1 || true)"
212+
grep -qF "$1" <<<"$help"
213+
}
214+
206215
git_push_supports_force_with_lease() {
207-
git push -h 2>&1 | grep -qF 'force-with-lease'
216+
git_push_help_mentions 'force-with-lease'
217+
}
218+
219+
# --force-if-includes (Git 2.30+) makes the safe retry below reject an unmerged
220+
# remote advance instead of overwriting it.
221+
git_push_supports_force_if_includes() {
222+
git_push_help_mentions 'force-if-includes'
208223
}
209224

210225
commit_and_push_translations_branch() {
211226
local dir="$1" branch="$2" libs_ref="$3" force="${4:-false}"
212-
local push_rc remote_sha
227+
local push_rc remote_sha fetch_rc attempt max_attempts=3
213228
git -C "$dir" status --short
214229
if git -C "$dir" diff --cached --quiet; then
215230
echo " No staged submodule changes on $branch; skipping commit." >&2
@@ -221,14 +236,35 @@ commit_and_push_translations_branch() {
221236
phase_err "git push --force-with-lease is not supported by this Git installation"
222237
return 1
223238
fi
224-
if git -C "$dir" push --force-with-lease origin "$branch"; then
225-
:
226-
else
227-
push_rc=$?
228-
remote_sha=$(git -C "$dir" ls-remote --heads origin "$branch" | awk '{print $1}')
229-
phase_err "force-with-lease push rejected for branch $branch (remote HEAD=${remote_sha:-unknown}); remote may have advanced concurrently — re-run after fetch or resolve manually."
230-
return "$push_rc"
239+
if ! git_push_supports_force_if_includes; then
240+
phase_err "git push --force-if-includes is not supported; requires Git 2.30+"
241+
return 1
231242
fi
243+
# A concurrent advance rejects the lease. Fetch so the lease reflects the
244+
# advanced remote and retry, but keep --force-if-includes so the retry still
245+
# rejects an advance we never integrated. A spurious rejection (remote
246+
# unchanged from our base) retries to success; a real concurrent commit
247+
# fails closed rather than being silently overwritten.
248+
for (( attempt=1; attempt<=max_attempts; attempt++ )); do
249+
if git -C "$dir" push --force-with-lease --force-if-includes origin "$branch"; then
250+
return 0
251+
else
252+
push_rc=$?
253+
fi
254+
if (( attempt < max_attempts )); then
255+
# A failed fetch (auth/network) leaves the tracking ref stale, so a
256+
# retry can't satisfy the lease. Fail with the real cause instead of
257+
# letting the loop misreport it as a concurrent-advance rejection.
258+
git -C "$dir" fetch origin "$branch" || {
259+
fetch_rc=$?
260+
phase_err "failed to fetch origin/$branch before retrying the push"
261+
return "$fetch_rc"
262+
}
263+
fi
264+
done
265+
remote_sha=$(git -C "$dir" ls-remote --heads origin "$branch" | awk '{print $1}')
266+
phase_err "force-with-lease push rejected for branch $branch (remote HEAD=${remote_sha:-unknown}); remote may have advanced concurrently — re-run after fetch or resolve manually."
267+
return "$push_rc"
232268
else
233269
git -C "$dir" push origin "$branch"
234270
fi

.github/workflows/sync-translation.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,9 @@ jobs:
138138
2>/dev/null | sed 's/^[^ ]* //')
139139
140140
if [[ $updated -eq 1 ]] && ! git diff --staged --quiet; then
141-
git commit -m "Update libs submodules ($branch)"
142-
git push --force-with-lease origin "HEAD:$branch"
141+
# Commit + push through the shared helper so this path gets the same
142+
# bounded retry and --force-if-includes safety as the finalize flow.
143+
commit_and_push_translations_branch "$GITHUB_WORKSPACE" "$branch" "$branch" true
143144
echo " Pushed to origin/$branch" >&2
144145
else
145146
echo " No pointer changes for $branch." >&2

tests/helpers/git_fixtures.bash

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -86,27 +86,44 @@ push_commit_to_remote_branch() {
8686
rm -rf "$tmp"
8787
}
8888

89-
# Prepend a git wrapper to PATH that advances remote branch before push --force-with-lease.
89+
# A git wrapper that injects a concurrent advance before each force-with-lease
90+
# push, so the lease is rejected. The optional 4th arg caps how many pushes get a
91+
# fresh advance (0 = every push, the default); pass 1 to inject a single real
92+
# concurrent advance that the retry still refuses to overwrite. Pass 1 as the
93+
# 5th arg to also fail every branch-specific `git fetch origin <branch>` (the
94+
# inter-attempt retry fetch).
9095
install_git_push_pre_hook() {
91-
local bare="$1" branch="$2" message="${3:-concurrent advance}"
96+
local bare="$1" branch="$2" message="${3:-concurrent advance}" max_injections="${4:-0}" fail_branch_fetch="${5:-0}"
9297
local wrapper_dir="$GIT_FIXTURE_ROOT/bin"
9398
REAL_GIT="$(command -v git)"
9499
export REAL_GIT
95100
export GIT_HOOK_BARE_REMOTE="$bare" GIT_HOOK_BRANCH="$branch" GIT_HOOK_MESSAGE="$message"
101+
export GIT_HOOK_MAX_INJECTIONS="$max_injections"
102+
export GIT_HOOK_INJECT_COUNTER="$GIT_FIXTURE_ROOT/inject-count"
103+
export GIT_HOOK_FAIL_BRANCH_FETCH="$fail_branch_fetch"
104+
: >"$GIT_HOOK_INJECT_COUNTER"
96105
mkdir -p "$wrapper_dir"
97106
cat >"$wrapper_dir/git" <<'EOF'
98107
#!/usr/bin/env bash
108+
if [[ "$GIT_HOOK_FAIL_BRANCH_FETCH" == "1" && "${1:-}" == "-C" && "${3:-}" == "fetch" && -n "${5:-}" ]]; then
109+
echo "simulated fetch failure" >&2
110+
exit 1
111+
fi
99112
if [[ "${1:-}" == "-C" && "${3:-}" == "push" && "$*" == *"--force-with-lease"* ]]; then
100-
tmp="$(mktemp -d)"
101-
"$REAL_GIT" clone "$GIT_HOOK_BARE_REMOTE" "$tmp"
102-
"$REAL_GIT" -C "$tmp" config user.email "test@test.local"
103-
"$REAL_GIT" -C "$tmp" config user.name "Test"
104-
"$REAL_GIT" -C "$tmp" checkout "$GIT_HOOK_BRANCH"
105-
echo "$GIT_HOOK_MESSAGE" >>"$tmp/README"
106-
"$REAL_GIT" -C "$tmp" add README
107-
"$REAL_GIT" -C "$tmp" commit -m "$GIT_HOOK_MESSAGE"
108-
"$REAL_GIT" -C "$tmp" push origin "$GIT_HOOK_BRANCH"
109-
rm -rf "$tmp"
113+
injected="$(wc -l <"$GIT_HOOK_INJECT_COUNTER" 2>/dev/null || echo 0)"
114+
if [[ "$GIT_HOOK_MAX_INJECTIONS" -eq 0 || "$injected" -lt "$GIT_HOOK_MAX_INJECTIONS" ]]; then
115+
tmp="$(mktemp -d)"
116+
"$REAL_GIT" clone "$GIT_HOOK_BARE_REMOTE" "$tmp"
117+
"$REAL_GIT" -C "$tmp" config user.email "test@test.local"
118+
"$REAL_GIT" -C "$tmp" config user.name "Test"
119+
"$REAL_GIT" -C "$tmp" checkout "$GIT_HOOK_BRANCH"
120+
echo "$GIT_HOOK_MESSAGE" >>"$tmp/README"
121+
"$REAL_GIT" -C "$tmp" add README
122+
"$REAL_GIT" -C "$tmp" commit -m "$GIT_HOOK_MESSAGE"
123+
"$REAL_GIT" -C "$tmp" push origin "$GIT_HOOK_BRANCH"
124+
rm -rf "$tmp"
125+
echo 1 >>"$GIT_HOOK_INJECT_COUNTER"
126+
fi
110127
fi
111128
exec "$REAL_GIT" "$@"
112129
EOF
@@ -122,20 +139,22 @@ restore_git_push_pre_hook() {
122139
rm -rf "$GIT_WRAPPER_DIR"
123140
unset GIT_WRAPPER_DIR REAL_GIT
124141
unset GIT_HOOK_BARE_REMOTE GIT_HOOK_BRANCH GIT_HOOK_MESSAGE GIT_FETCH_COUNTER_FILE
142+
unset GIT_HOOK_MAX_INJECTIONS GIT_HOOK_INJECT_COUNTER GIT_HOOK_FAIL_BRANCH_FETCH GIT_HIDE_PUSH_FLAG
125143
fi
126144
}
127145

128-
# Prepend a git wrapper that hides --force-with-lease from push -h (simulates pre-2.8 git).
129-
install_git_without_force_with_lease() {
146+
# Prepend a git wrapper that hides one flag from `git push -h` output (which
147+
# exits 129) to simulate a git that predates that flag.
148+
install_git_without_flag() {
130149
local wrapper_dir="$GIT_FIXTURE_ROOT/bin"
131150
REAL_GIT="$(command -v git)"
132-
export REAL_GIT
151+
export REAL_GIT GIT_HIDE_PUSH_FLAG="$1"
133152
mkdir -p "$wrapper_dir"
134153
cat >"$wrapper_dir/git" <<'EOF'
135154
#!/usr/bin/env bash
136155
if [[ "${1:-}" == "push" && "${2:-}" == "-h" ]]; then
137-
"$REAL_GIT" push -h 2>&1 | grep -v 'force-with-lease'
138-
exit 0
156+
"$REAL_GIT" push -h 2>&1 | grep -vF "$GIT_HIDE_PUSH_FLAG"
157+
exit 129 # match real `git push -h`
139158
fi
140159
exec "$REAL_GIT" "$@"
141160
EOF
@@ -144,6 +163,11 @@ EOF
144163
export PATH="$wrapper_dir:$PATH"
145164
}
146165

166+
# simulates pre-2.8 git (no force-with-lease)
167+
install_git_without_force_with_lease() { install_git_without_flag 'force-with-lease'; }
168+
# simulates a git from 2.8 to 2.29 (force-with-lease present, force-if-includes not)
169+
install_git_without_force_if_includes() { install_git_without_flag 'force-if-includes'; }
170+
147171
# Prepend a git wrapper that counts fetch invocations and optionally injects concurrent push.
148172
install_git_fetch_counter() {
149173
local counter_file="$1"

tests/test_lib.bats

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ teardown() {
345345
git --git-dir="$BARE_REMOTE" show -s --format=%s "$remote_sha" | grep -q "concurrent advance"
346346
}
347347

348-
@test "finalize_translations_local: fail-fast on lease rejection (no retry)" {
348+
@test "finalize_translations_local: does not overwrite a concurrent advance (force-if-includes)" {
349349
# shellcheck source=tests/helpers/git_fixtures.bash
350350
source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash"
351351
init_git_fixture_root
@@ -358,22 +358,84 @@ teardown() {
358358
UPDATES=("algorithm")
359359
update_translations_submodule() { :; }
360360

361+
# One concurrent advance: the fetch updates the lease baseline, but
362+
# --force-if-includes still rejects the retry because that advance was never
363+
# integrated, so the run fails closed instead of overwriting it.
364+
install_git_push_pre_hook "$BARE_REMOTE" "${LOCAL_BRANCH_PREFIX}en" "concurrent advance" 1
365+
366+
local stderr_file="$BATS_TMPDIR/finalize-no-overwrite-stderr"
367+
set +e
368+
finalize_translations_local "$trans_dir" "develop" "en" 2>"$stderr_file"
369+
rc=$?
370+
set -e
371+
372+
[ "$rc" -ne 0 ]
373+
grep -q "force-with-lease push rejected" "$stderr_file"
374+
# the concurrent advance is still the remote tip, not overwritten by our push
375+
remote_sha=$(git ls-remote --heads "$BARE_REMOTE" "${LOCAL_BRANCH_PREFIX}en" | awk '{print $1}')
376+
git --git-dir="$BARE_REMOTE" show -s --format=%s "$remote_sha" | grep -q "concurrent advance"
377+
}
378+
379+
@test "finalize_translations_local: fails after force-with-lease retries are exhausted" {
380+
# shellcheck source=tests/helpers/git_fixtures.bash
381+
source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash"
382+
init_git_fixture_root
383+
create_bare_remote_with_clone "translations"
384+
create_remote_branch "$BARE_REMOTE" "${LOCAL_BRANCH_PREFIX}en" "$MASTER_BRANCH"
385+
trans_dir="$GIT_FIXTURE_ROOT/translations-work"
386+
git clone "$BARE_REMOTE" "$trans_dir"
387+
set_git_bot_config "$trans_dir"
388+
389+
UPDATES=("algorithm")
390+
update_translations_submodule() { :; }
391+
392+
# Advance the remote before every push, so all three attempts are rejected.
393+
# The counter also records fetches: 1 finalize pre-fetch + 2 between retries.
361394
local fetch_counter="$BATS_TMPDIR/fetch-count"
362395
: >"$fetch_counter"
363396
install_git_fetch_counter "$fetch_counter" "$BARE_REMOTE" "${LOCAL_BRANCH_PREFIX}en" "concurrent advance"
364397

365-
remote_sha_before=$(git ls-remote --heads "$BARE_REMOTE" "${LOCAL_BRANCH_PREFIX}en" | awk '{print $1}')
398+
local stderr_file="$BATS_TMPDIR/finalize-exhausted-stderr"
399+
set +e
400+
finalize_translations_local "$trans_dir" "develop" "en" 2>"$stderr_file"
401+
rc=$?
402+
set -e
403+
404+
[ "$rc" -ne 0 ]
405+
[ "$(wc -l <"$fetch_counter")" -eq 3 ]
406+
grep -q "force-with-lease push rejected" "$stderr_file"
407+
remote_sha=$(git ls-remote --heads "$BARE_REMOTE" "${LOCAL_BRANCH_PREFIX}en" | awk '{print $1}')
408+
grep -q "$remote_sha" "$stderr_file"
409+
git --git-dir="$BARE_REMOTE" show -s --format=%s "$remote_sha" | grep -q "concurrent advance"
410+
}
366411

412+
@test "finalize_translations_local: fails with the real cause when an inter-attempt fetch fails" {
413+
# shellcheck source=tests/helpers/git_fixtures.bash
414+
source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash"
415+
init_git_fixture_root
416+
create_bare_remote_with_clone "translations"
417+
create_remote_branch "$BARE_REMOTE" "${LOCAL_BRANCH_PREFIX}en" "$MASTER_BRANCH"
418+
trans_dir="$GIT_FIXTURE_ROOT/translations-work"
419+
git clone "$BARE_REMOTE" "$trans_dir"
420+
set_git_bot_config "$trans_dir"
421+
422+
UPDATES=("algorithm")
423+
update_translations_submodule() { :; }
424+
425+
# First push is rejected (one concurrent advance), then the inter-attempt
426+
# `git fetch origin <branch>` fails.
427+
install_git_push_pre_hook "$BARE_REMOTE" "${LOCAL_BRANCH_PREFIX}en" "concurrent advance" 1 1
428+
429+
local stderr_file="$BATS_TMPDIR/finalize-fetch-fail-stderr"
367430
set +e
368-
finalize_translations_local "$trans_dir" "develop" "en" 2>/dev/null
431+
finalize_translations_local "$trans_dir" "develop" "en" 2>"$stderr_file"
369432
rc=$?
370433
set -e
371434

372435
[ "$rc" -ne 0 ]
373-
[ "$(wc -l <"$fetch_counter")" -eq 1 ]
374-
remote_sha_after=$(git ls-remote --heads "$BARE_REMOTE" "${LOCAL_BRANCH_PREFIX}en" | awk '{print $1}')
375-
[ "$remote_sha_before" != "$remote_sha_after" ]
376-
git --git-dir="$BARE_REMOTE" show -s --format=%s "$remote_sha_after" | grep -q "concurrent advance"
436+
grep -q "failed to fetch origin/${LOCAL_BRANCH_PREFIX}en" "$stderr_file"
437+
# not misreported as a concurrent-advance rejection
438+
! grep -q "force-with-lease push rejected" "$stderr_file"
377439
}
378440

379441
@test "commit_and_push_translations_branch: clear error when force-with-lease unsupported" {
@@ -390,17 +452,55 @@ teardown() {
390452
install_git_without_force_with_lease
391453

392454
local stderr_file="$BATS_TMPDIR/push-unsupported-stderr"
393-
set +e
455+
# pipefail is on in the workflows; the probe must still work when `git push -h`
456+
# exits 129 (the shim mirrors that).
457+
set +e -o pipefail
394458
commit_and_push_translations_branch "$trans_dir" "${LOCAL_BRANCH_PREFIX}en" "develop" true \
395459
2>"$stderr_file"
396460
rc=$?
397-
set -e
461+
set -e +o pipefail
398462

399463
[ "$rc" -ne 0 ]
400464
grep -q "force-with-lease is not supported" "$stderr_file"
401465
grep -q "not supported by this Git installation" "$stderr_file"
402466
}
403467

468+
@test "commit_and_push_translations_branch: clear error when force-if-includes unsupported" {
469+
# shellcheck source=tests/helpers/git_fixtures.bash
470+
source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash"
471+
init_git_fixture_root
472+
create_bare_remote_with_clone "translations"
473+
create_remote_branch "$BARE_REMOTE" "${LOCAL_BRANCH_PREFIX}en" "$MASTER_BRANCH"
474+
trans_dir="$GIT_FIXTURE_ROOT/translations-work"
475+
git clone "$BARE_REMOTE" "$trans_dir"
476+
set_git_bot_config "$trans_dir"
477+
git -C "$trans_dir" checkout "${LOCAL_BRANCH_PREFIX}en"
478+
479+
# force-with-lease present, force-if-includes hidden (pre-2.30 Git).
480+
install_git_without_force_if_includes
481+
482+
local stderr_file="$BATS_TMPDIR/push-no-includes-stderr"
483+
set +e -o pipefail
484+
commit_and_push_translations_branch "$trans_dir" "${LOCAL_BRANCH_PREFIX}en" "develop" true \
485+
2>"$stderr_file"
486+
rc=$?
487+
set -e +o pipefail
488+
489+
[ "$rc" -ne 0 ]
490+
grep -q "force-if-includes is not supported" "$stderr_file"
491+
grep -q "requires Git 2.30+" "$stderr_file"
492+
}
493+
494+
@test "git_push_supports_* probes are pipefail-safe with real git" {
495+
# Modern git supports both flags, but `git push -h` exits 129; piped into grep
496+
# that fails under pipefail even when the flag is present. The probes must
497+
# still report supported.
498+
set -o pipefail
499+
git_push_supports_force_with_lease
500+
git_push_supports_force_if_includes
501+
set +o pipefail
502+
}
503+
404504
@test "begin_phase and end_phase: emit group markers and track CURRENT_PHASE" {
405505
local out_file="$BATS_TMPDIR/phase.out"
406506

0 commit comments

Comments
 (0)