Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions .github/scripts/backport.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* Copyright 2025 NVIDIA CORPORATION
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

module.exports = async ({ github, context, core }) => {
const branches = JSON.parse(process.env.BRANCHES_JSON || '[]');

// Get PR number from event
const prNumber = context.payload.pull_request?.number || context.payload.issue.number;

// Fetch full PR data (needed when triggered via issue_comment)
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});

const prTitle = pullRequest.title;
const prAuthor = pullRequest.user.login;

// Validate PR is merged
if (!pullRequest.merged) {
// If triggered by a comment on an unmerged PR, acknowledge and exit gracefully
if (context.eventName === 'issue_comment') {
core.info('PR is not merged yet. Acknowledging /cherry-pick command and will backport after merge.');
// Add a reaction to the comment
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes'
});
// Add a comment explaining what will happen
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `👀 Backport request acknowledged for: ${branches.map(b => `\`${b}\``).join(', ')}\n\nThe backport PR(s) will be automatically created when this PR is merged.`
});
return [];
}
core.setFailed('PR is not merged yet. Only merged PRs can be backported.');
return;
}

// Get all commits from the PR
const { data: commits } = await github.rest.pulls.listCommits({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});

if (commits.length === 0) {
core.setFailed('No commits found in PR. This should not happen for merged PRs.');
return;
}

core.info(`Backporting PR #${prNumber}: "${prTitle}"`);
core.info(`Commits to cherry-pick: ${commits.length}`);
commits.forEach((commit, index) => {
core.info(` ${index + 1}. ${commit.sha.substring(0, 7)} - ${commit.commit.message.split('\n')[0]}`);
});

const { execSync } = require('child_process');

const results = [];

for (const targetBranch of branches) {
core.info(`\n========================================`);
core.info(`Backporting to ${targetBranch}`);
core.info(`========================================`);
const backportBranch = `backport-${prNumber}-to-${targetBranch}`;
try {
// Create backport branch from target release branch
core.info(`Creating branch ${backportBranch} from ${targetBranch}`);
execSync(`git fetch origin ${targetBranch}:${targetBranch}`, { stdio: 'inherit' });
execSync(`git checkout ${backportBranch} || git checkout -b ${backportBranch} ${targetBranch}`, { stdio: 'inherit' });
// Cherry-pick each commit from the PR
let hasConflicts = false;
for (let i = 0; i < commits.length; i++) {
const commit = commits[i];
const commitSha = commit.sha;
const commitMessage = commit.commit.message.split('\n')[0];
core.info(`Cherry-picking commit ${i + 1}/${commits.length}: ${commitSha.substring(0, 7)} - ${commitMessage}`);
try {
execSync(`git cherry-pick -x ${commitSha}`, {
encoding: 'utf-8',
stdio: 'pipe'
});
} catch (error) {
// Check if it's a conflict
const status = execSync('git status', { encoding: 'utf-8' });
if (status.includes('Unmerged paths') || status.includes('both modified')) {
hasConflicts = true;
core.warning(`Cherry-pick has conflicts for commit ${commitSha.substring(0, 7)}.`);
// Add all files (including conflicted ones) and commit
execSync('git add .', { stdio: 'inherit' });
try {
execSync(`git -c core.editor=true cherry-pick --continue`, { stdio: 'inherit' });
} catch (e) {
// If continue fails, make a simple commit
execSync(`git commit --no-edit --allow-empty-message || git commit -m "Cherry-pick ${commitSha} (with conflicts)"`, { stdio: 'inherit' });
}
} else {
throw error;
}
}
}
// Push the backport branch
core.info(`Pushing ${backportBranch} to origin`);
execSync(`git push origin ${backportBranch}`, { stdio: 'inherit' });
// Create pull request
const commitList = commits.map(c => `- \`${c.sha.substring(0, 7)}\` ${c.commit.message.split('\n')[0]}`).join('\n');

// Build PR body based on conflict status
let prBody = `🤖 **Automated backport of #${prNumber} to \`${targetBranch}\`**\n\n`;

if (hasConflicts) {
prBody += `⚠️ **This PR has merge conflicts that need manual resolution.**

Original PR: #${prNumber}
Original Author: @${prAuthor}

**Cherry-picked commits (${commits.length}):**
${commitList}

**Next Steps:**
1. Review the conflicts in the "Files changed" tab
2. Check out this branch locally: \`git fetch origin ${backportBranch} && git checkout ${backportBranch}\`
3. Resolve conflicts manually
4. Push the resolution: \`git push --force-with-lease origin ${backportBranch}\`

---
<details>
<summary>Instructions for resolving conflicts</summary>

\`\`\`bash
git fetch origin ${backportBranch}
git checkout ${backportBranch}
# Resolve conflicts in your editor
git add .
git commit
git push --force-with-lease origin ${backportBranch}
\`\`\`
</details>`;
} else {
prBody += `✅ Cherry-pick completed successfully with no conflicts.

Original PR: #${prNumber}
Original Author: @${prAuthor}

**Cherry-picked commits (${commits.length}):**
${commitList}

This backport was automatically created by the backport bot.`;
}

const newPR = await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[${targetBranch}] ${prTitle}`,
head: backportBranch,
base: targetBranch,
body: prBody,
draft: hasConflicts
});
// Add labels
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: newPR.data.number,
labels: ['backport', hasConflicts ? 'needs-manual-resolution' : 'auto-backport']
});
// Link to original PR
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `🤖 Backport PR created for \`${targetBranch}\`: #${newPR.data.number} ${hasConflicts ? '⚠️ (has conflicts)' : '✅'}`
});
results.push({
branch: targetBranch,
success: true,
prNumber: newPR.data.number,
prUrl: newPR.data.html_url,
hasConflicts
});
core.info(`✅ Successfully created backport PR #${newPR.data.number}`);
} catch (error) {
core.error(`❌ Failed to backport to ${targetBranch}: ${error.message}`);
// Comment on original PR about the failure
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `❌ Failed to create backport PR for \`${targetBranch}\`\n\nError: ${error.message}\n\nPlease backport manually.`
});
results.push({
branch: targetBranch,
success: false,
error: error.message
});
} finally {
// Clean up: go back to main branch
try {
execSync('git checkout main', { stdio: 'inherit' });
execSync(`git branch -D ${backportBranch} 2>/dev/null || true`, { stdio: 'inherit' });
} catch (e) {
// Ignore cleanup errors
}
}
}

