ci: automate v1 backports from the v1-needed label - #1327
Conversation
Backporting a fix to the maintenance branch meant opening a second pull request by hand and resolving the same conflicts every time. Six of the fifteen commits on v1 are backports, several of them rolling up five main PRs, so the work was already being batched to make the cost bearable. The obstacle is not the cherry-pick, it is the module path. main is google.golang.org/adk/v2 and v1 is google.golang.org/adk, so every Go file's import block differs and any patch touching imports conflicts. That difference is a pure string rewrite, so the patch is rewritten before it is applied and the common case now lands untouched. Verified against history rather than assumed: replaying #1195 fails as a raw cherry-pick and applies cleanly rewritten, and replaying #1301 reproduces the hand-made backport in 95a40be byte for byte. Genuine drift is left alone. Replaying #1252 rejects the hunks touching IsolationScope, a field v1 never had, which is the correct outcome; the script applies what it can, leaves .rej files, and prints the commands to finish. The workflow then comments on the original PR asking for a manual backport rather than failing silently. A clean apply is still not a correct backport. The hand-made #1195 also added a helper that existed on main but never on v1, so a patch can apply and not compile. The script says so and prints the verify commands. Two details worth recording. The job needs a PAT or App token rather than the built-in GITHUB_TOKEN, because pull requests opened with GITHUB_TOKEN do not trigger workflow runs and the backport PR would arrive with no CI and no way to merge it; the run refuses to start without one and confirms afterwards that the checks actually registered. And it triggers on push rather than pull_request_target, which would hand a token that can push and open pull requests to a workflow running in a pull request's context; the work is driven off the label queue, so it does not need the event payload at all. The queue clears itself: a PR drops off once its number is referenced by a commit on v1 or by an open PR targeting v1.
The queue skipped any PR whose number appeared anywhere in a v1 commit message, subject and body alike. Bodies are prose, and prose is full of numbers that mean something else. "Fixes #1152" names an issue. One commit explains that it bumped dependencies "rather than a cherry-pick of main's Dependabot commits (#1021, #1144, #1192, #1215, #1219, #1242, #1275)", naming seven PRs precisely because they were not backported. Fourteen numbers in the current history are references of that kind, and each one is a fix that would never appear in the queue and never be reported missing. A silent omission is the one failure this tool must not have, since not forgetting is the whole point of it. Read the two places a backport is actually recorded instead: the commit subject, where GitHub's squash puts "(#N)" and where a batched backport lists every number it carries, and the "* subject (#N)" bullets a squash leaves in the body for the commits it folded in. That second one matters and is easy to miss -- #1156 and #1217 are recorded only as bullets, and reading subjects alone would have re-queued both and opened duplicates. Checked against the whole v1 history: the twelve prose references become queueable again, and all forty-five real backport records are still matched. Open pull requests are matched on title alone. Every title this script writes carries its numbers, in the trailing "(#N)" of a single backport or the "(#a, #b, #c)" of a batch.
backported_prs ended in `| grep -oE '#[0-9]+' | tr -d '#' || true`, and that `|| true` covered the whole group feeding the pipe, the `gh pr list` call included. A network or auth blip while listing open v1 pull requests would therefore not fail; it would return a short exclusion set, and a short exclusion set means backporting something that is already in flight. Nightly, unattended, with nobody reading the log. The call is made up front now, and a failure stops the run. Refusing to act on an incomplete picture is the right instinct here: the queue is self-clearing, so a run skipped today is retried tomorrow at no cost, while a duplicate pull request has to be noticed and closed by a person. Also scopes the loop variable in the skip filter, which was assigning to a global, and drops a stale claim in confirm_checks that this only ever runs under a human's credentials. It runs in CI too now.
The new pull request was labelled through `gh pr create --label`, which fails outright when the repository has no such label rather than skipping it. By that point the branch has been pushed, so a rename of `v1` would leave the replayed commits on the remote with nothing pointing at them and the run red for a reason that reads nothing like "the label moved". Label as a separate step and warn if it does not take. A backport that arrives unlabelled is a small annoyance; one that never arrives is the failure this tool exists to prevent. The label names also stop borrowing the branch constants, which they only happened to match. V2_LABEL is what the queue filters merged main PRs on and V1_LABEL is what goes on the PRs this opens; neither has any reason to change when a branch name does.
The token check only asked whether the secret was non-empty. A fine-grained PAT scoped to an organization is non-empty, and authenticates, from the moment it is created -- but it cannot see the repository until an org admin approves the request. The run would get past the check and fail at actions/checkout instead, reporting that the repository could not be read, which points at everything except the pending approval that actually caused it. Ask the API what the token can do, before the checkout. Unreachable names approval as the likely cause and says where to look; readable but not pushable names the two permissions to grant. Both beat inferring it from a checkout failure. Verified against four cases: empty secret, a working token, a malformed token, and a valid token pointed at a repository it cannot see, which is the pending-approval case.
The labels changed: v2-only is gone, and v1-needed replaces it as the signal that a change on main still owes a 1.x equivalent. v1 and v2 stay on as information about which branch a pull request targets. That inverts what the queue reads. v2 was an opt-in that also happened to describe the branch, which made it do two jobs at once and left v2-only carrying the "no" case as a second label to remember. One label with behaviour attached and two that only describe things is easier to get right at review time, and it is the label the queue now filters on. The constant is BACKPORT_LABEL rather than V2_LABEL, since the name no longer has anything to do with a branch. V1_LABEL is untouched: a backport pull request targets v1, so the informational label still applies to the ones this opens. Verified against the live repository: the queue query returns #1328, the one pull request currently carrying v1-needed, with the exclusion set and output formatting intact around it.
|
The trigger choice holds up — Line references are pinned to d00dbb4. Three things I think need fixing before this lands. One conflicting PR stops every backport, and keeps commenting. The exclusion set matches any
Worth fixing in the same pass:
Smaller things, no need to hold the PR for them:
Nothing here needs a redesign — the shape of the tool is right. The theme is that the failure paths assume they will be rare, and a few of them are load-bearing on a branch where mistakes are hard to undo. |
The workflow refused to start without a BACKPORT_TOKEN secret on the grounds that a pull request opened with the built-in GITHUB_TOKEN never triggers workflow runs, so the backport PR would arrive with no CI and could never be merged. That stopped being true in June 2026. A pull request opened by github-actions[bot] now does trigger its pull_request workflows, in an approval-required state: the runs are created and wait for someone with write access to click "Approve workflows to run". So the backport PR can get its CI without the repository holding a personal access token that is bound to one person's account, outlives the job, and needs an organization approval before it works at all. The cost is one click on a pull request a human reviews and merges anyway. What this needs instead is the "Allow GitHub Actions to create and approve pull requests" repository setting. A workflow token cannot read it, so it is not checked up front; gh pr create fails on it and the failure handler names it rather than leaving a pushed branch and a 403 to interpret. confirm_checks no longer fails the run when nothing has registered. The branch is pushed and the PR is open by then, so exiting non-zero would report a backport that succeeded as broken; held runs are now the expected case, and it prints how to release them.
The review found three blockers and three near-blockers, and five of the six came from two design choices rather than from the code being wrong. Batching every pending backport onto one branch made a single conflict fatal to the whole run: apply_pr exited from inside the loop, so the PRs behind the conflicting one were never attempted and the ones that had already applied went away with the runner. It also made the batch title enumerate PRs that might have contributed no commit, and made the branch name a date that collides on the second batch of a day. Working out what was already backported by reading PR numbers out of commit subjects and PR titles meant parsing prose. An open v1 PR titled "address review feedback from #1301" silently dropped #1301 from the queue forever, and the die() protecting that lookup sat behind a pipeline in a command substitution, where errexit does not reach it. So: one branch and one pull request per original PR, replayed in its own worktree. A conflict now costs one backport. The branch name is a function of the PR number, so it cannot collide and it doubles as the in-flight check. And "is this already on v1" is answered by searching v1 for the "(cherry picked from commit <sha>)" trailer the script itself writes -- an exact match on something the automation owns, with no prose in the loop. What is left of the review is small and is fixed here: the push refuses any branch outside backport/v1/, a branch with no commits is never pushed, inherit_errexit is on, and an empty patch no longer counts as a backport. Failure modes are now distinguished -- a conflict is a normal outcome that comments once and keeps the PR queued, while a failed push or PR call fails the run, because nobody has been told. Dropped with the batching: --branch, --worktree, --force, --watch and the two-step resume flow, which existed to make a failed batch recoverable. --list, --pr and --skip-gomod remain. Verified against real history: replaying #1301 onto the tree before its hand-made backport reproduces 95a40be byte for byte, #1252 rejects exactly the IsolationScope hunks and leaves .rej files, and #1195 needs --skip-gomod. Note that #1195 does not apply cleanly with or without this change -- the claim in the original description was wrong, and is corrected there.
|
Thanks — this was a genuinely useful review, and the framing at the end ("the failure paths assume they will be rare") turned out to be the actionable part. Pushed 5f9413b. Five of your six substantive points came from two design choices rather than from the code, so I removed the choices instead of patching the symptoms. Batching is gone. One backport PR per original PR, each replayed on its own branch in its own worktree.
"Already backported" no longer parses prose. You suggested keying off something the automation owns; the script already writes git log "${remote}/v1" --fixed-strings --grep="cherry picked from commit ${sha}"
I verified the trailer lookup against real history first rather than assuming: all 27 existing Kept from your list as straightforward fixes:
Failure modes are also distinguished now, which they were not: a conflict is a normal outcome that comments once, keeps the PR queued and leaves the run green, while a failed push or Dropped with the batching: One correction to my own description, which you should not take on trust from the old testing plan. I had claimed #1195 "fails as a raw cherry-pick, applies cleanly once rewritten", and cited it as the case justifying the module rewrite. That is wrong. #1195 does not apply cleanly with or without the rewrite — the rejecting hunk is a dependency line in One trade-off this introduces, which batching did not have: independent branches are each cut from Still unproven, as before: the workflow has never run against the live API, and whether "Allow GitHub Actions to create and approve pull requests" is enabled on this repository is not readable from a workflow token. If it is off, the run pushes the branch and stops with a named error rather than a bare 403. Two of your smaller items I have deliberately not done: |
karolpiotrowicz
left a comment
There was a problem hiding this comment.
The module-path rewrite does what you claim: I replayed #1301 onto v1 as it stood before 95a40be and got exactly the same files, down to the tree hash. The push-instead-of-pull_request_target reasoning holds up, and so does the GITHUB_TOKEN argument — nothing here needs a long-lived credential. Five things need fixing first, and they share a shape: the script does the wrong thing and reports success.
The replay is tied to a named local branch, but the push resolves that name rather than the commit. backport.sh:218 is the only thing binding backport/v1/pr-<n> to the replayed commit, and its exit status is unchecked. If that branch already exists and is checked out anywhere in the clone, checkout -B fails, the worktree stays detached, and the commit is made on a detached HEAD. Both guards then pass while checking something other than what gets pushed: :297 tests a string built from the PR number and can never fail, and :299 counts commits on HEAD. :303 then pushes the ref name, which resolves to the pre-existing branch. The result is a backport PR carrying your own unrelated commit, whose description says it is identical to the original, on a run that reports backported 1 ... failed 0.
Both halves go away if the worktree stays detached: drop the checkout -B, push HEAD:refs/heads/${branch}, and drop the git branch -D. That also makes the :299 guard check the same ref that gets pushed. Neither variant can fire on a fresh runner, so this is a hazard for anyone driving the script by hand.
Reproduction
Needs a PR that is actually pending in the queue, so substitute a live number for <n>.
git switch -c backport/v1/pr-<n> origin/v1
git commit --allow-empty -m "unrelated local work"
git switch - # branch stays checked out in a worktree
scripts/backport.sh <n> --pr==> PR #<n>: <subject>
fatal: 'backport/v1/pr-<n>' is already used by worktree at <path>
==> applied cleanly
==> pushing backport/v1/pr-<n>
==> https://github.com/google/adk-go/pull/<new>
==> backported 1, conflicted 0, empty 0, failed 0
The commit now on the remote branch is the empty one, not the replay. Exit code 0.
The variant where the branch exists but is not checked out is quieter and loses data instead: -B resets it to the replay and git branch -D force-deletes it at the end, leaving whatever was there unreferenced by any ref.
The recovery procedure the bot posts destroys the work it asks for. The comment at backport.sh:184-185 says to resolve the rejects and then "Re-run with --pr once it builds". A re-run does not resume — :213-214 force-removes the worktree and rm -rfs the path before replaying, so the resolution is gone and the same patch conflicts for the same reason. I ran those steps against #1252: the hand-resolved commit ends up reachable only from the reflog, nothing is pushed, and the run exits 0. The terminal text at :256-259 is the procedure that works, and is what the posted comment should say.
An infrastructure failure is reported to the contributor as a conflict in their patch. Because backport_one is invoked as backport_one ... || status=$? at :410, set -e is inert for the whole function body. A failing git worktree add — disk, permissions, a stale registration the prune did not clear — falls through to git apply, which fails because the directory is not there, and comment_conflict posts "this change does not replay cleanly, which usually means 1.x lacks something 2.x already had" on a merged PR whose patch is fine. The run stays green. The same suppression covers the git commit that follows it.
die inside the per-PR path kills the whole run. die is exit 1, which the || status=$? at :410 does not catch — that suppresses errexit, not exit. So :204-205 and the two guards before the push abort mid-drain: every later PR is skipped and the summary line never prints. The documented contract for the function is that these return 3 and main collects them, which already produces a red run after draining the rest.
A branch pushed with no PR behind it silences that backport permanently. in_flight asks only whether the remote branch exists. If the push succeeds and gh pr create then fails, the branch stays and every later run reports "nothing pending" and exits 0 — one red run, then silence, with no backport PR in existence. The likeliest trigger is exactly the condition you handle at :327-331, so this is what the first real run looks like if "Allow GitHub Actions to create and approve pull requests" turns out to be off. Deleting the pushed branch on that return 3 path would make it retryable.
One thing I could not settle, and it is the largest of these if it is real. allow_rebase_merge is enabled on the repository. For a rebase merge, mergeCommit.oid is the last rebased commit rather than a squash of the branch, and it has a single parent either way, so the assumption stated at :228 would not hold. A multi-commit PR merged that way is backported partially, applies cleanly, and then clears itself from the queue on the trailer, permanently. Every one of the last 60 commits on main carries the squash signature, so this has never happened — but it is one button away and nothing asserts against it.
Smaller things:
--skip-gomodbuilds a patch relative to the working directory. The pathspecs at:222-223use.without:(top), so running from a subdirectory silently truncates the patch to that subtree — and the result clears every guard and writes the trailer.- The trailer match is an unanchored substring.
:123-125greps the whole message, and squash merges carry contributor commit bodies ontov1verbatim, so a mergedv1PR whose body contains that line suppresses an arbitrary backport.grep -Fxqon the exact line is tighter. - The workflow input check is line-oriented, so a newline gets past it.
grep -qEatbackport.yml:96matches any line, soprsset to$'1301\n*'passes andargs=(${PRS})then globs against the repo root into 28 paths. The script's own option loop rejects them, so nothing executes, but the containment is in a different file from the check. A bash[[ =~ ]]anchors to the whole string. - The
sedat:232rewrites module paths without their versions, turninggoogle.golang.org/adk/v2 v2.0.0inplugin/agentanalytics/go.modinto something the go tool rejects withshould be v0 or v1, not v2. It surfaces as a red build on the backport PR rather than silently. detect_remotematches the repository by substring, so on a fork:106finds nothing and the job is red on every push to a fork'smainonce Actions are enabled there. Anif: github.repository == 'google/adk-go'on the job avoids that. The same match would also accept a remote pointing atgoogle/adk-go-experimental.- The script exits immediately on stock macOS bash.
shopt -s inherit_errexitat:36needs 4.4 and macOS ships 3.2, so a contributor following the--listline in CONTRIBUTING gets one line ofshopt:noise and exit 1. ABASH_VERSINFOcheck that names the requirement would turn that into an actionable message. - Untested, and worth someone checking on a Mac: bare
mktempat:226and:310is a GNU extension and BSDmktempwants a template. I had no macOS to run it on. If it does fail there,patchis empty, the guard above treats it as "empty patch; nothing to backport", and every PR is skipped on a green exit 0. - Nothing in CI checks either new file. There is no
shellcheckoractionlintstep in.github/workflows/, and the# shellcheck disable=SC2206is inert without one.shellcheck -o allis what surfaces theset -esuppression behind three of the findings above. CONTRIBUTING.md:79-80says your current checkout is never touched. It is: the script writes and force-deletesrefs/heads/backport/v1/pr-<n>and prunes worktrees in the invoking clone.CONTRIBUTING.md:105-108says authorship works out the same way it does today. The mechanism described is right — I checked #1310, one branch commit byktsoator, landed as95a40beauthored bywolowith aCo-authored-by. What changes is that the PR owner is nowgithub-actions[bot], sogit blameonv1points at the bot from here on.
Every one of these is the same shape: the script did the wrong thing and reported success. The replay is no longer tied to a named local branch. `checkout -B` was the only thing binding backport/v1/pr-<n> to the replayed commit, its exit status was unchecked, and the push resolved the name rather than the commit -- so with that branch already checked out in the clone, the commit landed on a detached HEAD and the push sent the pre-existing branch instead, inside a run reporting "backported 1 ... failed 0". The worktree now stays detached from start to finish, the push sends HEAD:refs/heads/<branch>, and nothing writes or deletes a local ref. The guards before the push check HEAD, which is now the thing being pushed. `git worktree add` and `git commit` are checked. `|| status=$?` at the call site makes errexit inert for the whole function, so a failing worktree add fell through to `git apply`, failed because the directory was not there, and posted "this change does not replay cleanly" on a merged pull request whose patch was fine. `die` no longer appears in the per-PR path. It is `exit 1`, which `|| status=$?` does not catch, so it aborted the drain mid-queue: every later PR skipped, no summary line. Those paths return 3, which main already collects into a red run after draining the rest. A failed `gh pr create` now takes the pushed branch back down. in_flight treats a pushed branch as a backport already under way, so leaving it behind removed that PR from the queue permanently -- one red run, then silence. The likeliest trigger is the "Actions cannot create pull requests" case the script already handles by name. The comment the bot posts on a conflict described a recovery procedure that destroys the work it asks for: "re-run with --pr" starts from a clean replay and rm -rf's the worktree first. It now says what the terminal already said -- finish it in the worktree, push HEAD. Rebase merges are asserted against rather than assumed away. The repository allows them, and for a rebase merge mergeCommit.oid is only the last commit of the branch, so a multi-commit PR would be backported in part, apply cleanly, and clear itself from the queue on the trailer. The PR's file set is compared against the merge commit's, and a merge commit that does not carry the whole PR is refused. Also: the trailer test is an exact line match, so a v1 commit quoting the phrase in prose no longer suppresses an unrelated backport; the --skip-gomod pathspec is anchored with :(top), so a run from a subdirectory no longer silently truncates the patch; the module rewrite leaves `<path> v2.x` in go.mod require lines alone rather than producing a go.mod the go tool rejects; the remote match is anchored, so it no longer accepts google/adk-go-experimental; the workflow input is matched with [[ =~ ]] rather than a line-oriented grep a newline gets past; the job is skipped outside google/adk-go; bash >= 4.4 is checked by name rather than failing on `shopt: invalid option`; and mktemp is given a template so it works on BSD. CONTRIBUTING no longer claims the current checkout is untouched, and notes that the backport PR owner is now the bot, so git blame on v1 points there with the human author in the trailer. Verified: #1301 still replays byte-identically onto the tree before 95a40be, with no local ref written and the worktree detached; the same replay is unaffected by a pre-existing backport/v1/pr-1301 checked out elsewhere, which is left untouched; a merge commit missing the PR's files is refused; a prose mention of the trailer no longer matches while a real trailer still does; --skip-gomod gives the same 5 files from the repository root and from tool/; #1252 still leaves its .rej files.
|
Thanks — the framing ("the script does the wrong thing and reports success") was the right way to group these, and it made them straightforward to work through. Pushed 86e1e2c. All five, plus the rebase-merge question and every smaller item. The fiveThe replay is no longer tied to a named local branch. You were right that Reproduced your scenario before and after — a
A failed The posted comment no longer describes a procedure that destroys the work. You're right that the terminal text was already correct and the comment wasn't. The comment now carries the same steps — finish in the worktree, push The rebase-merge questionWorth settling rather than leaving, so it's asserted now. Before replaying, the PR's file set ( Smaller itemsAll of them:
Not doneNo |
The lint job is golangci-lint matrixed over Go modules, so nothing in CI reads scripts/backport.sh or .github/scripts/apidiff.sh. shellcheck with --enable=all is what surfaces the set -e suppression class -- a git call whose failure is swallowed while the script carries on -- which was behind three of the findings on this pull request. shellcheck ships on the runner image, so this is a job rather than an install step.
Ran shellcheck --enable=all once, as a temporary CI job, to answer the review point that nothing checks these files. Two of its findings were real; the job is removed again rather than kept, since a permanent shell lint across the repository is a separate decision from this change. The one that matters is the same failure mode already fixed once for `gh pr list`. already_backported piped `git log` into the loop through a process substitution, where a failure is invisible: it reads as "no candidates", meaning not yet backported, and opens a duplicate pull request. The output is now captured and checked, so a failed search stops the run instead of guessing. The other was an unquoted expansion in the rebase-merge guard's message, which would glob a path list against the working directory. The remaining findings are deliberate: the boolean predicates in pending_queue and the `|| status=$?` collecting per-PR outcomes both suppress errexit on purpose, and that suppression is now handled by checking each git call explicitly rather than relying on it.
The conflict path's terminal instructions still said `git push <remote> <branch>`, which was correct while the worktree checked out a local branch of that name and stopped being correct when it went detached. From a detached HEAD there is no such ref, so a contributor following those steps gets 'src refspec does not match any' after doing the resolution work, and `gh pr create` without --head has nothing to infer from either. Same wording as the comment the bot posts, which was already fixed. Found while tracing what happens to a backport whose patch depends on an earlier one: it conflicts, and this is the text it prints.
|
Dropping the PAT is the right call and the reasoning behind it checks out — I read the June 2026 changelog and it says what the header says it does, so the backport PRs will get CI on an approval click rather than needing a long-lived credential. I also tried to steer the push target and could not: Line references are pinned to f0aafae. The rebase-merge guard refuses ordinary squash merges when run locally. The Both recovery procedures tell the person to commit the Smaller things:
On the guard in the first item: I would fix it rather than drop it. A silent half-backport that then clears itself from the queue on its own trailer is the worst outcome this design has, rebase merges are enabled on the repository even though nothing has used one, and refusing rather than warning is the right way round for that. All three of its problems are one-line changes. |
Three blockers, four that were filed as smaller but fail silently and
permanently, and four cosmetic.
The rebase-merge guard refused ordinary squash merges anywhere but CI.
jq sorts by codepoint and sort(1) by locale, and under en_US.UTF-8 --
the normal workstation setting, and mine, which is why the guard's own
test passed -- comm reports present files as missing and the run tells
the operator to cherry-pick a healthy squash by hand. Both sides are now
sorted and compared under LC_ALL=C, with core.quotePath off so a
non-ASCII path cannot break the same comparison on the runner. The file
list is also paginated now; gh pr view --json files stops at 100.
The gh pr create redirect sat on the assignment rather than inside the
substitution, so ${err} was always empty, the grep could never match,
and the "Allow GitHub Actions to create and approve pull requests"
guidance -- the one diagnosis the workflow header promises, on the
failure most likely to happen first -- was dead code. The operator got a
warning ending in a colon. Moving it inside also removes an ordering
hazard: a failed mktemp made 2>"" an ambiguous redirect that fired after
the PR was created, and the failure branch then deleted its branch.
Both recovery procedures said git add -A immediately after git apply
--reject wrote .rej files next to the sources, so following them put
reject files on the backport branch and then on v1. They now delete the
rejects, stage with -u, and carry the (cherry picked from commit <sha>)
trailer, without which a hand-finished backport is invisible to the
exclusion check once its branch is deleted.
in_flight passed a bare name to ls-remote, which matches the tail of
every ref, so somebody's <user>/backport/v1/pr-<n> would read as this
backport already being under way and drop the PR from the queue for
good. Fully qualified as refs/heads/ now.
The patch pipeline's status was unchecked, and with errexit inert inside
this function a failed git show left an empty file that was read as
"nothing to backport" and tallied as a benign skip on a green run. Of
the four return codes 2 was the one absorbing tooling failure; it now
has to be earned.
The conflict marker is matched only on comments by the bot or the
authenticated user, so it can no longer be pre-posted by anyone to
suppress the only signal a human gets. The job takes timeout-minutes,
since it holds a serialized concurrency group and the default is six
hours.
Also: an explicit PR number that matches nothing now says so and why,
rather than claiming the whole queue is empty on the command the
conflict comment tells contributors to run; the return-code docstring
lists all six paths that return 3; and the push guard's comment says it
is an assertion rather than the thing holding the invariant.
Verified: the locale false positive reproduces on this PR's own three
files before the change and is gone after; the setting-specific
guidance fires now that stderr is captured; the qualified ref still
matches the real branch and no longer matches a prefixed one; a failed
patch pipeline returns 3; the recovery text prints commands that work;
and #1301 still replays byte-identically onto the tree before 95a40be.
Self-review of the previous commit, before asking for another round. The guard swallowed a failed API call: `gh api | sort` inside an `if` meant an unreachable API produced an empty list, which read as "no files to compare" and skipped the check entirely. A guard whose purpose is to stop a silent half-backport must not disable itself silently -- if the file list cannot be fetched the merge shape is unknown, and unknown is not the same as fine. It now refuses. Raised the job timeout from 10 to 30 minutes. Ten was tuned for unwedging quickly and ignored what a kill costs: the job dies mid-drain, and a kill landing between the push and `gh pr create` leaves a branch with no pull request behind it, which in_flight then reads as a backport already under way and drops that PR from the queue. A full-history checkout plus a backlog of replays needs room.
|
All eleven fixed, in 2fbb51e, plus two more in 47d8ddd that I found reviewing my own work before asking you to look again. Thanks in particular for the locale one. I had the bug in front of me and picked the input that hides it: my machine is The threeThe locale-dependent guard. Both sides sorted and compared under The Both recovery procedures. They now delete the rejects, stage with The four filed as smallerI moved these up rather than treating them as smaller, since by your own framing all four fail silently and permanently:
The four cosmeticExplicit numbers that match nothing now say which and why instead of claiming the whole queue is empty; the return-code docstring lists all six paths that return 3; the push guard's comment says it is an assertion rather than the thing holding the invariant. Two I found reviewing my own fixesWorth calling out separately because both are mine, from the commit that fixed your findings: The merge-shape guard failed open. I wrote The timeout was tuned the wrong way. I picked ten minutes to unwedge the serialized group quickly and did not think about what a kill costs. The job dies mid-drain, and a kill between the push and A design question I would rather put to you than decideShould Still not evidence
The end-to-end path has never run. Same as before: it needs a merged Correcting something I said last roundI wrote that I would open a separate PR adding a shell lint job and suggested it land before this one. That is not what happened and it is not the plan. I ran |
karolpiotrowicz
left a comment
There was a problem hiding this comment.
All three round-3 fixes hold, and I checked them by measurement rather than by reading: LC_ALL=C on both sides with core.quotePath=false removes the misfire (C.UTF-8 and C match jq's codepoint order, en_US.UTF-8 does not), gh api --paginate returns all 547 files for #1109 where gh pr view --json files returns 100, and the stderr redirect inside the substitution makes the "Allow GitHub Actions to create and approve pull requests" guidance actually fire. The PIPESTATUS capture survives the intervening comment block and a failing git show now returns 3, and the fully-qualified refs/heads/ pattern no longer matches a prefixed branch while still matching the real one. One thing needs fixing before this merges, and it came in with the fix it belongs to.
Line references are pinned to 47d8ddd, the current head.
git add -u drops every file the patch adds. :396 runs git apply --reject without --index, so a file the patch adds lands in the worktree untracked. Both recovery procedures — :405-407 in the terminal text and :235-237 in the posted comment — then say find . -name '*.rej' -delete followed by git add -u, which stages tracked paths only. I ran the printed steps verbatim against a patch that modifies one tracked file and adds another:
apply --reject -> added.txt applied cleanly (UNTRACKED), existing.txt rejected
human resolves existing.txt, then runs the printed steps exactly
commit SUCCEEDED
files in the commit: [existing.txt]
added.txt present in tree? NO — silently dropped
trailer present? 1
The commit still carries the cherry-pick trailer, so already_backported matches it and the pull request leaves the queue permanently. A partial change on v1, reached by following the tool's own instructions, and it is the previous git add -A that did this correctly — that form staged the added file, it merely also staged the .rej. Since the find … -delete now runs first, git add -A after it is both safe and complete, which I confirmed on the same fixture. It is one character in two strings.
In the same block: the posted comment's trailer is a placeholder, and the terminal one is not. :240 is literally (cherry picked from commit <sha of the main commit>) where :410 interpolates the real ${sha} — comment_conflict never receives it, its signature is local pr="$1". A reader who follows the bot's comment rather than re-running the script produces a trailer the exclusion check will never match. The failure direction is safe, since a bogus trailer re-queues rather than silently dropping, but it sits inside the one string the whole predicate keys on.
A successful backport can be recorded as a conflict. :525 is the last statement of open_backport_pr, so cleanup_worktree's status becomes the return value. If both git worktree remove and the rm -rf fallback fail, a landed backport returns 1 and is tallied as conflicted at :602, warning "needs a manual backport" for a pull request that is already open. Nothing is lost and the run stays green, so it is one wrong log line, but cleanup_worktree "${worktree}"; return 0 closes it.
Something I could not settle, and you can in one run. :204 is me="$(gh api user --jq '.login' 2>/dev/null || echo '')", interpolated into a jq program at :207. What I measured: on an API error gh api … --jq writes the raw JSON body to stdout, so 2>/dev/null does not suppress it and || echo '' can only append. Feeding that value into the real filter gives failed to parse jq expression (line 1, column 85) … unexpected token "message", and gh pr view exits non-zero into the handler at :209-212, which warns and returns 0 — so the conflict comment would never post. What I could not test is the first link: whether GET /user fails at all under an Actions installation token, since I have no such token. Reviewers reading the same lines split on it, so it is worth settling rather than taking my word: one run with echo "[$(gh api user --jq .login 2>/dev/null || echo '')]" says which it is. Building the filter with a jq variable instead of string interpolation removes the class either way. Note the comment at :202-203 — "an empty login simply matches nothing" — is not guaranteed by the line above it.
Two more, both real and neither urgent. A run killed between the push at :472 and gh pr create at :480 leaves a branch with no pull request, and in_flight then excludes that PR on every later run — the killed run itself is red, so this is quiet rather than invisible, and the documented branch deletion recovers it. And the trailer match at :152 is a whole-line match anywhere in a v1 body, so a commit carrying another PR's sha on its own line would dequeue it — pre-existing, and I would not rework the core exclusion predicate on a fourth revision just for that.
On your in_flight question: I would change it, and I think the contract it documents is the weaker one. Branch-existence conflates "under way" with "attempted and died", and only the second is silent. Checking for an open pull request makes the orphan self-healing, for one API call per queue entry against a queue that is currently empty. Worth costing at the same time: --paginate at :294 turns one request into roughly nineteen for a 547-file pull request, and each can now fail into the fail-closed return 3 — two individually-correct fixes that compose into a new way for a run to go red.
`git apply --reject` runs without --index, so a file the patch adds is
left untracked in the worktree. Both recovery procedures then said
`git add -u`, which stages tracked paths only, so following the printed
steps verbatim committed the modified file and silently dropped the
added one -- with the cherry-pick trailer intact, so already_backported
matched it and the pull request left the queue for good. A partial
change on v1, reached by doing exactly what the tool said.
Reproduced on a patch that modifies one tracked file and adds another:
the commit carried [existing.txt] and added.txt was gone. Back to
`git add -A`, which is now both safe and complete because the
`find -name '*.rej' -delete` added last round runs first. Verified on
the same fixture: commit carries [added.txt existing.txt], no .rej.
The posted comment's trailer was a literal placeholder where the
terminal text interpolated the real sha, because comment_conflict was
never given it. It takes the sha now.
open_backport_pr ended on cleanup_worktree, so a worktree that refused
to be removed made a landed backport return 1 and be tallied as a
conflict, telling the contributor to redo by hand a pull request that
was already open. Returns 0 explicitly.
Settled the `gh api user` question by measurement: on any API error
`gh api --jq` prints the response body to stdout, so 2>/dev/null does
not suppress it and `|| echo ''` only appends. A bad token yields a
JSON blob, which interpolated into the jq filter makes jq refuse to
parse, `gh pr view` exit non-zero, and the handler warn and return 0 --
the conflict comment silently never posts. That is the CI path, since
GITHUB_TOKEN is an installation token and GET /user wants user-to-server
auth. The login is validated against ^[A-Za-z0-9-]{1,39}$ now, so
anything else becomes empty and matches no author.
in_flight keys on an open pull request rather than on the branch.
Branch-existence conflated "under way" with "attempted and died", and
only the second is silent: a run killed between the push and
`gh pr create` left a branch that excluded its PR from every later run.
CONTRIBUTING documents the new contract.
Costing that alongside the fail-closed guard, as raised: paginating the
file list up front made a 547-file pull request ~19 requests, each able
to fail into a refusal. The guard now compares file counts first -- one
request -- and fetches the full list only when they differ, to name what
is missing. A squash carries the whole PR so the counts match; a rebase
merge records one commit so they do not. Residual noted in the comment:
equal counts over different sets would pass.
|
All fixed in fce6485, and the one you left open is settled by measurement rather than by argument.
|
karolpiotrowicz
left a comment
There was a problem hiding this comment.
Verified rather than read, and all six hold.
The git add -A change is right, and I checked the thing that makes it right rather than the change itself: find … -delete runs before git add -A in both procedures, at :249/:252 and :441/:444, with no git add -u left anywhere. Running the printed steps verbatim on the same fixture now commits [added.txt existing.txt], no .rej, trailer present, nothing left untracked. Swapping the two lines puts the .rej back in the commit, which is the useful way to see that the ordering is load-bearing and not incidental.
The rest: comment_conflict takes the sha at :203 and the call site passes it, so both procedures now print the same trailer. return 0 after cleanup_worktree at :566. The login pattern drops every error body I threw at it — 401, 403, empty, over-length — to empty, which matches no author, so the filter stays parseable whatever a future endpoint returns. And in_flight behaves in both directions against live data: a head with an open pull request reads as in flight, an absent one does not.
Two things I checked because they were new rather than because you claimed them. in_flight now fails via die inside an && condition, which is the shape that produced the round-three defect, so I tested it in isolation instead of reasoning: a failing gh pr list stops the run with no queue emitted, and the healthy path still filters correctly. And the count comparison holds on real data — changedFiles equals the squash commit's file count on seven of seven merged pull requests I tried, including #1328, so the one-request common case is real rather than hoped for.
On the residual you asked me to weigh: acceptable, no follow-up. One correction to how it is recorded, though. You describe it as equal counts over different sets, but the comparison is over file names, so equal sets with different content pass too — a rebase whose last commit touches the same files as the whole branch clears both the count check and the comm. That is not something this change introduced. It is inherent to comparing file lists rather than content, and it predates the optimisation. Your mitigating argument covers it unchanged: the last commit's patch is written against a parent that is not on v1, so it would almost certainly fail to apply. Worth widening the comment so the recorded residual is the real one.
Two notes, neither blocking. The exclusion moved from a git operation to an API call, so gh pr list --head now costs one request per pending pull request where ls-remote cost none — immaterial at a queue of three, worth knowing before a bulk drain. And the queue is not one entry: #1328, #1394 and #1396 all carry v1-needed. #1396 is chore(deps): bump the go-minor-patch group with 4 updates, which is the case CONTRIBUTING and the conflict text both single out as needing judgement because the 1.x dependency set differs — so the first unattended run will exercise the conflict path rather than the happy one.
One thing worth saying plainly, since you named it yourself: -u came from my round-three suggestion and I gave it to you as "one character in two strings". You were right that the character was the easy part. The fixture is what told either of us which one, and I should have written it before recommending the change rather than after it broke.
The git add -u defect this blocked on is fixed at fce6485, along with everything else raised here. Re-reviewed at that head: #1327 (review)
The comment said equal counts over different sets would pass. The comparison is over file names, so equal sets with different content pass too: a rebase whose last commit touches the same files as the whole branch clears both the count check and the comm. Comparing lists rather than content is what allows it, and the count step neither introduced nor widened it -- but the recorded residual should be the real one. What covers it is unchanged and now stated: the last commit's patch is written against a parent that is not on v1, so it fails to apply rather than landing silently.
|
Residual comment widened in cc62798 — it now records that the comparison is over file names, so equal sets with different content pass too, and that the count step neither introduced nor widened it. Your framing, since it was more accurate than mine. Nothing else changed. Both notes taken. The API call per pending PR is worth watching if the queue ever gets long; at three it is noise. And thanks for spotting the queue composition — #1396 being a deps bump means the first unattended run exercises the conflict path, which is the better rehearsal of the two, so I would rather let it than relabel to get a clean first run. |
karolpiotrowicz
left a comment
There was a problem hiding this comment.
Carried forward to 05aa688, the current head, and I checked the carry rather than assuming it.
The only change to reviewed code since the round-five pass is cc62798, which is seven added and three removed lines entirely inside a comment block. The main merge that followed it leaves all three files the pull request owns byte-identical, so every finding verified at fce6485 holds unchanged on the executable content.
The widened residual reads correctly now. It records that the comparison is over file names, so equal sets with differing content clear both the count check and the comm, and that the count step neither introduced that nor widened it.
Approving. The two notes from the last round stay notes rather than conditions. gh pr list --head costs one request per pending pull request where ls-remote cost none, immaterial at a queue of three and worth knowing before a bulk drain. And letting #1396 be the first unattended run is the right call for the reason you gave, since a dependency bump exercises the conflict path rather than the happy one.
Link to Issue or Description of Change
2. Or, if no issue exists, describe the change:
Problem:
A fix that applies to both lines needs two pull requests, and the second one is
hand-made every time. Six of the fifteen commits on
v1are backports, severalrolling up five
mainPRs each, so the work was already being batched to keepthe cost bearable.
The obstacle is not the cherry-pick.
mainisgoogle.golang.org/adk/v2andv1isgoogle.golang.org/adk, so every Go file's import block differs betweenthe branches and any patch touching imports conflicts.
Solution:
A
v1-neededlabel says a change onmainstill owes a 1.x equivalent. Thismakes it do something: merging a labelled PR replays its squash commit onto a
branch cut from
v1and opens the backport PR.v1andv2stayinformational, recording which branch a PR targets.
That module path difference is a pure string rewrite, so the patch is rewritten
before it is applied and the common case lands untouched. Genuine drift still
conflicts and still goes to a human — the run comments on the original PR asking
for a manual backport rather than pushing something half-applied.
One backport PR per original PR. An earlier revision batched the whole queue
onto one branch, which made a single conflict fatal to the run and put the
"which PRs are already done" bookkeeping into parsed prose. Both problems went
away with the batching; see the review thread. A conflict now costs its own
backport and nothing else.
Two decisions worth surfacing for review:
It runs on the built-in
GITHUB_TOKEN; no secret. The usual objection isthat a pull request opened with
GITHUB_TOKENnever triggers workflow runs,so the backport PR would arrive with no CI and could never be merged. That
stopped being true in June
2026:
a PR opened by
github-actions[bot]does trigger itspull_requestworkflows, in an approval-required state — the runs are created and held
until someone with write access clicks Approve workflows to run.
So the choice is one click per backport, against a personal access token bound
to one person's account, outliving every job it is used in, and needing an org
approval before it works at all. That click lands on a pull request a human
has to review and merge anyway. What it does need is the "Allow GitHub
Actions to create and approve pull requests" repository setting (Settings →
Actions → General, possibly inherited from the org). A workflow token cannot
read that setting, so it is not checked up front:
gh pr createfails on itand the script recognises that specific failure and names it, rather than
leaving a pushed branch and a bare 403 to interpret.
It triggers on
push, notpull_request_target. The job holds a tokenthat can push and open pull requests, and
pull_request_targetwould grantthat to a workflow running in a pull request's context. Since the work is
driven off the label queue rather than the event payload,
pushcostsnothing: a nightly
schedulecatches PRs labelled after they merged, andworkflow_dispatchtakes PR numbers orall.The queue clears itself. A PR leaves it when its change reaches
v1— matchedon the
(cherry picked from commit <sha>)trailer the script writes, so thetest is an exact match on something the automation owns rather than a number
scraped out of a title — or while its
backport/v1/pr-<n>branch exists.Deleting that branch re-queues the PR, which is how a stale backport is
regenerated.
Testing Plan
No Go code changes, so there are no unit tests to add. The shell and workflow
logic was verified against real repository history rather than by inspection.
Replay correctness, against backports that were already done by hand. Each
was replayed onto the
v1tree as it stood before the hand-made backportlanded, and the result compared to it:
#1301→ reproduces95a40be6byte for byte (git diffagainst it isempty). This is the regression test for the whole apply path.
#1252→ rejects exactly the hunks touchingIsolationScope, a fieldv1never had. Correct outcome: 4 of 7 hunks applied,
.rejfiles left behind.#1195→ needs--skip-gomod, and then applies. Correcting an earlierclaim in this description: I previously wrote that fix(session): enforce user ownership in VertexAI DeleteSession #1195 "fails as a raw
cherry-pick, applies cleanly once rewritten". That is wrong. It does not apply
cleanly with or without the module rewrite — the rejecting hunk is a
dependency line in
go.mod, which the rewrite does not touch, because the 1.xdependency set differs from main's. I re-ran the previous revision of the
script to confirm this was never true rather than a regression here. With
--skip-gomodthe rest applies, though the result still differs from thehand-made backport, which also adapted a test — the case for "a clean apply is
not a correct backport".
Also verified: the queue lists and self-clears against the live API;
--list/ explicit-number / bad-input paths; that the(cherry picked from commit <sha>)lookup finds all 27 such trailers already onv1, including#1301's; that a PR already on
v1is excluded rather than replayed; YAMLvalidity; and that no
run:step interpolates${{ }}directly (everythingpasses through
env, per the template-injection fix in #1297).Found by testing, fixed here: removing a worktree directory without
unregistering it leaves git refusing to re-create it as "missing but already
registered", which broke the second replay of any PR on the same machine. CI
would not have hit it — runners are fresh and each PR gets its own worktree
path — so this was a local-only fault on exactly the manual path the conflict
message tells you to use.
What has not been tested: the assembled workflow has never run against the
live API, because that needs a merged
v1-neededPR. Each piece is verifiedindividually; the end-to-end path is not. The first real merge is the actual
test, and is also where we find out whether "Allow GitHub Actions to create and
approve pull requests" is enabled here — if it is not, the run pushes the branch
and stops with a named error rather than a bare 403.
Manual E2E:
Replays happen in a scratch worktree under
$TMPDIR, so the current checkout isnever touched, and nothing is pushed without
--pr.Checklist
— no Go code here, so there is no unit test to add. Verified against real
repository history instead; see the Testing Plan.
— zero Go files changed, so the suite is unaffected and I did not run it.
Claiming otherwise would be inaccurate.
three historical backports. The workflow has not run against the live API;
that needs a merged
v1-neededPR.— not applicable.
Additional context
CONTRIBUTING.mdgains a "Backporting tov1" section documenting the threelabels, the workflow, and the two things the tooling deliberately does not do:
a clean apply is not a correct backport (the hand-made
#1195also adapted atest, so a patch can apply and still not be right), and dependency changes need
judgement because the 1.x dependency set differs.