diff --git a/.ado/azure-pipelines.publish.yml b/.ado/azure-pipelines.publish.yml index c3905c9211..93fc0341fb 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,205 @@ extends: environmentsEs6: true environmentsNode: true stages: + # Stage 1: Create version bump PR if change files exist + - stage: VersionBump + displayName: 'Create Version Bump PR' + 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') + + # 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') + + # 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" + + # Force push since we might be updating an existing branch + git push --force-with-lease origin "$(BRANCH_NAME)" + echo "āœ… Pushed branch to origin" + + # 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 + + # Create PR body + PR_BODY=$(cat <<'EOFBODY' + ## Version Bump + + This PR was automatically generated by Azure Pipelines. + + ### 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 merge and publish to NPM. + EOFBODY + ) + + EXISTING_PR="$(CheckPR.ExistingPR)" + + if [ -n "$EXISTING_PR" ]; then + # Update existing PR + echo "$PR_BODY" | gh pr edit "$EXISTING_PR" \ + --title "chore(release): bump package versions" \ + --body-file - + + 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=$(echo "$PR_BODY" | gh pr create \ + --title "chore(release): bump package versions" \ + --body-file - \ + --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 +268,39 @@ 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]' - 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 }} - - - 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 + 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) -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')) + set -eox pipefail + # Use --no-bump and --no-push because versions have already been committed via version bump PR + npx beachball publish --no-bump --no-push $(SkipNpmPublishArgs) --access public --token $(npmAuth) -y --verbose + displayName: 'Publish NPM Packages' + condition: and(succeeded(), eq(variables['PackagesNeedPublishing'], 'true')) - template: .ado/templates/win32-nuget-publish.yml@self parameters: diff --git a/.ado/azure-pipelines.yml b/.ado/azure-pipelines.yml index b65685c253..f00cf873df 100644 --- a/.ado/azure-pipelines.yml +++ b/.ado/azure-pipelines.yml @@ -44,6 +44,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: diff --git a/.ado/templates/setup-repo.yml b/.ado/templates/setup-repo.yml index 171dd961ec..9b8ca92db2 100644 --- a/.ado/templates/setup-repo.yml +++ b/.ado/templates/setup-repo.yml @@ -7,8 +7,8 @@ steps: - task: UseNode@1 inputs: - version: '22.x' - displayName: 'Use Node.js 22.x' + version: '24' + displayName: 'Use Node.js 24' - script: | yarn diff --git a/.node-version b/.node-version deleted file mode 100644 index 8fdd954df9..0000000000 --- a/.node-version +++ /dev/null @@ -1 +0,0 @@ -22 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47598bb0d3..768a54a476 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -236,6 +236,22 @@ This repo manages semantic versioning and publishing using [Beachball](https://g 1. `yarn change` will take you through a command line wizard to generate change files 2. Make sure to push the newly generated change file +#### Publishing Workflow + +The repository uses an automated publishing workflow powered by a single Azure Pipeline (`.ado/azure-pipelines.publish.yml`): + +1. **Change Files**: Contributors create change files using `yarn change` in their PRs +2. **Version Bump PR**: When change files are merged to `main`, Azure Pipelines automatically creates/updates a version bump PR + - This PR is updated automatically as more changes are merged + - The PR shows all packages that will be published and their new versions +3. **Review and Merge**: Maintainers review the version bump PR and merge when ready +4. **Automatic Publishing**: After the version bump PR is merged, Azure Pipelines automatically detects the version changes and publishes packages to NPM + - The pipeline intelligently skips publishing if all package versions already exist on NPM + +**Branch Support**: Only the `main` branch is configured for automatic publishing. Release branches are not supported in this workflow. + +**For Maintainers**: The version bump PR is created by Azure Pipelines using a fixed branch name (`beachball/version-bump/main`). This PR will be automatically updated as new change files are merged to main. Do not manually close or recreate this PR unless necessary. + #### Testing changes Before you create a pull request, test your changes with the FluentUI Tester on the platforms that are affected by your change. For more information on the FluentUI Tester, please follow instructions in the [FluentUI Tester readme](./apps/fluent-tester/README.md). diff --git a/beachball.config.js b/beachball.config.mts similarity index 81% rename from beachball.config.js rename to beachball.config.mts index f7be9faeef..7021041f97 100644 --- a/beachball.config.js +++ b/beachball.config.mts @@ -1,8 +1,9 @@ -const fs = require('fs'); -const path = require('path'); -const execSync = require('child_process').execSync; +import fs from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; +import type { BeachballConfig } from 'beachball'; -module.exports = { +const config: BeachballConfig = { disallowedChangeTypes: ['major'], hooks: { prepublish: (packagePath) => { @@ -36,8 +37,13 @@ module.exports = { }, }; -function getPackagesToInclude() { +/** + * Get list of packages to include in the changelog + */ +function getPackagesToInclude(): string[] { const content = fs.readFileSync(path.resolve('packages/libraries/core/src/index.ts'), 'utf8'); const matches = Array.from(content.matchAll(new RegExp("'(@.*)'", 'g')), (m) => m[1]); return matches; } + +export default config; diff --git a/scripts/check-packages-need-publishing.ts b/scripts/check-packages-need-publishing.ts new file mode 100755 index 0000000000..21fcbbc623 --- /dev/null +++ b/scripts/check-packages-need-publishing.ts @@ -0,0 +1,167 @@ +#!/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'; + +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) { + // npm view exits with code 1 when package doesn't exist (404) + // Check if this is a "not found" error vs a real error (network, etc) + const execError = error as ExecException; + const stderr = execError.stderr?.toString() || ''; + if (stderr.includes('404') || stderr.includes('Not Found')) { + return false; // Package doesn't exist on NPM + } + // For other errors (network issues, npm down, etc), throw so we don't incorrectly + // report that packages need publishing when we can't actually check NPM + 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/test-package-check.sh b/scripts/test-package-check.sh new file mode 100755 index 0000000000..3d99776c5c --- /dev/null +++ b/scripts/test-package-check.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# Test script to compare beachball bump output with check-packages-need-publishing.ts + +set -e + +# Activate mise if available +if [ -f "$HOME/.local/bin/mise" ]; then + eval "$("$HOME/.local/bin/mise" activate bash)" +fi + +echo "==========================================" +echo "Testing package check script" +echo "==========================================" +echo "" + +# Step 1: Run beachball bump in dry-run mode to see what it would change +echo "Step 1: Running beachball bump (dry-run)..." +echo "==========================================" + +# Create a temporary branch to test +TEMP_BRANCH="test-package-check-$(date +%s)" +git switch -c "$TEMP_BRANCH" 2>/dev/null || git checkout -b "$TEMP_BRANCH" + +# Run beachball bump and capture the output +echo "" +echo "Running: npx beachball bump --verbose" +mise exec -- npx beachball bump --verbose 2>&1 | tee /tmp/beachball-bump-output.txt + +echo "" +echo "==========================================" +echo "Step 2: Extracting packages bumped by beachball..." +echo "==========================================" + +# Get list of modified package.json files +BUMPED_PACKAGES=$(git diff --name-only | grep "package.json" | grep -v "^package.json$" || true) + +if [ -z "$BUMPED_PACKAGES" ]; then + echo "āš ļø No packages were bumped by beachball" + echo "This might mean there are no change files or they've already been processed" +else + echo "Beachball bumped the following package.json files:" + echo "$BUMPED_PACKAGES" + echo "" + + # Extract package names and versions + echo "Package versions after bump:" + for pkg in $BUMPED_PACKAGES; 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 +fi + +echo "" +echo "==========================================" +echo "Step 3: Running check-packages-need-publishing.ts..." +echo "==========================================" +echo "" + +mise exec -- node scripts/check-packages-need-publishing.ts --dry-run + +echo "" +echo "==========================================" +echo "Comparison complete!" +echo "==========================================" +echo "" +echo "To clean up:" +echo " git checkout main" +echo " git branch -D $TEMP_BRANCH"