diff --git a/.devops/templates/tools.yml b/.devops/templates/tools.yml index 7aba487e379604..6595e37ecbdbd2 100644 --- a/.devops/templates/tools.yml +++ b/.devops/templates/tools.yml @@ -6,13 +6,9 @@ parameters: default: false steps: - # Logs a message when dry run mode is enabled. - - script: | - echo "DRY RUN mode enabled!" - displayName: DRY RUN mode status - condition: eq(${{ parameters.dryRun }}, true) - - task: UseNode@1 + displayName: Install Node.js + retryCountOnTaskFailure: 1 inputs: # 👉 NOTE: # - we can use only versions that ship with container, otherwise we will run into nodejs installation issues. @@ -20,41 +16,33 @@ steps: # - `checkLatest: true` is required because some dependencies (e.g. @microsoft/fast-build) require node >=22.18.0, # while the 1ES container may still ship an older 22.x (e.g. 22.14.0). This forces the task to install the latest 22.x. # https://github.com/actions/runner-images/blob/ubuntu20/20230924.1/images/linux/Ubuntu2004-Readme.md#nodejs - version: '22.x' + version: 22.x checkLatest: true - displayName: 'Install Node.js' - retryCountOnTaskFailure: 1 - script: | echo "nodejs:" && node -v echo "yarn:" && yarn -v echo "npm:" && npm -v - displayName: 'print NodeJS,package managers versions' - - # For multiline scripts, we want the whole task to fail if any line of the script fails. - # ADO doesn't have bash configured this way by default. To fix we override the SHELLOPTS built-in variable. - # https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html - # The options below include ADO defaults (braceexpand:hashall:interactive-comments) plus - # errexit:errtrace for better error behavior. - - script: | - echo "##vso[task.setvariable variable=shellopts]braceexpand:hashall:interactive-comments:errexit:errtrace" - displayName: Force exit on error (bash) + displayName: Print Node.js and package manager versions # Log all the environment variables since it can be useful for debugging. # (This happens automatically for the built-in agents, but not for custom agents.) - script: | printenv | sort - echo "SHELLOPTS $SHELLOPTS" echo number of CPUs "$(getconf _NPROCESSORS_ONLN)" - displayName: Log environment variables (Linux) - condition: eq(variables['Agent.OS'], 'Linux') + set -x + git --no-pager config list + npm config list + yarn config --no-defaults + displayName: Log environment - script: | git config user.name "OGX bot" git config user.email "257645319+office-ogx-auth-helper[bot]@users.noreply.github.com" displayName: Configure git user (used by beachball) - - script: | - corepack enable - yarn install --immutable + # Do NOT run `corepack enable` - it's not needed since yarn is checked into the repo, and + # it causes a network request attempting to download yarn, which is not allowed. + + - script: yarn install --immutable displayName: yarn install diff --git a/.devops/templates/variables.yml b/.devops/templates/variables.yml index 6073cf0a9684be..8a9d7777312175 100644 --- a/.devops/templates/variables.yml +++ b/.devops/templates/variables.yml @@ -5,3 +5,10 @@ variables: NX_PARALLEL: 8 NX_PREFER_TS_NODE: true NX_VERBOSE_LOGGING: true + + # For multiline scripts, we want the whole task to fail if any line of the script fails. + # ADO doesn't have bash configured this way by default. To fix we override the SHELLOPTS built-in variable. + # https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html + # The options below include ADO defaults (braceexpand:hashall:interactive-comments) plus + # errexit:errtrace for better error behavior. + SHELLOPTS: braceexpand:hashall:interactive-comments:errexit:errtrace diff --git a/azure-pipelines.hotfix.yml b/azure-pipelines.hotfix.yml deleted file mode 100644 index d419a447eb313e..00000000000000 --- a/azure-pipelines.hotfix.yml +++ /dev/null @@ -1,84 +0,0 @@ -pr: none - -trigger: none - -name: '$(targetNpmVersion) ($(Rev:r))' - -parameters: - - name: dryRun - displayName: Dry Run Mode - type: boolean - default: false - -variables: - - template: .devops/templates/variables.yml - - name: skipComponentGovernanceDetection - value: true - - name: tags - value: production,externalfacing - -resources: - repositories: - - repository: 1esPipelines - type: git - name: 1ESPipelineTemplates/1ESPipelineTemplates - ref: refs/tags/release - -extends: - template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines - parameters: - pool: - name: Azure-Pipelines-1ESPT-ExDShared - image: windows-latest - os: windows # We need windows because compliance task only run on windows. - stages: - - stage: main - jobs: - - job: Release - dependsOn: Compliance - pool: - name: '1ES-Host-Ubuntu' - image: '1ES-PT-Ubuntu-20.04' - os: linux - workspace: - clean: all - steps: - - script: | - if [[ -z "$(targetNpmVersion)" ]]; then - echo "##vso[task.logissue type=error]targetNpmVersion is not specified" - exit 1 - fi - displayName: 'Checking for targetNpmVersion variable' - - - template: .devops/templates/tools.yml@self - parameters: - dryRun: ${{ parameters.dryRun }} - - - script: | - if ! yarn semver '$(targetNpmVersion)' >/dev/null; then - echo "##vso[task.logissue type=error]Invalid version specified: $(targetNpmVersion)" - exit 1 - fi - displayName: 'Check targetNpmVersion is valid semver' - - - script: | - npm version $(targetNpmVersion) - workingDirectory: 'packages/react' - displayName: 'Manually set package version' - - - script: | - yarn nx run react:build --production - displayName: yarn build to FUIR (Create production build) - - - script: yarn workspace @fluentui/react bundle --production - displayName: yarn bundle FUIR - - - script: | - npm pack - displayName: 'Create tarball' - workingDirectory: 'packages/react' - - - script: | - npm publish packages/react/react-$(targetNpmVersion).tgz --tag hf8 --//registry.npmjs.org/:_authToken=$(npmToken) - displayName: Publish new version - condition: not(${{ parameters.dryRun }}) diff --git a/azure-pipelines.release-headless-experimental.yml b/azure-pipelines.release-headless-experimental.yml index 618f0f0a3814e9..a955916ce5e1b1 100644 --- a/azure-pipelines.release-headless-experimental.yml +++ b/azure-pipelines.release-headless-experimental.yml @@ -7,15 +7,12 @@ parameters: type: boolean default: true -# Customize build number to include package prefix # Example: headless_experimental_20201022.1 -name: 'headless_experimental_$(Date:yyyyMMdd)$(Rev:.r)' +name: "headless_experimental_${{ iif(parameters.dryRun, 'dryrun_', '') }}$(Date:yyyyMMdd)$(Rev:.r)" variables: - group: 'Github and NPM secrets' - template: .devops/templates/variables.yml - - name: tags - value: production,externalfacing resources: repositories: @@ -114,7 +111,6 @@ extends: - script: | yarn beachball publish -b origin/$(validation.branchPath) --no-push --tag experimental --config scripts/beachball/src/release-headless.config.js - git reset --hard origin/$(validation.branchPath) displayName: Publish changes and bump versions condition: and(succeeded(), not(${{ parameters.dryRun }})) env: diff --git a/azure-pipelines.release-vnext-experimental.yml b/azure-pipelines.release-vnext-experimental.yml index 621d93671ca1b7..b937fc4fd948b0 100644 --- a/azure-pipelines.release-vnext-experimental.yml +++ b/azure-pipelines.release-vnext-experimental.yml @@ -7,17 +7,14 @@ parameters: type: boolean default: true -# Customize build number to include major version -# Example: v9_20201022.1 -name: 'v9_experimental_$(Date:yyyyMMdd)$(Rev:.r)' +# Example: v9_experimental_20201022.1 +name: "v9_experimental_${{ iif(parameters.dryRun, 'dryrun_', '') }}$(Date:yyyyMMdd)$(Rev:.r)" variables: - group: 'Github and NPM secrets' - template: .devops/templates/variables.yml - name: release.vnext # Used to scope beachball to release only vnext packages value: true - - name: tags - value: production,externalfacing resources: repositories: @@ -107,7 +104,6 @@ extends: - script: | yarn beachball publish -b origin/$(validation.branchPath) --no-push --tag experimental --config scripts/beachball/src/release-vNext.config.js - git reset --hard origin/$(validation.branchPath) displayName: Publish changes and bump versions condition: and(succeeded(), not(${{ parameters.dryRun }})) env: diff --git a/azure-pipelines.release-vnext-nightly.yml b/azure-pipelines.release-vnext-nightly.yml index ca88dc0f8337d6..5727e5b5af5096 100644 --- a/azure-pipelines.release-vnext-nightly.yml +++ b/azure-pipelines.release-vnext-nightly.yml @@ -7,17 +7,12 @@ parameters: type: boolean default: false -# Customize build number to include major version -# Example: v9_20201022.1 -name: 'v9_nightly_$(Date:yyyyMMdd)$(Rev:.r)' +# Example: v9_nightly_20201022.1 +name: "v9_nightly_${{ iif(parameters.dryRun, 'dryrun_', '') }}$(Date:yyyyMMdd)$(Rev:.r)" variables: - group: 'Github and NPM secrets' - template: .devops/templates/variables.yml - - name: release.vnext # Used to scope beachball to release only vnext packages - value: true - - name: tags - value: production,externalfacing schedules: # Triggers the nightly release @@ -92,7 +87,6 @@ extends: - script: | yarn beachball publish -b origin/$(Build.SourceBranchName) --no-push --tag nightly --config scripts/beachball/src/release-vNext.config.js - git reset --hard origin/$(Build.SourceBranchName) displayName: Publish changes and bump versions condition: not(${{ parameters.dryRun }}) env: diff --git a/azure-pipelines.release-vnext.yml b/azure-pipelines.release-vnext.yml index 334b3ef49e89a6..1170ff0efc862c 100644 --- a/azure-pipelines.release-vnext.yml +++ b/azure-pipelines.release-vnext.yml @@ -1,9 +1,8 @@ pr: none trigger: none -# Customize build number to include major version # Example: v9_20201022.1 -name: 'v9_$(Date:yyyyMMdd)$(Rev:.r)' +name: "v9_${{ iif(parameters.dryRun, 'dryrun_', '') }}$(Date:yyyyMMdd)$(Rev:.r)" parameters: - name: dryRun @@ -14,10 +13,6 @@ parameters: variables: - group: 'Github and NPM secrets' - template: .devops/templates/variables.yml - - name: release.vnext # Used to scope beachball to release only vnext packages - value: true - - name: tags - value: production,externalfacing resources: repositories: @@ -68,6 +63,7 @@ extends: - template: .devops/templates/run-with-token.yml@self parameters: steps: + # Publish, then reset to the updated origin/master state so manifest versions are correct. - script: | yarn beachball publish --config scripts/beachball/src/release-vNext.config.js --message 'release: applying package updates - react-components' git reset --hard origin/master @@ -78,12 +74,9 @@ extends: condition: not(${{ parameters.dryRun }}) - script: | - node -r ./scripts/ts-node/src/register scripts/executors/src/tag-react-components.ts --token $(npmToken) + node -r ./scripts/ts-node/src/register scripts/executors/src/tag-react-components.ts displayName: Tag prelease packages with prerelease tag continueOnError: true condition: not(${{ parameters.dryRun }}) - - # Since releases are scoped, this should warn for any packages that were mistakenly not included in scoping - - script: | - yarn syncpack list-mismatches - displayName: Check for dependency mismatches + env: + TOKEN: $(npmToken) diff --git a/azure-pipelines.release.headless.yml b/azure-pipelines.release.headless.yml index 8da799145ab8aa..abcd059c47ad37 100644 --- a/azure-pipelines.release.headless.yml +++ b/azure-pipelines.release.headless.yml @@ -3,7 +3,7 @@ trigger: none # Customize build number to include package prefix # Example: headless_20201022.1 -name: 'headless_$(Date:yyyyMMdd)$(Rev:.r)' +name: "headless_${{ iif(parameters.dryRun, 'dryrun_', '') }}$(Date:yyyyMMdd)$(Rev:.r)" parameters: - name: dryRun @@ -14,8 +14,6 @@ parameters: variables: - group: 'Github and NPM secrets' - template: .devops/templates/variables.yml - - name: tags - value: production,externalfacing resources: repositories: @@ -75,7 +73,6 @@ extends: steps: - script: | yarn beachball publish --config scripts/beachball/src/release-headless.config.js --message 'release: applying package updates - react-headless' - git reset --hard origin/master env: BEACHBALL_GIT_TOKEN: $(createToken.GITHUB_APP_TOKEN) BEACHBALL_NPM_TOKEN: $(npmToken) diff --git a/azure-pipelines.release.tools-experimental.yml b/azure-pipelines.release.tools-experimental.yml index 7532bf6e660e32..8440494132ae14 100644 --- a/azure-pipelines.release.tools-experimental.yml +++ b/azure-pipelines.release.tools-experimental.yml @@ -7,15 +7,12 @@ parameters: type: boolean default: true -# Customize build number to include tools experimental prefix # Example: tools_experimental_20201022.1 -name: 'tools_experimental_$(Date:yyyyMMdd)$(Rev:.r)' +name: "tools_experimental_${{ iif(parameters.dryRun, 'dryrun_', '') }}$(Date:yyyyMMdd)$(Rev:.r)" variables: - group: 'Github and NPM secrets' - template: .devops/templates/variables.yml - - name: tags - value: production,externalfacing resources: repositories: @@ -106,7 +103,6 @@ extends: - script: | yarn beachball publish -b origin/$(validation.branchPath) --no-push --tag experimental --config scripts/beachball/src/release-tools.config.js - git reset --hard origin/$(validation.branchPath) displayName: Publish changes and bump versions condition: and(succeeded(), not(${{ parameters.dryRun }})) env: diff --git a/azure-pipelines.release.tools.yml b/azure-pipelines.release.tools.yml index f300011cab2e68..77881959718a51 100644 --- a/azure-pipelines.release.tools.yml +++ b/azure-pipelines.release.tools.yml @@ -3,7 +3,7 @@ trigger: none # Customize build number to include major version # Example: tools_20201022.1 -name: 'tools_$(Date:yyyyMMdd)$(Rev:.r)' +name: "tools_${{ iif(parameters.dryRun, 'dryrun_', '') }}$(Date:yyyyMMdd)$(Rev:.r)" parameters: - name: dryRun @@ -14,8 +14,6 @@ parameters: variables: - group: 'Github and NPM secrets' - template: .devops/templates/variables.yml - - name: tags - value: production,externalfacing resources: repositories: @@ -74,7 +72,6 @@ extends: steps: - script: | yarn beachball publish --config scripts/beachball/src/release-tools.config.js --message 'release: applying package updates - tools' - git reset --hard origin/master env: BEACHBALL_GIT_TOKEN: $(createToken.GITHUB_APP_TOKEN) BEACHBALL_NPM_TOKEN: $(npmToken) diff --git a/azure-pipelines.release.web-components.yml b/azure-pipelines.release.web-components.yml index 02e46fa5c50fda..948ded3a57b23b 100644 --- a/azure-pipelines.release.web-components.yml +++ b/azure-pipelines.release.web-components.yml @@ -1,9 +1,8 @@ pr: none trigger: none -# Customize build number to include major version # Example: web-components_20201022.1 -name: 'web-components_$(Date:yyyyMMdd)$(Rev:.r)' +name: "web-components_${{ iif(parameters.dryRun, 'dryrun_', '') }}$(Date:yyyyMMdd)$(Rev:.r)" parameters: - name: dryRun @@ -16,8 +15,6 @@ variables: - template: .devops/templates/variables.yml - name: release.web_components # Used to scope beachball to release only vnext packages value: true - - name: tags - value: production,externalfacing resources: repositories: @@ -72,7 +69,6 @@ extends: steps: - script: | yarn beachball publish --config scripts/beachball/src/release-web-components.config.js --message 'release: applying package updates - web-components' - git reset --hard origin/master env: BEACHBALL_GIT_TOKEN: $(createToken.GITHUB_APP_TOKEN) BEACHBALL_NPM_TOKEN: $(npmToken) diff --git a/azure-pipelines.release.yml b/azure-pipelines.release.yml index 6b5a6a5fc61ef6..bc905ea851e8c1 100644 --- a/azure-pipelines.release.yml +++ b/azure-pipelines.release.yml @@ -3,7 +3,7 @@ trigger: none # Customize build number to include major version # Example: v8_20201022.1 -name: 'v8_$(Date:yyyyMMdd)$(Rev:.r)' +name: "v8_${{ iif(parameters.dryRun, 'dryrun_', '') }}$(Date:yyyyMMdd)$(Rev:.r)" parameters: - name: dryRun @@ -90,6 +90,7 @@ extends: - template: .devops/templates/run-with-token.yml@self parameters: steps: + # Publish, then reset to the updated origin/master state so manifest versions are correct. - script: | yarn beachball publish --config scripts/beachball/src/release-v8.config.js --message 'release: applying package updates - react v8' git reset --hard origin/master diff --git a/package.json b/package.json index 942b32c787072e..73dc552d143413 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@microsoft/api-extractor": "7.51.0", "@microsoft/api-extractor-model": "7.31.2", "@microsoft/eslint-plugin-sdl": "1.0.1", + "@microsoft/esrp-npm-release": "^0.1.3", "@microsoft/fast-build": "0.9.0", "@microsoft/fast-element": "3.0.0", "@microsoft/fast-test-harness": "0.4.0", @@ -175,7 +176,7 @@ "babel-plugin-module-resolver": "5.0.3", "babel-plugin-react-compiler": "1.0.0", "babel-plugin-tester": "10.1.0", - "beachball": "3.0.0-alpha.9", + "beachball": "3.0.0-alpha.10", "chalk": "4.1.0", "chrome-remote-interface": "0.28.2", "ci-info": "3.9.0", diff --git a/scripts/executors/src/deprecate-react-components-preview-packages.spec.ts b/scripts/executors/src/deprecate-react-components-preview-packages.spec.ts index 46cb622b54c552..b19494b2870e40 100644 --- a/scripts/executors/src/deprecate-react-components-preview-packages.spec.ts +++ b/scripts/executors/src/deprecate-react-components-preview-packages.spec.ts @@ -1,16 +1,13 @@ -import { execSync } from 'node:child_process'; - import type { AllPackageInfo } from '@fluentui/scripts-monorepo'; import { deprecateReactComponentsPreviewPackages } from './deprecate-react-components-preview-packages'; +import { runNpmCommand } from './npm-utils'; -// Mock the `execSync` function, as we don't want to actually run the `npm deprecate` command -jest.mock('node:child_process', () => ({ - ...jest.requireActual('node:child_process'), - execSync: jest.fn(), +jest.mock('./npm-utils', () => ({ + runNpmCommand: jest.fn(), })); -const mockExecSync = execSync as jest.MockedFunction; +const mockRunNpmCommand = runNpmCommand as jest.MockedFunction; const packages: AllPackageInfo = { // preview => stable package, published preview package should be deprecated @@ -70,6 +67,10 @@ const packages: AllPackageInfo = { }; describe('deprecateReactComponentsPreviewPackages', () => { + beforeEach(() => { + mockRunNpmCommand.mockReset(); + }); + it('should skip deprecating packages (no change files)', () => { deprecateReactComponentsPreviewPackages({ argv: { @@ -80,7 +81,7 @@ describe('deprecateReactComponentsPreviewPackages', () => { packages, }); - expect(mockExecSync).not.toHaveBeenCalled(); + expect(mockRunNpmCommand).not.toHaveBeenCalled(); }); it('should skip deprecating packages (no preview packages)', () => { @@ -93,7 +94,7 @@ describe('deprecateReactComponentsPreviewPackages', () => { packages, }); - expect(mockExecSync).not.toHaveBeenCalled(); + expect(mockRunNpmCommand).not.toHaveBeenCalled(); }); it('should deprecate preview packages', () => { @@ -106,12 +107,15 @@ describe('deprecateReactComponentsPreviewPackages', () => { packages, }); - expect(mockExecSync).toHaveBeenCalledTimes(1); - - expect(mockExecSync).toHaveBeenCalledWith( - `npm deprecate @fluentui/react-carousel-preview "Deprecated in favor of stable release - use/migrate to @fluentui/react-carousel" --registry https://registry.npmjs.org/ --//registry.npmjs.org/:_authToken=npm-token`, - { stdio: 'inherit' }, - ); + expect(mockRunNpmCommand).toHaveBeenCalledTimes(1); + expect(mockRunNpmCommand).toHaveBeenCalledWith({ + args: [ + 'deprecate', + '@fluentui/react-carousel-preview', + 'Deprecated in favor of stable release - use/migrate to @fluentui/react-carousel', + ], + npmToken: 'npm-token', + }); }); it('should throw an error (change dir is not correct)', () => { @@ -127,7 +131,7 @@ describe('deprecateReactComponentsPreviewPackages', () => { }); it('should throw an error (package deprecation/npm command fails)', () => { - mockExecSync.mockImplementation(() => { + mockRunNpmCommand.mockImplementation(() => { throw new Error('Failed to deprecate package'); }); diff --git a/scripts/executors/src/deprecate-react-components-preview-packages.ts b/scripts/executors/src/deprecate-react-components-preview-packages.ts index 466deddeb19b24..3164f1e4f8c0d9 100644 --- a/scripts/executors/src/deprecate-react-components-preview-packages.ts +++ b/scripts/executors/src/deprecate-react-components-preview-packages.ts @@ -1,4 +1,3 @@ -import { execSync } from 'node:child_process'; import { readdirSync } from 'node:fs'; import { join } from 'node:path'; @@ -7,6 +6,8 @@ import { logger, readJsonFile } from '@nx/devkit'; import type { ChangeType } from 'beachball'; import yargs from 'yargs'; +import { runNpmCommand } from './npm-utils'; + /** * Deprecates a package by executing an `npm deprecate` command. * @@ -15,14 +16,12 @@ import yargs from 'yargs'; */ function deprecatePackage(packageSpec: string, npmToken: string) { const projectNpmName = packageSpec.replace('-preview', ''); - - const command = `npm deprecate ${packageSpec} "Deprecated in favor of stable release - use/migrate to ${projectNpmName}" --registry https://registry.npmjs.org/ --//registry.npmjs.org/:_authToken=${npmToken}`; + const args = ['deprecate', packageSpec, `Deprecated in favor of stable release - use/migrate to ${projectNpmName}`]; logger.log(`Deprecating "${packageSpec}" package`); - logger.log(command); try { - execSync(command, { stdio: 'inherit' }); + runNpmCommand({ args, npmToken }); } catch (e) { throw new Error(`Failed to deprecate "${packageSpec}" package`); } @@ -123,20 +122,18 @@ function createPackageChangeFileReader(options: { changeFilesRoot: string }) { } function main() { - const argv = yargs - .option('changeFilesRoot', { + try { + const argv = yargs.option('changeFilesRoot', { type: 'string', description: 'Root folder where change files live (relative to workspace root)', default: 'change', - }) - .option('token', { - type: 'string', - description: 'NPM Token', - demandOption: true, }).argv; + const token = process.env.TOKEN; + if (!token) { + throw new Error('Please pass an NPM token through the TOKEN environment variable'); + } - try { - deprecateReactComponentsPreviewPackages({ argv, packages: getAllPackageInfo() }); + deprecateReactComponentsPreviewPackages({ argv: { ...argv, token }, packages: getAllPackageInfo() }); } catch (e) { logger.error(e); logger.error('Failed to deprecate packages'); diff --git a/scripts/executors/src/npm-utils.spec.ts b/scripts/executors/src/npm-utils.spec.ts new file mode 100644 index 00000000000000..e633fb723f010d --- /dev/null +++ b/scripts/executors/src/npm-utils.spec.ts @@ -0,0 +1,86 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { runNpmCommand } from './npm-utils'; + +jest.mock('node:child_process', () => ({ + ...jest.requireActual('node:child_process'), + spawnSync: jest.fn(), +})); + +jest.mock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + mkdtempSync: jest.fn(), + rmSync: jest.fn(), + writeFileSync: jest.fn(), +})); + +const mockSpawnSync = spawnSync as jest.MockedFunction; +const mockMkdtempSync = mkdtempSync as jest.MockedFunction; +const mockRmSync = rmSync as jest.MockedFunction; +const mockWriteFileSync = writeFileSync as jest.MockedFunction; +const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); + +const npmConfigDir = join(tmpdir(), 'fluentui-npm-test'); +const npmConfigPath = join(npmConfigDir, '.npmrc'); + +beforeEach(() => { + mockSpawnSync.mockReset(); + mockMkdtempSync.mockReset(); + mockRmSync.mockReset(); + mockWriteFileSync.mockReset(); + mockConsoleLog.mockClear(); + + mockMkdtempSync.mockReturnValue(npmConfigDir); + mockSpawnSync.mockReturnValue({ status: 0 } as ReturnType); +}); + +afterAll(() => { + mockConsoleLog.mockRestore(); +}); + +describe('runNpmCommand', () => { + it('runs npm with temporary registry authentication', () => { + const args = ['dist-tag', 'add', '@fluentui/react-example@1.0.0', 'latest']; + + runNpmCommand({ args, npmToken: 'npm-token' }); + + expect(mockMkdtempSync).toHaveBeenCalledWith(join(tmpdir(), 'fluentui-npm-')); + expect(mockWriteFileSync).toHaveBeenCalledWith(npmConfigPath, '//registry.npmjs.org/:_authToken=${TOKEN}\n'); + expect(mockConsoleLog).toHaveBeenCalledWith( + `npm ${[...args, '--registry', 'https://registry.npmjs.org/', '--userconfig', npmConfigPath].join(' ')}`, + ); + expect(mockSpawnSync).toHaveBeenCalledWith( + 'npm', + [...args, '--registry', 'https://registry.npmjs.org/', '--userconfig', npmConfigPath], + { + stdio: 'inherit', + shell: process.platform === 'win32', + env: { + ...process.env, + TOKEN: 'npm-token', + }, + }, + ); + expect(mockRmSync).toHaveBeenCalledWith(npmConfigDir, { recursive: true, force: true }); + }); + + it('cleans up and throws when npm exits unsuccessfully', () => { + mockSpawnSync.mockReturnValue({ status: 1 } as ReturnType); + + expect(() => runNpmCommand({ args: ['view'], npmToken: 'npm-token' })).toThrow('npm exited with status 1'); + + expect(mockRmSync).toHaveBeenCalledWith(npmConfigDir, { recursive: true, force: true }); + }); + + it('cleans up and throws when npm cannot be started', () => { + const error = new Error('Unable to start npm'); + mockSpawnSync.mockReturnValue({ error, status: null } as ReturnType); + + expect(() => runNpmCommand({ args: ['view'], npmToken: 'npm-token' })).toThrow(error); + + expect(mockRmSync).toHaveBeenCalledWith(npmConfigDir, { recursive: true, force: true }); + }); +}); diff --git a/scripts/executors/src/npm-utils.ts b/scripts/executors/src/npm-utils.ts new file mode 100644 index 00000000000000..bd9d8bad378180 --- /dev/null +++ b/scripts/executors/src/npm-utils.ts @@ -0,0 +1,47 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const npmRegistry = 'https://registry.npmjs.org/'; + +export type RunNpmCommandOptions = { + args: readonly string[]; + npmToken: string; +}; + +/** + * Runs an npm command against the public registry using a temporary user config for authentication. + * The temporary config is removed after the command completes or fails. + */ +export function runNpmCommand(options: RunNpmCommandOptions): void { + const { args, npmToken } = options; + const npmConfigDir = mkdtempSync(join(tmpdir(), 'fluentui-npm-')); + const npmConfigPath = join(npmConfigDir, '.npmrc'); + + try { + writeFileSync(npmConfigPath, '//registry.npmjs.org/:_authToken=${TOKEN}\n'); + const npmArgs = [...args, '--registry', npmRegistry, '--userconfig', npmConfigPath]; + + console.log(`npm ${npmArgs.join(' ')}`); + + const result = spawnSync('npm', npmArgs, { + stdio: 'inherit', + shell: process.platform === 'win32', + env: { + ...process.env, + TOKEN: npmToken, + }, + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + throw new Error(`npm exited with status ${result.status}`); + } + } finally { + rmSync(npmConfigDir, { recursive: true, force: true }); + } +} diff --git a/scripts/executors/src/tag-react-components.ts b/scripts/executors/src/tag-react-components.ts index 5ae6c635e1718c..873a2c975803f2 100644 --- a/scripts/executors/src/tag-react-components.ts +++ b/scripts/executors/src/tag-react-components.ts @@ -1,8 +1,7 @@ -import { execSync } from 'child_process'; - import { AllPackageInfo, getAllPackageInfo, isConvergedPackage } from '@fluentui/scripts-monorepo'; import * as semver from 'semver'; -import yargs from 'yargs'; + +import { runNpmCommand } from './npm-utils'; function tagPackages(npmToken: string) { const packagesToTag = getPackagesToTag(); @@ -38,10 +37,9 @@ function tagPackage(name: string, version: string, npmToken: string): boolean { return true; } - const command = `npm dist-tag add ${name}@${version} ${prereleaseTag} --registry https://registry.npmjs.org/ --//registry.npmjs.org/:_authToken=${npmToken}`; - console.log(command); + const args = ['dist-tag', 'add', `${name}@${version}`, String(prereleaseTag)]; try { - execSync(command, { stdio: 'inherit' }); + runNpmCommand({ args, npmToken }); } catch (e) { console.error(`failed to tag ${name}@${version}`); console.error(e); @@ -67,16 +65,16 @@ function getPackagesToTag() { .filter(Boolean) as Array<{ name: string; version: string }> | []; } -function main(argv: yargs.Arguments) { - if (!argv.token || typeof argv.token !== 'string') { - throw new Error('Please pass an NPM token through the --token argument'); +function main() { + const token = process.env.TOKEN; + if (!token) { + console.error('Please pass an NPM token through the TOKEN environment variable'); + process.exit(1); } - tagPackages(argv.token); + tagPackages(token); } -if (require.main === module && process.env.RELEASE_VNEXT) { - main(yargs.argv); -} else { - console.log('"RELEASE_VNEXT" not set - skipping'); +if (require.main === module) { + main(); } diff --git a/yarn.lock b/yarn.lock index d6cb3b0084399b..4fa370782d88e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2830,6 +2830,7 @@ __metadata: "@microsoft/api-extractor": "npm:7.51.0" "@microsoft/api-extractor-model": "npm:7.31.2" "@microsoft/eslint-plugin-sdl": "npm:1.0.1" + "@microsoft/esrp-npm-release": "npm:^0.1.3" "@microsoft/fast-build": "npm:0.9.0" "@microsoft/fast-element": "npm:3.0.0" "@microsoft/fast-test-harness": "npm:0.4.0" @@ -2930,7 +2931,7 @@ __metadata: babel-plugin-module-resolver: "npm:5.0.3" babel-plugin-react-compiler: "npm:1.0.0" babel-plugin-tester: "npm:10.1.0" - beachball: "npm:3.0.0-alpha.9" + beachball: "npm:3.0.0-alpha.10" chalk: "npm:4.1.0" chrome-remote-interface: "npm:0.28.2" ci-info: "npm:3.9.0" @@ -7775,6 +7776,15 @@ __metadata: languageName: node linkType: hard +"@microsoft/esrp-npm-release@npm:^0.1.3": + version: 0.1.3 + resolution: "@microsoft/esrp-npm-release@npm:0.1.3" + bin: + esrp-npm-release: bin/esrp-npm-release.js + checksum: 10c0/7502d7a71d3b09e95f2f2435f90b474566cd1fe5b8ffc54dd3c6b17d82cba2c2b84277803aeb96cb3efabd2b079a087bc3ee840eb9a678d2044f05d9cded5e7a + languageName: node + linkType: hard + "@microsoft/fast-build@npm:0.9.0": version: 0.9.0 resolution: "@microsoft/fast-build@npm:0.9.0" @@ -13575,14 +13585,14 @@ __metadata: languageName: node linkType: hard -"beachball@npm:3.0.0-alpha.9": - version: 3.0.0-alpha.9 - resolution: "beachball@npm:3.0.0-alpha.9" +"beachball@npm:3.0.0-alpha.10": + version: 3.0.0-alpha.10 + resolution: "beachball@npm:3.0.0-alpha.10" dependencies: "@vercel/detect-agent": "npm:^1.2.1" commander: "npm:^14.0.3" nano-spawn: "npm:^2.1.0" - p-graph: "npm:^3.0.0-alpha.6" + p-graph: "npm:^3.0.0-alpha.7" p-limit: "npm:^3.1.0" prompts: "npm:^2.4.2" semver: "npm:^7.7.4" @@ -13590,7 +13600,7 @@ __metadata: bin: beachball: bin/beachball.js beachball-auth-helper: bin/beachball-auth-helper.js - checksum: 10c0/51d5ec3258ddfed2dd9125437d0140f8d52e37d609a2678483b4223ed3bcb8c09fd92448bf86527cfb4fa9864522a35947ed3d2d7027cae9662767817b322a0e + checksum: 10c0/d9bee3ea354a57bc1f773a3fc36a317ff49e1d738d6412eb8df567a7bf33155b11e588f0c20460e1b6f2e73d9de9908f375e5c75ec3e35436724e756c2e75f83 languageName: node linkType: hard @@ -25984,10 +25994,10 @@ __metadata: languageName: node linkType: hard -"p-graph@npm:^3.0.0-alpha.6": - version: 3.0.0-alpha.6 - resolution: "p-graph@npm:3.0.0-alpha.6" - checksum: 10c0/c68616f581c0fd0f4803ac35e9edadca048913e6dce465d7860b22ec8643921c52d288fc52b9817d3ac565d3da1732d3afa34c6904d505e29bc4ecc3baf7a1c9 +"p-graph@npm:^3.0.0-alpha.7": + version: 3.0.0-alpha.7 + resolution: "p-graph@npm:3.0.0-alpha.7" + checksum: 10c0/1872da69cdc7a2e804aa3889ca2d54215f4d9bdbea367dd9a3f1c936873c159524d13dcdfedb1227e92b0e719d735489428a595b13b2e777c055bc8eda610da2 languageName: node linkType: hard