From 1a68225875eb81d221aa8e20e30662558e4925e1 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:30:47 -0700 Subject: [PATCH 1/5] Fix split package publishing flows Restore the old publishing cohort semantics in the split bump pipelines, filter package artifacts through the approved feed proxy, and allow recovery publishing from a specific bump run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../azure-pipelines/esrp-publish-rush.yaml | 5 + .../esrp-publish-rushstack.yaml | 5 + .../templates/bump-versions-stages.yaml | 22 ++ .../templates/esrp-publish-stages.yaml | 28 +- .../azure-pipelines/templates/pack.yaml | 39 ++- common/config/rush/.npmrc | 1 + common/scripts/publish-cohort.js | 253 ++++++++++++++++++ 7 files changed, 335 insertions(+), 18 deletions(-) create mode 100644 common/scripts/publish-cohort.js diff --git a/common/config/azure-pipelines/esrp-publish-rush.yaml b/common/config/azure-pipelines/esrp-publish-rush.yaml index a54830bb92..576b1a4f79 100644 --- a/common/config/azure-pipelines/esrp-publish-rush.yaml +++ b/common/config/azure-pipelines/esrp-publish-rush.yaml @@ -30,6 +30,10 @@ parameters: displayName: 'Commit SHA Override (for testing)' type: string default: $(Build.SourceVersion) + - name: BumpPipelineRunIdOverride + displayName: 'Bump pipeline run ID override (for recovery)' + type: number + default: 0 variables: - name: FORCE_COLOR @@ -68,3 +72,4 @@ extends: NpmTag: ${{ parameters.NpmTag }} DryRun: ${{ parameters.DryRun }} CommitShaOverride: ${{ parameters.CommitShaOverride }} + BumpPipelineRunIdOverride: ${{ parameters.BumpPipelineRunIdOverride }} diff --git a/common/config/azure-pipelines/esrp-publish-rushstack.yaml b/common/config/azure-pipelines/esrp-publish-rushstack.yaml index b420f39a52..7c8adcf125 100644 --- a/common/config/azure-pipelines/esrp-publish-rushstack.yaml +++ b/common/config/azure-pipelines/esrp-publish-rushstack.yaml @@ -30,6 +30,10 @@ parameters: displayName: 'Commit SHA Override (for testing)' type: string default: $(Build.SourceVersion) + - name: BumpPipelineRunIdOverride + displayName: 'Bump pipeline run ID override (for recovery)' + type: number + default: 0 variables: - name: FORCE_COLOR @@ -68,3 +72,4 @@ extends: NpmTag: ${{ parameters.NpmTag }} DryRun: ${{ parameters.DryRun }} CommitShaOverride: ${{ parameters.CommitShaOverride }} + BumpPipelineRunIdOverride: ${{ parameters.BumpPipelineRunIdOverride }} diff --git a/common/config/azure-pipelines/templates/bump-versions-stages.yaml b/common/config/azure-pipelines/templates/bump-versions-stages.yaml index cabc237130..00b77748f0 100644 --- a/common/config/azure-pipelines/templates/bump-versions-stages.yaml +++ b/common/config/azure-pipelines/templates/bump-versions-stages.yaml @@ -71,10 +71,25 @@ stages: - template: /common/config/azure-pipelines/templates/build.yaml@self + # "noRush" is a pipeline cohort, not a Rush version policy. The old publishing flow + # achieved the separation by processing non-Rush changes before the Rush lockstep bump. + # Preserve that behavior while the two cohorts run in independent pipelines. + - script: > + node common/scripts/publish-cohort.js partition-changes + --cohort ${{ parameters.VersionPolicyName }} + --backup-path $(Agent.TempDirectory)/excluded-publish-changes + displayName: 'Select ${{ parameters.VersionPolicyName }} change files' + - template: /common/config/azure-pipelines/templates/bump-versions.yaml@self parameters: VersionPolicyName: ${{ parameters.VersionPolicyName }} + - script: > + node common/scripts/publish-cohort.js restore-changes + --backup-path $(Agent.TempDirectory)/excluded-publish-changes + displayName: 'Restore other publishing cohort change files' + condition: succeededOrFailed() + - bash: | git add -u -- common/changes git add -- '**/CHANGELOG.*' '**/package.json' @@ -117,6 +132,13 @@ stages: parameters: VersionPolicyName: ${{ parameters.VersionPolicyName }} + - bash: | + if [ "$(HasChanges)" = "true" ] && [ "$(HasPackages)" != "true" ]; then + echo "##[error]Version bump produced no unpublished package tarballs." + exit 1 + fi + displayName: 'Verify package artifact' + - template: /common/config/azure-pipelines/templates/post-publish.yaml@self - template: /common/config/azure-pipelines/templates/push-and-create-github-pr.yaml@self diff --git a/common/config/azure-pipelines/templates/esrp-publish-stages.yaml b/common/config/azure-pipelines/templates/esrp-publish-stages.yaml index 54f9e85b24..e92803fc3a 100644 --- a/common/config/azure-pipelines/templates/esrp-publish-stages.yaml +++ b/common/config/azure-pipelines/templates/esrp-publish-stages.yaml @@ -24,6 +24,10 @@ parameters: type: boolean - name: CommitShaOverride type: string + # Allows a packaging run to be replayed when a previous release artifact was incomplete. + - name: BumpPipelineRunIdOverride + type: number + default: 0 stages: - stage: Prepare${{ parameters.StageNameSuffix }} @@ -60,13 +64,23 @@ stages: displayName: 'Initialize artifact staging directories' condition: always() - - template: /common/config/azure-pipelines/templates/find-bump-pipeline-run.yaml@self - parameters: - PipelineId: ${{ parameters.BumpPipelineId }} - CommitSha: ${{ coalesce(parameters.CommitShaOverride, '$(Build.SourceVersion)') }} - TeamProject: ${{ parameters.BumpPipelineProject }} - Name: FindBumpRun - WorkingDirectory: $(Build.SourcesDirectory) + - ${{ if gt(parameters.BumpPipelineRunIdOverride, 0) }}: + - bash: | + echo "Using bump pipeline run override ${{ parameters.BumpPipelineRunIdOverride }}" + echo "##vso[task.setvariable variable=IsVersionBumpMerge;isOutput=true]true" + echo "##vso[task.setvariable variable=BumpPipelineDefinitionId;isOutput=true]${{ parameters.BumpPipelineId }}" + echo "##vso[task.setvariable variable=BumpPipelineRunId;isOutput=true]${{ parameters.BumpPipelineRunIdOverride }}" + name: FindBumpRun + displayName: 'Use Bump Pipeline Run Override' + + - ${{ else }}: + - template: /common/config/azure-pipelines/templates/find-bump-pipeline-run.yaml@self + parameters: + PipelineId: ${{ parameters.BumpPipelineId }} + CommitSha: ${{ coalesce(parameters.CommitShaOverride, '$(Build.SourceVersion)') }} + TeamProject: ${{ parameters.BumpPipelineProject }} + Name: FindBumpRun + WorkingDirectory: $(Build.SourcesDirectory) - task: DownloadPipelineArtifact@2 condition: eq(variables['FindBumpRun.IsVersionBumpMerge'], 'true') diff --git a/common/config/azure-pipelines/templates/pack.yaml b/common/config/azure-pipelines/templates/pack.yaml index c5d46fdfe2..bc1bd130de 100644 --- a/common/config/azure-pipelines/templates/pack.yaml +++ b/common/config/azure-pipelines/templates/pack.yaml @@ -1,5 +1,4 @@ parameters: - # Only the packages belonging to this version policy are packed. - name: VersionPolicyName type: string @@ -9,13 +8,31 @@ steps: Arguments: '--help' DisplayName: 'Install Rush' - - template: /common/config/azure-pipelines/templates/install-run-rush.yaml@self - parameters: - Arguments: > - publish - --publish - --pack - --include-all - --version-policy ${{ parameters.VersionPolicyName }} - --release-folder $(Build.ArtifactStagingDirectory)/packages - DisplayName: 'Rush Pack (Policy: ${{ parameters.VersionPolicyName }})' + - ${{ if eq(parameters.VersionPolicyName, 'noRush') }}: + - template: /common/config/azure-pipelines/templates/install-run-rush.yaml@self + parameters: + Arguments: > + publish + --publish + --pack + --include-all + --release-folder $(Build.ArtifactStagingDirectory)/packages + DisplayName: 'Rush Pack (Policy: ${{ parameters.VersionPolicyName }})' + + - ${{ else }}: + - template: /common/config/azure-pipelines/templates/install-run-rush.yaml@self + parameters: + Arguments: > + publish + --publish + --pack + --include-all + --version-policy ${{ parameters.VersionPolicyName }} + --release-folder $(Build.ArtifactStagingDirectory)/packages + DisplayName: 'Rush Pack (Policy: ${{ parameters.VersionPolicyName }})' + + - script: > + node common/scripts/publish-cohort.js filter-packages + --cohort ${{ parameters.VersionPolicyName }} + --packages-path $(Build.ArtifactStagingDirectory)/packages + displayName: 'Filter unpublished ${{ parameters.VersionPolicyName }} packages' diff --git a/common/config/rush/.npmrc b/common/config/rush/.npmrc index 100721e01a..355848d7cc 100644 --- a/common/config/rush/.npmrc +++ b/common/config/rush/.npmrc @@ -1 +1,2 @@ omit-lockfile-registry-resolved=true +registry=https://packagefeedproxy.microsoft.io/npm/ diff --git a/common/scripts/publish-cohort.js b/common/scripts/publish-cohort.js new file mode 100644 index 0000000000..371c71a0e4 --- /dev/null +++ b/common/scripts/publish-cohort.js @@ -0,0 +1,253 @@ +#!/usr/bin/env node + +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const PUBLISH_REGISTRY_URL = 'https://packagefeedproxy.microsoft.io/npm/'; + +function parseArguments() { + const [command, ...args] = process.argv.slice(2); + const options = new Map(); + + for (let index = 0; index < args.length; index += 2) { + const name = args[index]; + const value = args[index + 1]; + if (!name?.startsWith('--') || value === undefined) { + throw new Error(`Invalid argument: ${name || ''}`); + } + options.set(name.slice(2), value); + } + + return { command, options }; +} + +function getRequiredOption(options, name) { + const value = options.get(name); + if (!value) { + throw new Error(`Missing required option: --${name}`); + } + return value; +} + +function loadProjects(repoPath) { + const result = childProcess.spawnSync( + process.execPath, + [path.join(repoPath, 'common/scripts/install-run-rush.js'), 'list', '--json'], + { + cwd: repoPath, + encoding: 'utf8', + env: process.env + } + ); + + if (result.status !== 0) { + process.stderr.write(result.stdout); + process.stderr.write(result.stderr); + throw new Error('Unable to read the Rush project list.'); + } + + const jsonStart = result.stdout.indexOf('{'); + if (jsonStart < 0) { + throw new Error('Rush list did not produce JSON output.'); + } + + const projectList = JSON.parse(result.stdout.slice(jsonStart)); + return new Map(projectList.projects.map((project) => [project.name, project])); +} + +function isProjectInCohort(project, cohort) { + if (cohort === 'rush') { + return project.versionPolicyName === 'rush'; + } + if (cohort === 'noRush') { + return !project.versionPolicyName; + } + throw new Error(`Unsupported publishing cohort: ${cohort}`); +} + +function getFilesRecursively(folderPath, extension) { + if (!fs.existsSync(folderPath)) { + return []; + } + + const result = []; + for (const entry of fs.readdirSync(folderPath, { withFileTypes: true })) { + const entryPath = path.join(folderPath, entry.name); + if (entry.isDirectory()) { + result.push(...getFilesRecursively(entryPath, extension)); + } else if (!extension || entry.name.endsWith(extension)) { + result.push(entryPath); + } + } + return result; +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, undefined, 2)}\n`); +} + +function partitionChanges(repoPath, backupPath, cohort) { + const projects = loadProjects(repoPath); + const changesPath = path.join(repoPath, 'common/changes'); + + fs.rmSync(backupPath, { recursive: true, force: true }); + + for (const changeFilePath of getFilesRecursively(changesPath, '.json')) { + const changeFileText = fs.readFileSync(changeFilePath, 'utf8'); + const changeFile = JSON.parse(changeFileText); + const includedChanges = []; + const excludedChanges = []; + + for (const change of changeFile.changes) { + const project = projects.get(change.packageName); + if (!project) { + throw new Error(`${changeFilePath} references unknown project ${change.packageName}.`); + } + + (isProjectInCohort(project, cohort) ? includedChanges : excludedChanges).push(change); + } + + const relativePath = path.relative(changesPath, changeFilePath); + if (excludedChanges.length > 0) { + const backupFilePath = path.join(backupPath, relativePath); + if (includedChanges.length === 0) { + fs.mkdirSync(path.dirname(backupFilePath), { recursive: true }); + fs.writeFileSync(backupFilePath, changeFileText); + } else { + writeJson(backupFilePath, { ...changeFile, changes: excludedChanges }); + } + } + + if (includedChanges.length > 0) { + writeJson(changeFilePath, { ...changeFile, changes: includedChanges }); + } else { + fs.rmSync(changeFilePath); + } + } +} + +function restoreChanges(repoPath, backupPath) { + const changesPath = path.join(repoPath, 'common/changes'); + + for (const backupFilePath of getFilesRecursively(backupPath, '.json')) { + const relativePath = path.relative(backupPath, backupFilePath); + const changeFilePath = path.join(changesPath, relativePath); + const backupChangeFile = JSON.parse(fs.readFileSync(backupFilePath, 'utf8')); + + if (fs.existsSync(changeFilePath)) { + const currentChangeFile = JSON.parse(fs.readFileSync(changeFilePath, 'utf8')); + writeJson(changeFilePath, { + ...backupChangeFile, + changes: [...currentChangeFile.changes, ...backupChangeFile.changes] + }); + } else { + writeJson(changeFilePath, backupChangeFile); + } + } +} + +function readPackageJsonFromTarball(tarballPath) { + const result = childProcess.spawnSync('tar', ['-xOf', tarballPath, 'package/package.json'], { + encoding: 'utf8' + }); + if (result.status !== 0) { + process.stderr.write(result.stderr); + throw new Error(`Unable to read package.json from ${tarballPath}.`); + } + return JSON.parse(result.stdout); +} + +function packageIsPublished(packageName, version, repoPath, registryUrl) { + const result = childProcess.spawnSync( + 'npm', + ['view', `${packageName}@${version}`, 'version', '--json', '--registry', registryUrl], + { + cwd: repoPath, + encoding: 'utf8', + env: process.env + } + ); + + if (result.status === 0) { + return true; + } + if (result.stderr.includes('E404')) { + return false; + } + + process.stderr.write(result.stdout); + process.stderr.write(result.stderr); + throw new Error(`Unable to determine whether ${packageName}@${version} is published.`); +} + +function filterPackages(repoPath, packagesPath, cohort) { + const projects = loadProjects(repoPath); + const npmrcPublish = fs.readFileSync(path.join(repoPath, 'common/config/rush/.npmrc-publish'), 'utf8'); + const registryMatch = npmrcPublish.match(/^registry=(.+)$/m); + if (!registryMatch) { + throw new Error('Unable to determine the publish registry from .npmrc-publish.'); + } + const registryUrl = registryMatch[1].trim(); + if (registryUrl !== PUBLISH_REGISTRY_URL) { + throw new Error( + `Publishing must use the feed proxy registry ${PUBLISH_REGISTRY_URL}; found ${registryUrl}.` + ); + } + let retainedPackageCount = 0; + + for (const tarballPath of getFilesRecursively(packagesPath, '.tgz')) { + const packageJson = readPackageJsonFromTarball(tarballPath); + const project = projects.get(packageJson.name); + if (!project) { + throw new Error(`${tarballPath} contains unknown project ${packageJson.name}.`); + } + + const retainPackage = + isProjectInCohort(project, cohort) && + project.shouldPublish && + !packageIsPublished(packageJson.name, packageJson.version, repoPath, registryUrl); + + if (retainPackage) { + retainedPackageCount++; + console.log(`Retaining ${packageJson.name}@${packageJson.version}`); + } else { + fs.rmSync(tarballPath); + } + } + + console.log(`Retained ${retainedPackageCount} unpublished ${cohort} package(s).`); + console.log( + `##vso[task.setvariable variable=HasPackages]${retainedPackageCount > 0 ? 'true' : 'false'}` + ); +} + +function main() { + const { command, options } = parseArguments(); + const repoPath = path.resolve(options.get('repo-path') || process.cwd()); + + switch (command) { + case 'partition-changes': + partitionChanges( + repoPath, + path.resolve(getRequiredOption(options, 'backup-path')), + getRequiredOption(options, 'cohort') + ); + break; + case 'restore-changes': + restoreChanges(repoPath, path.resolve(getRequiredOption(options, 'backup-path'))); + break; + case 'filter-packages': + filterPackages( + repoPath, + path.resolve(getRequiredOption(options, 'packages-path')), + getRequiredOption(options, 'cohort') + ); + break; + default: + throw new Error(`Unsupported command: ${command || ''}`); + } +} + +main(); From 34379a225d0184c160a8b842951f0271335becbc Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:30:47 -0700 Subject: [PATCH 2/5] Read publish registry from npmrc Use common/config/rush/.npmrc-publish as the single source of truth for package existence checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- common/scripts/publish-cohort.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/common/scripts/publish-cohort.js b/common/scripts/publish-cohort.js index 371c71a0e4..9b1fb298ca 100644 --- a/common/scripts/publish-cohort.js +++ b/common/scripts/publish-cohort.js @@ -4,8 +4,6 @@ const childProcess = require('node:child_process'); const fs = require('node:fs'); const path = require('node:path'); -const PUBLISH_REGISTRY_URL = 'https://packagefeedproxy.microsoft.io/npm/'; - function parseArguments() { const [command, ...args] = process.argv.slice(2); const options = new Map(); @@ -190,11 +188,6 @@ function filterPackages(repoPath, packagesPath, cohort) { throw new Error('Unable to determine the publish registry from .npmrc-publish.'); } const registryUrl = registryMatch[1].trim(); - if (registryUrl !== PUBLISH_REGISTRY_URL) { - throw new Error( - `Publishing must use the feed proxy registry ${PUBLISH_REGISTRY_URL}; found ${registryUrl}.` - ); - } let retainedPackageCount = 0; for (const tarballPath of getFilesRecursively(packagesPath, '.tgz')) { From 22dbe3e542bba0f43a76242b6a01266795c70efc Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:40:27 -0700 Subject: [PATCH 3/5] Preserve non-Rush-first release ordering Run the individual package bump before the Rush lockstep bump in the Rush release flow, package both cohorts together, and keep the scheduled non-Rush-only flow isolated. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../azure-pipelines/npm-publish-rush.yaml | 18 +++---- .../config/azure-pipelines/npm-publish.yaml | 1 + .../templates/bump-versions-stages.yaml | 53 ++++++++++++------- .../azure-pipelines/templates/pack.yaml | 8 +-- common/scripts/publish-cohort.js | 3 ++ 5 files changed, 50 insertions(+), 33 deletions(-) diff --git a/common/config/azure-pipelines/npm-publish-rush.yaml b/common/config/azure-pipelines/npm-publish-rush.yaml index 942b256ac7..ab0731f483 100644 --- a/common/config/azure-pipelines/npm-publish-rush.yaml +++ b/common/config/azure-pipelines/npm-publish-rush.yaml @@ -1,19 +1,11 @@ # Pipeline: Bump Versions (Rush) -- AzDO definition 12 in the GitHubProjects project -# Trigger: Automatically when changes merge to main that include change files or pipeline YAML. -# Purpose: Bump versions for the "rush" version policy, build, pack, create/update GitHub PR. +# Trigger: Manually when a Rush release is required. +# Purpose: Bump non-Rush versions first, then the "rush" version policy, build, pack, and create/update +# a GitHub PR. # The packed tarballs are published by the "esrp-publish-rush.yaml" pipeline once the # resulting version bump PR is merged. -trigger: - batch: true - branches: - include: - - main - paths: - include: - - 'common/changes/*' - - 'common/config/rush/version-policies.json' - - 'common/config/azure-pipelines/**' +trigger: none pr: none @@ -51,6 +43,8 @@ extends: - template: /common/config/azure-pipelines/templates/bump-versions-stages.yaml@self parameters: VersionPolicyName: rush + BumpNonRushFirst: true + PackageCohort: all StageName: BumpRushVersions StageDisplayName: 'Bump Rush Versions and Create PR' CheckoutRepository: rushstackGitHubApp diff --git a/common/config/azure-pipelines/npm-publish.yaml b/common/config/azure-pipelines/npm-publish.yaml index 7d59c64c23..b314cf7c00 100644 --- a/common/config/azure-pipelines/npm-publish.yaml +++ b/common/config/azure-pipelines/npm-publish.yaml @@ -51,6 +51,7 @@ extends: - template: /common/config/azure-pipelines/templates/bump-versions-stages.yaml@self parameters: VersionPolicyName: noRush + PackageCohort: noRush StageName: BumpRushstackVersions StageDisplayName: 'Bump Rushstack Versions and Create PR' CheckoutRepository: rushstackGitHubApp diff --git a/common/config/azure-pipelines/templates/bump-versions-stages.yaml b/common/config/azure-pipelines/templates/bump-versions-stages.yaml index 00b77748f0..ddc389cb85 100644 --- a/common/config/azure-pipelines/templates/bump-versions-stages.yaml +++ b/common/config/azure-pipelines/templates/bump-versions-stages.yaml @@ -8,6 +8,11 @@ parameters: - name: VersionPolicyName type: string + - name: BumpNonRushFirst + type: boolean + default: false + - name: PackageCohort + type: string - name: StageName type: string - name: StageDisplayName @@ -71,24 +76,35 @@ stages: - template: /common/config/azure-pipelines/templates/build.yaml@self - # "noRush" is a pipeline cohort, not a Rush version policy. The old publishing flow - # achieved the separation by processing non-Rush changes before the Rush lockstep bump. - # Preserve that behavior while the two cohorts run in independent pipelines. - - script: > - node common/scripts/publish-cohort.js partition-changes - --cohort ${{ parameters.VersionPolicyName }} - --backup-path $(Agent.TempDirectory)/excluded-publish-changes - displayName: 'Select ${{ parameters.VersionPolicyName }} change files' - - - template: /common/config/azure-pipelines/templates/bump-versions.yaml@self - parameters: - VersionPolicyName: ${{ parameters.VersionPolicyName }} - - - script: > - node common/scripts/publish-cohort.js restore-changes - --backup-path $(Agent.TempDirectory)/excluded-publish-changes - displayName: 'Restore other publishing cohort change files' - condition: succeededOrFailed() + - ${{ if eq(parameters.BumpNonRushFirst, true) }}: + # The Rush lockstep packages can depend on independently versioned packages. Preserve + # the old release ordering by applying all individual changes before the Rush bump. + - template: /common/config/azure-pipelines/templates/bump-versions.yaml@self + parameters: + VersionPolicyName: noRush + + - template: /common/config/azure-pipelines/templates/bump-versions.yaml@self + parameters: + VersionPolicyName: ${{ parameters.VersionPolicyName }} + + - ${{ else }}: + # "noRush" is a pipeline cohort, not a Rush version policy. Hide Rush change files so + # the independently scheduled non-Rush flow cannot consume lockstep release changes. + - script: > + node common/scripts/publish-cohort.js partition-changes + --cohort ${{ parameters.VersionPolicyName }} + --backup-path $(Agent.TempDirectory)/excluded-publish-changes + displayName: 'Select ${{ parameters.VersionPolicyName }} change files' + + - template: /common/config/azure-pipelines/templates/bump-versions.yaml@self + parameters: + VersionPolicyName: ${{ parameters.VersionPolicyName }} + + - script: > + node common/scripts/publish-cohort.js restore-changes + --backup-path $(Agent.TempDirectory)/excluded-publish-changes + displayName: 'Restore other publishing cohort change files' + condition: succeededOrFailed() - bash: | git add -u -- common/changes @@ -131,6 +147,7 @@ stages: - template: /common/config/azure-pipelines/templates/pack.yaml@self parameters: VersionPolicyName: ${{ parameters.VersionPolicyName }} + PackageCohort: ${{ parameters.PackageCohort }} - bash: | if [ "$(HasChanges)" = "true" ] && [ "$(HasPackages)" != "true" ]; then diff --git a/common/config/azure-pipelines/templates/pack.yaml b/common/config/azure-pipelines/templates/pack.yaml index bc1bd130de..2e35fedd92 100644 --- a/common/config/azure-pipelines/templates/pack.yaml +++ b/common/config/azure-pipelines/templates/pack.yaml @@ -1,6 +1,8 @@ parameters: - name: VersionPolicyName type: string + - name: PackageCohort + type: string steps: - template: /common/config/azure-pipelines/templates/install-run-rush.yaml@self @@ -8,7 +10,7 @@ steps: Arguments: '--help' DisplayName: 'Install Rush' - - ${{ if eq(parameters.VersionPolicyName, 'noRush') }}: + - ${{ if ne(parameters.PackageCohort, 'rush') }}: - template: /common/config/azure-pipelines/templates/install-run-rush.yaml@self parameters: Arguments: > @@ -33,6 +35,6 @@ steps: - script: > node common/scripts/publish-cohort.js filter-packages - --cohort ${{ parameters.VersionPolicyName }} + --cohort ${{ parameters.PackageCohort }} --packages-path $(Build.ArtifactStagingDirectory)/packages - displayName: 'Filter unpublished ${{ parameters.VersionPolicyName }} packages' + displayName: 'Filter unpublished ${{ parameters.PackageCohort }} packages' diff --git a/common/scripts/publish-cohort.js b/common/scripts/publish-cohort.js index 9b1fb298ca..dae5220186 100644 --- a/common/scripts/publish-cohort.js +++ b/common/scripts/publish-cohort.js @@ -55,6 +55,9 @@ function loadProjects(repoPath) { } function isProjectInCohort(project, cohort) { + if (cohort === 'all') { + return true; + } if (cohort === 'rush') { return project.versionPolicyName === 'rush'; } From 8dee1d9390b8b7dc32e34544613e4c66ec771d63 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:15:23 -0700 Subject: [PATCH 4/5] Fix ESRP publish stage condition Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../config/azure-pipelines/templates/esrp-publish-stages.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/config/azure-pipelines/templates/esrp-publish-stages.yaml b/common/config/azure-pipelines/templates/esrp-publish-stages.yaml index e92803fc3a..4834fb8913 100644 --- a/common/config/azure-pipelines/templates/esrp-publish-stages.yaml +++ b/common/config/azure-pipelines/templates/esrp-publish-stages.yaml @@ -128,7 +128,7 @@ stages: displayName: 'Publish ${{ parameters.DisplayNameSuffix }} NPM Packages' dependsOn: - Prepare${{ parameters.StageNameSuffix }} - condition: and(succeeded('Prepare${{ parameters.StageNameSuffix }}'), eq(stageDependencies.Prepare${{ parameters.StageNameSuffix }}.PrepareNpmPublish.outputs['FindBumpRun.IsVersionBumpMerge'], 'true')) + condition: and(succeeded('Prepare${{ parameters.StageNameSuffix }}'), eq(dependencies.Prepare${{ parameters.StageNameSuffix }}.outputs['PrepareNpmPublish.FindBumpRun.IsVersionBumpMerge'], 'true')) jobs: - job: PublishNpmPackages displayName: 'Publish ${{ parameters.DisplayNameSuffix }} NPM Packages' From 1f075c0a9df4a8ed935a802a9fac540dfa639d52 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:13:42 -0700 Subject: [PATCH 5/5] Address publishing helper review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- common/scripts/publish-cohort.js | 75 ++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 33 deletions(-) diff --git a/common/scripts/publish-cohort.js b/common/scripts/publish-cohort.js index dae5220186..448297d146 100644 --- a/common/scripts/publish-cohort.js +++ b/common/scripts/publish-cohort.js @@ -3,25 +3,27 @@ const childProcess = require('node:child_process'); const fs = require('node:fs'); const path = require('node:path'); +const { parseArgs } = require('node:util'); function parseArguments() { - const [command, ...args] = process.argv.slice(2); - const options = new Map(); - - for (let index = 0; index < args.length; index += 2) { - const name = args[index]; - const value = args[index + 1]; - if (!name?.startsWith('--') || value === undefined) { - throw new Error(`Invalid argument: ${name || ''}`); + const { positionals, values } = parseArgs({ + allowPositionals: true, + options: { + 'backup-path': { type: 'string' }, + cohort: { type: 'string' }, + 'packages-path': { type: 'string' }, + 'repo-path': { type: 'string' } } - options.set(name.slice(2), value); + }); + if (positionals.length !== 1) { + throw new Error('Expected exactly one command.'); } - return { command, options }; + return { command: positionals[0], options: values }; } function getRequiredOption(options, name) { - const value = options.get(name); + const value = options[name]; if (!value) { throw new Error(`Missing required option: --${name}`); } @@ -67,26 +69,35 @@ function isProjectInCohort(project, cohort) { throw new Error(`Unsupported publishing cohort: ${cohort}`); } -function getFilesRecursively(folderPath, extension) { - if (!fs.existsSync(folderPath)) { - return []; - } +function forEachFileRecursive(folderPath, extension, callback) { + const pendingFolders = [{ absolutePath: folderPath, relativePath: '' }]; + while (pendingFolders.length > 0) { + const currentFolder = pendingFolders.pop(); + let entries; + try { + entries = fs.readdirSync(currentFolder.absolutePath, { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { + continue; + } + throw error; + } - const result = []; - for (const entry of fs.readdirSync(folderPath, { withFileTypes: true })) { - const entryPath = path.join(folderPath, entry.name); - if (entry.isDirectory()) { - result.push(...getFilesRecursively(entryPath, extension)); - } else if (!extension || entry.name.endsWith(extension)) { - result.push(entryPath); + for (const entry of entries) { + const absolutePath = path.join(currentFolder.absolutePath, entry.name); + const relativePath = path.join(currentFolder.relativePath, entry.name); + if (entry.isDirectory()) { + pendingFolders.push({ absolutePath, relativePath }); + } else if (entry.isFile() && (!extension || entry.name.endsWith(extension))) { + callback(absolutePath, relativePath); + } } } - return result; } function writeJson(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, undefined, 2)}\n`); + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`); } function partitionChanges(repoPath, backupPath, cohort) { @@ -95,7 +106,7 @@ function partitionChanges(repoPath, backupPath, cohort) { fs.rmSync(backupPath, { recursive: true, force: true }); - for (const changeFilePath of getFilesRecursively(changesPath, '.json')) { + forEachFileRecursive(changesPath, '.json', (changeFilePath, relativePath) => { const changeFileText = fs.readFileSync(changeFilePath, 'utf8'); const changeFile = JSON.parse(changeFileText); const includedChanges = []; @@ -110,7 +121,6 @@ function partitionChanges(repoPath, backupPath, cohort) { (isProjectInCohort(project, cohort) ? includedChanges : excludedChanges).push(change); } - const relativePath = path.relative(changesPath, changeFilePath); if (excludedChanges.length > 0) { const backupFilePath = path.join(backupPath, relativePath); if (includedChanges.length === 0) { @@ -126,14 +136,13 @@ function partitionChanges(repoPath, backupPath, cohort) { } else { fs.rmSync(changeFilePath); } - } + }); } function restoreChanges(repoPath, backupPath) { const changesPath = path.join(repoPath, 'common/changes'); - for (const backupFilePath of getFilesRecursively(backupPath, '.json')) { - const relativePath = path.relative(backupPath, backupFilePath); + forEachFileRecursive(backupPath, '.json', (backupFilePath, relativePath) => { const changeFilePath = path.join(changesPath, relativePath); const backupChangeFile = JSON.parse(fs.readFileSync(backupFilePath, 'utf8')); @@ -146,7 +155,7 @@ function restoreChanges(repoPath, backupPath) { } else { writeJson(changeFilePath, backupChangeFile); } - } + }); } function readPackageJsonFromTarball(tarballPath) { @@ -193,7 +202,7 @@ function filterPackages(repoPath, packagesPath, cohort) { const registryUrl = registryMatch[1].trim(); let retainedPackageCount = 0; - for (const tarballPath of getFilesRecursively(packagesPath, '.tgz')) { + forEachFileRecursive(packagesPath, '.tgz', (tarballPath) => { const packageJson = readPackageJsonFromTarball(tarballPath); const project = projects.get(packageJson.name); if (!project) { @@ -211,7 +220,7 @@ function filterPackages(repoPath, packagesPath, cohort) { } else { fs.rmSync(tarballPath); } - } + }); console.log(`Retained ${retainedPackageCount} unpublished ${cohort} package(s).`); console.log( @@ -221,7 +230,7 @@ function filterPackages(repoPath, packagesPath, cohort) { function main() { const { command, options } = parseArguments(); - const repoPath = path.resolve(options.get('repo-path') || process.cwd()); + const repoPath = path.resolve(options['repo-path'] || process.cwd()); switch (command) { case 'partition-changes':