From 6612aebb74accff583b3ad6f8f7bdc6193eabd67 Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Fri, 30 Oct 2026 12:00:41 -0700 Subject: [PATCH 1/3] Title release PRs with a rocket prefix and add the release label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prepare-release bot now titles release PRs '🚀 []: Release N packages' (alias drops one trailing '-stable') and adds the 'release' label on create and refresh without touching other labels. The commit subject still starts with 'RELEASE:'. Dry run prints the title and label. --bump-only is unchanged. Adds unit tests for the package using the built-in Node.js test runner (node:test), with no new dependencies. (cherry picked from commit e5412d4505cf1a87da95cd2efa046e7011876782) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/@rnw-scripts/prepare-release/bin.js | 7 +- .../@rnw-scripts/prepare-release/package.json | 1 + .../prepare-release/src/github.ts | 6 +- .../prepare-release/src/prepareRelease.ts | 18 +- .../prepare-release/src/test/github.test.ts | 71 +++++ .../src/test/prepareRelease.test.ts | 255 ++++++++++++++++++ 6 files changed, 349 insertions(+), 9 deletions(-) create mode 100644 packages/@rnw-scripts/prepare-release/src/test/github.test.ts create mode 100644 packages/@rnw-scripts/prepare-release/src/test/prepareRelease.test.ts diff --git a/packages/@rnw-scripts/prepare-release/bin.js b/packages/@rnw-scripts/prepare-release/bin.js index aae8dc76ae9..c5b372ba9c7 100755 --- a/packages/@rnw-scripts/prepare-release/bin.js +++ b/packages/@rnw-scripts/prepare-release/bin.js @@ -8,4 +8,9 @@ */ require('source-map-support').install(); -require('./lib-commonjs/prepareRelease'); +require('./lib-commonjs/prepareRelease') + .main() + .catch(err => { + console.error(err); + process.exit(1); + }); diff --git a/packages/@rnw-scripts/prepare-release/package.json b/packages/@rnw-scripts/prepare-release/package.json index 878d3a0e3ff..97b80fb4619 100644 --- a/packages/@rnw-scripts/prepare-release/package.json +++ b/packages/@rnw-scripts/prepare-release/package.json @@ -13,6 +13,7 @@ "clean": "rnw-scripts clean", "lint": "rnw-scripts lint", "lint:fix": "rnw-scripts lint:fix", + "test": "node --test lib-commonjs/test/github.test.js lib-commonjs/test/prepareRelease.test.js", "watch": "rnw-scripts watch" }, "main": "lib-commonjs/prepareRelease.js", diff --git a/packages/@rnw-scripts/prepare-release/src/github.ts b/packages/@rnw-scripts/prepare-release/src/github.ts index 07ad6e264ae..40df4e6637e 100644 --- a/packages/@rnw-scripts/prepare-release/src/github.ts +++ b/packages/@rnw-scripts/prepare-release/src/github.ts @@ -90,7 +90,7 @@ export async function createPR(opts: { const result = await exec( `gh pr create --head "${opts.head}" --base "${opts.base}"` + ` --title "${escapeForShell(opts.title)}"` + - ` --body-file "${bodyFile}"${repoFlag}`, + ` --body-file "${bodyFile}"${repoFlag} --label release`, {cwd: opts.cwd}, ); @@ -104,7 +104,7 @@ export async function createPR(opts: { } /** - * Update an existing pull request's body text. + * Update an existing pull request's title, body and release label. * * Uses --body-file with a temp file to avoid shell escaping issues * with multiline markdown content on Windows cmd.exe. @@ -120,7 +120,7 @@ export async function updatePR(opts: { const repoFlag = opts.repo ? ` --repo "${opts.repo}"` : ''; await exec( `gh pr edit ${opts.number} --title "${escapeForShell(opts.title)}"` + - ` --body-file "${bodyFile}"${repoFlag}`, + ` --body-file "${bodyFile}"${repoFlag} --add-label release`, {cwd: opts.cwd}, ); }); diff --git a/packages/@rnw-scripts/prepare-release/src/prepareRelease.ts b/packages/@rnw-scripts/prepare-release/src/prepareRelease.ts index 765f0875429..9ad7c1036b5 100644 --- a/packages/@rnw-scripts/prepare-release/src/prepareRelease.ts +++ b/packages/@rnw-scripts/prepare-release/src/prepareRelease.ts @@ -158,7 +158,12 @@ async function detectRemote(git: GitRepo, repoUrl: string): Promise { // Main // --------------------------------------------------------------------------- -(async () => { +export function formatReleasePRTitle(branch: string, count: number): string { + const alias = branch.replace(/-stable$/, ''); + return `🚀 [${alias}]: Release ${count} ${count === 1 ? 'package' : 'packages'}`; +} + +export async function main(): Promise { // 1. Parse CLI arguments const {values} = parseArgs({ options: { @@ -352,14 +357,14 @@ async function detectRemote(git: GitRepo, repoUrl: string): Promise { } // 15. Create or update PR - const prTitle = `RELEASE: Releasing ${bumpedPackages.length} package(s) (${targetBranch})`; + const prTitle = formatReleasePRTitle(targetBranch, bumpedPackages.length); const prBody = generatePRBody(targetBranch, bumpedPackages); if (existingPR) { if (dryRun) { console.log( colorize( - `[DRY RUN] Would update PR #${existingPR.number}`, + `[DRY RUN] Would update PR #${existingPR.number}: "${prTitle}"; add label: release`, ansi.yellow, ), ); @@ -381,7 +386,10 @@ async function detectRemote(git: GitRepo, repoUrl: string): Promise { } else { if (dryRun) { console.log( - colorize(`[DRY RUN] Would create PR: "${prTitle}"`, ansi.yellow), + colorize( + `[DRY RUN] Would create PR: "${prTitle}"; label: release`, + ansi.yellow, + ), ); } else { console.log(colorize('Creating pull request...', ansi.dim)); @@ -419,4 +427,4 @@ async function detectRemote(git: GitRepo, repoUrl: string): Promise { console.error(`${colorize('Error:', ansi.red)} ${message}`); process.exit(1); } -})(); +} diff --git a/packages/@rnw-scripts/prepare-release/src/test/github.test.ts b/packages/@rnw-scripts/prepare-release/src/test/github.test.ts new file mode 100644 index 00000000000..7f0c05b6276 --- /dev/null +++ b/packages/@rnw-scripts/prepare-release/src/test/github.test.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import assert from 'node:assert/strict'; +import {afterEach, it, mock} from 'node:test'; +import fs from '@react-native-windows/fs'; +import {createPR, updatePR} from '../github'; +import * as proc from '../proc'; + +afterEach(() => mock.restoreAll()); + +// Cover both commands, optional repo flags, and cleanup on success or rejection. +for (const operation of ['create', 'edit'] as const) { + void it(`${operation} preserves shell arguments and cleans UTF-8 bodies`, async () => { + const execMock = mock.method(proc, 'exec', () => { + throw new Error('Unexpected exec call'); + }); + for (const repo of [undefined, 'owner/repo']) { + for (const failure of [undefined, 'label release not found']) { + const title = '🚀 [0.85]: Release 1 package'; + const body = 'Unicode 🚀\nsecond line'; + let bodyFile = ''; + execMock.mock.mockImplementationOnce(async (command, options) => { + bodyFile = command.match(/--body-file "([^"]+)"/)![1]; + const repoFlag = repo ? ` --repo "${repo}"` : ''; + const prefix = + operation === 'create' + ? 'gh pr create --head "prepare-release/0.85-stable" --base "0.85-stable"' + : 'gh pr edit 42'; + const labelFlag = operation === 'create' ? '--label' : '--add-label'; + assert.equal( + command, + `${prefix} --title "${title}" --body-file "${bodyFile}"${repoFlag} ${labelFlag} release`, + ); + assert.deepEqual(options, {cwd: 'repo root'}); + assert.equal(fs.readFileSync(bodyFile, 'utf8'), body); + if (failure) { + throw new Error(failure); + } + return 'https://github.com/owner/repo/pull/42\n'; + }); + const opts = {title, body, cwd: 'repo root', repo}; + const result = + operation === 'create' + ? createPR({ + ...opts, + head: 'prepare-release/0.85-stable', + base: '0.85-stable', + }) + : updatePR({...opts, number: 42}); + if (failure) { + await assert.rejects(result, new RegExp(failure)); + } else { + assert.deepEqual( + await result, + operation === 'create' + ? {number: 42, url: 'https://github.com/owner/repo/pull/42'} + : undefined, + ); + } + assert.notEqual(bodyFile, ''); + assert.equal(fs.existsSync(bodyFile), false); + } + } + assert.equal(execMock.mock.callCount(), 4); + }); +} diff --git a/packages/@rnw-scripts/prepare-release/src/test/prepareRelease.test.ts b/packages/@rnw-scripts/prepare-release/src/test/prepareRelease.test.ts new file mode 100644 index 00000000000..8a89e392d07 --- /dev/null +++ b/packages/@rnw-scripts/prepare-release/src/test/prepareRelease.test.ts @@ -0,0 +1,255 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {afterEach, beforeEach, it, mock} from 'node:test'; +import {isDeepStrictEqual} from 'node:util'; +import path from 'path'; +import {runInNewContext} from 'vm'; +import fs from '@react-native-windows/fs'; +import * as repoRoot from '@react-native-windows/find-repo-root'; +import {GitRepo} from '../git'; +import * as github from '../github'; +import * as beachball from '../beachballBump'; +import * as summary from '../releaseSummary'; +import {formatReleasePRTitle, main} from '../prepareRelease'; + +const pr = {number: 42, url: 'https://github.com/owner/repo/pull/42'}; +const sentinel = new Error('process.exit sentinel'); +const argv = process.argv; +function setupMocks() { + const git = GitRepo.prototype; + return { + exit: mock.method(process, 'exit', () => { + throw sentinel; + }), + log: mock.method(console, 'log', () => {}), + error: mock.method(console, 'error', () => {}), + root: mock.method(repoRoot, 'default', async () => 'repo root'), + read: mock.method(fs, 'readFileSync', () => + JSON.stringify({repository: {url: 'https://github.com/owner/repo'}}), + ), + git: { + remoteList: mock.method( + git, + 'remoteList', + async () => 'upstream\thttps://github.com/owner/repo (fetch)', + ), + currentBranch: mock.method(git, 'currentBranch', async () => 'main'), + statusPorcelain: mock.method( + git, + 'statusPorcelain', + async () => ' M packages/example/package.json', + ), + lsRemote: mock.method(git, 'lsRemote', async () => 'sha'), + fetch: mock.method(git, 'fetch', async () => {}), + checkout: mock.method(git, 'checkout', async () => {}), + checkoutNewBranch: mock.method(git, 'checkoutNewBranch', async () => {}), + stageAll: mock.method(git, 'stageAll', async () => {}), + commit: mock.method(git, 'commit', async () => {}), + push: mock.method(git, 'push', async () => {}), + }, + hasChangeFiles: mock.method(beachball, 'hasChangeFiles', () => true), + bumpVersions: mock.method(beachball, 'bumpVersions', async () => {}), + updateLockfile: mock.method(beachball, 'updateLockfile', async () => {}), + findPR: mock.method(github, 'findPR', async () => null), + createPR: mock.method(github, 'createPR', async () => pr), + updatePR: mock.method(github, 'updatePR', async () => {}), + collect: mock.method(summary, 'collectBumpedPackages', () => [ + {name: 'example', version: '1.0.0', comments: []}, + ]), + body: mock.method(summary, 'generatePRBody', () => 'PR body'), + }; +} +let mocks: ReturnType; +type Calls = {mock: {calls: {arguments: unknown[]}[]}}; +const calls = (fn: Calls) => + fn.mock.calls.map(call => Array.from(call.arguments)); +function calledWith(fn: Calls, ...args: unknown[]) { + assert.ok(calls(fn).some(actual => isDeepStrictEqual(actual, args))); +} +beforeEach(() => { + process.argv = [ + 'node', + 'prepare-release', + '--branch', + '0.85-stable', + '--no-color', + ]; + mocks = setupMocks(); +}); +afterEach(() => { + process.argv = argv; + mock.restoreAll(); +}); + +// Alias edge cases prove only one trailing suffix is removed; counts cover grammar. +for (const [branch, count, alias] of [ + ['main', 2, 'main'], + ['0.81-stable', 1, '0.81'], + ['0.85-stable', 2, '0.85'], + ['feature', 0, 'feature'], + ['a-stable-next', 2, 'a-stable-next'], + ['a-stable-stable', 1, 'a-stable'], + ['分支🚀', 1, '分支🚀'], +] as const) { + void it(`formats ${branch} with ${count} packages`, () => { + assert.equal( + formatReleasePRTitle(branch, count), + `🚀 [${alias}]: Release ${count} ${count === 1 ? 'package' : 'packages'}`, + ); + }); +} + +// New/existing PRs must share the title in live and dry-run paths. +for (const existing of [false, true]) { + void it(`creates or refreshes without changing commits (existing=${existing})`, async () => { + const {git, findPR, createPR, updatePR, exit} = mocks; + findPR.mock.mockImplementation(async () => (existing ? pr : null)); + await main(); + calledWith(git.commit, 'RELEASE: Releasing 1 package(s) (0.85-stable)'); + calledWith(git.push, 'upstream', 'prepare-release/0.85-stable', { + force: true, + }); + const common = { + title: '🚀 [0.85]: Release 1 package', + body: 'PR body', + cwd: 'repo root', + repo: 'owner/repo', + }; + calledWith( + existing ? updatePR : createPR, + existing + ? {...common, number: 42} + : {...common, head: 'prepare-release/0.85-stable', base: '0.85-stable'}, + ); + assert.equal((existing ? createPR : updatePR).mock.callCount(), 0); + calledWith(git.checkout, 'main'); + assert.equal(exit.mock.callCount(), 0); + }); + void it(`previews title and additive label without remote writes (existing=${existing})`, async () => { + const {git, findPR, log, bumpVersions, createPR, updatePR} = mocks; + process.argv.push('--dry-run'); + findPR.mock.mockImplementation(async () => (existing ? pr : null)); + await main(); + const output = calls(log).flat().join('\n'); + assert.ok(output.includes('🚀 [0.85]: Release 1 package";')); + assert.ok(output.includes('label: release')); + calledWith(log, 'PR body'); + assert.equal(bumpVersions.mock.callCount(), 1); + calledWith(git.commit, 'RELEASE: Releasing 1 package(s) (0.85-stable)'); + for (const fn of [git.push, createPR, updatePR]) { + assert.equal(fn.mock.callCount(), 0); + } + }); +} +// Bump-only wins even when dry-run is also passed. +for (const dry of [false, true]) { + void it(`bumps only without PR lookup or preview (dry=${dry})`, async () => { + process.argv.push('--bump-only', ...(dry ? ['--dry-run'] : [])); + await assert.rejects(main(), err => err === sentinel); + assert.deepEqual(calls(mocks.exit)[0], [0]); + assert.equal(mocks.bumpVersions.mock.callCount(), 1); + assert.equal( + mocks.bumpVersions.mock.calls[0].arguments[0]!.targetBranch, + '0.85-stable', + ); + for (const fn of [ + mocks.findPR, + mocks.git.push, + mocks.git.commit, + mocks.createPR, + mocks.updatePR, + mocks.git.checkoutNewBranch, + mocks.updateLockfile, + ]) { + assert.equal(fn.mock.callCount(), 0); + } + assert.doesNotMatch(calls(mocks.log).flat().join('\n'), /🚀|label:|Would/); + }); +} + +// Model the two gh failure boundaries: no PR on create; same PR after partial edit. +for (const existing of [false, true]) { + void it(`fails visibly, restores, and retries without duplicate PRs (existing=${existing})`, async () => { + const {git, findPR, createPR, updatePR, exit, error} = mocks; + findPR.mock.mockImplementation(async () => (existing ? pr : null)); + (existing ? updatePR : createPR).mock.mockImplementationOnce(async () => { + throw new Error('label release not found'); + }); + await assert.rejects(main(), err => err === sentinel); + assert.deepEqual(calls(exit)[0], [1]); + calledWith(error, 'Error: label release not found'); + assert.equal(git.push.mock.callCount(), 1); + calledWith(git.checkout, 'main'); + await main(); + assert.equal(findPR.mock.callCount(), 2); + assert.equal((existing ? updatePR : createPR).mock.callCount(), 2); + assert.equal((existing ? createPR : updatePR).mock.callCount(), 0); + if (existing) { + assert.deepEqual( + updatePR.mock.calls.map(({arguments: [opts]}) => opts!.number), + [42, 42], + ); + } + }); +} +void it('keeps the no-changes exit successful', async () => { + mocks.git.statusPorcelain.mock.mockImplementation(async () => ''); + await assert.rejects(main(), err => err === sentinel); + assert.deepEqual(calls(mocks.exit)[0], [0]); + assert.equal(mocks.git.commit.mock.callCount(), 0); + // Sentinel unwinds finally/catch; a real process.exit skips restoration. +}); +void it('is inert on import', () => { + const modulePath = require.resolve('../prepareRelease'); + const cached = require.cache[modulePath]; + try { + delete require.cache[modulePath]; + require(modulePath); + } finally { + require.cache[modulePath] = cached; + } + assert.equal(mocks.root.mock.callCount(), 0); + assert.equal(mocks.log.mock.callCount(), 0); + assert.equal(mocks.exit.mock.callCount(), 0); +}); +void it('rejects argument parsing before the internal catch', async () => { + process.argv.push('--unknown-option'); + await assert.rejects(main(), /Unknown option/); + assert.equal(mocks.error.mock.callCount(), 0); + assert.equal(mocks.exit.mock.callCount(), 0); +}); +void it('bin installs source maps, invokes main, and reports rejection with exit 1', async () => { + const failure = new Error('main rejected'); + const install = mock.fn(); + let completion: Promise | undefined; + const invoke = mock.fn(() => ({ + catch: (handler: (err: Error) => void) => { + completion = Promise.reject(failure).catch(handler); + }, + })); + const requireMock = mock.fn((name: string) => + name === 'source-map-support' ? {install} : {main: invoke}, + ); + runInNewContext(readFileSync(path.join(__dirname, '../../bin.js'), 'utf8'), { + require: requireMock, + console, + process, + }); + assert.ok(completion); + await assert.rejects(completion, err => err === sentinel); + assert.deepEqual(calls(requireMock), [ + ['source-map-support'], + ['./lib-commonjs/prepareRelease'], + ]); + assert.equal(install.mock.callCount(), 1); + assert.equal(invoke.mock.callCount(), 1); + calledWith(mocks.error, failure); + assert.deepEqual(calls(mocks.exit)[0], [1]); +}); From a5586fb6d8a32d610adb7496d3a81809a3348d07 Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Fri, 30 Oct 2026 16:52:34 -0700 Subject: [PATCH 2/3] Detect release builds from a commit marker instead of the PR title The prepare-release bot now adds a release-branch marker line to the body of its RELEASE: commit. CI reads the full commit message of the built commit with the new .ado/scripts/detectReleaseBuild.js: a single well-formed marker that names the built branch makes it a release build; a malformed, duplicate, or mismatched marker fails Setup in auto mode. Commits without a marker still use the RELEASE: first-line rule. PR builds keep the prepare-release/ branch rule, and the isReleaseBuild parameter still forces either scenario. The source tag step now also requires IsReleaseBuild=True and a release commit, so forcing a developer build no longer tags. The release pipeline is unchanged. Adds node:test coverage for the detector and wires it into the root test script. (cherry picked from commit 5b2afbf404c70089bbda1ff3ac13b240f9c3c843) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .ado/build-template.yml | 56 +---- .ado/scripts/detectReleaseBuild.js | 113 +++++++++ .ado/tests/detect-release-build.test.cjs | 236 ++++++++++++++++++ docs/build-pipelines.md | 15 +- package.json | 2 +- .../prepare-release/src/prepareRelease.ts | 2 +- .../src/test/prepareRelease.test.ts | 11 +- 7 files changed, 383 insertions(+), 52 deletions(-) create mode 100644 .ado/scripts/detectReleaseBuild.js create mode 100644 .ado/tests/detect-release-build.test.cjs diff --git a/.ado/build-template.yml b/.ado/build-template.yml index 119a460bca6..7ff0076891b 100644 --- a/.ado/build-template.yml +++ b/.ado/build-template.yml @@ -112,58 +112,23 @@ extends: pool: ${{ parameters.AgentPool.Medium }} timeoutInMinutes: 60 # CodeQL requires 3x usual build timeout steps: - # 1. Detect whether this is a release build or a developer build - - pwsh: | - $param = '${{ parameters.isReleaseBuild }}' - $buildReason = $env:BUILD_REASON - $sourceMessage = $env:SOURCE_MESSAGE - $prSourceBranch = $env:PR_SOURCE_BRANCH - - if ($param -eq 'true') { - $isRelease = $true - Write-Host "Release build forced by parameter" - } - elseif ($param -eq 'false') { - $isRelease = $false - Write-Host "Developer build forced by parameter" - } - else { - # Auto-detect - if ($buildReason -eq 'PullRequest') { - # PR: check source branch - $isRelease = $prSourceBranch.StartsWith('prepare-release/') - Write-Host "PR source branch: $prSourceBranch -> isRelease=$isRelease" - } - else { - # CI/Manual: check commit message - $firstLine = ($sourceMessage -split "`n")[0].Trim() - $isRelease = $firstLine.StartsWith("RELEASE:") - Write-Host "Commit message first line: $firstLine -> isRelease=$isRelease" - } - } - - Write-Host "RESULT: isReleaseBuild=$isRelease" - Write-Host "##vso[task.setvariable variable=isReleaseBuild;isOutput=true]$isRelease" - Write-Host "##vso[task.setvariable variable=isReleaseBuild]$isRelease" - name: detectScenario - displayName: Detect build scenario - env: - BUILD_REASON: $(Build.Reason) - SOURCE_MESSAGE: $(Build.SourceVersionMessage) - PR_SOURCE_BRANCH: $(System.PullRequest.SourceBranch) - - # 2. Full checkout (needed for beachball bump on developer builds; + # 1. Full checkout (needed for beachball bump on developer builds; # harmless ~1 min overhead on release builds where bump is skipped) - template: .ado/templates/checkout-full.yml@self parameters: persistCredentials: true - # 3. Node.js (always needed — setVersionEnvVars.js requires it) + # 2. Node.js (always needed — setVersionEnvVars.js requires it) - task: UseNode@1 inputs: version: '24.x' displayName: Use Node.js 24.x + # 3. Detect before any developer-build version bump + - pwsh: node .ado/scripts/detectReleaseBuild.js '${{ parameters.isReleaseBuild }}' + name: detectScenario + displayName: Detect build scenario + # CI is network-isolated; acquire the feed token used by the Verdaccio uplink. - ${{ if eq(parameters.buildEnvironment, 'Continuous') }}: - template: .ado/templates/auth-npm-feed.yml@self @@ -422,7 +387,10 @@ extends: # Create NuGet packages - job: RNWNuget displayName: Pack NuGet - variables: [template: .ado/variables/shared.yml@self] + variables: + - template: .ado/variables/shared.yml@self + - name: isReleaseCommit + value: $[stageDependencies.Setup.Setup.outputs['detectScenario.isReleaseCommit']] dependsOn: - RnwNpmPack - Linting @@ -500,7 +468,7 @@ extends: git -c http.extraheader="$authHeader" tag $tag "$(Build.SourceVersion)" git -c http.extraheader="$authHeader" push origin $tag displayName: Tag release sources - condition: and(succeeded(), startsWith(variables['Build.SourceVersionMessage'], 'RELEASE:')) + condition: and(succeeded(), eq(variables['IsReleaseBuild'], 'True'), eq(variables['isReleaseCommit'], 'True')) env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) diff --git a/.ado/scripts/detectReleaseBuild.js b/.ado/scripts/detectReleaseBuild.js new file mode 100644 index 00000000000..84e5866cf60 --- /dev/null +++ b/.ado/scripts/detectReleaseBuild.js @@ -0,0 +1,113 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * @format + */ + +const {execFileSync} = require('node:child_process'); + +// Commit text and diagnostics are untrusted ADO log content, +// not logging commands. +const safeLog = text => String(text).replace(/##vso\[/gi, '##vso ['); +const flag = value => (value ? 'True' : 'False'); + +function validateInputs(env, override) { + if (!['auto', 'true', 'false'].includes(override)) { + throw new Error('Expected one override argument: auto, true, or false'); + } + const required = ['BUILD_REASON']; + required.push( + ...(env.BUILD_REASON === 'PullRequest' + ? ['SYSTEM_PULLREQUEST_SOURCEBRANCH'] + : ['BUILD_SOURCEVERSION', 'BUILD_SOURCEBRANCH']), + ); + for (const name of required) { + if (!env[name]?.trim()) { + throw new Error(`Missing required input: ${name}`); + } + } +} + +// Pure classification: no Git, environment reads, or logging on this path. +function detectReleaseBuild(env, override, message = '') { + validateInputs(env, override); + let isReleaseCommit = false; + let detected; + let warning; + let rule; + if (env.BUILD_REASON === 'PullRequest') { + detected = + env.SYSTEM_PULLREQUEST_SOURCEBRANCH.startsWith('prepare-release/'); + rule = `PR source branch: ${env.SYSTEM_PULLREQUEST_SOURCEBRANCH}`; + } else { + const lines = message.split(/\r?\n/); + const markers = lines.filter(line => line.startsWith('RNW-Release-Branch')); + if (markers.length) { + const branch = env.BUILD_SOURCEBRANCH.replace(/^refs\/heads\//, ''); + const match = /^RNW-Release-Branch: (\S+)$/.exec(markers[0]); + rule = `Marker found: ${markers.join(' | ')}; target branch: ${branch}`; + if (markers.length !== 1 || !match || match[1] !== branch) { + const problem = + 'Invalid release marker (malformed, duplicate, or branch mismatch).' + + ` ${rule}`; + if (override === 'auto') { + throw new Error(problem); + } + warning = problem; + } else { + isReleaseCommit = true; + } + } else { + isReleaseCommit = lines[0].trim().startsWith('RELEASE:'); + rule = `Legacy subject rule used: ${lines[0]}`; + } + detected = isReleaseCommit; + } + return { + isReleaseBuild: override === 'auto' ? detected : override === 'true', + isReleaseCommit, + warning, + rule, + }; +} + +function main() { + try { + const override = process.argv.length === 3 ? process.argv[2] : undefined; + validateInputs(process.env, override); + let message = ''; + if (process.env.BUILD_REASON !== 'PullRequest') { + message = execFileSync( + 'git', + ['show', '-s', '--format=%B', process.env.BUILD_SOURCEVERSION], + {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}, + ); + console.log(`Commit message:\n${safeLog(message)}`); + } + const result = detectReleaseBuild(process.env, override, message); + if (result.warning) { + console.log( + `##vso[task.logissue type=warning]${safeLog(result.warning)}`, + ); + } + console.log(safeLog(`${result.rule}; override=${override}`)); + const build = flag(result.isReleaseBuild); + const commit = flag(result.isReleaseCommit); + console.log(`RESULT: isReleaseBuild=${build}; isReleaseCommit=${commit}`); + console.log( + `##vso[task.setvariable variable=isReleaseBuild;isOutput=true]${build}`, + ); + console.log( + `##vso[task.setvariable variable=isReleaseCommit;isOutput=true]${commit}`, + ); + console.log(`##vso[task.setvariable variable=isReleaseBuild]${build}`); + } catch (error) { + console.error(`##vso[task.logissue type=error]${safeLog(error.message)}`); + process.exitCode = 1; + } +} + +module.exports = {detectReleaseBuild, main}; +if (require.main === module) { + main(); +} diff --git a/.ado/tests/detect-release-build.test.cjs b/.ado/tests/detect-release-build.test.cjs new file mode 100644 index 00000000000..83bb5787d39 --- /dev/null +++ b/.ado/tests/detect-release-build.test.cjs @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const assert = require('node:assert/strict'); +const {execFileSync, spawnSync} = require('node:child_process'); +const {mkdtempSync, rmSync, readFileSync} = require('node:fs'); +const {tmpdir} = require('node:os'); +const path = require('node:path'); +const {test} = require('node:test'); +const {detectReleaseBuild} = require('../scripts/detectReleaseBuild'); +const script = path.resolve(__dirname, '../scripts/detectReleaseBuild.js'); +const env = { + BUILD_REASON: 'IndividualCI', + BUILD_SOURCEVERSION: 'sha', + BUILD_SOURCEBRANCH: 'refs/heads/main', +}; +const marker = 'RNW-Release-Branch: main'; +const canonical = `Rewritten title\n\n${marker}`; +const classify = (message, override = 'auto', inputs = env) => + detectReleaseBuild(inputs, override, message); + +// Variants cover squash bodies, branch normalization, and ignored mentions. +for (const [message, expected, branch = env.BUILD_SOURCEBRANCH] of [ + [canonical, true], + ...['main', '0.85-stable', 'feature/refs/heads/main'].flatMap(branch => + [branch, `refs/heads/${branch}`].map(source => [ + `title\nRNW-Release-Branch: ${branch}`, + true, + source, + ]), + ), + [canonical.replaceAll('\n', '\r\n'), true], + [`title\n${'body'.repeat(60)}\n${marker}`, true], + [ + `rocket title\n\n* bot commit\n\n${marker}\n\n* lock update\nCo-authored-by: Test `, + true, + ], + [ + ' RELEASE: historical (#16248)\r\n\r\n* RELEASE: bot\n* update lock file', + true, + ], + ['RELEASE: single (#16505)', true], + // PowerShell String.StartsWith is case-sensitive, unlike its -like operator. + ['rELEASE: mixed-case legacy', false], + ['ordinary\nRELEASE: body only', false], + ['Rewritten old release title without metadata', false], + ...[ + 'prose RNW-Release-Branch: main', + '* RNW-Release-Branch: main', + ' RNW-Release-Branch: main', + ].map(text => [`ordinary\n${text}`, false]), +]) { + test(`classifies message ${JSON.stringify(message.slice(0, 65))}`, () => { + const result = classify(message, 'auto', { + ...env, + BUILD_SOURCEBRANCH: branch, + }); + assert.equal(result.isReleaseBuild, expected); + assert.equal(result.isReleaseCommit, expected); + assert.match( + result.rule, + message.includes('\nRNW-Release-Branch:') + ? /Marker found:/ + : /Legacy subject rule used:/, + ); + }); +} +// Invalid metadata cannot fall back to legacy subjects, even under overrides. +for (const bad of [ + 'RNW-Release-Branch:', + 'RNW-Release-Branch: main', + 'RNW-Release-Branch: main ', + 'RNW-Release-Branch: main other', + 'RNW-Release-Branch:main', + 'RNW-Release-BranchExtra', + `${marker}\n${marker}`, + `${marker}\nRNW-Release-Branch: other`, + 'RNW-Release-Branch: other', + 'RNW-Release-Branch: Main', +]) { + test(`rejects invalid metadata ${JSON.stringify(bad)}`, () => { + const message = `RELEASE: legacy\n${bad}`; + assert.throws(() => classify(message), /Invalid release marker/); + for (const override of ['true', 'false']) { + const result = classify(message, override); + assert.equal(result.isReleaseBuild, override === 'true'); + assert.equal(result.isReleaseCommit, false); + assert.match(result.warning, /Invalid release marker/); + } + }); +} +// Metadata identity is independent of the effective build override. +for (const [override, on, off] of [ + ['auto', true, false], + ['true', true, true], + ['false', false, false], +]) { + test(`keeps commit identity independent of ${override}`, () => { + for (const message of [canonical, 'RELEASE: legacy', 'ordinary']) { + const result = classify(message, override); + const release = message !== 'ordinary'; + assert.equal(result.isReleaseCommit, release); + assert.equal(result.isReleaseBuild, release ? on : off); + } + for (const branch of [ + 'prepare-release/main', + 'ordinary', + 'refs/heads/prepare-release/main', + ]) { + const result = classify('', override, { + BUILD_REASON: 'PullRequest', + SYSTEM_PULLREQUEST_SOURCEBRANCH: branch, + }); + assert.equal(result.isReleaseCommit, false); + assert.equal( + result.isReleaseBuild, + branch.startsWith('prepare-release/') ? on : off, + ); + } + }); +} + +test('CLI reads exact SHA, emits safe exact outputs, and fails closed', () => { + const cwd = mkdtempSync(path.join(tmpdir(), 'rnw-release-')); + const gitEnv = {...process.env}; + delete gitEnv.GIT_DIR; + delete gitEnv.GIT_WORK_TREE; + const options = {cwd, env: gitEnv, encoding: 'utf8'}; + const identity = [ + 'user.name=RNW-Test', + 'user.email=rnw-test@example.invalid', + 'commit.gpgsign=false', + ].flatMap(value => ['-c', value]); + const git = (...args) => + execFileSync('git', [...identity, ...args], options).trim(); + const run = (inputs, args = ['auto']) => + spawnSync(process.execPath, [script, ...args], { + ...options, + env: {...gitEnv, ...inputs}, + }); + const outputs = (result, build, commit, prefix = '##vso[') => { + assert.equal(result.status, 0, result.stderr); + assert.deepEqual( + result.stdout.split(/\r?\n/).filter(line => line.startsWith(prefix)), + [ + `##vso[task.setvariable variable=isReleaseBuild;isOutput=true]${build}`, + `##vso[task.setvariable variable=isReleaseCommit;isOutput=true]${commit}`, + `##vso[task.setvariable variable=isReleaseBuild]${build}`, + ], + ); + }; + const fails = result => { + assert.notEqual(result.status, 0); + assert.match(result.stderr, /##vso\[task.logissue type=error\]/); + assert.doesNotMatch(result.stdout + result.stderr, /task.setvariable/); + }; + try { + git('init'); + const commit = message => git('commit', '--allow-empty', '-m', message); + commit(canonical); + const sha = git('rev-parse', 'HEAD'); + commit('ordinary\n\n##vso[task.setvariable variable=hostile]True'); + const inputs = {...env, BUILD_SOURCEVERSION: sha}; + const first = run(inputs); + outputs(first, 'True', 'True'); + assert.match(first.stdout, /Marker found: RNW-Release-Branch: main/); + outputs(run(inputs, ['false']), 'False', 'True'); + const second = run({ + ...inputs, + BUILD_SOURCEVERSION: git('rev-parse', 'HEAD'), + }); + outputs(second, 'False', 'False'); + assert.match(second.stdout, /##vso \[task.setvariable variable=hostile/); + assert.match(second.stdout, /Legacy subject rule used:/); + const pr = { + BUILD_REASON: 'PullRequest', + BUILD_SOURCEVERSION: '', + BUILD_SOURCEBRANCH: '', + SYSTEM_PULLREQUEST_SOURCEBRANCH: 'prepare-release/0.85-stable', + }; + outputs(run(pr), 'True', 'False'); + for (const override of ['auto', 'true', 'false']) { + fails(run({...inputs, BUILD_SOURCEVERSION: '0'.repeat(40)}, [override])); + } + for (const name of Object.keys(env)) { + fails(run({...inputs, [name]: ''})); + } + fails(run({...pr, SYSTEM_PULLREQUEST_SOURCEBRANCH: ''})); + for (const args of [[], ['invalid'], ['auto', 'extra']]) + fails(run(inputs, args)); + const mismatched = {...inputs, BUILD_SOURCEBRANCH: 'refs/heads/other'}; + fails(run(mismatched)); + const forced = run(mismatched, ['true']); + assert.match(forced.stdout, /##vso\[task.logissue type=warning\]/); + outputs(forced, 'True', 'False', '##vso[task.setvariable'); + } finally { + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('pipeline maps metadata across stages and tags only successful releases', () => { + const yaml = readFileSync( + path.resolve(__dirname, '../build-template.yml'), + 'utf8', + ).replaceAll('\r\n', '\n'); + // Bound each job by the next job or stage, not by a global substring match. + const sections = yaml.split(/^ {0,8}- (?:job|stage): /m); + const setup = sections.find(section => + section.startsWith('Setup\n displayName: Setup\n'), + ); + const nuget = sections.find(section => section.startsWith('RNWNuget\n')); + assert.match( + setup, + /checkout-full\.yml[\s\S]*UseNode@1[\s\S]*name: detectScenario[\s\S]*displayName: Remove postinstall/, + ); + assert.match( + setup, + /node \.ado\/scripts\/detectReleaseBuild\.js '\$\{\{ parameters.isReleaseBuild \}\}'/, + ); + const variables = nuget.slice(0, nuget.indexOf(' steps:')); + assert.match( + variables, + /- name: isReleaseCommit\s+value: \$\[stageDependencies.Setup.Setup.outputs\['detectScenario.isReleaseCommit'\]\]/, + ); + const condition = + "and(succeeded(), eq(variables['IsReleaseBuild'], 'True'), eq(variables['isReleaseCommit'], 'True'))"; + assert.ok( + nuget.includes( + `displayName: Tag release sources\n condition: ${condition}`, + ), + ); + assert.match( + nuget, + /if eq\(parameters.buildEnvironment, 'Continuous'\).*\n.*- pwsh: \|\n.*\$tag =/, + ); +}); diff --git a/docs/build-pipelines.md b/docs/build-pipelines.md index e29835c85eb..7d4d0bbe52e 100644 --- a/docs/build-pipelines.md +++ b/docs/build-pipelines.md @@ -26,7 +26,14 @@ Setup ──┬── Build ──── CLI ### Setup -Detects whether this is a **release build** (commit message starts with `RELEASE:`) or a **developer build**. For developer builds, runs beachball to bump versions. Publishes version variables for all downstream stages. +In auto mode, CI detects a **release build** from a commit body line +`RNW-Release-Branch: ` matching the built branch, or a `RELEASE:` first +line for older commits without a marker; otherwise it is a **developer build**. +A malformed, duplicate, or branch-mismatched marker fails Setup, including when +manually queuing CI on `prepare-release/`. PR builds instead use the +`prepare-release/` source-branch rule. The `isReleaseBuild` parameter can force +either scenario. Developer builds run beachball to bump versions. Setup +publishes version variables for downstream stages. ### Build @@ -79,10 +86,12 @@ PREfast, SpotBugs, and Bandit are disabled (no actionable findings for this repo 1. Developer merges a PR to `main` 2. **CI pipeline** runs: build, test, sign, pack 3. On success, a separate **prepare-release-bot** pipeline creates/updates a release PR (e.g., `prepare-release/main`) -4. When the release PR is merged (commit message: `RELEASE: ...`), CI runs again as a **release build**: +4. After merging a release PR, CI runs as a **release build** in auto mode with + matching marker metadata (or the legacy `RELEASE:` first line): - Skips beachball bump (versions already committed) - Builds, tests, signs, packs - - Tags the source commit (`react-native-windows_v`) + - Tags the source commit (`react-native-windows_v`) only when RNWNuget + succeeds, IsReleaseBuild is True, and the commit itself is a release commit. 5. **Release pipeline** triggers on successful CI, publishing packages to npm and NuGet feeds ## Key Files diff --git a/package.json b/package.json index 8424e4ab9ce..75505d55244 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "postinstall": "yarn build", "release-notes": "yarn workspace @rnw-scripts/generate-release-notes release-notes", "spellcheck": "npx cspell", - "test": "lage test --verbose --passWithNoTests", + "test": "node --test .ado/tests/detect-release-build.test.cjs && lage test --verbose --passWithNoTests", "validate-overrides": "react-native-platform-override validate", "fork-sync": "node packages/@rnw-scripts/fork-sync/lib-commonjs/sync.js", "fork-sync:status": "yarn fork-sync --dep fmt --status" diff --git a/packages/@rnw-scripts/prepare-release/src/prepareRelease.ts b/packages/@rnw-scripts/prepare-release/src/prepareRelease.ts index 9ad7c1036b5..22f46633236 100644 --- a/packages/@rnw-scripts/prepare-release/src/prepareRelease.ts +++ b/packages/@rnw-scripts/prepare-release/src/prepareRelease.ts @@ -321,7 +321,7 @@ export async function main(): Promise { console.log(generateConsoleSummary(bumpedPackages)); // 13. Stage all + commit - const commitMessage = `RELEASE: Releasing ${bumpedPackages.length} package(s) (${targetBranch})`; + const commitMessage = `RELEASE: Releasing ${bumpedPackages.length} package(s) (${targetBranch})\n\nRNW-Release-Branch: ${targetBranch}`; console.log(colorize(`Committing: "${commitMessage}"...`, ansi.dim)); await git.stageAll(); await git.commit(commitMessage); diff --git a/packages/@rnw-scripts/prepare-release/src/test/prepareRelease.test.ts b/packages/@rnw-scripts/prepare-release/src/test/prepareRelease.test.ts index 8a89e392d07..bdff5a9cb64 100644 --- a/packages/@rnw-scripts/prepare-release/src/test/prepareRelease.test.ts +++ b/packages/@rnw-scripts/prepare-release/src/test/prepareRelease.test.ts @@ -20,6 +20,8 @@ import * as summary from '../releaseSummary'; import {formatReleasePRTitle, main} from '../prepareRelease'; const pr = {number: 42, url: 'https://github.com/owner/repo/pull/42'}; +const commitMessage = + 'RELEASE: Releasing 1 package(s) (0.85-stable)\n\nRNW-Release-Branch: 0.85-stable'; const sentinel = new Error('process.exit sentinel'); const argv = process.argv; function setupMocks() { @@ -108,11 +110,12 @@ for (const [branch, count, alias] of [ // New/existing PRs must share the title in live and dry-run paths. for (const existing of [false, true]) { - void it(`creates or refreshes without changing commits (existing=${existing})`, async () => { + void it(`creates or refreshes with full target branch metadata (existing=${existing})`, async () => { const {git, findPR, createPR, updatePR, exit} = mocks; findPR.mock.mockImplementation(async () => (existing ? pr : null)); await main(); - calledWith(git.commit, 'RELEASE: Releasing 1 package(s) (0.85-stable)'); + calledWith(git.commit, commitMessage); + assert.equal(git.commit.mock.callCount(), 1); calledWith(git.push, 'upstream', 'prepare-release/0.85-stable', { force: true, }); @@ -140,9 +143,11 @@ for (const existing of [false, true]) { const output = calls(log).flat().join('\n'); assert.ok(output.includes('🚀 [0.85]: Release 1 package";')); assert.ok(output.includes('label: release')); + assert.equal(output.split(commitMessage).length - 1, 1); calledWith(log, 'PR body'); assert.equal(bumpVersions.mock.callCount(), 1); - calledWith(git.commit, 'RELEASE: Releasing 1 package(s) (0.85-stable)'); + calledWith(git.commit, commitMessage); + assert.equal(git.commit.mock.callCount(), 1); for (const fn of [git.push, createPR, updatePR]) { assert.equal(fn.mock.callCount(), 0); } From 50abe01929d51e0d563bfd82cd3fa821b6e17388 Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Sat, 31 Oct 2026 17:26:55 -0700 Subject: [PATCH 3/3] Address Copilot review: body-only release marker and retry test - Read the `RNW-Release-Branch` marker only from the commit body, so a commit whose subject is the marker line is not treated as a release. - Test that a rerun refreshes a PR that GitHub created before `gh` failed, instead of creating a second one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .ado/scripts/detectReleaseBuild.js | 5 ++++- .ado/tests/detect-release-build.test.cjs | 2 ++ .../src/test/prepareRelease.test.ts | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.ado/scripts/detectReleaseBuild.js b/.ado/scripts/detectReleaseBuild.js index 84e5866cf60..978e3013483 100644 --- a/.ado/scripts/detectReleaseBuild.js +++ b/.ado/scripts/detectReleaseBuild.js @@ -41,7 +41,10 @@ function detectReleaseBuild(env, override, message = '') { rule = `PR source branch: ${env.SYSTEM_PULLREQUEST_SOURCEBRANCH}`; } else { const lines = message.split(/\r?\n/); - const markers = lines.filter(line => line.startsWith('RNW-Release-Branch')); + // The marker is a body line; the subject never counts as one. + const markers = lines + .slice(1) + .filter(line => line.startsWith('RNW-Release-Branch')); if (markers.length) { const branch = env.BUILD_SOURCEBRANCH.replace(/^refs\/heads\//, ''); const match = /^RNW-Release-Branch: (\S+)$/.exec(markers[0]); diff --git a/.ado/tests/detect-release-build.test.cjs b/.ado/tests/detect-release-build.test.cjs index 83bb5787d39..8bf6cad925e 100644 --- a/.ado/tests/detect-release-build.test.cjs +++ b/.ado/tests/detect-release-build.test.cjs @@ -49,6 +49,8 @@ for (const [message, expected, branch = env.BUILD_SOURCEBRANCH] of [ '* RNW-Release-Branch: main', ' RNW-Release-Branch: main', ].map(text => [`ordinary\n${text}`, false]), + [marker, false], + [`${marker}\n\nordinary body`, false], ]) { test(`classifies message ${JSON.stringify(message.slice(0, 65))}`, () => { const result = classify(message, 'auto', { diff --git a/packages/@rnw-scripts/prepare-release/src/test/prepareRelease.test.ts b/packages/@rnw-scripts/prepare-release/src/test/prepareRelease.test.ts index bdff5a9cb64..ce38d9cd332 100644 --- a/packages/@rnw-scripts/prepare-release/src/test/prepareRelease.test.ts +++ b/packages/@rnw-scripts/prepare-release/src/test/prepareRelease.test.ts @@ -204,6 +204,24 @@ for (const existing of [false, true]) { } }); } +// gh can fail after GitHub created the PR (e.g. a lost response); the rerun +// must find that PR and refresh it instead of creating a second one. +void it('refreshes a PR that was created before gh failed', async () => { + const {findPR, createPR, updatePR, exit} = mocks; + findPR.mock.mockImplementationOnce(async () => pr, 1); + createPR.mock.mockImplementationOnce(async () => { + throw new Error('lost response'); + }); + await assert.rejects(main(), err => err === sentinel); + assert.deepEqual(calls(exit)[0], [1]); + await main(); + assert.equal(findPR.mock.callCount(), 2); + assert.equal(createPR.mock.callCount(), 1); + assert.deepEqual( + updatePR.mock.calls.map(({arguments: [opts]}) => opts!.number), + [42], + ); +}); void it('keeps the no-changes exit successful', async () => { mocks.git.statusPorcelain.mock.mockImplementation(async () => ''); await assert.rejects(main(), err => err === sentinel);