diff --git a/.ado/jobs/setup.yml b/.ado/jobs/setup.yml index 71ecc18c655..d7522863c2e 100644 --- a/.ado/jobs/setup.yml +++ b/.ado/jobs/setup.yml @@ -44,6 +44,7 @@ jobs: - ${{ if endsWith(parameters.buildEnvironment, 'PullRequest') }}: - script: npx beachball check --branch origin/$(BeachBallBranchName) --verbose --changehint "##vso[task.logissue type=error]Run \"yarn change\" from root of repo to generate a change file." displayName: Check for change files + condition: not(startsWith(variables['System.PullRequest.SourceBranch'], 'refs/heads/prepare-release/')) - script: npx beachball bump --branch origin/$(BeachBallBranchName) --yes --verbose displayName: beachball bump diff --git a/.ado/prepare-release-bot.yml b/.ado/prepare-release-bot.yml new file mode 100644 index 00000000000..ba980c5482e --- /dev/null +++ b/.ado/prepare-release-bot.yml @@ -0,0 +1,48 @@ +name: $(Date:yyyyMMdd).$(Rev:r) + +trigger: none +pr: none + +schedules: + - cron: "0 */4 * * *" + displayName: Prepare release + branches: + include: + - main + - "*-stable" + always: true + +parameters: + - name: AgentPool + type: object + default: + Small: + name: rnw-pool-2-microsoft + demands: ImageOverride -equals rnw-img-vs2022-node22 + +variables: + - template: variables/windows.yml + - group: RNW Secrets + +jobs: + - job: PrepareRelease + displayName: Prepare Release Bot + pool: ${{ parameters.AgentPool.Small }} + timeoutInMinutes: 30 + + steps: + - template: templates/checkout-full.yml + parameters: + persistCredentials: true + + - script: | + git config user.name "React-Native-Windows Bot" + git config user.email "53619745+rnbot@users.noreply.github.com" + displayName: Configure Git Identity + + - template: templates/prepare-js-env.yml + + - script: npx prepare-release --branch $(Build.SourceBranchName) --no-color + displayName: Prepare Release + env: + GH_TOKEN: $(githubAuthToken) diff --git a/.ado/publish.yml b/.ado/publish.yml index a7f8c349ba1..3a6cfac3164 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -1,18 +1,6 @@ name: 0.0.$(Date:yyMM.d)$(Rev:rrr) parameters: -- name: skipNpmPublish - displayName: Skip Npm Publish - type: boolean - default: false -- name: skipGitPush - displayName: Skip Git Push - type: boolean - default: false -- name: stopOnNoCI - displayName: Stop if latest commit is ***NO_CI*** - type: boolean - default: true - name: performBeachballCheck displayName: Perform Beachball Check (Disable when promoting) type: boolean @@ -74,14 +62,10 @@ parameters: variables: - template: variables/windows.yml - group: RNW Secrets - - name: SkipGitPushPublishArgs - value: '' - name: FailCGOnAlert value: false - name: EnableCodesign value: false - - name: SourceBranchWithFolders - value: $[ replace(variables['Build.SourceBranch'], 'refs/heads/', '') ] trigger: none pr: none @@ -112,20 +96,9 @@ extends: timeoutInMinutes: 120 cancelTimeoutInMinutes: 5 steps: - - powershell: | - Write-Host "Stopping because commit message contains ***NO_CI***." - $uri = "https://dev.azure.com/microsoft/ReactNative/_apis/build/builds/$(Build.BuildId)?api-version=5.1" - $json = @{status="Cancelling"} | ConvertTo-Json -Compress - $build = Invoke-RestMethod -Uri $uri -Method Patch -Headers @{Authorization = "Bearer $(System.AccessToken)"} -ContentType "application/json" -Body $json - Write-Host $build - Write-Host "Waiting 60 seconds for build cancellation..." - Start-Sleep -Seconds 60 - displayName: Stop pipeline if latest commit message contains ***NO_CI*** - condition: and(${{ parameters.stopOnNoCI }}, contains(variables['Build.SourceVersionMessage'], '***NO_CI***')) - - template: .ado/templates/checkout-full.yml@self parameters: - persistCredentials: false # We're going to use rnbot's git creds to publish + persistCredentials: false - powershell: gci env:/BUILD_* displayName: Show build information @@ -146,7 +119,7 @@ extends: condition: ${{ parameters.performBeachballCheck }} - job: RnwNpmPublish - displayName: React-Native-Windows Npm Build Rev Publish + displayName: React-Native-Windows Npm Pack dependsOn: RnwPublishPrep pool: name: Azure-Pipelines-1ESPT-ExDShared @@ -159,36 +132,12 @@ extends: parameters: agentImage: HostedImage - - template: .ado/templates/configure-git.yml@self - - - pwsh: | - Write-Host "##vso[task.setvariable variable=SkipGitPushPublishArgs]--no-push" - displayName: Enable No-Publish (git) - condition: ${{ parameters.skipGitPush }} - - # Beachball publishes NPM packages to the "$(Pipeline.Workspace)\published-packages" folder. - # It pushes NPM version updates to Git depending on the SkipGitPushPublishArgs variable derived from the skipGitPush parameter. - - script: | - if exist "$(Pipeline.Workspace)\published-packages" rd /s /q "$(Pipeline.Workspace)\published-packages" - mkdir "$(Pipeline.Workspace)\published-packages" - npx beachball publish --no-publish $(SkipGitPushPublishArgs) --pack-to-path "$(Pipeline.Workspace)\published-packages" --branch origin/$(SourceBranchWithFolders) -yes --bump-deps --verbose --access public --message "applying package updates ***NO_CI***" - displayName: Beachball Publish + - script: node .ado/scripts/npmPack.js --clean --no-color "$(Pipeline.Workspace)\published-packages" + displayName: Pack npm packages - script: dir /s "$(Pipeline.Workspace)\published-packages" displayName: Show created npm packages - # Beachball reverts to local state after publish, but we want the updates it added - - script: git pull origin $(SourceBranchWithFolders) - displayName: git pull - - - script: npx @rnw-scripts/create-github-releases --yes --authToken $(githubAuthToken) - displayName: Create GitHub Releases (New Canary Version) - condition: and(succeeded(), ${{ not(parameters.skipGitPush) }}, ${{ eq(variables['Build.SourceBranchName'], 'main') }} ) - - - script: npx --yes @rnw-scripts/create-github-releases@latest --yes --authToken $(githubAuthToken) - displayName: Create GitHub Releases (New Stable Version) - condition: and(succeeded(), ${{ not(parameters.skipGitPush) }}, ${{ ne(variables['Build.SourceBranchName'], 'main') }} ) - - template: .ado/templates/set-version-vars.yml@self parameters: buildEnvironment: Continuous @@ -196,26 +145,6 @@ extends: - script: echo NpmDistTag is $(NpmDistTag) displayName: Show NPM dist tag - - script: dir /s "$(Pipeline.Workspace)\published-packages" - displayName: Show npm packages before ESRP release - - - task: 'SFP.release-tasks.custom-build-release-task.EsrpRelease@10' - displayName: 'ESRP Release to npmjs.com' - condition: and(succeeded(), ne(variables['NpmDistTag'], '')) - inputs: - connectedservicename: 'ESRP-CodeSigning-OGX-JSHost-RNW' - usemanagedidentity: false - keyvaultname: 'OGX-JSHost-KV' - authcertname: 'OGX-JSHost-Auth4' - signcertname: 'OGX-JSHost-Sign3' - clientid: '0a35e01f-eadf-420a-a2bf-def002ba898d' - domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2' - contenttype: npm - folderlocation: '$(Pipeline.Workspace)\published-packages' - productstate: '$(NpmDistTag)' - owners: 'vmorozov@microsoft.com' - approvers: 'khosany@microsoft.com' - - task: AzureArtifacts.manifest-generator-task.manifest-generator-task.ManifestGeneratorTask@0 displayName: 📒 Generate Manifest Npm inputs: diff --git a/package.json b/package.json index 6d702fa8832..82bb56b2018 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@rnw-scripts/format-files": "*", "@rnw-scripts/integrate-rn": "*", "@rnw-scripts/just-task": "*", + "@rnw-scripts/prepare-release": "*", "@rnw-scripts/promote-release": "*", "@rnw-scripts/stamp-version": "0.0.0", "@rnw-scripts/take-screenshot": "*", diff --git a/packages/@rnw-scripts/prepare-release/.eslintrc.js b/packages/@rnw-scripts/prepare-release/.eslintrc.js new file mode 100644 index 00000000000..35e0d115126 --- /dev/null +++ b/packages/@rnw-scripts/prepare-release/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: ['@rnw-scripts'], + parserOptions: {tsconfigRootDir : __dirname}, +}; diff --git a/packages/@rnw-scripts/prepare-release/.gitignore b/packages/@rnw-scripts/prepare-release/.gitignore new file mode 100644 index 00000000000..f42efbb9f7c --- /dev/null +++ b/packages/@rnw-scripts/prepare-release/.gitignore @@ -0,0 +1,2 @@ +lib/ +lib-commonjs/ diff --git a/packages/@rnw-scripts/prepare-release/bin.js b/packages/@rnw-scripts/prepare-release/bin.js new file mode 100644 index 00000000000..aae8dc76ae9 --- /dev/null +++ b/packages/@rnw-scripts/prepare-release/bin.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +require('source-map-support').install(); +require('./lib-commonjs/prepareRelease'); diff --git a/packages/@rnw-scripts/prepare-release/package.json b/packages/@rnw-scripts/prepare-release/package.json new file mode 100644 index 00000000000..fb3db4eaf13 --- /dev/null +++ b/packages/@rnw-scripts/prepare-release/package.json @@ -0,0 +1,45 @@ +{ + "name": "@rnw-scripts/prepare-release", + "version": "0.0.1", + "private": true, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/react-native-windows", + "directory": "packages/@rnw-scripts/prepare-release" + }, + "scripts": { + "build": "rnw-scripts build", + "clean": "rnw-scripts clean", + "lint": "rnw-scripts lint", + "lint:fix": "rnw-scripts lint:fix", + "watch": "rnw-scripts watch" + }, + "main": "lib-commonjs/prepareRelease.js", + "bin": { + "prepare-release": "./bin.js" + }, + "dependencies": { + "@react-native-windows/find-repo-root": "^0.0.0-canary.99", + "@react-native-windows/package-utils": "^0.0.0-canary.96", + "source-map-support": "^0.5.19" + }, + "devDependencies": { + "@rnw-scripts/eslint-config": "1.2.38", + "@rnw-scripts/just-task": "2.3.58", + "@rnw-scripts/ts-config": "2.0.6", + "@types/node": "^22.14.0", + "@typescript-eslint/eslint-plugin": "^7.1.1", + "@typescript-eslint/parser": "^7.1.1", + "eslint": "^8.19.0", + "prettier": "2.8.8", + "typescript": "5.0.4" + }, + "files": [ + "bin.js", + "lib-commonjs" + ], + "engines": { + "node": ">= 22" + } +} diff --git a/packages/@rnw-scripts/prepare-release/src/beachballBump.ts b/packages/@rnw-scripts/prepare-release/src/beachballBump.ts new file mode 100644 index 00000000000..20f695c21ee --- /dev/null +++ b/packages/@rnw-scripts/prepare-release/src/beachballBump.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Beachball change file detection and version bump invocation. + * + * @format + */ + +import fs from 'fs'; +import path from 'path'; + +import {exec} from './proc'; + +/** + * Check whether there are pending beachball change files in the repo. + * + * Beachball stores change files as JSON in the `change/` directory at the + * repo root. If there are any .json files there, there are pending changes. + */ +export function hasChangeFiles(repoRoot: string): boolean { + const changeDir = path.join(repoRoot, 'change'); + if (!fs.existsSync(changeDir)) { + return false; + } + + const entries = fs.readdirSync(changeDir); + return entries.some(entry => entry.endsWith('.json')); +} + +/** + * Run beachball bump to consume change files and update versions/changelogs. + * + * Invokes: npx beachball bump --branch / --yes --verbose + * + * The --yes flag suppresses prompts. + * The --verbose flag provides detailed output. + * The --branch flag tells beachball the baseline branch to diff against. + */ +export async function bumpVersions(opts: { + targetBranch: string; + remote: string; + cwd: string; +}): Promise { + await exec( + `npx beachball bump --branch ${opts.remote}/${opts.targetBranch} --yes --verbose`, + {cwd: opts.cwd}, + ); +} diff --git a/packages/@rnw-scripts/prepare-release/src/git.ts b/packages/@rnw-scripts/prepare-release/src/git.ts new file mode 100644 index 00000000000..9980e3ca363 --- /dev/null +++ b/packages/@rnw-scripts/prepare-release/src/git.ts @@ -0,0 +1,118 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Git operations module. Provides a typed wrapper around git CLI commands. + * Simplified from fork-sync/src/modules/git.ts. + * + * @format + */ + +import {spawn} from './proc'; + +/** + * Typed wrapper around a git repository directory. + * All methods execute git commands with cwd set to the wrapped directory. + */ +export class GitRepo { + readonly dir: string; + + constructor(dir: string) { + this.dir = dir; + } + + /** Fetch from a remote. */ + async fetch(remote: string): Promise { + await this.git('fetch', remote); + } + + /** Checkout an existing ref (branch, tag, or commit). */ + async checkout(ref: string): Promise { + await this.git('checkout', ref); + } + + /** + * Create or reset a branch to a start point. + * Uses `git checkout -B` which creates the branch if it doesn't exist, + * or resets it if it does. + */ + async checkoutNewBranch(name: string, startPoint?: string): Promise { + const args = ['checkout', '-B', name]; + if (startPoint) { + args.push(startPoint); + } + await this.git(...args); + } + + /** Stage all changes (git add --all). */ + async stageAll(): Promise { + await this.git('add', '--all'); + } + + /** Create a commit with the given message. */ + async commit(message: string): Promise { + await this.git('commit', '-m', message); + } + + /** Push a branch to a remote, optionally with --force. */ + async push( + remote: string, + branch: string, + opts?: {force?: boolean}, + ): Promise { + const args = ['push', remote, branch]; + if (opts?.force) { + args.push('--force'); + } + await this.git(...args); + } + + /** Resolve a ref to its SHA (git rev-parse). */ + async revParse(ref: string): Promise { + return this.git('rev-parse', ref); + } + + /** Check for uncommitted changes (git status --porcelain). */ + async statusPorcelain(pathspec?: string): Promise { + const args = ['status', '--porcelain']; + if (pathspec) { + args.push('--', pathspec); + } + return this.git(...args); + } + + /** List all remotes with their URLs (git remote -v). */ + async remoteList(): Promise { + return this.git('remote', '-v'); + } + + /** Git log with format and optional range. */ + async log(opts: {format: string; range?: string}): Promise { + const args = ['log', `--format=${opts.format}`]; + if (opts.range) { + args.push(opts.range); + } + return this.git(...args); + } + + /** Get file names changed between two refs, optionally filtered by path. */ + async diffNameOnly( + ref1: string, + ref2?: string, + pathspec?: string, + ): Promise { + const args = ['diff', '--name-only', ref1]; + if (ref2) { + args.push(ref2); + } + if (pathspec) { + args.push('--', pathspec); + } + return this.git(...args); + } + + /** Internal helper: run git with the repo's cwd. */ + private async git(...args: string[]): Promise { + return spawn('git', args, {cwd: this.dir}); + } +} diff --git a/packages/@rnw-scripts/prepare-release/src/github.ts b/packages/@rnw-scripts/prepare-release/src/github.ts new file mode 100644 index 00000000000..e80ccce7655 --- /dev/null +++ b/packages/@rnw-scripts/prepare-release/src/github.ts @@ -0,0 +1,92 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * GitHub operations via the gh CLI. + * + * @format + */ + +import {exec} from './proc'; + +export interface PRInfo { + number: number; + url: string; +} + +/** + * Find an existing open PR by head branch name. + * Returns null if no matching PR exists. + */ +export async function findPR(opts: { + head: string; + cwd: string; +}): Promise { + const result = await exec( + `gh pr list --head "${opts.head}" --json number,url --limit 1`, + {cwd: opts.cwd, fallback: '[]'}, + ); + + let prs: Array<{number: number; url: string}>; + try { + prs = JSON.parse(result); + } catch { + return null; + } + + if (!Array.isArray(prs) || prs.length === 0) { + return null; + } + + return {number: prs[0].number, url: prs[0].url}; +} + +/** + * Create a new pull request. Returns the PR number and URL. + */ +export async function createPR(opts: { + head: string; + base: string; + title: string; + body: string; + cwd: string; +}): Promise { + const result = await exec( + `gh pr create --head "${opts.head}" --base "${opts.base}"` + + ` --title "${escapeForShell(opts.title)}"` + + ` --body "${escapeForShell(opts.body)}"`, + {cwd: opts.cwd}, + ); + + // gh pr create outputs the PR URL on success + const url = result.trim(); + const match = url.match(/\/pull\/(\d+)/); + const number = match ? parseInt(match[1], 10) : 0; + + return {number, url}; +} + +/** + * Update an existing pull request's body text. + */ +export async function updatePR(opts: { + number: number; + body: string; + cwd: string; +}): Promise { + await exec( + `gh pr edit ${opts.number} --body "${escapeForShell(opts.body)}"`, + {cwd: opts.cwd}, + ); +} + +/** + * Escape a string for safe inclusion in a double-quoted shell argument. + */ +function escapeForShell(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\$/g, '\\$') + .replace(/`/g, '\\`'); +} diff --git a/packages/@rnw-scripts/prepare-release/src/prepareRelease.ts b/packages/@rnw-scripts/prepare-release/src/prepareRelease.ts new file mode 100644 index 00000000000..f7722ccabff --- /dev/null +++ b/packages/@rnw-scripts/prepare-release/src/prepareRelease.ts @@ -0,0 +1,346 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * This script automates the version bump PR workflow. It checks for pending + * beachball change files, bumps versions, and creates or updates a + * "Version Packages" pull request. + * + * Usage: + * npx prepare-release --branch [--dry-run] [--no-color] [--help] + * + * @format + */ + +import {parseArgs} from 'node:util'; +import fs from 'fs'; +import path from 'path'; + +import findRepoRoot from '@react-native-windows/find-repo-root'; + +import {GitRepo} from './git'; +import {findPR, createPR, updatePR} from './github'; +import {hasChangeFiles, bumpVersions} from './beachballBump'; +import { + collectBumpedPackages, + generatePRBody, + generateConsoleSummary, +} from './releaseSummary'; + +// --------------------------------------------------------------------------- +// Color utilities (from npmPack.js pattern) +// --------------------------------------------------------------------------- + +const ansi = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + gray: '\x1b[90m', +}; + +let useColors = true; + +function colorize(text: string, color: string): string { + if (!useColors) { + return text; + } + return color + text + ansi.reset; +} + +// --------------------------------------------------------------------------- +// CLI help +// --------------------------------------------------------------------------- + +function showHelp(): void { + console.log(` +prepare-release - Automate version bump PRs using beachball + +Usage: + npx prepare-release --branch [options] + +Options: + --branch Target branch to prepare release for (required) + --dry-run Do everything except push and PR create/update + --no-color Disable colored output + --help, -h Show this help message + +Examples: + npx prepare-release --branch main + npx prepare-release --branch 0.76-stable --dry-run +`); +} + +// --------------------------------------------------------------------------- +// Remote detection +// --------------------------------------------------------------------------- + +/** + * Normalize a git URL to a canonical form for comparison. + * + * Handles SSH (git@github.com:org/repo.git), HTTPS, with/without .git suffix. + * Output: lowercase "github.com/org/repo" + */ +function normalizeGitUrl(url: string): string { + let normalized = url; + + // Strip trailing .git + normalized = normalized.replace(/\.git$/, ''); + + // Convert SSH format: git@github.com:org/repo -> github.com/org/repo + normalized = normalized.replace(/^git@([^:]+):/, '$1/'); + + // Convert HTTPS format: https://github.com/org/repo -> github.com/org/repo + normalized = normalized.replace(/^https?:\/\//, ''); + + // Lowercase for case-insensitive comparison + normalized = normalized.toLowerCase(); + + // Strip trailing slash + normalized = normalized.replace(/\/$/, ''); + + return normalized; +} + +/** + * Detect which git remote matches the repository URL from package.json. + * + * Parses `git remote -v` output and matches against the normalized repo URL. + * On CI this is typically "origin"; on developer machines it may differ. + */ +async function detectRemote( + git: GitRepo, + repoUrl: string, +): Promise { + const canonical = normalizeGitUrl(repoUrl); + const remoteOutput = await git.remoteList(); + + for (const line of remoteOutput.split('\n')) { + // Lines: "origin\thttps://github.com/microsoft/react-native-windows.git (fetch)" + const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)/); + if (!match) { + continue; + } + const remoteName = match[1]; + const remoteUrl = match[2]; + + if (normalizeGitUrl(remoteUrl) === canonical) { + return remoteName; + } + } + + throw new Error( + `Could not find a git remote matching "${repoUrl}". ` + + 'Run "git remote -v" and verify the repository URL in root package.json.', + ); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +(async () => { + // 1. Parse CLI arguments + const {values} = parseArgs({ + options: { + branch: {type: 'string'}, + 'dry-run': {type: 'boolean', default: false}, + help: {type: 'boolean', short: 'h', default: false}, + 'no-color': {type: 'boolean', default: false}, + }, + }); + + if (values.help) { + showHelp(); + process.exit(0); + } + + useColors = !values['no-color']; + + const targetBranch = values.branch; + if (!targetBranch) { + console.error(colorize('Error: --branch is required', ansi.red)); + showHelp(); + process.exit(1); + } + + const dryRun = values['dry-run'] ?? false; + + if (dryRun) { + console.log(colorize('[DRY RUN MODE]', ansi.yellow)); + } + + try { + // 2. Find repo root + const repoRoot = await findRepoRoot(); + console.log(`${colorize('Repository root:', ansi.bright)} ${repoRoot}`); + + // 3. Read repository URL from root package.json + const rootPkgJsonPath = path.join(repoRoot, 'package.json'); + const rootPkgJson = JSON.parse(fs.readFileSync(rootPkgJsonPath, 'utf8')); + const repoUrl: string = rootPkgJson.repository?.url ?? ''; + + if (!repoUrl) { + throw new Error( + 'Could not find repository.url in root package.json', + ); + } + console.log(`${colorize('Repository URL:', ansi.bright)} ${repoUrl}`); + + // 4. Detect git remote name + const git = new GitRepo(repoRoot); + const remoteName = await detectRemote(git, repoUrl); + console.log(`${colorize('Git remote:', ansi.bright)} ${remoteName}`); + + // 5. Fetch from remote + console.log(colorize(`Fetching from ${remoteName}...`, ansi.dim)); + await git.fetch(remoteName); + + // 6. Check for pending change files + console.log(colorize('Checking for change files...', ansi.dim)); + if (!hasChangeFiles(repoRoot)) { + console.log( + colorize('No pending change files found. Nothing to do.', ansi.green), + ); + process.exit(0); + } + console.log(colorize('Found pending change files.', ansi.green)); + + // 7. Check for existing PR + const prBranch = `prepare-release/${targetBranch}`; + console.log( + colorize(`Looking for existing PR from ${prBranch}...`, ansi.dim), + ); + const existingPR = await findPR({head: prBranch, cwd: repoRoot}); + + if (existingPR) { + console.log( + `${colorize('Found existing PR:', ansi.bright)} #${existingPR.number} (${existingPR.url})`, + ); + } else { + console.log( + colorize('No existing PR found. Will create one.', ansi.dim), + ); + } + + // 8. Create/reset the prepare-release branch from target branch HEAD + console.log( + colorize( + `Creating branch ${prBranch} from ${remoteName}/${targetBranch}...`, + ansi.dim, + ), + ); + await git.checkoutNewBranch(prBranch, `${remoteName}/${targetBranch}`); + + // 9. Run beachball bump + console.log(colorize('Running beachball bump...', ansi.bright)); + await bumpVersions({ + targetBranch, + remote: remoteName, + cwd: repoRoot, + }); + + // 10. Check if beachball actually changed anything + const status = await git.statusPorcelain(); + if (!status) { + console.log( + colorize( + 'beachball bump made no changes. Nothing to commit.', + ansi.yellow, + ), + ); + process.exit(0); + } + + // 11. Collect bumped package info for PR description + // Parse changed package.json paths from git status --porcelain output + const changedFiles = status + .split('\n') + .map(line => line.trim().split(/\s+/).pop()!) + .filter(f => f.endsWith('package.json')); + + const bumpedPackages = collectBumpedPackages(changedFiles, repoRoot); + console.log(generateConsoleSummary(bumpedPackages)); + + // 12. Stage all + commit + const commitMessage = `Version Packages (${targetBranch})`; + console.log(colorize(`Committing: "${commitMessage}"...`, ansi.dim)); + await git.stageAll(); + await git.commit(commitMessage); + + // 13. Force-push the branch + if (dryRun) { + console.log( + colorize( + `[DRY RUN] Would force-push ${prBranch} to ${remoteName}`, + ansi.yellow, + ), + ); + } else { + console.log( + colorize(`Force-pushing ${prBranch} to ${remoteName}...`, ansi.dim), + ); + await git.push(remoteName, prBranch, {force: true}); + } + + // 14. Create or update PR + const prTitle = `Version Packages (${targetBranch})`; + const prBody = generatePRBody(targetBranch, bumpedPackages); + + if (existingPR) { + if (dryRun) { + console.log( + colorize( + `[DRY RUN] Would update PR #${existingPR.number}`, + ansi.yellow, + ), + ); + } else { + console.log( + colorize(`Updating PR #${existingPR.number}...`, ansi.dim), + ); + await updatePR({ + number: existingPR.number, + body: prBody, + cwd: repoRoot, + }); + console.log( + `${colorize('PR updated:', ansi.green)} ${existingPR.url}`, + ); + } + } else { + if (dryRun) { + console.log( + colorize(`[DRY RUN] Would create PR: "${prTitle}"`, ansi.yellow), + ); + } else { + console.log(colorize('Creating pull request...', ansi.dim)); + const newPR = await createPR({ + head: prBranch, + base: targetBranch, + title: prTitle, + body: prBody, + cwd: repoRoot, + }); + console.log(`${colorize('PR created:', ansi.green)} ${newPR.url}`); + } + } + + // 15. Done + console.log(''); + console.log(colorize('Done!', ansi.green + ansi.bright)); + + if (dryRun) { + console.log(colorize('\nPR body that would be used:', ansi.dim)); + console.log(prBody); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`${colorize('Error:', ansi.red)} ${message}`); + process.exit(1); + } +})(); diff --git a/packages/@rnw-scripts/prepare-release/src/proc.ts b/packages/@rnw-scripts/prepare-release/src/proc.ts new file mode 100644 index 00000000000..da5a19913e2 --- /dev/null +++ b/packages/@rnw-scripts/prepare-release/src/proc.ts @@ -0,0 +1,120 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Async wrapper around Node.js child_process.spawn. + * Simplified from fork-sync/src/modules/proc.ts. + * + * @format + */ + +import {spawn as nodeSpawn} from 'child_process'; + +/** + * Error thrown when a spawned process exits with a non-zero code. + */ +export class ExecError extends Error { + readonly command: string; + readonly args: readonly string[]; + readonly cwd: string | undefined; + readonly exitCode: number | null; + readonly stderr: string; + + constructor(opts: { + command: string; + args: readonly string[]; + cwd?: string; + exitCode: number | null; + stderr: string; + }) { + const cmdStr = [opts.command, ...opts.args].join(' '); + super( + opts.stderr || + `Command failed with exit code ${opts.exitCode}: ${cmdStr}`, + ); + this.name = 'ExecError'; + this.command = opts.command; + this.args = opts.args; + this.cwd = opts.cwd; + this.exitCode = opts.exitCode; + this.stderr = opts.stderr; + } +} + +export interface SpawnOpts { + cwd?: string; + /** If set, return this value instead of throwing on non-zero exit */ + fallback?: string; + /** Extra environment variables (merged with process.env) */ + env?: Record; +} + +/** + * Spawn a command (no shell) and return its trimmed stdout. + * Throws ExecError on non-zero exit unless `fallback` is provided. + */ +export function spawn( + command: string, + args: readonly string[], + opts?: SpawnOpts, +): Promise { + return spawnImpl(command, [...args], opts, false); +} + +/** + * Execute a command string in a shell and return its trimmed stdout. + * Uses shell mode, needed for .cmd shims on Windows (npx, gh). + */ +export function exec(command: string, opts?: SpawnOpts): Promise { + return spawnImpl(command, [], opts, true); +} + +function spawnImpl( + command: string, + args: string[], + opts: SpawnOpts | undefined, + shell: boolean, +): Promise { + return new Promise((resolve, reject) => { + const child = nodeSpawn(command, args, { + cwd: opts?.cwd, + env: opts?.env ? {...process.env, ...opts.env} : undefined, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + shell, + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout!.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); + child.stderr!.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); + + child.on('error', err => { + reject(err); + }); + + child.on('close', exitCode => { + const stdout = Buffer.concat(stdoutChunks).toString('utf8').trimEnd(); + const stderr = Buffer.concat(stderrChunks).toString('utf8').trimEnd(); + + if (exitCode !== 0) { + if (opts?.fallback !== undefined) { + resolve(opts.fallback); + } else { + reject( + new ExecError({ + command, + args, + cwd: opts?.cwd, + exitCode, + stderr, + }), + ); + } + } else { + resolve(stdout); + } + }); + }); +} diff --git a/packages/@rnw-scripts/prepare-release/src/releaseSummary.ts b/packages/@rnw-scripts/prepare-release/src/releaseSummary.ts new file mode 100644 index 00000000000..0a1556ec889 --- /dev/null +++ b/packages/@rnw-scripts/prepare-release/src/releaseSummary.ts @@ -0,0 +1,145 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Parse bumped packages and generate PR description markdown. + * + * @format + */ + +import fs from 'fs'; +import path from 'path'; + +export interface BumpedPackage { + name: string; + version: string; + comments: Array<{comment: string; author: string}>; +} + +/** + * Collect information about packages that were bumped by beachball. + * + * For each changed package.json, reads the new version and parses + * CHANGELOG.json for the latest changelog entry. + * + * @param changedPackageJsonPaths Relative paths to package.json files + * that were modified by beachball bump (from git status --porcelain) + * @param repoRoot The repository root directory + */ +export function collectBumpedPackages( + changedPackageJsonPaths: string[], + repoRoot: string, +): BumpedPackage[] { + const bumped: BumpedPackage[] = []; + + for (const relPath of changedPackageJsonPaths) { + const fullPath = path.join(repoRoot, relPath); + if (!fs.existsSync(fullPath)) { + continue; + } + + let pkgJson: {name?: string; version?: string}; + try { + pkgJson = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + } catch { + continue; + } + + const name = pkgJson.name; + const version = pkgJson.version; + if (!name || !version) { + continue; + } + + // Try to read CHANGELOG.json from the same directory + const pkgDir = path.dirname(fullPath); + const changelogPath = path.join(pkgDir, 'CHANGELOG.json'); + const comments: Array<{comment: string; author: string}> = []; + + if (fs.existsSync(changelogPath)) { + try { + const changelog = JSON.parse( + fs.readFileSync(changelogPath, 'utf8'), + ); + + // CHANGELOG.json structure: + // { entries: [{ version, comments: { : [{ comment, author }] } }] } + const latest = changelog.entries?.[0]; + if (latest && latest.version === version) { + for (const typeComments of Object.values( + latest.comments || {}, + )) { + for (const c of typeComments as Array<{ + comment: string; + author: string; + }>) { + comments.push({ + comment: c.comment || '', + author: c.author || '', + }); + } + } + } + } catch { + // If CHANGELOG.json is malformed, skip comments + } + } + + bumped.push({name, version, comments}); + } + + // Sort by package name for consistent output + bumped.sort((a, b) => a.name.localeCompare(b.name)); + return bumped; +} + +/** + * Generate the pull request body markdown. + */ +export function generatePRBody( + targetBranch: string, + packages: BumpedPackage[], +): string { + const lines: string[] = []; + + lines.push('> This PR was auto-generated by `prepare-release`.'); + lines.push( + `> When ready to release, merge this PR into \`${targetBranch}\`.`, + ); + lines.push( + '> If not ready yet, this PR will be updated as more changes merge.', + ); + lines.push(''); + lines.push(`## Packages to Release (${packages.length})`); + lines.push(''); + + for (const pkg of packages) { + lines.push(`### ${pkg.name}@${pkg.version}`); + if (pkg.comments.length > 0) { + for (const c of pkg.comments) { + lines.push(`- ${c.comment}`); + } + } else { + lines.push('- *(dependency update)*'); + } + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Generate a console-friendly summary of bumped packages. + */ +export function generateConsoleSummary(packages: BumpedPackage[]): string { + if (packages.length === 0) { + return 'No packages were bumped.'; + } + + const lines: string[] = []; + lines.push(`Bumped ${packages.length} package(s):`); + for (const pkg of packages) { + lines.push(` ${pkg.name} => ${pkg.version}`); + } + return lines.join('\n'); +} diff --git a/packages/@rnw-scripts/prepare-release/tsconfig.json b/packages/@rnw-scripts/prepare-release/tsconfig.json new file mode 100644 index 00000000000..c62faa78baf --- /dev/null +++ b/packages/@rnw-scripts/prepare-release/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@rnw-scripts/ts-config", + "include": ["src"], + "exclude": ["node_modules"] +}