-
Notifications
You must be signed in to change notification settings - Fork 3
feat: add backport ci #285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
7d5372f
832ba8b
c8c87bb
235683b
4ddde82
34bcc4e
e431736
055adc1
be7f08d
54327c5
18230b4
2255f54
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||||||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Positive FeedbackNegative Feedback |
||||||
|
||||||
if ! cherry_pick; then | ||||||
echo -e "${YELLOW}Cherry-pick reported conflicts; leaving markers for manual resolution.${NC}" | ||||||
HAS_CONFLICTS=true | ||||||
git add . | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||
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}" |
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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to manually trigger it? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" |
There was a problem hiding this comment.
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.
Copilot uses AI. Check for mistakes.