fix(release): tolerate npm propagation in smoke

This commit is contained in:
Andrey Avtomonov 2026-05-17 01:08:58 +02:00
parent ceb578e0f6
commit 8d1837f26e
2 changed files with 73 additions and 3 deletions

View file

@ -2,7 +2,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises'; import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
@ -22,6 +22,8 @@ export {
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const SMOKE_TIMEOUT_MS = 180_000; const SMOKE_TIMEOUT_MS = 180_000;
const TRANSIENT_LOOKUP_RETRY_ATTEMPTS = 6;
const TRANSIENT_LOOKUP_RETRY_DELAY_MS = 10_000;
const VERSION_LABELS = new Set([ const VERSION_LABELS = new Set([
'published package npx version', 'published package npx version',
@ -33,6 +35,18 @@ export function isPublishedPackageVersionLabel(label) {
return VERSION_LABELS.has(label); return VERSION_LABELS.has(label);
} }
export function publishedPackageSmokePnpmWorkspaceYaml() {
return ['packages:', ' - "."', 'allowBuilds:', ' better-sqlite3: true', ''].join('\n');
}
export function isTransientPublishedPackageLookupFailure(result) {
return (
result.code !== 0 &&
(result.stderr.includes('npm error code ETARGET') ||
result.stderr.includes('No matching version found for @kaelio/ktx@'))
);
}
function scriptRootDir() { function scriptRootDir() {
return resolve(dirname(fileURLToPath(import.meta.url)), '..'); return resolve(dirname(fileURLToPath(import.meta.url)), '..');
} }
@ -61,6 +75,24 @@ async function runCommand(command, args, options = {}) {
} }
} }
function delay(ms) {
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
}
async function runCommandWithRegistryRetry(command, args, options = {}) {
const attempts = options.retryAttempts ?? TRANSIENT_LOOKUP_RETRY_ATTEMPTS;
const retryDelayMs = options.retryDelayMs ?? TRANSIENT_LOOKUP_RETRY_DELAY_MS;
let result = await runCommand(command, args, options);
for (let attempt = 2; attempt <= attempts && isTransientPublishedPackageLookupFailure(result); attempt += 1) {
process.stdout.write(`npm registry has not exposed the package yet; retrying smoke command (${attempt}/${attempts})\n`);
await delay(retryDelayMs);
result = await runCommand(command, args, options);
}
return result;
}
function requireSuccess(label, result) { function requireSuccess(label, result) {
assert.equal( assert.equal(
result.code, result.code,
@ -74,15 +106,17 @@ export async function runPublishedPackageSmoke(config) {
try { try {
const projectDir = join(root, 'demo-project'); const projectDir = join(root, 'demo-project');
await writeFile(join(root, 'pnpm-workspace.yaml'), publishedPackageSmokePnpmWorkspaceYaml());
const commands = buildPublishedPackageSmokeCommands(config, projectDir); const commands = buildPublishedPackageSmokeCommands(config, projectDir);
const pnpmHome = join(root, 'pnpm-home'); const pnpmHome = join(root, 'pnpm-home');
const globalEnv = { const globalEnv = {
PNPM_HOME: pnpmHome, PNPM_HOME: pnpmHome,
PATH: `${pnpmHome}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH ?? ''}`, PATH: [join(pnpmHome, 'bin'), pnpmHome, process.env.PATH ?? ''].join(process.platform === 'win32' ? ';' : ':'),
}; };
for (const command of commands) { for (const command of commands) {
const isGlobalCommand = command.label.includes('global'); const isGlobalCommand = command.label.includes('global');
const result = await runCommand(command.command, command.args, { const result = await runCommandWithRegistryRetry(command.command, command.args, {
cwd: command.label.includes('local') || isGlobalCommand ? root : undefined, cwd: command.label.includes('local') || isGlobalCommand ? root : undefined,
env: isGlobalCommand ? { ...globalEnv, ...command.env } : command.env, env: isGlobalCommand ? { ...globalEnv, ...command.env } : command.env,
}); });

View file

@ -6,6 +6,8 @@ import {
buildPublishedPackageNpxCommand, buildPublishedPackageNpxCommand,
buildPublishedPackageSmokeCommands, buildPublishedPackageSmokeCommands,
isPublishedPackageVersionLabel, isPublishedPackageVersionLabel,
isTransientPublishedPackageLookupFailure,
publishedPackageSmokePnpmWorkspaceYaml,
publishedPackageSpec, publishedPackageSpec,
readPublishedPackageSmokeConfig, readPublishedPackageSmokeConfig,
} from './published-package-smoke.mjs'; } from './published-package-smoke.mjs';
@ -156,6 +158,33 @@ describe('published package smoke output validation labels', () => {
}); });
}); });
describe('published package smoke registry retry classification', () => {
it('recognizes npm propagation misses as transient lookup failures', () => {
assert.equal(
isTransientPublishedPackageLookupFailure({
code: 1,
stdout: '',
stderr: [
'npm error code ETARGET',
'npm error notarget No matching version found for @kaelio/ktx@0.1.0-rc.4.',
].join('\n'),
}),
true,
);
});
it('does not retry unrelated command failures', () => {
assert.equal(
isTransientPublishedPackageLookupFailure({
code: 1,
stdout: '',
stderr: 'npm error code EOTP',
}),
false,
);
});
});
describe('published package smoke command construction', () => { describe('published package smoke command construction', () => {
const config = { const config = {
enabled: true, enabled: true,
@ -244,6 +273,13 @@ describe('published package smoke command construction', () => {
); );
}); });
it('allows native dependency build scripts in clean pnpm smoke installs', () => {
assert.equal(
publishedPackageSmokePnpmWorkspaceYaml(),
['packages:', ' - "."', 'allowBuilds:', ' better-sqlite3: true', ''].join('\n'),
);
});
it('exposes the smoke through the package release script', async () => { it('exposes the smoke through the package release script', async () => {
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));