Skip to content

[ML] Auto-approve automated version-bump PRs #848

[ML] Auto-approve automated version-bump PRs

[ML] Auto-approve automated version-bump PRs #848

Workflow file for this run

name: Backport
on:
pull_request_target:
types: ["labeled", "closed"]
permissions:
contents: write
pull-requests: write
jobs:
backport:
name: Backport PR
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
# Required by elastic/ci-gh-actions/fetch-github-token to authenticate to Vault
# via OIDC and mint the ephemeral approval token.
id-token: write
# Only run for merged PRs that are not themselves backports (avoid loops).
# Skip automation PRs (minor-freeze main bumps carry a release version label from
# elasticsearchmachine but must not backport to the new release branch). The
# head.ref fallback keys off the minor-freeze topic branch created by
# dev-tools/bump_main_minor_freeze.sh (ci/ml-cpp-minor-freeze-main-*) so it cannot
# accidentally skip an unrelated PR whose title merely mentions a minor freeze.
if: |
github.event.pull_request.merged == true &&
!(contains(github.event.pull_request.labels.*.name, 'backport')) &&
!(contains(github.event.pull_request.labels.*.name, 'no-backport')) &&
!startsWith(github.event.pull_request.head.ref, 'ci/ml-cpp-minor-freeze-main-')
steps:
- name: Check for version labels
id: check-labels
env:
LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }}
run: |
for label in $LABELS; do
if echo "$label" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+'; then
echo "Found version label: $label"
echo "has_version_label=true" >> "$GITHUB_OUTPUT"
exit 0
fi
done
echo "No version label matching vN.N.N found — nothing to backport."
echo "has_version_label=false" >> "$GITHUB_OUTPUT"
# Mint a short-lived token from Elastic's central ephemeral-token GitHub App
# (via OIDC -> Vault) and use it to AUTHOR the backport PRs. This mirrors the
# pattern used by elastic/cloud, elastic/elasticsearch-ruby and the ECP GitOps
# repos. Authoring with a real token (not the built-in GITHUB_TOKEN) means:
# * the backport PR is opened by a distinct, non-github-actions identity, so
# github-actions[bot] can later approve it (a token cannot approve its own
# PR); and
# * CI actually runs on the backport PR (PRs opened with GITHUB_TOKEN do not
# trigger further workflow runs).
#
# continue-on-error keeps the workflow safe before the Token Policy is registered
# (resources/github-token-policies in elastic/catalog-info): the fetch fails
# softly and we fall back to GITHUB_TOKEN below, so backports still open — but CI
# is not triggered on them and they will need a manual approval to merge.
- name: Fetch ephemeral GitHub token
id: ephemeral_token
if: steps.check-labels.outputs.has_version_label == 'true'
continue-on-error: true
uses: elastic/ci-gh-actions/fetch-github-token@v1
with:
vault-instance: "ci-prod"
- name: Backport Action
id: backport
if: steps.check-labels.outputs.has_version_label == 'true'
uses: sorenlouv/[email protected]
continue-on-error: true
with:
github_token: ${{ steps.ephemeral_token.outputs.token || secrets.GITHUB_TOKEN }}
- name: Info log
if: steps.backport.outcome == 'success'
run: cat ~/.backport/backport.info.log
# When version labels were detected we always run the Backport Action. If it fails with
# no-branches-exception, backport PRs were not created — fail CI so this is not silent,
# UNLESS the label resolved only to the source branch (current-version label, e.g.
# v9.6.0 -> main), which legitimately has nothing to backport (see step logic).
- name: Check for real failures
if: steps.backport.outcome == 'failure'
env:
HAS_VERSION_LABEL: ${{ steps.check-labels.outputs.has_version_label }}
run: |
echo "::group::backport.debug.log"
cat ~/.backport/backport.debug.log 2>/dev/null || echo "(missing ~/.backport/backport.debug.log)"
echo "::endgroup::"
echo "::group::backport.info.log"
cat ~/.backport/backport.info.log 2>/dev/null || echo "(missing ~/.backport/backport.info.log)"
echo "::endgroup::"
if grep -q '"code":"no-branches-exception"' ~/.backport/backport.debug.log 2>/dev/null; then
if [ "${HAS_VERSION_LABEL}" = "true" ]; then
# A version label for the current main version (e.g. v9.6.0 -> main via
# branchLabelMapping) resolves only to the source branch, so there is
# legitimately nothing to backport. The CLI still raises
# no-branches-exception in that case; treat it as success, but ONLY when
# every resolved target is the source branch (isSourceBranch:true and no
# isSourceBranch:false). A genuine misconfiguration (label mapped to a
# real, different, or missing branch) still fails loudly below.
if grep -Ehq '"isSourceBranch":[[:space:]]*true' ~/.backport/backport.info.log ~/.backport/backport.debug.log 2>/dev/null \
&& ! grep -Ehq '"isSourceBranch":[[:space:]]*false' ~/.backport/backport.info.log ~/.backport/backport.debug.log 2>/dev/null; then
echo "Version label resolved only to the source branch — nothing to backport (expected for current-version labels)."
exit 0
fi
echo "::error::Backport CLI reported no-branches-exception while this PR had version labels. No backport PRs were opened — check branchLabelMapping / targetBranchChoices in .backportrc.json, review logs above, or open backports manually."
exit 1
fi
echo "No target branches matched (no version labels from workflow check) — nothing to backport."
exit 0
fi
echo "::error::Backport failed — see logs above."
exit 1
# Approve the backport PRs with GITHUB_TOKEN (github-actions[bot]) and arm
# auto-merge. Because the PRs were authored by the ephemeral-token identity above,
# github-actions[bot] is a *different* identity and its approval counts (a token
# cannot approve its own PR). One approval satisfies the org-wide "[org] Require a
# PR" ruleset on the release branches (one review, no code-owner requirement); the
# repo has "Allow GitHub Actions to create and approve pull requests" enabled.
#
# Auto-merge is now safe: the release branches (and main) require the
# `buildkite/ml-cpp-pr-builds` rollup status, which reflects the whole build/test
# matrix — including any triggered QA/PyTorch downstream (their trigger steps are
# `async: false`, so their result propagates into the parent build). `gh pr merge
# --auto` therefore waits for CI to go green (and the review requirement to be
# met) before merging, rather than merging as soon as the lightweight checks (CLA,
# snyk) pass. (Auto-merge was temporarily disabled in #3114 until this required
# status check existed; re-enabled here now that it does.)
- name: Approve and enable auto-merge on backport PRs
if: >-
steps.backport.outcome == 'success' &&
contains(github.event.pull_request.labels.*.name, 'auto-backport')
env:
# github-actions[bot] token: used to APPROVE the backport PRs (a distinct
# identity from the ephemeral-token author, so the approval counts).
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The ephemeral App token (elastic-vault-github-plugin-prod[bot]) that authored
# the backport PRs, when it was minted (Token Policy registered). Empty on the
# fallback path. Used to ARM auto-merge — see the note below.
EPHEMERAL_TOKEN: ${{ steps.ephemeral_token.outputs.token }}
REPO: ${{ github.repository }}
run: |
# Extract created PR numbers from the backport info log.
PR_NUMBERS=$(grep -oE '"pullRequestNumber":[0-9]+' ~/.backport/backport.info.log \
| grep -oE '[0-9]+' || true)
if [ -z "$PR_NUMBERS" ]; then
echo "No backport PRs found in log. Skipping approval."
exit 0
fi
# Choose the token that ARMS auto-merge. GitHub attributes a deferred auto-merge
# to whichever identity enabled it, and merges attributed to github-actions[bot]
# (i.e. GITHUB_TOKEN) do NOT trigger further workflows — GitHub's recursion guard.
# If we armed auto-merge with GITHUB_TOKEN, the merge would never fire the
# remove-backport-pending job, so backport-pending would stay stuck on the source
# PR (it had to be cleared by hand). Arming with the ephemeral App identity makes
# the eventual merge trigger that job normally. This is the same reason we don't
# arm auto-merge with GITHUB_TOKEN: events attributed to github-actions[bot] won't
# start this workflow on merge. Falls back to GITHUB_TOKEN when the ephemeral token is
# unavailable (Token Policy not registered) — in that degraded case cleanup still
# needs doing manually, but backports at least open and can be merged by a human.
MERGE_TOKEN="${EPHEMERAL_TOKEN:-$GH_TOKEN}"
for pr in $PR_NUMBERS; do
if [ -n "$EPHEMERAL_TOKEN" ]; then
# Authored by the ephemeral-token identity, so this github-actions[bot]
# approval (via the step-level GH_TOKEN) is from a distinct identity and
# satisfies the required review.
echo "Approving backport PR #$pr as github-actions[bot]"
gh pr review "$pr" --repo "$REPO" --approve \
--body "Automated approval: clean backport of an already-reviewed change. Auto-merge is armed and will merge once the required CI checks are green." || \
echo "::warning::Could not approve #$pr"
else
echo "::warning::Ephemeral token was unavailable (is the backport Token Policy registered in elastic/catalog-info?); #$pr was authored by github-actions[bot], so it cannot be auto-approved and needs a manual approval. Auto-merge is still armed and will merge once approved and CI is green."
fi
# Arm auto-merge (squash) as MERGE_TOKEN. This does NOT merge immediately:
# GitHub merges only once every required status check (including
# buildkite/ml-cpp-pr-builds) is green and the review requirement is satisfied.
# If CI never runs or fails, the PR simply stays open — fail-safe.
echo "Enabling auto-merge (squash) on backport PR #$pr"
GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$REPO" --auto --squash || \
echo "::warning::Could not enable auto-merge on #$pr"
done
remove-backport-pending:
name: Remove backport-pending label
runs-on: ubuntu-latest
# Run when a backport PR is merged or closed.
if: |
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'backport')
steps:
- name: Check if all backports are complete
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
# Backport PR titles follow the pattern: [9.2] Original title (#1234)
ORIGINAL_PR=$(echo "$PR_TITLE" | grep -oE '\(#[0-9]+\)' | tail -1 | tr -d '(#)')
if [ -z "$ORIGINAL_PR" ]; then
echo "Could not extract original PR number from title: $PR_TITLE"
exit 0
fi
echo "Original PR: #$ORIGINAL_PR"
echo "Just-merged backport PR: #$PR_NUMBER"
HAS_LABEL=$(gh pr view "$ORIGINAL_PR" --repo "$REPO" --json labels \
--jq '[.labels[].name] | if index("backport-pending") then "true" else "false" end')
if [ "$HAS_LABEL" != "true" ]; then
echo "Original PR #$ORIGINAL_PR does not have backport-pending label. Nothing to do."
exit 0
fi
# Count open backport PRs, excluding the just-merged PR (API eventual consistency).
OPEN_BACKPORTS=$(gh pr list --repo "$REPO" --label backport --state open \
--json number,title \
--jq "[.[] | select(.number != ${PR_NUMBER}) | select(.title | test(\"\\\\(#${ORIGINAL_PR}\\\\)\"))] | length")
echo "Open backport PRs remaining: $OPEN_BACKPORTS"
if [ "$OPEN_BACKPORTS" -eq 0 ]; then
echo "All backport PRs are merged/closed. Removing backport-pending label from #$ORIGINAL_PR."
gh pr edit "$ORIGINAL_PR" --repo "$REPO" --remove-label "backport-pending"
else
echo "Still $OPEN_BACKPORTS open backport PR(s). Keeping backport-pending label on #$ORIGINAL_PR."
fi