backport.yml

  1name: Backport to release/v1
  2
  3# Cherry-picks a merged PR's commit straight onto release/v1 and pushes it —
  4# no intermediate PR. Fires when:
  5#   - a PR labeled `backport/v1` is merged into master, or
  6#   - a maintainer comments `/backport v1` on an already-merged PR.
  7# On a clean cherry-pick the commit is pushed directly. On conflict nothing is
  8# pushed; the source PR is labeled `backport-failed` and a comment asks for a
  9# manual cherry-pick.
 10
 11on:
 12  pull_request_target:
 13    types: [closed]
 14  issue_comment:
 15    types: [created]
 16
 17permissions:
 18  contents: write
 19  pull-requests: write
 20  issues: write
 21
 22jobs:
 23  backport:
 24    name: Backport
 25    runs-on: ubuntu-latest
 26    if: >
 27      (github.event_name == 'pull_request_target'
 28        && github.event.pull_request.merged == true
 29        && contains(github.event.pull_request.labels.*.name, 'backport/v1'))
 30      || (github.event_name == 'issue_comment'
 31        && github.event.issue.pull_request
 32        && github.event.issue.state == 'closed'
 33        && startsWith(github.event.comment.body, '/backport v1')
 34        && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
 35    steps:
 36      - name: Resolve PR and merge commit
 37        id: pr
 38        uses: actions/github-script@v9
 39        with:
 40          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
 41          script: |
 42            const { owner, repo } = context.repo;
 43            const num = context.eventName === 'issue_comment'
 44              ? context.payload.issue.number
 45              : context.payload.pull_request.number;
 46
 47            const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: num });
 48
 49            if (!pr.merged || !pr.merge_commit_sha) {
 50              core.notice(`PR #${num} is not merged; nothing to backport.`);
 51              core.setOutput('run', 'false');
 52              return;
 53            }
 54            core.setOutput('run', 'true');
 55            core.setOutput('number', String(num));
 56            core.setOutput('sha', pr.merge_commit_sha);
 57            core.setOutput('title', pr.title);
 58
 59      - name: Checkout
 60        if: steps.pr.outputs.run == 'true'
 61        uses: actions/checkout@v7
 62        with:
 63          fetch-depth: 0
 64          ref: release/v1
 65          token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
 66
 67      - name: Cherry-pick onto release/v1
 68        if: steps.pr.outputs.run == 'true'
 69        id: pick
 70        run: |
 71          set -euo pipefail
 72          SHA="${{ steps.pr.outputs.sha }}"
 73          git config user.name "Floatpane Bot"
 74          git config user.email "us@floatpane.com"
 75          git fetch --no-tags origin "$SHA"
 76
 77          # A true merge commit has >1 parent and needs a mainline (-m 1);
 78          # squash/rebase merges are a single commit cherry-picked as-is.
 79          PARENTS=$(git rev-list --parents -n1 "$SHA" | wc -w)
 80          PICK_ARGS="-x"
 81          if [ "$PARENTS" -gt 2 ]; then PICK_ARGS="-x -m 1"; fi
 82
 83          if git cherry-pick $PICK_ARGS "$SHA"; then
 84            git push origin HEAD:release/v1
 85            echo "status=ok" >> "$GITHUB_OUTPUT"
 86          else
 87            git cherry-pick --abort || true
 88            echo "status=conflict" >> "$GITHUB_OUTPUT"
 89          fi
 90
 91      - name: Report result
 92        if: steps.pr.outputs.run == 'true'
 93        uses: actions/github-script@v9
 94        with:
 95          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
 96          script: |
 97            const { owner, repo } = context.repo;
 98            const issue_number = Number('${{ steps.pr.outputs.number }}');
 99            const status = '${{ steps.pick.outputs.status }}';
100            const sha = '${{ steps.pr.outputs.sha }}';
101
102            if (status === 'ok') {
103              await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['backported'] });
104              try {
105                await github.rest.issues.removeLabel({ owner, repo, issue_number, name: 'backport-failed' });
106              } catch (e) { if (e.status !== 404) throw e; }
107              await github.rest.issues.createComment({
108                owner, repo, issue_number,
109                body: `Cherry-picked \`${sha.substring(0, 7)}\` onto \`release/v1\`.`,
110              });
111            } else {
112              await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['backport-failed'] });
113              await github.rest.issues.createComment({
114                owner, repo, issue_number,
115                body: [
116                  `Backport to \`release/v1\` hit a conflict — cherry-pick it manually:`,
117                  '```bash',
118                  'git fetch origin',
119                  'git checkout release/v1',
120                  `git cherry-pick -x ${sha}`,
121                  '# resolve conflicts, then:',
122                  'git push origin release/v1',
123                  '```',
124                ].join('\n'),
125              });
126              core.setFailed('Cherry-pick conflict.');
127            }