Skip to content

Commit f1735bd

Browse files
fix(js): support bun-only environments in release-publish executor (#34835)
1 parent 343f954 commit f1735bd

File tree

2 files changed

+217
-91
lines changed

2 files changed

+217
-91
lines changed

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

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { ExecutorContext, readJsonFile } from '@nx/devkit';
1+
import {
2+
ExecutorContext,
3+
readJsonFile,
4+
detectPackageManager,
5+
} from '@nx/devkit';
26
import { execSync } from 'child_process';
37
import { PublishExecutorSchema } from './schema';
48
import runExecutor from './release-publish.impl';
@@ -23,6 +27,9 @@ describe('release-publish executor', () => {
2327
let context: ExecutorContext;
2428
let options: PublishExecutorSchema;
2529
const mockExecSync = execSync as jest.MockedFunction<typeof execSync>;
30+
const mockDetectPackageManager = detectPackageManager as jest.MockedFunction<
31+
typeof detectPackageManager
32+
>;
2633
const mockParseRegistryOptions =
2734
npmConfigModule.parseRegistryOptions as jest.MockedFunction<
2835
typeof npmConfigModule.parseRegistryOptions
@@ -83,6 +90,7 @@ describe('release-publish executor', () => {
8390
describe('npm dist-tag error handling', () => {
8491
it('returns failure and logs only the dist-tag add error when add fails with empty stdout', async () => {
8592
mockExecSync
93+
.mockReturnValueOnce('11.5.1' as any)
8694
.mockReturnValueOnce(
8795
Buffer.from(
8896
JSON.stringify({
@@ -109,4 +117,64 @@ describe('release-publish executor', () => {
109117
);
110118
});
111119
});
120+
121+
describe('npm availability check', () => {
122+
it('should continue without error when pm is bun and npm is not installed', async () => {
123+
mockDetectPackageManager.mockReturnValue('bun');
124+
125+
// npm --version throws (npm not installed)
126+
mockExecSync
127+
.mockImplementationOnce(() => {
128+
throw new Error('Command not found: npm');
129+
})
130+
// bun info call for view command
131+
.mockReturnValueOnce(
132+
Buffer.from(
133+
JSON.stringify({
134+
versions: ['0.9.0'],
135+
'dist-tags': { latest: '0.9.0' },
136+
})
137+
)
138+
)
139+
// bun publish call
140+
.mockReturnValueOnce(Buffer.from('bun publish output'));
141+
142+
jest
143+
.spyOn(extractModule, 'extractNpmPublishJsonData')
144+
.mockReturnValue(null);
145+
146+
const result = await runExecutor(options, context);
147+
148+
expect(result.success).toBe(true);
149+
// Verify the view command used bun info (not npm view)
150+
expect(mockExecSync).toHaveBeenCalledWith(
151+
expect.stringContaining('bun info'),
152+
expect.anything()
153+
);
154+
// Verify npm dist-tag add was NOT called (npm not installed)
155+
expect(mockExecSync).not.toHaveBeenCalledWith(
156+
expect.stringContaining('npm dist-tag add'),
157+
expect.anything()
158+
);
159+
});
160+
161+
it('should return failure when pm is not bun and npm is not installed', async () => {
162+
mockDetectPackageManager.mockReturnValue('pnpm');
163+
164+
// npm --version throws (npm not installed)
165+
mockExecSync.mockImplementationOnce(() => {
166+
throw new Error('Command not found: npm');
167+
});
168+
169+
const result = await runExecutor(options, context);
170+
171+
expect(result.success).toBe(false);
172+
expect(console.error).toHaveBeenCalledWith(
173+
expect.stringContaining('npm was not found in the current environment')
174+
);
175+
expect(console.error).toHaveBeenCalledWith(
176+
expect.stringContaining('"pnpm"')
177+
);
178+
});
179+
});
112180
});

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

Lines changed: 148 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,28 @@ export default async function runExecutor(
3333
context: ExecutorContext
3434
) {
3535
const pm = detectPackageManager();
36+
37+
// Check if npm is installed (needed for dist-tag management and as fallback for view command)
38+
let isNpmInstalled = false;
39+
try {
40+
isNpmInstalled =
41+
execSync('npm --version', {
42+
encoding: 'utf-8',
43+
windowsHide: true,
44+
stdio: ['ignore', 'pipe', 'ignore'],
45+
}).trim() !== '';
46+
} catch {
47+
// Allow missing npm only when using bun
48+
if (pm !== 'bun') {
49+
console.error(
50+
`npm was not found in the current environment. This is only supported when using \`bun\` as a package manager, but your detected package manager is "${pm}"`
51+
);
52+
return {
53+
success: false,
54+
};
55+
}
56+
}
57+
3658
/**
3759
* We need to check both the env var and the option because the executor may have been triggered
3860
* indirectly via dependsOn, in which case the env var will be set, but the option will not.
@@ -126,9 +148,14 @@ Please update the local dependency on "${depName}" to be a valid semantic versio
126148
warnFn
127149
);
128150

129-
const npmViewCommandSegments = [
130-
`npm view ${packageName} versions dist-tags --json --"${registryConfigKey}=${registry}"`,
131-
];
151+
// Use bun info when bun is the package manager, otherwise use npm view
152+
// (npm view works across npm/pnpm/yarn environments and is the established default)
153+
const npmViewCommandSegments =
154+
pm === 'bun'
155+
? ['bun info', packageName, `--json --"${registryConfigKey}=${registry}"`]
156+
: [
157+
`npm view ${packageName} versions dist-tags --json --"${registryConfigKey}=${registry}"`,
158+
];
132159
const npmDistTagAddCommandSegments = [
133160
`npm dist-tag add ${packageName}@${packageJson.version} ${tag} --"${registryConfigKey}=${registry}"`,
134161
];
@@ -162,103 +189,134 @@ Please update the local dependency on "${depName}" to be a valid semantic versio
162189
};
163190
}
164191

165-
// If only one version of a package exists in the registry, versions will be a string instead of an array.
166-
const versions = Array.isArray(resultJson.versions)
167-
? resultJson.versions
168-
: [resultJson.versions];
169-
170-
if (versions.includes(currentVersion)) {
171-
try {
172-
if (!isDryRun) {
173-
execSync(npmDistTagAddCommandSegments.join(' '), {
174-
env: processEnv(true),
175-
cwd: context.root,
176-
stdio: 'ignore',
177-
windowsHide: false,
178-
});
179-
console.log(
180-
`Added the dist-tag ${tag} to v${currentVersion} for registry ${registry}.\n`
181-
);
182-
} else {
183-
console.log(
184-
`Would add the dist-tag ${tag} to v${currentVersion} for registry ${registry}, but ${chalk.keyword(
185-
'orange'
186-
)('[dry-run]')} was set.\n`
187-
);
188-
}
189-
return {
190-
success: true,
191-
};
192-
} catch (err) {
193-
try {
194-
const stdoutData = JSON.parse(err.stdout?.toString() || '{}');
195-
196-
// If the error is that the package doesn't exist, then we can ignore it because we will be publishing it for the first time in the next step
197-
if (
198-
!(
199-
stdoutData.error?.code?.includes('E404') &&
200-
stdoutData.error?.summary?.includes('no such package available')
201-
) &&
202-
!(
203-
err.stderr?.toString().includes('E404') &&
204-
err.stderr?.toString().includes('no such package available')
205-
)
206-
) {
207-
console.error('npm dist-tag add error:');
208-
// npm returns error.summary and error.detail
209-
if (stdoutData.error?.summary) {
210-
console.error(stdoutData.error.summary);
211-
}
212-
if (stdoutData.error?.detail) {
213-
console.error(stdoutData.error.detail);
214-
}
215-
// pnpm returns error.code and error.message
216-
if (stdoutData.error?.code && !stdoutData.error?.summary) {
217-
console.error(`Error code: ${stdoutData.error.code}`);
218-
}
219-
if (stdoutData.error?.message && !stdoutData.error?.summary) {
220-
console.error(stdoutData.error.message);
221-
}
192+
if (isNpmInstalled) {
193+
// If only one version of a package exists in the registry, versions will be a string instead of an array.
194+
const versions = Array.isArray(resultJson.versions)
195+
? resultJson.versions
196+
: [resultJson.versions];
222197

223-
if (context.isVerbose) {
224-
console.error('npm dist-tag add stdout:');
225-
console.error(JSON.stringify(stdoutData, null, 2));
198+
if (versions.includes(currentVersion)) {
199+
try {
200+
if (!isDryRun) {
201+
execSync(npmDistTagAddCommandSegments.join(' '), {
202+
env: processEnv(true),
203+
cwd: context.root,
204+
stdio: 'ignore',
205+
windowsHide: false,
206+
});
207+
console.log(
208+
`Added the dist-tag ${tag} to v${currentVersion} for registry ${registry}.\n`
209+
);
210+
} else {
211+
console.log(
212+
`Would add the dist-tag ${tag} to v${currentVersion} for registry ${registry}, but ${chalk.keyword(
213+
'orange'
214+
)('[dry-run]')} was set.\n`
215+
);
216+
}
217+
return {
218+
success: true,
219+
};
220+
} catch (err) {
221+
try {
222+
const stdoutData = JSON.parse(err.stdout?.toString() || '{}');
223+
224+
// If the error is that the package doesn't exist, then we can ignore it because we will be publishing it for the first time in the next step
225+
if (
226+
!(
227+
stdoutData.error?.code?.includes('E404') &&
228+
stdoutData.error?.summary?.includes(
229+
'no such package available'
230+
)
231+
) &&
232+
!(
233+
err.stderr?.toString().includes('E404') &&
234+
err.stderr?.toString().includes('no such package available')
235+
)
236+
) {
237+
console.error('npm dist-tag add error:');
238+
// npm returns error.summary and error.detail
239+
if (stdoutData.error?.summary) {
240+
console.error(stdoutData.error.summary);
241+
}
242+
if (stdoutData.error?.detail) {
243+
console.error(stdoutData.error.detail);
244+
}
245+
// pnpm returns error.code and error.message
246+
if (stdoutData.error?.code && !stdoutData.error?.summary) {
247+
console.error(`Error code: ${stdoutData.error.code}`);
248+
}
249+
if (stdoutData.error?.message && !stdoutData.error?.summary) {
250+
console.error(stdoutData.error.message);
251+
}
252+
253+
if (context.isVerbose) {
254+
console.error('npm dist-tag add stdout:');
255+
console.error(JSON.stringify(stdoutData, null, 2));
256+
}
257+
return {
258+
success: false,
259+
};
226260
}
261+
} catch (err) {
262+
console.error(
263+
'Something unexpected went wrong when processing the npm dist-tag add output\n',
264+
err
265+
);
227266
return {
228267
success: false,
229268
};
230269
}
231-
} catch (err) {
232-
console.error(
233-
'Something unexpected went wrong when processing the npm dist-tag add output\n',
234-
err
235-
);
236-
return {
237-
success: false,
238-
};
239270
}
240271
}
241272
}
242273
} catch (err) {
243-
const stdoutData = JSON.parse(err.stdout?.toString() || '{}');
244-
// If the error is that the package doesn't exist, then we can ignore it because we will be publishing it for the first time in the next step
245-
if (
246-
!(
247-
stdoutData.error?.code?.includes('E404') &&
248-
stdoutData.error?.summary?.toLowerCase().includes('not found')
249-
) &&
250-
!(
251-
err.stderr?.toString().includes('E404') &&
252-
err.stderr?.toString().toLowerCase().includes('not found')
253-
)
254-
) {
255-
console.error(
256-
`Something unexpected went wrong when checking for existing dist-tags.\n`,
257-
err
258-
);
259-
return {
260-
success: false,
261-
};
274+
try {
275+
const stdoutData = JSON.parse(err.stdout?.toString() || '{}');
276+
// If the error is that the package doesn't exist, then we can ignore it because we will be publishing it for the first time in the next step
277+
if (
278+
!(
279+
stdoutData.error?.code?.includes('E404') &&
280+
stdoutData.error?.summary?.toLowerCase().includes('not found')
281+
) &&
282+
!(
283+
err.stderr?.toString().includes('E404') &&
284+
err.stderr?.toString().toLowerCase().includes('not found')
285+
) &&
286+
// bun uses plain '404' instead of 'E404'
287+
!(
288+
err.stderr?.toString().includes('404') &&
289+
err.stderr?.toString().toLowerCase().includes('not found')
290+
)
291+
) {
292+
console.error(
293+
`Something unexpected went wrong when checking for existing dist-tags.\n`,
294+
err
295+
);
296+
return {
297+
success: false,
298+
};
299+
}
300+
} catch {
301+
// JSON parse failed entirely — check stderr/stdout for plain 404
302+
const stderrStr = err.stderr?.toString() || '';
303+
const stdoutStr = err.stdout?.toString() || '';
304+
if (
305+
!(
306+
(stderrStr.includes('404') &&
307+
stderrStr.toLowerCase().includes('not found')) ||
308+
(stdoutStr.includes('404') &&
309+
stdoutStr.toLowerCase().includes('not found'))
310+
)
311+
) {
312+
console.error(
313+
`Something unexpected went wrong when checking for existing dist-tags.\n`,
314+
err
315+
);
316+
return {
317+
success: false,
318+
};
319+
}
262320
}
263321
}
264322
}

0 commit comments

Comments
 (0)