diff --git a/.ado/jobs/npm-publish.yml b/.ado/jobs/npm-publish.yml index 7da6c6029a76..f27e49130344 100644 --- a/.ado/jobs/npm-publish.yml +++ b/.ado/jobs/npm-publish.yml @@ -1,64 +1,109 @@ +parameters: + - name: dryRun + type: boolean + default: false + 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 + - 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 - 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 - - task: UseNode@1 - inputs: - version: '22.22.0' - displayName: 'Use Node.js 22.22.0' + - template: /.ado/templates/configure-git.yml@self - - template: /.ado/templates/configure-git.yml@self + - script: yarn install + displayName: Install npm dependencies - - 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 - displayName: Verify release config + - script: node .ado/scripts/configure-publish.mts --verbose --skip-auth + name: config + 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 + - 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')) - - 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')) + - 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 - - 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')) + - 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) - - script: | - yarn config unset npmPublishAccess || true - yarn config unset npmAuthToken || true - yarn config unset npmPublishRegistry || true - displayName: Remove NPM auth configuration - condition: always() + - 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 090cd21cd91e..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: @@ -52,4 +58,6 @@ extends: - stage: NPM dependsOn: [] jobs: - - template: /.ado/jobs/npm-publish.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 new file mode 100644 index 000000000000..3d162686db83 --- /dev/null +++ b/.ado/scripts/__tests__/configure-publish-test.mts @@ -0,0 +1,73 @@ +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, + 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'), [ + '##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( + getPublishTags( + {state: 'STABLE_IS_LATEST', currentVersion: 83, latestVersion: 83, nextVersion: 84}, + '0.83-stable', + ), + {npmTags: ['latest']}, + ); + assert.deepEqual( + getPublishTags( + {state: 'STABLE_IS_OLD', currentVersion: 82, latestVersion: 83, nextVersion: 84}, + '0.82-stable', + ), + {npmTags: ['0.82-stable']}, + ); + assert.deepEqual( + getPublishTags( + {state: 'STABLE_IS_NEW', currentVersion: 84, latestVersion: 83, nextVersion: 84}, + '0.84-stable', + ), + {npmTags: ['next'], prerelease: 'rc'}, + ); + assert.deepEqual( + getPublishTags( + {state: 'STABLE_IS_NEW', currentVersion: 84, latestVersion: 83, nextVersion: 83}, + '0.84-stable', + '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 new file mode 100644 index 000000000000..a2386b184ae4 --- /dev/null +++ b/.ado/scripts/__tests__/npm-pack-test.mts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +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, + safeTarballName, + type CommandRunner, +} from '../npm-pack.mts'; + +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 [ + ['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('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}; + 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/__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/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..17d971c4f2fd 100644 --- a/.ado/scripts/configure-publish.mts +++ b/.ado/scripts/configure-publish.mts @@ -1,5 +1,5 @@ #!/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/'; @@ -31,7 +31,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 { @@ -131,21 +147,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 {npmTags: ['latest']}; case 'STABLE_IS_OLD': - // Patching an older stable version - return { npmTags: [branch] }; + return {npmTags: [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 {npmTags: ['latest']}; } // Publishing a release candidate @@ -155,7 +165,7 @@ export function getPublishTags( ); } - return { npmTags: [NPM_TAG_NEXT], prerelease: 'rc' }; + return {npmTags: [NPM_TAG_NEXT], prerelease: 'rc'}; } } } @@ -178,23 +188,13 @@ async function verifyNpmAuth(registry = NPM_DEFAULT_REGISTRY) { } async function enablePublishing(tagInfo: TagInfo, options: Options) { - const [primaryTag, ...additionalTags] = tagInfo.npmTags; + const [primaryTag] = tagInfo.npmTags; - // Output publishTag for subsequent pipeline steps - echo(`##vso[task.setvariable variable=publishTag]${primaryTag}`); + setAzurePipelineVariable('publishTag', primaryTag); 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 { @@ -240,7 +240,7 @@ if (isDirectRun) { log(`Release state: ${stateInfo.state}`); const tagInfo = getPublishTags(stateInfo, branch, options.tag); - log(`Expected npm tags: ${tagInfo.npmTags.join(', ')}`); + log(`Expected npm tag: ${tagInfo.npmTags[0]}`); await enablePublishing(tagInfo, options); } else { diff --git a/.ado/scripts/npm-pack.mts b/.ado/scripts/npm-pack.mts new file mode 100644 index 000000000000..6528d17b5ff3 --- /dev/null +++ b/.ado/scripts/npm-pack.mts @@ -0,0 +1,141 @@ +#!/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; +} + +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({ + 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 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; + } +} 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.