Skip to content

Commit

Permalink
feat(release): allow local dependency version protocols to be preserv…
Browse files Browse the repository at this point in the history
…ed, pnpm publish support (#27787)
  • Loading branch information
JamesHenry authored Sep 10, 2024
1 parent 0c449b4 commit 431fe2a
Show file tree
Hide file tree
Showing 6 changed files with 334 additions and 63 deletions.
9 changes: 8 additions & 1 deletion e2e/release/src/custom-registries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,21 @@ describe('nx release - custom npm registries', () => {
const verdaccioPort = 7191;
const customRegistryUrl = `http://localhost:${verdaccioPort}`;
const scope = 'scope';
let previousPackageManager: string;

beforeAll(async () => {
previousPackageManager = process.env.SELECTED_PM;
// We are testing some more advanced scoped registry features that only npm has within this file
process.env.SELECTED_PM = 'npm';
newProject({
unsetProjectNameAndRootFormat: false,
packages: ['@nx/js'],
});
}, 60000);
afterAll(() => cleanupProject());
afterAll(() => {
cleanupProject();
process.env.SELECTED_PM = previousPackageManager;
});

it('should respect registry configuration for each package', async () => {
updateJson<NxJsonConfiguration>('nx.json', (nxJson) => {
Expand Down
64 changes: 49 additions & 15 deletions packages/js/src/executors/release-publish/release-publish.impl.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { ExecutorContext, readJsonFile } from '@nx/devkit';
import {
detectPackageManager,
ExecutorContext,
readJsonFile,
} from '@nx/devkit';
import { execSync } from 'child_process';
import { env as appendLocalEnv } from 'npm-run-path';
import { join } from 'path';
import { isLocallyLinkedPackageVersion } from '../../utils/is-locally-linked-package-version';
import { parseRegistryOptions } from '../../utils/npm-config';
import { extractNpmPublishJsonData } from './extract-npm-publish-json-data';
import { logTar } from './log-tar';
import { PublishExecutorSchema } from './schema';
import chalk = require('chalk');
import { extractNpmPublishJsonData } from './extract-npm-publish-json-data';

const LARGE_BUFFER = 1024 * 1000000;

Expand All @@ -26,6 +31,7 @@ export default async function runExecutor(
options: PublishExecutorSchema,
context: ExecutorContext
) {
const pm = detectPackageManager();
/**
* We need to check both the env var and the option because the executor may have been triggered
* indirectly via dependsOn, in which case the env var will be set, but the option will not.
Expand All @@ -44,6 +50,31 @@ export default async function runExecutor(
const packageJson = readJsonFile(packageJsonPath);
const packageName = packageJson.name;

/**
* pnpm supports dynamically updating locally linked packages during its packing phase, but other package managers do not.
* Therefore, protect the user from publishing invalid packages by checking if it contains local dependency protocols.
*/
if (pm !== 'pnpm') {
const depTypes = ['dependencies', 'devDependencies', 'peerDependencies'];
for (const depType of depTypes) {
const deps = packageJson[depType];
if (deps) {
for (const depName in deps) {
if (isLocallyLinkedPackageVersion(deps[depName])) {
console.error(
`Error: Cannot publish package "${packageName}" because it contains a local dependency protocol in its "${depType}", and your package manager is ${pm}.
Please update the local dependency on "${depName}" to be a valid semantic version (e.g. using \`nx release\`) before publishing, or switch to pnpm as a package manager, which supports dynamically replacing these protocols during publishing.`
);
return {
success: false,
};
}
}
}
}
}

// If package and project name match, we can make log messages terser
let packageTxt =
packageName === context.projectName
Expand Down Expand Up @@ -88,7 +119,7 @@ export default async function runExecutor(
* request with.
*
* Therefore, so as to not produce misleading output in dry around dist-tags being altered, we do not
* perform the npm view step, and just show npm publish's dry-run output.
* perform the npm view step, and just show npm/pnpm publish's dry-run output.
*/
if (!isDryRun && !options.firstRelease) {
const currentVersion = packageJson.version;
Expand Down Expand Up @@ -208,42 +239,45 @@ export default async function runExecutor(

/**
* NOTE: If this is ever changed away from running the command at the workspace root and pointing at the package root (e.g. back
* to running from the package root directly), then special attention should be paid to the fact that npm publish will nest its
* to running from the package root directly), then special attention should be paid to the fact that npm/pnpm publish will nest its
* JSON output under the name of the package in that case (and it would need to be handled below).
*/
const npmPublishCommandSegments = [
`npm publish "${packageRoot}" --json --"${registryConfigKey}=${registry}" --tag=${tag}`,
const publishCommandSegments = [
pm === 'pnpm'
? // Unlike npm, pnpm publish does not support a custom registryConfigKey option, and will error on uncommitted changes by default if --no-git-checks is not set
`pnpm publish "${packageRoot}" --json --registry="${registry}" --tag=${tag} --no-git-checks`
: `npm publish "${packageRoot}" --json --"${registryConfigKey}=${registry}" --tag=${tag}`,
];

if (options.otp) {
npmPublishCommandSegments.push(`--otp=${options.otp}`);
publishCommandSegments.push(`--otp=${options.otp}`);
}

if (options.access) {
npmPublishCommandSegments.push(`--access=${options.access}`);
publishCommandSegments.push(`--access=${options.access}`);
}

if (isDryRun) {
npmPublishCommandSegments.push(`--dry-run`);
publishCommandSegments.push(`--dry-run`);
}

try {
const output = execSync(npmPublishCommandSegments.join(' '), {
const output = execSync(publishCommandSegments.join(' '), {
maxBuffer: LARGE_BUFFER,
env: processEnv(true),
cwd: context.root,
stdio: ['ignore', 'pipe', 'pipe'],
});

/**
* We cannot JSON.parse the output directly because if the user is using lifecycle scripts, npm will mix its publish output with the JSON output all on stdout.
* We cannot JSON.parse the output directly because if the user is using lifecycle scripts, npm/pnpm will mix its publish output with the JSON output all on stdout.
* Additionally, we want to capture and show the lifecycle script outputs as beforeJsonData and afterJsonData and print them accordingly below.
*/
const { beforeJsonData, jsonData, afterJsonData } =
extractNpmPublishJsonData(output.toString());
if (!jsonData) {
console.error(
'The npm publish output data could not be extracted. Please report this issue on https://github.com/nrwl/nx'
`The ${pm} publish output data could not be extracted. Please report this issue on https://github.com/nrwl/nx`
);
return {
success: false,
Expand Down Expand Up @@ -294,7 +328,7 @@ export default async function runExecutor(
try {
const stdoutData = JSON.parse(err.stdout?.toString() || '{}');

console.error('npm publish error:');
console.error(`${pm} publish error:`);
if (stdoutData.error?.summary) {
console.error(stdoutData.error.summary);
}
Expand All @@ -303,7 +337,7 @@ export default async function runExecutor(
}

if (context.isVerbose) {
console.error('npm publish stdout:');
console.error(`${pm} publish stdout:`);
console.error(JSON.stringify(stdoutData, null, 2));
}

Expand All @@ -316,7 +350,7 @@ export default async function runExecutor(
};
} catch (err) {
console.error(
'Something unexpected went wrong when processing the npm publish output\n',
`Something unexpected went wrong when processing the ${pm} publish output\n`,
err
);
return {
Expand Down
212 changes: 212 additions & 0 deletions packages/js/src/generators/release-version/release-version.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ const processExitSpy = jest
return originalExit(...args);
});

const mockDetectPackageManager = jest.fn();
jest.mock('@nx/devkit', () => {
const devkit = jest.requireActual('@nx/devkit');
return {
...devkit,
detectPackageManager: mockDetectPackageManager,
};
});

import { ProjectGraph, Tree, output, readJson } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import * as enquirer from 'enquirer';
Expand Down Expand Up @@ -1749,6 +1758,209 @@ Valid values are: "auto", "", "~", "^", "="`,
});
});
});

describe('preserveLocalDependencyProtocols', () => {
it('should preserve local `workspace:` references when preserveLocalDependencyProtocols is true', async () => {
// Supported package manager for workspace: protocol
mockDetectPackageManager.mockReturnValue('pnpm');

projectGraph = createWorkspaceWithPackageDependencies(tree, {
'package-a': {
projectRoot: 'packages/package-a',
packageName: 'package-a',
version: '1.0.0',
packageJsonPath: 'packages/package-a/package.json',
localDependencies: [
{
projectName: 'package-b',
dependencyCollection: 'dependencies',
version: 'workspace:*',
},
],
},
'package-b': {
projectRoot: 'packages/package-b',
packageName: 'package-b',
version: '1.0.0',
packageJsonPath: 'packages/package-b/package.json',
localDependencies: [],
},
});

expect(readJson(tree, 'packages/package-a/package.json'))
.toMatchInlineSnapshot(`
{
"dependencies": {
"package-b": "workspace:*",
},
"name": "package-a",
"version": "1.0.0",
}
`);
expect(readJson(tree, 'packages/package-b/package.json'))
.toMatchInlineSnapshot(`
{
"name": "package-b",
"version": "1.0.0",
}
`);

expect(
await releaseVersionGenerator(tree, {
projects: [projectGraph.nodes['package-b']], // version only package-b
projectGraph,
specifier: '2.0.0',
currentVersionResolver: 'disk',
specifierSource: 'prompt',
releaseGroup: createReleaseGroup('independent'),
updateDependents: 'auto',
preserveLocalDependencyProtocols: true,
})
).toMatchInlineSnapshot(`
{
"callback": [Function],
"data": {
"package-a": {
"currentVersion": "1.0.0",
"dependentProjects": [],
"newVersion": "1.0.1",
},
"package-b": {
"currentVersion": "1.0.0",
"dependentProjects": [
{
"dependencyCollection": "dependencies",
"rawVersionSpec": "workspace:*",
"source": "package-a",
"target": "package-b",
"type": "static",
},
],
"newVersion": "2.0.0",
},
},
}
`);

expect(readJson(tree, 'packages/package-a/package.json'))
.toMatchInlineSnapshot(`
{
"dependencies": {
"package-b": "workspace:*",
},
"name": "package-a",
"version": "1.0.1",
}
`);

expect(readJson(tree, 'packages/package-b/package.json'))
.toMatchInlineSnapshot(`
{
"name": "package-b",
"version": "2.0.0",
}
`);
});

it('should preserve local `file:` references when preserveLocalDependencyProtocols is true', async () => {
projectGraph = createWorkspaceWithPackageDependencies(tree, {
'package-a': {
projectRoot: 'packages/package-a',
packageName: 'package-a',
version: '1.0.0',
packageJsonPath: 'packages/package-a/package.json',
localDependencies: [
{
projectName: 'package-b',
dependencyCollection: 'dependencies',
version: 'file:../package-b',
},
],
},
'package-b': {
projectRoot: 'packages/package-b',
packageName: 'package-b',
version: '1.0.0',
packageJsonPath: 'packages/package-b/package.json',
localDependencies: [],
},
});

expect(readJson(tree, 'packages/package-a/package.json'))
.toMatchInlineSnapshot(`
{
"dependencies": {
"package-b": "file:../package-b",
},
"name": "package-a",
"version": "1.0.0",
}
`);
expect(readJson(tree, 'packages/package-b/package.json'))
.toMatchInlineSnapshot(`
{
"name": "package-b",
"version": "1.0.0",
}
`);

expect(
await releaseVersionGenerator(tree, {
projects: [projectGraph.nodes['package-b']], // version only package-b
projectGraph,
specifier: '2.0.0',
currentVersionResolver: 'disk',
specifierSource: 'prompt',
releaseGroup: createReleaseGroup('independent'),
updateDependents: 'auto',
preserveLocalDependencyProtocols: true,
})
).toMatchInlineSnapshot(`
{
"callback": [Function],
"data": {
"package-a": {
"currentVersion": "1.0.0",
"dependentProjects": [],
"newVersion": "1.0.1",
},
"package-b": {
"currentVersion": "1.0.0",
"dependentProjects": [
{
"dependencyCollection": "dependencies",
"rawVersionSpec": "file:../package-b",
"source": "package-a",
"target": "package-b",
"type": "static",
},
],
"newVersion": "2.0.0",
},
},
}
`);

expect(readJson(tree, 'packages/package-a/package.json'))
.toMatchInlineSnapshot(`
{
"dependencies": {
"package-b": "file:../package-b",
},
"name": "package-a",
"version": "1.0.1",
}
`);

expect(readJson(tree, 'packages/package-b/package.json'))
.toMatchInlineSnapshot(`
{
"name": "package-b",
"version": "2.0.0",
}
`);
});
});
});

function createReleaseGroup(
Expand Down
Loading

0 comments on commit 431fe2a

Please sign in to comment.