add new patch release process
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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
|
||||
Executable
+15
@@ -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);
|
||||
@@ -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 .",
|
||||
|
||||
+171
-15
@@ -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,35 @@ 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 => `- ${desc}`).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 => {
|
||||
|
||||
Reference in New Issue
Block a user