Skip to content

Commit 01aca7f

Browse files
authored
Merge pull request #1374 from elezar/add-cherry-pick-automation
[no-relnote] Copy cherrypick workflow from gpu-operator repo
2 parents 05fb139 + d548f69 commit 01aca7f

File tree

3 files changed

+425
-0
lines changed

3 files changed

+425
-0
lines changed

.github/scripts/backport.js

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
/**
2+
* Copyright 2025 NVIDIA CORPORATION
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
module.exports = async ({ github, context, core }) => {
18+
const branches = JSON.parse(process.env.BRANCHES_JSON || '[]');
19+
20+
// Get PR number from event
21+
const prNumber = context.payload.pull_request?.number || context.payload.issue.number;
22+
23+
// Fetch full PR data (needed when triggered via issue_comment)
24+
const { data: pullRequest } = await github.rest.pulls.get({
25+
owner: context.repo.owner,
26+
repo: context.repo.repo,
27+
pull_number: prNumber
28+
});
29+
30+
const prTitle = pullRequest.title;
31+
const prAuthor = pullRequest.user.login;
32+
const isMerged = pullRequest.merged;
33+
34+
// Get all commits from the PR
35+
const { data: commits } = await github.rest.pulls.listCommits({
36+
owner: context.repo.owner,
37+
repo: context.repo.repo,
38+
pull_number: prNumber
39+
});
40+
41+
if (commits.length === 0) {
42+
core.warning('No commits found in PR - skipping backport');
43+
return [];
44+
}
45+
46+
core.info(`Backporting PR #${prNumber}: "${prTitle}"`);
47+
core.info(`Commits to cherry-pick: ${commits.length}`);
48+
commits.forEach((commit, index) => {
49+
core.info(` ${index + 1}. ${commit.sha.substring(0, 7)} - ${commit.commit.message.split('\n')[0]}`);
50+
});
51+
52+
const { execSync } = require('child_process');
53+
54+
const results = [];
55+
56+
for (const targetBranch of branches) {
57+
core.info(`\n========================================`);
58+
core.info(`Backporting to ${targetBranch}`);
59+
core.info(`========================================`);
60+
const backportBranch = `backport-${prNumber}-to-${targetBranch}`;
61+
try {
62+
// Create/reset backport branch from target release branch
63+
core.info(`Creating/resetting branch ${backportBranch} from ${targetBranch}`);
64+
execSync(`git fetch origin ${targetBranch}:${targetBranch}`, { stdio: 'inherit' });
65+
execSync(`git checkout -B ${backportBranch} ${targetBranch}`, { stdio: 'inherit' });
66+
// Cherry-pick each commit from the PR
67+
let hasConflicts = false;
68+
for (let i = 0; i < commits.length; i++) {
69+
const commit = commits[i];
70+
const commitSha = commit.sha;
71+
const commitMessage = commit.commit.message.split('\n')[0];
72+
core.info(`Cherry-picking commit ${i + 1}/${commits.length}: ${commitSha.substring(0, 7)} - ${commitMessage}`);
73+
try {
74+
execSync(`git cherry-pick -x ${commitSha}`, {
75+
encoding: 'utf-8',
76+
stdio: 'pipe'
77+
});
78+
} catch (error) {
79+
// Check if it's a conflict
80+
const status = execSync('git status', { encoding: 'utf-8' });
81+
if (status.includes('Unmerged paths') || status.includes('both modified')) {
82+
hasConflicts = true;
83+
core.warning(`Cherry-pick has conflicts for commit ${commitSha.substring(0, 7)}.`);
84+
// Add all files (including conflicted ones) and commit
85+
execSync('git add .', { stdio: 'inherit' });
86+
try {
87+
execSync(`git -c core.editor=true cherry-pick --continue`, { stdio: 'inherit' });
88+
} catch (e) {
89+
// If continue fails, make a simple commit
90+
execSync(`git commit --no-edit --allow-empty-message || git commit -m "Cherry-pick ${commitSha} (with conflicts)"`, { stdio: 'inherit' });
91+
}
92+
} else if (error.message && error.message.includes('previous cherry-pick is now empty')) {
93+
// Handle empty commits (changes already exist in target branch)
94+
core.info(`Commit ${commitSha.substring(0, 7)} is empty (changes already in target branch), skipping`);
95+
execSync('git cherry-pick --skip', { stdio: 'inherit' });
96+
} else {
97+
throw error;
98+
}
99+
}
100+
}
101+
// Push the backport branch (force to handle updates)
102+
core.info(`Pushing ${backportBranch} to origin`);
103+
execSync(`git push --force-with-lease origin ${backportBranch}`, { stdio: 'inherit' });
104+
105+
// Check if a PR already exists for this backport branch
106+
const { data: existingPRs } = await github.rest.pulls.list({
107+
owner: context.repo.owner,
108+
repo: context.repo.repo,
109+
head: `${context.repo.owner}:${backportBranch}`,
110+
base: targetBranch,
111+
state: 'open'
112+
});
113+
const existingPR = existingPRs.length > 0 ? existingPRs[0] : null;
114+
115+
// Create pull request
116+
const commitList = commits.map(c => `- \`${c.sha.substring(0, 7)}\` ${c.commit.message.split('\n')[0]}`).join('\n');
117+
118+
// Build PR body based on conflict status and merge status
119+
let prBody = `🤖 **Automated backport of #${prNumber} to \`${targetBranch}\`**\n\n`;
120+
121+
// Add merge status indicator
122+
if (!isMerged) {
123+
prBody += `⚠️ **Note:** The source PR #${prNumber} is not yet merged. This backport was created from the current state of the PR and may need updates if more commits are added before merge.\n\n`;
124+
}
125+
126+
if (hasConflicts) {
127+
prBody += `⚠️ **This PR has merge conflicts that need manual resolution.**
128+
129+
Original PR: #${prNumber} ${isMerged ? '(merged)' : '(not yet merged)'}
130+
Original Author: @${prAuthor}
131+
132+
**Cherry-picked commits (${commits.length}):**
133+
${commitList}
134+
135+
**Next Steps:**
136+
1. Review the conflicts in the "Files changed" tab
137+
2. Check out this branch locally: \`git fetch origin ${backportBranch} && git checkout ${backportBranch}\`
138+
3. Resolve conflicts manually
139+
4. Push the resolution: \`git push --force-with-lease origin ${backportBranch}\`
140+
141+
---
142+
<details>
143+
<summary>Instructions for resolving conflicts</summary>
144+
145+
\`\`\`bash
146+
git fetch origin ${backportBranch}
147+
git checkout ${backportBranch}
148+
# Resolve conflicts in your editor
149+
git add .
150+
git commit
151+
git push --force-with-lease origin ${backportBranch}
152+
\`\`\`
153+
</details>`;
154+
} else {
155+
prBody += `✅ Cherry-pick completed successfully with no conflicts.
156+
157+
Original PR: #${prNumber} ${isMerged ? '(merged)' : '(not yet merged)'}
158+
Original Author: @${prAuthor}
159+
160+
**Cherry-picked commits (${commits.length}):**
161+
${commitList}
162+
163+
This backport was automatically created by the backport bot.`;
164+
}
165+
166+
if (existingPR) {
167+
// Update existing PR
168+
core.info(`Found existing PR #${existingPR.number}, updating it`);
169+
await github.rest.pulls.update({
170+
owner: context.repo.owner,
171+
repo: context.repo.repo,
172+
pull_number: existingPR.number,
173+
body: prBody,
174+
draft: hasConflicts
175+
});
176+
177+
// Update labels
178+
const currentLabels = existingPR.labels.map(l => l.name);
179+
const desiredLabels = ['backport', hasConflicts ? 'needs-manual-resolution' : 'auto-backport'];
180+
181+
// Remove old labels if conflict status changed
182+
if (hasConflicts && currentLabels.includes('auto-backport')) {
183+
await github.rest.issues.removeLabel({
184+
owner: context.repo.owner,
185+
repo: context.repo.repo,
186+
issue_number: existingPR.number,
187+
name: 'auto-backport'
188+
}).catch(() => {}); // Ignore if label doesn't exist
189+
} else if (!hasConflicts && currentLabels.includes('needs-manual-resolution')) {
190+
await github.rest.issues.removeLabel({
191+
owner: context.repo.owner,
192+
repo: context.repo.repo,
193+
issue_number: existingPR.number,
194+
name: 'needs-manual-resolution'
195+
}).catch(() => {}); // Ignore if label doesn't exist
196+
}
197+
198+
// Add current labels
199+
await github.rest.issues.addLabels({
200+
owner: context.repo.owner,
201+
repo: context.repo.repo,
202+
issue_number: existingPR.number,
203+
labels: desiredLabels
204+
});
205+
206+
// Comment about the update
207+
await github.rest.issues.createComment({
208+
owner: context.repo.owner,
209+
repo: context.repo.repo,
210+
issue_number: prNumber,
211+
body: `🤖 Updated existing backport PR for \`${targetBranch}\`: #${existingPR.number} ${hasConflicts ? '⚠️ (has conflicts)' : '✅'}`
212+
});
213+
214+
results.push({
215+
branch: targetBranch,
216+
success: true,
217+
prNumber: existingPR.number,
218+
prUrl: existingPR.html_url,
219+
hasConflicts,
220+
updated: true
221+
});
222+
core.info(`✅ Successfully updated backport PR #${existingPR.number}`);
223+
} else {
224+
// Create new PR
225+
const newPR = await github.rest.pulls.create({
226+
owner: context.repo.owner,
227+
repo: context.repo.repo,
228+
title: `[${targetBranch}] ${prTitle}`,
229+
head: backportBranch,
230+
base: targetBranch,
231+
body: prBody,
232+
draft: hasConflicts
233+
});
234+
// Add labels
235+
await github.rest.issues.addLabels({
236+
owner: context.repo.owner,
237+
repo: context.repo.repo,
238+
issue_number: newPR.data.number,
239+
labels: ['backport', hasConflicts ? 'needs-manual-resolution' : 'auto-backport']
240+
});
241+
// Link to original PR
242+
await github.rest.issues.createComment({
243+
owner: context.repo.owner,
244+
repo: context.repo.repo,
245+
issue_number: prNumber,
246+
body: `🤖 Backport PR created for \`${targetBranch}\`: #${newPR.data.number} ${hasConflicts ? '⚠️ (has conflicts)' : '✅'}`
247+
});
248+
results.push({
249+
branch: targetBranch,
250+
success: true,
251+
prNumber: newPR.data.number,
252+
prUrl: newPR.data.html_url,
253+
hasConflicts,
254+
updated: false
255+
});
256+
core.info(`✅ Successfully created backport PR #${newPR.data.number}`);
257+
}
258+
} catch (error) {
259+
core.error(`❌ Failed to backport to ${targetBranch}: ${error.message}`);
260+
// Comment on original PR about the failure
261+
await github.rest.issues.createComment({
262+
owner: context.repo.owner,
263+
repo: context.repo.repo,
264+
issue_number: prNumber,
265+
body: `❌ Failed to create backport PR for \`${targetBranch}\`\n\nError: ${error.message}\n\nPlease backport manually.`
266+
});
267+
results.push({
268+
branch: targetBranch,
269+
success: false,
270+
error: error.message
271+
});
272+
} finally {
273+
// Clean up: go back to main branch
274+
try {
275+
execSync('git checkout main', { stdio: 'inherit' });
276+
execSync(`git branch -D ${backportBranch} 2>/dev/null || true`, { stdio: 'inherit' });
277+
} catch (e) {
278+
// Ignore cleanup errors
279+
}
280+
}
281+
}
282+
283+
// Summary (console only)
284+
core.info('\n========================================');
285+
core.info('Backport Summary');
286+
core.info('========================================');
287+
for (const result of results) {
288+
if (result.success) {
289+
const action = result.updated ? 'Updated' : 'Created';
290+
core.info(`✅ ${result.branch}: ${action} PR #${result.prNumber} ${result.hasConflicts ? '(has conflicts)' : ''}`);
291+
} else {
292+
core.error(`❌ ${result.branch}: ${result.error}`);
293+
}
294+
}
295+
return results;
296+
};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Copyright 2025 NVIDIA CORPORATION
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
module.exports = async ({ github, context, core }) => {
18+
let branches = [];
19+
20+
// Get PR number
21+
const prNumber = context.payload.pull_request?.number || context.payload.issue?.number;
22+
23+
if (!prNumber) {
24+
core.warning('Could not determine PR number from event - skipping backport');
25+
return [];
26+
}
27+
28+
// Check PR body
29+
if (context.payload.pull_request?.body) {
30+
const prBody = context.payload.pull_request.body;
31+
// Strict ASCII, anchored; allow X.Y or X.Y.Z
32+
// Support multiple space-separated branches on one line
33+
const lineMatches = prBody.matchAll(/^\/cherry-pick\s+(.+)$/gmi);
34+
for (const match of lineMatches) {
35+
const branchMatches = match[1].matchAll(/release-\d+\.\d+(?:\.\d+)?/g);
36+
branches.push(...Array.from(branchMatches, m => m[0]));
37+
}
38+
}
39+
40+
// Check all comments
41+
const comments = await github.rest.issues.listComments({
42+
owner: context.repo.owner,
43+
repo: context.repo.repo,
44+
issue_number: prNumber
45+
});
46+
47+
for (const comment of comments.data) {
48+
const lineMatches = comment.body.matchAll(/^\/cherry-pick\s+(.+)$/gmi);
49+
for (const match of lineMatches) {
50+
const branchMatches = match[1].matchAll(/release-\d+\.\d+(?:\.\d+)?/g);
51+
branches.push(...Array.from(branchMatches, m => m[0]));
52+
}
53+
}
54+
55+
// Deduplicate
56+
branches = [...new Set(branches)];
57+
58+
if (branches.length === 0) {
59+
core.info('No cherry-pick requests found - skipping backport');
60+
return [];
61+
}
62+
63+
core.info(`Target branches: ${branches.join(', ')}`);
64+
return branches;
65+
};

0 commit comments

Comments
 (0)