Skip to content
Merged
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
96 changes: 96 additions & 0 deletions .github/workflows/backport.yml
Original file line number Diff line number Diff line change
@@ -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 },
},
})
148 changes: 148 additions & 0 deletions scripts/backport.js
Original file line number Diff line number Diff line change
@@ -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}`,
'```',
'',
'<details><summary>Error details</summary>',
'',
'```',
error.message,
'```',
'',
'</details>',
].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`)
}
}
Loading