diff --git a/change/beachball-55d9e364-8092-44cb-b48b-1c8adf681703.json b/change/beachball-55d9e364-8092-44cb-b48b-1c8adf681703.json new file mode 100644 index 000000000..35803dc99 --- /dev/null +++ b/change/beachball-55d9e364-8092-44cb-b48b-1c8adf681703.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add option packToPath to pack packages instead of publishing", + "packageName": "beachball", + "email": "elcraig@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/src/__functional__/packageManager/packPackage.test.ts b/src/__functional__/packageManager/packPackage.test.ts new file mode 100644 index 000000000..cd01a098f --- /dev/null +++ b/src/__functional__/packageManager/packPackage.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach, jest, afterEach } from '@jest/globals'; +import fs from 'fs-extra'; +import path from 'path'; +import { initMockLogs } from '../../__fixtures__/mockLogs'; +import { tmpdir } from '../../__fixtures__/tmpdir'; +import * as npmModule from '../../packageManager/npm'; +import { packPackage } from '../../packageManager/packPackage'; +import { PackageInfo } from '../../types/PackageInfo'; +import { npm } from '../../packageManager/npm'; + +const testName = 'testbeachballpackage'; +const testVersion = '0.6.0'; +const testSpec = `${testName}@${testVersion}`; +const testPackage = { name: testName, version: testVersion }; +const testPackName = `${testName}-${testVersion}.tgz`; + +describe('packPackage', () => { + let npmSpy: jest.SpiedFunction; + let tempRoot: string; + let tempPackageJsonPath: string; + let tempPackPath: string; + + const logs = initMockLogs(); + + function getTestPackageInfo(): PackageInfo { + return { + ...testPackage, + packageJsonPath: tempPackageJsonPath, + private: false, + combinedOptions: {} as any, + packageOptions: {} as any, + }; + } + + beforeAll(() => { + tempRoot = tmpdir(); + tempPackageJsonPath = path.join(tempRoot, 'package.json'); + tempPackPath = tmpdir(); + }); + + beforeEach(() => { + npmSpy = jest.spyOn(npmModule, 'npm'); + }); + + afterEach(() => { + npmSpy.mockRestore(); + fs.emptyDirSync(tempRoot); + fs.emptyDirSync(tempPackPath); + }); + + afterAll(() => { + fs.removeSync(tempRoot); + fs.removeSync(tempPackPath); + }); + + it('does nothing if packToPath is not specified', async () => { + const testPackageInfo = getTestPackageInfo(); + fs.writeJSONSync(tempPackageJsonPath, testPackage, { spaces: 2 }); + + const packResult = await packPackage(testPackageInfo, {}); + expect(packResult).toEqual({ success: false }); + expect(npmSpy).toHaveBeenCalledTimes(0); + }); + + it('packs package', async () => { + fs.writeJSONSync(tempPackageJsonPath, testPackage, { spaces: 2 }); + + const packResult = await packPackage(getTestPackageInfo(), { packToPath: tempPackPath }); + expect(packResult).toEqual({ success: true, packFile: testPackName }); + expect(npmSpy).toHaveBeenCalledTimes(1); + expect(npmSpy).toHaveBeenCalledWith(['pack', '--loglevel', 'warn'], expect.objectContaining({ cwd: tempRoot })); + // file is moved to correct location (not the package folder) + expect(fs.existsSync(path.join(tempPackPath, testPackName))).toBe(true); + expect(fs.existsSync(path.join(tempRoot, testPackName))).toBe(false); + + const allLogs = logs.getMockLines('all'); + expect(allLogs).toMatch(`Packing - ${testSpec}`); + expect(allLogs).toMatch(`Packed ${testSpec} to ${path.join(tempPackPath, testPackName)}`); + }); + + it('handles failure packing', async () => { + // It's difficult to simulate actual error conditions, so mock an npm call failure. + npmSpy.mockImplementation(() => + Promise.resolve({ success: false, stdout: 'oh no', all: 'oh no' } as npmModule.NpmResult) + ); + + const packResult = await packPackage(getTestPackageInfo(), { packToPath: tempPackPath }); + expect(packResult).toEqual({ success: false }); + expect(npmSpy).toHaveBeenCalledTimes(1); + expect(fs.existsSync(path.join(tempRoot, testPackName))).toBe(false); + expect(fs.existsSync(path.join(tempPackPath, testPackName))).toBe(false); + + const allLogs = logs.getMockLines('all'); + expect(allLogs).toMatch(`Packing - ${testSpec}`); + expect(allLogs).toMatch(`Packing ${testSpec} failed (see above for details)`); + }); + + it('handles if filename is missing from output', async () => { + npmSpy.mockImplementation(() => + Promise.resolve({ success: true, stdout: 'not a file', all: 'not a file' } as npmModule.NpmResult) + ); + + const packResult = await packPackage(getTestPackageInfo(), { packToPath: tempPackPath }); + expect(packResult).toEqual({ success: false }); + expect(npmSpy).toHaveBeenCalledTimes(1); + expect(fs.existsSync(path.join(tempRoot, testPackName))).toBe(false); + expect(fs.existsSync(path.join(tempPackPath, testPackName))).toBe(false); + + const allLogs = logs.getMockLines('all'); + expect(allLogs).toMatch(`Packing - ${testSpec}`); + expect(allLogs).toMatch(`npm pack output for ${testSpec} (above) did not end with a filename that exists`); + }); + + it('handles if filename in output does not exist', async () => { + npmSpy.mockImplementation(() => + Promise.resolve({ success: true, stdout: 'nope.tgz', all: 'nope.tgz' } as npmModule.NpmResult) + ); + + const packResult = await packPackage(getTestPackageInfo(), { packToPath: tempPackPath }); + expect(packResult).toEqual({ success: false }); + expect(npmSpy).toHaveBeenCalledTimes(1); + expect(fs.existsSync(path.join(tempRoot, testPackName))).toBe(false); + expect(fs.existsSync(path.join(tempPackPath, testPackName))).toBe(false); + + const allLogs = logs.getMockLines('all'); + expect(allLogs).toMatch(`Packing - ${testSpec}`); + expect(allLogs).toMatch(`npm pack output for ${testSpec} (above) did not end with a filename that exists`); + }); + + it('handles failure moving file', async () => { + // mock the npm call to just write a fake .tgz file (since calling npm is slow) + npmSpy.mockImplementation(() => { + fs.writeFileSync(path.join(tempRoot, testPackName), 'some content'); + return Promise.resolve({ success: true, stdout: testPackName, all: testPackName } as npmModule.NpmResult); + }); + // create a file with the same name to simulate a move failure + fs.writeFileSync(path.join(tempPackPath, testPackName), 'other content'); + + const packResult = await packPackage(getTestPackageInfo(), { packToPath: tempPackPath }); + expect(packResult).toEqual({ success: false }); + expect(npmSpy).toHaveBeenCalledTimes(1); + + const allLogs = logs.getMockLines('all'); + expect(allLogs).toMatch(`Packing - ${testSpec}`); + expect(allLogs).toMatch( + `Failed to move ${path.join(tempRoot, testPackName)} to ${path.join(tempPackPath, testPackName)}: Error:` + ); + + // tgz file is cleaned up + expect(fs.existsSync(path.join(tempRoot, testPackName))).toBe(false); + }); +}); diff --git a/src/commands/canary.ts b/src/commands/canary.ts index ba79ced3d..436065d2e 100644 --- a/src/commands/canary.ts +++ b/src/commands/canary.ts @@ -37,7 +37,7 @@ export async function canary(options: BeachballOptions): Promise { await performBump(bumpInfo, options); - if (options.publish) { + if (options.publish || options.packToPath) { await publishToRegistry(bumpInfo, options); } else { console.log('Skipping publish'); diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 8a3cd1184..dfb6820ac 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -66,8 +66,10 @@ export async function publish(options: BeachballOptions): Promise { // Step 1. Bump + npm publish // npm / yarn publish - if (options.publish) { - console.log('\nBumping versions and publishing to npm'); + if (options.publish || options.packToPath) { + console.log( + `\nBumping versions and ${options.packToPath ? `packing packages to ${options.packToPath}` : 'publishing to npm'}` + ); await publishToRegistry(bumpInfo, options); console.log(); } else { diff --git a/src/options/getCliOptions.ts b/src/options/getCliOptions.ts index dbd5330b9..f542dc42f 100644 --- a/src/options/getCliOptions.ts +++ b/src/options/getCliOptions.ts @@ -34,6 +34,7 @@ const stringOptions = [ 'dependentChangeType', 'fromRef', 'message', + 'packToPath', 'prereleasePrefix', 'registry', 'tag', diff --git a/src/packageManager/npmArgs.ts b/src/packageManager/npmArgs.ts index f1ed3bc1c..5192aead3 100644 --- a/src/packageManager/npmArgs.ts +++ b/src/packageManager/npmArgs.ts @@ -2,6 +2,10 @@ import { AuthType } from '../types/Auth'; import { NpmOptions } from '../types/NpmOptions'; import { PackageInfo } from '../types/PackageInfo'; +export function getNpmLogLevelArgs(verbose: boolean | undefined): string[] { + return ['--loglevel', verbose ? 'notice' : 'warn']; +} + export function getNpmPublishArgs(packageInfo: PackageInfo, options: NpmOptions): string[] { const { registry, token, authType, access } = options; const pkgCombinedOptions = packageInfo.combinedOptions; @@ -11,8 +15,7 @@ export function getNpmPublishArgs(packageInfo: PackageInfo, options: NpmOptions) registry, '--tag', pkgCombinedOptions.tag || pkgCombinedOptions.defaultNpmTag || 'latest', - '--loglevel', - options.verbose ? 'notice' : 'warn', + ...getNpmLogLevelArgs(options.verbose), ...getNpmAuthArgs(registry, token, authType), ]; diff --git a/src/packageManager/packPackage.ts b/src/packageManager/packPackage.ts new file mode 100644 index 000000000..5eb8babed --- /dev/null +++ b/src/packageManager/packPackage.ts @@ -0,0 +1,63 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { PackageInfo } from '../types/PackageInfo'; +import { BeachballOptions } from '../types/BeachballOptions'; +import { npm } from './npm'; +import { getNpmLogLevelArgs } from './npmArgs'; + +/** + * Attempts to pack the package and move the tgz to `options.packPath`. + * Returns a success flag and the pack file name (not full path) if successful. + */ +export async function packPackage( + packageInfo: PackageInfo, + options: Pick +): Promise<{ success: true; packFile: string } | { success: false }> { + if (!options.packToPath) { + // this is mainly to make things easier with types (not really necessary to log an error) + return { success: false }; + } + + const packArgs = ['pack', ...getNpmLogLevelArgs(options.verbose)]; + + const packageRoot = path.dirname(packageInfo.packageJsonPath); + const packageSpec = `${packageInfo.name}@${packageInfo.version}`; + console.log(`\nPacking - ${packageSpec}`); + console.log(` (cwd: ${packageRoot})`); + + const result = await npm(packArgs, { + // Run npm pack in the package directory + cwd: packageRoot, + all: true, + }); + // log afterwards instead of piping because we need to access the output to get the filename + console.log(result.all); + + if (!result.success) { + console.error(`\nPacking ${packageSpec} failed (see above for details)`); + return { success: false }; + } + + const packFile = result.stdout.trim().split('\n').pop()!; + const packFilePath = path.join(packageRoot, packFile); + if (!packFile.endsWith('.tgz') || !fs.existsSync(packFilePath)) { + console.error(`\nnpm pack output for ${packageSpec} (above) did not end with a filename that exists`); + return { success: false }; + } + + const finalPackFilePath = path.join(options.packToPath, packFile); + try { + fs.ensureDirSync(options.packToPath); + fs.moveSync(packFilePath, finalPackFilePath); + } catch (err) { + console.error(`\nFailed to move ${packFilePath} to ${finalPackFilePath}: ${err}`); + try { + // attempt to clean up the pack file (ignore any failures) + fs.removeSync(packFilePath); + } catch {} + return { success: false }; + } + + console.log(`\nPacked ${packageSpec} to ${finalPackFilePath}`); + return { success: true, packFile }; +} diff --git a/src/publish/publishToRegistry.ts b/src/publish/publishToRegistry.ts index c66df905f..2e3eb15dc 100644 --- a/src/publish/publishToRegistry.ts +++ b/src/publish/publishToRegistry.ts @@ -1,4 +1,6 @@ import _ from 'lodash'; +import fs from 'fs-extra'; +import path from 'path'; import { performBump } from '../bump/performBump'; import { BumpInfo } from '../types/BumpInfo'; import { BeachballOptions } from '../types/BeachballOptions'; @@ -9,11 +11,15 @@ import { validatePackageDependencies } from './validatePackageDependencies'; import { performPublishOverrides } from './performPublishOverrides'; import { getPackagesToPublish } from './getPackagesToPublish'; import { callHook } from '../bump/callHook'; +import { packPackage } from '../packageManager/packPackage'; /** - * Publish all the bumped packages to the registry. + * Publish all the bumped packages to the registry, OR if `packToPath` is specified, + * pack the packages instead of publishing. */ export async function publishToRegistry(originalBumpInfo: BumpInfo, options: BeachballOptions): Promise { + const verb = options.packToPath ? 'pack' : 'publish'; + const bumpInfo = _.cloneDeep(originalBumpInfo); if (options.bump) { @@ -32,7 +38,7 @@ export async function publishToRegistry(originalBumpInfo: BumpInfo, options: Bea } if (invalid) { - console.error('No packages were published due to validation errors (see above for details).'); + console.error(`No packages were ${verb}ed due to validation errors (see above for details).`); process.exit(1); } @@ -44,17 +50,37 @@ export async function publishToRegistry(originalBumpInfo: BumpInfo, options: Bea // finally pass through doing the actual npm publish command const succeededPackages = new Set(); + const packFiles: string[] = []; + // publish or pack each package for (const pkg of packagesToPublish) { - const result = await packagePublish(bumpInfo.packageInfos[pkg], options); - if (result.success) { + let success: boolean; + if (options.packToPath) { + const result = await packPackage(bumpInfo.packageInfos[pkg], options); + if (result.success) { + packFiles.push(result.packFile); + } + success = result.success; + } else { + success = (await packagePublish(bumpInfo.packageInfos[pkg], options)).success; + } + + if (success) { succeededPackages.add(pkg); } else { displayManualRecovery(bumpInfo, succeededPackages); - throw new Error('Error publishing! Refer to the previous logs for recovery instructions.'); + throw new Error(`Error ${verb}ing! Refer to the previous logs for recovery instructions.`); } } + if (options.packToPath && packFiles.length) { + // Write a file with the proper topological order for publishing the pack files + const orderJsonPath = path.join(options.packToPath, 'order.json'); + console.log(`Writing package publishing order to ${orderJsonPath}`); + fs.ensureDirSync(options.packToPath); + fs.writeJSONSync(orderJsonPath, packFiles, { spaces: 2 }); + } + // if there is a postpublish hook perform a postpublish pass, calling the routine on each package await callHook(options.hooks?.postpublish, packagesToPublish, bumpInfo.packageInfos); } diff --git a/src/types/BeachballOptions.ts b/src/types/BeachballOptions.ts index def99276c..2acf52dec 100644 --- a/src/types/BeachballOptions.ts +++ b/src/types/BeachballOptions.ts @@ -18,6 +18,7 @@ export interface CliOptions | 'fetch' | 'gitTags' | 'message' + | 'packToPath' | 'path' | 'prereleasePrefix' | 'publish' @@ -132,6 +133,11 @@ export interface RepoOptions { * @default true */ publish: boolean; + /** + * If provided, pack packages to the specified path instead of publishing. + * Implies `publish: false`. + */ + packToPath?: string; /** * Whether to push to the remote git branch when publishing * @default true