Skip to content

Commit 45e44dd

Browse files
committed
feat: adds a backport script
1 parent 07552f5 commit 45e44dd

2 files changed

Lines changed: 244 additions & 0 deletions

File tree

.github/workflows/backport.yml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
name: Backport
2+
3+
on:
4+
pull_request_target:
5+
types: [closed, labeled]
6+
# TODO: Remove before merging — manual trigger for testing from any branch
7+
workflow_dispatch:
8+
inputs:
9+
pr_number:
10+
description: 'Merged PR number to test backporting'
11+
required: true
12+
type: number
13+
14+
permissions:
15+
contents: write
16+
pull-requests: write
17+
18+
jobs:
19+
backport:
20+
name: Backport
21+
runs-on: ubuntu-latest
22+
# Run when a labeled PR is merged, or when a backport label is added to an already-merged PR.
23+
# Uses pull_request_target so the token has write access even for PRs from forks.
24+
if: >-
25+
github.repository_owner == 'npm' &&
26+
github.event.pull_request.merged == true &&
27+
(
28+
(github.event.action == 'closed' &&
29+
contains(join(github.event.pull_request.labels.*.name, ','), 'backport:'))
30+
||
31+
(github.event.action == 'labeled' &&
32+
startsWith(github.event.label.name, 'backport:'))
33+
)
34+
defaults:
35+
run:
36+
shell: bash
37+
steps:
38+
- name: Checkout
39+
uses: actions/checkout@v6
40+
with:
41+
fetch-depth: 0
42+
- name: Setup Git User
43+
run: |
44+
git config --global user.email "npm-cli+bot@github.com"
45+
git config --global user.name "npm CLI robot"
46+
- name: Create Backports
47+
uses: actions/github-script@v7
48+
env:
49+
MERGE_COMMIT_SHA: ${{ github.event.pull_request.merge_commit_sha }}
50+
with:
51+
script: |
52+
const backport = require('./scripts/backport.js')
53+
await backport({ github, context, core })
54+
55+
# TODO: Remove before merging — manual test job
56+
backport-test:
57+
name: Backport (Test)
58+
if: github.event_name == 'workflow_dispatch'
59+
runs-on: ubuntu-latest
60+
defaults:
61+
run:
62+
shell: bash
63+
steps:
64+
- name: Checkout
65+
uses: actions/checkout@v6
66+
with:
67+
fetch-depth: 0
68+
- name: Setup Git User
69+
run: |
70+
git config --global user.email "npm-cli+bot@github.com"
71+
git config --global user.name "npm CLI robot"
72+
- name: Create Backports
73+
uses: actions/github-script@v7
74+
with:
75+
script: |
76+
const { data: pr } = await github.rest.pulls.get({
77+
owner: context.repo.owner,
78+
repo: context.repo.repo,
79+
pull_number: ${{ inputs.pr_number }},
80+
})
81+
82+
if (!pr.merged) {
83+
return core.setFailed('PR #${{ inputs.pr_number }} is not merged')
84+
}
85+
86+
process.env.MERGE_COMMIT_SHA = pr.merge_commit_sha
87+
88+
const backport = require('./scripts/backport.js')
89+
await backport({
90+
github,
91+
core,
92+
context: {
93+
...context,
94+
payload: { action: 'closed', pull_request: pr },
95+
},
96+
})

