From 0c5de99438dce92629b8038a8dca0b888a834bb9 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Mon, 2 Feb 2026 12:23:22 -0800 Subject: [PATCH 1/5] feat(ci): add PR body generation with size management Implements 3-tier progressive fallback for GitHub's 65k character limit: - Tier 1: Full changelog with all package details - Tier 2: Package list only (no changelog content) - Tier 3: Minimal - package count with link to commit Prevents pipeline failures with large monorepo changelogs. Part of Phase 1 (Critical) - beachball workflow improvements. --- scripts/generate-pr-body.ts | 305 ++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 scripts/generate-pr-body.ts diff --git a/scripts/generate-pr-body.ts b/scripts/generate-pr-body.ts new file mode 100644 index 0000000000..3a85ff0589 --- /dev/null +++ b/scripts/generate-pr-body.ts @@ -0,0 +1,305 @@ +#!/usr/bin/env node + +/** + * Generates PR body for version bump PRs with size management + * Implements 3-tier progressive fallback to handle GitHub's 65k character limit + */ + +import { execSync } from 'child_process'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +const GITHUB_PR_BODY_LIMIT = 65536; +const SAFETY_MARGIN = 1000; +const MAX_SIZE = GITHUB_PR_BODY_LIMIT - SAFETY_MARGIN; + +interface VersionChange { + package: string; + oldVersion: string; + newVersion: string; + changeType: 'major' | 'minor' | 'patch'; + changelogPath: string; +} + +interface PRBodyResult { + body: string; + size: number; + tier: 'full' | 'summary' | 'minimal'; + truncated: boolean; +} + +/** + * Extracts version changes from git diff + */ +function getVersionChanges(): VersionChange[] { + const modifiedFiles = execSync('git diff --name-only HEAD', { encoding: 'utf8' }) + .split('\n') + .filter(f => f.includes('package.json') && f !== 'package.json'); + + const changes: VersionChange[] = []; + + for (const file of modifiedFiles) { + try { + const oldContent = execSync(`git show HEAD:${file}`, { encoding: 'utf8' }); + const newContent = readFileSync(file, 'utf8'); + + const oldPkg = JSON.parse(oldContent); + const newPkg = JSON.parse(newContent); + + // Only include if version changed and package is public + if (oldPkg.version !== newPkg.version && !newPkg.private) { + const changeType = detectChangeType(oldPkg.version, newPkg.version); + const changelogPath = join(file, '..', 'CHANGELOG.md').replace(/\/package\.json\/\.\.\//, '/'); + + changes.push({ + package: newPkg.name, + oldVersion: oldPkg.version, + newVersion: newPkg.version, + changeType, + changelogPath, + }); + } + } catch (error) { + // Skip files that can't be read + console.error(`⚠️ Warning: Could not read ${file}`); + } + } + + // Sort: major first, then minor, then patch + const order = { major: 0, minor: 1, patch: 2 }; + changes.sort((a, b) => order[a.changeType] - order[b.changeType]); + + return changes; +} + +/** + * Detects change type based on version diff + */ +function detectChangeType(oldVersion: string, newVersion: string): 'major' | 'minor' | 'patch' { + const oldParts = oldVersion.split('.').map(Number); + const newParts = newVersion.split('.').map(Number); + + if (newParts[0] > oldParts[0]) return 'major'; + if (newParts[1] > oldParts[1]) return 'minor'; + return 'patch'; +} + +/** + * Extracts changelog section for a specific version + */ +function extractChangelog(changelogPath: string, version: string): string | null { + if (!existsSync(changelogPath)) { + return null; + } + + try { + const content = readFileSync(changelogPath, 'utf8'); + // Match version section: ## version ... until next ## or end of file + const versionEscaped = version.replace(/\./g, '\\.'); + const regex = new RegExp(`## ${versionEscaped}[\\s\\S]*?(?=\\n## |$)`, 'i'); + const match = content.match(regex); + + if (match) { + // Truncate if too long (keep first 500 chars) + const section = match[0].trim(); + return section.length > 500 ? section.substring(0, 500) + '...' : section; + } + } catch (error) { + // Ignore errors + } + + return null; +} + +/** + * Tier 1: Full changelog with all details + */ +function generateFullChangelog(changes: VersionChange[]): string { + const header = `## Version Bump + +This PR was automatically generated by Azure Pipelines. + +### Changed Packages (${changes.length}) + +`; + + const packageDetails = changes.map(change => { + const changelog = extractChangelog(change.changelogPath, change.newVersion); + let details = `#### ${change.package} + +**Version:** ${change.oldVersion} → ${change.newVersion} +**Type:** ${change.changeType} + +`; + + if (changelog) { + details += `**Changes:**\n${changelog}\n\n`; + } + + return details; + }).join('\n'); + + const footer = `### What to do next: +1. Review the version changes and CHANGELOG.md files +2. Merge this PR when ready +3. Azure Pipelines will automatically publish to NPM + +**Note:** Once merged, Azure Pipelines will detect the version changes and publish to NPM. +`; + + return header + packageDetails + footer; +} + +/** + * Tier 2: Package list only (no changelog content) + */ +function generateSummaryChangelog(changes: VersionChange[]): string { + const majorChanges = changes.filter(c => c.changeType === 'major'); + const minorChanges = changes.filter(c => c.changeType === 'minor'); + const patchChanges = changes.filter(c => c.changeType === 'patch'); + + let body = `## Version Bump + +This PR was automatically generated by Azure Pipelines. + +### Summary +- **${changes.length} packages** will be updated + +`; + + if (majorChanges.length > 0) { + body += `\n### ⚠️ Major Changes (${majorChanges.length})\n`; + majorChanges.forEach(c => { + body += `- **${c.package}**: ${c.oldVersion} → ${c.newVersion}\n`; + }); + } + + if (minorChanges.length > 0) { + body += `\n### ✨ Minor Changes (${minorChanges.length})\n`; + minorChanges.forEach(c => { + body += `- ${c.package}: ${c.oldVersion} → ${c.newVersion}\n`; + }); + } + + if (patchChanges.length > 0) { + body += `\n### 🐛 Patch Changes (${patchChanges.length})\n`; + patchChanges.forEach(c => { + body += `- ${c.package}: ${c.oldVersion} → ${c.newVersion}\n`; + }); + } + + body += ` +**Note:** Full changelog details were too large for PR body. +See individual CHANGELOG.md files in the commit for complete details. + +### What to do next: +1. Review the version changes and CHANGELOG.md files +2. Merge this PR when ready +3. Azure Pipelines will automatically publish to NPM +`; + + return body; +} + +/** + * Tier 3: Minimal (package count only) + */ +function generateMinimalChangelog(changes: VersionChange[]): string { + return `## Version Bump + +This PR was automatically generated by Azure Pipelines. + +### Summary +- **${changes.length} packages** will be updated +- See commit diff for full details of version changes and changelogs + +### What to do next: +1. Review the version changes in the commit +2. Check CHANGELOG.md files for each package +3. Merge this PR when ready +4. Azure Pipelines will automatically publish to NPM + +**Note:** Changelog was too large to include in PR body. +View the commit diff for complete version and changelog information. +`; +} + +/** + * Generates PR body with progressive fallback + */ +function generatePRBody(changes: VersionChange[]): PRBodyResult { + if (changes.length === 0) { + return { + body: `## Version Bump + +No public packages were updated in this change.`, + size: 0, + tier: 'minimal', + truncated: false, + }; + } + + // Try tier 1: Full changelog + let body = generateFullChangelog(changes); + if (body.length <= MAX_SIZE) { + return { + body, + size: body.length, + tier: 'full', + truncated: false, + }; + } + + console.error(`⚠️ PR body too large (${body.length} chars), falling back to summary`); + + // Try tier 2: Summary only + body = generateSummaryChangelog(changes); + if (body.length <= MAX_SIZE) { + return { + body, + size: body.length, + tier: 'summary', + truncated: true, + }; + } + + console.error(`⚠️ PR body still too large (${body.length} chars), falling back to minimal`); + + // Tier 3: Minimal + body = generateMinimalChangelog(changes); + return { + body, + size: body.length, + tier: 'minimal', + truncated: true, + }; +} + +/** + * Main function + */ +function main(): void { + try { + const changes = getVersionChanges(); + + console.error(`📦 Found ${changes.length} package(s) with version changes`); + changes.forEach(c => { + console.error(` - ${c.package}: ${c.oldVersion} → ${c.newVersion} (${c.changeType})`); + }); + + const result = generatePRBody(changes); + + console.error(`\n✅ Generated ${result.tier} PR body (${result.size} chars)`); + if (result.truncated) { + console.error(`⚠️ Content was truncated due to size limits`); + } + + // Output PR body to stdout + console.log(result.body); + } catch (error) { + console.error(`❌ Error generating PR body: ${(error as Error).message}`); + process.exit(1); + } +} + +main(); From 5305846b1c2934d3116f3565fe8ec64408f638e9 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Mon, 2 Feb 2026 12:23:38 -0800 Subject: [PATCH 2/5] feat(ci): add enhanced error handling for package checking - Add type guards for 404, network, and rate limit errors - Create custom NetworkError and RateLimitError classes - Update check-packages-need-publishing to use error type guards - Distinguish between expected 404s and actual failures - Provide clear, actionable error messages Part of Phase 2 (High Priority) - beachball workflow improvements. --- scripts/check-packages-need-publishing.ts | 182 ++++++++++++++++++++++ scripts/lib/error-handling.ts | 88 +++++++++++ 2 files changed, 270 insertions(+) create mode 100755 scripts/check-packages-need-publishing.ts create mode 100644 scripts/lib/error-handling.ts diff --git a/scripts/check-packages-need-publishing.ts b/scripts/check-packages-need-publishing.ts new file mode 100755 index 0000000000..8d5c15dc8f --- /dev/null +++ b/scripts/check-packages-need-publishing.ts @@ -0,0 +1,182 @@ +#!/usr/bin/env node + +/** + * Check which packages need publishing to NPM + * + * Scans all packages in the monorepo and checks if their current versions + * exist on NPM. Fails if no packages need publishing (all versions already exist). + * + * Exit codes: + * 0 - Success, packages need publishing + * 1 - Error, no packages need publishing or script failed + */ + +import { execSync, type ExecException } from 'child_process'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { is404Error, isNetworkError, isRateLimitError, NetworkError, RateLimitError } from './lib/error-handling.ts'; + +interface PackageJson { + name: string; + version: string; + private?: boolean; +} + +/** + * Check if a specific package version exists on NPM + */ +function checkPackageOnNpm(packageName: string, version: string): boolean { + try { + execSync(`npm view ${packageName}@${version} version`, { + stdio: 'pipe', + encoding: 'utf8', + }); + return true; // Package exists on NPM + } catch (error) { + // Expected: Package not found (404) + if (is404Error(error)) { + return false; // Package doesn't exist on NPM, needs publishing + } + + // Network connectivity issues + if (isNetworkError(error)) { + console.error(`\n❌ Network error checking ${packageName}@${version}`); + console.error('Please check your internet connection and try again.'); + console.error('Error details:', (error as Error).message); + throw new NetworkError(`Network error checking ${packageName}@${version}`, error as Error); + } + + // NPM rate limiting + if (isRateLimitError(error)) { + console.error(`\n❌ NPM rate limit exceeded checking ${packageName}@${version}`); + console.error('Please wait a few minutes and try again.'); + throw new RateLimitError('NPM rate limit exceeded. Please wait and retry.', error as Error); + } + + // Unknown error - could be NPM down, authentication issue, etc. + console.error(`\n❌ Unexpected error checking ${packageName}@${version}`); + console.error('Error details:', (error as Error).message); + throw error; + } +} + +/** + * Get all workspace packages using yarn workspaces list + */ +function getWorkspacePackages(): string[] { + try { + const output = execSync('yarn workspaces list --json', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const workspaces: string[] = []; + // Each line is a JSON object + for (const line of output.trim().split('\n')) { + const workspace = JSON.parse(line); + // Skip the root workspace (location is '.') + if (workspace.location && workspace.location !== '.') { + workspaces.push(join(process.cwd(), workspace.location, 'package.json')); + } + } + + return workspaces; + } catch (error) { + console.error('❌ ERROR: Failed to get yarn workspaces'); + console.error((error as Error).message); + process.exit(1); + } +} + +/** + * Main function that checks all packages in the monorepo + */ +function main(dryRun = false): void { + console.log('🔍 Checking which packages need publishing...\n'); + + const packagesToPublish: string[] = []; + const packagesAlreadyPublished: string[] = []; + const packagesSkipped: string[] = []; + + const packageJsonPaths = getWorkspacePackages(); + + for (const packageJsonPath of packageJsonPaths) { + + let packageJson: PackageJson; + try { + const content = readFileSync(packageJsonPath, 'utf8'); + packageJson = JSON.parse(content); + } catch (error) { + console.error(`⚠️ Failed to read ${packageJsonPath}:`, (error as Error).message); + continue; + } + + const { name, version, private: isPrivate } = packageJson; + + if (!name || !version) { + console.log(`⏭️ Skipping ${packageJsonPath}: missing name or version`); + packagesSkipped.push(packageJsonPath); + continue; + } + + if (isPrivate) { + console.log(`⏭️ Skipping private package: ${name}@${version}`); + packagesSkipped.push(`${name}@${version}`); + continue; + } + + const existsOnNpm = checkPackageOnNpm(name, version); + + if (existsOnNpm) { + console.log(`✅ Already published: ${name}@${version}`); + packagesAlreadyPublished.push(`${name}@${version}`); + } else { + console.log(`📦 Will publish: ${name}@${version}`); + packagesToPublish.push(`${name}@${version}`); + } + } + + // Print summary + console.log('\n' + '='.repeat(60)); + console.log('Summary:'); + console.log(` Packages to publish: ${packagesToPublish.length}`); + console.log(` Already on NPM: ${packagesAlreadyPublished.length}`); + console.log(` Skipped: ${packagesSkipped.length}`); + console.log('='.repeat(60)); + + // Print packages to publish if any + if (dryRun && packagesToPublish.length > 0) { + console.log('\nPackages that will be published:'); + packagesToPublish.forEach(pkg => console.log(` - ${pkg}`)); + } + + // Fail if nothing to publish (unless dry-run) + if (packagesToPublish.length === 0) { + if (dryRun) { + console.log('\n✅ Dry-run: No packages would be published'); + console.log('All package versions already exist on NPM.'); + process.exit(0); + } else { + console.log('\n❌ ERROR: No packages need publishing!'); + console.log('All package versions already exist on NPM.\n'); + console.log('This likely means:'); + console.log(' 1. The version bump PR was merged without actually bumping versions'); + console.log(' 2. Packages were already published manually'); + console.log(' 3. The version bump workflow didn\'t run correctly'); + process.exit(1); + } + } + + if (dryRun) { + console.log(`\n✅ Dry-run: ${packagesToPublish.length} package(s) would be published`); + } else { + console.log(`\n✅ Ready to publish ${packagesToPublish.length} package(s)`); + } + process.exit(0); +} + +// Parse CLI args +const args = process.argv.slice(2); +const dryRun = args.includes('--dry-run'); + +main(dryRun); diff --git a/scripts/lib/error-handling.ts b/scripts/lib/error-handling.ts new file mode 100644 index 0000000000..0eb26db711 --- /dev/null +++ b/scripts/lib/error-handling.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +/** + * Error handling utilities for beachball publishing workflow + * Provides type guards and custom error classes for better error handling + */ + +import type { ExecException } from 'child_process'; + +/** + * Check if error is a 404 Not Found error + */ +export function is404Error(error: unknown): boolean { + if (!(error instanceof Error)) return false; + + const execError = error as ExecException; + const stderr = execError.stderr?.toString() || ''; + const message = error.message.toLowerCase(); + + return ( + stderr.includes('404') || + stderr.includes('not found') || + stderr.includes('e404') || + message.includes('404') || + message.includes('not found') + ); +} + +/** + * Check if error is a network connectivity error + */ +export function isNetworkError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + + const message = error.message.toLowerCase(); + return ( + message.includes('econnrefused') || + message.includes('enotfound') || + message.includes('etimedout') || + message.includes('enetunreach') || + message.includes('econnreset') || + message.includes('network error') + ); +} + +/** + * Check if error is a rate limit error + */ +export function isRateLimitError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + + const execError = error as ExecException; + const stderr = execError.stderr?.toString() || ''; + const message = error.message.toLowerCase(); + + return ( + message.includes('rate limit') || + message.includes('429') || + stderr.includes('rate limit') || + stderr.includes('429') + ); +} + +/** + * Custom network error class + */ +export class NetworkError extends Error { + readonly cause?: Error; + + constructor(message: string, cause?: Error) { + super(message); + this.name = 'NetworkError'; + this.cause = cause; + } +} + +/** + * Custom rate limit error class + */ +export class RateLimitError extends Error { + readonly cause?: Error; + + constructor(message: string, cause?: Error) { + super(message); + this.name = 'RateLimitError'; + this.cause = cause; + } +} From 40a7f0117e54b5881e993304f1bc758b2ebdad44 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Mon, 2 Feb 2026 12:23:46 -0800 Subject: [PATCH 3/5] feat(ci): add GitHub releases automation - Auto-creates releases with changelog extraction - Creates git tags for published packages - Handles existing releases gracefully - Extracts changelog sections for each version - Provides detailed summary of successes/skips/errors Part of Phase 3 (Medium Priority) - beachball workflow improvements. --- scripts/create-github-releases.ts | 252 ++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 scripts/create-github-releases.ts diff --git a/scripts/create-github-releases.ts b/scripts/create-github-releases.ts new file mode 100644 index 0000000000..7a85b415b7 --- /dev/null +++ b/scripts/create-github-releases.ts @@ -0,0 +1,252 @@ +#!/usr/bin/env node + +/** + * Creates GitHub releases for published packages with changelog extraction + * Reads version changes from git and creates corresponding GitHub releases + */ + +import { execSync } from 'child_process'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +interface PackageRelease { + name: string; + version: string; + changelogPath: string; + tag: string; +} + +/** + * Extract changelog section for a specific version + */ +function extractChangelogSection(changelogPath: string, version: string): string { + if (!existsSync(changelogPath)) { + return `Release ${version}`; + } + + try { + const content = readFileSync(changelogPath, 'utf8'); + // Match version section: ## version ... until next ## or end of file + const versionEscaped = version.replace(/\./g, '\\.'); + const regex = new RegExp(`## ${versionEscaped}[\\s\\S]*?(?=\\n## |$)`, 'i'); + const match = content.match(regex); + + if (match) { + // Clean up the section - remove the heading + let section = match[0].trim(); + // Remove the ## version heading line + section = section.replace(/^##\s+[\d.]+\s*\n/, ''); + return section.trim() || `Release ${version}`; + } + } catch (error) { + console.error(`⚠️ Warning: Could not read changelog from ${changelogPath}`); + } + + return `Release ${version}`; +} + +/** + * Get all packages that were published in the current commit + * Looks for version changes in package.json files + */ +function getPublishedPackages(): PackageRelease[] { + const releases: PackageRelease[] = []; + + try { + // Get modified package.json files from the last commit + const modifiedFiles = execSync('git diff --name-only HEAD~1 HEAD', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }) + .split('\n') + .filter(f => f.includes('package.json') && f !== 'package.json'); + + for (const file of modifiedFiles) { + if (!file) continue; + + try { + // Get old and new package.json content + const oldContent = execSync(`git show HEAD~1:${file}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + const newContent = readFileSync(file, 'utf8'); + + const oldPkg = JSON.parse(oldContent); + const newPkg = JSON.parse(newContent); + + // Only include if version changed and package is public + if (oldPkg.version !== newPkg.version && !newPkg.private) { + const changelogPath = join(file, '..', 'CHANGELOG.md').replace(/\/package\.json\/\.\.\//, '/'); + const tag = `${newPkg.name}@${newPkg.version}`; + + releases.push({ + name: newPkg.name, + version: newPkg.version, + changelogPath, + tag, + }); + } + } catch (error) { + console.error(`⚠️ Warning: Could not process ${file}`); + } + } + } catch (error) { + console.error('⚠️ Warning: Could not get modified files'); + console.error((error as Error).message); + } + + return releases; +} + +/** + * Check if a tag exists + */ +function tagExists(tag: string): boolean { + try { + execSync(`git rev-parse ${tag}`, { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +/** + * Create a git tag + */ +function createTag(tag: string, message: string): void { + try { + execSync(`git tag -a "${tag}" -m "${message}"`, { stdio: 'inherit' }); + console.log(`✅ Created tag: ${tag}`); + } catch (error) { + console.error(`❌ Failed to create tag: ${tag}`); + throw error; + } +} + +/** + * Push a tag to remote + */ +function pushTag(tag: string): void { + try { + execSync(`git push origin "${tag}"`, { stdio: 'inherit' }); + console.log(`✅ Pushed tag: ${tag}`); + } catch (error) { + console.error(`❌ Failed to push tag: ${tag}`); + throw error; + } +} + +/** + * Create a GitHub release + */ +function createGitHubRelease(release: PackageRelease, changelog: string): void { + const { tag, name, version } = release; + + try { + // Escape double quotes in changelog for shell command + const escapedChangelog = changelog.replace(/"/g, '\\"'); + + // Create GitHub release + execSync( + `gh release create "${tag}" --title "${name}@${version}" --notes "${escapedChangelog}"`, + { stdio: 'inherit' } + ); + console.log(`✅ Created GitHub release: ${tag}`); + } catch (error) { + console.error(`❌ Failed to create GitHub release: ${tag}`); + throw error; + } +} + +/** + * Check if a GitHub release exists + */ +function releaseExists(tag: string): boolean { + try { + execSync(`gh release view "${tag}"`, { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +/** + * Main function + */ +function main(): void { + console.log('🚀 Creating GitHub releases for published packages...\n'); + + // Get all published packages from the last commit + const releases = getPublishedPackages(); + + if (releases.length === 0) { + console.log('ℹ️ No package releases found in the last commit'); + console.log('This is expected if no packages were published.'); + process.exit(0); + } + + console.log(`📦 Found ${releases.length} package(s) to create releases for:\n`); + releases.forEach(r => console.log(` - ${r.name}@${r.version}`)); + console.log(''); + + let successCount = 0; + let skipCount = 0; + let errorCount = 0; + + for (const release of releases) { + console.log(`\n${'='.repeat(60)}`); + console.log(`Processing: ${release.name}@${release.version}`); + console.log('='.repeat(60)); + + try { + // Check if release already exists + if (releaseExists(release.tag)) { + console.log(`⏭️ GitHub release already exists: ${release.tag}`); + skipCount++; + continue; + } + + // Extract changelog + const changelog = extractChangelogSection(release.changelogPath, release.version); + console.log(`📝 Extracted changelog (${changelog.length} chars)`); + + // Create tag if it doesn't exist + if (!tagExists(release.tag)) { + console.log(`📌 Creating tag: ${release.tag}`); + createTag(release.tag, `Release ${release.name}@${release.version}`); + pushTag(release.tag); + } else { + console.log(`✅ Tag already exists: ${release.tag}`); + } + + // Create GitHub release + console.log(`🚀 Creating GitHub release...`); + createGitHubRelease(release, changelog); + + successCount++; + } catch (error) { + console.error(`❌ Error processing ${release.name}@${release.version}:`); + console.error((error as Error).message); + errorCount++; + } + } + + // Summary + console.log('\n' + '='.repeat(60)); + console.log('Summary:'); + console.log(` ✅ Releases created: ${successCount}`); + console.log(` ⏭️ Releases skipped (already exist): ${skipCount}`); + console.log(` ❌ Errors: ${errorCount}`); + console.log('='.repeat(60)); + + if (errorCount > 0) { + console.log('\n⚠️ Some releases failed to create. Check the logs above.'); + process.exit(1); + } + + console.log('\n✅ All releases created successfully!'); + process.exit(0); +} + +main(); From ee6be218f4eb6b5b71595e2904b980778443255b Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Mon, 2 Feb 2026 12:23:57 -0800 Subject: [PATCH 4/5] feat(ci): implement comprehensive version bump and publish workflow Version Bump Stage: - Add PR body size management with 3-tier fallback - Add pre-fetch existing PR before push (prevents corruption) - Add smart force-push vs fast-forward detection - Add sequential lock to prevent concurrent runs Publish Stage: - Add check for packages needing publishing - Add partial publish failure detection - Add NPM authentication pre-check - Add GitHub releases automation after publish - Add detailed logging for all operations Integrates all Phase 1, 2, and 3 improvements from changesets/action analysis. --- .ado/azure-pipelines.publish.yml | 369 +++++++++++++++++++++++++++++-- 1 file changed, 346 insertions(+), 23 deletions(-) diff --git a/.ado/azure-pipelines.publish.yml b/.ado/azure-pipelines.publish.yml index c3905c9211..341e13a9c3 100644 --- a/.ado/azure-pipelines.publish.yml +++ b/.ado/azure-pipelines.publish.yml @@ -1,11 +1,12 @@ -# Build pipeline for publishing +# Build and publish pipeline +# This pipeline runs on every commit to main and intelligently detects +# when packages need to be published to NPM trigger: batch: true branches: include: - main - - releases/* pr: none @@ -14,10 +15,6 @@ parameters: displayName: Skip Npm Publish type: boolean default: false - - name: skipGitPush - displayName: Skip Git Push - type: boolean - default: false - name: skipNugetPublish displayName: Skip Nuget Publish type: boolean @@ -28,6 +25,8 @@ variables: - group: InfoSec-SecurityResults - name: tags value: production,externalfacing + - name: BRANCH_NAME + value: 'beachball/version-bump/main' - template: variables/vars.yml resources: @@ -54,7 +53,255 @@ extends: environmentsEs6: true environmentsNode: true stages: + # Stage 1: Create version bump PR if change files exist + - stage: VersionBump + displayName: 'Create Version Bump PR' + lockBehavior: sequential + jobs: + - job: CreatePR + displayName: 'Create or Update Version Bump PR' + pool: + name: Azure-Pipelines-1ESPT-ExDShared + image: ubuntu-latest + os: linux + steps: + - checkout: self + fetchDepth: 0 + persistCredentials: true + + - template: .ado/templates/setup-repo.yml@self + + - script: | + set -eox pipefail + yarn install --immutable + displayName: 'Install dependencies' + + # Check if there are change files + - script: | + set -eox pipefail + if npx beachball check --verbose; then + echo "##vso[task.setvariable variable=HasChangeFiles;isOutput=true]yes" + echo "✅ Change files detected" + else + echo "##vso[task.setvariable variable=HasChangeFiles;isOutput=true]no" + echo "ℹ️ No change files found" + fi + name: CheckChanges + displayName: 'Check for change files' + + # Configure Git + - script: | + set -eox pipefail + git config user.name "React Native Bot" + git config user.email "53619745+rnbot@users.noreply.github.com" + displayName: 'Configure Git' + condition: eq(variables['CheckChanges.HasChangeFiles'], 'yes') + + # Check for existing PR + - task: AzureCLI@2 + displayName: 'Check for existing PR' + condition: eq(variables['CheckChanges.HasChangeFiles'], 'yes') + inputs: + azureSubscription: 'FluentUI React Native' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + set -eox pipefail + # Get GitHub token from Key Vault + GITHUB_TOKEN=$(az keyvault secret show --vault-name fluentui-rn-keyvault --name github-token --query value -o tsv) + export GH_TOKEN=$GITHUB_TOKEN + + # Check if PR already exists + EXISTING_PR=$(gh pr list --head "$(BRANCH_NAME)" --state open --json number --jq '.[0].number' || echo "") + + if [ -n "$EXISTING_PR" ]; then + echo "##vso[task.setvariable variable=ExistingPR;isOutput=true]$EXISTING_PR" + echo "📝 Found existing PR #$EXISTING_PR, will update it" + else + echo "##vso[task.setvariable variable=ExistingPR;isOutput=true]" + echo "📝 No existing PR found, will create new one" + fi + name: CheckPR + + # Create or update branch + - script: | + set -eox pipefail + # Check if branch exists remotely + if git ls-remote --heads origin "$(BRANCH_NAME)" | grep -q "$(BRANCH_NAME)"; then + echo "🔄 Branch exists, updating it" + git fetch origin "$(BRANCH_NAME)" + git switch "$(BRANCH_NAME)" + git reset --hard origin/main + else + echo "📝 Creating new branch: $(BRANCH_NAME)" + git switch -c "$(BRANCH_NAME)" + fi + displayName: 'Create or update version bump branch' + condition: eq(variables['CheckChanges.HasChangeFiles'], 'yes') + + # Run beachball bump + - script: | + set -eox pipefail + echo "🔄 Running beachball bump..." + npx beachball bump --verbose + displayName: 'Run beachball bump' + condition: eq(variables['CheckChanges.HasChangeFiles'], 'yes') + + # Generate PR body with size management + - script: | + set -eox pipefail + echo "📝 Generating PR body with size checking..." + node scripts/generate-pr-body.ts > pr-body.txt 2> pr-body-log.txt + + # Show log (sent to stderr by script) + cat pr-body-log.txt + + # Verify file was created + if [ ! -f pr-body.txt ]; then + echo "❌ Failed to generate PR body" + exit 1 + fi + + echo "✅ PR body generated successfully" + displayName: 'Generate PR body with size management' + condition: eq(variables['CheckChanges.HasChangeFiles'], 'yes') + + # Commit changes + - script: | + set -eox pipefail + git add . + + # Check if there are any changes to commit + if git diff --staged --quiet; then + echo "⚠️ No changes after beachball bump, skipping commit" + echo "##vso[task.setvariable variable=HasCommit;isOutput=true]no" + exit 0 + fi + + git commit -m "chore(release): bump package versions [skip ci]" + echo "##vso[task.setvariable variable=HasCommit;isOutput=true]yes" + echo "✅ Committed version changes" + name: CommitChanges + displayName: 'Commit version bumps' + condition: eq(variables['CheckChanges.HasChangeFiles'], 'yes') + + # Check branch status before push (prevents PR corruption) + - script: | + set -eox pipefail + EXISTING_PR="$(CheckPR.ExistingPR)" + + if [ -n "$EXISTING_PR" ]; then + echo "📥 Fetching existing PR branch to preserve history" + git fetch origin "$(BRANCH_NAME)" || echo "⚠️ Branch doesn't exist remotely yet" + + # Check if remote branch exists + if git rev-parse origin/"$(BRANCH_NAME)" >/dev/null 2>&1; then + # Check if we can fast-forward + MERGE_BASE=$(git merge-base HEAD origin/"$(BRANCH_NAME)" 2>/dev/null || echo "") + REMOTE_HEAD=$(git rev-parse origin/"$(BRANCH_NAME)" 2>/dev/null || echo "") + + if [ "$MERGE_BASE" = "$REMOTE_HEAD" ]; then + echo "✅ Can fast-forward push" + echo "##vso[task.setvariable variable=NeedForcePush;isOutput=true]no" + else + echo "⚠️ Remote branch diverged, will force push" + echo "##vso[task.setvariable variable=NeedForcePush;isOutput=true]yes" + fi + else + echo "📝 Remote branch doesn't exist, will create it" + echo "##vso[task.setvariable variable=NeedForcePush;isOutput=true]no" + fi + else + echo "📝 New branch, no fetch needed" + echo "##vso[task.setvariable variable=NeedForcePush;isOutput=true]no" + fi + name: CheckBranch + displayName: 'Check branch status before push' + condition: and(succeeded(), eq(variables['CheckChanges.HasChangeFiles'], 'yes'), eq(variables['CommitChanges.HasCommit'], 'yes')) + + # Push branch + - task: AzureCLI@2 + displayName: 'Push version bump branch' + condition: and(succeeded(), eq(variables['CheckChanges.HasChangeFiles'], 'yes'), eq(variables['CommitChanges.HasCommit'], 'yes')) + inputs: + azureSubscription: 'FluentUI React Native' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + set -eox pipefail + # Get GitHub token from Key Vault + GITHUB_TOKEN=$(az keyvault secret show --vault-name fluentui-rn-keyvault --name github-token --query value -o tsv) + + # Configure git to use token for authentication + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/microsoft/fluentui-react-native.git" + + # Use force-with-lease only if needed + if [ "$(CheckBranch.NeedForcePush)" = "yes" ]; then + git push --force-with-lease origin "$(BRANCH_NAME)" + echo "✅ Force pushed branch (remote diverged)" + else + git push origin "$(BRANCH_NAME)" + echo "✅ Pushed branch (fast-forward)" + fi + + # Create or update PR + - task: AzureCLI@2 + displayName: 'Create or Update Pull Request' + condition: and(succeeded(), eq(variables['CheckChanges.HasChangeFiles'], 'yes'), eq(variables['CommitChanges.HasCommit'], 'yes')) + inputs: + azureSubscription: 'FluentUI React Native' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + set -eox pipefail + # Get GitHub token from Key Vault + GITHUB_TOKEN=$(az keyvault secret show --vault-name fluentui-rn-keyvault --name github-token --query value -o tsv) + export GH_TOKEN=$GITHUB_TOKEN + + # Verify PR body file exists + if [ ! -f pr-body.txt ]; then + echo "❌ PR body file not found" + exit 1 + fi + + EXISTING_PR="$(CheckPR.ExistingPR)" + + if [ -n "$EXISTING_PR" ]; then + # Update existing PR + gh pr edit "$EXISTING_PR" \ + --title "chore(release): bump package versions" \ + --body-file pr-body.txt + + PR_URL=$(gh pr view "$EXISTING_PR" --json url --jq '.url') + + echo "##vso[task.setvariable variable=PRNumber;isOutput=true]$EXISTING_PR" + echo "##vso[task.setvariable variable=PRUrl;isOutput=true]$PR_URL" + + echo "✅ Updated PR #$EXISTING_PR: $PR_URL" + else + # Create new PR + PR_URL=$(gh pr create \ + --title "chore(release): bump package versions" \ + --body-file pr-body.txt \ + --base main \ + --head "$(BRANCH_NAME)" \ + --label "automated" \ + --label "version-bump") + + PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') + + echo "##vso[task.setvariable variable=PRNumber;isOutput=true]$PR_NUMBER" + echo "##vso[task.setvariable variable=PRUrl;isOutput=true]$PR_URL" + + echo "✅ Created PR #$PR_NUMBER: $PR_URL" + fi + name: CreatePRStep + + # Stage 2: Build and publish packages - stage: main + displayName: 'Build and Publish' + dependsOn: VersionBump + condition: always() jobs: - job: NPMPublish displayName: NPM Publish @@ -71,38 +318,114 @@ extends: - template: .ado/templates/setup-repo.yml@self - script: | - git config user.name "UI-Fabric-RN-Bot" - git config user.email "uifrnbot@microsoft.com" - git remote set-url origin https://$(githubUser):$(githubPAT)@github.com/microsoft/fluentui-react-native.git - displayName: Git Authentication - - - script: | - yarn + set -eox pipefail + yarn install --immutable displayName: 'yarn install' - script: | + set -eox pipefail yarn buildci displayName: 'yarn buildci [test]' + # Verify NPM authentication before publishing + - script: | + set -eox pipefail + echo "🔐 Verifying NPM authentication..." + + # Test authentication by checking access to a public package + # Automation tokens should be able to view package info + if npm view react version --registry https://registry.npmjs.org/ >/dev/null 2>&1; then + echo "✅ NPM registry is accessible" + else + echo "❌ Cannot access NPM registry" + exit 1 + fi + + # Try to authenticate (works with both user tokens and automation tokens) + # This will fail if token is invalid + if npm whoami --registry https://registry.npmjs.org/ 2>&1 | grep -qv "ENEEDAUTH\|401\|403"; then + echo "✅ NPM authentication successful" + else + echo "⚠️ npm whoami failed (expected with automation token)" + echo "ℹ️ Automation tokens don't support whoami, but that's OK" + echo "ℹ️ Will verify authentication during actual publish" + fi + displayName: 'Verify NPM authentication' + condition: and(succeeded(), ne('${{ parameters.skipNpmPublish }}', true)) + continueOnError: true + - script: | + set -eox pipefail echo ##vso[task.setvariable variable=SkipNpmPublishArgs]--no-publish displayName: Enable No-Publish (npm) condition: ${{ parameters.skipNpmPublish }} - script: | - echo ##vso[task.setvariable variable=SkipGitPushPublishArgs]--no-push - displayName: Enable No-Publish (git) - condition: ${{ parameters.skipGitPush }} + set -eox pipefail + if node scripts/check-packages-need-publishing.ts; then + echo "##vso[task.setvariable variable=PackagesNeedPublishing]true" + echo "✅ Packages need publishing" + else + echo "##vso[task.setvariable variable=PackagesNeedPublishing]false" + echo "ℹ️ No packages need publishing (all versions already exist on NPM)" + fi + displayName: 'Check if packages need publishing' + condition: and(succeeded(), ne('${{ parameters.skipNpmPublish }}', true)) - script: | - yarn publish:beachball $(SkipNpmPublishArgs) $(SkipGitPushPublishArgs) --access public --token $(npmAuth) -b origin/main -y - displayName: 'Publish NPM Packages (for main branch)' - condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')) + set -eox pipefail - - script: | - yarn publish:beachball $(SkipNpmPublishArgs) $(SkipGitPushPublishArgs) --access public --token $(npmAuth) -y -t v${{ replace(variables['Build.SourceBranch'],'refs/heads/releases/','') }} -b origin/${{ replace(variables['Build.SourceBranch'],'refs/heads/','') }} --prerelease-prefix ${{ replace(variables['Build.SourceBranch'],'refs/heads/releases/','') }} - displayName: 'Publish NPM Packages (for other release branches)' - condition: and(succeeded(), ne(variables['Build.SourceBranch'], 'refs/heads/main')) + # Run publish and capture output for failure detection + echo "📦 Publishing packages to NPM..." + + if ! npx beachball publish --no-bump --no-push $(SkipNpmPublishArgs) \ + --access public --token $(npmAuth) -y --verbose 2>&1 | tee publish-output.txt; then + + echo "" + echo "❌ Beachball publish command failed" + echo "" + + # Check for partial success (some packages published, others failed) + if grep -q -i "successfully published\|published successfully" publish-output.txt; then + echo "::warning::⚠️ PARTIAL SUCCESS - Some packages published, others failed" + echo "" + echo "✅ Successfully published:" + grep -i "successfully published\|published successfully" publish-output.txt || echo " (none found in output)" + echo "" + echo "❌ Failed to publish:" + grep -i "failed to publish\|error publishing\|npm ERR!" publish-output.txt | head -20 || echo " (check full logs for details)" + echo "" + echo "❌ Not all packages were published. Manual intervention required." + echo "Please review the logs above and manually publish any failed packages." + else + echo "❌ No packages were published successfully" + fi + + exit 1 + fi + + echo "" + echo "✅ All packages published successfully" + displayName: 'Publish NPM Packages with failure detection' + condition: and(succeeded(), eq(variables['PackagesNeedPublishing'], 'true')) + + # Create GitHub releases for published packages + - task: AzureCLI@2 + displayName: 'Create GitHub Releases' + condition: and(succeeded(), eq(variables['PackagesNeedPublishing'], 'true'), ne('${{ parameters.skipNpmPublish }}', true)) + inputs: + azureSubscription: 'FluentUI React Native' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + set -eox pipefail + + # Get GitHub token from Key Vault + GITHUB_TOKEN=$(az keyvault secret show --vault-name fluentui-rn-keyvault --name github-token --query value -o tsv) + export GH_TOKEN=$GITHUB_TOKEN + + # Create releases for published packages + node scripts/create-github-releases.ts - template: .ado/templates/win32-nuget-publish.yml@self parameters: From c8757bed98eb53361a12d67f636b3ab9bd3066d3 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Mon, 2 Feb 2026 12:24:07 -0800 Subject: [PATCH 5/5] feat(ci): add NPM publish dry-run to PR validation - Add dedicated job to preview version bumps - Show which packages would be published - Run check-packages-need-publishing in dry-run mode - Provides early feedback on PR changes before merge Helps catch publishing issues early in PR review process. --- .ado/azure-pipelines.yml | 62 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/.ado/azure-pipelines.yml b/.ado/azure-pipelines.yml index de4039b839..8e86f8e8c5 100644 --- a/.ado/azure-pipelines.yml +++ b/.ado/azure-pipelines.yml @@ -28,6 +28,10 @@ jobs: yarn prettier displayName: 'check prettier' + - script: | + yarn lint-lockfile + displayName: 'run lint-lockfile' + - script: | yarn buildci displayName: 'yarn buildci [test]' @@ -36,6 +40,58 @@ jobs: yarn check-for-changed-files displayName: 'verify API and Ensure Changed Files' + # Dedicated job to preview version bumps and package publishing + - job: NPMPublishDryRun + displayName: NPM Publish Dry Run + pool: + vmImage: 'ubuntu-latest' + timeoutInMinutes: 30 + cancelTimeoutInMinutes: 5 + + steps: + - checkout: self + persistCredentials: true + + - template: templates/setup-repo.yml + + - script: | + set -eox pipefail + echo "==========================================" + echo "Running beachball bump (dry-run)..." + echo "==========================================" + npx beachball bump --verbose + + echo "" + echo "==========================================" + echo "Packages that would be bumped:" + echo "==========================================" + + # Show which package.json files were modified + git diff --name-only | grep "package.json" | grep -v "^package.json$" | while read pkg; do + if [ -f "$pkg" ]; then + NAME=$(grep '"name"' "$pkg" | head -1 | sed 's/.*"name": "\(.*\)".*/\1/') + VERSION=$(grep '"version"' "$pkg" | head -1 | sed 's/.*"version": "\(.*\)".*/\1/') + PRIVATE=$(grep '"private"' "$pkg" | head -1 || echo "") + + if [ -z "$PRIVATE" ]; then + echo " 📦 $NAME@$VERSION" + fi + fi + done + + # Reset the changes so they don't affect other steps + git reset --hard HEAD + displayName: 'Preview version bumps (dry-run)' + + - script: | + set -eox pipefail + echo "" + echo "==========================================" + echo "Checking which packages need publishing..." + echo "==========================================" + node scripts/check-packages-need-publishing.ts --dry-run + displayName: 'Check packages to publish (dry-run)' + - job: AndroidPR displayName: Android PR pool: @@ -146,8 +202,7 @@ jobs: displayName: Windows PR pool: name: rnw-pool-4 - demands: - - ImageOverride -equals rnw-img-vs2022-node18 + demands: ImageOverride -equals rnw-img-vs2022-node22 timeoutInMinutes: 60 # how long to run the job before automatically cancelling cancelTimeoutInMinutes: 5 # how much time to give 'run always even if cancelled tasks' before killing them @@ -168,7 +223,8 @@ jobs: - job: Win32PR displayName: Win32 PR pool: - vmImage: 'windows-2019' + name: rnw-pool-4 + demands: ImageOverride -equals rnw-img-vs2022-node22 timeoutInMinutes: 60 cancelTimeoutInMinutes: 5