Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions .github/scripts/backport-commit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
#!/usr/bin/env bash

# Safe backport helper. Creates a PR in the current repository that cherry-picks a commit from upstream.

set -euo pipefail

# ANSI colors for readability
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

die() {
echo -e "${RED}$1${NC}" >&2
exit "${2:-1}"
}

require_env() {
local name="$1"
local value="${!name:-}"
if [[ -z "$value" ]]; then
die "Environment variable $name is required"
fi
}

if [[ $# -ne 1 ]]; then
die "Usage: $0 <commit-sha>"
fi

COMMIT_SHA="$1"

if ! [[ "$COMMIT_SHA" =~ ^[0-9a-f]{40}$ ]]; then
Copy link

Copilot AI Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex only accepts lowercase hex characters but Git commit SHAs can contain uppercase letters A-F. The regex should be ^[0-9a-fA-F]{40}$ to accept both cases.

Suggested change
if ! [[ "$COMMIT_SHA" =~ ^[0-9a-f]{40}$ ]]; then
if ! [[ "$COMMIT_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then

Copilot uses AI. Check for mistakes.

die "Invalid commit SHA: $COMMIT_SHA"
fi

SOURCE_REPO="${SOURCE_REPO:-apache/apisix-ingress-controller}"
TARGET_BRANCH="${TARGET_BRANCH:-master}"
GITHUB_REPO="${GITHUB_REPOSITORY:-}"

require_env SOURCE_REPO
require_env TARGET_BRANCH
require_env GH_TOKEN

[[ "$SOURCE_REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || die "Invalid SOURCE_REPO: $SOURCE_REPO"
[[ "$TARGET_BRANCH" =~ ^[A-Za-z0-9._/-]+$ ]] || die "Invalid TARGET_BRANCH: $TARGET_BRANCH"

if [[ -z "$GITHUB_REPO" ]]; then
GITHUB_REPO="$(gh repo view --json nameWithOwner -q '.nameWithOwner')"
fi

[[ "$GITHUB_REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || die "Invalid target repo: $GITHUB_REPO"

echo -e "${YELLOW}Backporting commit ${COMMIT_SHA} from ${SOURCE_REPO}${NC}"

ORIGINAL_REF=""
ORIGINAL_COMMIT=""
if ORIGINAL_REF=$(git symbolic-ref --quiet HEAD 2>/dev/null); then
ORIGINAL_REF=${ORIGINAL_REF#refs/heads/}
else
ORIGINAL_COMMIT=$(git rev-parse HEAD)
fi

restore_original_ref() {
if [[ -n "$ORIGINAL_REF" ]]; then
git checkout "$ORIGINAL_REF" >/dev/null 2>&1 || true
elif [[ -n "$ORIGINAL_COMMIT" ]]; then
git checkout --detach "$ORIGINAL_COMMIT" >/dev/null 2>&1 || true
fi
}

if ! git cat-file -e "${COMMIT_SHA}^{commit}" 2>/dev/null; then
die "Commit $COMMIT_SHA is not available locally - fetch upstream before running this script"
fi

COMMIT_TITLE="$(git log --format='%s' -n 1 "$COMMIT_SHA")"
COMMIT_AUTHOR="$(git log --format='%an <%ae>' -n 1 "$COMMIT_SHA")"
COMMIT_URL="https://github.com/${SOURCE_REPO}/commit/${COMMIT_SHA}"
SHORT_SHA="${COMMIT_SHA:0:7}"
if [[ -z "$COMMIT_TITLE" ]]; then
COMMIT_TITLE="Backport ${SHORT_SHA} from ${SOURCE_REPO}"
fi
TITLE_SUFFIX=" (${SHORT_SHA})"
if [[ "$COMMIT_TITLE" == *"$SHORT_SHA"* ]]; then
TITLE_SUFFIX=""
fi
BRANCH_NAME="backport/${SHORT_SHA}-to-${TARGET_BRANCH}"

[[ "$BRANCH_NAME" =~ ^[A-Za-z0-9._/-]+$ ]] || die "Generated branch name is unsafe: $BRANCH_NAME"

echo -e "${YELLOW}Generated branch name: ${BRANCH_NAME}${NC}"

EXISTING_PR="$(gh pr list --state all --head "$BRANCH_NAME" --json url --jq '.[0].url' 2>/dev/null || true)"
if [[ -n "$EXISTING_PR" ]]; then
echo -e "${GREEN}PR already exists: ${EXISTING_PR}. Skipping duplicate.${NC}"
exit 0
fi

git fetch origin "$TARGET_BRANCH" --quiet
git checkout -B "$TARGET_BRANCH" "origin/$TARGET_BRANCH"

if git rev-parse --verify "$BRANCH_NAME" >/dev/null 2>&1; then
git checkout "$BRANCH_NAME"
git reset --hard "origin/$TARGET_BRANCH"
else
git checkout -b "$BRANCH_NAME"
fi

PARENT_COUNT="$(git rev-list --parents -n 1 "$COMMIT_SHA" | awk '{print NF-1}')"
HAS_CONFLICTS=false

echo -e "${YELLOW}Running cherry-pick...${NC}"

cherry_pick() {
if [[ "$PARENT_COUNT" -gt 1 ]]; then
git cherry-pick -x -m 1 "$COMMIT_SHA"
else
git cherry-pick -x "$COMMIT_SHA"
fi
}
Comment on lines +113 to +119
Copy link

Copilot AI Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function always uses parent 1 for merge commits without validation. Consider adding a comment explaining why parent 1 is chosen, or add logic to detect the appropriate parent for the merge.

Copilot uses AI. Check for mistakes.


if ! cherry_pick; then
echo -e "${YELLOW}Cherry-pick reported conflicts; leaving markers for manual resolution.${NC}"
HAS_CONFLICTS=true
git add .
Copy link

Copilot AI Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using git add . stages all changes including unrelated files that might be in the working directory. Consider using git add -u to only stage tracked files with conflicts, or be more specific about which files to stage.

Suggested change
git add .
git add -u

Copilot uses AI. Check for mistakes.

git -c core.editor=true cherry-pick --continue || true
fi

echo -e "${YELLOW}Pushing branch to origin...${NC}"
if ! git push -u origin "$BRANCH_NAME"; then
echo -e "${YELLOW}Push failed, trying force-with-lease...${NC}"
git fetch origin "$BRANCH_NAME" || true
git branch --set-upstream-to="origin/$BRANCH_NAME" "$BRANCH_NAME" || true
git push -u origin "$BRANCH_NAME" --force-with-lease || {
git checkout "$TARGET_BRANCH"
git branch -D "$BRANCH_NAME" || true
restore_original_ref
die "Unable to push branch ${BRANCH_NAME}"
}
fi

echo -e "${YELLOW}Creating pull request...${NC}"

if [[ "$HAS_CONFLICTS" == "true" ]]; then
PR_TITLE="conflict: ${COMMIT_TITLE}${TITLE_SUFFIX}"
PR_BODY=$(cat <<EOF
<!-- backport:${COMMIT_SHA} -->

## ⚠️ Backport With Conflicts

- Upstream commit: ${COMMIT_URL}
- Original title: ${COMMIT_TITLE}
- Original author: ${COMMIT_AUTHOR}

This PR contains unresolved conflicts. Please resolve them before merging.

### Suggested workflow
1. \`git fetch origin ${BRANCH_NAME}\`
2. \`git checkout ${BRANCH_NAME}\`
3. Resolve conflicts, commit, and push updates.

> Created automatically by backport-bot.
EOF
)
LABEL_FLAGS=(--label backport --label automated --label needs-manual-action --label conflicts)
else
PR_TITLE="${COMMIT_TITLE}${TITLE_SUFFIX}"
PR_BODY=$(cat <<EOF
<!-- backport:${COMMIT_SHA} -->

## 🔄 Automated Backport

- Upstream commit: ${COMMIT_URL}
- Original title: ${COMMIT_TITLE}
- Original author: ${COMMIT_AUTHOR}

Please review and run the relevant validation before merging.

> Created automatically by backport-bot.
EOF
)
LABEL_FLAGS=(--label backport --label automated)
fi

set +e
PR_RESPONSE="$(gh pr create \
--title "$PR_TITLE" \
--body "$PR_BODY" \
--head "$BRANCH_NAME" \
--base "$TARGET_BRANCH" \
--repo "$GITHUB_REPO" \
"${LABEL_FLAGS[@]}" 2>&1)"
PR_EXIT_CODE=$?
set -e

if [[ $PR_EXIT_CODE -ne 0 ]]; then
echo -e "${RED}Failed to create PR:${NC}\n${PR_RESPONSE}"
if grep -q "already exists" <<<"$PR_RESPONSE"; then
echo -e "${YELLOW}Detected existing PR, assuming success.${NC}"
git checkout "$TARGET_BRANCH"
restore_original_ref
exit 0
fi
git checkout "$TARGET_BRANCH"
git push origin --delete "$BRANCH_NAME" || true
git branch -D "$BRANCH_NAME" || true
restore_original_ref
die "PR creation failed"
fi

echo -e "${GREEN}Pull request created successfully:${NC} ${PR_RESPONSE}"

restore_original_ref

echo -e "${GREEN}Backport finished for ${COMMIT_SHA}.${NC}"
150 changes: 150 additions & 0 deletions .github/workflows/backport.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
name: Auto Backport from Upstream

on:
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to manually trigger it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be timed?

inputs:
force_sync:
description: "Force sync all recent commits (ignores watermark)"
required: false
default: "false"

concurrency:
group: auto-backport
cancel-in-progress: false

env:
SOURCE_REPO: apache/apisix-ingress-controller
SOURCE_BRANCH: master
TARGET_BRANCH: ${{ github.event.repository.default_branch || 'master' }}
MAX_COMMITS_PER_RUN: 5

jobs:
auto-backport:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
actions: write
contents: write
pull-requests: write
issues: write
repository-projects: write
env:
GH_TOKEN: ${{ secrets.BACKPORT_PAT }}
GITHUB_REPOSITORY: ${{ github.repository }}
FORCE_SYNC: ${{ github.event.inputs.force_sync || 'false' }}

steps:
- name: Checkout target repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.BACKPORT_PAT }}

- name: Show run configuration
run: |
echo "Force sync: $FORCE_SYNC"
echo "Source repo: $SOURCE_REPO"
echo "Source branch: $SOURCE_BRANCH"
echo "Target branch: $TARGET_BRANCH"

- name: Configure git identity
run: |
git config --global user.name "backport-bot[bot]"
git config --global user.email "backport-bot[bot]@users.noreply.github.com"

- name: Add upstream remote
run: |
git remote add upstream "https://github.com/${SOURCE_REPO}.git" 2>/dev/null || true
git remote set-url upstream "https://github.com/${SOURCE_REPO}.git"

- name: Fetch upstream branch
run: |
git fetch --prune --no-tags upstream "${SOURCE_BRANCH}"

- name: Read last processed commit watermark
id: watermark
run: |
LAST_SHA=$(gh variable get LAST_BACKPORT_SHA -R "${GITHUB_REPOSITORY}" --json value --jq '.value' 2>/dev/null || echo "")
if [[ -z "$LAST_SHA" || "$FORCE_SYNC" == "true" ]]; then
LAST_SHA=$(git log "upstream/${SOURCE_BRANCH}" --since="7 days ago" --format="%H" | tail -n 1)
fi
echo "last_sha=${LAST_SHA}" >> "$GITHUB_OUTPUT"
echo "Last processed SHA: ${LAST_SHA:-<none>}"

- name: Collect new commits
id: collect_commits
run: |
LAST_SHA="${{ steps.watermark.outputs.last_sha }}"
if [[ -n "$LAST_SHA" ]]; then
COMMITS=$(git log "upstream/${SOURCE_BRANCH}" ^"$LAST_SHA" --format="%H" --reverse | head -"${MAX_COMMITS_PER_RUN}")
else
COMMITS=$(git log "upstream/${SOURCE_BRANCH}" -1 --format="%H")
fi
{
echo "commits<<EOF"
printf '%s\n' "$COMMITS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
if [[ -z "$COMMITS" ]]; then
COUNT=0
else
COUNT=$(printf '%s\n' "$COMMITS" | grep -c '[0-9a-f]')
fi
echo "count=${COUNT}" >> "$GITHUB_OUTPUT"
echo "Commits to process: ${COUNT}"

- name: Ensure labels exist
run: |
gh label create backport --color EDEDED --description "Automated backport" -R "${GITHUB_REPOSITORY}" 2>/dev/null || true
gh label create automated --color EDEDED --description "Created by automation" -R "${GITHUB_REPOSITORY}" 2>/dev/null || true
gh label create backport-failed --color D73A4A --description "Backport failed" -R "${GITHUB_REPOSITORY}" 2>/dev/null || true
gh label create needs-manual-action --color FBCA04 --description "Manual intervention required" -R "${GITHUB_REPOSITORY}" 2>/dev/null || true
gh label create conflicts --color D93F0B --description "Contains merge conflicts" -R "${GITHUB_REPOSITORY}" 2>/dev/null || true

- name: Process commits
if: steps.collect_commits.outputs.count != '0'
env:
GH_TOKEN: ${{ github.token }}
run: |
chmod +x .github/scripts/backport-commit.sh
SUCCESS=0
FAILURE=0
LAST_PROCESSED=""
while IFS= read -r COMMIT; do
[[ -z "$COMMIT" ]] && continue
if .github/scripts/backport-commit.sh "$COMMIT"; then
SUCCESS=$((SUCCESS + 1))
LAST_PROCESSED="$COMMIT"
else
echo "Commit ${COMMIT} failed to backport"
FAILURE=$((FAILURE + 1))
fi
done <<< "${{ steps.collect_commits.outputs.commits }}"
echo "SUCCESS_COUNT=$SUCCESS" >> "$GITHUB_ENV"
echo "FAILURE_COUNT=$FAILURE" >> "$GITHUB_ENV"
echo "LAST_PROCESSED_SHA=$LAST_PROCESSED" >> "$GITHUB_ENV"

- name: Update watermark
if: env.LAST_PROCESSED_SHA != ''
run: |
if [[ "${FAILURE_COUNT:-0}" == "0" ]]; then
gh variable set LAST_BACKPORT_SHA -b "${LAST_PROCESSED_SHA}" -R "${GITHUB_REPOSITORY}"
else
echo "Failures detected; watermark will not be updated."
fi

- name: Summary
run: |
echo "Successful cherry-picks: ${SUCCESS_COUNT:-0}"
echo "Failed cherry-picks: ${FAILURE_COUNT:-0}"
echo "Last processed SHA: ${LAST_PROCESSED_SHA:-none}"
{
echo "# Backport Summary"
echo
echo "- Successful: ${SUCCESS_COUNT:-0}"
echo "- Failed: ${FAILURE_COUNT:-0}"
echo "- Last processed: ${LAST_PROCESSED_SHA:-none}"
echo "- Force sync: ${FORCE_SYNC}"
} >> "$GITHUB_STEP_SUMMARY"
Loading