Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option packToPath to pack packages instead of publishing #935

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions change/beachball-55d9e364-8092-44cb-b48b-1c8adf681703.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add option packToPath to pack packages instead of publishing",
"packageName": "beachball",
"email": "[email protected]",
"dependentChangeType": "patch"
}
152 changes: 152 additions & 0 deletions src/__functional__/packageManager/packPackage.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof npm>;
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);
});
});
2 changes: 1 addition & 1 deletion src/commands/canary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export async function canary(options: BeachballOptions): Promise<void> {

await performBump(bumpInfo, options);

if (options.publish) {
if (options.publish || options.packToPath) {
await publishToRegistry(bumpInfo, options);
} else {
console.log('Skipping publish');
Expand Down
6 changes: 4 additions & 2 deletions src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ export async function publish(options: BeachballOptions): Promise<void> {

// 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 {
Expand Down
1 change: 1 addition & 0 deletions src/options/getCliOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const stringOptions = [
'dependentChangeType',
'fromRef',
'message',
'packToPath',
'prereleasePrefix',
'registry',
'tag',
Expand Down
7 changes: 5 additions & 2 deletions src/packageManager/npmArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
];

Expand Down
63 changes: 63 additions & 0 deletions src/packageManager/packPackage.ts
Original file line number Diff line number Diff line change
@@ -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<BeachballOptions, 'packToPath' | 'verbose'>
): 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 };
}
36 changes: 31 additions & 5 deletions src/publish/publishToRegistry.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<void> {
const verb = options.packToPath ? 'pack' : 'publish';

const bumpInfo = _.cloneDeep(originalBumpInfo);

if (options.bump) {
Expand All @@ -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);
}

Expand All @@ -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<string>();
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);
}
6 changes: 6 additions & 0 deletions src/types/BeachballOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface CliOptions
| 'fetch'
| 'gitTags'
| 'message'
| 'packToPath'
| 'path'
| 'prereleasePrefix'
| 'publish'
Expand Down Expand Up @@ -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
Expand Down