Repository navigation
rebase mode's force-push auto-closes open sync PRs — branch content survives, the PR silently leaves the open set #280
Description
Note (2026-10-09): adopted on 2026-10-06 as a work item of #349 (adoption comment). The adopted scope, rebase moving a PR branch in one step and queuing its runs, supersedes the reopen guard under Suggested guard below.
Observed 2026-08-18/19 on lecture-python.zh-cn during a backlog drain of 29 open sync PRs. Two open sync PRs were silently closed by rebase mode's force-push, with their branch content fully intact. The PR left the open set; nothing was delivered; no error was raised anywhere.
Mechanism
Each merge of a translation PR triggers rebase-translations.yml, which runs the Action in mode: rebase and force-pushes every other open sync branch. When the force-push lands, GitHub momentarily evaluates the branch as having no commits ahead of the base and auto-closes the PR. The close is permanent — GitHub does not reopen the PR when the branch is repopulated microseconds later.
The event logs put both events in the same second, which is the signature to look for:
| PR | head_ref_force_pushed |
closed |
Branch content after close |
|---|---|---|---|
| lecture-python.zh-cn#240 | 2026-08-18T23:22:43Z | 2026-08-18T23:22:43Z | 6 files (three likelihood lectures + state) |
| lecture-python.zh-cn#247 | 2026-08-18T23:27:11Z | 2026-08-18T23:27:11Z | 30 commits ahead of main, 30 files |
Why this is worse than it first looks
The closed PR reports changed_files: 0 and an empty gh pr diff, because the PR object is frozen against a head that no longer matches the branch ref. Anyone auditing the tracker after a drain reads that as "already merged, subsumed by another PR" — which is exactly what I concluded before checking the event log, and I posted an incorrect closing comment on #240 as a result. The branch ref is the only place the truth survives; gh api repos/OWNER/REPO/compare/main...BRANCH reports the real ahead_by and file count.
This is a silent-delivery-loss shape in the same family as #90 and the W1 work in #259, but it lives in the rebase workflow rather than the sync run, so a declared-vs-delivered assertion inside sync mode would not see it.
Recovery is not simply reopen
gh pr reopen fails once the branch has been force-pushed again after the close (the recorded head no longer matches the ref), so recovery meant recreating the PRs from the same branches, as lecture-python.zh-cn#255 and lecture-python.zh-cn#256. A recreated PR is not a first-class sync PR until three things are restored, each of which failed loudly enough to find but would be easy to miss:
- The labels
action-translationandautomated— the review workflow'sifrequires the first, so review runs reportskippedwithout it. - The original generated PR body — review mode aborts with "Could not find source PR reference in translation PR body" unless the
### Source PRblock is present. - A review re-fire, by removing and re-adding the
action-translationlabel.
Suggested guard
After each force-push in rebaseSinglePR, assert the PR is still open and reopen it if not; failing that, treat a post-push closed state as a run failure so it surfaces rather than passing silently. Given issue 160 made rebase mode capable of failing, this seems like a natural addition to that path. A cheaper mitigation, if the race is hard to close directly, is to push the rebased content as a single non-empty update rather than any sequence that leaves the branch momentarily empty.
Related: #276 covers a second defect in the same regeneration path observed the same night, and #259 tracks the broader silent-partial-delivery work.
Activity
mmcky commentedon Aug 19, 2026
Severity upgrade: this has already caused undelivered content in production, and it goes back further than the two instances above.
Found while tidying up after the drain — an orphaned branch on lecture-python.zh-cn with no open PR:
translation-sync-2026-08-05T23-15-41-pr-760, 15 commits ahead of main. It belongs to lecture-python.zh-cn#242 ("Add Job Search V lecture on risk-sensitive preferences"), and its event log carries the same signature as the two 2026-08-18 instances:head_ref_force_pushedclosedThere is no separate manual close event — the only close is the one coincident with the force-push, and the automation's own comment lands nine seconds later announcing the rebase it had just performed.
The consequence is a missing lecture. A full set comparison of source against target as of today:
lectures/*.mdin QuantEcon/lecture-python.mystlectures/*.mdin QuantEcon/lecture-python.zh-cnlearning_approximation.md,mccall_risk.mdlearning_approximationis known and tracked (lecture-python.zh-cn#210, blocked on #118).mccall_riskis not tracked anywhere — it is absent from the edition'slectures/and absent from its_toc.yml, while present in both upstream. It was never delivered by any other route: the PR's other five files were updates to lectures that already existed and have since been superseded, so nothing flagged the gap. The edition has been one lecture short of source for twelve days, and the only reason it surfaced now is that the branch outlived its PR and I went looking for stray refs.This is the failure mode the issue describes, realised: the PR left the open set, the branch retained the content, and no stage — not CI, not review mode, not the sync run, not a coverage check — noticed that a lecture the source had was never delivered. It is worth noting the detection gap independently of the race, since any path that drops a lecture would be equally invisible today; a periodic source-vs-target set comparison would catch this class regardless of cause.
Recovery here should probably not be a straight recreate-the-PR: that branch is 37 commits behind main and, per #276, regenerating or merging stale snapshot content risks reverting newer target edits to the five other lectures it touches. A targeted gap-fill of
mccall_riskalone at the current engine looks like the cleaner route.Seed mccall_risk (gap-fill via translate init -f at engine e6e9176) (#…
quantecon-services commentedon Oct 6, 2026
Adopted on 2026-10-06 as a work item of the project #349, Hardening from the 2026-10-05 codebase review: forward resyncs carry edition policy, rebase moves a PR branch in one step. The 2026-10-05 codebase review found the mechanism in the code.
rebaseSinglePRmoves the PR branch tomain's own SHA before it writes a single file, so every overlapping rebase passes through the state GitHub closes a PR on. The same-second timelines above come from that window being hit. The report and the comment above stand as written; this comment adds the evidence and fixes the scope.The item also covers two more ways the same write step loses content: partial writes on an unretried error, and a sibling rebase that races a merge and silently reverts it. It also covers the rebase workflow's
concurrencyblock, which today cancels the one run that would repair the second. Sync PR creation is out of scope.Where
At
415cf87, the write step ofrebaseSinglePR(src/index.ts:551-614), condensed:src/index.ts:291,:326,:378and:403read the sibling's target files with noref.fetchFileContent(:901-913) then reads whatever the default branch holds at that moment. That need not be themainthe branch is later reset onto.src/index.ts:238is the failure comment: "You may need to manually resolve conflicts or run/translate-resyncon the source PR."src/index.ts:116-122and:165-166: each run checks overlap only against the merged PR that triggered it.examples/rebase-translations.yml:43-46has noqueue:key:translate setupcopies the file verbatim (src/cli/commands/setup.ts:319,:372-375), and the E2E harness renders it (tool-test-action-on-github/test-action-on-github.sh:273).Two throws guard the reset: the content-fetch throw at
:428-434(#165) and the translation-error throw at:537-544(#160). Both cover reads and translation, and nothing covers the writes after the reset. Nothing insrc/callscreateTreeorcreateCommit. The octokit is a plaingithub.getOctokitwith no retry or throttling plugin.dist-action/index.jsships the same sequence.Failure scenario
1. Auto-close (this issue). From the reset until the first file commit lands, the branch has no commits ahead of
main. That window lasts at least two API round trips. If GitHub evaluates the PR inside it, GitHub closes the PR, and it does not reopen it when the branch is repopulated. Most rebases miss the window, but production has four instances, each withhead_ref_force_pushedandclosedin the same second:mccall_risk.mdstayed out of the edition for 12+ days (it is present today).:564-571creates.2. Partial write. A write in the loop can fail with a 5xx, a secondary rate limit, or a 409 on a blob SHA read just after the reset (
.qe/dev/PLAN.mdPhase 4 already lists the 409). None of these is retried. That PR's rebase then aborts with the branch atmainplus k of N files. At k = 0 the PR auto-closes as in (1). Otherwise it stays open, looks routine, and lacks the remaining translations. The:238comment advises resolving conflicts and does not say the branch was reset. A failed deletion does not even abort the rebase: the catch at:607-609logs any error as "Not found on branch — skip deletion", and the file stays on the branch.rebaseSinglePRcannot be imported into a test today (#169). The review therefore ran the same per-file pattern increateTranslationPRagainst a mock octokit, and re-ran it on 2026-10-06 againstdist/pr-creator.jsat415cf87. A 409 on the third of four files (lectures/c.md) threw after two commits had landed, leaving the branch holdinglectures/a.mdandlectures/b.mdonly.3. Concurrency. A concurrency group holds one running and one pending run. Unless
queue: maxis set, a new arrival cancels the pending run. Suppose B and C merge while run A (triggered by A's merge) is still in progress. Run B is cancelled. A sibling D that shares a file only with B is never rebased, so it stays conflicted. The review modelled this against the run-queue rule and the overlap check at:165-166:The worse case is silent:
main.:556-562reads the newmain, and:564-571resets D onto it.D now shows no conflict and quietly undoes B's sections in the shared file. The
:225comment still tells the PR that only unchanged sections were updated to match currentmain. Run B would have rebased D again and repaired it, and run B is the run that C cancels. Nothing in the run reports the revert. Review re-runs on the rebased head only where the rebase workflow passes the PAT (#125); lecture-python-programming.ml and .ja still passGITHUB_TOKEN, under which it does not. Unless a reviewer spots B's sections going backwards in D's diff, merging D ships the revert to readers.Why it matters
In August 2026, four sync PRs on two public editions were closed silently. One loss kept
mccall_riskout of lecture-python.zh-cn for 12+ days without any stage noticing. #287's state drift (mccall_persist_trans.md) followed from the same lost PR. A closed PR reportschanged_files: 0, so an audit reads it as already merged. Recovery means recreating the PR by hand, then restoring its labels and body and re-firing review.A partial write produces an open PR that looks complete. The concurrency case bites during backlog drains (the 29-PR drain above is the pattern), and #63 measured a 62% conflict rate on fa sync PRs, so overlaps are routine. An org-wide code search on 2026-10-06 found the same
concurrencyblock, withcancel-in-progress: falseand noqueue:key, in therebase-translations.ymlof all seven production editions: lecture-intro.zh-cn, lecture-python.zh-cn, and lecture-python-programming.{zh-cn,fa,fr,ml,ja}.Fix
Only the rebase write step changes. The fix removes the mechanism instead of detecting it afterwards: the branch never equals
main.A commit module. Add a new module, e.g.
src/git-commit.ts, and register it indocs/developer/architecture.md, whichdocs-module-map.test.tsenforces. From a base SHA, the module builds a tree and a commit:createTreefails on asha: nullentry for a path that is absent from the base tree. Deletions are therefore filtered to paths present in it, and a dropped path gets an info log, as today's loop does for not-found. None of these calls moves a ref, so they can sit inside a small bounded retry on 5xx and secondary rate limits.The rebase write.
rebaseSinglePRreadsmain's SHA once, at its start, and uses that SHA as thereffor every target read and as the commit's parent. A single forcedgit.updateRefthen moves the branch from its old head straight to the new commit, andpulls.getconfirms the PR is still open.With the pin, a merge that races the rebase leaves D's branch behind
main. The result is a visible conflict or a clean three-way merge, never a silent revert, and the queued run for that merge then repairs it. PR Preserve localised _toc.yml part captions on sync #291 changes the_toc.ymltarget read at:326to carry the target's captions forward, so that read starts feeding content too. Whichever of the two PRs lands second passes the pinnedrefthere.The template. Add
queue: maxto the rebaseconcurrencyblock. Per GitHub's changelog of 2026-05-07, it queues up to 100 pending runs in order and cannot be combined withcancel-in-progress: true; the template already setsfalse.translate setupand the harness pick it up from the template.Order matters. Restoring every queued run also restores every force-push, so deployed editions get the queue setting only after the atomic write has been released and the floating tags have moved. This respects
D-2026-04-01-rebase-on-merge. The queue serialises workflow runs; it does not serialise PR merges, which is what the merge queue rejected there would have done. The decision's "merge order doesn't matter" also assumes that every merge's run executes, and the queue restores that.The fix has two side effects to accept: one commit per rebase instead of one per file, and
file.sha/fetchExistingStateShasgoing unused on this path. The item also settles PLAN.md Phase 4's "[L] Rebase force-push races": no blob SHA is read after a reset, and runs queue instead of being dropped.It is also how #259's "rebase refuses to force-push a branch to/behind base" clause (#256.4) gets delivered. As worded, that guard cannot sit in front of today's sequence, because the sequence always passes through base. The atomic write plus the tree-equals-base check does the job.
Out of scope:
createTranslationPR): PR Remove failed-new-file TOC entries before opening sync PR #289 edits it, and its idempotency belongs to PR creation reports failure when the GitHub API times out after succeeding (false negative; naive retry would duplicate) #92. On its own, a mid-loop failure there fails loudly and leaves only a stray branch;file.shaplumbing;index.ts;Acceptance criteria
main's own SHA. The branch moves exactly once per rebase, from its old head to a commit whose single parent is the pinnedmainSHA. NocreateOrUpdateFileContentsordeleteFilecall remains on the rebase path.main's tree, nothing is pushed. The PR is closed deliberately, with a comment that says nothing was left to carry and names the files the PR declared (Six defects observed syncing a six-PR batch to lecture-intro.zh-cn (declared files silently dropped, toc clobbered, .bib never synced) #256 defect 4).updateRefthat never targetsmain's SHA, the tree-equals-mainpath, a post-check that fails on a closed PR, and a commit-build failure that makes noupdateRefcall.concurrencyblock inexamples/rebase-translations.ymldeclaresqueue: maxalongsidecancel-in-progress: false, andsrc/cli/__tests__/workflow-templates.test.tsasserts it. The key is confirmed accepted on a harness target repository before any edition is patched.[Unreleased]has a Fixed entry (one commit per rebase instead of one per file), anddist-action/is rebuilt with the drift check green.rebase-translations.ymlis patched with the queue setting, and the list of repositories is recorded on this issue.head_ref_force_pushed/closedpair.The body's suggestion to "reopen it if not" is superseded. A reopen fails once the branch has been re-pushed (see this issue's own "Recovery is not simply reopen" section), and the fix removes the pass through base instead of repairing it afterwards.
Refs: #349, #256 (defect 4), #259 (the #256.4 clause of its state-not-response box), #92, #169, #281, #287, #276, #125, #63, #160, #165, PR #289, PR #291,
.qe/dev/PLAN.mdPhase 4,.qe/dev/decisions/D-2026-04-01-rebase-on-merge.mdGenerated by Claude Code
dev: 2026-10-05 codebase review — logs, hardening tracker #349, NEXT-…
dev: log — handover of the review follow-up's GitHub updates to a loc…
dev: run the 2026-10-06 review handover after #350 and #353 merged (#358