diff --git a/.ado/jobs/npm-publish.yml b/.ado/jobs/npm-publish.yml index 7da6c6029a76..fcc264537e96 100644 --- a/.ado/jobs/npm-publish.yml +++ b/.ado/jobs/npm-publish.yml @@ -1,64 +1,91 @@ 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' + displayName: Use Node.js 22 - - 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' + displayName: Use Node.js 22 + + - script: node .ado/scripts/npm-pack.mts --no-pack --check-npm "$(Pipeline.Workspace)/npm-packed-tarballs" + displayName: Remove already-published packages - - 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')) + inputs: + connectedservicename: 'ESRP-JSHost3' + usemanagedidentity: false + keyvaultname: 'OGX-JSHost-KV' + authcertname: 'OGX-JSHost-Auth4' + signcertname: 'OGX-JSHost-Sign3' + clientid: '0a35e01f-eadf-420a-a2bf-def002ba898d' + domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2' + contenttype: npm + folderlocation: $(Pipeline.Workspace)/npm-packed-tarballs + productstate: $(publishTag) + owners: 'vmorozov@microsoft.com' + approvers: 'khosany@microsoft.com' diff --git a/.ado/publish.yml b/.ado/publish.yml index 090cd21cd91e..9062e6b3aa27 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -52,4 +52,4 @@ extends: - stage: NPM dependsOn: [] jobs: - - template: /.ado/jobs/npm-publish.yml@self + - template: /.ado/jobs/npm-publish.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..e313fc9fa067 --- /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', + ), + {npmTag: 'latest'}, + ); + assert.deepEqual( + getPublishTags( + {state: 'STABLE_IS_OLD', currentVersion: 82, latestVersion: 83, nextVersion: 84}, + '0.82-stable', + ), + {npmTag: '0.82-stable'}, + ); + assert.deepEqual( + getPublishTags( + {state: 'STABLE_IS_NEW', currentVersion: 84, latestVersion: 83, nextVersion: 84}, + '0.84-stable', + ), + {npmTag: 'next', prerelease: 'rc'}, + ); + assert.deepEqual( + getPublishTags( + {state: 'STABLE_IS_NEW', currentVersion: 84, latestVersion: 83, nextVersion: 83}, + '0.84-stable', + 'latest', + ), + {npmTag: '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/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..b8ced84e7f7e 100644 --- a/.ado/scripts/configure-publish.mts +++ b/.ado/scripts/configure-publish.mts @@ -6,6 +6,8 @@ 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'; +export type StableBranch = `${number}.${number}-stable`; +export type NpmTag = 'latest' | 'next' | StableBranch; export interface ReleaseStateInfo { state: ReleaseState; @@ -15,7 +17,7 @@ export interface ReleaseStateInfo { } export interface TagInfo { - npmTags: string[]; + npmTag: NpmTag; prerelease?: string; } @@ -31,14 +33,35 @@ 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'); +} + +/** + * Plain task.setvariable is same-job scope, consumed by Pack npm packages via + * variables['publish_react_native_macos']. isOutput=true exposes the named-step + * output to NpmEsrpRelease via dependencies.NpmPack.outputs['config.']. + */ +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 { return branch === 'main'; } -export function isStableBranch(branch: string): boolean { +export function isStableBranch(branch: string): branch is StableBranch { return /^\d+\.\d+-stable$/.test(branch); } @@ -124,7 +147,7 @@ export function getReleaseState( export function getPublishTags( stateInfo: ReleaseStateInfo, - branch: string, + branch: StableBranch, tag: string = NPM_TAG_NEXT, ): TagInfo { const { state, currentVersion, nextVersion } = stateInfo; @@ -132,20 +155,16 @@ export function getPublishTags( switch (state) { case 'STABLE_IS_LATEST': // Patching the current latest version - return { npmTags: ['latest', branch] }; + 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,7 +174,7 @@ export function getPublishTags( ); } - return { npmTags: [NPM_TAG_NEXT], prerelease: 'rc' }; + return { npmTag: NPM_TAG_NEXT, prerelease: 'rc' }; } } } @@ -178,21 +197,9 @@ async function verifyNpmAuth(registry = NPM_DEFAULT_REGISTRY) { } 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}`); + 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`); - } + fs.appendFileSync(process.env['GITHUB_OUTPUT'], `publishTag=${tagInfo.npmTag}\n`); } if (options['skip-auth']) { @@ -240,7 +247,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.npmTag}`); 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); + } +}