scripts/backport.js

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
const { execFileSync } = require('node:child_process')
2+
3+
module.exports = async ({ github, context, core }) => {
4+
const pr = context.payload.pull_request
5+
const sha = process.env.MERGE_COMMIT_SHA
6+
const { owner, repo } = context.repo
7+
8+
// For 'labeled' events, only process the newly added label.
9+
// For 'closed' (merged) events, process all backport labels on the PR.
10+
const labels = context.payload.action === 'labeled'
11+
? [context.payload.label.name].filter(n => n.startsWith('backport:'))
12+
: pr.labels.map(l => l.name).filter(n => n.startsWith('backport:'))
13+
14+
if (!labels.length) {
15+
core.info('No backport labels found, nothing to do')
16+
return
17+
}
18+
19+
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim()
20+
21+
// Build cherry-pick args based on merge strategy:
22+
// - Merge commit (2 parents): cherry-pick -m 1
23+
// - Squash (1 parent, single commit): cherry-pick that commit
24+
// - Rebase (1 parent, N commits rebased onto base): cherry-pick the full range to preserve all conventional commits
25+
const cherryPickArgs = await (async () => {
26+
const parentCount = git('cat-file', '-p', sha)
27+
.split('\n')
28+
.filter(l => l.startsWith('parent ')).length
29+
30+
if (parentCount > 1) {
31+
core.info('Detected merge commit')
32+
return ['-x', '-m', '1', sha]
33+
}
34+
35+
if (pr.commits > 1) {
36+
// Could be squash or rebase. Rebase preserves commit subjects, squash does not.
37+
const { data: prCommits } = await github.rest.pulls.listCommits({
38+
owner, repo, pull_number: pr.number, per_page: 100,
39+
})
40+
const prSubjects = prCommits.map(c => c.commit.message.split('\n')[0])
41+
try {
42+
const branchSubjects = git('log', '--format=%s', '--reverse', `${sha}~${prCommits.length}..${sha}`)
43+
.split('\n')
44+
if (
45+
branchSubjects.length === prSubjects.length &&
46+
branchSubjects.every((s, i) => s === prSubjects[i])
47+
) {
48+
core.info(`Detected rebase merge with ${prCommits.length} commits`)
49+
return ['-x', `${sha}~${prCommits.length}..${sha}`]
50+
}
51+
} catch {
52+
// Fall through to squash
53+
}
54+
core.info('Detected squash merge')
55+
}
56+
57+
return ['-x', sha]
58+
})()
59+
60+
const startRef = git('rev-parse', 'HEAD')
61+
const results = []
62+
63+
for (const label of labels) {
64+
const version = label.replace('backport:', '')
65+
const target = `release/${version}`
66+
const branch = `backport/${version}/${pr.number}`
67+
68+
try {
69+
// Target branch is available locally from fetch-depth: 0
70+
try {
71+
git('rev-parse', '--verify', `refs/remotes/origin/${target}`)
72+
} catch {
73+
throw new Error(`Target branch \`${target}\` does not exist`)
74+
}
75+
76+
// Backport branch requires ls-remote since a parallel run may have created it after our checkout
77+
try {
78+
git('ls-remote', '--exit-code', 'origin', `refs/heads/${branch}`)
79+
core.info(`Branch ${branch} already exists, skipping`)
80+
continue
81+
} catch {
82+
// Expected — branch doesn't exist yet
83+
}
84+
85+
git('checkout', '-b', branch, `origin/${target}`)
86+
git('cherry-pick', ...cherryPickArgs)
87+
git('push', 'origin', branch)
88+
89+
const { data: backportPr } = await github.rest.pulls.create({
90+
owner,
91+
repo,
92+
title: pr.title,
93+
body: `Backport of #${pr.number} to \`${target}\`.`,
94+
head: branch,
95+
base: target,
96+
})
97+
98+
results.push(`🎉 Backport to \`${target}\` created: #${backportPr.number}`)
99+
core.info(`Created backport PR #${backportPr.number} for ${target}`)
100+
} catch (error) {
101+
core.error(`Backport to ${target} failed: ${error.message}`)
102+
103+
results.push([
104+
`⚠️ Backport to \`${target}\` failed.`,
105+
'',
106+
'This usually means the cherry-pick had conflicts. Please create a manual backport:',
107+
'',
108+
'```sh',
109+
`git fetch origin ${target}`,
110+
`git checkout -b ${branch} origin/${target}`,
111+
`git cherry-pick ${cherryPickArgs.join(' ')}`,
112+
'# resolve any conflicts, then:',
113+
`git push origin ${branch}`,
114+
'```',
115+
'',
116+
'<details><summary>Error details</summary>',
117+
'',
118+
'```',
119+
error.message,
120+
'```',
121+
'',
122+
'</details>',
123+
].join('\n'))
124+
} finally {
125+
try {
126+
git('cherry-pick', '--abort')
127+
} catch { /* noop */ }
128+
git('checkout', '-f', startRef)
129+
try {
130+
git('branch', '-D', branch)
131+
} catch { /* noop */ }
132+
}
133+
}
134+
135+
if (results.length) {
136+
await github.rest.issues.createComment({
137+
owner,
138+
repo,
139+
issue_number: pr.number,
140+
body: results.join('\n\n---\n\n'),
141+
})
142+
}
143+
144+
const failures = results.filter(r => r.startsWith('⚠️')).length
145+
if (failures) {
146+
core.setFailed(`${failures} backport(s) failed`)
147+
}
148+
}

0 commit comments

Comments
 (0)