From e86efaa982eb7a3810febef12c30154769b52c77 Mon Sep 17 00:00:00 2001 From: Yicong Huang <17627829+Yicong-Huang@users.noreply.github.com> Date: Sat, 2 May 2026 23:05:49 -0700 Subject: [PATCH 1/5] fix(ci): repair direct-backport-push YAML and post backport result comments The inline `python3 -c ''` block introduced in the trailer-aware backport message composer had its Python source at column 0 inside a `run: |` block whose indentation indicator was set at column 10 by the preceding bash. YAML treated `import re, sys` as a top-level key and GitHub Actions failed every push to main with 0 jobs / 0 seconds. Move the Python into `.github/scripts/compose-backport-message.py` and call it from the workflow. While here, add post-cherry-pick steps that: * On success, post a commit comment on the original main commit naming the backport target branch and the new SHA, plus a comment on the original PR with the same info. * On failure, post a comment on the original PR linking to the failing matrix-leg job log. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/scripts/compose-backport-message.py | 56 ++++++++ .github/workflows/direct-backport-push.yml | 134 ++++++++++++++------ 2 files changed, 150 insertions(+), 40 deletions(-) create mode 100644 .github/scripts/compose-backport-message.py diff --git a/.github/scripts/compose-backport-message.py b/.github/scripts/compose-backport-message.py new file mode 100644 index 00000000000..90621be681f --- /dev/null +++ b/.github/scripts/compose-backport-message.py @@ -0,0 +1,56 @@ +# 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. + +# Composes the backport commit message: insert "(backported from commit X)" +# between the message body and the trailer block (the trailing run of +# `Key: value` lines such as Co-Authored-By and Signed-off-by) so trailers +# stay contiguous at the bottom — that's where git itself parses them. +# +# The trailer block, by git convention, is the run of `Key: value` lines +# after the LAST blank line in the message, and only counts if EVERY line +# after that blank line is in trailer format. This avoids mis-detecting a +# Conventional Commits subject like "feat: foo" or a body line like +# "References:" as a trailer. +# +# Usage: original-message-on-stdin | compose-backport-message.py + +import re +import sys + +sha = sys.argv[1] +msg = sys.stdin.read().rstrip("\n") +lines = msg.split("\n") +trailer_re = re.compile(r"^[A-Za-z][A-Za-z0-9-]*:\s") + +last_blank = -1 +for idx in range(len(lines) - 1, -1, -1): + if lines[idx] == "": + last_blank = idx + break + +trailer_start = len(lines) +if last_blank != -1: + candidate = lines[last_blank + 1 :] + if candidate and all(trailer_re.match(line) for line in candidate): + trailer_start = last_blank + 1 + +backport = f"(backported from commit {sha})" +if trailer_start == len(lines): + print(msg + "\n\n" + backport) +else: + body = "\n".join(lines[:trailer_start]).rstrip("\n") + trailers = "\n".join(lines[trailer_start:]) + print(body + "\n\n" + backport + "\n\n" + trailers) diff --git a/.github/workflows/direct-backport-push.yml b/.github/workflows/direct-backport-push.yml index 215a888996a..0ba3204e1c8 100644 --- a/.github/workflows/direct-backport-push.yml +++ b/.github/workflows/direct-backport-push.yml @@ -24,7 +24,8 @@ on: permissions: actions: read contents: write - pull-requests: read + issues: write + pull-requests: write jobs: discover: @@ -195,6 +196,7 @@ jobs: # silences post-merge CI on backport commits. token: ${{ secrets.AUTO_MERGE_TOKEN || secrets.GITHUB_TOKEN }} - name: Cherry-pick merge commit onto target branch + id: cherry_pick env: MERGE_SHA: ${{ github.sha }} TARGET_BRANCH: ${{ matrix.target }} @@ -221,48 +223,100 @@ jobs: git checkout -B "${TARGET_BRANCH}" "origin/${TARGET_BRANCH}" git cherry-pick --no-commit "${MERGE_SHA}" - # Compose the final commit message. The "(backported from commit X)" - # note goes between the message body and the trailer block (the - # trailing run of `Key: value` lines such as Co-Authored-By and - # Signed-off-by) so trailers stay contiguous at the bottom of the - # message — that's where git itself parses them. - # - # The trailer block, by git convention, is the run of `Key: value` - # lines after the LAST blank line in the message, and only counts - # if EVERY line after that blank line is in trailer format. This - # avoids mis-detecting a Conventional Commits subject like - # "feat: foo" or a body line like "References:" as a trailer. new_message=$( - printf '%s' "${merge_message}" | \ - python3 -c ' -import re, sys -sha = sys.argv[1] -msg = sys.stdin.read().rstrip("\n") -lines = msg.split("\n") -trailer_re = re.compile(r"^[A-Za-z][A-Za-z0-9-]*:\s") - -last_blank = -1 -for idx in range(len(lines) - 1, -1, -1): - if lines[idx] == "": - last_blank = idx - break - -trailer_start = len(lines) -if last_blank != -1: - candidate = lines[last_blank + 1:] - if candidate and all(trailer_re.match(l) for l in candidate): - trailer_start = last_blank + 1 - -backport = f"(backported from commit {sha})" -if trailer_start == len(lines): - print(msg + "\n\n" + backport) -else: - body = "\n".join(lines[:trailer_start]).rstrip("\n") - trailers = "\n".join(lines[trailer_start:]) - print(body + "\n\n" + backport + "\n\n" + trailers) -' "${MERGE_SHA}" + 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 origin "HEAD:${TARGET_BRANCH}" + + new_sha=$(git rev-parse HEAD) + echo "new_sha=${new_sha}" >> "$GITHUB_OUTPUT" + + - name: Annotate original PR and commit on success + if: success() + uses: actions/github-script@v8 + env: + MERGE_SHA: ${{ github.sha }} + TARGET_BRANCH: ${{ matrix.target }} + NEW_SHA: ${{ steps.cherry_pick.outputs.new_sha }} + PR_NUMBER: ${{ needs.discover.outputs.pr_number }} + with: + script: | + const { MERGE_SHA, TARGET_BRANCH, NEW_SHA, PR_NUMBER } = process.env; + const { owner, repo } = context.repo; + const runUrl = + `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + const newCommitUrl = + `${context.serverUrl}/${owner}/${repo}/commit/${NEW_SHA}`; + const branchUrl = + `${context.serverUrl}/${owner}/${repo}/tree/${TARGET_BRANCH}`; + + // GitHub auto-derives the branch badges shown next to a commit + // title from "branches that contain this commit". A cherry-pick + // produces a different SHA than the main commit, so the release + // branch will never naturally appear on the main commit's page. + // Posting a commit comment is the closest practical equivalent — + // it surfaces the backport target on the same page. + await github.rest.repos.createCommitComment({ + owner, + repo, + commit_sha: MERGE_SHA, + body: + `Backported to [\`${TARGET_BRANCH}\`](${branchUrl}) as ` + + `[\`${NEW_SHA.slice(0, 7)}\`](${newCommitUrl}). ` + + `[Run](${runUrl})`, + }); + + if (PR_NUMBER) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: Number(PR_NUMBER), + body: + `Backport to [\`${TARGET_BRANCH}\`](${branchUrl}) succeeded ` + + `as [\`${NEW_SHA.slice(0, 7)}\`](${newCommitUrl}). ` + + `[Run](${runUrl})`, + }); + } + + - name: Annotate original PR on failure + if: failure() + uses: actions/github-script@v8 + env: + TARGET_BRANCH: ${{ matrix.target }} + PR_NUMBER: ${{ needs.discover.outputs.pr_number }} + with: + script: | + const { TARGET_BRANCH, PR_NUMBER } = process.env; + if (!PR_NUMBER) return; + const { owner, repo } = context.repo; + const runUrl = + `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + + // Find this matrix leg's job so the link drops the user directly + // onto the failing log instead of the run summary. + let jobUrl = runUrl; + try { + const jobs = await github.paginate( + github.rest.actions.listJobsForWorkflowRun, + { owner, repo, run_id: context.runId, per_page: 100 }, + ); + const me = jobs.find((j) => + j.name.includes(`(${TARGET_BRANCH})`), + ); + if (me?.html_url) jobUrl = me.html_url; + } catch (e) { + core.warning(`Could not resolve job URL: ${e.message}`); + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: Number(PR_NUMBER), + body: + `Backport to \`${TARGET_BRANCH}\` failed. ` + + `See [job log](${jobUrl}).`, + }); From 19367ab8c84869728aabc3a83b9a55d90645b86d Mon Sep 17 00:00:00 2001 From: Yicong Huang <17627829+Yicong-Huang@users.noreply.github.com> Date: Sat, 2 May 2026 23:10:28 -0700 Subject: [PATCH 2/5] fix(ci): also publish a backport commit status alongside the comment Commit comments are easy to miss in busy commit pages. A commit status shows next to CI badges on both the commit and any PR that references it, with target_url linking to the new SHA on success or the failing job on failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/direct-backport-push.yml | 46 +++++++++++++++++----- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/.github/workflows/direct-backport-push.yml b/.github/workflows/direct-backport-push.yml index 0ba3204e1c8..b57de0ce7c2 100644 --- a/.github/workflows/direct-backport-push.yml +++ b/.github/workflows/direct-backport-push.yml @@ -26,6 +26,7 @@ permissions: contents: write issues: write pull-requests: write + statuses: write jobs: discover: @@ -258,8 +259,21 @@ jobs: // title from "branches that contain this commit". A cherry-pick // produces a different SHA than the main commit, so the release // branch will never naturally appear on the main commit's page. - // Posting a commit comment is the closest practical equivalent — - // it surfaces the backport target on the same page. + // Two surfacing channels instead: + // 1. A commit status — appears as a green check badge in the + // same row as CI statuses on the commit and any PRs that + // reference it. target_url drops the user on the new commit. + // 2. A commit comment with the same info, for richer detail. + await github.rest.repos.createCommitStatus({ + owner, + repo, + sha: MERGE_SHA, + state: "success", + context: `backport/${TARGET_BRANCH}`, + description: `Backported as ${NEW_SHA.slice(0, 7)}`, + target_url: newCommitUrl, + }); + await github.rest.repos.createCommitComment({ owner, repo, @@ -282,16 +296,16 @@ jobs: }); } - - name: Annotate original PR on failure + - name: Annotate original PR and commit on failure if: failure() uses: actions/github-script@v8 env: + MERGE_SHA: ${{ github.sha }} TARGET_BRANCH: ${{ matrix.target }} PR_NUMBER: ${{ needs.discover.outputs.pr_number }} with: script: | - const { TARGET_BRANCH, PR_NUMBER } = process.env; - if (!PR_NUMBER) return; + const { MERGE_SHA, TARGET_BRANCH, PR_NUMBER } = process.env; const { owner, repo } = context.repo; const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; @@ -312,11 +326,23 @@ jobs: core.warning(`Could not resolve job URL: ${e.message}`); } - await github.rest.issues.createComment({ + await github.rest.repos.createCommitStatus({ owner, repo, - issue_number: Number(PR_NUMBER), - body: - `Backport to \`${TARGET_BRANCH}\` failed. ` + - `See [job log](${jobUrl}).`, + sha: MERGE_SHA, + state: "failure", + context: `backport/${TARGET_BRANCH}`, + description: "Backport failed", + target_url: jobUrl, }); + + if (PR_NUMBER) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: Number(PR_NUMBER), + body: + `Backport to \`${TARGET_BRANCH}\` failed. ` + + `See [job log](${jobUrl}).`, + }); + } From f4b2df7b23cf78803339f30e92c6914b13744843 Mon Sep 17 00:00:00 2001 From: Yicong Huang <17627829+Yicong-Huang@users.noreply.github.com> Date: Sat, 2 May 2026 23:15:00 -0700 Subject: [PATCH 3/5] fix(ci): add retry + structured logging to backport push and annotation steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bash cherry-pick step now retries `git push` up to 5 times with backoff [0, 5, 15, 30, 60]s. Between attempts it refreshes `origin/` and rebases the single backport commit on top, so a race with another push to the same release branch resolves itself instead of leaving the run in a "almost backported" state. A genuine rebase conflict aborts the rebase and fails loudly. Each phase is wrapped in `::group::` markers and emits a `[backport ] ...` prefix for greppable logs (parent_count, base_sha, local_sha, new_sha, remote_head per attempt). The github-script annotation steps (commit status, commit comment, PR comment, job-URL lookup) gain a `withRetry` helper with the same backoff schedule. Annotation failures degrade to warnings instead of demoting the whole job — when the cherry-pick + push has already succeeded, a transient 5xx on a comment shouldn't undo that. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/direct-backport-push.yml | 259 ++++++++++++++++----- 1 file changed, 202 insertions(+), 57 deletions(-) diff --git a/.github/workflows/direct-backport-push.yml b/.github/workflows/direct-backport-push.yml index b57de0ce7c2..56b78acf0c5 100644 --- a/.github/workflows/direct-backport-push.yml +++ b/.github/workflows/direct-backport-push.yml @@ -204,11 +204,18 @@ jobs: run: | set -euo pipefail + log() { printf '[backport %s] %s\n' "${TARGET_BRANCH}" "$*"; } + group() { printf '::group::%s\n' "$*"; } + endgroup() { printf '::endgroup::\n'; } + + group "Validate merge commit ${MERGE_SHA}" parent_count=$(git rev-list --parents -n 1 "${MERGE_SHA}" | awk '{print NF-1}') + log "parent_count=${parent_count}" if [[ "${parent_count}" -ne 1 ]]; then - echo "Direct backport expects a squash-merged commit on main. ${MERGE_SHA} has ${parent_count} parents." >&2 + echo "::error::Direct backport expects a squash-merged commit on main. ${MERGE_SHA} has ${parent_count} parents." exit 1 fi + endgroup git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -219,21 +226,72 @@ jobs: # (the original PR author for squash merges). original_author=$(git log -1 --format='%an <%ae>' "${MERGE_SHA}") merge_message=$(git log -1 --format=%B "${MERGE_SHA}") + log "original_author=${original_author}" + group "Cherry-pick onto ${TARGET_BRANCH}" git fetch --no-tags origin "${TARGET_BRANCH}" git checkout -B "${TARGET_BRANCH}" "origin/${TARGET_BRANCH}" - git cherry-pick --no-commit "${MERGE_SHA}" + base_sha=$(git rev-parse HEAD) + log "base_sha=${base_sha}" + if ! git cherry-pick --no-commit "${MERGE_SHA}"; then + echo "::error::Cherry-pick of ${MERGE_SHA} onto ${TARGET_BRANCH} hit a conflict; manual backport required." + git status --short || true + exit 1 + fi + endgroup + group "Compose backport commit message" 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}" + log "local_sha=$(git rev-parse HEAD)" + endgroup - git push origin "HEAD:${TARGET_BRANCH}" + # Push with retry. Transient failures (network, GitHub 5xx) are pure + # backoff. Non-fast-forward (race with another push to the same + # release branch) refreshes origin/ and rebases this single + # cherry-pick on top before retrying. + push_attempts=5 + push_backoffs=(0 5 15 30 60) + push_success=0 + for i in $(seq 0 $((push_attempts - 1))); do + if [[ "${push_backoffs[i]}" -gt 0 ]]; then + log "Push attempt $((i + 1))/${push_attempts}: sleeping ${push_backoffs[i]}s" + sleep "${push_backoffs[i]}" + fi + group "Push attempt $((i + 1))/${push_attempts}" + if git push origin "HEAD:${TARGET_BRANCH}" 2>&1; then + push_success=1 + endgroup + break + fi + push_rc=$? + log "git push exit code=${push_rc}" + endgroup + + # Refresh origin and rebase before retrying. If the remote did not + # advance, this is a no-op rebase and the next push will likely + # hit the same transient error — backoff handles that. + log "Refreshing origin/${TARGET_BRANCH} before retry" + git fetch --no-tags origin "${TARGET_BRANCH}" + remote_head=$(git rev-parse "origin/${TARGET_BRANCH}") + log "origin/${TARGET_BRANCH}=${remote_head}" + if ! git rebase "origin/${TARGET_BRANCH}"; then + git rebase --abort || true + echo "::error::Rebase onto refreshed origin/${TARGET_BRANCH} hit a conflict; another commit changed the same lines. Manual backport required." + exit 1 + fi + done + + if [[ "${push_success}" -ne 1 ]]; then + echo "::error::git push to ${TARGET_BRANCH} failed after ${push_attempts} attempts" + exit 1 + fi new_sha=$(git rev-parse HEAD) + log "new_sha=${new_sha}" echo "new_sha=${new_sha}" >> "$GITHUB_OUTPUT" - name: Annotate original PR and commit on success @@ -255,6 +313,41 @@ jobs: const branchUrl = `${context.serverUrl}/${owner}/${repo}/tree/${TARGET_BRANCH}`; + core.info( + `Annotating: merge_sha=${MERGE_SHA} new_sha=${NEW_SHA} ` + + `target=${TARGET_BRANCH} pr=${PR_NUMBER || ''}`, + ); + + // Annotation API calls are best-effort: a transient 5xx shouldn't + // demote the whole job to failure when the cherry-pick itself + // succeeded. Each call gets a small bounded retry; if it still + // fails we log a warning and move on so the other annotations + // still get a chance to land. + const backoffsMs = [0, 2000, 5000, 15000]; + async function withRetry(name, fn) { + for (let i = 0; i < backoffsMs.length; i++) { + if (backoffsMs[i] > 0) { + core.info( + `Retrying ${name} in ${backoffsMs[i] / 1000}s ` + + `(attempt ${i + 1}/${backoffsMs.length}).`, + ); + await new Promise((r) => setTimeout(r, backoffsMs[i])); + } + try { + const out = await fn(); + core.info(`${name} ok.`); + return out; + } catch (e) { + const msg = `${name} failed (status ${e.status ?? '?'}): ${e.message}`; + if (i === backoffsMs.length - 1) { + core.warning(`${msg} — giving up.`); + return null; + } + core.warning(`${msg} — will retry.`); + } + } + } + // GitHub auto-derives the branch badges shown next to a commit // title from "branches that contain this commit". A cherry-pick // produces a different SHA than the main commit, so the release @@ -264,36 +357,44 @@ jobs: // same row as CI statuses on the commit and any PRs that // reference it. target_url drops the user on the new commit. // 2. A commit comment with the same info, for richer detail. - await github.rest.repos.createCommitStatus({ - owner, - repo, - sha: MERGE_SHA, - state: "success", - context: `backport/${TARGET_BRANCH}`, - description: `Backported as ${NEW_SHA.slice(0, 7)}`, - target_url: newCommitUrl, - }); - - await github.rest.repos.createCommitComment({ - owner, - repo, - commit_sha: MERGE_SHA, - body: - `Backported to [\`${TARGET_BRANCH}\`](${branchUrl}) as ` + - `[\`${NEW_SHA.slice(0, 7)}\`](${newCommitUrl}). ` + - `[Run](${runUrl})`, - }); + await withRetry("createCommitStatus", () => + github.rest.repos.createCommitStatus({ + owner, + repo, + sha: MERGE_SHA, + state: "success", + context: `backport/${TARGET_BRANCH}`, + description: `Backported as ${NEW_SHA.slice(0, 7)}`, + target_url: newCommitUrl, + }), + ); - if (PR_NUMBER) { - await github.rest.issues.createComment({ + await withRetry("createCommitComment", () => + github.rest.repos.createCommitComment({ owner, repo, - issue_number: Number(PR_NUMBER), + commit_sha: MERGE_SHA, body: - `Backport to [\`${TARGET_BRANCH}\`](${branchUrl}) succeeded ` + - `as [\`${NEW_SHA.slice(0, 7)}\`](${newCommitUrl}). ` + + `Backported to [\`${TARGET_BRANCH}\`](${branchUrl}) as ` + + `[\`${NEW_SHA.slice(0, 7)}\`](${newCommitUrl}). ` + `[Run](${runUrl})`, - }); + }), + ); + + if (PR_NUMBER) { + await withRetry("createPRComment", () => + github.rest.issues.createComment({ + owner, + repo, + issue_number: Number(PR_NUMBER), + body: + `Backport to [\`${TARGET_BRANCH}\`](${branchUrl}) succeeded ` + + `as [\`${NEW_SHA.slice(0, 7)}\`](${newCommitUrl}). ` + + `[Run](${runUrl})`, + }), + ); + } else { + core.info("No PR number resolved — skipping PR comment."); } - name: Annotate original PR and commit on failure @@ -310,39 +411,83 @@ jobs: const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + core.info( + `Annotating failure: merge_sha=${MERGE_SHA} ` + + `target=${TARGET_BRANCH} pr=${PR_NUMBER || ''}`, + ); + + const backoffsMs = [0, 2000, 5000, 15000]; + async function withRetry(name, fn) { + for (let i = 0; i < backoffsMs.length; i++) { + if (backoffsMs[i] > 0) { + core.info( + `Retrying ${name} in ${backoffsMs[i] / 1000}s ` + + `(attempt ${i + 1}/${backoffsMs.length}).`, + ); + await new Promise((r) => setTimeout(r, backoffsMs[i])); + } + try { + const out = await fn(); + core.info(`${name} ok.`); + return out; + } catch (e) { + const msg = `${name} failed (status ${e.status ?? '?'}): ${e.message}`; + if (i === backoffsMs.length - 1) { + core.warning(`${msg} — giving up.`); + return null; + } + core.warning(`${msg} — will retry.`); + } + } + } + // Find this matrix leg's job so the link drops the user directly // onto the failing log instead of the run summary. let jobUrl = runUrl; - try { - const jobs = await github.paginate( - github.rest.actions.listJobsForWorkflowRun, - { owner, repo, run_id: context.runId, per_page: 100 }, - ); - const me = jobs.find((j) => - j.name.includes(`(${TARGET_BRANCH})`), - ); - if (me?.html_url) jobUrl = me.html_url; - } catch (e) { - core.warning(`Could not resolve job URL: ${e.message}`); + const jobs = await withRetry("listJobsForWorkflowRun", () => + github.paginate(github.rest.actions.listJobsForWorkflowRun, { + owner, + repo, + run_id: context.runId, + per_page: 100, + }), + ); + if (jobs) { + const me = jobs.find((j) => j.name.includes(`(${TARGET_BRANCH})`)); + if (me?.html_url) { + jobUrl = me.html_url; + core.info(`Resolved job URL for matrix leg: ${jobUrl}`); + } else { + core.info( + `No job matched name including "(${TARGET_BRANCH})"; ` + + "falling back to run URL.", + ); + } } - await github.rest.repos.createCommitStatus({ - owner, - repo, - sha: MERGE_SHA, - state: "failure", - context: `backport/${TARGET_BRANCH}`, - description: "Backport failed", - target_url: jobUrl, - }); - - if (PR_NUMBER) { - await github.rest.issues.createComment({ + await withRetry("createCommitStatus", () => + github.rest.repos.createCommitStatus({ owner, repo, - issue_number: Number(PR_NUMBER), - body: - `Backport to \`${TARGET_BRANCH}\` failed. ` + - `See [job log](${jobUrl}).`, - }); + sha: MERGE_SHA, + state: "failure", + context: `backport/${TARGET_BRANCH}`, + description: "Backport failed", + target_url: jobUrl, + }), + ); + + if (PR_NUMBER) { + await withRetry("createPRComment", () => + github.rest.issues.createComment({ + owner, + repo, + issue_number: Number(PR_NUMBER), + body: + `Backport to \`${TARGET_BRANCH}\` failed. ` + + `See [job log](${jobUrl}).`, + }), + ); + } else { + core.info("No PR number resolved — skipping PR comment."); } From c647ea4d32e013956844ced5a0ef57fe9c80edb9 Mon Sep 17 00:00:00 2001 From: Yicong Huang <17627829+Yicong-Huang@users.noreply.github.com> Date: Sat, 2 May 2026 23:19:46 -0700 Subject: [PATCH 4/5] fix(ci): name the likely-missing prereqs and conflict lines on backport conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a cherry-pick or rebase hits a conflict, the run now logs: * every conflicted file * the conflict-marker line numbers in each file (`grep -nE` on `<<<<<<<` / `=======` / `>>>>>>>`) * commits on `main` between merge-base and the source commit that touched each conflicted file — these are the likely-missing prerequisite commits the backport needs first * commits on the release branch that diverged from main on the same file — these are what's already there * the most recent commits anywhere that touched the file For the rebase-during-push retry path (race with another push to the same release branch), the diagnosis instead lists the racing commits that landed on origin/ while this run was preparing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/direct-backport-push.yml | 57 ++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/.github/workflows/direct-backport-push.yml b/.github/workflows/direct-backport-push.yml index 56b78acf0c5..c9a41856aa0 100644 --- a/.github/workflows/direct-backport-push.yml +++ b/.github/workflows/direct-backport-push.yml @@ -234,8 +234,44 @@ jobs: base_sha=$(git rev-parse HEAD) log "base_sha=${base_sha}" if ! git cherry-pick --no-commit "${MERGE_SHA}"; then - echo "::error::Cherry-pick of ${MERGE_SHA} onto ${TARGET_BRANCH} hit a conflict; manual backport required." - git status --short || true + endgroup + group "Conflict diagnosis" + conflicts=$(git diff --name-only --diff-filter=U) + log "Conflicted files:" + printf ' %s\n' ${conflicts} + + # merge-base of the source commit and the target branch — the + # most recent point where main and the release branch shared + # history. Anything on main between the merge-base and the + # source commit that touches a conflicting file is a candidate + # "missing prerequisite" the backport probably needs first. + merge_base=$(git merge-base "${MERGE_SHA}^" "origin/${TARGET_BRANCH}" || echo "") + log "" + log "merge_base(${MERGE_SHA:0:8}^, origin/${TARGET_BRANCH})=${merge_base:-}" + + for f in ${conflicts}; do + log "" + log "── ${f} ──" + log "Conflict markers (line numbers in the working tree):" + grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- "${f}" | head -40 || true + + if [[ -n "${merge_base}" ]]; then + log "" + log "Commits on main that modified ${f} between ${merge_base:0:8}..${MERGE_SHA:0:8}^ (likely-missing prerequisites — consider backporting these first):" + git log --oneline --no-merges "${merge_base}..${MERGE_SHA}^" -- "${f}" | head -20 || true + + log "" + log "Commits on ${TARGET_BRANCH} that modified ${f} since ${merge_base:0:8} (changes already on the release branch that diverged from main):" + git log --oneline --no-merges "${merge_base}..origin/${TARGET_BRANCH}" -- "${f}" | head -20 || true + fi + + log "" + log "Last 3 commits anywhere that touched ${f}:" + git log --oneline --all -3 -- "${f}" || true + done + endgroup + + echo "::error::Cherry-pick of ${MERGE_SHA} onto ${TARGET_BRANCH} hit conflicts. See 'Conflict diagnosis' group above for likely-missing prerequisite commits and per-file conflict markers." exit 1 fi endgroup @@ -276,11 +312,26 @@ jobs: # hit the same transient error — backoff handles that. log "Refreshing origin/${TARGET_BRANCH} before retry" git fetch --no-tags origin "${TARGET_BRANCH}" + old_remote_head="${remote_head:-${base_sha}}" remote_head=$(git rev-parse "origin/${TARGET_BRANCH}") log "origin/${TARGET_BRANCH}=${remote_head}" if ! git rebase "origin/${TARGET_BRANCH}"; then + conflicts=$(git diff --name-only --diff-filter=U) + group "Rebase conflict diagnosis" + log "Conflicted files during rebase:" + printf ' %s\n' ${conflicts} + log "" + log "Commits on ${TARGET_BRANCH} that landed since this run started (${old_remote_head:0:8}..${remote_head:0:8}):" + git log --oneline --no-merges "${old_remote_head}..${remote_head}" | head -20 || true + for f in ${conflicts}; do + log "" + log "── ${f} ──" + log "Commits in ${old_remote_head:0:8}..${remote_head:0:8} that touched ${f}:" + git log --oneline --no-merges "${old_remote_head}..${remote_head}" -- "${f}" | head -20 || true + done + endgroup git rebase --abort || true - echo "::error::Rebase onto refreshed origin/${TARGET_BRANCH} hit a conflict; another commit changed the same lines. Manual backport required." + echo "::error::Rebase onto refreshed origin/${TARGET_BRANCH} hit a conflict; another commit changed the same lines. See 'Rebase conflict diagnosis' group for the racing commits." exit 1 fi done From 4ae13cd4dc710c6683750f71100725ae3b484708 Mon Sep 17 00:00:00 2001 From: Yicong Huang <17627829+Yicong-Huang@users.noreply.github.com> Date: Sat, 2 May 2026 23:26:36 -0700 Subject: [PATCH 5/5] fix(ci): include conflict diagnosis in the failure PR comment The cherry-pick step already logs the likely-missing prerequisite commits and per-file conflict markers. Mirror that into a condensed markdown summary written to \$RUNNER_TEMP/backport-diagnosis.md (capped at 5 conflicted files and 10 prerequisite commits to keep PR comments scannable). The failure script reads the file and appends it to the PR comment so the reviewer sees, in the PR itself, which commits likely need to be backported first. Same treatment for rebase-during-push conflicts: the comment lists the racing commits that landed on origin/ between the start of the run and the push attempt. If no diagnosis file exists (e.g. failure was a permissions error or network 5xx after retries), the comment falls back to the basic "Backport failed. See job log." form. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/direct-backport-push.yml | 81 +++++++++++++++++++++- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/.github/workflows/direct-backport-push.yml b/.github/workflows/direct-backport-push.yml index c9a41856aa0..b9b92cdbfba 100644 --- a/.github/workflows/direct-backport-push.yml +++ b/.github/workflows/direct-backport-push.yml @@ -271,6 +271,36 @@ jobs: done endgroup + # Write a condensed markdown summary for the failure PR comment. + # Caps: 5 files, 10 prerequisite commits total — keep PR + # comments scannable; the full detail is in the job log above. + diagnosis_file="${RUNNER_TEMP:-/tmp}/backport-diagnosis.md" + { + echo "**Conflicts in:**" + num=0 + for f in ${conflicts}; do + if [[ ${num} -lt 5 ]]; then + printf -- '- `%s`\n' "${f}" + fi + num=$((num + 1)) + done + if [[ ${num} -gt 5 ]]; then + printf -- '- _(+%d more)_\n' "$((num - 5))" + fi + if [[ -n "${merge_base}" ]]; then + echo + echo "**Likely-missing prerequisites on main** (commits that touched these files between merge-base \`${merge_base:0:8}\` and \`${MERGE_SHA:0:8}^\` — consider backporting these first):" + { + for f in ${conflicts}; do + git log --oneline --no-merges "${merge_base}..${MERGE_SHA}^" -- "${f}" || true + done + } | sort -u | head -10 | while IFS= read -r line; do + [[ -n "${line}" ]] && printf -- '- `%s`\n' "${line}" + done + fi + } > "${diagnosis_file}" + log "Wrote diagnosis summary to ${diagnosis_file}" + echo "::error::Cherry-pick of ${MERGE_SHA} onto ${TARGET_BRANCH} hit conflicts. See 'Conflict diagnosis' group above for likely-missing prerequisite commits and per-file conflict markers." exit 1 fi @@ -330,6 +360,30 @@ jobs: git log --oneline --no-merges "${old_remote_head}..${remote_head}" -- "${f}" | head -20 || true done endgroup + + diagnosis_file="${RUNNER_TEMP:-/tmp}/backport-diagnosis.md" + { + printf -- '**Rebase conflict during push** — another commit landed on `%s` between the start of this run and the push attempt.\n\n' "${TARGET_BRANCH}" + echo "**Conflicts in:**" + num=0 + for f in ${conflicts}; do + if [[ ${num} -lt 5 ]]; then + printf -- '- `%s`\n' "${f}" + fi + num=$((num + 1)) + done + if [[ ${num} -gt 5 ]]; then + printf -- '- _(+%d more)_\n' "$((num - 5))" + fi + echo + printf -- '**Racing commits on `%s`** (`%s..%s`):\n' \ + "${TARGET_BRANCH}" "${old_remote_head:0:8}" "${remote_head:0:8}" + git log --oneline --no-merges "${old_remote_head}..${remote_head}" | head -10 | while IFS= read -r line; do + [[ -n "${line}" ]] && printf -- '- `%s`\n' "${line}" + done + } > "${diagnosis_file}" + log "Wrote diagnosis summary to ${diagnosis_file}" + git rebase --abort || true echo "::error::Rebase onto refreshed origin/${TARGET_BRANCH} hit a conflict; another commit changed the same lines. See 'Rebase conflict diagnosis' group for the racing commits." exit 1 @@ -528,15 +582,36 @@ jobs: }), ); + // Pick up the markdown diagnosis the bash step wrote on + // conflict (cherry-pick or rebase). Missing file just means + // the failure happened elsewhere (e.g. push 5xx after retries, + // permissions) — we still post the basic comment. + const fs = require("fs"); + const diagPath = + `${process.env.RUNNER_TEMP || "/tmp"}/backport-diagnosis.md`; + let diagnosis = ""; + try { + diagnosis = fs.readFileSync(diagPath, "utf8").trim(); + if (diagnosis) { + core.info(`Found diagnosis at ${diagPath} (${diagnosis.length} chars)`); + } + } catch (e) { + core.info( + `No diagnosis file at ${diagPath} (${e.code}) — failure likely not a conflict.`, + ); + } + if (PR_NUMBER) { + const head = + `Backport to \`${TARGET_BRANCH}\` failed. ` + + `See [job log](${jobUrl}).`; + const body = diagnosis ? `${head}\n\n${diagnosis}` : head; await withRetry("createPRComment", () => github.rest.issues.createComment({ owner, repo, issue_number: Number(PR_NUMBER), - body: - `Backport to \`${TARGET_BRANCH}\` failed. ` + - `See [job log](${jobUrl}).`, + body, }), ); } else {