Skip to content

Commit 431fe2a

Browse files
authored
feat(release): allow local dependency version protocols to be preserved, pnpm publish support (#27787)
1 parent 0c449b4 commit 431fe2a

File tree

6 files changed

+334
-63
lines changed

6 files changed

+334
-63
lines changed

e2e/release/src/custom-registries.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,21 @@ describe('nx release - custom npm registries', () => {
1717
const verdaccioPort = 7191;
1818
const customRegistryUrl = `http://localhost:${verdaccioPort}`;
1919
const scope = 'scope';
20+
let previousPackageManager: string;
2021

2122
beforeAll(async () => {
23+
previousPackageManager = process.env.SELECTED_PM;
24+
// We are testing some more advanced scoped registry features that only npm has within this file
25+
process.env.SELECTED_PM = 'npm';
2226
newProject({
2327
unsetProjectNameAndRootFormat: false,
2428
packages: ['@nx/js'],
2529
});
2630
}, 60000);
27-
afterAll(() => cleanupProject());
31+
afterAll(() => {
32+
cleanupProject();
33+
process.env.SELECTED_PM = previousPackageManager;
34+
});
2835

2936
it('should respect registry configuration for each package', async () => {
3037
updateJson<NxJsonConfiguration>('nx.json', (nxJson) => {

packages/js/src/executors/release-publish/release-publish.impl.ts

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
import { ExecutorContext, readJsonFile } from '@nx/devkit';
1+
import {
2+
detectPackageManager,
3+
ExecutorContext,
4+
readJsonFile,
5+
} from '@nx/devkit';
26
import { execSync } from 'child_process';
37
import { env as appendLocalEnv } from 'npm-run-path';
48
import { join } from 'path';
9+
import { isLocallyLinkedPackageVersion } from '../../utils/is-locally-linked-package-version';
510
import { parseRegistryOptions } from '../../utils/npm-config';
11+
import { extractNpmPublishJsonData } from './extract-npm-publish-json-data';
612
import { logTar } from './log-tar';
713
import { PublishExecutorSchema } from './schema';
814
import chalk = require('chalk');
9-
import { extractNpmPublishJsonData } from './extract-npm-publish-json-data';
1015

1116
const LARGE_BUFFER = 1024 * 1000000;
1217

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

53+
/**
54+
* pnpm supports dynamically updating locally linked packages during its packing phase, but other package managers do not.
55+
* Therefore, protect the user from publishing invalid packages by checking if it contains local dependency protocols.
56+
*/
57+
if (pm !== 'pnpm') {
58+
const depTypes = ['dependencies', 'devDependencies', 'peerDependencies'];
59+
for (const depType of depTypes) {
60+
const deps = packageJson[depType];
61+
if (deps) {
62+
for (const depName in deps) {
63+
if (isLocallyLinkedPackageVersion(deps[depName])) {
64+
console.error(
65+
`Error: Cannot publish package "${packageName}" because it contains a local dependency protocol in its "${depType}", and your package manager is ${pm}.
66+
67+
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.`
68+
);
69+
return {
70+
success: false,
71+
};
72+
}
73+
}
74+
}
75+
}
76+
}
77+
4778
// If package and project name match, we can make log messages terser
4879
let packageTxt =
4980
packageName === context.projectName
@@ -88,7 +119,7 @@ export default async function runExecutor(
88119
* request with.
89120
*
90121
* Therefore, so as to not produce misleading output in dry around dist-tags being altered, we do not
91-
* perform the npm view step, and just show npm publish's dry-run output.
122+
* perform the npm view step, and just show npm/pnpm publish's dry-run output.
92123
*/
93124
if (!isDryRun && !options.firstRelease) {
94125
const currentVersion = packageJson.version;
@@ -208,42 +239,45 @@ export default async function runExecutor(
208239

209240
/**
210241
* NOTE: If this is ever changed away from running the command at the workspace root and pointing at the package root (e.g. back
211-
* to running from the package root directly), then special attention should be paid to the fact that npm publish will nest its
242+
* to running from the package root directly), then special attention should be paid to the fact that npm/pnpm publish will nest its
212243
* JSON output under the name of the package in that case (and it would need to be handled below).
213244
*/
214-
const npmPublishCommandSegments = [
215-
`npm publish "${packageRoot}" --json --"${registryConfigKey}=${registry}" --tag=${tag}`,
245+
const publishCommandSegments = [
246+
pm === 'pnpm'
247+
? // 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
248+
`pnpm publish "${packageRoot}" --json --registry="${registry}" --tag=${tag} --no-git-checks`
249+
: `npm publish "${packageRoot}" --json --"${registryConfigKey}=${registry}" --tag=${tag}`,
216250
];
217251

218252
if (options.otp) {
219-
npmPublishCommandSegments.push(`--otp=${options.otp}`);
253+
publishCommandSegments.push(`--otp=${options.otp}`);
220254
}
221255

222256
if (options.access) {
223-
npmPublishCommandSegments.push(`--access=${options.access}`);
257+
publishCommandSegments.push(`--access=${options.access}`);
224258
}
225259

226260
if (isDryRun) {
227-
npmPublishCommandSegments.push(`--dry-run`);
261+
publishCommandSegments.push(`--dry-run`);
228262
}
229263

230264
try {
231-
const output = execSync(npmPublishCommandSegments.join(' '), {
265+
const output = execSync(publishCommandSegments.join(' '), {
232266
maxBuffer: LARGE_BUFFER,
233267
env: processEnv(true),
234268
cwd: context.root,
235269
stdio: ['ignore', 'pipe', 'pipe'],
236270
});
237271

238272
/**
239-
* 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.
273+
* 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.
240274
* Additionally, we want to capture and show the lifecycle script outputs as beforeJsonData and afterJsonData and print them accordingly below.
241275
*/
242276
const { beforeJsonData, jsonData, afterJsonData } =
243277
extractNpmPublishJsonData(output.toString());
244278
if (!jsonData) {
245279
console.error(
246-
'The npm publish output data could not be extracted. Please report this issue on https://github.com/nrwl/nx'
280+
`The ${pm} publish output data could not be extracted. Please report this issue on https://github.com/nrwl/nx`
247281
);
248282
return {
249283
success: false,
@@ -294,7 +328,7 @@ export default async function runExecutor(
294328
try {
295329
const stdoutData = JSON.parse(err.stdout?.toString() || '{}');
296330

297-
console.error('npm publish error:');
331+
console.error(`${pm} publish error:`);
298332
if (stdoutData.error?.summary) {
299333
console.error(stdoutData.error.summary);
300334
}
@@ -303,7 +337,7 @@ export default async function runExecutor(
303337
}
304338

305339
if (context.isVerbose) {
306-
console.error('npm publish stdout:');
340+
console.error(`${pm} publish stdout:`);
307341
console.error(JSON.stringify(stdoutData, null, 2));
308342
}
309343

@@ -316,7 +350,7 @@ export default async function runExecutor(
316350
};
317351
} catch (err) {
318352
console.error(
319-
'Something unexpected went wrong when processing the npm publish output\n',
353+
`Something unexpected went wrong when processing the ${pm} publish output\n`,
320354
err
321355
);
322356
return {

packages/js/src/generators/release-version/release-version.spec.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ const processExitSpy = jest
1010
return originalExit(...args);
1111
});
1212

13+
const mockDetectPackageManager = jest.fn();
14+
jest.mock('@nx/devkit', () => {
15+
const devkit = jest.requireActual('@nx/devkit');
16+
return {
17+
...devkit,
18+
detectPackageManager: mockDetectPackageManager,
19+
};
20+
});
21+
1322
import { ProjectGraph, Tree, output, readJson } from '@nx/devkit';
1423
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
1524
import * as enquirer from 'enquirer';
@@ -1749,6 +1758,209 @@ Valid values are: "auto", "", "~", "^", "="`,
17491758
});
17501759
});
17511760
});
1761+
1762+
describe('preserveLocalDependencyProtocols', () => {
1763+
it('should preserve local `workspace:` references when preserveLocalDependencyProtocols is true', async () => {
1764+
// Supported package manager for workspace: protocol
1765+
mockDetectPackageManager.mockReturnValue('pnpm');
1766+
1767+
projectGraph = createWorkspaceWithPackageDependencies(tree, {
1768+
'package-a': {
1769+
projectRoot: 'packages/package-a',
1770+
packageName: 'package-a',
1771+
version: '1.0.0',
1772+
packageJsonPath: 'packages/package-a/package.json',
1773+
localDependencies: [
1774+
{
1775+
projectName: 'package-b',
1776+
dependencyCollection: 'dependencies',
1777+
version: 'workspace:*',
1778+
},
1779+
],
1780+
},
1781+
'package-b': {
1782+
projectRoot: 'packages/package-b',
1783+
packageName: 'package-b',
1784+
version: '1.0.0',
1785+
packageJsonPath: 'packages/package-b/package.json',
1786+
localDependencies: [],
1787+
},
1788+
});
1789+
1790+
expect(readJson(tree, 'packages/package-a/package.json'))
1791+
.toMatchInlineSnapshot(`
1792+
{
1793+
"dependencies": {
1794+
"package-b": "workspace:*",
1795+
},
1796+
"name": "package-a",
1797+
"version": "1.0.0",
1798+
}
1799+
`);
1800+
expect(readJson(tree, 'packages/package-b/package.json'))
1801+
.toMatchInlineSnapshot(`
1802+
{
1803+
"name": "package-b",
1804+
"version": "1.0.0",
1805+
}
1806+
`);
1807+
1808+
expect(
1809+
await releaseVersionGenerator(tree, {
1810+
projects: [projectGraph.nodes['package-b']], // version only package-b
1811+
projectGraph,
1812+
specifier: '2.0.0',
1813+
currentVersionResolver: 'disk',
1814+
specifierSource: 'prompt',
1815+
releaseGroup: createReleaseGroup('independent'),
1816+
updateDependents: 'auto',
1817+
preserveLocalDependencyProtocols: true,
1818+
})
1819+
).toMatchInlineSnapshot(`
1820+
{
1821+
"callback": [Function],
1822+
"data": {
1823+
"package-a": {
1824+
"currentVersion": "1.0.0",
1825+
"dependentProjects": [],
1826+
"newVersion": "1.0.1",
1827+
},
1828+
"package-b": {
1829+
"currentVersion": "1.0.0",
1830+
"dependentProjects": [
1831+
{
1832+
"dependencyCollection": "dependencies",
1833+
"rawVersionSpec": "workspace:*",
1834+
"source": "package-a",
1835+
"target": "package-b",
1836+
"type": "static",
1837+
},
1838+
],
1839+
"newVersion": "2.0.0",
1840+
},
1841+
},
1842+
}
1843+
`);
1844+
1845+
expect(readJson(tree, 'packages/package-a/package.json'))
1846+
.toMatchInlineSnapshot(`
1847+
{
1848+
"dependencies": {
1849+
"package-b": "workspace:*",
1850+
},
1851+
"name": "package-a",
1852+
"version": "1.0.1",
1853+
}
1854+
`);
1855+
1856+
expect(readJson(tree, 'packages/package-b/package.json'))
1857+
.toMatchInlineSnapshot(`
1858+
{
1859+
"name": "package-b",
1860+
"version": "2.0.0",
1861+
}
1862+
`);
1863+
});
1864+
1865+
it('should preserve local `file:` references when preserveLocalDependencyProtocols is true', async () => {
1866+
projectGraph = createWorkspaceWithPackageDependencies(tree, {
1867+
'package-a': {
1868+
projectRoot: 'packages/package-a',
1869+
packageName: 'package-a',
1870+
version: '1.0.0',
1871+
packageJsonPath: 'packages/package-a/package.json',
1872+
localDependencies: [
1873+
{
1874+
projectName: 'package-b',
1875+
dependencyCollection: 'dependencies',
1876+
version: 'file:../package-b',
1877+
},
1878+
],
1879+
},
1880+
'package-b': {
1881+
projectRoot: 'packages/package-b',
1882+
packageName: 'package-b',
1883+
version: '1.0.0',
1884+
packageJsonPath: 'packages/package-b/package.json',
1885+
localDependencies: [],
1886+
},
1887+
});
1888+
1889+
expect(readJson(tree, 'packages/package-a/package.json'))
1890+
.toMatchInlineSnapshot(`
1891+
{
1892+
"dependencies": {
1893+
"package-b": "file:../package-b",
1894+
},
1895+
"name": "package-a",
1896+
"version": "1.0.0",
1897+
}
1898+
`);
1899+
expect(readJson(tree, 'packages/package-b/package.json'))
1900+
.toMatchInlineSnapshot(`
1901+
{
1902+
"name": "package-b",
1903+
"version": "1.0.0",
1904+
}
1905+
`);
1906+
1907+
expect(
1908+
await releaseVersionGenerator(tree, {
1909+
projects: [projectGraph.nodes['package-b']], // version only package-b
1910+
projectGraph,
1911+
specifier: '2.0.0',
1912+
currentVersionResolver: 'disk',
1913+
specifierSource: 'prompt',
1914+
releaseGroup: createReleaseGroup('independent'),
1915+
updateDependents: 'auto',
1916+
preserveLocalDependencyProtocols: true,
1917+
})
1918+
).toMatchInlineSnapshot(`
1919+
{
1920+
"callback": [Function],
1921+
"data": {
1922+
"package-a": {
1923+
"currentVersion": "1.0.0",
1924+
"dependentProjects": [],
1925+
"newVersion": "1.0.1",
1926+
},
1927+
"package-b": {
1928+
"currentVersion": "1.0.0",
1929+
"dependentProjects": [
1930+
{
1931+
"dependencyCollection": "dependencies",
1932+
"rawVersionSpec": "file:../package-b",
1933+
"source": "package-a",
1934+
"target": "package-b",
1935+
"type": "static",
1936+
},
1937+
],
1938+
"newVersion": "2.0.0",
1939+
},
1940+
},
1941+
}
1942+
`);
1943+
1944+
expect(readJson(tree, 'packages/package-a/package.json'))
1945+
.toMatchInlineSnapshot(`
1946+
{
1947+
"dependencies": {
1948+
"package-b": "file:../package-b",
1949+
},
1950+
"name": "package-a",
1951+
"version": "1.0.1",
1952+
}
1953+
`);
1954+
1955+
expect(readJson(tree, 'packages/package-b/package.json'))
1956+
.toMatchInlineSnapshot(`
1957+
{
1958+
"name": "package-b",
1959+
"version": "2.0.0",
1960+
}
1961+
`);
1962+
});
1963+
});
17521964
});
17531965

17541966
function createReleaseGroup(

0 commit comments

Comments
 (0)