Skip to content

Commit 317311e

Browse files
committed
Introduce a workflow for staging backport PRs.
1 parent 4ca7b7a commit 317311e

1 file changed

Lines changed: 237 additions & 0 deletions

File tree

.github/workflows/backport.yml

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
name: Create backport pull requests
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
end_branch:
7+
description: 'The branch to end at (e.g. 4.7). Defaults to the oldest branch receiving security updates.'
8+
required: false
9+
type: string
10+
default: '4.7'
11+
pr-name:
12+
description: 'The name for the pull requests. When provided, PRs are titled "<name> - <branch> branch".'
13+
required: false
14+
type: string
15+
default: ''
16+
commit-sha:
17+
description: 'The full length commit hash to cherry-pick. When provided, PR numbers are ignored.'
18+
required: false
19+
type: string
20+
default: ''
21+
pr_numbers:
22+
description: 'Comma-separated PR numbers. Each PR is merged as a single commit using the PR title as the commit message. Ignored when a commit SHA is provided.'
23+
required: false
24+
type: string
25+
default: ''
26+
27+
# Disable permissions for all available scopes by default.
28+
# Any needed permissions should be configured at the job level.
29+
permissions: {}
30+
31+
jobs:
32+
get-branches:
33+
name: Get target branches
34+
runs-on: ubuntu-24.04
35+
outputs:
36+
branches: ${{ steps.branches.outputs.result }}
37+
steps:
38+
- name: Checkout repository
39+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
40+
with:
41+
persist-credentials: false
42+
43+
# Read keys from .version-support-php.json, filter to those >= end_branch,
44+
# convert dashes to dots, and sort numerically descending.
45+
# The first key is always the version in active development on trunk, so skip it.
46+
- name: Get target branches
47+
id: branches
48+
env:
49+
INPUTS_END_BRANCH: ${{ inputs.end_branch }}
50+
run: |
51+
END_X=$(echo '${INPUTS_END_BRANCH}' | cut -d. -f1)
52+
END_Y=$(echo '${INPUTS_END_BRANCH}' | cut -d. -f2)
53+
54+
BRANCHES=$(jq -c \
55+
--argjson x "$END_X" \
56+
--argjson y "$END_Y" \
57+
'[ keys[] |
58+
. as $k | ($k | split("-")) as $p |
59+
select( ($p[0]|tonumber) > $x or
60+
(($p[0]|tonumber) == $x and ($p[1]|tonumber) >= $y) ) |
61+
{ v: ($k | gsub("-"; ".")), x: ($p[0]|tonumber), y: ($p[1]|tonumber) }
62+
] | sort_by(.x, .y) | reverse | .[1:] | map(.v)' \
63+
.version-support-php.json)
64+
65+
echo "result=$BRANCHES" >> "$GITHUB_OUTPUT"
66+
67+
backport:
68+
name: 'Backport to ${{ matrix.branch }}'
69+
needs: [ 'get-branches' ]
70+
if: ${{ needs.get-branches.outputs.branches != '[]' }}
71+
runs-on: ubuntu-24.04
72+
permissions:
73+
contents: write
74+
pull-requests: write
75+
strategy:
76+
fail-fast: false
77+
matrix:
78+
branch: ${{ fromJson( needs.get-branches.outputs.branches ) }}
79+
steps:
80+
- name: Checkout repository
81+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
82+
with:
83+
fetch-depth: 0
84+
persist-credentials: 'true'
85+
86+
- name: Set up git identity
87+
run: |
88+
git config user.name "github-actions[bot]"
89+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
90+
91+
- name: Add upstream remote
92+
id: upstream
93+
env:
94+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
95+
run: |
96+
UPSTREAM=$(gh repo view "${{ github.repository }}" --json parent --jq '.parent.full_name // empty')
97+
if [ -n "$UPSTREAM" ]; then
98+
git remote add upstream "https://github.com/${UPSTREAM}.git"
99+
git fetch upstream
100+
echo "repo=$UPSTREAM" >> "$GITHUB_OUTPUT"
101+
else
102+
echo "repo=${{ github.repository }}" >> "$GITHUB_OUTPUT"
103+
fi
104+
105+
# Determine the name of the branch for the pull request.
106+
#
107+
# 1. pr-name (normalized to alphanumeric, hyphens, and periods only)
108+
# 2. commit-sha
109+
# 3. pr_numbers with commas replaced by hyphens
110+
- name: Determine backport branch name
111+
id: backport-branch
112+
env:
113+
INPUTS_PR_NAME: ${{ inputs.pr-name }}
114+
MATRIX_BRANCH: ${{ matrix.branch }}
115+
INPUTS_COMMIT_SHA: ${{ inputs.commit-sha }}
116+
INPUTS_PR_NUMBERS: ${{ inputs.pr_numbers }}
117+
run: |
118+
if [ -n '${INPUTS_PR_NAME}' ]; then
119+
echo "name=backport/${MATRIX_BRANCH}-$(echo '${INPUTS_PR_NAME}' | tr -cs '[:alnum:].-' '-' | sed 's/^-//;s/-$//')" >> "$GITHUB_OUTPUT"
120+
elif [ -n '${INPUTS_COMMIT_SHA}' ]; then
121+
echo "name=backport/${MATRIX_BRANCH}-${INPUTS_COMMIT_SHA}" >> "$GITHUB_OUTPUT"
122+
else
123+
echo "name=backport/${MATRIX_BRANCH}-$(echo '${INPUTS_PR_NUMBERS}' | tr -d ' ' | tr ',' '-')" >> "$GITHUB_OUTPUT"
124+
fi
125+
126+
- name: Create backport branch
127+
env:
128+
STEPS_BACKPORT_BRANCH_OUTPUTS_NAME: ${{ steps.backport-branch.outputs.name }}
129+
MATRIX_BRANCH: ${{ matrix.branch }}
130+
run: git checkout -b "${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}" "origin/${MATRIX_BRANCH}"
131+
132+
- name: Cherry-pick commit
133+
if: ${{ inputs['commit-sha'] != '' }}
134+
env:
135+
INPUTS_COMMIT_SHA: ${{ inputs.commit-sha }}
136+
run: |
137+
COMMIT='${INPUTS_COMMIT_SHA}'
138+
PARENTS=$(git cat-file -p "$COMMIT" | grep -c '^parent ' || true)
139+
140+
if [ "$PARENTS" -gt 1 ]; then
141+
git cherry-pick -m 1 "$COMMIT"
142+
else
143+
git cherry-pick "$COMMIT"
144+
fi
145+
146+
- name: Merge PRs
147+
if: ${{ inputs['commit-sha'] == '' && inputs.pr_numbers != '' }}
148+
env:
149+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
150+
STEPS_UPSTREAM_OUTPUTS_REPO: ${{ steps.upstream.outputs.repo }}
151+
INPUTS_PR_NUMBERS: ${{ inputs.pr_numbers }}
152+
run: |
153+
UPSTREAM_REPO="${STEPS_UPSTREAM_OUTPUTS_REPO}"
154+
155+
IFS=',' read -ra PR_LIST <<< "${INPUTS_PR_NUMBERS}"
156+
157+
UPSTREAM_URL="https://github.com/${UPSTREAM_REPO}.git"
158+
159+
for PR_NUMBER in "${PR_LIST[@]}"; do
160+
PR_NUMBER=$(echo "$PR_NUMBER" | tr -d ' ')
161+
162+
PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$UPSTREAM_REPO" --json title,mergeCommit,baseRefName)
163+
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
164+
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
165+
166+
if [ -n "$MERGE_COMMIT" ] && [ "$MERGE_COMMIT" != "null" ]; then
167+
# PR is merged: cherry-pick its merge commit.
168+
# Determine if it is a merge commit or squash commit.
169+
PARENTS=$(git cat-file -p "$MERGE_COMMIT" | grep -c '^parent ' || true)
170+
171+
if [ "$PARENTS" -gt 1 ]; then
172+
git cherry-pick -m 1 --no-commit "$MERGE_COMMIT"
173+
else
174+
git cherry-pick --no-commit "$MERGE_COMMIT"
175+
fi
176+
else
177+
# PR is open or closed without merging: apply its changes as a diff
178+
# against the point where it diverged from its base branch.
179+
BASE_REF=$(echo "$PR_DATA" | jq -r '.baseRefName')
180+
181+
git fetch "$UPSTREAM_URL" "$BASE_REF"
182+
BASE_SHA=$(git rev-parse FETCH_HEAD)
183+
184+
git fetch "$UPSTREAM_URL" "refs/pull/${PR_NUMBER}/head"
185+
PR_HEAD_SHA=$(git rev-parse FETCH_HEAD)
186+
187+
MERGE_BASE=$(git merge-base "$PR_HEAD_SHA" "$BASE_SHA")
188+
git diff "$MERGE_BASE" "$PR_HEAD_SHA" | git apply --index
189+
fi
190+
191+
git commit -m "$PR_TITLE"
192+
done
193+
194+
- name: Push backport branch
195+
env:
196+
STEPS_BACKPORT_BRANCH_OUTPUTS_NAME: ${{ steps.backport-branch.outputs.name }}
197+
run: git push -u origin "${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}"
198+
199+
- name: Create pull request
200+
env:
201+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
202+
STEPS_UPSTREAM_OUTPUTS_REPO: ${{ steps.upstream.outputs.repo }}
203+
INPUTS_PR_NAME: ${{ inputs.pr-name }}
204+
MATRIX_BRANCH: ${{ matrix.branch }}
205+
INPUTS_COMMIT_SHA: ${{ inputs.commit-sha }}
206+
INPUTS_PR_NUMBERS: ${{ inputs.pr_numbers }}
207+
STEPS_BACKPORT_BRANCH_OUTPUTS_NAME: ${{ steps.backport-branch.outputs.name }}
208+
run: |
209+
UPSTREAM_REPO="${STEPS_UPSTREAM_OUTPUTS_REPO}"
210+
211+
if [ -n '${INPUTS_PR_NAME}' ]; then
212+
PR_TITLE='${INPUTS_PR_NAME} - ${MATRIX_BRANCH} branch'
213+
else
214+
PR_TITLE='Backport to ${MATRIX_BRANCH}'
215+
fi
216+
217+
BODY="Backport to the \`${MATRIX_BRANCH}\` branch."
218+
BODY="${BODY}\n\n## Changes\n"
219+
220+
if [ -n '${INPUTS_COMMIT_SHA}' ]; then
221+
BODY="${BODY}\n- Cherry-picked commit: \`${INPUTS_COMMIT_SHA}\`"
222+
fi
223+
224+
if [ -n "${INPUTS_PR_NUMBERS}" ] && [ -z '${INPUTS_COMMIT_SHA}' ]; then
225+
IFS=',' read -ra PR_LIST <<< "${INPUTS_PR_NUMBERS}"
226+
for PR_NUMBER in "${PR_LIST[@]}"; do
227+
PR_NUMBER=$(echo "$PR_NUMBER" | tr -d ' ')
228+
BODY="${BODY}\n- ${UPSTREAM_REPO}#${PR_NUMBER}"
229+
done
230+
fi
231+
232+
gh pr create \
233+
--base "${MATRIX_BRANCH}" \
234+
--head "${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}" \
235+
--title "$PR_TITLE" \
236+
--assignee "${GITHUB_ACTOR}" \
237+
--body "$(echo -e "$BODY")"

0 commit comments

Comments
 (0)