From 11ba5f8b4ae6c95c8d4f814c5ebf6a329abc6484 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Mon, 20 Jul 2026 20:28:52 -0700 Subject: [PATCH 1/2] ci: publish npm packages through ESRP Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .ado/jobs/npm-esrp-release.yml | 85 ++++++++++++ .ado/jobs/npm-pack.yml | 50 +++++++ .ado/jobs/npm-publish.yml | 64 --------- .ado/publish.yml | 12 +- .../__tests__/configure-publish-test.mts | 55 ++++++++ .ado/scripts/__tests__/npm-pack-test.mts | 102 ++++++++++++++ .ado/scripts/apply-additional-tags.mjs | 102 -------------- .ado/scripts/configure-publish.mts | 90 +++++-------- .ado/scripts/npm-pack.mts | 124 ++++++++++++++++++ .changeset/calm-clouds-release.md | 4 + 10 files changed, 461 insertions(+), 227 deletions(-) create mode 100644 .ado/jobs/npm-esrp-release.yml create mode 100644 .ado/jobs/npm-pack.yml delete mode 100644 .ado/jobs/npm-publish.yml create mode 100644 .ado/scripts/__tests__/configure-publish-test.mts create mode 100644 .ado/scripts/__tests__/npm-pack-test.mts delete mode 100644 .ado/scripts/apply-additional-tags.mjs create mode 100644 .ado/scripts/npm-pack.mts create mode 100644 .changeset/calm-clouds-release.md diff --git a/.ado/jobs/npm-esrp-release.yml b/.ado/jobs/npm-esrp-release.yml new file mode 100644 index 000000000000..74923446946f --- /dev/null +++ b/.ado/jobs/npm-esrp-release.yml @@ -0,0 +1,85 @@ +jobs: + - job: NpmEsrpRelease + displayName: NPM ESRP Release + variables: + - name: publish_react_native_macos + value: $[ stageDependencies.NpmPack.NpmPack.outputs['config.publish_react_native_macos'] ] + - name: publishTag + value: $[ stageDependencies.NpmPack.NpmPack.outputs['config.publishTag'] ] + timeoutInMinutes: 30 + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + artifactName: NpmPackedTarballs + targetPath: $(Pipeline.Workspace)/npm-packed-tarballs + steps: + - checkout: self + clean: true + fetchFilter: blob:none + + - task: UseNode@1 + inputs: + version: '22.22.0' + displayName: Use Node.js 22.22.0 + + - script: node .ado/scripts/npm-pack.mts --no-pack --check-npm "$(Pipeline.Workspace)/npm-packed-tarballs" + displayName: Remove already-published packages + + - pwsh: | + $tgzFiles = Get-ChildItem -Path "$(Pipeline.Workspace)/npm-packed-tarballs" -Filter "*.tgz" + $hasPackages = $tgzFiles.Count -gt 0 + Write-Host "Found $($tgzFiles.Count) unpublished package(s)" + Write-Host "##vso[task.setvariable variable=HasPackagesToPublish]$($hasPackages.ToString().ToLowerInvariant())" + displayName: Check for packages to publish + + - pwsh: | + $required = @{ + EsrpConnectedServiceName = $env:ESRP_CONNECTED_SERVICE_NAME + EsrpKeyVaultName = $env:ESRP_KEY_VAULT_NAME + EsrpAuthCertName = $env:ESRP_AUTH_CERT_NAME + EsrpSignCertName = $env:ESRP_SIGN_CERT_NAME + EsrpClientId = $env:ESRP_CLIENT_ID + EsrpTenantId = $env:ESRP_TENANT_ID + EsrpOwners = $env:ESRP_OWNERS + EsrpApprovers = $env:ESRP_APPROVERS + PublishTag = $env:PUBLISH_TAG + } + $missing = @( + $required.GetEnumerator() | + Where-Object { [string]::IsNullOrWhiteSpace($_.Value) -or $_.Value -match '^\$\(.+\)$' } | + ForEach-Object { $_.Key } + ) + if ($missing.Count -gt 0) { + throw "Missing ESRP onboarding variable(s) in React-native-macos Secrets: $($missing -join ', ')" + } + displayName: Validate ESRP onboarding + condition: and(succeeded(), eq(variables['HasPackagesToPublish'], 'true')) + env: + ESRP_CONNECTED_SERVICE_NAME: $(EsrpConnectedServiceName) + ESRP_KEY_VAULT_NAME: $(EsrpKeyVaultName) + ESRP_AUTH_CERT_NAME: $(EsrpAuthCertName) + ESRP_SIGN_CERT_NAME: $(EsrpSignCertName) + ESRP_CLIENT_ID: $(EsrpClientId) + ESRP_TENANT_ID: $(EsrpTenantId) + ESRP_OWNERS: $(EsrpOwners) + ESRP_APPROVERS: $(EsrpApprovers) + PUBLISH_TAG: $(publishTag) + + - task: EsrpRelease@11 + displayName: ESRP release to npmjs.com + condition: and(succeeded(), eq(variables['HasPackagesToPublish'], 'true')) + inputs: + connectedservicename: $(EsrpConnectedServiceName) + usemanagedidentity: false + keyvaultname: $(EsrpKeyVaultName) + authcertname: $(EsrpAuthCertName) + signcertname: $(EsrpSignCertName) + clientid: $(EsrpClientId) + domaintenantid: $(EsrpTenantId) + contenttype: npm + folderlocation: $(Pipeline.Workspace)\npm-packed-tarballs + productstate: $(publishTag) + owners: $(EsrpOwners) + approvers: $(EsrpApprovers) diff --git a/.ado/jobs/npm-pack.yml b/.ado/jobs/npm-pack.yml new file mode 100644 index 000000000000..5df1750a68f5 --- /dev/null +++ b/.ado/jobs/npm-pack.yml @@ -0,0 +1,50 @@ +jobs: + - job: NpmPack + displayName: NPM Pack + pool: + name: cxeiss-ubuntu-20-04-large + image: cxe-ubuntu-20-04-1es-pt + os: linux + variables: + - name: BUILDSECMON_OPT_IN + value: true + timeoutInMinutes: 90 + cancelTimeoutInMinutes: 5 + templateContext: + outputs: + - output: pipelineArtifact + condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) + targetPath: $(Build.ArtifactStagingDirectory)/npm-packed-tarballs + artifactName: NpmPackedTarballs + steps: + - checkout: self + clean: true + fetchFilter: blob:none + persistCredentials: true + + - task: UseNode@1 + inputs: + version: '22.22.0' + displayName: Use Node.js 22.22.0 + + - template: /.ado/templates/configure-git.yml@self + + - script: yarn install --immutable + displayName: Install npm dependencies + + - script: yarn build + displayName: Build npm packages + + - script: yarn build-types --skip-snapshot + displayName: Build npm package types + + - script: yarn workspaces foreach --all --topological --no-private run build + displayName: Build publishable workspaces + + - script: node .ado/scripts/configure-publish.mts --verbose + name: config + displayName: Verify release config + + - script: node .ado/scripts/npm-pack.mts --clean "$(Build.ArtifactStagingDirectory)/npm-packed-tarballs" + displayName: Pack npm packages + condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) diff --git a/.ado/jobs/npm-publish.yml b/.ado/jobs/npm-publish.yml deleted file mode 100644 index 7da6c6029a76..000000000000 --- a/.ado/jobs/npm-publish.yml +++ /dev/null @@ -1,64 +0,0 @@ -jobs: -- job: NPMPublish - displayName: NPM Publish - pool: - name: cxeiss-ubuntu-20-04-large - image: cxe-ubuntu-20-04-1es-pt - os: linux - variables: - - name: BUILDSECMON_OPT_IN - value: true - - timeoutInMinutes: 90 - cancelTimeoutInMinutes: 5 - templateContext: - outputs: - - output: pipelineArtifact - targetPath: $(System.DefaultWorkingDirectory) - artifactName: github-npm-js-publish - steps: - - checkout: self - clean: true - fetchFilter: blob:none - persistCredentials: true - - - task: UseNode@1 - inputs: - version: '22.22.0' - displayName: 'Use Node.js 22.22.0' - - - template: /.ado/templates/configure-git.yml@self - - - script: | - yarn install - displayName: Install npm dependencies - - - script: | - node .ado/scripts/configure-publish.mts --verbose --skip-auth - displayName: Verify release config - - # Disable Nightly publishing on the main branch - - ${{ if endsWith(variables['Build.SourceBranchName'], '-stable') }}: - - script: | - yarn config set npmPublishAccess public - yarn config set npmPublishRegistry "https://registry.npmjs.org" - yarn config set npmAuthToken $(npmAuthToken) - displayName: Configure yarn for npm publishing - condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) - - - script: | - yarn workspaces foreach -vv --all --topological --no-private npm publish --tag $(publishTag) --tolerate-republish - displayName: Publish packages - condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) - - - script: | - node .ado/scripts/apply-additional-tags.mjs --tags "$(additionalTags)" --token "$(npmAuthToken)" - displayName: Apply additional dist-tags - condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) - - - script: | - yarn config unset npmPublishAccess || true - yarn config unset npmAuthToken || true - yarn config unset npmPublishRegistry || true - displayName: Remove NPM auth configuration - condition: always() diff --git a/.ado/publish.yml b/.ado/publish.yml index 090cd21cd91e..55d33607177b 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -49,7 +49,15 @@ extends: # Justification: js files in this repo are flow files. the built-in eslint does not support this. Adding a separate step to run the sdl rules for flow files. exclusionPatterns: '**/*.js' stages: - - stage: NPM + - stage: NpmPack + displayName: Pack npm packages dependsOn: [] jobs: - - template: /.ado/jobs/npm-publish.yml@self + - template: /.ado/jobs/npm-pack.yml@self + + - stage: Release + displayName: Release npm packages + dependsOn: NpmPack + condition: and(succeeded(), eq(dependencies.NpmPack.outputs['NpmPack.config.publish_react_native_macos'], '1')) + jobs: + - template: /.ado/jobs/npm-esrp-release.yml@self diff --git a/.ado/scripts/__tests__/configure-publish-test.mts b/.ado/scripts/__tests__/configure-publish-test.mts new file mode 100644 index 000000000000..4d9e6dd7a854 --- /dev/null +++ b/.ado/scripts/__tests__/configure-publish-test.mts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; + +import { + getAzurePipelineVariableCommands, + getPublishTag, +} from '../configure-publish.mts'; + +describe('configure-publish', () => { + it('emits plain and named-step output variables', () => { + assert.deepEqual(getAzurePipelineVariableCommands('publishTag', 'next'), [ + '##vso[task.setvariable variable=publishTag]next', + '##vso[task.setvariable variable=publishTag;isOutput=true]next', + ]); + assert.deepEqual( + getAzurePipelineVariableCommands('publish_react_native_macos', '1'), + [ + '##vso[task.setvariable variable=publish_react_native_macos]1', + '##vso[task.setvariable variable=publish_react_native_macos;isOutput=true]1', + ], + ); + }); + + it('uses one tag per release line', () => { + assert.deepEqual( + getPublishTag( + {state: 'STABLE_IS_LATEST', currentVersion: 83, latestVersion: 83, nextVersion: 84}, + '0.83-stable', + ), + {npmTag: 'latest'}, + ); + assert.deepEqual( + getPublishTag( + {state: 'STABLE_IS_OLD', currentVersion: 82, latestVersion: 83, nextVersion: 84}, + '0.82-stable', + ), + {npmTag: '0.82-stable'}, + ); + assert.deepEqual( + getPublishTag( + {state: 'STABLE_IS_NEW', currentVersion: 84, latestVersion: 83, nextVersion: 84}, + '0.84-stable', + ), + {npmTag: 'next', prerelease: 'rc'}, + ); + assert.deepEqual( + getPublishTag( + {state: 'STABLE_IS_NEW', currentVersion: 84, latestVersion: 83, nextVersion: 83}, + '0.84-stable', + 'latest', + ), + {npmTag: 'latest'}, + ); + }); +}); diff --git a/.ado/scripts/__tests__/npm-pack-test.mts b/.ado/scripts/__tests__/npm-pack-test.mts new file mode 100644 index 000000000000..00db4ea77557 --- /dev/null +++ b/.ado/scripts/__tests__/npm-pack-test.mts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import {mkdtemp, mkdir, readFile, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {describe, it} from 'node:test'; + +import { + filterPublishedTarballs, + getPublishableWorkspaces, + getRegistryStatus, + packWorkspaces, + safeTarballName, + type CommandRunner, +} from '../npm-pack.mts'; + +const temporaryDirectory = () => mkdtemp(join(tmpdir(), 'rnm-npm-pack-')); + +describe('npm-pack', () => { + it('selects only public workspaces and creates safe names', async () => { + const root = await temporaryDirectory(); + for (const [directory, manifest] of [ + ['private', {name: 'private-package', private: true, version: '1.0.0'}], + ['public', {name: '@scope/public-package', version: '1.2.3'}], + ] as const) { + await mkdir(join(root, 'packages', directory), {recursive: true}); + await writeFile(join(root, 'packages', directory, 'package.json'), JSON.stringify(manifest)); + } + const run: CommandRunner = async () => ({ + stderr: '', + stdout: + '{"location":"packages/private"}\n' + + '{"location":"packages/public"}\n', + }); + + assert.deepEqual(await getPublishableWorkspaces(root, run), [ + {name: '@scope/public-package', version: '1.2.3'}, + ]); + assert.equal(safeTarballName('@scope/public-package', '1.2.3'), 'scope-public-package-1.2.3.tgz'); + + const calls: string[][] = []; + const pack: CommandRunner = async (_command, args) => { + calls.push(args); + return args[0] === 'workspaces' + ? run(_command, args) + : {stderr: '', stdout: ''}; + }; + await packWorkspaces(root, join(root, 'output'), pack); + assert.deepEqual(calls.at(-1), [ + 'workspace', + '@scope/public-package', + 'pack', + '--out', + join(root, 'output', 'scope-public-package-1.2.3.tgz'), + ]); + }); + + it('removes only exact versions already published', async () => { + const output = await temporaryDirectory(); + const published = join(output, 'published.tgz'); + const unpublished = join(output, 'unpublished.tgz'); + await writeFile(published, 'published'); + await writeFile(unpublished, 'unpublished'); + const run: CommandRunner = async (command, args) => { + const file = args[1]; + if (command === 'tar') { + return { + stderr: '', + stdout: JSON.stringify({ + name: file === published ? 'published' : 'unpublished', + version: '1.0.0', + }), + }; + } + if (args[1] === 'published@1.0.0') return {stderr: '', stdout: '"1.0.0"\n'}; + const error = new Error('not found') as Error & {stderr: string}; + error.stderr = 'npm error code E404'; + throw error; + }; + + assert.deepEqual(await filterPublishedTarballs(output, run), [unpublished]); + await assert.rejects(readFile(published), {code: 'ENOENT'}); + assert.equal(await readFile(unpublished, 'utf8'), 'unpublished'); + }); + + it('fails on registry errors and malformed responses', async () => { + const serverError: CommandRunner = async () => { + const error = new Error('server error') as Error & {stderr: string}; + error.stderr = 'npm error code E500'; + throw error; + }; + await assert.rejects(getRegistryStatus('package', '1.0.0', serverError), /Failed to query npm/); + + const malformed: CommandRunner = async () => ({stderr: '', stdout: 'not-json'}); + await assert.rejects(getRegistryStatus('package', '1.0.0', malformed), SyntaxError); + + const wrongVersion: CommandRunner = async () => ({stderr: '', stdout: '"2.0.0"'}); + await assert.rejects( + getRegistryStatus('package', '1.0.0', wrongVersion), + /unexpected version/, + ); + }); +}); diff --git a/.ado/scripts/apply-additional-tags.mjs b/.ado/scripts/apply-additional-tags.mjs deleted file mode 100644 index 73bb0f35829f..000000000000 --- a/.ado/scripts/apply-additional-tags.mjs +++ /dev/null @@ -1,102 +0,0 @@ -// @ts-check -import { spawnSync } from "node:child_process"; -import * as fs from "node:fs"; -import * as util from "node:util"; - -/** - * Apply additional dist-tags to published packages - * Usage: node apply-additional-tags.mjs --tags --token - * node apply-additional-tags.mjs --tags --dry-run - * Where tags is a comma-separated list of tags (e.g., "next,v0.79-stable") - */ - -const registry = "https://registry.npmjs.org/"; -const packages = [ - "@react-native-macos/virtualized-lists", - "react-native-macos", -]; - -/** - * @typedef {{ - * tags?: string; - * token?: string; - * "dry-run"?: boolean; - * }} Options; - */ - -/** - * @param {Options} options - * @returns {number} - */ -function main({ tags, token, "dry-run": dryRun }) { - if (!tags) { - console.log("No additional tags to apply"); - return 0; - } - - if (!dryRun && !token) { - console.error("Error: npm auth token is required (use --dry-run to preview)"); - return 1; - } - - const packageJson = JSON.parse( - fs.readFileSync("./packages/react-native/package.json", "utf-8") - ); - const version = packageJson.version; - - if (dryRun) { - console.log(""); - console.log("=== Additional dist-tags that would be applied ==="); - for (const tag of tags.split(",")) { - for (const pkg of packages) { - console.log(` ${pkg}@${version} -> ${tag}`); - } - } - return 0; - } - - for (const tag of tags.split(",")) { - for (const pkg of packages) { - console.log(`Adding dist-tag '${tag}' to ${pkg}@${version}`); - const result = spawnSync( - "npm", - [ - "dist-tag", - "add", - `${pkg}@${version}`, - tag, - "--registry", - registry, - `--//registry.npmjs.org/:_authToken=${token}`, - ], - { stdio: "inherit", shell: true } - ); - - if (result.status !== 0) { - console.error(`Failed to add dist-tag '${tag}' to ${pkg}@${version}`); - return 1; - } - } - } - - return 0; -} - -const { values } = util.parseArgs({ - args: process.argv.slice(2), - options: { - tags: { - type: "string", - }, - token: { - type: "string", - }, - "dry-run": { - type: "boolean", - default: false, - }, - }, - strict: true, -}); - -process.exitCode = main(values); diff --git a/.ado/scripts/configure-publish.mts b/.ado/scripts/configure-publish.mts index 0d080975bbcb..d0002c56955e 100644 --- a/.ado/scripts/configure-publish.mts +++ b/.ado/scripts/configure-publish.mts @@ -1,8 +1,7 @@ #!/usr/bin/env node -import { $, argv, echo, fs } from 'zx'; +import {$, argv, echo, fs} from 'zx'; import { resolve } from 'node:path'; -const NPM_DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; const NPM_TAG_NEXT = 'next'; export type ReleaseState = 'STABLE_IS_LATEST' | 'STABLE_IS_NEW' | 'STABLE_IS_OLD'; @@ -15,13 +14,12 @@ export interface ReleaseStateInfo { } export interface TagInfo { - npmTags: string[]; + npmTag: string; prerelease?: string; } interface Options { 'mock-branch'?: string; - 'skip-auth'?: boolean; tag?: string; verbose?: boolean; } @@ -31,7 +29,23 @@ interface Options { * enable publishing on Azure Pipelines. */ function enablePublishingOnAzurePipelines() { - echo(`##vso[task.setvariable variable=publish_react_native_macos]1`); + setAzurePipelineVariable('publish_react_native_macos', '1'); +} + +export function getAzurePipelineVariableCommands( + name: string, + value: string, +): string[] { + return [ + `##vso[task.setvariable variable=${name}]${value}`, + `##vso[task.setvariable variable=${name};isOutput=true]${value}`, + ]; +} + +function setAzurePipelineVariable(name: string, value: string) { + for (const command of getAzurePipelineVariableCommands(name, value)) { + echo(command); + } } export function isMainBranch(branch: string): boolean { @@ -122,7 +136,7 @@ export function getReleaseState( return { state, currentVersion, latestVersion, nextVersion }; } -export function getPublishTags( +export function getPublishTag( stateInfo: ReleaseStateInfo, branch: string, tag: string = NPM_TAG_NEXT, @@ -131,21 +145,15 @@ export function getPublishTags( switch (state) { case 'STABLE_IS_LATEST': - // Patching the current latest version - return { npmTags: ['latest', branch] }; + // The current release line follows RNW's latest-only model. + return {npmTag: 'latest'}; case 'STABLE_IS_OLD': - // Patching an older stable version - return { npmTags: [branch] }; + return {npmTag: branch}; case 'STABLE_IS_NEW': { if (tag === 'latest') { - // Promoting this branch to latest - const npmTags = ['latest', branch]; - if (currentVersion > nextVersion) { - npmTags.push(NPM_TAG_NEXT); - } - return { npmTags }; + return {npmTag: 'latest'}; } // Publishing a release candidate @@ -155,50 +163,15 @@ export function getPublishTags( ); } - return { npmTags: [NPM_TAG_NEXT], prerelease: 'rc' }; - } - } -} - -async function verifyNpmAuth(registry = NPM_DEFAULT_REGISTRY) { - const whoami = await $`npm whoami --registry ${registry}`.nothrow(); - if (whoami.exitCode !== 0) { - const errText = whoami.stderr; - const m = errText.match(/npm error code (\w+)/); - const errorCode = m && m[1]; - switch (errorCode) { - case 'EINVALIDNPMTOKEN': - throw new Error(`Invalid auth token for npm registry: ${registry}`); - case 'ENEEDAUTH': - throw new Error(`Missing auth token for npm registry: ${registry}`); - default: - throw new Error(errText); + return {npmTag: NPM_TAG_NEXT, prerelease: 'rc'}; } } } -async function enablePublishing(tagInfo: TagInfo, options: Options) { - const [primaryTag, ...additionalTags] = tagInfo.npmTags; - - // Output publishTag for subsequent pipeline steps - echo(`##vso[task.setvariable variable=publishTag]${primaryTag}`); +function enablePublishing(tagInfo: TagInfo) { + setAzurePipelineVariable('publishTag', tagInfo.npmTag); if (process.env['GITHUB_OUTPUT']) { - fs.appendFileSync(process.env['GITHUB_OUTPUT'], `publishTag=${primaryTag}\n`); - } - - // Output additional tags - if (additionalTags.length > 0) { - const tagsValue = additionalTags.join(','); - echo(`##vso[task.setvariable variable=additionalTags]${tagsValue}`); - if (process.env['GITHUB_OUTPUT']) { - fs.appendFileSync(process.env['GITHUB_OUTPUT'], `additionalTags=${tagsValue}\n`); - } - } - - if (options['skip-auth']) { - echo('ℹ️ Skipped npm auth validation'); - } else { - await verifyNpmAuth(); + fs.appendFileSync(process.env['GITHUB_OUTPUT'], `publishTag=${tagInfo.npmTag}\n`); } // Don't enable publishing in PRs @@ -215,7 +188,6 @@ if (isDirectRun) { // Parse CLI args using zx's argv (minimist) const options: Options = { 'mock-branch': argv['mock-branch'] as string | undefined, - 'skip-auth': Boolean(argv['skip-auth']), tag: typeof argv['tag'] === 'string' ? argv['tag'] : NPM_TAG_NEXT, verbose: Boolean(argv['verbose']), }; @@ -239,10 +211,10 @@ if (isDirectRun) { log(`Current version: ${stateInfo.currentVersion}`); log(`Release state: ${stateInfo.state}`); - const tagInfo = getPublishTags(stateInfo, branch, options.tag); - log(`Expected npm tags: ${tagInfo.npmTags.join(', ')}`); + const tagInfo = getPublishTag(stateInfo, branch, options.tag); + log(`Expected npm tag: ${tagInfo.npmTag}`); - await enablePublishing(tagInfo, options); + enablePublishing(tagInfo); } else { echo(`ℹ️ Branch '${branch}' is not main or a stable branch — skipping`); } diff --git a/.ado/scripts/npm-pack.mts b/.ado/scripts/npm-pack.mts new file mode 100644 index 000000000000..a2439d754a72 --- /dev/null +++ b/.ado/scripts/npm-pack.mts @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import {execFile as execFileCallback} from 'node:child_process'; +import {mkdir, readFile, readdir, rm} from 'node:fs/promises'; +import {join, resolve} from 'node:path'; +import {parseArgs, promisify} from 'node:util'; + +const execFile = promisify(execFileCallback); +const registry = 'https://registry.npmjs.org/'; + +export type CommandRunner = ( + command: string, + args: string[], + options?: {cwd?: string}, +) => Promise<{stdout: string; stderr: string}>; + +const runCommand: CommandRunner = (command, args, options) => + execFile(command, args, {cwd: options?.cwd, encoding: 'utf8'}); + +export function safeTarballName(name: string, version: string): string { + return `${name.replace(/^@/, '').replaceAll('/', '-')}-${version}.tgz`; +} + +export async function getPublishableWorkspaces( + root: string, + run: CommandRunner = runCommand, +): Promise> { + const {stdout} = await run('yarn', ['workspaces', 'list', '--json'], {cwd: root}); + const workspaces = []; + for (const line of stdout.split(/\r?\n/).filter(Boolean)) { + const {location} = JSON.parse(line) as {location: string}; + const manifest = JSON.parse(await readFile(join(root, location, 'package.json'), 'utf8')) as { + name?: string; + private?: boolean; + version?: string; + }; + if (manifest.private) continue; + if (!manifest.name || !manifest.version) { + throw new Error(`Publishable workspace is missing name or version: ${location}`); + } + workspaces.push({name: manifest.name, version: manifest.version}); + } + return workspaces; +} + +export async function packWorkspaces( + root: string, + output: string, + run: CommandRunner = runCommand, +): Promise { + await mkdir(output, {recursive: true}); + for (const workspace of await getPublishableWorkspaces(root, run)) { + const tarball = join(output, safeTarballName(workspace.name, workspace.version)); + await run('yarn', ['workspace', workspace.name, 'pack', '--out', tarball], {cwd: root}); + console.log(`Packed ${workspace.name}@${workspace.version}: ${tarball}`); + } +} + +export async function getRegistryStatus( + name: string, + version: string, + run: CommandRunner = runCommand, +): Promise<'published' | 'unpublished'> { + let stdout: string; + try { + ({stdout} = await run('npm', [ + 'view', + `${name}@${version}`, + 'version', + '--json', + '--registry', + registry, + ])); + } catch (error) { + const output = `${(error as {stdout?: string}).stdout ?? ''}\n${ + (error as {stderr?: string}).stderr ?? '' + }`; + if (/\bE404\b|404 Not Found|is not in this registry/i.test(output)) return 'unpublished'; + throw new Error(`Failed to query npm for ${name}@${version}`, {cause: error}); + } + if (JSON.parse(stdout) !== version) { + throw new Error(`npm returned an unexpected version for ${name}@${version}`); + } + return 'published'; +} + +export async function filterPublishedTarballs( + output: string, + run: CommandRunner = runCommand, +): Promise { + const remaining = []; + for (const file of (await readdir(output)).filter(name => name.endsWith('.tgz')).sort()) { + const tarball = join(output, file); + const {stdout} = await run('tar', ['-xOf', tarball, 'package/package.json']); + const {name, version} = JSON.parse(stdout) as {name?: string; version?: string}; + if (!name || !version) throw new Error(`Invalid package metadata in ${tarball}`); + if ((await getRegistryStatus(name, version, run)) === 'published') { + await rm(tarball); + console.log(`Removed already-published ${name}@${version}`); + } else { + remaining.push(tarball); + console.log(`Keeping unpublished ${name}@${version}`); + } + } + return remaining; +} + +const isDirectRun = process.argv[1] != null && resolve(process.argv[1]) === resolve(import.meta.filename); +if (isDirectRun) { + const {values, positionals} = parseArgs({ + options: { + 'check-npm': {type: 'boolean', default: false}, + clean: {type: 'boolean', default: false}, + 'no-pack': {type: 'boolean', default: false}, + }, + allowPositionals: true, + strict: true, + }); + if (positionals.length !== 1) throw new Error('Expected one output directory'); + const output = resolve(positionals[0]); + if (values.clean) await rm(output, {recursive: true, force: true}); + await mkdir(output, {recursive: true}); + if (!values['no-pack']) await packWorkspaces(process.cwd(), output); + if (values['check-npm']) await filterPublishedTarballs(output); +} diff --git a/.changeset/calm-clouds-release.md b/.changeset/calm-clouds-release.md new file mode 100644 index 000000000000..3c408d222dc2 --- /dev/null +++ b/.changeset/calm-clouds-release.md @@ -0,0 +1,4 @@ +--- +--- + +Migrate Azure DevOps npm publishing to ESRP without changing package versions. From 7573ace04c172adfd41d3d3df4c32700931a3d76 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Mon, 20 Jul 2026 23:01:50 -0700 Subject: [PATCH 2/2] ci: simplify ESRP npm release pipeline Restore the original single-stage npm publishing shape, replace PowerShell release checks with Node helpers, and add a safe manual dry-run gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .ado/jobs/npm-esrp-release.yml | 85 -------------- .ado/jobs/npm-pack.yml | 50 -------- .ado/jobs/npm-publish.yml | 109 ++++++++++++++++++ .ado/publish.yml | 20 ++-- .../__tests__/configure-publish-test.mts | 36 ++++-- .ado/scripts/__tests__/npm-pack-test.mts | 53 ++++++++- .../__tests__/validate-esrp-config-test.mts | 39 +++++++ .ado/scripts/configure-publish.mts | 52 +++++++-- .ado/scripts/npm-pack.mts | 19 ++- .ado/scripts/validate-esrp-config.mts | 50 ++++++++ 10 files changed, 345 insertions(+), 168 deletions(-) delete mode 100644 .ado/jobs/npm-esrp-release.yml delete mode 100644 .ado/jobs/npm-pack.yml create mode 100644 .ado/jobs/npm-publish.yml create mode 100644 .ado/scripts/__tests__/validate-esrp-config-test.mts create mode 100644 .ado/scripts/validate-esrp-config.mts diff --git a/.ado/jobs/npm-esrp-release.yml b/.ado/jobs/npm-esrp-release.yml deleted file mode 100644 index 74923446946f..000000000000 --- a/.ado/jobs/npm-esrp-release.yml +++ /dev/null @@ -1,85 +0,0 @@ -jobs: - - job: NpmEsrpRelease - displayName: NPM ESRP Release - variables: - - name: publish_react_native_macos - value: $[ stageDependencies.NpmPack.NpmPack.outputs['config.publish_react_native_macos'] ] - - name: publishTag - value: $[ stageDependencies.NpmPack.NpmPack.outputs['config.publishTag'] ] - timeoutInMinutes: 30 - templateContext: - type: releaseJob - isProduction: true - inputs: - - input: pipelineArtifact - artifactName: NpmPackedTarballs - targetPath: $(Pipeline.Workspace)/npm-packed-tarballs - steps: - - checkout: self - clean: true - fetchFilter: blob:none - - - task: UseNode@1 - inputs: - version: '22.22.0' - displayName: Use Node.js 22.22.0 - - - script: node .ado/scripts/npm-pack.mts --no-pack --check-npm "$(Pipeline.Workspace)/npm-packed-tarballs" - displayName: Remove already-published packages - - - pwsh: | - $tgzFiles = Get-ChildItem -Path "$(Pipeline.Workspace)/npm-packed-tarballs" -Filter "*.tgz" - $hasPackages = $tgzFiles.Count -gt 0 - Write-Host "Found $($tgzFiles.Count) unpublished package(s)" - Write-Host "##vso[task.setvariable variable=HasPackagesToPublish]$($hasPackages.ToString().ToLowerInvariant())" - displayName: Check for packages to publish - - - pwsh: | - $required = @{ - EsrpConnectedServiceName = $env:ESRP_CONNECTED_SERVICE_NAME - EsrpKeyVaultName = $env:ESRP_KEY_VAULT_NAME - EsrpAuthCertName = $env:ESRP_AUTH_CERT_NAME - EsrpSignCertName = $env:ESRP_SIGN_CERT_NAME - EsrpClientId = $env:ESRP_CLIENT_ID - EsrpTenantId = $env:ESRP_TENANT_ID - EsrpOwners = $env:ESRP_OWNERS - EsrpApprovers = $env:ESRP_APPROVERS - PublishTag = $env:PUBLISH_TAG - } - $missing = @( - $required.GetEnumerator() | - Where-Object { [string]::IsNullOrWhiteSpace($_.Value) -or $_.Value -match '^\$\(.+\)$' } | - ForEach-Object { $_.Key } - ) - if ($missing.Count -gt 0) { - throw "Missing ESRP onboarding variable(s) in React-native-macos Secrets: $($missing -join ', ')" - } - displayName: Validate ESRP onboarding - condition: and(succeeded(), eq(variables['HasPackagesToPublish'], 'true')) - env: - ESRP_CONNECTED_SERVICE_NAME: $(EsrpConnectedServiceName) - ESRP_KEY_VAULT_NAME: $(EsrpKeyVaultName) - ESRP_AUTH_CERT_NAME: $(EsrpAuthCertName) - ESRP_SIGN_CERT_NAME: $(EsrpSignCertName) - ESRP_CLIENT_ID: $(EsrpClientId) - ESRP_TENANT_ID: $(EsrpTenantId) - ESRP_OWNERS: $(EsrpOwners) - ESRP_APPROVERS: $(EsrpApprovers) - PUBLISH_TAG: $(publishTag) - - - task: EsrpRelease@11 - displayName: ESRP release to npmjs.com - condition: and(succeeded(), eq(variables['HasPackagesToPublish'], 'true')) - inputs: - connectedservicename: $(EsrpConnectedServiceName) - usemanagedidentity: false - keyvaultname: $(EsrpKeyVaultName) - authcertname: $(EsrpAuthCertName) - signcertname: $(EsrpSignCertName) - clientid: $(EsrpClientId) - domaintenantid: $(EsrpTenantId) - contenttype: npm - folderlocation: $(Pipeline.Workspace)\npm-packed-tarballs - productstate: $(publishTag) - owners: $(EsrpOwners) - approvers: $(EsrpApprovers) diff --git a/.ado/jobs/npm-pack.yml b/.ado/jobs/npm-pack.yml deleted file mode 100644 index 5df1750a68f5..000000000000 --- a/.ado/jobs/npm-pack.yml +++ /dev/null @@ -1,50 +0,0 @@ -jobs: - - job: NpmPack - displayName: NPM Pack - pool: - name: cxeiss-ubuntu-20-04-large - image: cxe-ubuntu-20-04-1es-pt - os: linux - variables: - - name: BUILDSECMON_OPT_IN - value: true - timeoutInMinutes: 90 - cancelTimeoutInMinutes: 5 - templateContext: - outputs: - - output: pipelineArtifact - condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) - targetPath: $(Build.ArtifactStagingDirectory)/npm-packed-tarballs - artifactName: NpmPackedTarballs - steps: - - checkout: self - clean: true - fetchFilter: blob:none - persistCredentials: true - - - task: UseNode@1 - inputs: - version: '22.22.0' - displayName: Use Node.js 22.22.0 - - - template: /.ado/templates/configure-git.yml@self - - - script: yarn install --immutable - displayName: Install npm dependencies - - - script: yarn build - displayName: Build npm packages - - - script: yarn build-types --skip-snapshot - displayName: Build npm package types - - - script: yarn workspaces foreach --all --topological --no-private run build - displayName: Build publishable workspaces - - - script: node .ado/scripts/configure-publish.mts --verbose - name: config - displayName: Verify release config - - - script: node .ado/scripts/npm-pack.mts --clean "$(Build.ArtifactStagingDirectory)/npm-packed-tarballs" - displayName: Pack npm packages - condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) diff --git a/.ado/jobs/npm-publish.yml b/.ado/jobs/npm-publish.yml new file mode 100644 index 000000000000..f27e49130344 --- /dev/null +++ b/.ado/jobs/npm-publish.yml @@ -0,0 +1,109 @@ +parameters: + - name: dryRun + type: boolean + default: false + +jobs: + - job: NpmPack + displayName: NPM Pack + pool: + name: cxeiss-ubuntu-20-04-large + image: cxe-ubuntu-20-04-1es-pt + os: linux + variables: + - name: BUILDSECMON_OPT_IN + value: true + timeoutInMinutes: 90 + cancelTimeoutInMinutes: 5 + templateContext: + outputs: + - output: pipelineArtifact + condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) + targetPath: $(Build.ArtifactStagingDirectory)/npm-packed-tarballs + artifactName: NpmPackedTarballs + steps: + - checkout: self + clean: true + fetchFilter: blob:none + persistCredentials: true + + - task: UseNode@1 + inputs: + version: '22.22.0' + displayName: Use Node.js 22.22.0 + + - template: /.ado/templates/configure-git.yml@self + + - script: yarn install + displayName: Install npm dependencies + + - script: yarn workspaces foreach --all --topological --no-private run build + displayName: Build publishable workspaces + + - script: node .ado/scripts/configure-publish.mts --verbose --skip-auth + name: config + displayName: Verify release config + + - script: node .ado/scripts/npm-pack.mts --clean "$(Build.ArtifactStagingDirectory)/npm-packed-tarballs" + displayName: Pack npm packages + condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) + + - job: NpmEsrpRelease + displayName: NPM ESRP Release + dependsOn: NpmPack + condition: and(succeeded(), eq(dependencies.NpmPack.outputs['config.publish_react_native_macos'], '1')) + variables: + - name: publish_react_native_macos + value: $[ dependencies.NpmPack.outputs['config.publish_react_native_macos'] ] + - name: publishTag + value: $[ dependencies.NpmPack.outputs['config.publishTag'] ] + timeoutInMinutes: 30 + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + artifactName: NpmPackedTarballs + targetPath: $(Pipeline.Workspace)/npm-packed-tarballs + steps: + - checkout: self + clean: true + fetchFilter: blob:none + + - task: UseNode@1 + inputs: + version: '22.22.0' + displayName: Use Node.js 22.22.0 + + - script: node .ado/scripts/npm-pack.mts --no-pack --check-npm "$(Pipeline.Workspace)/npm-packed-tarballs" + displayName: Remove already-published packages + + - script: node .ado/scripts/validate-esrp-config.mts + displayName: Validate ESRP onboarding + env: + EsrpConnectedServiceName: $(EsrpConnectedServiceName) + EsrpKeyVaultName: $(EsrpKeyVaultName) + EsrpAuthCertName: $(EsrpAuthCertName) + EsrpSignCertName: $(EsrpSignCertName) + EsrpClientId: $(EsrpClientId) + EsrpTenantId: $(EsrpTenantId) + EsrpOwners: $(EsrpOwners) + EsrpApprovers: $(EsrpApprovers) + publishTag: $(publishTag) + + - task: EsrpRelease@11 + displayName: ESRP release to npmjs.com + condition: and(succeeded(), eq(variables['HasPackagesToPublish'], 'true'), eq('${{ parameters.dryRun }}', 'false')) + inputs: + connectedservicename: $(EsrpConnectedServiceName) + usemanagedidentity: false + keyvaultname: $(EsrpKeyVaultName) + authcertname: $(EsrpAuthCertName) + signcertname: $(EsrpSignCertName) + clientid: $(EsrpClientId) + domaintenantid: $(EsrpTenantId) + contenttype: npm + folderlocation: $(Pipeline.Workspace)/npm-packed-tarballs + productstate: $(publishTag) + owners: $(EsrpOwners) + approvers: $(EsrpApprovers) diff --git a/.ado/publish.yml b/.ado/publish.yml index 55d33607177b..65ff7d50c6e5 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -1,5 +1,11 @@ name: $(Date:yyyyMMdd).$(Rev:.r) +parameters: + - name: dryRun + displayName: Validate npm release without publishing + type: boolean + default: false + trigger: batch: true branches: @@ -49,15 +55,9 @@ extends: # Justification: js files in this repo are flow files. the built-in eslint does not support this. Adding a separate step to run the sdl rules for flow files. exclusionPatterns: '**/*.js' stages: - - stage: NpmPack - displayName: Pack npm packages + - stage: NPM dependsOn: [] jobs: - - template: /.ado/jobs/npm-pack.yml@self - - - stage: Release - displayName: Release npm packages - dependsOn: NpmPack - condition: and(succeeded(), eq(dependencies.NpmPack.outputs['NpmPack.config.publish_react_native_macos'], '1')) - jobs: - - template: /.ado/jobs/npm-esrp-release.yml@self + - template: /.ado/jobs/npm-publish.yml@self + parameters: + dryRun: ${{ parameters.dryRun }} diff --git a/.ado/scripts/__tests__/configure-publish-test.mts b/.ado/scripts/__tests__/configure-publish-test.mts index 4d9e6dd7a854..3d162686db83 100644 --- a/.ado/scripts/__tests__/configure-publish-test.mts +++ b/.ado/scripts/__tests__/configure-publish-test.mts @@ -1,11 +1,16 @@ import assert from 'node:assert/strict'; +import {execFile as execFileCallback} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {promisify} from 'node:util'; import {describe, it} from 'node:test'; import { getAzurePipelineVariableCommands, - getPublishTag, + getPublishTags, } from '../configure-publish.mts'; +const execFile = promisify(execFileCallback); + describe('configure-publish', () => { it('emits plain and named-step output variables', () => { assert.deepEqual(getAzurePipelineVariableCommands('publishTag', 'next'), [ @@ -23,33 +28,46 @@ describe('configure-publish', () => { it('uses one tag per release line', () => { assert.deepEqual( - getPublishTag( + getPublishTags( {state: 'STABLE_IS_LATEST', currentVersion: 83, latestVersion: 83, nextVersion: 84}, '0.83-stable', ), - {npmTag: 'latest'}, + {npmTags: ['latest']}, ); assert.deepEqual( - getPublishTag( + getPublishTags( {state: 'STABLE_IS_OLD', currentVersion: 82, latestVersion: 83, nextVersion: 84}, '0.82-stable', ), - {npmTag: '0.82-stable'}, + {npmTags: ['0.82-stable']}, ); assert.deepEqual( - getPublishTag( + getPublishTags( {state: 'STABLE_IS_NEW', currentVersion: 84, latestVersion: 83, nextVersion: 84}, '0.84-stable', ), - {npmTag: 'next', prerelease: 'rc'}, + {npmTags: ['next'], prerelease: 'rc'}, ); assert.deepEqual( - getPublishTag( + getPublishTags( {state: 'STABLE_IS_NEW', currentVersion: 84, latestVersion: 83, nextVersion: 83}, '0.84-stable', 'latest', ), - {npmTag: 'latest'}, + {npmTags: ['latest']}, ); }); + + it('does not emit publish variables on main', async () => { + const script = fileURLToPath(new URL('../configure-publish.mts', import.meta.url)); + const {stdout} = await execFile(process.execPath, [ + script, + '--mock-branch', + 'main', + '--skip-auth', + ]); + + assert.match(stdout, /nightly publishing is currently disabled/); + assert.doesNotMatch(stdout, /##vso\[task\.setvariable/); + }); }); diff --git a/.ado/scripts/__tests__/npm-pack-test.mts b/.ado/scripts/__tests__/npm-pack-test.mts index 00db4ea77557..a2386b184ae4 100644 --- a/.ado/scripts/__tests__/npm-pack-test.mts +++ b/.ado/scripts/__tests__/npm-pack-test.mts @@ -1,11 +1,13 @@ import assert from 'node:assert/strict'; -import {mkdtemp, mkdir, readFile, writeFile} from 'node:fs/promises'; +import {mkdtemp, mkdir, readFile, rm, writeFile} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {describe, it} from 'node:test'; import { + checkPublishedTarballs, filterPublishedTarballs, + getHasPackagesToPublishCommand, getPublishableWorkspaces, getRegistryStatus, packWorkspaces, @@ -16,6 +18,17 @@ import { const temporaryDirectory = () => mkdtemp(join(tmpdir(), 'rnm-npm-pack-')); describe('npm-pack', () => { + it('emits the Azure package availability variable', () => { + assert.equal( + getHasPackagesToPublishCommand(1), + '##vso[task.setvariable variable=HasPackagesToPublish]true', + ); + assert.equal( + getHasPackagesToPublishCommand(0), + '##vso[task.setvariable variable=HasPackagesToPublish]false', + ); + }); + it('selects only public workspaces and creates safe names', async () => { const root = await temporaryDirectory(); for (const [directory, manifest] of [ @@ -82,6 +95,44 @@ describe('npm-pack', () => { assert.equal(await readFile(unpublished, 'utf8'), 'unpublished'); }); + it('reports whether filtered tarballs remain', async () => { + const output = await temporaryDirectory(); + const unpublished = join(output, 'unpublished.tgz'); + await writeFile(unpublished, 'unpublished'); + const run: CommandRunner = async (command, args) => { + if (command === 'tar') { + return { + stderr: '', + stdout: JSON.stringify({name: 'unpublished', version: '1.0.0'}), + }; + } + const error = new Error('not found') as Error & {stderr: string}; + error.stderr = 'npm error code E404'; + throw error; + }; + const messages: string[] = []; + + assert.deepEqual( + await checkPublishedTarballs(output, run, message => messages.push(message)), + [unpublished], + ); + assert.deepEqual(messages, [ + 'Found 1 unpublished package(s)', + '##vso[task.setvariable variable=HasPackagesToPublish]true', + ]); + + await rm(unpublished); + messages.length = 0; + assert.deepEqual( + await checkPublishedTarballs(output, run, message => messages.push(message)), + [], + ); + assert.deepEqual(messages, [ + 'Found 0 unpublished package(s)', + '##vso[task.setvariable variable=HasPackagesToPublish]false', + ]); + }); + it('fails on registry errors and malformed responses', async () => { const serverError: CommandRunner = async () => { const error = new Error('server error') as Error & {stderr: string}; diff --git a/.ado/scripts/__tests__/validate-esrp-config-test.mts b/.ado/scripts/__tests__/validate-esrp-config-test.mts new file mode 100644 index 000000000000..a0379df8534f --- /dev/null +++ b/.ado/scripts/__tests__/validate-esrp-config-test.mts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; + +import { + ESRP_CONFIG_KEYS, + getInvalidEsrpConfigKeys, + validateEsrpConfig, +} from '../validate-esrp-config.mts'; + +const populatedEnvironment = () => + Object.fromEntries(ESRP_CONFIG_KEYS.map(key => [key, `${key}-value`])); + +describe('validate-esrp-config', () => { + it('accepts a fully populated configuration', () => { + assert.doesNotThrow(() => validateEsrpConfig(populatedEnvironment())); + }); + + it('rejects missing, blank, and unexpanded values by key name only', () => { + const environment: Record = populatedEnvironment(); + delete environment.EsrpClientId; + environment.EsrpOwners = ' '; + environment.publishTag = '$(publishTag)'; + + assert.deepEqual(getInvalidEsrpConfigKeys(environment), [ + 'EsrpClientId', + 'EsrpOwners', + 'publishTag', + ]); + + assert.throws( + () => validateEsrpConfig(environment), + error => + error instanceof Error && + error.message === + 'Missing or unexpanded ESRP onboarding variable(s): EsrpClientId, EsrpOwners, publishTag' && + !error.message.includes('$(publishTag)'), + ); + }); +}); diff --git a/.ado/scripts/configure-publish.mts b/.ado/scripts/configure-publish.mts index d0002c56955e..17d971c4f2fd 100644 --- a/.ado/scripts/configure-publish.mts +++ b/.ado/scripts/configure-publish.mts @@ -2,6 +2,7 @@ import {$, argv, echo, fs} from 'zx'; import { resolve } from 'node:path'; +const NPM_DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; const NPM_TAG_NEXT = 'next'; export type ReleaseState = 'STABLE_IS_LATEST' | 'STABLE_IS_NEW' | 'STABLE_IS_OLD'; @@ -14,12 +15,13 @@ export interface ReleaseStateInfo { } export interface TagInfo { - npmTag: string; + npmTags: string[]; prerelease?: string; } interface Options { 'mock-branch'?: string; + 'skip-auth'?: boolean; tag?: string; verbose?: boolean; } @@ -136,7 +138,7 @@ export function getReleaseState( return { state, currentVersion, latestVersion, nextVersion }; } -export function getPublishTag( +export function getPublishTags( stateInfo: ReleaseStateInfo, branch: string, tag: string = NPM_TAG_NEXT, @@ -146,14 +148,14 @@ export function getPublishTag( switch (state) { case 'STABLE_IS_LATEST': // The current release line follows RNW's latest-only model. - return {npmTag: 'latest'}; + return {npmTags: ['latest']}; case 'STABLE_IS_OLD': - return {npmTag: branch}; + return {npmTags: [branch]}; case 'STABLE_IS_NEW': { if (tag === 'latest') { - return {npmTag: 'latest'}; + return {npmTags: ['latest']}; } // Publishing a release candidate @@ -163,15 +165,40 @@ export function getPublishTag( ); } - return {npmTag: NPM_TAG_NEXT, prerelease: 'rc'}; + return {npmTags: [NPM_TAG_NEXT], prerelease: 'rc'}; } } } -function enablePublishing(tagInfo: TagInfo) { - setAzurePipelineVariable('publishTag', tagInfo.npmTag); +async function verifyNpmAuth(registry = NPM_DEFAULT_REGISTRY) { + const whoami = await $`npm whoami --registry ${registry}`.nothrow(); + if (whoami.exitCode !== 0) { + const errText = whoami.stderr; + const m = errText.match(/npm error code (\w+)/); + const errorCode = m && m[1]; + switch (errorCode) { + case 'EINVALIDNPMTOKEN': + throw new Error(`Invalid auth token for npm registry: ${registry}`); + case 'ENEEDAUTH': + throw new Error(`Missing auth token for npm registry: ${registry}`); + default: + throw new Error(errText); + } + } +} + +async function enablePublishing(tagInfo: TagInfo, options: Options) { + const [primaryTag] = tagInfo.npmTags; + + setAzurePipelineVariable('publishTag', primaryTag); if (process.env['GITHUB_OUTPUT']) { - fs.appendFileSync(process.env['GITHUB_OUTPUT'], `publishTag=${tagInfo.npmTag}\n`); + fs.appendFileSync(process.env['GITHUB_OUTPUT'], `publishTag=${primaryTag}\n`); + } + + if (options['skip-auth']) { + echo('ℹ️ Skipped npm auth validation'); + } else { + await verifyNpmAuth(); } // Don't enable publishing in PRs @@ -188,6 +215,7 @@ if (isDirectRun) { // Parse CLI args using zx's argv (minimist) const options: Options = { 'mock-branch': argv['mock-branch'] as string | undefined, + 'skip-auth': Boolean(argv['skip-auth']), tag: typeof argv['tag'] === 'string' ? argv['tag'] : NPM_TAG_NEXT, verbose: Boolean(argv['verbose']), }; @@ -211,10 +239,10 @@ if (isDirectRun) { log(`Current version: ${stateInfo.currentVersion}`); log(`Release state: ${stateInfo.state}`); - const tagInfo = getPublishTag(stateInfo, branch, options.tag); - log(`Expected npm tag: ${tagInfo.npmTag}`); + const tagInfo = getPublishTags(stateInfo, branch, options.tag); + log(`Expected npm tag: ${tagInfo.npmTags[0]}`); - enablePublishing(tagInfo); + await enablePublishing(tagInfo, options); } else { echo(`ℹ️ Branch '${branch}' is not main or a stable branch — skipping`); } diff --git a/.ado/scripts/npm-pack.mts b/.ado/scripts/npm-pack.mts index a2439d754a72..6528d17b5ff3 100644 --- a/.ado/scripts/npm-pack.mts +++ b/.ado/scripts/npm-pack.mts @@ -104,6 +104,21 @@ export async function filterPublishedTarballs( return remaining; } +export function getHasPackagesToPublishCommand(packageCount: number): string { + return `##vso[task.setvariable variable=HasPackagesToPublish]${packageCount > 0}`; +} + +export async function checkPublishedTarballs( + output: string, + run: CommandRunner = runCommand, + log: (message: string) => void = console.log, +): Promise { + const remaining = await filterPublishedTarballs(output, run); + log(`Found ${remaining.length} unpublished package(s)`); + log(getHasPackagesToPublishCommand(remaining.length)); + return remaining; +} + const isDirectRun = process.argv[1] != null && resolve(process.argv[1]) === resolve(import.meta.filename); if (isDirectRun) { const {values, positionals} = parseArgs({ @@ -120,5 +135,7 @@ if (isDirectRun) { if (values.clean) await rm(output, {recursive: true, force: true}); await mkdir(output, {recursive: true}); if (!values['no-pack']) await packWorkspaces(process.cwd(), output); - if (values['check-npm']) await filterPublishedTarballs(output); + if (values['check-npm']) { + await checkPublishedTarballs(output); + } } diff --git a/.ado/scripts/validate-esrp-config.mts b/.ado/scripts/validate-esrp-config.mts new file mode 100644 index 000000000000..991d48f669b6 --- /dev/null +++ b/.ado/scripts/validate-esrp-config.mts @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import {resolve} from 'node:path'; + +export const ESRP_CONFIG_KEYS = [ + 'EsrpConnectedServiceName', + 'EsrpKeyVaultName', + 'EsrpAuthCertName', + 'EsrpSignCertName', + 'EsrpClientId', + 'EsrpTenantId', + 'EsrpOwners', + 'EsrpApprovers', + 'publishTag', +] as const; + +type EsrpConfigKey = (typeof ESRP_CONFIG_KEYS)[number]; + +export function getInvalidEsrpConfigKeys( + environment: Readonly>, +): EsrpConfigKey[] { + return ESRP_CONFIG_KEYS.filter(key => { + const value = environment[key]?.trim(); + return !value || /^\$\(.+\)$/.test(value); + }); +} + +export function validateEsrpConfig( + environment: Readonly> = process.env, +): void { + const invalidKeys = getInvalidEsrpConfigKeys(environment); + if (invalidKeys.length > 0) { + throw new Error( + `Missing or unexpanded ESRP onboarding variable(s): ${invalidKeys.join(', ')}`, + ); + } +} + +const isDirectRun = + process.argv[1] != null && + resolve(process.argv[1]) === new URL(import.meta.url).pathname; + +if (isDirectRun) { + try { + validateEsrpConfig(); + console.log('ESRP onboarding configuration is valid'); + } catch (error) { + console.error((error as Error).message); + process.exitCode = 1; + } +}