Skip to content

[ML] Pass explicit vault-role for the Backstage-registered backport token policy #695

[ML] Pass explicit vault-role for the Backstage-registered backport token policy

[ML] Pass explicit vault-role for the Backstage-registered backport token policy #695

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"
# The Token Policy was registered via the Backstage self-service template,
# which names the Vault role after the AppRole name (token-policy-ml-cpp-backport)
# rather than the SHA-256 hash of the workflow ref that fetch-github-token
# derives by default. Pass the role explicitly so the lookup matches the
# registered policy (github/token/token-policy-ml-cpp-backport).
vault-role: "token-policy-ml-cpp-backport"
- name: Backport Action
id: backport
if: steps.check-labels.outputs.has_version_label == 'true'
uses: sorenlouv/backport-github-action@v10.2.0
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. This
# is the same author-with-app / approve-with-GITHUB_TOKEN pattern the ECP GitOps
# repos (e.g. elastic/catalog-sync-config) use under the identical ruleset.
- name: Approve and enable auto-merge on backport PRs
if: >-
steps.backport.outcome == 'success' &&
contains(github.event.pull_request.labels.*.name, 'auto-backport')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 'true' only when the ephemeral token was minted (Token Policy registered),
# i.e. the backport PRs were authored by the ephemeral-token app identity and
# can therefore be approved by github-actions[bot].
EPHEMERAL_AUTHORED: ${{ 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 / auto-merge."
exit 0
fi
for pr in $PR_NUMBERS; do
if [ "$EPHEMERAL_AUTHORED" = "true" ]; then
# Authored by the ephemeral-token identity, so this github-actions[bot]
# approval 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." || \
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 will need a manual approval to merge."
fi
echo "Enabling auto-merge (squash) on PR #$pr"
gh pr merge "$pr" --repo "$REPO" --auto --squash || \
echo "::warning::Could not enable auto-merge on #$pr (is auto-merge enabled in repo settings?)"
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