Auto Cherry-pick to Main #784
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: Auto Cherry-pick to Main | |
| on: | |
| push: | |
| branches: | |
| - canary | |
| schedule: | |
| # Run every hour to catch any missed commits | |
| - cron: '0 * * * *' | |
| workflow_dispatch: | |
| jobs: | |
| auto-cherry-pick: | |
| runs-on: ubuntu-latest | |
| if: github.repository == 'better-auth/better-auth' | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.PAT_TOKEN }} | |
| - name: Configure Git | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| - name: Find commits to cherry-pick | |
| id: find-commits | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| with: | |
| script: | | |
| // Get all merged PRs targeting canary branch | |
| const prs = await github.rest.pulls.list({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'closed', | |
| base: 'canary', | |
| per_page: 100, | |
| sort: 'updated', | |
| direction: 'desc' | |
| }); | |
| // Filter for merged PRs without breaking change labels | |
| const prsToProcess = prs.data.filter(pr => { | |
| if (!pr.merged_at) return false; | |
| // Skip if PR has breaking change label | |
| const hasBreakingChange = pr.labels.some(label => | |
| label.name === 'breaking-change' || | |
| label.name === 'breaking' || | |
| label.name === 'major' | |
| ); | |
| if (hasBreakingChange) { | |
| console.log(`Skipping PR #${pr.number}: has breaking change label`); | |
| return false; | |
| } | |
| // Skip if PR has merge-to-main or merge-to-main-failure label (handled by other workflow) | |
| const hasManualLabel = pr.labels.some(label => | |
| label.name === 'merge-to-main' || | |
| label.name === 'merge-to-main-failure' | |
| ); | |
| if (hasManualLabel) { | |
| console.log(`Skipping PR #${pr.number}: handled by manual cherry-pick workflow`); | |
| return false; | |
| } | |
| // Skip if PR has skip-cherry-pick label | |
| const hasSkipLabel = pr.labels.some(label => | |
| label.name === 'skip-cherry-pick' || | |
| label.name === 'canary-only' | |
| ); | |
| if (hasSkipLabel) { | |
| console.log(`Skipping PR #${pr.number}: has skip label`); | |
| return false; | |
| } | |
| // Skip if PR has already been auto-cherry-picked (prevents duplicates) | |
| const hasAutoPickedLabel = pr.labels.some(label => | |
| label.name === 'auto-cherry-picked' | |
| ); | |
| if (hasAutoPickedLabel) { | |
| console.log(`Skipping PR #${pr.number}: already auto-cherry-picked`); | |
| return false; | |
| } | |
| return true; | |
| }); | |
| console.log(`Found ${prsToProcess.length} PRs eligible for auto cherry-pick`); | |
| if (prsToProcess.length === 0) { | |
| console.log('No PRs to process'); | |
| core.setOutput('has_prs', 'false'); | |
| return; | |
| } | |
| core.setOutput('has_prs', 'true'); | |
| core.setOutput('prs_to_process', JSON.stringify(prsToProcess.map(pr => ({ | |
| number: pr.number, | |
| merge_commit_sha: pr.merge_commit_sha, | |
| title: pr.title | |
| })))); | |
| - name: Cherry-pick to main | |
| id: cherry-pick | |
| if: steps.find-commits.outputs.has_prs == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.PAT_TOKEN }} | |
| PRS_JSON: ${{ steps.find-commits.outputs.prs_to_process }} | |
| run: | | |
| # Fetch all branches | |
| git fetch origin main | |
| git fetch origin canary | |
| # Checkout main branch | |
| git checkout -B main origin/main | |
| set +e # Don't exit on error | |
| echo "Processing PRs for auto cherry-pick..." | |
| echo "$PRS_JSON" > prs.json | |
| success_prs="" | |
| conflict_prs="" | |
| skipped_prs="" | |
| success_count=0 | |
| conflict_count=0 | |
| skipped_count=0 | |
| for row in $(jq -r '.[] | @base64' prs.json); do | |
| _jq() { | |
| echo ${row} | base64 --decode | jq -r ${1} | |
| } | |
| PR_NUM=$(_jq '.number') | |
| MERGE_COMMIT=$(_jq '.merge_commit_sha') | |
| PR_TITLE=$(_jq '.title') | |
| echo "----------------------------------------" | |
| echo "Processing PR #$PR_NUM: $PR_TITLE" | |
| echo "Merge commit: $MERGE_COMMIT" | |
| # Attempt cherry-pick | |
| if git cherry-pick -m 1 "$MERGE_COMMIT"; then | |
| echo "✅ Successfully cherry-picked PR #$PR_NUM" | |
| success_prs="$success_prs $PR_NUM" | |
| success_count=$((success_count + 1)) | |
| else | |
| # Check if it's an empty commit (already applied) | |
| if git diff-index --quiet HEAD; then | |
| echo "⏭️ PR #$PR_NUM changes already in main (empty commit)" | |
| git cherry-pick --abort | |
| skipped_prs="$skipped_prs $PR_NUM" | |
| skipped_count=$((skipped_count + 1)) | |
| else | |
| echo "❌ Conflict in PR #$PR_NUM" | |
| conflict_prs="$conflict_prs $PR_NUM" | |
| git cherry-pick --abort | |
| conflict_count=$((conflict_count + 1)) | |
| fi | |
| fi | |
| done | |
| set -e # Re-enable exit on error | |
| echo "----------------------------------------" | |
| echo "Summary:" | |
| echo " Success: $success_count" | |
| echo " Conflicts: $conflict_count" | |
| echo " Already in main: $skipped_count" | |
| # Push all successful cherry-picks | |
| if [ $success_count -gt 0 ]; then | |
| git push origin main | |
| echo "✅ Pushed $success_count cherry-pick(s) to main" | |
| fi | |
| # Save results for next steps | |
| echo "success_prs=$success_prs" >> $GITHUB_OUTPUT | |
| echo "conflict_prs=$conflict_prs" >> $GITHUB_OUTPUT | |
| echo "skipped_prs=$skipped_prs" >> $GITHUB_OUTPUT | |
| echo "success_count=$success_count" >> $GITHUB_OUTPUT | |
| echo "conflict_count=$conflict_count" >> $GITHUB_OUTPUT | |
| echo "skipped_count=$skipped_count" >> $GITHUB_OUTPUT | |
| - name: Label and comment on successful PRs | |
| if: always() && steps.cherry-pick.outputs.success_prs != '' | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| with: | |
| script: | | |
| const successPrs = '${{ steps.cherry-pick.outputs.success_prs }}'.trim().split(' ').filter(Boolean); | |
| for (const prNumber of successPrs) { | |
| console.log(`Processing successful PR #${prNumber}`); | |
| // Add auto-cherry-picked label to prevent re-processing | |
| try { | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| labels: ['auto-cherry-picked'] | |
| }); | |
| console.log(`✅ Added auto-cherry-picked label to PR #${prNumber}`); | |
| } catch (error) { | |
| console.log(`⚠️ Could not add label to PR #${prNumber}: ${error.message}`); | |
| } | |
| // Check if we've already commented about success | |
| const comments = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| }); | |
| const hasSuccessComment = comments.data.some(comment => | |
| comment.body.includes('Automatically cherry-picked to `main` branch') | |
| ); | |
| if (hasSuccessComment) { | |
| console.log(`⏭️ Skipping comment on PR #${prNumber} - success comment already exists`); | |
| continue; | |
| } | |
| // Add success comment | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: '✅ Automatically cherry-picked to `main` branch!' | |
| }); | |
| } | |
| - name: Label skipped PRs | |
| if: always() && steps.cherry-pick.outputs.skipped_prs != '' | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| with: | |
| script: | | |
| const skippedPrs = '${{ steps.cherry-pick.outputs.skipped_prs }}'.trim().split(' ').filter(Boolean); | |
| for (const prNumber of skippedPrs) { | |
| console.log(`Marking skipped PR #${prNumber} as auto-cherry-picked`); | |
| // Add auto-cherry-picked label to prevent re-processing | |
| try { | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| labels: ['auto-cherry-picked'] | |
| }); | |
| console.log(`✅ Added auto-cherry-picked label to PR #${prNumber}`); | |
| } catch (error) { | |
| console.log(`⚠️ Could not add label to PR #${prNumber}: ${error.message}`); | |
| } | |
| } | |
| - name: Handle PRs with conflicts | |
| if: always() && steps.cherry-pick.outputs.conflict_prs != '' | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| with: | |
| script: | | |
| const conflictPrs = '${{ steps.cherry-pick.outputs.conflict_prs }}'.trim().split(' ').filter(Boolean); | |
| for (const prNumber of conflictPrs) { | |
| console.log(`Checking PR #${prNumber} for existing conflict comment`); | |
| // Check if we've already commented about conflicts | |
| const comments = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| }); | |
| const hasConflictComment = comments.data.some(comment => | |
| comment.body.includes('Auto cherry-pick to `main` failed due to conflicts') | |
| ); | |
| if (hasConflictComment) { | |
| console.log(`⏭️ Skipping PR #${prNumber} - conflict comment already exists`); | |
| continue; | |
| } | |
| console.log(`Adding conflict comment and label to PR #${prNumber}`); | |
| // Add skip-cherry-pick label to prevent retry attempts | |
| try { | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| labels: ['skip-cherry-pick'] | |
| }); | |
| console.log(`✅ Added skip-cherry-pick label to PR #${prNumber}`); | |
| } catch (error) { | |
| console.log(`⚠️ Could not add label to PR #${prNumber}: ${error.message}`); | |
| } | |
| // Add conflict comment | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: `⚠️ **Auto cherry-pick to \`main\` failed due to conflicts!**\n\nThis PR could not be automatically cherry-picked to the \`main\` branch. Please either:\n\n1. Manually cherry-pick and resolve conflicts, or\n2. Add the \`merge-to-main\` label to retry the cherry-pick\n\nThe \`skip-cherry-pick\` label has been added to prevent automatic retry attempts.` | |
| }); | |
| } | |
| - name: Workflow Summary | |
| if: always() && steps.find-commits.outputs.has_prs == 'true' | |
| run: | | |
| echo "### Auto Cherry-pick Summary" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "- ✅ Success: ${{ steps.cherry-pick.outputs.success_count }}" >> $GITHUB_STEP_SUMMARY | |
| echo "- ⚠️ Conflicts: ${{ steps.cherry-pick.outputs.conflict_count }}" >> $GITHUB_STEP_SUMMARY | |
| echo "- ℹ️ Already in main: ${{ steps.cherry-pick.outputs.skipped_count }}" >> $GITHUB_STEP_SUMMARY | |
| if [ -n "${{ steps.cherry-pick.outputs.success_prs }}" ]; then | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "**Successful PRs:** ${{ steps.cherry-pick.outputs.success_prs }}" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| if [ -n "${{ steps.cherry-pick.outputs.conflict_prs }}" ]; then | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "**PRs with conflicts:** ${{ steps.cherry-pick.outputs.conflict_prs }}" >> $GITHUB_STEP_SUMMARY | |
| fi |