// Summary (console only)
core.info('\n========================================');
core.info('Backport Summary');
core.info('========================================');
for (const result of results) {
if (result.success) {
core.info(`✅ ${result.branch}: PR #${result.prNumber} ${result.hasConflicts ? '(has conflicts)' : ''}`);
} else {
core.error(`❌ ${result.branch}: ${result.error}`);
}
}
return results;
};
58 changes: 58 additions & 0 deletions .github/scripts/extract-branches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Copyright 2025 NVIDIA CORPORATION
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

module.exports = async ({ github, context, core }) => {
let branches = [];

// Get PR number
const prNumber = context.payload.pull_request?.number || context.payload.issue?.number;

if (!prNumber) {
core.setFailed('Could not determine PR number from event');
return [];
}

// Check PR body
if (context.payload.pull_request?.body) {
const prBody = context.payload.pull_request.body;
// Strict ASCII, anchored; allow X.Y or X.Y.Z
const bodyMatches = prBody.matchAll(/^\/cherry-pick\s+(release-\d+\.\d+(?:\.\d+)?)/gmi);
branches.push(...Array.from(bodyMatches, m => m[1]));
}

// Check all comments
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});

for (const comment of comments.data) {
const commentMatches = comment.body.matchAll(/^\/cherry-pick\s+(release-\d+\.\d+(?:\.\d+)?)/gmi);
branches.push(...Array.from(commentMatches, m => m[1]));
}

// Deduplicate
branches = [...new Set(branches)];

if (branches.length === 0) {
core.setFailed('No valid release branches found in /cherry-pick comments');
return [];
}

core.info(`Target branches: ${branches.join(', ')}`);
return branches;
};
65 changes: 65 additions & 0 deletions .github/workflows/cherrypick.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Copyright 2025 NVIDIA CORPORATION
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

name: Cherry-Pick

on:
pull_request_target:
types: [closed]
issue_comment:
types: [created]

permissions:
contents: write
pull-requests: write
issues: write

jobs:
backport:
name: Backport PR
runs-on: ubuntu-latest
# Run on merged PRs OR on /cherry-pick comments
if: |
(github.event_name == 'pull_request_target' && github.event.pull_request.merged == true) ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/cherry-pick'))

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

- name: Extract target branches from PR comments
id: extract-branches
uses: actions/github-script@v8
with:
script: |
const run = require('./.github/scripts/extract-branches.js');
return await run({ github, context, core });

- name: Configure git
run: |
git config user.name "nvidia-backport-bot"
git config user.email "[email protected]"

- name: Backport to release branches
id: backport
uses: actions/github-script@v8
env:
BRANCHES_JSON: ${{ steps.extract-branches.outputs.result }}
with:
script: |
const run = require('./.github/scripts/backport.js');
return await run({ github, context, core });