Skip to content

Commit e1ae960

Browse files
ci: refactor cherry-pick workflow to backport individual commits
Signed-off-by: Karthik Vetrivel <[email protected]>
1 parent 855aee7 commit e1ae960

File tree

4 files changed

+331
-320
lines changed

4 files changed

+331
-320
lines changed

.github/scripts/backport.js

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
module.exports = async ({ github, context, core }) => {
2+
const branches = JSON.parse(process.env.BRANCHES_JSON || '[]');
3+
4+
// Get PR number from event
5+
const prNumber = context.payload.pull_request?.number || context.payload.issue.number;
6+
7+
// Fetch full PR data (needed when triggered via issue_comment)
8+
const { data: pullRequest } = await github.rest.pulls.get({
9+
owner: context.repo.owner,
10+
repo: context.repo.repo,
11+
pull_number: prNumber
12+
});
13+
14+
const prTitle = pullRequest.title;
15+
const prAuthor = pullRequest.user.login;
16+
17+
// Validate PR is merged
18+
if (!pullRequest.merged) {
19+
// If triggered by a comment on an unmerged PR, acknowledge and exit gracefully
20+
if (context.eventName === 'issue_comment') {
21+
core.info('PR is not merged yet. Acknowledging /cherry-pick command and will backport after merge.');
22+
// Add a reaction to the comment
23+
await github.rest.reactions.createForIssueComment({
24+
owner: context.repo.owner,
25+
repo: context.repo.repo,
26+
comment_id: context.payload.comment.id,
27+
content: 'eyes'
28+
});
29+
// Add a comment explaining what will happen
30+
await github.rest.issues.createComment({
31+
owner: context.repo.owner,
32+
repo: context.repo.repo,
33+
issue_number: prNumber,
34+
body: `👀 Backport request acknowledged for: ${branches.map(b => `\`${b}\``).join(', ')}\n\nThe backport PR(s) will be automatically created when this PR is merged.`
35+
});
36+
return [];
37+
}
38+
core.setFailed('PR is not merged yet. Only merged PRs can be backported.');
39+
return;
40+
}
41+
42+
// Get all commits from the PR
43+
const { data: commits } = await github.rest.pulls.listCommits({
44+
owner: context.repo.owner,
45+
repo: context.repo.repo,
46+
pull_number: prNumber
47+
});
48+
49+
if (commits.length === 0) {
50+
core.setFailed('No commits found in PR. This should not happen for merged PRs.');
51+
return;
52+
}
53+
54+
core.info(`Backporting PR #${prNumber}: "${prTitle}"`);
55+
core.info(`Commits to cherry-pick: ${commits.length}`);
56+
commits.forEach((commit, index) => {
57+
core.info(` ${index + 1}. ${commit.sha.substring(0, 7)} - ${commit.commit.message.split('\n')[0]}`);
58+
});
59+
60+
const { execSync } = require('child_process');
61+
62+
const results = [];
63+
64+
for (const targetBranch of branches) {
65+
core.info(`\n========================================`);
66+
core.info(`Backporting to ${targetBranch}`);
67+
core.info(`========================================`);
68+
const backportBranch = `backport-${prNumber}-to-${targetBranch}`;
69+
try {
70+
// Create backport branch from target release branch
71+
core.info(`Creating branch ${backportBranch} from ${targetBranch}`);
72+
execSync(`git fetch origin ${targetBranch}:${targetBranch}`, { stdio: 'inherit' });
73+
execSync(`git checkout -b ${backportBranch} ${targetBranch}`, { stdio: 'inherit' });
74+
// Cherry-pick each commit from the PR
75+
let hasConflicts = false;
76+
for (let i = 0; i < commits.length; i++) {
77+
const commit = commits[i];
78+
const commitSha = commit.sha;
79+
const commitMessage = commit.commit.message.split('\n')[0];
80+
core.info(`Cherry-picking commit ${i + 1}/${commits.length}: ${commitSha.substring(0, 7)} - ${commitMessage}`);
81+
try {
82+
execSync(`git cherry-pick -x ${commitSha}`, {
83+
encoding: 'utf-8',
84+
stdio: 'pipe'
85+
});
86+
} catch (error) {
87+
// Check if it's a conflict
88+
const status = execSync('git status', { encoding: 'utf-8' });
89+
if (status.includes('Unmerged paths') || status.includes('both modified')) {
90+
hasConflicts = true;
91+
core.warning(`Cherry-pick has conflicts for commit ${commitSha.substring(0, 7)}.`);
92+
// Add all files (including conflicted ones) and commit
93+
execSync('git add .', { stdio: 'inherit' });
94+
try {
95+
execSync(`git -c core.editor=true cherry-pick --continue`, { stdio: 'inherit' });
96+
} catch (e) {
97+
// If continue fails, make a simple commit
98+
execSync(`git commit --no-edit --allow-empty-message || git commit -m "Cherry-pick ${commitSha} (with conflicts)"`, { stdio: 'inherit' });
99+
}
100+
} else {
101+
throw error;
102+
}
103+
}
104+
}
105+
// Push the backport branch
106+
core.info(`Pushing ${backportBranch} to origin`);
107+
execSync(`git push origin ${backportBranch}`, { stdio: 'inherit' });
108+
// Create pull request
109+
const commitList = commits.map(c => `- \`${c.sha.substring(0, 7)}\` ${c.commit.message.split('\n')[0]}`).join('\n');
110+
const prBody = hasConflicts
111+
? `🤖 **Automated backport of #${prNumber} to \`${targetBranch}\`**
112+
113+
⚠️ **This PR has merge conflicts that need manual resolution.**
114+
115+
Original PR: #${prNumber}
116+
Original Author: @${prAuthor}
117+
118+
**Cherry-picked commits (${commits.length}):**
119+
${commitList}
120+
121+
**Next Steps:**
122+
1. Review the conflicts in the "Files changed" tab
123+
2. Check out this branch locally: \`git fetch origin ${backportBranch} && git checkout ${backportBranch}\`
124+
3. Resolve conflicts manually
125+
4. Push the resolution: \`git push origin ${backportBranch}\`
126+
127+
---
128+
<details>
129+
<summary>Instructions for resolving conflicts</summary>
130+
131+
\`\`\`bash
132+
git fetch origin ${backportBranch}
133+
git checkout ${backportBranch}
134+
# Resolve conflicts in your editor
135+
git add .
136+
git commit
137+
git push origin ${backportBranch}
138+
\`\`\`
139+
</details>`
140+
: `🤖 **Automated backport of #${prNumber} to \`${targetBranch}\`**
141+
142+
✅ Cherry-pick completed successfully with no conflicts.
143+
144+
Original PR: #${prNumber}
145+
Original Author: @${prAuthor}
146+
147+
**Cherry-picked commits (${commits.length}):**
148+
${commitList}
149+
150+
This backport was automatically created by the backport bot.`;
151+
152+
const newPR = await github.rest.pulls.create({
153+
owner: context.repo.owner,
154+
repo: context.repo.repo,
155+
title: `[${targetBranch}] ${prTitle}`,
156+
head: backportBranch,
157+
base: targetBranch,
158+
body: prBody,
159+
draft: hasConflicts
160+
});
161+
// Add labels
162+
await github.rest.issues.addLabels({
163+
owner: context.repo.owner,
164+
repo: context.repo.repo,
165+
issue_number: newPR.data.number,
166+
labels: ['backport', hasConflicts ? 'needs-manual-resolution' : 'auto-backport']
167+
});
168+
// Link to original PR
169+
await github.rest.issues.createComment({
170+
owner: context.repo.owner,
171+
repo: context.repo.repo,
172+
issue_number: prNumber,
173+
body: `🤖 Backport PR created for \`${targetBranch}\`: #${newPR.data.number} ${hasConflicts ? '⚠️ (has conflicts)' : '✅'}`
174+
});
175+
results.push({
176+
branch: targetBranch,
177+
success: true,
178+
prNumber: newPR.data.number,
179+
prUrl: newPR.data.html_url,
180+
hasConflicts
181+
});
182+
core.info(`✅ Successfully created backport PR #${newPR.data.number}`);
183+
} catch (error) {
184+
core.error(`❌ Failed to backport to ${targetBranch}: ${error.message}`);
185+
// Comment on original PR about the failure
186+
await github.rest.issues.createComment({
187+
owner: context.repo.owner,
188+
repo: context.repo.repo,
189+
issue_number: prNumber,
190+
body: `❌ Failed to create backport PR for \`${targetBranch}\`\n\nError: ${error.message}\n\nPlease backport manually.`
191+
});
192+
results.push({
193+
branch: targetBranch,
194+
success: false,
195+
error: error.message
196+
});
197+
} finally {
198+
// Clean up: go back to main branch
199+
try {
200+
execSync('git checkout main', { stdio: 'inherit' });
201+
execSync(`git branch -D ${backportBranch} 2>/dev/null || true`, { stdio: 'inherit' });
202+
} catch (e) {
203+
// Ignore cleanup errors
204+
}
205+
}
206+
}
207+
208+
// Summary (console only)
209+
core.info('\n========================================');
210+
core.info('Backport Summary');
211+
core.info('========================================');
212+
for (const result of results) {
213+
if (result.success) {
214+
core.info(`✅ ${result.branch}: PR #${result.prNumber} ${result.hasConflicts ? '(has conflicts)' : ''}`);
215+
} else {
216+
core.error(`❌ ${result.branch}: ${result.error}`);
217+
}
218+
}
219+
return results;
220+
};
221+
222+
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
module.exports = async ({ github, context, core }) => {
2+
let branches = [];
3+
4+
// Get PR number
5+
const prNumber = context.payload.pull_request?.number || context.payload.issue?.number;
6+
7+
if (!prNumber) {
8+
core.setFailed('Could not determine PR number from event');
9+
return [];
10+
}
11+
12+
// Check PR body
13+
if (context.payload.pull_request?.body) {
14+
const prBody = context.payload.pull_request.body;
15+
// Enforce release-X.Y or release-X.Y.Z
16+
const bodyMatches = prBody.matchAll(/\/cherry-pick\s+(release-\d+\.\d+(?:\.\d+)?)/g);
17+
branches.push(...Array.from(bodyMatches, m => m[1]));
18+
}
19+
20+
// Check all comments
21+
const comments = await github.rest.issues.listComments({
22+
owner: context.repo.owner,
23+
repo: context.repo.repo,
24+
issue_number: prNumber
25+
});
26+
27+
for (const comment of comments.data) {
28+
const commentMatches = comment.body.matchAll(/\/cherry-pick\s+(release-\d+\.\d+(?:\.\d+)?)/g);
29+
branches.push(...Array.from(commentMatches, m => m[1]));
30+
}
31+
32+
// Deduplicate
33+
branches = [...new Set(branches)];
34+
35+
if (branches.length === 0) {
36+
core.setFailed('No valid release branches found in /cherry-pick comments');
37+
return [];
38+
}
39+
40+
core.info(`Target branches: ${branches.join(', ')}`);
41+
return branches;
42+
};

.github/workflows/cherrypick.yml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Copyright 2025 NVIDIA CORPORATION
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
name: Cherry-Pick
16+
17+
on:
18+
pull_request_target:
19+
types: [closed]
20+
issue_comment:
21+
types: [created]
22+
23+
permissions:
24+
contents: write
25+
pull-requests: write
26+
issues: write
27+
28+
jobs:
29+
backport:
30+
name: Backport PR
31+
runs-on: ubuntu-latest
32+
# Run on merged PRs OR on /cherry-pick comments
33+
if: |
34+
(github.event_name == 'pull_request_target' && github.event.pull_request.merged == true) ||
35+
(github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/cherry-pick'))
36+
37+
steps:
38+
- name: Checkout repository
39+
uses: actions/checkout@v4
40+
with:
41+
fetch-depth: 0
42+
token: ${{ secrets.GITHUB_TOKEN }}
43+
44+
- name: Extract target branches from PR comments
45+
id: extract-branches
46+
uses: actions/github-script@v7
47+
with:
48+
script: |
49+
const run = require('./.github/scripts/extract-branches.js');
50+
return await run({ github, context, core });
51+
52+
- name: Configure git
53+
run: |
54+
git config user.name "nvidia-backport-bot"
55+
git config user.email "[email protected]"
56+
57+
- name: Backport to release branches
58+
id: backport
59+
uses: actions/github-script@v7
60+
env:
61+
BRANCHES_JSON: ${{ steps.extract-branches.outputs.result }}
62+
with:
63+
script: |
64+
const run = require('./.github/scripts/backport.js');
65+
return await run({ github, context, core });
66+
67+

0 commit comments

Comments
 (0)