Merge pull request #31832 from backstage/rugvip/patch-process

add new patch release process
This commit is contained in:
Patrik Oldsberg
2025-11-21 15:06:59 +01:00
committed by GitHub
9 changed files with 634 additions and 20 deletions
+1
View File
@@ -11,6 +11,7 @@ yarn.lock @backstage/maintainers @backst
/.changeset/*.md
/.github @backstage/operations-maintainers
/.github/vale @backstage/documentation-maintainers
/.patches @backstage/operations-maintainers
/beps/0001-notifications-system @backstage/maintainers @backstage/notifications-maintainers
/docs @backstage/maintainers @backstage/documentation-maintainers
/docs/assets/search @backstage/search-maintainers
+160
View File
@@ -0,0 +1,160 @@
name: Cleanup Patch Files
on:
push:
branches:
- 'patch/v*'
concurrency:
group: cleanup-patch-files
cancel-in-progress: false
jobs:
cleanup:
name: Cleanup Patch Files
runs-on: ubuntu-latest
if: github.repository == 'backstage/backstage'
permissions:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
- name: Configure Git
run: |
git config --global user.email noreply@backstage.io
git config --global user.name 'Github patch cleanup workflow'
- name: Extract PR numbers from commit messages
id: extract-pr-numbers
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
github-token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
script: |
const { execSync } = require('child_process');
// Extract PR numbers from commits pushed to the patch branch
// Commits have messages in format: "Patch from PR #123"
const beforeSha = context.payload.before;
const afterSha = context.payload.after;
let commitRange;
if (beforeSha && beforeSha !== '0000000000000000000000000000000000000000') {
commitRange = `${beforeSha}..${afterSha}`;
} else {
// First push to branch, check recent commits
commitRange = `-n 20 ${afterSha}`;
}
// Get commit messages from git log
let commitMessages;
try {
commitMessages = execSync(
`git log --format=%B ${commitRange}`,
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
);
} catch (error) {
console.log('No commits found in range');
commitMessages = '';
}
// Extract PR numbers from commit messages matching "Patch from PR #123"
const prNumbers = new Set();
const prPattern = /Patch from PR #(\d+)/g;
let match;
while ((match = prPattern.exec(commitMessages)) !== null) {
prNumbers.add(parseInt(match[1], 10));
}
// Convert to sorted array
const prNumbersArray = Array.from(prNumbers).sort((a, b) => a - b);
if (prNumbersArray.length > 0) {
console.log(`Found PR numbers: ${prNumbersArray.join(', ')}`);
core.setOutput('pr_numbers', JSON.stringify(prNumbersArray));
core.setOutput('has_pr_numbers', 'true');
} else {
console.log('No PR numbers found in commit messages');
core.setOutput('pr_numbers', '[]');
core.setOutput('has_pr_numbers', 'false');
}
- name: Checkout master
if: steps.extract-pr-numbers.outputs.has_pr_numbers == 'true'
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
ref: master
token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
- name: Delete patch files
if: steps.extract-pr-numbers.outputs.has_pr_numbers == 'true'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
PR_NUMBERS: ${{ steps.extract-pr-numbers.outputs.pr_numbers }}
REF_NAME: ${{ github.ref_name }}
with:
github-token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
script: |
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const prNumbers = JSON.parse(process.env.PR_NUMBERS);
const patchesDir = path.join(process.cwd(), '.patches');
if (!fs.existsSync(patchesDir)) {
console.log('No .patches directory found, nothing to clean up');
return;
}
const deletedFiles = [];
// Delete patch files for each PR number
for (const prNum of prNumbers) {
const patchFile = path.join(patchesDir, `pr-${prNum}.txt`);
if (fs.existsSync(patchFile)) {
fs.unlinkSync(patchFile);
deletedFiles.push(patchFile);
console.log(`Deleted ${patchFile}`);
} else {
console.log(`Patch file ${patchFile} not found, skipping`);
}
}
if (deletedFiles.length === 0) {
console.log('No patch files to delete');
return;
}
- name: Commit and push changes
if: steps.extract-pr-numbers.outputs.has_pr_numbers == 'true'
run: |
# Check if there are any changes to commit
if git diff --quiet && git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git add .patches/
git commit -m "chore: remove applied patch files after patch release merge
Removed patch files for PRs included in patch release to ${{ github.ref_name }}"
git push origin master
- name: Summary
if: steps.extract-pr-numbers.outputs.has_pr_numbers == 'true'
run: |
echo "## Patch Files Cleanup Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Patch release to ${{ github.ref_name }} has been merged." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Patch files have been removed from the master branch." >> $GITHUB_STEP_SUMMARY
+252
View File
@@ -0,0 +1,252 @@
name: Sync Patch Release
on:
push:
branches:
- master
paths:
- '.patches/**'
pull_request:
branches:
- master
paths:
- '.patches/**'
concurrency:
group: sync-patch-release
cancel-in-progress: true
jobs:
sync-patch-release:
name: Sync Patch Release
runs-on: ubuntu-latest
if: github.repository == 'backstage/backstage'
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 20000
fetch-tags: true
token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
- name: Configure Git
run: |
git config --global user.email noreply@backstage.io
git config --global user.name 'Github patch release workflow'
- name: Check for patch files
id: check-patches
run: |
if [ -d ".patches" ] && [ "$(ls -A .patches/pr-*.txt 2>/dev/null)" ]; then
echo "has_patches=true" >> $GITHUB_OUTPUT
echo "Found patch files"
else
echo "has_patches=false" >> $GITHUB_OUTPUT
echo "No patch files found"
fi
- name: Find existing PR
id: find-pr
if: steps.check-patches.outputs.has_patches == 'true'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
github-token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
script: |
const branchName = 'patch-release';
const owner = context.repo.owner;
const repo = context.repo.repo;
// Find PR with the branch as head
const { data: pulls } = await github.rest.pulls.list({
owner,
repo,
head: `${owner}:${branchName}`,
state: 'open',
});
if (pulls.length > 0) {
console.log(`Found existing PR: #${pulls[0].number}`);
core.setOutput('pr_number', pulls[0].number.toString());
core.setOutput('pr_exists', 'true');
} else {
console.log('No existing PR found');
core.setOutput('pr_exists', 'false');
}
- name: Close PR and delete branch if no patches
if: steps.check-patches.outputs.has_patches == 'false'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
github-token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
script: |
const branchName = 'patch-release';
const owner = context.repo.owner;
const repo = context.repo.repo;
// Find PR with the branch as head
const { data: pulls } = await github.rest.pulls.list({
owner,
repo,
head: `${owner}:${branchName}`,
state: 'open',
});
if (pulls.length > 0) {
console.log(`Closing PR #${pulls[0].number}`);
await github.rest.pulls.update({
owner,
repo,
pull_number: pulls[0].number,
state: 'closed',
});
}
// Delete the branch
try {
await github.rest.git.deleteRef({
owner,
repo,
ref: `heads/${branchName}`,
});
console.log(`Deleted branch ${branchName}`);
} catch (error) {
if (error.status === 422) {
console.log(`Branch ${branchName} does not exist, skipping deletion`);
} else {
throw error;
}
}
- name: Install Dependencies
if: steps.check-patches.outputs.has_patches == 'true'
run: yarn --immutable
- name: Read patch files for PR metadata
if: steps.check-patches.outputs.has_patches == 'true'
id: read-patches
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
github-token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
script: |
// Read patch files from master branch to generate PR body
const fs = require('fs').promises;
const path = require('path');
const patchesDir = path.join(process.cwd(), '.patches');
let files = [];
try {
files = await fs.readdir(patchesDir);
} catch (error) {
console.log(`Error reading patches directory: ${error.message}`);
core.setOutput('pr_body', 'This patch release fixes issues.');
return;
}
const patchFiles = files.filter(f => /^pr-\d+\.txt$/.test(f));
const patches = [];
for (const file of patchFiles) {
const match = file.match(/^pr-(\d+)\.txt$/);
if (match) {
const prNumber = parseInt(match[1], 10);
const filePath = path.join(patchesDir, file);
let content = '';
try {
content = await fs.readFile(filePath, 'utf8');
} catch (error) {
console.log(`Error reading ${file}: ${error.message}`);
}
const description = content.trim() || '<empty>';
patches.push({ prNumber, description });
}
}
if (patches.length === 0) {
console.log('No patches found, using default values');
core.setOutput('pr_body', 'This patch release fixes issues.');
core.setOutput('pr_numbers', '[]');
return;
}
patches.sort((a, b) => a.prNumber - b.prNumber);
const prNumbers = patches.map(p => p.prNumber);
const descriptions = patches.map(p => p.description);
const descriptionList = descriptions.map(desc => `- ${desc}`).join('\n');
const body = `This patch release fixes the following issues:\n\n${descriptionList}`;
core.setOutput('pr_body', body);
core.setOutput('pr_numbers', JSON.stringify(prNumbers));
- name: Run patch release script
if: steps.check-patches.outputs.has_patches == 'true'
id: run-script
env:
GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
HUSKY: '0'
PATCH_RELEASE_BRANCH: patch-release
run: |
# The script will handle finding the release version, creating the patch branch,
# and deleting patch files. Run the patch script with --from-dir flag to read from .patches/ directory
# The script will write the patch branch to GITHUB_OUTPUT
node scripts/patch-release-for-pr.js --from-dir
# Ensure the branch is pushed (script pushes it, but force push in case of conflicts)
git push origin patch-release --force
- name: Create or update PR
if: steps.check-patches.outputs.has_patches == 'true'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
PR_EXISTS: ${{ steps.find-pr.outputs.pr_exists }}
PR_NUMBER: ${{ steps.find-pr.outputs.pr_number }}
PR_BODY: ${{ steps.read-patches.outputs.pr_body }}
PATCH_BRANCH: ${{ steps.run-script.outputs.patch_branch }}
with:
github-token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
script: |
const branchName = 'patch-release';
const owner = context.repo.owner;
const repo = context.repo.repo;
const title = 'Patch Release';
const body = process.env.PR_BODY;
const prExists = process.env.PR_EXISTS === 'true';
const prNumber = process.env.PR_NUMBER;
const patchBranch = process.env.PATCH_BRANCH;
if (prExists && prNumber) {
console.log(`Updating PR #${prNumber}`);
await github.rest.pulls.update({
owner,
repo,
pull_number: parseInt(prNumber, 10),
title,
body,
});
} else {
// Use the patch branch from the script
if (!patchBranch) {
throw new Error('Patch branch not provided from script. Make sure the script outputs the patch branch name.');
}
const baseBranch = patchBranch;
console.log(`Creating new PR with base branch: ${baseBranch}`);
const { data: pr } = await github.rest.pulls.create({
owner,
repo,
title,
body,
head: branchName,
base: baseBranch,
});
console.log(`Created PR #${pr.number}`);
}
+16
View File
@@ -0,0 +1,16 @@
# Patch Release Process
This directory tracks patches to be applied to the next patch release. It contains a list of patch files of the format `pr-<number>.txt` that define which pull requests should be included in the next patch release, where the content of the file is the description of the fix.
The [sync_patch-release.yml](/.github/workflows/sync_patch-release.yml) workflow will automatically create a "Patch Release" PR with the patches in this directory.
## Usage
To add a PR to the set of patches, run `yarn patch-pr <pr-number> <description>` in the root of the repository.
## GitHub Workflow
A GitHub workflow automatically keeps a "Patch Release" PR in sync with the patches listed in this directory. When you add, modify, or remove patch files, the workflow will:
- Update the existing PR if patch files exist
- Close and delete the PR branch if no patch files exist
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const prNumber = process.argv[2];
const description = process.argv.slice(3).join(' ');
if (!prNumber || !description) {
console.error('Usage: yarn patch-pr <pr-number> <description>');
process.exit(1);
}
const patchFilePath = path.join(__dirname, `pr-${prNumber}.txt`);
fs.writeFileSync(patchFilePath, description);
+1
View File
@@ -0,0 +1 @@
Fixed an issue where the `useTable` hook from `@backstage/ui` did not use the provided `rowCount`
+11 -5
View File
@@ -69,17 +69,23 @@ Once the release has been published edit the newly created release in the [GitHu
**This emergency release process is intended only for the Backstage
maintainers.**
Given one or more PRs towards master that we want to create a patch release for, run the following script from the repo root:
Given one or more PRs towards master that we want to create a patch release for, add them to the patch release queue by running the following command for each PR:
```bash
./scripts/patch-release-for-pr.js <pr-number> <pr-number-2> ...
yarn patch-pr <pr-number> <description>
```
Wait until the script has finished executing, at the end of the output you will find a link of the format `https://github.com/backstage/backstage/pull/new/patch-release-pr-...`. Open this link in your browser to create a PR for the patch release. Finish the sentence "This release fixes an issue where..." and create the PR.
This creates a patch file in the `.patches/` directory (e.g., `.patches/pr-12345.txt`) containing the description of the fix. The [sync_patch-release.yml](https://github.com/backstage/backstage/blob/master/.github/workflows/sync_patch-release.yml) workflow will automatically detect these patch files and create or update a "Patch Release" PR.
Once the PR has been approved and merged, the patch release will be automatically created. The patch release is complete when a notification has been posted to Discord in the `#announcements` channel. Keep an eye on "Deploy Packages" workflow and re-trigger if it fails. It is safe to re-trigger any part of this workflow, including the release step.
The workflow will:
If the above process fails, you can fall back to the manual process documented below.
- Automatically create a patch release PR if one doesn't exist
- Update the existing PR if patch files are added, modified, or removed
- Close and delete the PR branch if all patch files are removed
Once the "Patch Release" PR has been approved and merged, the patch release will be automatically created. The patch files will be automatically removed from the master branch after the patch release is merged. From here the patch release process is the same as the usual release process, starting with the notification in the `#maintainers` channel on Discord.
If the above process fails, you can fall back to the manual process documented below, or run the `./scripts/patch-release-for-pr.js <pr-number> <pr-number-2> ...` script to manually create a patch release PR.
### Old Process
+1
View File
@@ -47,6 +47,7 @@
"lint:type-deps": "backstage-repo-tools type-deps",
"mui-to-bui": "node scripts/mui-to-bui/backstage-migration-analytics.js",
"new": "backstage-cli new",
"patch-pr": "node .patches/create-pr-patch.js",
"prepare": "husky",
"prettier:check": "prettier --check .",
"prettier:fix": "prettier --write .",
+177 -15
View File
@@ -20,13 +20,14 @@ const path = require('path');
const semver = require('semver');
const { Octokit } = require('@octokit/rest');
const { execFile: execFileCb } = require('child_process');
const { promisify } = require('util');
const { promisify, parseArgs } = require('util');
const execFile = promisify(execFileCb);
const owner = 'backstage';
const repo = 'backstage';
const rootDir = path.resolve(__dirname, '..');
const PATCH_FILE_PATTERN = /^pr-(\d+)\.txt$/;
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
@@ -81,15 +82,120 @@ async function findCurrentReleaseVersion() {
throw new Error('No stable release found');
}
async function main(args) {
const prNumbers = args.map(s => {
const num = parseInt(s, 10);
if (!Number.isInteger(num)) {
throw new Error(`Must provide valid PR number arguments, got ${s}`);
}
return num;
/**
* Parses command-line arguments and determines which patches to use.
* Returns an object with prNumbers and descriptions, or null if should exit early.
*/
async function parsePatchArguments(args) {
const { values, positionals } = parseArgs({
args,
options: {
'from-dir': {
type: 'boolean',
short: 'd',
},
},
allowPositionals: true,
});
console.log(`PR number(s): ${prNumbers.join(', ')}`);
const useDirectory = values['from-dir'] === true;
let prNumbers;
let descriptions;
if (useDirectory) {
// Read patches from directory
const patchesFromDir = await readPatchesFromDirectory();
if (patchesFromDir) {
// Use patches from directory
prNumbers = patchesFromDir.map(p => p.prNumber);
descriptions = patchesFromDir.map(p => p.description);
console.log(`Reading patches from .patches/ directory`);
console.log(`PR number(s): ${prNumbers.join(', ')}`);
return { prNumbers, descriptions };
}
// No patches found in directory - exit early
return null;
} else if (positionals.length > 0) {
// Use command-line arguments
prNumbers = positionals.map(s => {
const num = parseInt(s, 10);
if (!Number.isInteger(num)) {
throw new Error(`Must provide valid PR number arguments, got ${s}`);
}
return num;
});
console.log(`Using command-line arguments`);
console.log(`PR number(s): ${prNumbers.join(', ')}`);
return { prNumbers, descriptions: undefined };
}
// No patches found and no command-line args - exit early
return null;
}
/**
* Reads patch files from .patches/ directory and returns PR numbers with descriptions.
* Returns null if directory doesn't exist or contains no matching files.
*/
async function readPatchesFromDirectory() {
const patchesDir = path.resolve(rootDir, '.patches');
try {
const exists = await fs.pathExists(patchesDir);
if (!exists) {
return null;
}
const files = await fs.readdir(patchesDir);
const patchFiles = files.filter(file => PATCH_FILE_PATTERN.test(file));
if (patchFiles.length === 0) {
return null;
}
const patches = [];
for (const file of patchFiles) {
const match = file.match(PATCH_FILE_PATTERN);
const prNumber = parseInt(match[1], 10);
const filePath = path.resolve(patchesDir, file);
const content = await fs.readFile(filePath, 'utf8');
const description = content.trim() || '<empty>';
patches.push({ prNumber, description });
}
// Sort by PR number for consistent ordering
patches.sort((a, b) => a.prNumber - b.prNumber);
return patches;
} catch {
// If there's an error reading the directory, return null to fall back to CLI args
return null;
}
}
async function ensureCommitExists(sha) {
try {
await run('git', 'cat-file', '-e', sha);
} catch {
console.log(`Commit ${sha} not found locally, fetching...`);
try {
await run('git', 'fetch', 'origin', sha);
} catch (error) {
throw new Error(`Failed to fetch commit ${sha}: ${error}`);
}
}
}
async function main(args) {
const patchInfo = await parsePatchArguments(args);
if (!patchInfo) {
// No patches found and no command-line args - exit early
return;
}
const { prNumbers, descriptions } = patchInfo;
if (await run('git', 'status', '--porcelain')) {
throw new Error('Cannot run with a dirty working tree');
@@ -101,6 +207,16 @@ async function main(args) {
await run('git', 'fetch');
const patchBranch = `patch/v${release}`;
// Output patch branch for CI workflows to capture
if (process.env.PATCH_RELEASE_BRANCH && process.env.GITHUB_OUTPUT) {
// Use native fs for appendFileSync (fs-extra doesn't have sync version)
const nativeFs = require('fs');
nativeFs.appendFileSync(
process.env.GITHUB_OUTPUT,
`patch_branch=${patchBranch}\n`,
);
}
try {
await run('git', 'checkout', `origin/${patchBranch}`);
} catch {
@@ -109,8 +225,24 @@ async function main(args) {
}
// Create new branch, apply changes from all commits on PR branch, commit, push
const branchName = `patch-release-pr-${prNumbers.join('-')}`;
await run('git', 'checkout', '-b', branchName);
// Allow fixed branch name via environment variable for CI workflows
const branchName =
process.env.PATCH_RELEASE_BRANCH ||
`patch-release-pr-${prNumbers.join('-')}`;
// Check if branch already exists (for CI workflows using fixed branch name)
try {
await run(
'git',
'show-ref',
'--verify',
'--quiet',
`refs/heads/${branchName}`,
);
await run('git', 'checkout', branchName);
} catch {
await run('git', 'checkout', '-b', branchName);
}
for (const prNumber of prNumbers) {
const { data } = await octokit.pulls.get({
@@ -127,6 +259,9 @@ async function main(args) {
if (!baseSha) {
throw new Error('base sha not available');
}
await ensureCommitExists(headSha);
const mergeBaseSha = await run('git', 'merge-base', headSha, baseSha);
const logLines = await run(
@@ -155,6 +290,10 @@ async function main(args) {
console.log('Running "yarn release" ...');
await run('yarn', 'release');
// Note: Patch files are not deleted here because this script runs in the patch
// release branch, not master. The cleanup_patch-files.yml workflow handles
// deletion from master after the patch release PR is merged.
await run('git', 'add', '.');
await run(
'git',
@@ -165,18 +304,41 @@ async function main(args) {
'Generate Release',
);
await run('git', 'push', 'origin', '-u', branchName);
// Use force push if using a specific branch name (for CI workflows)
if (process.env.PATCH_RELEASE_BRANCH) {
await run('git', 'push', 'origin', '-u', '--force-with-lease', branchName);
} else {
await run('git', 'push', 'origin', '-u', branchName);
}
// Generate PR body
let body;
if (descriptions) {
const descriptionList = descriptions
.map((desc, index) => {
const prNumber = prNumbers[index];
const prLink = `https://github.com/${owner}/${repo}/pull/${prNumber}`;
return `- ${desc} ([#${prNumber}](${prLink}))`;
})
.join('\n');
body = `This patch release includes the following fixes:\n\n${descriptionList}`;
} else {
body = 'This release fixes an issue where';
}
const params = new URLSearchParams({
expand: 1,
body: 'This release fixes an issue where',
body: body,
title: `Patch release of ${prNumbers.map(nr => `#${nr}`).join(', ')}`,
});
const url = `https://github.com/backstage/backstage/compare/${patchBranch}...${branchName}?${params}`;
console.log(`Opening ${url} ...`);
console.log(`PR URL: ${url}`);
await run('open', url);
// Only open URL if not in CI (no PATCH_RELEASE_BRANCH env var means manual run)
if (!process.env.PATCH_RELEASE_BRANCH) {
await run('open', url);
}
}
main(process.argv.slice(2)).catch(error => {