From a2a0401a02f55b8ae4b0b7c628e788860e3782be Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Wed, 11 Nov 2026 08:21:04 +0100 Subject: [PATCH] fix(template-sync): read the signed branch back more than once before failing GitHub can return a branch's previous commit for a moment after an update. The signing helper read the branch once and failed a sync that had succeeded. It now reads up to five times, two seconds apart, and still fails closed when the branch keeps pointing elsewhere. Co-Authored-By: Claude Fable 5.2 --- .../scripts/replace-template-sync-commit.sh | 17 +++++- .../tests/test-template-sync-signed-commit.sh | 55 ++++++++++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/.github/scripts/replace-template-sync-commit.sh b/.github/scripts/replace-template-sync-commit.sh index ee022dfd..4bec51d3 100755 --- a/.github/scripts/replace-template-sync-commit.sh +++ b/.github/scripts/replace-template-sync-commit.sh @@ -407,8 +407,21 @@ jq -e \ and .commit.verification.verified == true' \ <<<"$verified_commit" >/dev/null || fail "replacement commit readback changed content or lost verification" -updated_ref="$(gh api "$ref_endpoint")" || fail "could not read back the generated branch" -updated_sha="$(jq -er '.object.sha' <<<"$updated_ref")" || fail "updated branch response has no sha" +# GitHub can serve a branch's previous commit for a moment after an update. Read it back a few +# times before failing, and still fail closed when it keeps pointing elsewhere (#897). +readback_attempts=5 +readback_pause_seconds=2 +updated_sha="" +for ((readback = 1; readback <= readback_attempts; readback++)); do + updated_ref="$(gh api "$ref_endpoint")" || fail "could not read back the generated branch" + updated_sha="$(jq -er '.object.sha' <<<"$updated_ref")" || fail "updated branch response has no sha" + if [[ "$updated_sha" == "$signed_sha" ]]; then + break + fi + if ((readback < readback_attempts)); then + sleep "$readback_pause_seconds" + fi +done [[ "$updated_sha" == "$signed_sha" ]] || fail "generated branch does not point to the verified replacement" printf '%s\n' "$signed_sha" diff --git a/.github/tests/test-template-sync-signed-commit.sh b/.github/tests/test-template-sync-signed-commit.sh index 8f06ea4d..745fd627 100755 --- a/.github/tests/test-template-sync-signed-commit.sh +++ b/.github/tests/test-template-sync-signed-commit.sh @@ -66,7 +66,17 @@ printf '%s %s\n' "$method" "$endpoint" >>"$FAKE_GH_LOG" case "$method $endpoint" in "GET repos/example/consumer/git/ref/heads/chore/template-sync_deadbee") if [[ -f "$FAKE_GH_UPDATED" ]]; then - jq -n --arg sha "$SIGNED_SHA" '{object:{sha:$sha}}' + # After the update, serve the previous commit for the first FAKE_GH_STALE_READS reads. + stale_seen=0 + if [[ -f "$FAKE_GH_UPDATED.reads" ]]; then + stale_seen="$(cat "$FAKE_GH_UPDATED.reads")" + fi + if ((stale_seen < ${FAKE_GH_STALE_READS:-0})); then + printf '%s\n' "$((stale_seen + 1))" >"$FAKE_GH_UPDATED.reads" + jq -n --arg sha "$REMOTE_SHA" '{object:{sha:$sha}}' + else + jq -n --arg sha "$SIGNED_SHA" '{object:{sha:$sha}}' + fi else jq -n --arg sha "$REMOTE_SHA" '{object:{sha:$sha}}' fi @@ -175,6 +185,49 @@ fi [[ ! -f "$FAKE_GH_UPDATED" ]] || fail "helper moved the ref before signature verification passed" grep -q "GitHub did not verify" "$fixture/error" || fail "unsigned failure did not name the verification gate" +# A branch read that is stale once after the update, then current, still succeeds (#897). +run_readback_case() { # -> runs the helper; output and error land in the fixture + fixture="$(make_fixture "$1")" + base_sha="$(git -C "$fixture/repo" rev-parse HEAD)" + git -C "$fixture/repo" switch -q -c chore/template-sync_deadbee + printf 'synced\n' >"$fixture/repo/file.txt" + git -C "$fixture/repo" add file.txt + git -C "$fixture/repo" commit -q -m "chore: sync template" + EXPECTED_TREE="$(git -C "$fixture/repo" rev-parse 'HEAD^{tree}')" + export EXPECTED_TREE + export EXPECTED_PARENT="$base_sha" + EXPECTED_MESSAGE="$(git -C "$fixture/repo" log -1 --format=%B | sed '${/^$/d;}')" + export EXPECTED_MESSAGE + REMOTE_SHA="$(git -C "$fixture/repo" rev-parse HEAD)" + export REMOTE_SHA + export FAKE_GH_LOG="$fixture/gh.log" + export FAKE_GH_UPDATED="$fixture/updated" + export FAKE_GH_MODE=success + export FAKE_GH_STALE_READS="$2" + ( + cd "$fixture/repo" + PATH="$fixture/bin:$PATH" GITHUB_REPOSITORY=example/consumer \ + "$helper" --base-sha "$base_sha" --branch-prefix chore/template-sync + ) >"$fixture/output" 2>"$fixture/error" +} +# Count the branch reads the helper made after it moved the branch. +reads_after_update() { + awk '/^PATCH / { seen = 1; next } seen && /^GET repos\/example\/consumer\/git\/ref\/heads\// { n++ } END { print n + 0 }' "$FAKE_GH_LOG" +} + +run_readback_case stale-once 1 || fail "helper failed although the branch read was stale only once" +grep -qx "$SIGNED_SHA" "$fixture/output" || fail "stale-once readback did not return the verified replacement sha" +[[ "$(reads_after_update)" == 2 ]] || fail "stale-once readback did not read the branch exactly twice" + +# A branch that keeps pointing at another commit still fails, after the bounded retries. +if run_readback_case never-current 99; then + fail "helper accepted a branch that never pointed at the verified replacement" +fi +grep -q "generated branch does not point to the verified replacement" "$fixture/error" || + fail "persistent readback mismatch did not name the branch gate" +[[ "$(reads_after_update)" == 5 ]] || fail "persistent readback mismatch was not read exactly five times" +unset FAKE_GH_STALE_READS + fixture="$(make_fixture no-change)" base_sha="$(git -C "$fixture/repo" rev-parse HEAD)" export FAKE_GH_LOG="$fixture/gh.log"