From a0cea86cb8b98e9bb1bbcc6d1388ce1b2089eb2e Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Mon, 13 Apr 2026 13:12:05 -0700 Subject: [PATCH] feat: adds a backport script --- .github/workflows/backport.yml | 96 +++++++++++++++++++++ scripts/backport.js | 148 +++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 .github/workflows/backport.yml create mode 100644 scripts/backport.js diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 0000000000000..2e51ef6ffbdca --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,96 @@ +name: Backport + +on: + pull_request_target: + types: [closed, labeled] + # TODO: Remove before merging — manual trigger for testing from any branch + workflow_dispatch: + inputs: + pr_number: + description: 'Merged PR number to test backporting' + required: true + type: number + +permissions: + contents: write + pull-requests: write + +jobs: + backport: + name: Backport + runs-on: ubuntu-latest + # Run when a labeled PR is merged, or when a backport label is added to an already-merged PR. + # Uses pull_request_target so the token has write access even for PRs from forks. + if: >- + github.repository_owner == 'npm' && + github.event.pull_request.merged == true && + ( + (github.event.action == 'closed' && + contains(join(github.event.pull_request.labels.*.name, ','), 'backport:')) + || + (github.event.action == 'labeled' && + startsWith(github.event.label.name, 'backport:')) + ) + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Setup Git User + run: | + git config --global user.email "npm-cli+bot@github.com" + git config --global user.name "npm CLI robot" + - name: Create Backports + uses: actions/github-script@v7 + env: + MERGE_COMMIT_SHA: ${{ github.event.pull_request.merge_commit_sha }} + with: + script: | + const backport = require('./scripts/backport.js') + await backport({ github, context, core }) + + # TODO: Remove before merging — manual test job + backport-test: + name: Backport (Test) + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Setup Git User + run: | + git config --global user.email "npm-cli+bot@github.com" + git config --global user.name "npm CLI robot" + - name: Create Backports + uses: actions/github-script@v7 + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: ${{ inputs.pr_number }}, + }) + + if (!pr.merged) { + return core.setFailed('PR #${{ inputs.pr_number }} is not merged') + } + + process.env.MERGE_COMMIT_SHA = pr.merge_commit_sha + + const backport = require('./scripts/backport.js') + await backport({ + github, + core, + context: { + ...context, + payload: { action: 'closed', pull_request: pr }, + }, + }) diff --git a/scripts/backport.js b/scripts/backport.js new file mode 100644 index 0000000000000..a67baffca7aff --- /dev/null +++ b/scripts/backport.js @@ -0,0 +1,148 @@ +const { execFileSync } = require('node:child_process') + +module.exports = async ({ github, context, core }) => { + const pr = context.payload.pull_request + const sha = process.env.MERGE_COMMIT_SHA + const { owner, repo } = context.repo + + // For 'labeled' events, only process the newly added label. + // For 'closed' (merged) events, process all backport labels on the PR. + const labels = context.payload.action === 'labeled' + ? [context.payload.label.name].filter(n => n.startsWith('backport:')) + : pr.labels.map(l => l.name).filter(n => n.startsWith('backport:')) + + if (!labels.length) { + core.info('No backport labels found, nothing to do') + return + } + + const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim() + + // Build cherry-pick args based on merge strategy: + // - Merge commit (2 parents): cherry-pick -m 1 + // - Squash (1 parent, single commit): cherry-pick that commit + // - Rebase (1 parent, N commits rebased onto base): cherry-pick the full range to preserve all conventional commits + const cherryPickArgs = await (async () => { + const parentCount = git('cat-file', '-p', sha) + .split('\n') + .filter(l => l.startsWith('parent ')).length + + if (parentCount > 1) { + core.info('Detected merge commit') + return ['-x', '-m', '1', sha] + } + + if (pr.commits > 1) { + // Could be squash or rebase. Rebase preserves commit subjects, squash does not. + const { data: prCommits } = await github.rest.pulls.listCommits({ + owner, repo, pull_number: pr.number, per_page: 100, + }) + const prSubjects = prCommits.map(c => c.commit.message.split('\n')[0]) + try { + const branchSubjects = git('log', '--format=%s', '--reverse', `${sha}~${prCommits.length}..${sha}`) + .split('\n') + if ( + branchSubjects.length === prSubjects.length && + branchSubjects.every((s, i) => s === prSubjects[i]) + ) { + core.info(`Detected rebase merge with ${prCommits.length} commits`) + return ['-x', `${sha}~${prCommits.length}..${sha}`] + } + } catch { + // Fall through to squash + } + core.info('Detected squash merge') + } + + return ['-x', sha] + })() + + const startRef = git('rev-parse', 'HEAD') + const results = [] + + for (const label of labels) { + const version = label.replace('backport:', '') + const target = `release/${version}` + const branch = `backport/${version}/${pr.number}` + + try { + // Target branch is available locally from fetch-depth: 0 + try { + git('rev-parse', '--verify', `refs/remotes/origin/${target}`) + } catch { + throw new Error(`Target branch \`${target}\` does not exist`) + } + + // Backport branch requires ls-remote since a parallel run may have created it after our checkout + try { + git('ls-remote', '--exit-code', 'origin', `refs/heads/${branch}`) + core.info(`Branch ${branch} already exists, skipping`) + continue + } catch { + // Expected — branch doesn't exist yet + } + + git('checkout', '-b', branch, `origin/${target}`) + git('cherry-pick', ...cherryPickArgs) + git('push', 'origin', branch) + + const { data: backportPr } = await github.rest.pulls.create({ + owner, + repo, + title: pr.title, + body: `Backport of #${pr.number} to \`${target}\`.`, + head: branch, + base: target, + }) + + results.push(`🎉 Backport to \`${target}\` created: #${backportPr.number}`) + core.info(`Created backport PR #${backportPr.number} for ${target}`) + } catch (error) { + core.error(`Backport to ${target} failed: ${error.message}`) + + results.push([ + `⚠️ Backport to \`${target}\` failed.`, + '', + 'This usually means the cherry-pick had conflicts. Please create a manual backport:', + '', + '```sh', + `git fetch origin ${target}`, + `git checkout -b ${branch} origin/${target}`, + `git cherry-pick ${cherryPickArgs.join(' ')}`, + '# resolve any conflicts, then:', + `git push origin ${branch}`, + '```', + '', + '
Error details', + '', + '```', + error.message, + '```', + '', + '
', + ].join('\n')) + } finally { + try { + git('cherry-pick', '--abort') + } catch { /* noop */ } + git('checkout', '-f', startRef) + try { + git('branch', '-D', branch) + } catch { /* noop */ } + } + } + + if (results.length) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: results.join('\n\n---\n\n'), + }) + } + + const failures = results.filter(r => r.startsWith('⚠️')).length + if (failures) { + core.setFailed(`${failures} backport(s) failed`) + } +}