[ML] Auto-approve automated version-bump PRs #850
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |