This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-8626-60a37b1e8495b7d16b1458560ae48d0834f7c513 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 35be7c72005cbe47e7bcf675920f96ed786467a9 Author: Meng Wang <[email protected]> AuthorDate: Mon Sep 21 21:39:50 2026 +0000 ci: open a pull request for a clean backport instead of pushing it (#8626) ### What changes were proposed in this PR? `Direct Backport Push` cherry-picks a cleanly-applying fix onto the release branch and pushes it. Every one of those pushes has been rejected since 2026-07-24: `release/*` is covered by the Merge Queue ruleset, which requires a pull request of everyone. #8379 tried to exempt the Actions app from it; GitHub refuses to create that bypass, and #8624 reverts it. ASF policy points the same way — an automated service must not push to a branch subject to official release without prior authorization from Infrastructure. Both outcomes now open a pull request. The conflicted one is unchanged: a draft, assigned to its author. A clean one opens **ready for review and assigned to nobody**, because there is no code for anyone to write on it. What a clean backport still needs is its checks started, and that is the part worth stating plainly. GitHub creates no workflow run for anything `GITHUB_TOKEN` does, so a bot-opened pull request has none — and nothing will arrive on its own: | action on a pull request with no checks | starts the three required contexts | | --- | --- | | push any commit to the branch | yes (`synchronize`) | | close and reopen it | yes (`reopened`) | | mark it ready for review | **no** — none of the three workflows listens for `ready_for_review` | | add or remove a label | only `Required Checks` | | "Re-run all jobs" | no — with no run there is nothing to re-run | A conflicted backport never had this problem: its author pushes a resolution, and that push brings CI with it. A clean one has nobody to push anything. So the comment the conflict path already posts for its instructions now says, for a clean backport, the one action that works — and says that marking it ready for review is not it. That leaves the release manager three ordinary buttons: reopen, approve, and auto-merge if they would rather not come back when the checks finish. The approval is not automated and should not be: the `release/*` label on the original PR records the decision, and this is the look at the tree that actually lands. Nothing here depends on a token's pull-request scope, on an Actions bypass, or on a close/reopen the workflow performs itself. Those are the paths that can only be proven in production, and that fail quietly when they are wrong — which is how #8432, #8494 and #8562 were lost. `push_entries` is now always empty, leaving `push-backports` unreachable. Removing it is left to a separate change, so that this one is a behaviour change and that one is a pure deletion. ### Any related issues, documentation, discussions? Closes #8377. #8378 proposed the same routing with the workflow performing the close/reopen itself and arming auto-merge; this drops both in favour of the release manager's own click, and is closed in favour of this. ### How was this PR tested? The routing was driven locally against a stubbed `github-script` environment. With the pre-merge preflight green, both targets come out as pull-request entries carrying `clean: "true"` and `push_entries` empty; with it neutral, `clean: "false"`; with no completed signal, neither target is acted on, as before. Restoring the old `pushEntries.push` turns that check red, so it is not vacuous. The workflow parses, and all four inline `github-script` bodies pass `node --check`. That a bot-opened pull request starts with no checks is what this repository already shows: #8584 — bot-opened, one commit, nobody pushed to it — carries no check runs at all, while #8553, opened the same way, has the full set after a commit was pushed. That `ready_for_review` does not start them is in the triggers: `required-checks.yml` lists `opened`/`reopened`/`synchronize`/`labeled`/`unlabeled`, `check-header.yml` takes the bare `pull_request:` defaults, and `lint-pr.yml` lists `opened`/`edited`/`reopened`/`synchronize`. Not provable before merge: that a human reopen produces the three contexts on a backport PR. #8619 to #8623 — five backports into `release/v1.3` opened by hand this week — show that the contexts do appear and pass on a pull request into a release branch; the reopen path shares everything with them but the event that starts the run. ### Was this PR authored or co-authored using generative AI tooling? Yes. Generated-by: Claude Code (claude-opus-5) --- .github/release-branches.yml | 9 +- .github/scripts/create-backport-branch.sh | 13 ++- .github/workflows/backport-checks.yml | 3 +- .github/workflows/backport-publish.yml | 2 +- .github/workflows/direct-backport-push.yml | 181 ++++++++++++++++++++++++----- 5 files changed, 169 insertions(+), 39 deletions(-) diff --git a/.github/release-branches.yml b/.github/release-branches.yml index ff3e76854a..a092d19f20 100644 --- a/.github/release-branches.yml +++ b/.github/release-branches.yml @@ -23,10 +23,11 @@ # - blocks the merge until that manager has approved the PR, or removed the # label to decline (.github/workflows/backport-approval-check.yml, the # required `Backport Approvals` check — see `manager` below); -# - drives the post-merge backport (.github/workflows/direct-backport-push.yml): -# a green pre-merge backport check cherry-picks straight to the branch, a -# red one opens a draft backport PR assigned to the author with the manager -# as reviewer. +# - drives the post-merge backport (.github/workflows/direct-backport-push.yml), +# which always opens a pull request against the branch: a green pre-merge +# backport check opens it ready for review with the manager requested, a +# red one opens a draft assigned to the author with the manager as +# reviewer. # # The label name equals the branch name (e.g. branch `release/v1.2` <-> label # `release/v1.2`). diff --git a/.github/scripts/create-backport-branch.sh b/.github/scripts/create-backport-branch.sh index 32c8fe5e69..8fda47be17 100755 --- a/.github/scripts/create-backport-branch.sh +++ b/.github/scripts/create-backport-branch.sh @@ -16,12 +16,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -# Builds and pushes the branch behind an auto-opened backport PR, used when the -# pre-merge backport check was red so a straight cherry-pick to the release -# branch is unsafe. The cherry-pick is committed even when it conflicts: the -# tree carries the conflict markers, and the human resolves them in the PR -# rather than starting the backport from scratch (the same approach the common -# backport bots take). +# Builds and pushes the branch behind an auto-opened backport PR. Every backport +# goes through one now, whatever the pre-merge backport check said: a release +# branch takes no direct push, so the check only decides whether the PR opens +# ready for review or as a draft. The cherry-pick is committed even when it +# conflicts: the tree carries the conflict markers, and the human resolves them +# in the PR rather than starting the backport from scratch (the same approach +# the common backport bots take). # # Usage: create-backport-branch.sh <merge-sha> <target-branch> <pr-number> # Writes to $GITHUB_OUTPUT (or stdout when unset): branch, version, diff --git a/.github/workflows/backport-checks.yml b/.github/workflows/backport-checks.yml index 7288928ef3..1244d8f7d2 100644 --- a/.github/workflows/backport-checks.yml +++ b/.github/workflows/backport-checks.yml @@ -28,7 +28,8 @@ # because this workflow runs on `pull_request`, whose token is read-only on # fork PRs (renovate, external contributors) and so cannot create check runs. # Post-merge, Direct Backport Push reads the neutral/success check and the -# backport build legs to decide push-straight vs. open-a-draft-PR. +# backport build legs to decide whether the backport PR it opens is ready for +# review or a draft for its author to finish. name: Backport Checks on: diff --git a/.github/workflows/backport-publish.yml b/.github/workflows/backport-publish.yml index fde3467d64..5cd309e685 100644 --- a/.github/workflows/backport-publish.yml +++ b/.github/workflows/backport-publish.yml @@ -93,7 +93,7 @@ jobs: ? `Cherry-picks cleanly onto ${r.target}` : `Conflicts on ${r.target} — needs a manual backport`, summary: applied - ? `The change applies cleanly onto \`${r.target}\`. If this PR merges it is cherry-picked straight to the release branch.` + ? `The change applies cleanly onto \`${r.target}\`. If this PR merges, a backport PR opens against that branch, ready for review.` : `The change does not apply cleanly onto \`${r.target}\`. This is advisory and does not block merging: after merge, Direct Backport Push opens a draft backport PR for manual conflict resolution. Remove the \`${r.target}\` label to decline the backport.`, }, }); diff --git a/.github/workflows/direct-backport-push.yml b/.github/workflows/direct-backport-push.yml index b67fcc90fc..fe96a37ed4 100644 --- a/.github/workflows/direct-backport-push.yml +++ b/.github/workflows/direct-backport-push.yml @@ -273,10 +273,12 @@ jobs: const commits = await listPushCommits(); core.info(`Push contains ${commits.length} commit(s).`); - // push_entries: (PR, target) pairs cherry-picked straight to the - // release branch, in commit order. - // pr_entries: (PR, target, manager) pairs that get an auto-opened - // draft backport PR for the author to finish. + // pr_entries: (PR, target, manager, clean) pairs that each get an + // auto-opened backport PR, in commit order -- ready for review + // when the cherry-pick is clean, a draft for its author to finish + // when it is not. + // push_entries: empty. Kept until the unreachable push-backports + // job is removed, so that removal is a pure deletion. const pushEntries = []; const prEntries = []; const seenPrNumbers = new Set(); @@ -314,11 +316,23 @@ jobs: `PR #${pullRequest.number}: push=[${push.join(", ")}] pr=[${pr.join(", ")}]` ); + // Both outcomes open a pull request. Pushing a cherry-pick + // straight onto a release branch is rejected by the Merge Queue + // ruleset that covers them (#8377), and ASF policy asks for prior + // Infrastructure authorization before an automated service writes + // a branch subject to official release -- so a backport travels + // the way every other change to a release branch travels. + // + // `clean` only changes what the pull request says and whether it + // opens as a draft: a clean cherry-pick needs nobody to touch the + // code, a conflicted one is still its author's to finish. for (const target of push) { - pushEntries.push({ + prEntries.push({ pr_number: pullRequest.number, merge_sha: commit.sha, target, + manager: releaseManagers.get(target) || "", + clean: "true", }); } for (const target of pr) { @@ -327,12 +341,17 @@ jobs: merge_sha: commit.sha, target, manager: releaseManagers.get(target) || "", + clean: "false", }); } } core.info(`Push entries: ${JSON.stringify(pushEntries)}`); core.info(`PR entries: ${JSON.stringify(prEntries)}`); + // Always empty now: every target opens a pull request. The + // push-backports job below is therefore unreachable, and is + // removed separately so this change is a behaviour change and + // that one is a pure deletion. core.setOutput("push_entries", JSON.stringify(pushEntries)); core.setOutput("pr_entries", JSON.stringify(prEntries)); core.setOutput("has_push", pushEntries.length > 0 ? "true" : "false"); @@ -796,9 +815,14 @@ jobs: needs: discover if: ${{ needs.discover.outputs.has_pr == 'true' }} runs-on: ubuntu-latest - name: "open backport PR #${{ matrix.pr_number }} to ${{ matrix.target }}" + name: "backport PR #${{ matrix.pr_number }} to ${{ matrix.target }}" strategy: fail-fast: false + # One at a time, as push-backports did: a single push can carry several + # fixes for the same release branch, and each cherry-pick is taken + # against that branch as it stands, so preparing them concurrently would + # race their branches against each other. + max-parallel: 1 matrix: include: ${{ fromJson(needs.discover.outputs.pr_entries) }} steps: @@ -818,7 +842,7 @@ jobs: run: | bash ./.github/scripts/create-backport-branch.sh \ "${MERGE_SHA}" "${TARGET_BRANCH}" "${PR_NUMBER}" - - name: Open draft backport PR + - name: Open backport PR uses: actions/github-script@v9 env: MERGE_SHA: ${{ matrix.merge_sha }} @@ -831,22 +855,38 @@ jobs: CONFLICT_FILES: ${{ steps.branch.outputs.conflict_files }} SUBJECT: ${{ steps.branch.outputs.subject }} FEATURE_ABSENT: ${{ steps.branch.outputs.feature_absent }} + CLEAN: ${{ matrix.clean }} with: - # Open the draft as github-actions[bot], not the PAT owner, by using - # the default GITHUB_TOKEN. Trade-off: a GITHUB_TOKEN-opened PR does - # not trigger pull_request CI — acceptable because this is a draft for - # manual conflict/build resolution, so CI fires once the human pushes - # their fix to the branch. + # Opened as github-actions[bot], not as the PAT owner, by using the + # default GITHUB_TOKEN — so nobody is recorded as the author of a + # pull request they did not write, and the token owner is never + # barred from approving one. + # + # The cost is that GitHub creates no workflow run for anything + # GITHUB_TOKEN does, so the pull request opens with no checks. A + # conflicted one does not care: CI fires when its author pushes a + # resolution. A clean one has nobody to push anything, so the comment + # posted on it asks for the one action that starts them. github-token: ${{ secrets.GITHUB_TOKEN }} script: | const { MERGE_SHA, TARGET_BRANCH, PR_NUMBER, MANAGER, BRANCH, VERSION, HAD_CONFLICT, CONFLICT_FILES, SUBJECT, - FEATURE_ABSENT, + FEATURE_ABSENT, CLEAN, } = process.env; const { owner, repo } = context.repo; const prNumber = Number(PR_NUMBER); const hadConflict = HAD_CONFLICT === "true"; + // Clean means the cherry-pick applied and the backported tree built + // green before the merge: nothing here needs a human's hands on the + // code, only the release manager's confirmation. + // + // CLEAN carries the pre-merge preflight's verdict, but the branch + // step above cherry-picked again just now, onto a release branch + // that may have moved since. Trust that fresher result too, or a + // race would open a tree full of conflict markers and call it + // conflict-free. + const clean = CLEAN === "true" && !hadConflict; const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; @@ -904,11 +944,63 @@ jobs: let pr = existing.data[0]; if (pr) { core.info(`Backport PR already open: #${pr.number}; not duplicating.`); + // The branch was just force-pushed with a fresh cherry-pick onto + // the release branch as it stands now, which may have moved since + // the PR was opened. If that changed the outcome, the PR in front + // of the reviewer no longer describes its own branch -- a tree + // with conflict markers still reading "nothing here was edited by + // hand", or a resolved one still marked draft. Neither is safe to + // leave standing, and rewriting the PR from here would overwrite + // whatever a human has since done to it. Say so and stop. + if (pr.draft === clean) { + const wanted = clean ? "ready for review" : "a draft"; + const note = + `This backport was re-run and its branch force-pushed. The ` + + `cherry-pick onto \`${TARGET_BRANCH}\` is now ` + + `**${clean ? "clean" : "conflicted"}**, so this PR should be ` + + `${wanted} — it is not, and what it says above no longer ` + + `describes its branch. Check the branch before reviewing: ` + + `[automation run](${runUrl}).`; + try { + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, body: note, + }); + } catch (e) { + core.warning(`Could not comment on #${pr.number}: ${e.message}`); + } + try { + await github.rest.repos.createCommitStatus({ + owner, repo, sha: MERGE_SHA, state: "failure", + context: `backport/${TARGET_BRANCH}`, + description: `Backport PR #${pr.number} no longer matches its branch`, + target_url: `${context.serverUrl}/${owner}/${repo}/pull/${pr.number}`, + }); + } catch (e) { + core.warning(`Could not set commit status: ${e.message}`); + } + core.setFailed( + `#${pr.number} is ${pr.draft ? "a draft" : "ready for review"} but the ` + + `re-run cherry-pick is ${clean ? "clean" : "conflicted"}; it needs a look.` + ); + return; + } } else { const conflictList = CONFLICT_FILES.trim() ? CONFLICT_FILES.trim().split(/\s+/).map((f) => `- \`${f}\``).join("\n") : ""; - const statusLine = hadConflict + const statusLine = clean + ? `**Clean cherry-pick — nothing here was edited by hand.** It applied to ` + + `\`${TARGET_BRANCH}\` without conflicts, and the backported tree built ` + + `green before #${prNumber} merged.\n\n` + + `**Close this PR and reopen it to start its required checks.** This ` + + `pull request was opened by \`github-actions[bot]\`, and GitHub starts ` + + `no workflow run for anything the Actions token does, so it has no ` + + `checks yet. Reopening it (or pushing any commit to the branch) emits ` + + `the event they subscribe to. *Marking it ready for review will not* — ` + + `none of the three required workflows listen for that.\n\n` + + `After that: approve, and enable auto-merge if you would rather not ` + + `come back when the checks finish.` + : hadConflict ? `The cherry-pick **conflicted** and was committed with conflict markers. ` + `Resolve the conflicts on this branch, then mark this PR ready for review.` : `The cherry-pick applied cleanly but the backported tree failed its ` + @@ -979,9 +1071,13 @@ jobs: ``, `### How was this PR tested?`, ``, - `Release-branch CI runs on this branch once the ` + - `${hadConflict ? "conflicts are resolved" : "build is fixed"} and ` + - `this PR is marked ready for review.`, + clean + ? `The backported tree built green before #${prNumber} merged. ` + + `Release-branch CI runs on this PR once it is reopened — see the ` + + `comment below.` + : `Release-branch CI runs on this branch once the ` + + `${hadConflict ? "conflicts are resolved" : "build is fixed"} and ` + + `this PR is marked ready for review.`, ``, `### Was this PR authored or co-authored using generative AI tooling?`, ``, @@ -990,9 +1086,12 @@ jobs: pr = (await github.rest.pulls.create({ owner, repo, base: TARGET_BRANCH, head: BRANCH, - title, body, draft: true, + // A clean backport opens ready for review: nobody has code to + // write on it, and a draft would only suggest otherwise. It + // still needs its checks started -- the comment below says how. + title, body, draft: !clean, })).data; - core.info(`Opened draft backport PR #${pr.number}.`); + core.info(`Opened ${clean ? "ready" : "draft"} backport PR #${pr.number}.`); // Post the how-to-finish instructions as a comment on the new // backport PR (kept out of the templated body). @@ -1018,7 +1117,10 @@ jobs: } catch (e) { core.warning(`Could not fetch PR #${prNumber} author: ${e.message}`); } - if (author) { + // Only a backport somebody has to finish gets assigned. A clean + // one is the release manager's to review, and assigning its author + // would tell them they have work they do not have. + if (author && !clean) { try { await github.rest.issues.addAssignees({ owner, repo, issue_number: pr.number, assignees: [author], @@ -1027,7 +1129,13 @@ jobs: core.warning(`Could not assign ${author}: ${e.message}`); } } - if (MANAGER && MANAGER !== author) { + // The backport PR is owned by github-actions[bot], so the source + // author is not its author and the guard below only exists to keep + // a conflicted backport's assignee from being asked to review their + // own resolution. A clean one has no assignee, and its manager must + // be asked even when they wrote the fix -- otherwise a fix authored + // by the release manager notifies nobody at all. + if (MANAGER && (clean || MANAGER !== author)) { try { await github.rest.pulls.requestReviewers({ owner, repo, pull_number: pr.number, reviewers: [MANAGER], @@ -1045,11 +1153,17 @@ jobs: try { await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, - body: - `Backport PR opened: draft #${pr.number} (${prUrl}) to ` + - `\`${TARGET_BRANCH}\`` + - (author ? `, assigned to @${author}` : "") + - ` — needs manual work because ${reason}.`, + body: clean + ? `Backport PR opened: #${pr.number} (${prUrl}) to ` + + `\`${TARGET_BRANCH}\` — the cherry-pick was clean, so it is ` + + `ready for review` + + (MANAGER ? `, waiting on @${MANAGER}` : "") + + `. It needs to be reopened once to start its checks; the PR ` + + `says how.` + : `Backport PR opened: draft #${pr.number} (${prUrl}) to ` + + `\`${TARGET_BRANCH}\`` + + (author ? `, assigned to @${author}` : "") + + ` — needs manual work because ${reason}.`, }); } catch (e) { core.warning(`Could not comment on #${prNumber}: ${e.message}`); @@ -1057,11 +1171,24 @@ jobs: // A failure commit status on the merge commit keeps the "needs // backport" signal visible on main next to the successful ones. + // A clean backport carries it too: it is open, not landed, and + // something still has to be done to it. + // + // Nothing updates this status afterwards -- not when the backport + // PR merges, not when it is closed. That predates this change and + // already leaves landed backports red (#8562's v1.2 status still + // names the draft #8585, which merged on 2026-09-19); routing the + // clean ones here widens it from some commits to all of them. + // Settling it needs a handler on the backport PR closing, which + // belongs with the removal of push-backports -- the only place in + // the repository that writes a success for this context. try { await github.rest.repos.createCommitStatus({ owner, repo, sha: MERGE_SHA, state: "failure", context: `backport/${TARGET_BRANCH}`, - description: `Draft backport PR #${pr.number} opened`, + description: clean + ? `Backport PR #${pr.number} open, awaiting review` + : `Draft backport PR #${pr.number} opened`, target_url: prUrl, }); } catch (e) {
