Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
222 changes: 222 additions & 0 deletions .github/scripts/backport.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
module.exports = async ({ github, context, core }) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have a Licence Header

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I was thinking if we really needed to block the cherrypick PR creation on the merge of the original. Why can't we just create the cherrypick PR regardless of merge status?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main pro of blocking are that cherry-picks are based on reviewed, approved, and merged code. Therefore, cherry-picks represents the "final" state of the changes. I also thought it was cleaner in case a PR is abandoned or closed without merging.

// 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 -b ${backportBranch} ${targetBranch}`, { stdio: 'inherit' });
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
execSync(`git checkout -b ${backportBranch} ${targetBranch}`, { stdio: 'inherit' });
execSync(`git checkout ${backportBranch} || git checkout -b ${backportBranch} ${targetBranch}`, { stdio: 'inherit' });```

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

// 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');
const prBody = hasConflicts
? `🤖 **Automated backport of #${prNumber} to \`${targetBranch}\`**

⚠️ **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 origin ${backportBranch}\`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Resolving the conflicts will most likely require a force push:

Suggested change
4. Push the resolution: \`git push origin ${backportBranch}\`
4. Push the resolution: \`git push -f origin ${backportBranch}\`

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.


---
<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 origin ${backportBranch}
\`\`\`
</details>`
: `🤖 **Automated backport of #${prNumber} to \`${targetBranch}\`**
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I understand that this may be idiomatic, but looking for this : to differentiate between the two contents is not ideal.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.


✅ 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;
};


Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double end of file line

42 changes: 42 additions & 0 deletions .github/scripts/extract-branches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module.exports = async ({ github, context, core }) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Licence header

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

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;
// Enforce release-X.Y or release-X.Y.Z
const bodyMatches = prBody.matchAll(/\/cherry-pick\s+(release-\d+\.\d+(?:\.\d+)?)/g);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const bodyMatches = prBody.matchAll(/\/cherry-pick\s+(release-\d+\.\d+(?:\.\d+)?)/g);
// Strict ASCII, anchored; allow X.Y or X.Y.Z
const bodyMatches = prBody.matchAll(/^\/cherry-pick\s+(release-\d+\.\d+(?:\.\d+)?)/gmi);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

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+)?)/g);
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;
};
67 changes: 67 additions & 0 deletions .github/workflows/cherrypick.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# 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@v7
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 "noreply@nvidia.com"

- name: Backport to release branches
id: backport
uses: actions/github-script@v7
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
uses: actions/github-script@v7
uses: actions/github-script@v8

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

env:
BRANCHES_JSON: ${{ steps.extract-branches.outputs.result }}
with:
script: |
const run = require('./.github/scripts/backport.js');
return await run({ github, context, core });


Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double end of file line

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.