Skip to content

Commit 5e85014

Browse files
committed
[no-relnote] Copy cherrypick workflow from gpu-operator repo
This captures the state at dc6d485437b99af456c8444f6382bbc5e3e76a43 Signed-off-by: Evan Lezar <[email protected]>
1 parent 05fb139 commit 5e85014

File tree

3 files changed

+361
-0
lines changed

3 files changed

+361
-0
lines changed

.github/scripts/backport.js

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

.github/workflows/cherrypick.yml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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@v8
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@v8
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 });

0 commit comments

Comments
 (0)