diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8491b1a260..40c09a9362 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/workflows/cleanup_patch-files.yml b/.github/workflows/cleanup_patch-files.yml new file mode 100644 index 0000000000..68ca8136ad --- /dev/null +++ b/.github/workflows/cleanup_patch-files.yml @@ -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 diff --git a/.github/workflows/sync_patch-release.yml b/.github/workflows/sync_patch-release.yml new file mode 100644 index 0000000000..f7fc468ba4 --- /dev/null +++ b/.github/workflows/sync_patch-release.yml @@ -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() || ''; + 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}`); + } diff --git a/.patches/README.md b/.patches/README.md new file mode 100644 index 0000000000..19b599d6bb --- /dev/null +++ b/.patches/README.md @@ -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-.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 ` 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 diff --git a/.patches/create-pr-patch.js b/.patches/create-pr-patch.js new file mode 100755 index 0000000000..528d4fc404 --- /dev/null +++ b/.patches/create-pr-patch.js @@ -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 '); + process.exit(1); +} + +const patchFilePath = path.join(__dirname, `pr-${prNumber}.txt`); +fs.writeFileSync(patchFilePath, description); diff --git a/.patches/pr-31817.txt b/.patches/pr-31817.txt new file mode 100644 index 0000000000..38665ea30a --- /dev/null +++ b/.patches/pr-31817.txt @@ -0,0 +1 @@ +Fixed an issue where the `useTable` hook from `@backstage/ui` did not use the provided `rowCount` \ No newline at end of file diff --git a/docs/publishing.md b/docs/publishing.md index e6c0b6b466..15b613f800 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -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 ... +yarn patch-pr ``` -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 ...` script to manually create a patch release PR. ### Old Process diff --git a/package.json b/package.json index fd9c500b1c..63a8d24512 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/scripts/patch-release-for-pr.js b/scripts/patch-release-for-pr.js index d70eaa8ac5..c0caa8ee75 100755 --- a/scripts/patch-release-for-pr.js +++ b/scripts/patch-release-for-pr.js @@ -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() || ''; + 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 => {