Skip to content

Commit 2cd9fab

Browse files
committed
Add a workflow to create backport PRs.
1 parent 538331d commit 2cd9fab

1 file changed

Lines changed: 369 additions & 0 deletions

File tree

.github/workflows/backport.yml

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
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. 6.9). Defaults to the current supported branch.'
8+
required: false
9+
type: string
10+
default: '7.0'
11+
pr-name:
12+
description: 'Pull request name (format is "<pr-name> - <branch> branch".'
13+
required: false
14+
type: string
15+
default: ''
16+
commit-sha:
17+
description: 'Full length commit hash to stage for backport.'
18+
required: false
19+
type: string
20+
default: ''
21+
pr_numbers:
22+
description: 'Comma-separated PR numbers. Ignored when a SHA is provided.'
23+
required: false
24+
type: string
25+
default: ''
26+
repo-source:
27+
description: 'Repository to merge changes from.'
28+
required: false
29+
type: choice
30+
default: 'upstream'
31+
options:
32+
- upstream
33+
- current
34+
pr-target:
35+
description: 'Repository to submit pull requests to.'
36+
required: false
37+
type: choice
38+
default: 'current'
39+
options:
40+
- upstream
41+
- current
42+
43+
# Disable permissions for all available scopes by default.
44+
# Any needed permissions should be configured at the job level.
45+
permissions: {}
46+
47+
jobs:
48+
validate-inputs:
49+
name: Validate inputs
50+
runs-on: ubuntu-24.04
51+
steps:
52+
- name: Ensure a commit SHA or PR numbers are provided
53+
env:
54+
INPUTS_COMMIT_SHA: ${{ inputs.commit-sha }}
55+
INPUTS_PR_NUMBERS: ${{ inputs.pr_numbers }}
56+
run: |
57+
if [ -z "${INPUTS_COMMIT_SHA}" ] && [ -z "${INPUTS_PR_NUMBERS}" ]; then
58+
echo "::error::A commit SHA or PR number(s) must be included."
59+
exit 1
60+
fi
61+
62+
get-branches:
63+
name: Get target branches
64+
needs: [ 'validate-inputs' ]
65+
runs-on: ubuntu-24.04
66+
outputs:
67+
branches: ${{ steps.branches.outputs.result }}
68+
steps:
69+
- name: Checkout repository
70+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
71+
with:
72+
persist-credentials: false
73+
74+
# Read keys from .version-support-php.json, filter to those >= end_branch,
75+
# convert dashes to dots, and sort numerically descending.
76+
# The first key is always the version in active development on trunk, so skip it.
77+
- name: Get target branches
78+
id: branches
79+
env:
80+
INPUTS_END_BRANCH: ${{ inputs.end_branch }}
81+
run: |
82+
END_X=$(echo "${INPUTS_END_BRANCH}" | cut -d. -f1)
83+
END_Y=$(echo "${INPUTS_END_BRANCH}" | cut -d. -f2)
84+
85+
BRANCHES=$(jq -c \
86+
--argjson x "$END_X" \
87+
--argjson y "$END_Y" \
88+
'[ keys[] |
89+
. as $k | ($k | split("-")) as $p |
90+
select( ($p[0]|tonumber) > $x or
91+
(($p[0]|tonumber) == $x and ($p[1]|tonumber) >= $y) ) |
92+
{ v: ($k | gsub("-"; ".")), x: ($p[0]|tonumber), y: ($p[1]|tonumber) }
93+
] | sort_by(.x, .y) | reverse | .[1:] | map(.v)' \
94+
.version-support-php.json)
95+
96+
echo "result=$BRANCHES" >> "$GITHUB_OUTPUT"
97+
98+
backport:
99+
name: 'Backport to ${{ matrix.branch }}'
100+
needs: [ 'validate-inputs', 'get-branches' ]
101+
if: ${{ needs.get-branches.outputs.branches != '[]' }}
102+
runs-on: ubuntu-24.04
103+
permissions:
104+
contents: write
105+
pull-requests: write
106+
strategy:
107+
fail-fast: false
108+
matrix:
109+
branch: ${{ fromJson( needs.get-branches.outputs.branches ) }}
110+
steps:
111+
- name: Checkout repository
112+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
113+
with:
114+
fetch-depth: 0
115+
persist-credentials: 'true'
116+
117+
- name: Set up git identity
118+
run: |
119+
git config user.name "github-actions[bot]"
120+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
121+
122+
- name: Add upstream remote
123+
id: upstream
124+
env:
125+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
126+
run: |
127+
UPSTREAM=$(gh repo view "${{ github.repository }}" --json parent --jq 'if .parent then "\(.parent.owner.login)/\(.parent.name)" else empty end')
128+
if [ -n "$UPSTREAM" ]; then
129+
git remote add upstream "https://github.com/${UPSTREAM}.git"
130+
git fetch upstream
131+
echo "repo=$UPSTREAM" >> "$GITHUB_OUTPUT"
132+
else
133+
echo "repo=${{ github.repository }}" >> "$GITHUB_OUTPUT"
134+
fi
135+
136+
# Determine the name of the branch for the pull request.
137+
#
138+
# 1. pr-name (normalized to alphanumeric, hyphens, and periods only)
139+
# 2. commit-sha
140+
# 3. pr_numbers with commas replaced by hyphens
141+
- name: Determine backport branch name
142+
id: backport-branch
143+
env:
144+
INPUTS_PR_NAME: ${{ inputs.pr-name }}
145+
MATRIX_BRANCH: ${{ matrix.branch }}
146+
INPUTS_COMMIT_SHA: ${{ inputs.commit-sha }}
147+
INPUTS_PR_NUMBERS: ${{ inputs.pr_numbers }}
148+
run: |
149+
if [ -n "${INPUTS_PR_NAME}" ]; then
150+
echo "name=backport/${MATRIX_BRANCH}-$(echo "${INPUTS_PR_NAME}" | tr -cs '[:alnum:].-' '-' | sed 's/^-//;s/-$//')" >> "$GITHUB_OUTPUT"
151+
elif [ -n "${INPUTS_COMMIT_SHA}" ]; then
152+
echo "name=backport/${MATRIX_BRANCH}-${INPUTS_COMMIT_SHA}" >> "$GITHUB_OUTPUT"
153+
else
154+
echo "name=backport/${MATRIX_BRANCH}-$(echo "${INPUTS_PR_NUMBERS}" | tr -d ' ' | tr ',' '-')" >> "$GITHUB_OUTPUT"
155+
fi
156+
157+
- name: Create backport branch
158+
env:
159+
STEPS_BACKPORT_BRANCH_OUTPUTS_NAME: ${{ steps.backport-branch.outputs.name }}
160+
MATRIX_BRANCH: ${{ matrix.branch }}
161+
run: |
162+
if git ls-remote --exit-code --heads origin "${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}" > /dev/null 2>&1; then
163+
echo "::error::Branch '${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}' already exists on origin."
164+
exit 1
165+
fi
166+
167+
git checkout -b "${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}" "origin/${MATRIX_BRANCH}"
168+
169+
- name: Cherry-pick commit
170+
if: ${{ inputs['commit-sha'] != '' }}
171+
env:
172+
INPUTS_COMMIT_SHA: ${{ inputs.commit-sha }}
173+
run: |
174+
COMMIT="${INPUTS_COMMIT_SHA}"
175+
PARENTS=$(git cat-file -p "$COMMIT" | grep -c '^parent ' || true)
176+
177+
if [ "$PARENTS" -gt 1 ]; then
178+
git cherry-pick -m 1 "$COMMIT"
179+
else
180+
git cherry-pick "$COMMIT"
181+
fi
182+
183+
- name: Merge PRs
184+
id: merge-prs
185+
if: ${{ inputs['commit-sha'] == '' && inputs.pr_numbers != '' }}
186+
env:
187+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
188+
STEPS_UPSTREAM_OUTPUTS_REPO: ${{ steps.upstream.outputs.repo }}
189+
INPUTS_PR_NUMBERS: ${{ inputs.pr_numbers }}
190+
INPUTS_REPO_SOURCE: ${{ inputs.repo-source }}
191+
run: |
192+
if [ "${INPUTS_REPO_SOURCE}" = "upstream" ]; then
193+
PR_REPO="${STEPS_UPSTREAM_OUTPUTS_REPO}"
194+
else
195+
PR_REPO="${GITHUB_REPOSITORY}"
196+
fi
197+
198+
IFS=',' read -ra PR_LIST <<< "${INPUTS_PR_NUMBERS}"
199+
200+
UPSTREAM_URL="https://github.com/${STEPS_UPSTREAM_OUTPUTS_REPO}.git"
201+
RESULTS=""
202+
FAILED=false
203+
204+
for PR_NUMBER in "${PR_LIST[@]}"; do
205+
PR_NUMBER=$(echo "$PR_NUMBER" | tr -d ' ')
206+
207+
PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$PR_REPO" --json title,mergeCommit,baseRefName)
208+
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
209+
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
210+
211+
set +e
212+
if [ -n "$MERGE_COMMIT" ] && [ "$MERGE_COMMIT" != "null" ]; then
213+
# PR is merged: cherry-pick its merge commit.
214+
# Determine if it is a merge commit or squash commit.
215+
PARENTS=$(git cat-file -p "$MERGE_COMMIT" | grep -c '^parent ' || true)
216+
217+
if [ "$PARENTS" -gt 1 ]; then
218+
git cherry-pick -m 1 --no-commit "$MERGE_COMMIT"
219+
else
220+
git cherry-pick --no-commit "$MERGE_COMMIT"
221+
fi
222+
else
223+
# PR is open or closed without merging: apply its changes as a diff
224+
# against the point where it diverged from its base branch.
225+
BASE_REF=$(echo "$PR_DATA" | jq -r '.baseRefName')
226+
227+
git fetch "$UPSTREAM_URL" "$BASE_REF"
228+
BASE_SHA=$(git rev-parse FETCH_HEAD)
229+
230+
git fetch "$UPSTREAM_URL" "refs/pull/${PR_NUMBER}/head"
231+
PR_HEAD_SHA=$(git rev-parse FETCH_HEAD)
232+
233+
MERGE_BASE=$(git merge-base "$PR_HEAD_SHA" "$BASE_SHA")
234+
git diff "$MERGE_BASE" "$PR_HEAD_SHA" | git apply --index
235+
fi
236+
APPLY_EXIT=$?
237+
set -e
238+
239+
if [ $APPLY_EXIT -eq 0 ]; then
240+
git commit -m "$PR_TITLE"
241+
RESULTS="${RESULTS}${PR_NUMBER}=✅ "
242+
else
243+
git cherry-pick --abort 2>/dev/null || git reset --hard HEAD
244+
RESULTS="${RESULTS}${PR_NUMBER}=❌ "
245+
FAILED=true
246+
break
247+
fi
248+
done
249+
250+
echo "results=${RESULTS}" >> "$GITHUB_OUTPUT"
251+
252+
if [ "$FAILED" = "true" ]; then
253+
exit 1
254+
fi
255+
256+
- name: Push backport branch
257+
env:
258+
STEPS_BACKPORT_BRANCH_OUTPUTS_NAME: ${{ steps.backport-branch.outputs.name }}
259+
run: git push -u origin "${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}"
260+
261+
- name: Create pull request
262+
id: create-pr
263+
env:
264+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
265+
STEPS_UPSTREAM_OUTPUTS_REPO: ${{ steps.upstream.outputs.repo }}
266+
INPUTS_PR_NAME: ${{ inputs.pr-name }}
267+
MATRIX_BRANCH: ${{ matrix.branch }}
268+
INPUTS_COMMIT_SHA: ${{ inputs.commit-sha }}
269+
INPUTS_PR_NUMBERS: ${{ inputs.pr_numbers }}
270+
INPUTS_REPO_SOURCE: ${{ inputs.repo-source }}
271+
INPUTS_PR_TARGET: ${{ inputs.pr-target }}
272+
STEPS_BACKPORT_BRANCH_OUTPUTS_NAME: ${{ steps.backport-branch.outputs.name }}
273+
run: |
274+
if [ "${INPUTS_REPO_SOURCE}" = "upstream" ]; then
275+
PR_REPO="${STEPS_UPSTREAM_OUTPUTS_REPO}"
276+
else
277+
PR_REPO="${GITHUB_REPOSITORY}"
278+
fi
279+
280+
if [ -n "${INPUTS_PR_NAME}" ]; then
281+
PR_TITLE="${INPUTS_PR_NAME} - ${MATRIX_BRANCH} branch"
282+
else
283+
PR_TITLE="Backport to ${MATRIX_BRANCH}"
284+
fi
285+
286+
if [ -n "${INPUTS_COMMIT_SHA}" ]; then
287+
BODY="This pull request backports \`${INPUTS_COMMIT_SHA}\` (https://github.com/${STEPS_UPSTREAM_OUTPUTS_REPO}/commit/${INPUTS_COMMIT_SHA}) to the \`${MATRIX_BRANCH}\` branch."
288+
else
289+
BODY="Backports to the \`${MATRIX_BRANCH}\` branch."
290+
fi
291+
292+
BODY="${BODY}\n\n## Changes Included\n"
293+
294+
if [ -n "${INPUTS_COMMIT_SHA}" ]; then
295+
COMMIT_MESSAGE=$(git log --format=%B -n 1 "${INPUTS_COMMIT_SHA}")
296+
BLOCKQUOTE=$(echo "${COMMIT_MESSAGE}" | sed 's/^/> /')
297+
BODY="${BODY}\n${BLOCKQUOTE}"
298+
fi
299+
300+
if [ -n "${INPUTS_PR_NUMBERS}" ] && [ -z "${INPUTS_COMMIT_SHA}" ]; then
301+
IFS=',' read -ra PR_LIST <<< "${INPUTS_PR_NUMBERS}"
302+
for PR_NUMBER in "${PR_LIST[@]}"; do
303+
PR_NUMBER=$(echo "$PR_NUMBER" | tr -d ' ')
304+
BODY="${BODY}\n- ${PR_REPO}#${PR_NUMBER}"
305+
done
306+
fi
307+
308+
if [ "${INPUTS_PR_TARGET}" = "upstream" ]; then
309+
PR_REPO="${STEPS_UPSTREAM_OUTPUTS_REPO}"
310+
PR_HEAD="${GITHUB_REPOSITORY_OWNER}:${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}"
311+
else
312+
PR_REPO="${GITHUB_REPOSITORY}"
313+
PR_HEAD="${STEPS_BACKPORT_BRANCH_OUTPUTS_NAME}"
314+
fi
315+
316+
PR_URL=$(gh pr create \
317+
--repo "${PR_REPO}" \
318+
--base "${MATRIX_BRANCH}" \
319+
--head "${PR_HEAD}" \
320+
--title "$PR_TITLE" \
321+
--assignee "${GITHUB_ACTOR}" \
322+
--draft \
323+
--body "$(echo -e "$BODY")")
324+
325+
if gh label list --repo "${PR_REPO}" --json name --jq '[.[].name] | contains(["Auto-backport"])' | grep -q 'true'; then
326+
gh pr edit "$PR_URL" --repo "${PR_REPO}" --add-label 'Auto-backport'
327+
else
328+
echo "::notice::The 'Auto-backport' label does not exist on ${PR_REPO}. Consider adding it so that backport pull requests can be identified easily."
329+
fi
330+
331+
echo "url=${PR_URL}" >> "$GITHUB_OUTPUT"
332+
333+
- name: Write job summary
334+
if: always()
335+
env:
336+
MATRIX_BRANCH: ${{ matrix.branch }}
337+
INPUTS_COMMIT_SHA: ${{ inputs.commit-sha }}
338+
INPUTS_PR_NUMBERS: ${{ inputs.pr_numbers }}
339+
STEPS_MERGE_PRS_OUTPUTS_RESULTS: ${{ steps.merge-prs.outputs.results }}
340+
STEPS_CREATE_PR_OUTPUTS_URL: ${{ steps.create-pr.outputs.url }}
341+
run: |
342+
PR_DISPLAY="${STEPS_CREATE_PR_OUTPUTS_URL:-N/A}"
343+
344+
if [ -n "${INPUTS_PR_NUMBERS}" ] && [ -z "${INPUTS_COMMIT_SHA}" ]; then
345+
IFS=',' read -ra PR_LIST <<< "${INPUTS_PR_NUMBERS}"
346+
347+
HEADER="| Branch |"
348+
SEPARATOR="| :--- |"
349+
for PR_NUM in "${PR_LIST[@]}"; do
350+
PR_NUM=$(echo "$PR_NUM" | tr -d ' ')
351+
HEADER="${HEADER} #${PR_NUM} |"
352+
SEPARATOR="${SEPARATOR} :---: |"
353+
done
354+
HEADER="${HEADER} Pull Request |"
355+
SEPARATOR="${SEPARATOR} :--- |"
356+
357+
ROW="| \`${MATRIX_BRANCH}\` |"
358+
for PR_NUM in "${PR_LIST[@]}"; do
359+
PR_NUM=$(echo "$PR_NUM" | tr -d ' ')
360+
STATUS=$(echo "${STEPS_MERGE_PRS_OUTPUTS_RESULTS}" | tr ' ' '\n' | grep "^${PR_NUM}=" | cut -d= -f2)
361+
ROW="${ROW} ${STATUS:-❌} |"
362+
done
363+
ROW="${ROW} ${PR_DISPLAY} |"
364+
365+
printf '%s\n%s\n%s\n' "$HEADER" "$SEPARATOR" "$ROW" >> "$GITHUB_STEP_SUMMARY"
366+
else
367+
printf '| Branch | Pull Request |\n| :--- | :--- |\n| `%s` | %s |\n' \
368+
"${MATRIX_BRANCH}" "${PR_DISPLAY}" >> "$GITHUB_STEP_SUMMARY"
369+
fi

0 commit comments

Comments
 (0)