diff --git a/scripts/published-package-smoke.mjs b/scripts/published-package-smoke.mjs index e304a7af..88902c42 100644 --- a/scripts/published-package-smoke.mjs +++ b/scripts/published-package-smoke.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; 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 { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -22,6 +22,8 @@ export { const execFileAsync = promisify(execFile); 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([ 'published package npx version', @@ -33,6 +35,18 @@ export function isPublishedPackageVersionLabel(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() { 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) { assert.equal( result.code, @@ -74,15 +106,17 @@ export async function runPublishedPackageSmoke(config) { try { const projectDir = join(root, 'demo-project'); + await writeFile(join(root, 'pnpm-workspace.yaml'), publishedPackageSmokePnpmWorkspaceYaml()); + const commands = buildPublishedPackageSmokeCommands(config, projectDir); const pnpmHome = join(root, 'pnpm-home'); const globalEnv = { 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) { 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, env: isGlobalCommand ? { ...globalEnv, ...command.env } : command.env, }); diff --git a/scripts/published-package-smoke.test.mjs b/scripts/published-package-smoke.test.mjs index 56bbe424..719eed2a 100644 --- a/scripts/published-package-smoke.test.mjs +++ b/scripts/published-package-smoke.test.mjs @@ -6,6 +6,8 @@ import { buildPublishedPackageNpxCommand, buildPublishedPackageSmokeCommands, isPublishedPackageVersionLabel, + isTransientPublishedPackageLookupFailure, + publishedPackageSmokePnpmWorkspaceYaml, publishedPackageSpec, readPublishedPackageSmokeConfig, } 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', () => { const config = { 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 () => { const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));