add new patch release process

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-11-20 11:44:26 +01:00
parent 69c19609f7
commit fb5c157262
7 changed files with 616 additions and 15 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}`);
}