From c913d5acf5880c94217dcbb4882be55e962054cc Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 17 Nov 2023 09:06:56 +0100 Subject: [PATCH] feat(npm): Allow to configure `checkPackageName` for npm target (#504) --------- Co-authored-by: Lukas Stracke --- README.md | 7 +- src/targets/__tests__/npm.test.ts | 173 ++++++++++++++++++++++++++++++ src/targets/npm.ts | 130 ++++++++++++++++++++-- 3 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 src/targets/__tests__/npm.test.ts diff --git a/README.md b/README.md index e74f0756..56e777d2 100644 --- a/README.md +++ b/README.md @@ -567,9 +567,10 @@ The `npm` utility must be installed on the system. **Configuration** -| Option | Description | -| -------- | -------------------------------------------------------------------------------- | -| `access` | **optional**. Visibility for scoped packages: `restricted` (default) or `public` | +| Option | Description | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| `access` | **optional**. Visibility for scoped packages: `restricted` (default) or `public` | +| `checkPackageName` | **optional**. If defined, check this package on the registry to get the current latest version to compare for the `latest` tag. The package(s) to be published will only be tagged with `latest` if the new version is greater than the checked package's version| **Example** diff --git a/src/targets/__tests__/npm.test.ts b/src/targets/__tests__/npm.test.ts new file mode 100644 index 00000000..cd34da08 --- /dev/null +++ b/src/targets/__tests__/npm.test.ts @@ -0,0 +1,173 @@ +import { getPublishTag, getLatestVersion } from '../npm'; +import * as system from '../../utils/system'; + +const defaultNpmConfig = { + useYarn: false, + token: 'xxx', +}; + +describe('getLatestVersion', () => { + let spawnProcessMock: jest.SpyInstance; + + beforeEach(() => { + spawnProcessMock = jest + .spyOn(system, 'spawnProcess') + .mockImplementation(() => Promise.reject('does not exist')); + }); + + afterEach(() => { + spawnProcessMock.mockReset(); + }); + + it('returns undefined if package name does not exist', async () => { + const actual = await getLatestVersion( + 'sentry-xx-this-does-not-exist', + defaultNpmConfig + ); + expect(actual).toEqual(undefined); + expect(spawnProcessMock).toBeCalledTimes(1); + expect(spawnProcessMock).toBeCalledWith( + 'npm', + ['info', 'sentry-xx-this-does-not-exist', 'version'], + expect.objectContaining({}) + ); + }); + + it('returns version for valid package name', async () => { + spawnProcessMock = jest + .spyOn(system, 'spawnProcess') + .mockImplementation(() => + Promise.resolve(Buffer.from('7.20.0\n', 'utf-8')) + ); + const actual = await getLatestVersion('@sentry/browser', defaultNpmConfig); + expect(actual).toBe('7.20.0'); + expect(spawnProcessMock).toBeCalledTimes(1); + expect(spawnProcessMock).toBeCalledWith( + 'npm', + ['info', '@sentry/browser', 'version'], + expect.objectContaining({}) + ); + }); +}); + +describe('getPublishTag', () => { + let spawnProcessMock: jest.SpyInstance; + + beforeEach(() => { + spawnProcessMock = jest + .spyOn(system, 'spawnProcess') + .mockImplementation(() => Promise.reject('does not exist')); + }); + + afterEach(() => { + spawnProcessMock.mockReset(); + }); + + it('returns undefined without a checkPackageName', async () => { + const logger = { + warn: jest.fn(), + } as any; + const actual = await getPublishTag( + '1.0.0', + undefined, + defaultNpmConfig, + logger + ); + expect(actual).toEqual(undefined); + expect(logger.warn).not.toHaveBeenCalled(); + expect(spawnProcessMock).not.toBeCalled(); + }); + + it('returns undefined for unexisting package name', async () => { + const logger = { + warn: jest.fn(), + } as any; + const actual = await getPublishTag( + '1.0.0', + 'sentry-xx-does-not-exist', + defaultNpmConfig, + logger + ); + expect(actual).toEqual(undefined); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + 'Could not fetch current version for package sentry-xx-does-not-exist' + ); + expect(spawnProcessMock).toBeCalledTimes(1); + }); + + it('returns undefined for invalid package version', async () => { + spawnProcessMock = jest + .spyOn(system, 'spawnProcess') + .mockImplementation(() => + Promise.resolve(Buffer.from('weird-version', 'utf-8')) + ); + + const logger = { + warn: jest.fn(), + } as any; + const actual = await getPublishTag( + '1.0.0', + '@sentry/browser', + defaultNpmConfig, + logger + ); + expect(actual).toEqual(undefined); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + 'Could not fetch current version for package @sentry/browser' + ); + expect(spawnProcessMock).toBeCalledTimes(1); + }); + + it('returns next for prereleases', async () => { + const logger = { + warn: jest.fn(), + } as any; + const actual = await getPublishTag( + '1.0.0-alpha.1', + undefined, + defaultNpmConfig, + logger + ); + expect(actual).toBe('next'); + expect(logger.warn).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + 'Detected pre-release version for npm package!' + ); + expect(logger.warn).toHaveBeenCalledWith( + 'Adding tag "next" to not make it "latest" in registry.' + ); + expect(spawnProcessMock).not.toBeCalled(); + }); + + it('returns old for older versions', async () => { + spawnProcessMock = jest + .spyOn(system, 'spawnProcess') + .mockImplementation(() => + Promise.resolve(Buffer.from('7.20.0\n', 'utf-8')) + ); + + const logger = { + warn: jest.fn(), + } as any; + + const actual = await getPublishTag( + '1.0.0', + '@sentry/browser', + defaultNpmConfig, + logger + ); + expect(actual).toBe('old'); + expect(logger.warn).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching( + /Detected older version than currently published version \(([\d.]+)\) for @sentry\/browser/ + ) + ); + expect(logger.warn).toHaveBeenCalledWith( + 'Adding tag "old" to not make it "latest" in registry.' + ); + expect(spawnProcessMock).toBeCalledTimes(1); + }); +}); diff --git a/src/targets/npm.ts b/src/targets/npm.ts index ecee4e64..06b13534 100644 --- a/src/targets/npm.ts +++ b/src/targets/npm.ts @@ -5,7 +5,11 @@ import { TargetConfig } from '../schemas/project_config'; import { ConfigurationError, reportError } from '../utils/errors'; import { isDryRun } from '../utils/helpers'; import { hasExecutable, spawnProcess } from '../utils/system'; -import { isPreviewRelease, parseVersion } from '../utils/version'; +import { + isPreviewRelease, + parseVersion, + versionGreaterOrEqualThan, +} from '../utils/version'; import { BaseTarget } from './base'; import { BaseArtifactProvider, @@ -36,6 +40,12 @@ export enum NpmPackageAccess { RESTRICTED = 'restricted', } +export interface NpmTargetConfig extends TargetConfig { + access?: NpmPackageAccess; + /** If defined, lookup this package name on the registry to get the current latest version. */ + checkPackageName?: string; +} + /** NPM target configuration options */ export interface NpmTargetOptions { /** Package access specifier */ @@ -54,6 +64,8 @@ interface NpmPublishOptions { otp?: string; /** New version to publish */ version: string; + /** A tag to use for the publish. If not set, defaults to "latest" */ + tag?: string; } /** @@ -66,7 +78,7 @@ export class NpmTarget extends BaseTarget { public readonly npmConfig: NpmTargetOptions; public constructor( - config: TargetConfig, + config: NpmTargetConfig, artifactProvider: BaseArtifactProvider ) { super(config, artifactProvider); @@ -178,14 +190,8 @@ export class NpmTarget extends BaseTarget { args.push(`--access=${this.npmConfig.access}`); } - // In case we have a prerelease, there should never be a reason to publish - // it with the latest tag in npm. - if (isPreviewRelease(options.version)) { - this.logger.warn('Detected pre-release version for npm package!'); - this.logger.warn( - 'Adding tag "next" to not make it "latest" in registry.' - ); - args.push('--tag=next'); + if (options.tag) { + args.push(`--tag=${options.tag}`); } return withTempFile(filePath => { @@ -235,6 +241,17 @@ export class NpmTarget extends BaseTarget { publishOptions.otp = await this.requestOtp(); } + const tag = await getPublishTag( + version, + this.config.checkPackageName, + this.npmConfig, + this.logger, + publishOptions.otp + ); + if (tag) { + publishOptions.tag = tag; + } + await Promise.all( packageFiles.map(async (file: RemoteArtifact) => { const path = await this.artifactProvider.downloadArtifact(file); @@ -246,3 +263,96 @@ export class NpmTarget extends BaseTarget { this.logger.info('NPM release complete'); } } + +/** + * Get the latest version for the given package. + */ +export async function getLatestVersion( + packageName: string, + npmConfig: NpmTargetOptions, + otp?: NpmPublishOptions['otp'] +): Promise { + const args = ['info', packageName, 'version']; + const bin = NPM_BIN; + + try { + const response = await withTempFile(filePath => { + // Pass OTP if configured + const spawnOptions: SpawnOptions = {}; + spawnOptions.env = { ...process.env }; + if (otp) { + spawnOptions.env.NPM_CONFIG_OTP = otp; + } + spawnOptions.env[NPM_TOKEN_ENV_VAR] = npmConfig.token; + // NOTE(byk): Use npm_config_userconfig instead of --userconfig for yarn compat + spawnOptions.env.npm_config_userconfig = filePath; + writeFileSync( + filePath, + `//registry.npmjs.org/:_authToken=\${${NPM_TOKEN_ENV_VAR}}` + ); + + return spawnProcess(bin, args, spawnOptions); + }); + + if (!response) { + return undefined; + } + + return response.toString().trim(); + } catch { + return undefined; + } +} +/** + * Get the tag to use for publishing to npm. + * If this returns `undefined`, we'll use the default behavior from NPM + * (which is to set the `latest` tag). + */ +export async function getPublishTag( + version: string, + checkPackageName: string | undefined, + npmConfig: NpmTargetOptions, + logger: NpmTarget['logger'], + otp?: NpmPublishOptions['otp'] +): Promise { + if (isPreviewRelease(version)) { + logger.warn('Detected pre-release version for npm package!'); + logger.warn('Adding tag "next" to not make it "latest" in registry.'); + return 'next'; + } + + // If no checkPackageName is given, we return undefined + if (!checkPackageName) { + return undefined; + } + + const latestVersion = await getLatestVersion( + checkPackageName, + npmConfig, + otp + ); + const parsedLatestVersion = latestVersion && parseVersion(latestVersion); + const parsedNewVersion = parseVersion(version); + + if (!parsedLatestVersion) { + logger.warn( + `Could not fetch current version for package ${checkPackageName}` + ); + return undefined; + } + + // If we are publishing a version that is older than the currently latest version, + // We tag it with "old" instead of "latest" + if ( + parsedNewVersion && + !versionGreaterOrEqualThan(parsedNewVersion, parsedLatestVersion) + ) { + logger.warn( + `Detected older version than currently published version (${latestVersion}) for ${checkPackageName}` + ); + logger.warn('Adding tag "old" to not make it "latest" in registry.'); + return 'old'; + } + + return undefined; +}