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