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-6941-9c6e8acfb9060600f7d6ff43a8025161cbdb25d6 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 1540a81949dc4111b633935da547dd3bb1ebf90e Author: Yicong Huang <[email protected]> AuthorDate: Mon Jul 27 15:23:36 2026 -0700 ci: automate backport labeling and open PRs for failed backports (#6941) ### What changes were proposed in this PR? This PR overhauls the release-backport lifecycle so it stops depending on people remembering. Three problems today, each addressed below. **Problem 1 — Backports are forgotten.** The `release/*` label is added by hand, so authors and committers routinely forget to request a backport, and fixes silently never reach the release branch. _Solution:_ Backporting becomes **opt-out**. A new `backport-auto-label` workflow labels every `fix:` PR into `main` with the configured `release/*` targets and requests review from each branch's **release manager**. `.github/release-branches.yml` is the single source of truth mapping each release branch to its manager and an `actively-supporting` flag (`release/v1.2` → `xuang7`, active; `release/v1.1` → `bobbai00`, inactive), parsed by a stdlib-only helper. Only actively-supporting branches are auto-labeled; an inactive branch stays a valid manual target but isn't offered by default. Remove a label to decline — a removal is remembered via the timeline, so a later edit never silently re-adds it. **Problem 2 — Failed backports are lost.** When a backport conflicts, the only trace is a PR comment that sinks to the bottom and gets forgotten; nobody is on the hook to finish it. And a conflicting cherry-pick still burns the full build matrix. _Solution:_ The pre-merge backport is split into a fast, git-only **apply-check** that gates the expensive build (a conflict is reported in seconds and no longer spins up the stacks), and `backport` is **removed from the Required Checks aggregator** so it's advisory, not a merge gate. Post-merge, `direct-backport-push` classifies each target from that signal: **green** cherry-picks straight to the release branch; **red** (conflict, or applies-but-won't-build) auto-opens a **draft** backport PR `fix(scope, vX.Y): …` on `backport/<PR>-<slug>-<target>`, with the conflicted tree committed, **assigned to the author** with the release manager as reviewer — an actionable task instead of a lost comment. **Problem 3 — Fixes for main-only features get backported anyway.** A fix for a feature that only exists on `main` shouldn't go to the release at all, but it still gets labeled and attempted. _Solution:_ **Automatic feature-absent skip** — before labeling a target (and again on the red path), skip it when every file the PR *modifies* (added files excluded) is absent on that release branch: the feature clearly isn't there. Any ambiguity keeps the label and lets the apply-check decide. For what automation can't catch (new code inside an existing file, or pure judgment), a **`no-backport-needed`** label is a hard manual veto honored end-to-end (auto-label, pre-merge precheck, and post-merge push). Note: a red apply-check now shows as a (non-blocking) red check on the PR — the intended "this needs a manual backport" signal, not a merge blocker. ### Any related issues, documentation, discussions? Resolves #6940. ### How was this PR tested? Static: `actionlint` on all workflows; YAML parses; every `github-script` block passes `node --check`; `bash -n` on the scripts; unit-checked the parser, branch-slug derivation, `fix(scope, vX.Y)` title injection, and the feature-absent git guard. End-to-end on a fork test harness ([`Yicong-Huang/texera`](https://github.com/Yicong-Huang/texera), throwaway `release/test-clean` + `release/test-conflict` branches, docs-only fixes to skip the heavy build): | Scenario | Result | Evidence | | --- | --- | --- | | `fix:` PR into main → auto-labeled with both targets | ✅ | [PR #19](https://github.com/Yicong-Huang/texera/pull/19) | | apply-check: green on clean target, red on conflicting one | ✅ | [run](https://github.com/Yicong-Huang/texera/actions/runs/30303217100) | | red apply-check does **not** block merge (Required Checks green) | ✅ | [run](https://github.com/Yicong-Huang/texera/actions/runs/30303217100) | | green target → cherry-picked to the release branch (author preserved) | ✅ | [commit `ef1df63`](https://github.com/Yicong-Huang/texera/commit/ef1df63cc6429b3ea9429a5375007422861934e8) · [run](https://github.com/Yicong-Huang/texera/actions/runs/30303765765) | | red target → draft PR `fix(scope, vX.Y): …`, conflict markers committed, assigned to author | ✅ | [PR #22](https://github.com/Yicong-Huang/texera/pull/22) | | fix touching only files absent on the release branch → not labeled | ✅ | [PR #20](https://github.com/Yicong-Huang/texera/pull/20) | | `no-backport-needed` → all backport work vetoed | ✅ | [PR #21](https://github.com/Yicong-Huang/texera/pull/21) | | remove a label then edit → not re-added (opt-out remembered) | ✅ | [PR #23](https://github.com/Yicong-Huang/texera/pull/23) | Not exercised on the fork: the request-review success path (the test manager was the PR author, so it correctly took the skip branch) and a full heavy-build green path (docs-only by design). ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]> --- .github/release-branches.yml | 46 ++++ .github/scripts/create-backport-branch.sh | 114 ++++++++++ .github/scripts/release_branches.py | 115 ++++++++++ .github/workflows/backport-auto-label.yml | 208 ++++++++++++++++++ .github/workflows/direct-backport-push.yml | 340 +++++++++++++++++++++++++---- .github/workflows/required-checks.yml | 81 ++++++- 6 files changed, 857 insertions(+), 47 deletions(-) diff --git a/.github/release-branches.yml b/.github/release-branches.yml new file mode 100644 index 0000000000..afc0f14747 --- /dev/null +++ b/.github/release-branches.yml @@ -0,0 +1,46 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Active release branches and their release managers. This file is the single +# source of truth for the backport automation. Listing a branch here: +# - auto-labels new `fix:` PRs with the branch's `release/*` label +# (.github/workflows/backport-auto-label.yml), so backports are opt-out +# rather than easy-to-forget opt-in; +# - requests review from the branch's release manager on those PRs; +# - 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. +# +# The label name equals the branch name (e.g. branch `release/v1.2` <-> label +# `release/v1.2`). +# +# `actively-supporting` controls the default: only actively-supporting branches +# are auto-labeled on new `fix:` PRs. An inactive branch stays a valid backport +# target — a maintainer can still add its label by hand and the apply-check / +# post-merge backport run as usual — it just isn't offered by default. Omitting +# the field defaults to true. When a branch reaches end-of-life, drop its entry +# entirely. +# +# `manager` is a GitHub username. It may be omitted (leave the branch as a +# backport target without an auto-requested reviewer), but is recommended. +targets: + - branch: release/v1.2 + manager: xuang7 + actively-supporting: true + - branch: release/v1.1 + manager: bobbai00 + actively-supporting: false diff --git a/.github/scripts/create-backport-branch.sh b/.github/scripts/create-backport-branch.sh new file mode 100755 index 0000000000..68f22d9ece --- /dev/null +++ b/.github/scripts/create-backport-branch.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash + +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# 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). +# +# Usage: create-backport-branch.sh <merge-sha> <target-branch> <pr-number> +# Writes to $GITHUB_OUTPUT (or stdout when unset): branch, version, +# had_conflict, conflict_files, subject. + +set -euo pipefail + +MERGE_SHA="${1:?merge sha is required}" +TARGET_BRANCH="${2:?target branch is required}" +PR_NUMBER="${3:?pr number is required}" + +log() { printf '[open-backport-pr %s] %s\n' "${TARGET_BRANCH}" "$*"; } +out() { printf '%s\n' "$*" >> "${GITHUB_OUTPUT:-/dev/stdout}"; } + +version="${TARGET_BRANCH#release/}" + +# Human-readable slug from the squash subject: drop the trailing "(#N)" and the +# conventional-commit "type(scope): " prefix, then kebab-case and truncate. +subject="$(git log -1 --format=%s "${MERGE_SHA}")" +desc="$(printf '%s' "${subject}" \ + | sed -E 's/ \(#[0-9]+\)$//; s/^[a-z]+(\([^)]*\))?!?: *//')" +slug="$(printf '%s' "${desc}" \ + | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9' '-' \ + | sed -E 's/-+/-/g; s/^-//; s/-$//' \ + | cut -c1-40 \ + | sed -E 's/-$//')" +[[ -n "${slug}" ]] || slug="backport" +branch="backport/${PR_NUMBER}-${slug}-${version}" +log "branch=${branch}" + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +original_author="$(git log -1 --format='%an <%ae>' "${MERGE_SHA}")" +merge_message="$(git log -1 --format=%B "${MERGE_SHA}")" + +git fetch --no-tags origin "${TARGET_BRANCH}" + +# Feature-absent guard: if every file this commit modifies or deletes is +# missing on the target branch, the fix targets code that does not exist on +# the release (e.g. a fix for a main-only feature). Skip instead of opening a +# doomed PR. Added files don't count — a backportable fix may add files too. +preexisting="$(git diff-tree --no-commit-id --name-only -r --diff-filter=MD "${MERGE_SHA}")" +if [[ -n "${preexisting}" ]]; then + any_present=0 + while IFS= read -r path; do + [[ -z "${path}" ]] && continue + if git cat-file -e "origin/${TARGET_BRANCH}:${path}" 2>/dev/null; then + any_present=1 + break + fi + done <<< "${preexisting}" + if [[ "${any_present}" -eq 0 ]]; then + log "all modified files absent on ${TARGET_BRANCH}; feature not on release — skipping PR" + out "feature_absent=true" + out "version=${version}" + exit 0 + fi +fi +out "feature_absent=false" + +git checkout -B "${branch}" "origin/${TARGET_BRANCH}" + +# --no-commit keeps full control of the message/author in both the clean and +# the conflicted case. On conflict the working tree keeps the markers; staging +# everything turns the unmerged entries into a normal (dirty) commit. +had_conflict=false +conflict_files="" +if ! git cherry-pick --no-commit "${MERGE_SHA}"; then + had_conflict=true + conflict_files="$(git diff --name-only --diff-filter=U | tr '\n' ' ')" + log "cherry-pick conflicted in: ${conflict_files}" + git add -A +fi +# Clear any cherry-pick sequencer state so the manual commit below is clean. +git cherry-pick --quit 2>/dev/null || true + +new_message="$(printf '%s' "${merge_message}" \ + | python3 .github/scripts/compose-backport-message.py "${MERGE_SHA}")" +printf '%s\n' "${new_message}" | git commit -F - --author="${original_author}" + +git push --force origin "HEAD:${branch}" +log "pushed ${branch}=$(git rev-parse HEAD)" + +out "branch=${branch}" +out "version=${version}" +out "had_conflict=${had_conflict}" +out "conflict_files=${conflict_files}" +out "subject=${subject}" diff --git a/.github/scripts/release_branches.py b/.github/scripts/release_branches.py new file mode 100644 index 0000000000..f0f2e61d49 --- /dev/null +++ b/.github/scripts/release_branches.py @@ -0,0 +1,115 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Reads .github/release-branches.yml and emits the release-branch -> manager +# mapping as JSON, so the backport workflows have one parser instead of each +# hand-rolling YAML handling in shell/JS. Uses only the standard library +# (GitHub runners have python3 but not necessarily PyYAML), parsing the small, +# fixed schema this file uses: +# +# targets: +# - branch: release/v1.2 # inline comments allowed +# manager: xuang7 # optional +# actively-supporting: true # optional, defaults to true +# +# Usage: +# release_branches.py [path] -> JSON array +# [{"branch","manager","active"}] +# release_branches.py [path] --targets -> JSON array of branch names +# release_branches.py [path] --manager B -> manager for branch B (or empty) + +import json +import sys + +DEFAULT_PATH = ".github/release-branches.yml" + + +def _strip(value): + # Drop an inline "# ..." comment (the values here never contain '#'), + # surrounding whitespace, and optional quotes. + value = value.split("#", 1)[0].strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + return value + + +def parse(path): + entries = [] + current = None + in_targets = False + with open(path, encoding="utf-8") as handle: + for raw in handle: + line = raw.rstrip("\n") + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + indent = len(line) - len(line.lstrip()) + if indent == 0: + # A new top-level key ends the targets block. + in_targets = stripped.split(":", 1)[0].strip() == "targets" + current = None + continue + if not in_targets: + continue + + item = stripped + if item.startswith("-"): + # `active` defaults to true; an entry is only inactive when it + # explicitly says so. + current = {"branch": "", "manager": "", "active": True} + entries.append(current) + item = item[1:].strip() + if not item: + continue + if current is None or ":" not in item: + continue + key, value = item.split(":", 1) + key = key.strip() + if key in ("branch", "manager"): + current[key] = _strip(value) + elif key == "actively-supporting": + current["active"] = _strip(value).lower() in ("true", "yes", "1") + + return [e for e in entries if e["branch"]] + + +def main(argv): + args = list(argv) + manager_of = None + mode = "entries" + if "--targets" in args: + args.remove("--targets") + mode = "targets" + if "--manager" in args: + idx = args.index("--manager") + manager_of = args[idx + 1] + del args[idx : idx + 2] + mode = "manager" + path = args[0] if args else DEFAULT_PATH + + entries = parse(path) + if mode == "targets": + print(json.dumps([e["branch"] for e in entries])) + elif mode == "manager": + match = next((e for e in entries if e["branch"] == manager_of), None) + print(match["manager"] if match else "") + else: + print(json.dumps(entries)) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/.github/workflows/backport-auto-label.yml b/.github/workflows/backport-auto-label.yml new file mode 100644 index 0000000000..f10959b8c4 --- /dev/null +++ b/.github/workflows/backport-auto-label.yml @@ -0,0 +1,208 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Makes backporting opt-out instead of easy-to-forget opt-in: when a `fix:` PR +# targeting main opens, label it with every active release branch from +# .github/release-branches.yml and request review from each branch's release +# manager. The author (or a committer) removes a release/* label to decline the +# backport for that branch — a removal is remembered (via the timeline), so a +# later edit never silently re-adds a label the author took off. + +name: Backport Auto Label + +on: + pull_request_target: + types: + - opened + - reopened + - edited + +permissions: + contents: read + pull-requests: write + +jobs: + auto-label: + # Only PRs into main get backport labels. A PR already targeting a + # release/* branch is itself a backport and must not be re-labeled. + if: ${{ github.event.pull_request.base.ref == 'main' }} + runs-on: ubuntu-latest + steps: + # Check out the base commit (a trusted commit already on main) so the + # release-branches config and its parser come from the base repo, never + # from the (untrusted) PR head. pull_request_target runs with write + # permissions, so we must not execute PR-controlled code here. + - name: Checkout base + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Read release branches + id: targets + run: | + entries=$(python3 .github/scripts/release_branches.py .github/release-branches.yml) + echo "entries=${entries}" >> "$GITHUB_OUTPUT" + echo "Release branch targets: ${entries}" + + - name: Label fix PRs and request release-manager review + uses: actions/github-script@v9 + env: + ENTRIES: ${{ steps.targets.outputs.entries }} + with: + # Add the label with the fine-grained PAT (falling back to the + # default token) so the resulting `labeled` event retriggers the + # backport pre-merge check in Required Checks. Labels applied by the + # default GITHUB_TOKEN do not trigger downstream workflows. + github-token: ${{ secrets.AUTO_MERGE_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const entries = JSON.parse(process.env.ENTRIES || "[]"); + if (entries.length === 0) { + core.info("No release branches configured; nothing to label."); + return; + } + + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const title = pr.title || ""; + + // Conventional-commit `fix:` (optional scope, optional `!`). Other + // types (feat/chore/docs/...) are not auto-backported. + if (!/^fix(\([^)]*\))?!?:/i.test(title)) { + core.info(`PR #${pr.number} title is not a fix (\"${title}\"); skipping.`); + return; + } + + const author = pr.user?.login; + const currentLabels = new Set((pr.labels || []).map((l) => l.name)); + + // Hard manual override: a maintainer marks a PR that must never be + // backported (e.g. it fixes a feature that only exists on main). + if (currentLabels.has("no-backport-needed")) { + core.info(`PR #${pr.number} has no-backport-needed; skipping all backport labels.`); + return; + } + + // A fix for a feature that does not exist on a release branch + // should not be backported there. The reliable, cheap signal: the + // files the PR *modifies* (not the ones it adds — a backportable + // fix may add files too) don't exist on that branch at all. If + // every modified file is absent, the feature isn't there, so skip + // the target. Any ambiguity (a modified file still exists, or the + // existence check errors) keeps the label and lets the apply-check + // decide — we would rather over-label than drop a real backport. + async function featureAbsentOnBranch(prNumber, branch) { + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: prNumber, per_page: 100, + }); + const preexisting = files + .filter((f) => f.status !== "added") + .map((f) => (f.status === "renamed" ? f.previous_filename : f.filename)); + if (preexisting.length === 0) return false; // pure-add fix applies cleanly + for (const path of preexisting) { + try { + await github.rest.repos.getContent({ owner, repo, path, ref: branch }); + return false; // a modified file exists on the branch + } catch (e) { + if (e.status === 404) continue; + core.warning(`getContent ${path}@${branch} failed (${e.status ?? "?"}); assuming present.`); + return false; + } + } + return true; // every modified file is absent from the branch + } + + // Labels the author (or anyone) explicitly removed. Re-adding one + // would override a deliberate opt-out, so we skip those forever. + const timeline = await github.paginate( + github.rest.issues.listEventsForTimeline, + { owner, repo, issue_number: pr.number, per_page: 100 } + ); + const removed = new Set( + timeline + .filter((e) => e.event === "unlabeled" && e.label?.name) + .map((e) => e.label.name) + ); + + async function ensureLabelExists(name) { + try { + await github.rest.issues.getLabel({ owner, repo, name }); + } catch (e) { + if (e.status !== 404) throw e; + // Match the colour of the existing release/* labels. + await github.rest.issues.createLabel({ + owner, + repo, + name, + color: "ccebc2", + description: `back porting to ${name}`, + }); + core.info(`Created missing label ${name}.`); + } + } + + for (const entry of entries) { + const label = entry.branch; + const manager = entry.manager; + + // Only actively-supporting branches are auto-labeled. An inactive + // branch stays a valid manual target (label it by hand and the + // apply-check / post-merge backport still run) — just not by + // default. + if (!entry.active) { + core.info(`${label} is not actively-supporting; not auto-labeling.`); + continue; + } + if (currentLabels.has(label)) { + core.info(`PR #${pr.number} already has ${label}.`); + continue; + } + if (removed.has(label)) { + core.info(`${label} was removed on PR #${pr.number} (opt-out); not re-adding.`); + continue; + } + if (await featureAbsentOnBranch(pr.number, label)) { + core.info(`PR #${pr.number}: all modified files absent on ${label}; feature not on that release — skipping.`); + continue; + } + + await ensureLabelExists(label); + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr.number, + labels: [label], + }); + core.info(`Added ${label} to PR #${pr.number}.`); + + if (manager && manager !== author) { + try { + await github.rest.pulls.requestReviewers({ + owner, + repo, + pull_number: pr.number, + reviewers: [manager], + }); + core.info(`Requested review from ${manager} for ${label}.`); + } catch (e) { + core.warning( + `Could not request review from ${manager} (status ${e.status ?? "?"}): ${e.message}` + ); + } + } else if (manager === author) { + core.info(`Release manager ${manager} is the author; skipping review request.`); + } + } diff --git a/.github/workflows/direct-backport-push.yml b/.github/workflows/direct-backport-push.yml index 2456db159b..58031e26e1 100644 --- a/.github/workflows/direct-backport-push.yml +++ b/.github/workflows/direct-backport-push.yml @@ -33,12 +33,26 @@ jobs: name: Discover direct backport targets runs-on: ubuntu-latest outputs: - entries: ${{ steps.discover.outputs.entries }} - has_targets: ${{ steps.discover.outputs.has_targets }} + push_entries: ${{ steps.discover.outputs.push_entries }} + pr_entries: ${{ steps.discover.outputs.pr_entries }} + has_push: ${{ steps.discover.outputs.has_push }} + has_pr: ${{ steps.discover.outputs.has_pr }} steps: - - name: Resolve merged PRs and green targets + - name: Checkout main + uses: actions/checkout@v7 + with: + fetch-depth: 1 + - name: Read release branches + id: release_branches + run: | + entries=$(python3 .github/scripts/release_branches.py .github/release-branches.yml) + echo "entries=${entries}" >> "$GITHUB_OUTPUT" + echo "Release branches: ${entries}" + - name: Resolve merged PRs and classify targets id: discover uses: actions/github-script@v9 + env: + RELEASE_BRANCHES: ${{ steps.release_branches.outputs.entries }} with: script: | const sha = context.sha; @@ -133,7 +147,10 @@ jobs: return null; } - async function greenTargetsFor(pullRequest, requestedTargets) { + // Collect every job across the PR head's Required Checks runs so + // both the pre-flight apply-check and the full backport build can + // be inspected per target. + async function jobsForPr(pullRequest) { const buildRuns = await github.paginate( github.rest.actions.listWorkflowRuns, { @@ -144,45 +161,88 @@ jobs: per_page: 100, } ); - if (buildRuns.length === 0) { - core.warning(`No Build workflow runs found for ${pullRequest.head.sha}.`); + core.warning(`No Required Checks runs found for ${pullRequest.head.sha}.`); return []; } - const allJobs = []; for (const run of buildRuns) { const jobs = await github.paginate( github.rest.actions.listJobsForWorkflowRun, - { - owner, - repo, - run_id: run.id, - per_page: 100, - } + { owner, repo, run_id: run.id, per_page: 100 } ); allJobs.push(...jobs); } + return allJobs; + } - return requestedTargets.filter((target) => { - const prefix = `backport (${target}) / `; - const targetJobs = allJobs.filter((job) => job.name.startsWith(prefix)); - // Treat skipped as green: precheck legitimately skips stacks - // based on PR labels, matching the Required Checks aggregator. - return ( - targetJobs.length > 0 && - targetJobs.every( - (job) => job.conclusion === "success" || job.conclusion === "skipped" - ) + const isDone = (job) => job.status === "completed"; + // Skipped legs count as green: precheck legitimately skips stacks + // by label; a real conflict shows up as an apply-check *failure*. + const isGreen = (job) => + job.conclusion === "success" || job.conclusion === "skipped"; + + // Classify each requested target into: + // push — safe to cherry-pick straight to the release branch: the + // pre-flight apply-check finished green AND no finished + // backport build leg failed. + // pr — needs a human: the apply-check finished red (conflict) + // or a finished build leg failed (applies but won't build). + // skip — no *completed* signal (nothing ran, or the PR merged + // before the checks finished); acting on an in-progress + // job could push a conflict or open a spurious PR, so we + // leave it (the scheduled reconciliation, if any, or a + // re-push will pick it up once checks are done). + function classifyTargets(allJobs, requestedTargets) { + const push = []; + const pr = []; + for (const target of requestedTargets) { + const applyJob = allJobs.find( + (job) => job.name === `backport-apply-check (${target})` ); - }); + const buildJobs = allJobs.filter((job) => + job.name.startsWith(`backport (${target}) / `) + ); + + if (applyJob && isDone(applyJob) && !isGreen(applyJob)) { + pr.push(target); // cherry-pick conflicts + continue; + } + if (buildJobs.some((job) => isDone(job) && !isGreen(job))) { + pr.push(target); // applies but does not build + continue; + } + + const applyOk = applyJob && isDone(applyJob) && isGreen(applyJob); + const anyBuildDone = buildJobs.some(isDone); + // Normal path: apply-check finished green. The `!applyJob` + // clause keeps older runs (before apply-check existed) working + // off the build legs alone during rollout. + if (applyOk || (!applyJob && anyBuildDone)) { + push.push(target); + } else { + core.warning(`PR: no completed backport signal for ${target}; skipping.`); + } + } + return { push, pr }; } + const releaseManagers = new Map( + JSON.parse(process.env.RELEASE_BRANCHES || "[]").map((e) => [ + e.branch, + e.manager || "", + ]) + ); + const commits = await listPushCommits(); core.info(`Push contains ${commits.length} commit(s).`); - // One matrix entry per (PR, target) pair, in commit order. - const entries = []; + // 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. + const pushEntries = []; + const prEntries = []; const seenPrNumbers = new Set(); for (const commit of commits) { const pullRequest = @@ -196,10 +256,14 @@ jobs: } seenPrNumbers.add(pullRequest.number); + const labelNames = pullRequest.labels.map((label) => label.name); + if (labelNames.includes("no-backport-needed")) { + core.info(`PR #${pullRequest.number} has no-backport-needed; skipping.`); + continue; + } + const requestedTargets = [...new Set( - pullRequest.labels - .map((label) => label.name) - .filter((name) => /^release\/.+$/.test(name)) + labelNames.filter((name) => /^release\/.+$/.test(name)) )].sort(); if (requestedTargets.length === 0) { @@ -207,28 +271,39 @@ jobs: continue; } - const greenTargets = await greenTargetsFor(pullRequest, requestedTargets); - const skippedTargets = requestedTargets.filter((target) => !greenTargets.includes(target)); - if (skippedTargets.length > 0) { - core.warning(`PR #${pullRequest.number}: skipping targets without a successful Backport run: ${skippedTargets.join(", ")}`); - } + const allJobs = await jobsForPr(pullRequest); + const { push, pr } = classifyTargets(allJobs, requestedTargets); + core.info( + `PR #${pullRequest.number}: push=[${push.join(", ")}] pr=[${pr.join(", ")}]` + ); - for (const target of greenTargets) { - entries.push({ + for (const target of push) { + pushEntries.push({ + pr_number: pullRequest.number, + merge_sha: commit.sha, + target, + }); + } + for (const target of pr) { + prEntries.push({ pr_number: pullRequest.number, merge_sha: commit.sha, target, + manager: releaseManagers.get(target) || "", }); } } - core.info(`Backport entries: ${JSON.stringify(entries)}`); - core.setOutput("entries", JSON.stringify(entries)); - core.setOutput("has_targets", entries.length > 0 ? "true" : "false"); + core.info(`Push entries: ${JSON.stringify(pushEntries)}`); + core.info(`PR entries: ${JSON.stringify(prEntries)}`); + core.setOutput("push_entries", JSON.stringify(pushEntries)); + core.setOutput("pr_entries", JSON.stringify(prEntries)); + core.setOutput("has_push", pushEntries.length > 0 ? "true" : "false"); + core.setOutput("has_pr", prEntries.length > 0 ? "true" : "false"); push-backports: needs: discover - if: ${{ needs.discover.outputs.has_targets == 'true' }} + if: ${{ needs.discover.outputs.has_push == 'true' }} runs-on: ubuntu-latest name: "backport #${{ matrix.pr_number }} to ${{ matrix.target }}" strategy: @@ -238,7 +313,7 @@ jobs: # release branch. max-parallel: 1 matrix: - include: ${{ fromJson(needs.discover.outputs.entries) }} + include: ${{ fromJson(needs.discover.outputs.push_entries) }} steps: - name: Checkout main uses: actions/checkout@v7 @@ -672,3 +747,186 @@ jobs: } else { core.info("No PR number resolved — skipping PR comment."); } + + open-backport-pr: + needs: discover + if: ${{ needs.discover.outputs.has_pr == 'true' }} + runs-on: ubuntu-latest + name: "open backport PR #${{ matrix.pr_number }} to ${{ matrix.target }}" + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.discover.outputs.pr_entries) }} + steps: + - name: Checkout main + uses: actions/checkout@v7 + with: + fetch-depth: 0 + # AUTO_MERGE_TOKEN so the pushed branch and the opened PR retrigger + # CI on the release branch (GITHUB_TOKEN-authored pushes/PRs do not). + token: ${{ secrets.AUTO_MERGE_TOKEN || secrets.GITHUB_TOKEN }} + - name: Create backport branch + id: branch + env: + MERGE_SHA: ${{ matrix.merge_sha }} + TARGET_BRANCH: ${{ matrix.target }} + PR_NUMBER: ${{ matrix.pr_number }} + run: | + bash ./.github/scripts/create-backport-branch.sh \ + "${MERGE_SHA}" "${TARGET_BRANCH}" "${PR_NUMBER}" + - name: Open draft backport PR + uses: actions/github-script@v9 + env: + MERGE_SHA: ${{ matrix.merge_sha }} + TARGET_BRANCH: ${{ matrix.target }} + PR_NUMBER: ${{ matrix.pr_number }} + MANAGER: ${{ matrix.manager }} + BRANCH: ${{ steps.branch.outputs.branch }} + VERSION: ${{ steps.branch.outputs.version }} + HAD_CONFLICT: ${{ steps.branch.outputs.had_conflict }} + CONFLICT_FILES: ${{ steps.branch.outputs.conflict_files }} + SUBJECT: ${{ steps.branch.outputs.subject }} + FEATURE_ABSENT: ${{ steps.branch.outputs.feature_absent }} + with: + github-token: ${{ secrets.AUTO_MERGE_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { + MERGE_SHA, TARGET_BRANCH, PR_NUMBER, MANAGER, + BRANCH, VERSION, HAD_CONFLICT, CONFLICT_FILES, SUBJECT, + FEATURE_ABSENT, + } = process.env; + const { owner, repo } = context.repo; + const prNumber = Number(PR_NUMBER); + const hadConflict = HAD_CONFLICT === "true"; + const runUrl = + `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + + // The branch step skipped this target: the fix changes only files + // that don't exist on the release branch, so there is nothing to + // backport. Record it on the original PR instead of opening a PR. + if (FEATURE_ABSENT === "true") { + core.info(`Feature absent on ${TARGET_BRANCH}; not opening a backport PR.`); + try { + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, + body: + `Not backported to \`${TARGET_BRANCH}\`: the files this fix ` + + `changes do not exist there (the feature is not on that ` + + `release). Add \`no-backport-needed\` to silence this for ` + + `future targets.`, + }); + } catch (e) { + core.warning(`Could not comment on #${prNumber}: ${e.message}`); + } + try { + await github.rest.repos.createCommitStatus({ + owner, repo, sha: MERGE_SHA, state: "success", + context: `backport/${TARGET_BRANCH}`, + description: "Skipped: feature not on release branch", + }); + } catch (e) { + core.warning(`Could not set commit status: ${e.message}`); + } + return; + } + + // Inject the release version into the conventional-commit scope so + // the backport PR reads e.g. `fix(amber, v1.2): ...`. Falls back to + // suffixing the version if the subject is not conventional. + function backportTitle(subject, version) { + const m = subject.match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.*)$/); + if (!m) return `${subject} (${version})`; + const [, type, scope, bang, rest] = m; + const newScope = scope ? `${scope}, ${version}` : version; + return `${type}(${newScope})${bang || ""}: ${rest}`; + } + const title = backportTitle(SUBJECT, VERSION); + + // Idempotency: on a re-run the branch is force-pushed, so an open + // PR from the same head already reflects the new commit — don't + // open a duplicate. + const existing = await github.rest.pulls.list({ + owner, repo, state: "open", head: `${owner}:${BRANCH}`, + }); + let pr = existing.data[0]; + if (pr) { + core.info(`Backport PR already open: #${pr.number}; not duplicating.`); + } else { + const conflictList = CONFLICT_FILES.trim() + ? CONFLICT_FILES.trim().split(/\s+/).map((f) => `- \`${f}\``).join("\n") + : ""; + const body = [ + `Automated backport of #${prNumber} to \`${TARGET_BRANCH}\`.`, + ``, + hadConflict + ? `⚠️ The cherry-pick **conflicted** and was committed with conflict markers. ` + + `Resolve the conflicts on this branch, then mark the PR ready for review.` + : `The cherry-pick applied cleanly but the backported tree failed its ` + + `pre-merge build. Fix the build on this branch, then mark the PR ready for review.`, + conflictList ? `\n**Conflicting files:**\n${conflictList}` : ``, + ``, + `Source: ${MERGE_SHA} · [automation run](${runUrl})`, + ].join("\n"); + + pr = (await github.rest.pulls.create({ + owner, repo, base: TARGET_BRANCH, head: BRANCH, + title, body, draft: true, + })).data; + core.info(`Opened draft backport PR #${pr.number}.`); + } + + // Assign the original author (best-effort: external authors may not + // be assignable) and label the PR with the target branch. + let author = ""; + try { + author = (await github.rest.pulls.get({ + owner, repo, pull_number: prNumber, + })).data.user?.login || ""; + } catch (e) { + core.warning(`Could not fetch PR #${prNumber} author: ${e.message}`); + } + if (author) { + try { + await github.rest.issues.addAssignees({ + owner, repo, issue_number: pr.number, assignees: [author], + }); + } catch (e) { + core.warning(`Could not assign ${author}: ${e.message}`); + } + } + if (MANAGER && MANAGER !== author) { + try { + await github.rest.pulls.requestReviewers({ + owner, repo, pull_number: pr.number, reviewers: [MANAGER], + }); + } catch (e) { + core.warning(`Could not request review from ${MANAGER}: ${e.message}`); + } + } + + // Link the backport PR back from the original PR so it is not lost. + const prUrl = `${context.serverUrl}/${owner}/${repo}/pull/${pr.number}`; + try { + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, + body: + `Backport to \`${TARGET_BRANCH}\` needs manual work — opened ` + + `draft PR #${pr.number} (${prUrl})` + + (author ? `, assigned to @${author}` : "") + `.`, + }); + } catch (e) { + core.warning(`Could not comment on #${prNumber}: ${e.message}`); + } + + // A failure commit status on the merge commit keeps the "needs + // backport" signal visible on main next to the successful ones. + try { + await github.rest.repos.createCommitStatus({ + owner, repo, sha: MERGE_SHA, state: "failure", + context: `backport/${TARGET_BRANCH}`, + description: `Draft backport PR #${pr.number} opened`, + target_url: prUrl, + }); + } catch (e) { + core.warning(`Could not set commit status: ${e.message}`); + } diff --git a/.github/workflows/required-checks.yml b/.github/workflows/required-checks.yml index 8ee83d3813..6194d621cd 100644 --- a/.github/workflows/required-checks.yml +++ b/.github/workflows/required-checks.yml @@ -216,9 +216,16 @@ jobs: core.setOutput("run_infra", runInfra ? "true" : "false"); core.setOutput("run_pyright_language_service", runPyrightLanguageService ? "true" : "false"); - // Backport targets: all current release/* labels on the PR. - const targets = [...new Set(labels.filter((n) => /^release\/.+$/.test(n)))].sort(); - if (targets.length === 0) { + // Backport targets: all current release/* labels on the PR, unless + // `no-backport-needed` vetoes backporting entirely (e.g. a fix for + // a feature that does not exist on any release branch). The veto + // wins even if release/* labels are also present, so adding it + // mid-review cancels the backport apply-check/build on the re-run. + let targets = [...new Set(labels.filter((n) => /^release\/.+$/.test(n)))].sort(); + if (labels.includes("no-backport-needed")) { + core.info("no-backport-needed present; skipping all backport targets."); + targets = []; + } else if (targets.length === 0) { core.info("No backport targets on PR."); } else { core.info(`Backport targets: ${targets.join(", ")}`); @@ -275,13 +282,70 @@ jobs: run_pyright_language_service: ${{ needs.precheck.outputs.run_pyright_language_service == 'true' }} secrets: inherit - backport: + # Fast pre-flight: does the change even cherry-pick onto each release branch? + # This is a git-only job (no build), so a conflict is reported in seconds and + # gates the expensive backport build below — a conflicting target never spins + # up the full stack matrix. Post-merge, Direct Backport Push reads this job's + # result as the authoritative "conflicts / applies" signal. + backport-apply-check: needs: precheck if: ${{ needs.precheck.outputs.backport_targets != '[]' }} strategy: fail-fast: false matrix: target: ${{ fromJson(needs.precheck.outputs.backport_targets) }} + runs-on: ubuntu-latest + steps: + - name: Checkout PR head + uses: actions/checkout@v7 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/head + fetch-depth: 0 + - name: Dry-run cherry-pick onto ${{ matrix.target }} + run: | + bash ./.github/scripts/prepare-backport-checkout.sh \ + "${{ matrix.target }}" \ + "${{ format('{0}..{1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) }}" + + # Collect the targets whose pre-flight cherry-pick succeeded. always() so a + # conflict on one target still lets the others build; an all-conflict run + # yields an empty list and skips the backport build entirely. + backport-buildable: + needs: [precheck, backport-apply-check] + if: ${{ always() && needs.precheck.outputs.backport_targets != '[]' }} + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.collect.outputs.targets }} + steps: + - name: Collect targets that applied cleanly + id: collect + uses: actions/github-script@v9 + env: + REQUESTED: ${{ needs.precheck.outputs.backport_targets }} + with: + script: | + const requested = JSON.parse(process.env.REQUESTED || "[]"); + const { owner, repo } = context.repo; + const jobs = await github.paginate( + github.rest.actions.listJobsForWorkflowRun, + { owner, repo, run_id: context.runId, per_page: 100 } + ); + const buildable = requested.filter((target) => { + const job = jobs.find( + (j) => j.name === `backport-apply-check (${target})` + ); + return job && job.conclusion === "success"; + }); + core.info(`Buildable backport targets: ${JSON.stringify(buildable)}`); + core.setOutput("targets", JSON.stringify(buildable)); + + backport: + needs: [precheck, backport-buildable] + if: ${{ needs.backport-buildable.outputs.targets != '' && needs.backport-buildable.outputs.targets != '[]' }} + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.backport-buildable.outputs.targets) }} uses: ./.github/workflows/build.yml with: checkout_ref: refs/pull/${{ github.event.pull_request.number }}/head @@ -302,7 +366,13 @@ jobs: required-checks: # Do not rename this job — its display name is referenced in .asf.yaml. name: Required Checks - needs: [precheck, build, backport] + # The backport job is deliberately NOT a dependency here: it is an advisory + # signal, not a merge gate. A red pre-merge backport (a cherry-pick that + # conflicts, or a backported tree that fails to build) must not block the + # PR from landing on main — instead Direct Backport Push opens a draft + # backport PR for the author after merge. Keeping backport out of `needs` + # also lets this aggregator report as soon as build finishes. + needs: [precheck, build] if: always() runs-on: ubuntu-latest steps: @@ -311,7 +381,6 @@ jobs: declare -A results=( [precheck]="${{ needs.precheck.result }}" [build]="${{ needs.build.result }}" - [backport]="${{ needs.backport.result }}" ) failed=0 for job in "${!results[@]}"; do
