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/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..1906b13916 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,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 => {