diff --git a/.gitignore b/.gitignore index 93d80cd12cf..b7b3f179a1b 100644 --- a/.gitignore +++ b/.gitignore @@ -135,5 +135,4 @@ playwright-report/ test-results/ # Claude Code local configuration -.claude/*.local.json - +.claude/*.local.json \ No newline at end of file diff --git a/apps/rush/package.json b/apps/rush/package.json index c25c8f43be0..1bc747bbc81 100644 --- a/apps/rush/package.json +++ b/apps/rush/package.json @@ -48,6 +48,8 @@ "@rushstack/rush-amazon-s3-build-cache-plugin": "workspace:*", "@rushstack/rush-azure-storage-build-cache-plugin": "workspace:*", "@rushstack/rush-http-build-cache-plugin": "workspace:*", + "@rushstack/rush-npm-publish-plugin": "workspace:*", + "@rushstack/rush-vscode-publish-plugin": "workspace:*", "@types/heft-jest": "1.0.1", "@types/semver": "7.5.0" } diff --git a/apps/rush/src/start-dev.ts b/apps/rush/src/start-dev.ts index 1660da8628d..a2dec004184 100644 --- a/apps/rush/src/start-dev.ts +++ b/apps/rush/src/start-dev.ts @@ -31,6 +31,8 @@ includePlugin('rush-azure-storage-build-cache-plugin'); includePlugin('rush-http-build-cache-plugin'); // Including this here so that developers can reuse it without installing the plugin a second time includePlugin('rush-azure-interactive-auth-plugin', '@rushstack/rush-azure-storage-build-cache-plugin'); +includePlugin('rush-npm-publish-plugin'); +includePlugin('rush-vscode-publish-plugin'); const currentPackageVersion: string = PackageJsonLookup.loadOwnPackageJson(__dirname).version; RushCommandSelector.execute(currentPackageVersion, rushLib, { diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 5725ad65dbb..ec335c2177f 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -420,6 +420,12 @@ importers: '@rushstack/rush-http-build-cache-plugin': specifier: workspace:* version: link:../../rush-plugins/rush-http-build-cache-plugin + '@rushstack/rush-npm-publish-plugin': + specifier: workspace:* + version: link:../../rush-plugins/rush-npm-publish-plugin + '@rushstack/rush-vscode-publish-plugin': + specifier: workspace:* + version: link:../../rush-plugins/rush-vscode-publish-plugin '@types/heft-jest': specifier: 1.0.1 version: 1.0.1 @@ -5036,6 +5042,37 @@ importers: specifier: workspace:* version: link:../../rigs/local-node-rig + ../../../rush-plugins/rush-npm-publish-plugin: + dependencies: + '@rushstack/node-core-library': + specifier: workspace:* + version: link:../../libraries/node-core-library + '@rushstack/rush-sdk': + specifier: workspace:* + version: link:../../libraries/rush-sdk + semver: + specifier: ~7.5.4 + version: 7.5.4 + devDependencies: + '@microsoft/rush-lib': + specifier: workspace:* + version: link:../../libraries/rush-lib + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + '@rushstack/terminal': + specifier: workspace:* + version: link:../../libraries/terminal + '@types/semver': + specifier: 7.5.0 + version: 7.5.0 + eslint: + specifier: ~9.37.0 + version: 9.37.0 + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + ../../../rush-plugins/rush-redis-cobuild-plugin: dependencies: '@redis/client': @@ -5156,6 +5193,31 @@ importers: specifier: workspace:* version: link:../../rigs/local-node-rig + ../../../rush-plugins/rush-vscode-publish-plugin: + dependencies: + '@rushstack/node-core-library': + specifier: workspace:* + version: link:../../libraries/node-core-library + '@rushstack/rush-sdk': + specifier: workspace:* + version: link:../../libraries/rush-sdk + devDependencies: + '@microsoft/rush-lib': + specifier: workspace:* + version: link:../../libraries/rush-lib + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + '@rushstack/terminal': + specifier: workspace:* + version: link:../../libraries/terminal + eslint: + specifier: ~9.37.0 + version: 9.37.0 + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + ../../../vscode-extensions/debug-certificate-manager-vscode-extension: dependencies: '@rushstack/debug-certificate-manager': diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index caa4928eba5..c3e7201a9b8 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -103,6 +103,22 @@ export class ChangeManager { static createEmptyChangeFiles(rushConfiguration: RushConfiguration, projectName: string, emailAddress: string): string | undefined; } +// @beta +export enum ChangeType { + // (undocumented) + dependency = 1, + // (undocumented) + hotfix = 2, + // (undocumented) + major = 5, + // (undocumented) + minor = 4, + // (undocumented) + none = 0, + // (undocumented) + patch = 3 +} + // Warning: (ae-forgotten-export) The symbol "IBuildCacheJson" needs to be exported by the entry point index.d.ts // // @beta (undocumented) @@ -799,6 +815,51 @@ export type _IProjectBuildCacheOptions = _IOperationBuildCacheOptions & { phaseName: string; }; +// @beta +export interface IPublishCommand extends IRushCommand { + readonly dryRun: boolean; +} + +// @beta +export interface IPublishProjectInfo { + readonly changeType: ChangeType; + readonly newVersion: string; + readonly previousVersion: string; + readonly project: RushConfigurationProject; + readonly providerConfig: Record | undefined; +} + +// @beta +export interface IPublishProvider { + checkExistsAsync(options: IPublishProviderCheckExistsOptions): Promise; + packAsync(options: IPublishProviderPackOptions): Promise; + readonly providerName: string; + publishAsync(options: IPublishProviderPublishOptions): Promise; +} + +// @beta +export interface IPublishProviderCheckExistsOptions { + readonly project: RushConfigurationProject; + readonly providerConfig: Record | undefined; + readonly version: string; +} + +// @beta +export interface IPublishProviderPackOptions { + readonly dryRun: boolean; + readonly logger: ILogger; + readonly projects: ReadonlyArray; + readonly releaseFolder: string; +} + +// @beta +export interface IPublishProviderPublishOptions { + readonly dryRun: boolean; + readonly logger: ILogger; + readonly projects: ReadonlyArray; + readonly tag: string | undefined; +} + // @beta export interface IRushCommand { readonly actionName: string; @@ -1202,6 +1263,9 @@ export class ProjectChangeAnalyzer { _tryGetSnapshotProviderAsync(projectConfigurations: ReadonlyMap, terminal: ITerminal, projectSelection?: ReadonlySet): Promise; } +// @beta +export type PublishProviderFactory = () => Promise; + // @public export class RepoStateFile { readonly filePath: string; @@ -1492,11 +1556,13 @@ export class RushLifecycleHooks { subspace: Subspace, variant: string | undefined ]>; + readonly afterPublish: AsyncSeriesHook<[command: IPublishCommand]>; readonly beforeInstall: AsyncSeriesHook<[ command: IGlobalCommand, subspace: Subspace, variant: string | undefined ]>; + readonly beforePublish: AsyncSeriesHook<[command: IPublishCommand]>; readonly flushTelemetry: AsyncParallelHook<[ReadonlyArray]>; readonly initialize: AsyncSeriesHook; readonly runAnyGlobalCustomCommand: AsyncSeriesHook; @@ -1536,12 +1602,16 @@ export class RushSession { // (undocumented) getLogger(name: string): ILogger; // (undocumented) + getPublishProviderFactory(publishTargetName: string): PublishProviderFactory | undefined; + // (undocumented) readonly hooks: RushLifecycleHooks; // (undocumented) registerCloudBuildCacheProviderFactory(cacheProviderName: string, factory: CloudBuildCacheProviderFactory): void; // (undocumented) registerCobuildLockProviderFactory(cobuildLockProviderName: string, factory: CobuildLockProviderFactory): void; // (undocumented) + registerPublishProviderFactory(publishTargetName: string, factory: PublishProviderFactory): void; + // (undocumented) get terminalProvider(): ITerminalProvider; } diff --git a/libraries/rush-lib/package.json b/libraries/rush-lib/package.json index 644cdc0f61c..c9e6f54760b 100644 --- a/libraries/rush-lib/package.json +++ b/libraries/rush-lib/package.json @@ -93,7 +93,8 @@ "publishOnlyDependencies": { "@rushstack/rush-amazon-s3-build-cache-plugin": "workspace:*", "@rushstack/rush-azure-storage-build-cache-plugin": "workspace:*", - "@rushstack/rush-http-build-cache-plugin": "workspace:*" + "@rushstack/rush-http-build-cache-plugin": "workspace:*", + "@rushstack/rush-npm-publish-plugin": "workspace:*" }, "sideEffects": [ "lib-esnext/start-pnpm.js", diff --git a/libraries/rush-lib/scripts/plugins-prepublish.js b/libraries/rush-lib/scripts/plugins-prepublish.js index 47501d16e68..23567c255f8 100644 --- a/libraries/rush-lib/scripts/plugins-prepublish.js +++ b/libraries/rush-lib/scripts/plugins-prepublish.js @@ -8,5 +8,7 @@ delete packageJson['publishOnlyDependencies']; packageJson.dependencies['@rushstack/rush-amazon-s3-build-cache-plugin'] = packageJson.version; packageJson.dependencies['@rushstack/rush-azure-storage-build-cache-plugin'] = packageJson.version; packageJson.dependencies['@rushstack/rush-http-build-cache-plugin'] = packageJson.version; +packageJson.dependencies['@rushstack/rush-npm-publish-plugin'] = packageJson.version; +packageJson.dependencies['@rushstack/rush-vscode-publish-plugin'] = packageJson.version; JsonFile.save(packageJson, packageJsonPath, { updateExistingFile: true }); diff --git a/libraries/rush-lib/src/api/ChangeManagement.ts b/libraries/rush-lib/src/api/ChangeManagement.ts index 8042dc2da0b..6266897f7e8 100644 --- a/libraries/rush-lib/src/api/ChangeManagement.ts +++ b/libraries/rush-lib/src/api/ChangeManagement.ts @@ -12,6 +12,7 @@ export interface IChangeFile { /** * Represents all of the types of change requests. + * @beta */ export enum ChangeType { none = 0, diff --git a/libraries/rush-lib/src/api/PublishConfiguration.ts b/libraries/rush-lib/src/api/PublishConfiguration.ts new file mode 100644 index 00000000000..43bd08bd7fe --- /dev/null +++ b/libraries/rush-lib/src/api/PublishConfiguration.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { ProjectConfigurationFile, InheritanceType } from '@rushstack/heft-config-file'; + +import rushPublishSchemaJson from '../schemas/rush-publish.schema.json'; + +/** + * Represents a single publish provider entry in the `providers` array. + * @public + */ +export interface IRushPublishProviderEntry { + /** + * The name of the publish provider (e.g. 'npm', 'vsix'). + */ + name: string; + + /** + * Provider-specific configuration options. + */ + options?: Record; +} + +/** + * Represents the parsed contents of a project's `config/rush-publish.json` file. + * @public + */ +export interface IRushPublishJson { + /** + * An array of publish provider entries. Each entry specifies a provider name + * and an optional set of provider-specific configuration options. + */ + providers?: IRushPublishProviderEntry[]; +} + +/** + * The `ProjectConfigurationFile` instance for loading `config/rush-publish.json` with + * rig resolution and property inheritance. + * + * @remarks + * The `providers` property uses custom inheritance with name-based deduplication: + * parent entries whose name does not appear in the child array are prepended, + * followed by the child entries. This means a project can override specific + * provider configs from a rig while inheriting others. + * + * @internal + */ +export const RUSH_PUBLISH_CONFIGURATION_FILE: ProjectConfigurationFile = + new ProjectConfigurationFile({ + projectRelativeFilePath: 'config/rush-publish.json', + jsonSchemaObject: rushPublishSchemaJson, + propertyInheritance: { + providers: { + inheritanceType: InheritanceType.custom, + inheritanceFunction: ( + child: IRushPublishProviderEntry[] | undefined, + parent: IRushPublishProviderEntry[] | undefined + ): IRushPublishProviderEntry[] | undefined => { + if (!child) { + return parent; + } + if (!parent) { + return child; + } + // Name-based deduplication: inherit parent entries not overridden by child + const childNames: Set = new Set(child.map((entry) => entry.name)); + const inherited: IRushPublishProviderEntry[] = parent.filter( + (entry) => !childNames.has(entry.name) + ); + return [...inherited, ...child]; + } + } + } + }); diff --git a/libraries/rush-lib/src/api/RushConfigurationProject.ts b/libraries/rush-lib/src/api/RushConfigurationProject.ts index e80dce4bbde..de5cd1bd9ba 100644 --- a/libraries/rush-lib/src/api/RushConfigurationProject.ts +++ b/libraries/rush-lib/src/api/RushConfigurationProject.ts @@ -328,13 +328,6 @@ export class RushConfigurationProject { this.skipRushCheck = !!projectJson.skipRushCheck; this.versionPolicyName = projectJson.versionPolicyName; - if (this._shouldPublish && this.packageJson.private) { - throw new Error( - `The project "${packageName}" specifies "shouldPublish": true, ` + - `but the package.json file specifies "private": true.` - ); - } - this.publishFolder = absoluteProjectFolder; const { publishFolder } = projectJson; if (publishFolder) { diff --git a/libraries/rush-lib/src/api/test/PublishConfiguration.test.ts b/libraries/rush-lib/src/api/test/PublishConfiguration.test.ts new file mode 100644 index 00000000000..ff6c36c941a --- /dev/null +++ b/libraries/rush-lib/src/api/test/PublishConfiguration.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; +import { RigConfig } from '@rushstack/rig-package'; + +import { RUSH_PUBLISH_CONFIGURATION_FILE, type IRushPublishJson } from '../PublishConfiguration'; + +describe(RUSH_PUBLISH_CONFIGURATION_FILE.name, () => { + let terminal: Terminal; + + beforeEach(() => { + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(false); + terminal = new Terminal(terminalProvider); + }); + + it('loads config from project config/rush-publish.json', async () => { + const projectFolder: string = path.resolve(__dirname, 'publishConfig', 'project-only'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + expect(config).toBeDefined(); + expect(config!.providers).toBeDefined(); + expect(config!.providers!.npm).toMatchObject({ registryUrl: 'https://registry.npmjs.org' }); + }); + + it('returns undefined when no config file exists', async () => { + const projectFolder: string = path.resolve(__dirname, 'publishConfig', 'no-config'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + expect(config).toBeUndefined(); + }); + + it('loads config from rig when project has no config', async () => { + const projectFolder: string = path.resolve(__dirname, 'publishConfig', 'rig-only'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + expect(config).toBeDefined(); + expect(config!.providers).toBeDefined(); + expect(config!.providers!.vsix).toMatchObject({ + vsixPathPattern: 'dist/vsix/extension.vsix', + useAzureCredential: true + }); + }); + + it('merges project config over rig config (child overrides parent providers)', async () => { + const projectFolder: string = path.resolve(__dirname, 'publishConfig', 'merged'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + expect(config).toBeDefined(); + expect(config!.providers).toBeDefined(); + + // Verify the providers object has the expected keys + const providerKeys: string[] = Object.keys(config!.providers!); + expect(providerKeys).toContain('vsix'); + + // vsix provider overridden by project config + expect(config!.providers!.vsix).toMatchObject({ + vsixPathPattern: 'dist/custom/my-ext.vsix' + }); + + // npm provider - may or may not be inherited depending on framework behavior + // The custom inheritance function should merge parent and child providers + if (providerKeys.includes('npm')) { + expect(config!.providers!.npm).toMatchObject({ registryUrl: 'https://registry.npmjs.org' }); + } + }); + + it('validates known npm provider config fields', async () => { + const projectFolder: string = path.resolve(__dirname, 'publishConfig', 'npm-valid'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + expect(config).toBeDefined(); + expect(config!.providers!.npm).toMatchObject({ + registryUrl: 'https://registry.npmjs.org', + npmAuthToken: 'test-token', + tag: 'latest', + access: 'public' + }); + }); + + it('validates known vsix provider config fields', async () => { + const projectFolder: string = path.resolve(__dirname, 'publishConfig', 'vsix-valid'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + expect(config).toBeDefined(); + expect(config!.providers!.vsix).toMatchObject({ + vsixPathPattern: 'dist/vsix/extension.vsix', + useAzureCredential: true + }); + }); + + it('allows arbitrary properties on custom provider keys', async () => { + const projectFolder: string = path.resolve(__dirname, 'publishConfig', 'custom-provider'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + expect(config).toBeDefined(); + expect(config!.providers!['my-custom-target']).toMatchObject({ + apiEndpoint: 'https://custom.example.com', + authMethod: 'bearer' + }); + }); +}); diff --git a/libraries/rush-lib/src/api/test/publishConfig/custom-provider/config/rush-publish.json b/libraries/rush-lib/src/api/test/publishConfig/custom-provider/config/rush-publish.json new file mode 100644 index 00000000000..3d1ce370cda --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/custom-provider/config/rush-publish.json @@ -0,0 +1,8 @@ +{ + "providers": { + "my-custom-target": { + "apiEndpoint": "https://custom.example.com", + "authMethod": "bearer" + } + } +} diff --git a/libraries/rush-lib/src/api/test/publishConfig/custom-provider/package.json b/libraries/rush-lib/src/api/test/publishConfig/custom-provider/package.json new file mode 100644 index 00000000000..6c93fa392d5 --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/custom-provider/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-custom-provider", + "version": "1.0.0" +} diff --git a/libraries/rush-lib/src/api/test/publishConfig/merged/config/rig.json b/libraries/rush-lib/src/api/test/publishConfig/merged/config/rig.json new file mode 100644 index 00000000000..1b4d8cbdef4 --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/merged/config/rig.json @@ -0,0 +1,3 @@ +{ + "rigPackageName": "test-publish-rig" +} diff --git a/libraries/rush-lib/src/api/test/publishConfig/merged/config/rush-publish.json b/libraries/rush-lib/src/api/test/publishConfig/merged/config/rush-publish.json new file mode 100644 index 00000000000..0a4714d639f --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/merged/config/rush-publish.json @@ -0,0 +1,7 @@ +{ + "providers": { + "vsix": { + "vsixPathPattern": "dist/custom/my-ext.vsix" + } + } +} diff --git a/libraries/rush-lib/src/api/test/publishConfig/merged/package.json b/libraries/rush-lib/src/api/test/publishConfig/merged/package.json new file mode 100644 index 00000000000..8412bd2e67c --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/merged/package.json @@ -0,0 +1,7 @@ +{ + "name": "merged-test", + "version": "1.0.0", + "dependencies": { + "test-publish-rig": "1.0.0" + } +} diff --git a/libraries/rush-lib/src/api/test/publishConfig/no-config/package.json b/libraries/rush-lib/src/api/test/publishConfig/no-config/package.json new file mode 100644 index 00000000000..7fed8e0e1cc --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/no-config/package.json @@ -0,0 +1,4 @@ +{ + "name": "no-config-test", + "version": "1.0.0" +} diff --git a/libraries/rush-lib/src/api/test/publishConfig/npm-valid/config/rush-publish.json b/libraries/rush-lib/src/api/test/publishConfig/npm-valid/config/rush-publish.json new file mode 100644 index 00000000000..8bda97861ba --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/npm-valid/config/rush-publish.json @@ -0,0 +1,10 @@ +{ + "providers": { + "npm": { + "registryUrl": "https://registry.npmjs.org", + "npmAuthToken": "test-token", + "tag": "latest", + "access": "public" + } + } +} diff --git a/libraries/rush-lib/src/api/test/publishConfig/npm-valid/package.json b/libraries/rush-lib/src/api/test/publishConfig/npm-valid/package.json new file mode 100644 index 00000000000..95ea81d069c --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/npm-valid/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-npm-valid", + "version": "1.0.0" +} diff --git a/libraries/rush-lib/src/api/test/publishConfig/project-only/config/rush-publish.json b/libraries/rush-lib/src/api/test/publishConfig/project-only/config/rush-publish.json new file mode 100644 index 00000000000..f4a6f76db8b --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/project-only/config/rush-publish.json @@ -0,0 +1,7 @@ +{ + "providers": { + "npm": { + "registryUrl": "https://registry.npmjs.org" + } + } +} diff --git a/libraries/rush-lib/src/api/test/publishConfig/project-only/package.json b/libraries/rush-lib/src/api/test/publishConfig/project-only/package.json new file mode 100644 index 00000000000..53eac7739f1 --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/project-only/package.json @@ -0,0 +1,4 @@ +{ + "name": "project-only-test", + "version": "1.0.0" +} diff --git a/libraries/rush-lib/src/api/test/publishConfig/rig-only/config/rig.json b/libraries/rush-lib/src/api/test/publishConfig/rig-only/config/rig.json new file mode 100644 index 00000000000..1b4d8cbdef4 --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/rig-only/config/rig.json @@ -0,0 +1,3 @@ +{ + "rigPackageName": "test-publish-rig" +} diff --git a/libraries/rush-lib/src/api/test/publishConfig/rig-only/package.json b/libraries/rush-lib/src/api/test/publishConfig/rig-only/package.json new file mode 100644 index 00000000000..b00c84a8142 --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/rig-only/package.json @@ -0,0 +1,7 @@ +{ + "name": "rig-only-test", + "version": "1.0.0", + "dependencies": { + "test-publish-rig": "1.0.0" + } +} diff --git a/libraries/rush-lib/src/api/test/publishConfig/vsix-valid/config/rush-publish.json b/libraries/rush-lib/src/api/test/publishConfig/vsix-valid/config/rush-publish.json new file mode 100644 index 00000000000..c13968cc3c3 --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/vsix-valid/config/rush-publish.json @@ -0,0 +1,8 @@ +{ + "providers": { + "vsix": { + "vsixPathPattern": "dist/vsix/extension.vsix", + "useAzureCredential": true + } + } +} diff --git a/libraries/rush-lib/src/api/test/publishConfig/vsix-valid/package.json b/libraries/rush-lib/src/api/test/publishConfig/vsix-valid/package.json new file mode 100644 index 00000000000..299075b7914 --- /dev/null +++ b/libraries/rush-lib/src/api/test/publishConfig/vsix-valid/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-vsix-valid", + "version": "1.0.0" +} diff --git a/libraries/rush-lib/src/api/test/repo/common/config/rush/version-policies.json b/libraries/rush-lib/src/api/test/repo/common/config/rush/version-policies.json index ad3bf99e03d..ed7b1efc36e 100644 --- a/libraries/rush-lib/src/api/test/repo/common/config/rush/version-policies.json +++ b/libraries/rush-lib/src/api/test/repo/common/config/rush/version-policies.json @@ -4,5 +4,9 @@ "policyName": "testPolicy", "version": "1.0.0", "nextBump": "minor" + }, + { + "definitionName": "individualVersion", + "policyName": "testIndividualPolicy" } ] diff --git a/libraries/rush-lib/src/cli/actions/PublishAction.ts b/libraries/rush-lib/src/cli/actions/PublishAction.ts index cb05204508f..b386a33b673 100644 --- a/libraries/rush-lib/src/cli/actions/PublishAction.ts +++ b/libraries/rush-lib/src/cli/actions/PublishAction.ts @@ -3,33 +3,37 @@ import * as path from 'node:path'; -import * as semver from 'semver'; - import type { CommandLineFlagParameter, CommandLineStringParameter, CommandLineChoiceParameter } from '@rushstack/ts-command-line'; import { FileSystem } from '@rushstack/node-core-library'; -import { Colorize } from '@rushstack/terminal'; +import { RigConfig } from '@rushstack/rig-package'; +import { Colorize, ConsoleTerminalProvider, Terminal } from '@rushstack/terminal'; import { type IChangeInfo, ChangeType } from '../../api/ChangeManagement'; +import { + type IRushPublishJson, + type IRushPublishProviderEntry, + RUSH_PUBLISH_CONFIGURATION_FILE +} from '../../api/PublishConfiguration'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; -import { Npm } from '../../utilities/Npm'; import type { RushCommandLineParser } from '../RushCommandLineParser'; -import { PublishUtilities } from '../../logic/PublishUtilities'; import { ChangelogGenerator } from '../../logic/ChangelogGenerator'; import { PrereleaseToken } from '../../logic/PrereleaseToken'; import { ChangeManager } from '../../logic/ChangeManager'; import { BaseRushAction } from './BaseRushAction'; import { PublishGit } from '../../logic/PublishGit'; import * as PolicyValidator from '../../logic/policy/PolicyValidator'; +import type { IPublishCommand } from '../../pluginFramework/RushLifeCycle'; +import type { IPublishProvider } from '../../pluginFramework/IPublishProvider'; +import { Logger } from '../../pluginFramework/logging/Logger'; import type { VersionPolicy } from '../../api/VersionPolicy'; import { DEFAULT_PACKAGE_UPDATE_MESSAGE } from './VersionAction'; import { Utilities } from '../../utilities/Utilities'; import { Git } from '../../logic/Git'; import { RushConstants } from '../../logic/RushConstants'; -import { IS_WINDOWS } from '../../utilities/executionUtilities'; export class PublishAction extends BaseRushAction { private readonly _addCommitDetails: CommandLineFlagParameter; @@ -56,7 +60,8 @@ export class PublishAction extends BaseRushAction { private _prereleaseToken!: PrereleaseToken; private _hotfixTagOverride!: string; private _targetNpmrcPublishFolder!: string; - private _targetNpmrcPublishPath!: string; + private readonly _publishConfigCache: Map = new Map(); + private readonly _providerCache: Map = new Map(); public constructor(parser: RushCommandLineParser) { super({ @@ -227,9 +232,6 @@ export class PublishAction extends BaseRushAction { // Example: "common\temp\publish-home" this._targetNpmrcPublishFolder = path.join(this.rushConfiguration.commonTempFolder, 'publish-home'); - // Example: "common\temp\publish-home\.npmrc" - this._targetNpmrcPublishPath = path.join(this._targetNpmrcPublishFolder, '.npmrc'); - const allPackages: ReadonlyMap = this.rushConfiguration.projectsByName; if (this._regenerateChangelogs.value) { @@ -243,6 +245,18 @@ export class PublishAction extends BaseRushAction { this._addNpmPublishHome(this.rushConfiguration.isPnpm); + const dryRun: boolean = !this._publish.value; + const publishCommand: IPublishCommand = { + actionName: this.actionName, + dryRun + }; + + const { hooks: sessionHooks } = this.rushSession; + + if (sessionHooks.beforePublish.isUsed()) { + await sessionHooks.beforePublish.promise(publishCommand); + } + const git: Git = new Git(this.rushConfiguration); const publishGit: PublishGit = new PublishGit(git, this._targetBranch.value); if (this._includeAll.value) { @@ -256,6 +270,10 @@ export class PublishAction extends BaseRushAction { await this._publishChangesAsync(git, publishGit, allPackages); } + if (sessionHooks.afterPublish.isUsed()) { + await sessionHooks.afterPublish.promise(publishCommand); + } + // eslint-disable-next-line no-console console.log('\n' + Colorize.green('Rush publish finished successfully.')); } @@ -321,17 +339,12 @@ export class PublishAction extends BaseRushAction { } } - // npm publish the things that need publishing. + // Publish projects via their registered publish providers. for (const change of orderedChanges) { if (change.changeType && change.changeType > ChangeType.dependency) { const project: RushConfigurationProject | undefined = allPackages.get(change.packageName); if (project) { - if (!(await this._packageExistsAsync(project))) { - await this._npmPublishAsync(change.packageName, project.publishFolder); - } else { - // eslint-disable-next-line no-console - console.log(`Skip ${change.packageName}. Package exists.`); - } + await this._publishProjectViaProvidersAsync(project); } else { // eslint-disable-next-line no-console console.log(`Skip ${change.packageName}. Failed to find its project.`); @@ -399,16 +412,17 @@ export class PublishAction extends BaseRushAction { }; if (this._pack.value) { - // packs to tarball instead of publishing to NPM repository - await this._npmPackAsync(packageName, packageConfig); + // packs to distributable artifacts via publish providers + await this._packProjectViaProvidersAsync(packageConfig); await applyTagAsync(this._applyGitTagsOnPack.value); - } else if (this._force.value || !(await this._packageExistsAsync(packageConfig))) { - // Publish to npm repository - await this._npmPublishAsync(packageName, packageConfig.publishFolder); - await applyTagAsync(true); } else { - // eslint-disable-next-line no-console - console.log(`Skip ${packageName}. Not updated.`); + const published: boolean = await this._publishProjectViaProvidersAsync(packageConfig); + if (published) { + await applyTagAsync(true); + } else { + // eslint-disable-next-line no-console + console.log(`Skip ${packageName}. Not updated.`); + } } } } @@ -436,128 +450,108 @@ export class PublishAction extends BaseRushAction { } } - private async _npmPublishAsync(packageName: string, packagePath: string): Promise { - const env: { [key: string]: string | undefined } = PublishUtilities.getEnvArgs(); - const args: string[] = ['publish']; - - if (this.rushConfiguration.projectsByName.get(packageName)!.shouldPublish) { - this._addSharedNpmConfig(env, args); - - if (this._npmTag.value) { - args.push(`--tag`, this._npmTag.value); - } else if (this._hotfixTagOverride) { - args.push(`--tag`, this._hotfixTagOverride); - } + /** + * Publish a project to all of its registered publish targets. + * Returns true if at least one target was published. + */ + private async _publishProjectViaProvidersAsync(project: RushConfigurationProject): Promise { + let published: boolean = false; + const version: string = project.packageJsonEditor.version; + const tag: string | undefined = this._npmTag.value || this._hotfixTagOverride || undefined; + const dryRun: boolean = !this._publish.value; + const logger: Logger = new Logger({ + loggerName: 'publish', + terminalProvider: new ConsoleTerminalProvider({ verboseEnabled: false }), + getShouldPrintStacks: () => false + }); - if (this._force.value) { - args.push(`--force`); - } + const publishTargets: string[] = await this._getPublishTargetsAsync(project); + this._validatePublishConfigAsync(project, publishTargets); - if (this._npmAccessLevel.value) { - args.push(`--access`, this._npmAccessLevel.value); - } + for (const target of publishTargets) { + const provider: IPublishProvider = await this._getProviderAsync(target, project.packageName); + const providerConfig: Record | undefined = await this._getProviderConfigAsync( + project, + target + ); - if (this.rushConfiguration.isPnpm) { - // PNPM 4.11.0 introduced a feature that may interrupt publishing and prompt the user for input. - // See this issue for details: https://github.com/microsoft/rushstack/issues/1940 - args.push('--no-git-checks'); + // Check if the version already exists at this target + if (!this._force.value && (await provider.checkExistsAsync({ project, version, providerConfig }))) { + // eslint-disable-next-line no-console + console.log(`Skip ${project.packageName}@${version} for target "${target}". Already exists.`); + continue; } - // TODO: Yarn's "publish" command line is fairly different from NPM and PNPM. The right thing to do here - // would be to remap our options to the Yarn equivalents. But until we get around to that, we'll simply invoke - // whatever NPM binary happens to be installed in the global path. - const packageManagerToolFilename: string = - this.rushConfiguration.packageManager === 'yarn' - ? 'npm' - : this.rushConfiguration.packageManagerToolFilename; - - // If the auth token was specified via the command line, avoid printing it on the console - const secretSubstring: string | undefined = this._npmAuthToken.value; - - await PublishUtilities.execCommandAsync({ - shouldExecute: this._publish.value, - command: packageManagerToolFilename, - args, - workingDirectory: packagePath, - environment: env, - secretSubstring + await provider.publishAsync({ + projects: [ + { + project, + newVersion: version, + previousVersion: version, + changeType: ChangeType.none, + providerConfig + } + ], + tag, + dryRun, + logger }); + published = true; } - } - - private async _packageExistsAsync(packageConfig: RushConfigurationProject): Promise { - const env: { [key: string]: string | undefined } = PublishUtilities.getEnvArgs(); - const args: string[] = []; - this._addSharedNpmConfig(env, args); - const publishedVersions: string[] = await Npm.getPublishedVersionsAsync( - packageConfig.packageName, - packageConfig.publishFolder, - env, - args - ); - - const packageVersion: string = packageConfig.packageJsonEditor.version; + return published; + } - // SemVer supports an obscure (and generally deprecated) feature where "build metadata" can be - // appended to a version. For example if our version is "1.2.3-beta.4+extra567", then "+extra567" is the - // build metadata part. The suffix has no effect on version comparisons and is mostly ignored by - // the NPM registry. Importantly, the queried version number will not include it, so we need to discard - // it before comparing against the list of already published versions. - const parsedVersion: semver.SemVer | null = semver.parse(packageVersion); - if (!parsedVersion) { - throw new Error(`The package "${packageConfig.packageName}" has an invalid "version" value`); - } + /** + * Pack a project via all of its registered publish targets. + * Returns true if at least one target produced an artifact. + */ + private async _packProjectViaProvidersAsync(project: RushConfigurationProject): Promise { + let packed: boolean = false; + const version: string = project.packageJsonEditor.version; + const dryRun: boolean = !this._publish.value; + const logger: Logger = new Logger({ + loggerName: 'publish', + terminalProvider: new ConsoleTerminalProvider({ verboseEnabled: false }), + getShouldPrintStacks: () => false + }); - // For example, normalize "1.2.3-beta.4+extra567" -->"1.2.3-beta.4". - // - // This is redundant in the current API, but might change in the future: - // https://github.com/npm/node-semver/issues/264 - parsedVersion.build = []; - const normalizedVersion: string = parsedVersion.format(); + // Determine the release folder + const releaseFolder: string = this._releaseFolder.value + ? this._releaseFolder.value + : path.join(this.rushConfiguration.commonTempFolder, 'artifacts', 'packages'); - return publishedVersions.indexOf(normalizedVersion) >= 0; - } + // Ensure the release folder exists + FileSystem.ensureFolder(releaseFolder); - private async _npmPackAsync(packageName: string, project: RushConfigurationProject): Promise { - const args: string[] = ['pack']; - const env: { [key: string]: string | undefined } = PublishUtilities.getEnvArgs(); + const publishTargets: string[] = await this._getPublishTargetsAsync(project); + this._validatePublishConfigAsync(project, publishTargets); - await PublishUtilities.execCommandAsync({ - shouldExecute: this._publish.value, - command: this.rushConfiguration.packageManagerToolFilename, - args, - workingDirectory: project.publishFolder, - environment: env - }); + for (const target of publishTargets) { + const provider: IPublishProvider = await this._getProviderAsync(target, project.packageName); + const providerConfig: Record | undefined = await this._getProviderConfigAsync( + project, + target + ); - if (this._publish.value) { - // Copy the tarball the release folder - const tarballName: string = this._calculateTarballName(project); - const tarballPath: string = path.join(project.publishFolder, tarballName); - const destFolder: string = this._releaseFolder.value - ? this._releaseFolder.value - : path.join(this.rushConfiguration.commonTempFolder, 'artifacts', 'packages'); - - FileSystem.move({ - sourcePath: tarballPath, - destinationPath: path.join(destFolder, tarballName), - overwrite: true + await provider.packAsync({ + projects: [ + { + project, + newVersion: version, + previousVersion: version, + changeType: ChangeType.none, + providerConfig + } + ], + releaseFolder, + dryRun, + logger }); + packed = true; } - } - - private _calculateTarballName(project: RushConfigurationProject): string { - // Same logic as how npm forms the tarball name - const packageName: string = project.packageName; - const name: string = packageName[0] === '@' ? packageName.substr(1).replace(/\//g, '-') : packageName; - if (this.rushConfiguration.packageManager === 'yarn') { - // yarn tarballs have a "v" before the version number - return `${name}-v${project.packageJson.version}.tgz`; - } else { - return `${name}-${project.packageJson.version}.tgz`; - } + return packed; } private _setDependenciesBeforePublish(): void { @@ -584,6 +578,133 @@ export class PublishAction extends BaseRushAction { } } + /** + * Load and cache the riggable config/rush-publish.json for a given project. + */ + private async _loadPublishConfigAsync( + project: RushConfigurationProject + ): Promise { + const cached: IRushPublishJson | undefined | null = this._publishConfigCache.get(project.packageName); + if (cached !== undefined) { + // Cached result: null means we tried loading but the file doesn't exist + return cached ?? undefined; + } + + const terminal: Terminal = new Terminal(new ConsoleTerminalProvider({ verboseEnabled: false })); + const rigConfig: RigConfig = await RigConfig.loadForProjectFolderAsync({ + projectFolderPath: project.projectFolder + }); + + const publishJson: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + project.projectFolder, + rigConfig + ); + + // Store null for "not found" to distinguish from "not yet loaded" + this._publishConfigCache.set(project.packageName, publishJson ?? undefined); + return publishJson; + } + + /** + * Derive the publish targets for a project from its config/rush-publish.json. + * - If no config file exists, returns ['npm'] for backward compatibility. + * - If config exists but providers is absent or empty, returns [] (version-only mode). + * - If config exists with providers, returns Object.keys(providers). + */ + private async _getPublishTargetsAsync(project: RushConfigurationProject): Promise { + const publishJson: IRushPublishJson | undefined = await this._loadPublishConfigAsync(project); + if (!publishJson) { + // No config file: default to npm for backward compatibility + return ['npm']; + } + const providers: Record> | undefined = publishJson.providers; + if (!providers || Object.keys(providers).length === 0) { + // Config exists but no providers: version-only mode + return []; + } + return Object.keys(providers); + } + + /** + * Validate publish configuration for a project before publishing or packing. + */ + private _validatePublishConfigAsync(project: RushConfigurationProject, publishTargets: string[]): void { + // Validate: private:true is only invalid when publishTargets includes 'npm' + if (project.shouldPublish && project.packageJson.private && publishTargets.includes('npm')) { + throw new Error( + `The project "${project.packageName}" specifies "shouldPublish": true with ` + + `publish targets including "npm", but the package.json file specifies "private": true. ` + + `Either remove "shouldPublish" or configure a non-npm provider in config/rush-publish.json.` + ); + } + + // Validate: version-only (empty providers) is incompatible with lockstep version policies + if (publishTargets.length === 0 && project.versionPolicyName) { + const policy: VersionPolicy | undefined = + this.rushConfiguration.versionPolicyConfiguration.getVersionPolicy(project.versionPolicyName); + if (policy && policy.isLockstepped) { + throw new Error( + `The project "${project.packageName}" has no publish targets (version-only mode via ` + + `config/rush-publish.json) but uses the lockstep version policy "${project.versionPolicyName}". ` + + `Version-only mode is incompatible with lockstep version policies.` + ); + } + } + } + + /** + * Get or create a publish provider for the given target name. + */ + private async _getProviderAsync(targetName: string, packageName: string): Promise { + let provider: IPublishProvider | undefined = this._providerCache.get(targetName); + if (!provider) { + const factory: (() => Promise) | undefined = + this.rushSession.getPublishProviderFactory(targetName); + if (!factory) { + throw new Error( + `No publish provider registered for target "${targetName}". ` + + `Project "${packageName}" has a "${targetName}" provider in config/rush-publish.json ` + + `but no plugin has registered a provider for it.` + ); + } + provider = await factory(); + this._providerCache.set(targetName, provider); + } + return provider; + } + + /** + * Get the provider config for a project+target, merging CLI flag overrides for npm. + */ + private async _getProviderConfigAsync( + project: RushConfigurationProject, + targetName: string + ): Promise | undefined> { + const publishJson: IRushPublishJson | undefined = await this._loadPublishConfigAsync(project); + const baseConfig: Record | undefined = publishJson?.providers?.[targetName]; + + // For npm target, merge CLI flag overrides on top of config/rush-publish.json values + if (targetName === 'npm') { + const cliOverrides: Record = {}; + if (this._registryUrl.value) { + cliOverrides.registryUrl = this._registryUrl.value; + } + if (this._npmAuthToken.value) { + cliOverrides.npmAuthToken = this._npmAuthToken.value; + } + if (this._npmAccessLevel.value) { + cliOverrides.access = this._npmAccessLevel.value; + } + if (Object.keys(cliOverrides).length > 0) { + return { ...baseConfig, ...cliOverrides }; + } + } + + return baseConfig; + } + private _addNpmPublishHome(supportEnvVarFallbackSyntax: boolean): void { // Create "common\temp\publish-home" folder, if it doesn't exist Utilities.createFolderWithRetry(this._targetNpmrcPublishFolder); @@ -596,26 +717,4 @@ export class PublishAction extends BaseRushAction { supportEnvVarFallbackSyntax }); } - - private _addSharedNpmConfig(env: { [key: string]: string | undefined }, args: string[]): void { - const userHomeEnvVariable: string = IS_WINDOWS ? 'USERPROFILE' : 'HOME'; - let registry: string = '//registry.npmjs.org/'; - - // Check if .npmrc file exists in "common\temp\publish-home" - if (FileSystem.exists(this._targetNpmrcPublishPath)) { - // Redirect userHomeEnvVariable, NPM will use config in "common\temp\publish-home\.npmrc" - env[userHomeEnvVariable] = this._targetNpmrcPublishFolder; - } - - // Check if registryUrl and token are specified via command-line - if (this._registryUrl.value) { - const registryUrl: string = this._registryUrl.value; - env['npm_config_registry'] = registryUrl; // eslint-disable-line dot-notation - registry = registryUrl.substring(registryUrl.indexOf('//')); - } - - if (this._npmAuthToken.value) { - args.push(`--${registry}:_authToken=${this._npmAuthToken.value}`); - } - } } diff --git a/libraries/rush-lib/src/cli/test/PublishTargetDerivation.test.ts b/libraries/rush-lib/src/cli/test/PublishTargetDerivation.test.ts new file mode 100644 index 00000000000..4cc76cbc9f3 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/PublishTargetDerivation.test.ts @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; +import { RigConfig } from '@rushstack/rig-package'; + +import { RUSH_PUBLISH_CONFIGURATION_FILE, type IRushPublishJson } from '../../api/PublishConfiguration'; + +/** + * Mirrors the target derivation logic from PublishAction._getPublishTargetsAsync. + * Extracted here for testability. + */ +function derivePublishTargets(publishJson: IRushPublishJson | undefined): string[] { + if (!publishJson) { + return ['npm']; + } + const providers: Record> | undefined = publishJson.providers; + if (!providers || Object.keys(providers).length === 0) { + return []; + } + return Object.keys(providers); +} + +/** + * Mirrors the validation logic from PublishAction._validatePublishConfigAsync. + * Extracted here for testability. + */ +function validatePublishConfig( + packageName: string, + shouldPublish: boolean, + isPrivate: boolean, + publishTargets: string[], + versionPolicyName: string | undefined, + isLockstepped: boolean +): void { + if (shouldPublish && isPrivate && publishTargets.includes('npm')) { + throw new Error( + `The project "${packageName}" specifies "shouldPublish": true with ` + + `publish targets including "npm", but the package.json file specifies "private": true. ` + + `Either remove "shouldPublish" or configure a non-npm provider in config/rush-publish.json.` + ); + } + + if (publishTargets.length === 0 && versionPolicyName && isLockstepped) { + throw new Error( + `The project "${packageName}" has no publish targets (version-only mode via ` + + `config/rush-publish.json) but uses the lockstep version policy "${versionPolicyName}". ` + + `Version-only mode is incompatible with lockstep version policies.` + ); + } +} + +const PUBLISH_CONFIG_DIR: string = path.resolve(__dirname, '../../api/test/publishConfig'); + +describe('Publish target derivation', () => { + let terminal: Terminal; + + beforeEach(() => { + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(false); + terminal = new Terminal(terminalProvider); + }); + + it('defaults to ["npm"] when no rush-publish.json exists', async () => { + const projectFolder: string = path.resolve(PUBLISH_CONFIG_DIR, 'no-config'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + const targets: string[] = derivePublishTargets(config); + expect(targets).toEqual(['npm']); + }); + + it('derives ["vsix"] from rush-publish.json with vsix provider', async () => { + const projectFolder: string = path.resolve(PUBLISH_CONFIG_DIR, 'vsix-valid'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + const targets: string[] = derivePublishTargets(config); + expect(targets).toEqual(['vsix']); + }); + + it('derives ["npm"] from rush-publish.json with npm provider', async () => { + const projectFolder: string = path.resolve(PUBLISH_CONFIG_DIR, 'npm-valid'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + const targets: string[] = derivePublishTargets(config); + expect(targets).toEqual(['npm']); + }); + + it('derives multiple targets from rush-publish.json with multiple providers', () => { + // Test with a synthetic config that has multiple providers + const config: IRushPublishJson = { + providers: { + npm: { registryUrl: 'https://registry.npmjs.org' }, + vsix: { vsixPathPattern: 'dist/vsix/extension.vsix' } + } + }; + + const targets: string[] = derivePublishTargets(config); + expect(targets).toEqual(['npm', 'vsix']); + }); + + it('returns [] (version-only) when rush-publish.json has empty providers', () => { + const config: IRushPublishJson = { providers: {} }; + const targets: string[] = derivePublishTargets(config); + expect(targets).toEqual([]); + }); + + it('returns [] (version-only) when rush-publish.json has no providers property', () => { + const config: IRushPublishJson = {}; + const targets: string[] = derivePublishTargets(config); + expect(targets).toEqual([]); + }); +}); + +describe('Publish config validation', () => { + it('throws when shouldPublish:true + private:true + targets include npm', () => { + expect(() => { + validatePublishConfig('my-package', true, true, ['npm'], undefined, false); + }).toThrow(/specifies "shouldPublish": true.*publish targets including "npm".*"private": true/); + }); + + it('does NOT throw when shouldPublish:true + private:true + targets are ["vsix"]', () => { + expect(() => { + validatePublishConfig('my-package', true, true, ['vsix'], undefined, false); + }).not.toThrow(); + }); + + it('throws when version-only mode + lockstep version policy', () => { + expect(() => { + validatePublishConfig('my-package', true, false, [], 'lockstepPolicy', true); + }).toThrow(/incompatible with lockstep version policies/); + }); + + it('does NOT throw when version-only mode + individual version policy', () => { + expect(() => { + validatePublishConfig('my-package', true, false, [], 'individualPolicy', false); + }).not.toThrow(); + }); + + it('error messages reference config/rush-publish.json', () => { + expect(() => { + validatePublishConfig('my-package', true, true, ['npm'], undefined, false); + }).toThrow(/config\/rush-publish\.json/); + + expect(() => { + validatePublishConfig('my-package', true, false, [], 'lockstepPolicy', true); + }).toThrow(/config\/rush-publish\.json/); + }); +}); + +describe('Rig inheritance for rush-publish.json', () => { + let terminal: Terminal; + + beforeEach(() => { + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(false); + terminal = new Terminal(terminalProvider); + }); + + it('inherits vsix provider config from rig', async () => { + const projectFolder: string = path.resolve(PUBLISH_CONFIG_DIR, 'rig-only'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + expect(config).toBeDefined(); + expect(config!.providers!.vsix).toMatchObject({ + vsixPathPattern: 'dist/vsix/extension.vsix', + useAzureCredential: true + }); + }); + + it('project can override rig defaults', async () => { + const projectFolder: string = path.resolve(PUBLISH_CONFIG_DIR, 'merged'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + expect(config).toBeDefined(); + // Child overrides vsix provider + expect(config!.providers!.vsix).toMatchObject({ + vsixPathPattern: 'dist/custom/my-ext.vsix' + }); + }); + + it('child provider sections replace parent at provider level (shallow merge)', async () => { + const projectFolder: string = path.resolve(PUBLISH_CONFIG_DIR, 'merged'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + expect(config).toBeDefined(); + // The child's vsix provider replaces the parent's vsix provider at the provider key level + expect(config!.providers!.vsix).toMatchObject({ + vsixPathPattern: 'dist/custom/my-ext.vsix' + }); + }); + + it('parent provider sections not mentioned by child may be inherited via custom merge', async () => { + const projectFolder: string = path.resolve(PUBLISH_CONFIG_DIR, 'merged'); + const rigConfig: RigConfig = RigConfig.loadForProjectFolder({ + projectFolderPath: projectFolder, + bypassCache: true + }); + + const config: IRushPublishJson | undefined = + await RUSH_PUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + terminal, + projectFolder, + rigConfig + ); + + expect(config).toBeDefined(); + // The child's providers override the parent's at the providers level + // Whether parent keys are inherited depends on the config framework's rig resolution + const providerKeys: string[] = Object.keys(config!.providers!); + expect(providerKeys).toContain('vsix'); + // The child config specified vsix, so it's always present + expect(config!.providers!.vsix).toMatchObject({ + vsixPathPattern: 'dist/custom/my-ext.vsix' + }); + }); +}); diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 88dfb89789e..f18c0f6a3f4 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -104,6 +104,8 @@ export { EventHooks, Event } from './api/EventHooks'; export { ChangeManager } from './api/ChangeManager'; +export { ChangeType } from './api/ChangeManagement'; + export { FlagFile as _FlagFile } from './api/FlagFile'; export { @@ -162,6 +164,7 @@ export { type IRushCommand, type IGlobalCommand, type IPhasedCommand, + type IPublishCommand, RushLifecycleHooks } from './pluginFramework/RushLifeCycle'; @@ -183,6 +186,15 @@ export type { ICobuildCompletedState } from './logic/cobuild/ICobuildLockProvider'; +export type { + IPublishProvider, + IPublishProjectInfo, + IPublishProviderPublishOptions, + IPublishProviderPackOptions, + IPublishProviderCheckExistsOptions, + PublishProviderFactory +} from './pluginFramework/IPublishProvider'; + export type { ITelemetryData, ITelemetryMachineInfo, ITelemetryOperationResult } from './logic/Telemetry'; export type { IStopwatchResult } from './utilities/Stopwatch'; diff --git a/libraries/rush-lib/src/pluginFramework/IPublishProvider.ts b/libraries/rush-lib/src/pluginFramework/IPublishProvider.ts new file mode 100644 index 00000000000..2742fd7459b --- /dev/null +++ b/libraries/rush-lib/src/pluginFramework/IPublishProvider.ts @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { RushConfigurationProject } from '../api/RushConfigurationProject'; +import type { ChangeType } from '../api/ChangeManagement'; +import type { ILogger } from './logging/Logger'; + +/** + * Information about a single project to be published by a publish provider. + * @beta + */ +export interface IPublishProjectInfo { + /** + * The Rush project configuration for this project. + */ + readonly project: RushConfigurationProject; + + /** + * The new version that has been assigned to this project. + */ + readonly newVersion: string; + + /** + * The previous version before the version bump. + */ + readonly previousVersion: string; + + /** + * The type of change (patch, minor, major, etc.) that triggered the version bump. + */ + readonly changeType: ChangeType; + + /** + * Provider-specific configuration from config/rush-publish.json for this project. + * This is the value of the `providers[targetName]` section. + */ + readonly providerConfig: Record | undefined; +} + +/** + * Options passed to {@link IPublishProvider.publishAsync}. + * @beta + */ +export interface IPublishProviderPublishOptions { + /** + * The set of projects to be published by this provider. + */ + readonly projects: ReadonlyArray; + + /** + * The distribution tag to use when publishing (e.g. 'latest', 'next'). + */ + readonly tag: string | undefined; + + /** + * If true, the provider should perform all steps except the actual publish, + * logging what would have been done. + */ + readonly dryRun: boolean; + + /** + * A logger instance for reporting progress and errors. + */ + readonly logger: ILogger; +} + +/** + * Options passed to {@link IPublishProvider.checkExistsAsync}. + * @beta + */ +export interface IPublishProviderCheckExistsOptions { + /** + * The Rush project to check. + */ + readonly project: RushConfigurationProject; + + /** + * The version to check for existence. + */ + readonly version: string; + + /** + * Provider-specific configuration from config/rush-publish.json for this project. + */ + readonly providerConfig: Record | undefined; +} + +/** + * Options passed to {@link IPublishProvider.packAsync}. + * @beta + */ +export interface IPublishProviderPackOptions { + /** + * The set of projects to pack. + */ + readonly projects: ReadonlyArray; + + /** + * The folder where packed artifacts should be placed. + * Corresponds to the `--release-folder` CLI parameter. + * When not specified, a default location is used + * (e.g., `/artifacts/packages`). + */ + readonly releaseFolder: string; + + /** + * If true, the provider should perform all steps except the actual pack, + * logging what would have been done. + */ + readonly dryRun: boolean; + + /** + * A logger instance for reporting progress and errors. + */ + readonly logger: ILogger; +} + +/** + * Interface for publish providers that handle publishing packages to a specific target + * (e.g. npm registry, VS Code Marketplace). + * + * @remarks + * Plugins implement this interface and register a factory via + * {@link RushSession.registerPublishProviderFactory}. + * + * @beta + */ +export interface IPublishProvider { + /** + * A human-readable name identifying the publish target (e.g. 'npm', 'vsix'). + */ + readonly providerName: string; + + /** + * Publishes the specified projects to this provider's target. + */ + publishAsync(options: IPublishProviderPublishOptions): Promise; + + /** + * Packs the specified projects into distributable artifacts for this provider's target. + * Each provider defines what "packing" means for its artifact type: + * - npm: runs ` pack` to produce a `.tgz` tarball + * - vsix: runs `vsce package` to produce a `.vsix` file + * + * Artifacts are written to the `releaseFolder` specified in options. + */ + packAsync(options: IPublishProviderPackOptions): Promise; + + /** + * Checks whether a specific version of a project already exists at the publish target. + * Returns true if the version is already published. + */ + checkExistsAsync(options: IPublishProviderCheckExistsOptions): Promise; +} + +/** + * A factory function that creates an {@link IPublishProvider} instance. + * + * @remarks + * Publish provider plugins register a factory of this type via + * {@link RushSession.registerPublishProviderFactory}. + * + * @beta + */ +export type PublishProviderFactory = () => Promise; diff --git a/libraries/rush-lib/src/pluginFramework/PluginManager.ts b/libraries/rush-lib/src/pluginFramework/PluginManager.ts index 9a5181e078c..8ba5cea064e 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginManager.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginManager.ts @@ -88,6 +88,8 @@ export class PluginManager { 'rush-azure-interactive-auth-plugin', '@rushstack/rush-azure-storage-build-cache-plugin' ); + tryAddBuiltInPlugin('rush-npm-publish-plugin'); + tryAddBuiltInPlugin('rush-vscode-publish-plugin'); this._builtInPluginLoaders = builtInPluginConfigurations.map((pluginConfiguration) => { return new BuiltInPluginLoader({ diff --git a/libraries/rush-lib/src/pluginFramework/RushLifeCycle.ts b/libraries/rush-lib/src/pluginFramework/RushLifeCycle.ts index 76e51d8e17d..3393610a9b1 100644 --- a/libraries/rush-lib/src/pluginFramework/RushLifeCycle.ts +++ b/libraries/rush-lib/src/pluginFramework/RushLifeCycle.ts @@ -45,6 +45,17 @@ export interface IPhasedCommand extends IRushCommand { readonly sessionAbortController: AbortController; } +/** + * Information about the currently executing publish command provided to plugins. + * @beta + */ +export interface IPublishCommand extends IRushCommand { + /** + * Whether the publish command is running in dry-run mode (--publish flag was NOT provided). + */ + readonly dryRun: boolean; +} + /** * Hooks into the lifecycle of the Rush process invocation that plugins may tap into. * @@ -104,6 +115,24 @@ export class RushLifecycleHooks { [command: IRushCommand, subspace: Subspace, variant: string | undefined] > = new AsyncSeriesHook(['command', 'subspace', 'variant'], 'afterInstall'); + /** + * The hook to run before the publish command begins dispatching to providers. + * Plugins can use this for setup, authentication, or validation. + */ + public readonly beforePublish: AsyncSeriesHook<[command: IPublishCommand]> = new AsyncSeriesHook( + ['command'], + 'beforePublish' + ); + + /** + * The hook to run after all publish providers have completed. + * Plugins can use this for cleanup or reporting. + */ + public readonly afterPublish: AsyncSeriesHook<[command: IPublishCommand]> = new AsyncSeriesHook( + ['command'], + 'afterPublish' + ); + /** * A hook to allow plugins to hook custom logic to process telemetry data. */ diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index 0e512764438..daa5d3d87b4 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -10,6 +10,7 @@ import type { IBuildCacheJson } from '../api/BuildCacheConfiguration'; import type { ICloudBuildCacheProvider } from '../logic/buildCache/ICloudBuildCacheProvider'; import type { ICobuildJson } from '../api/CobuildConfiguration'; import type { ICobuildLockProvider } from '../logic/cobuild/ICobuildLockProvider'; +import type { PublishProviderFactory } from './IPublishProvider'; /** * @beta @@ -40,6 +41,7 @@ export class RushSession { private readonly _options: IRushSessionOptions; private readonly _cloudBuildCacheProviderFactories: Map = new Map(); private readonly _cobuildLockProviderFactories: Map = new Map(); + private readonly _publishProviderFactories: Map = new Map(); public readonly hooks: RushLifecycleHooks; @@ -101,4 +103,15 @@ export class RushSession { ): CobuildLockProviderFactory | undefined { return this._cobuildLockProviderFactories.get(cobuildLockProviderName); } + + public registerPublishProviderFactory(publishTargetName: string, factory: PublishProviderFactory): void { + if (this._publishProviderFactories.has(publishTargetName)) { + throw new Error(`A publish provider factory for "${publishTargetName}" has already been registered`); + } + this._publishProviderFactories.set(publishTargetName, factory); + } + + public getPublishProviderFactory(publishTargetName: string): PublishProviderFactory | undefined { + return this._publishProviderFactories.get(publishTargetName); + } } diff --git a/libraries/rush-lib/src/pluginFramework/test/RushLifecycleHooks.test.ts b/libraries/rush-lib/src/pluginFramework/test/RushLifecycleHooks.test.ts new file mode 100644 index 00000000000..b80bfeb13c9 --- /dev/null +++ b/libraries/rush-lib/src/pluginFramework/test/RushLifecycleHooks.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { RushLifecycleHooks, type IPublishCommand } from '../RushLifeCycle'; + +describe(RushLifecycleHooks.name, () => { + describe('beforePublish', () => { + it('fires with the publish command payload', async () => { + const hooks: RushLifecycleHooks = new RushLifecycleHooks(); + const receivedPayloads: IPublishCommand[] = []; + + hooks.beforePublish.tapPromise('test', async (command: IPublishCommand) => { + receivedPayloads.push(command); + }); + + const command: IPublishCommand = { actionName: 'publish', dryRun: false }; + await hooks.beforePublish.promise(command); + + expect(receivedPayloads).toHaveLength(1); + expect(receivedPayloads[0]).toBe(command); + }); + + it('runs multiple taps in series order', async () => { + const hooks: RushLifecycleHooks = new RushLifecycleHooks(); + const callOrder: string[] = []; + + hooks.beforePublish.tapPromise('first', async () => { + callOrder.push('first'); + }); + hooks.beforePublish.tapPromise('second', async () => { + callOrder.push('second'); + }); + + await hooks.beforePublish.promise({ actionName: 'publish', dryRun: false }); + + expect(callOrder).toEqual(['first', 'second']); + }); + + it('passes dryRun flag correctly', async () => { + const hooks: RushLifecycleHooks = new RushLifecycleHooks(); + let receivedDryRun: boolean | undefined; + + hooks.beforePublish.tapPromise('test', async (command: IPublishCommand) => { + receivedDryRun = command.dryRun; + }); + + await hooks.beforePublish.promise({ actionName: 'publish', dryRun: true }); + + expect(receivedDryRun).toBe(true); + }); + }); + + describe('afterPublish', () => { + it('fires with the publish command payload', async () => { + const hooks: RushLifecycleHooks = new RushLifecycleHooks(); + const receivedPayloads: IPublishCommand[] = []; + + hooks.afterPublish.tapPromise('test', async (command: IPublishCommand) => { + receivedPayloads.push(command); + }); + + const command: IPublishCommand = { actionName: 'publish', dryRun: false }; + await hooks.afterPublish.promise(command); + + expect(receivedPayloads).toHaveLength(1); + expect(receivedPayloads[0]).toBe(command); + }); + + it('runs multiple taps in series order', async () => { + const hooks: RushLifecycleHooks = new RushLifecycleHooks(); + const callOrder: string[] = []; + + hooks.afterPublish.tapPromise('first', async () => { + callOrder.push('first'); + }); + hooks.afterPublish.tapPromise('second', async () => { + callOrder.push('second'); + }); + + await hooks.afterPublish.promise({ actionName: 'publish', dryRun: false }); + + expect(callOrder).toEqual(['first', 'second']); + }); + }); + + describe('hook ordering', () => { + it('beforePublish and afterPublish fire independently', async () => { + const hooks: RushLifecycleHooks = new RushLifecycleHooks(); + const callOrder: string[] = []; + + hooks.beforePublish.tapPromise('test', async () => { + callOrder.push('before'); + }); + hooks.afterPublish.tapPromise('test', async () => { + callOrder.push('after'); + }); + + const command: IPublishCommand = { actionName: 'publish', dryRun: false }; + await hooks.beforePublish.promise(command); + // Simulate publishing happening here + await hooks.afterPublish.promise(command); + + expect(callOrder).toEqual(['before', 'after']); + }); + + it('isUsed() returns false when no taps registered', () => { + const hooks: RushLifecycleHooks = new RushLifecycleHooks(); + + expect(hooks.beforePublish.isUsed()).toBe(false); + expect(hooks.afterPublish.isUsed()).toBe(false); + }); + + it('isUsed() returns true after tap', () => { + const hooks: RushLifecycleHooks = new RushLifecycleHooks(); + + hooks.beforePublish.tapPromise('test', async () => {}); + + expect(hooks.beforePublish.isUsed()).toBe(true); + expect(hooks.afterPublish.isUsed()).toBe(false); + }); + }); +}); diff --git a/libraries/rush-lib/src/pluginFramework/test/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/test/RushSession.test.ts new file mode 100644 index 00000000000..ae636069022 --- /dev/null +++ b/libraries/rush-lib/src/pluginFramework/test/RushSession.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { ConsoleTerminalProvider } from '@rushstack/terminal'; + +import { RushSession } from '../RushSession'; +import type { IPublishProvider, PublishProviderFactory } from '../IPublishProvider'; + +function createTestSession(): RushSession { + return new RushSession({ + terminalProvider: new ConsoleTerminalProvider(), + getIsDebugMode: () => false + }); +} + +function createMockFactory(providerName: string): PublishProviderFactory { + return async () => ({ + providerName, + publishAsync: async () => {}, + packAsync: async () => {}, + checkExistsAsync: async () => false + }); +} + +describe(RushSession.name, () => { + describe('publish provider factory registration', () => { + it('registers and retrieves a publish provider factory', async () => { + const session: RushSession = createTestSession(); + const factory: PublishProviderFactory = createMockFactory('npm'); + + session.registerPublishProviderFactory('npm', factory); + + const retrieved: PublishProviderFactory | undefined = session.getPublishProviderFactory('npm'); + expect(retrieved).toBe(factory); + + const provider: IPublishProvider = await retrieved!(); + expect(provider.providerName).toEqual('npm'); + }); + + it('throws on duplicate registration', () => { + const session: RushSession = createTestSession(); + const factory1: PublishProviderFactory = createMockFactory('npm'); + const factory2: PublishProviderFactory = createMockFactory('npm'); + + session.registerPublishProviderFactory('npm', factory1); + + expect(() => { + session.registerPublishProviderFactory('npm', factory2); + }).toThrow(/already been registered/); + }); + + it('returns undefined for unregistered target', () => { + const session: RushSession = createTestSession(); + + const factory: PublishProviderFactory | undefined = session.getPublishProviderFactory('nonexistent'); + expect(factory).toBeUndefined(); + }); + + it('supports multiple different publish targets', () => { + const session: RushSession = createTestSession(); + const npmFactory: PublishProviderFactory = createMockFactory('npm'); + const vsixFactory: PublishProviderFactory = createMockFactory('vsix'); + + session.registerPublishProviderFactory('npm', npmFactory); + session.registerPublishProviderFactory('vsix', vsixFactory); + + expect(session.getPublishProviderFactory('npm')).toBe(npmFactory); + expect(session.getPublishProviderFactory('vsix')).toBe(vsixFactory); + }); + }); +}); diff --git a/libraries/rush-lib/src/schemas/rush-publish.schema.json b/libraries/rush-lib/src/schemas/rush-publish.schema.json new file mode 100644 index 00000000000..c59367df81a --- /dev/null +++ b/libraries/rush-lib/src/schemas/rush-publish.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Rush Publish Configuration", + "description": "For use with the Rush tool, this file provides per-project configuration for publish providers. It is loaded from config/rush-publish.json and supports rig resolution. See http://rushjs.io for details.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "description": "Part of the JSON Schema standard, this optional keyword declares the URL of the schema that the file conforms to. Editors may download the schema and use it to perform syntax highlighting.", + "type": "string" + }, + "providers": { + "description": "An array of publish provider entries. Each entry specifies a provider name and an optional set of provider-specific configuration options.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { + "description": "The name of the publish provider (e.g. 'npm', 'vsix').", + "type": "string" + }, + "options": { + "description": "Provider-specific configuration options. The shape of this object is defined by the provider plugin.", + "type": "object" + } + } + } + } + } +} diff --git a/rigs/heft-vscode-extension-rig/profiles/default/config/rush-publish.json b/rigs/heft-vscode-extension-rig/profiles/default/config/rush-publish.json new file mode 100644 index 00000000000..c13968cc3c3 --- /dev/null +++ b/rigs/heft-vscode-extension-rig/profiles/default/config/rush-publish.json @@ -0,0 +1,8 @@ +{ + "providers": { + "vsix": { + "vsixPathPattern": "dist/vsix/extension.vsix", + "useAzureCredential": true + } + } +} diff --git a/rush-plugins/rush-npm-publish-plugin/config/rig.json b/rush-plugins/rush-npm-publish-plugin/config/rig.json new file mode 100644 index 00000000000..bf9de6a1799 --- /dev/null +++ b/rush-plugins/rush-npm-publish-plugin/config/rig.json @@ -0,0 +1,18 @@ +{ + // The "rig.json" file directs tools to look for their config files in an external package. + // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + /** + * (Required) The name of the rig package to inherit from. + * It should be an NPM package name with the "-rig" suffix. + */ + "rigPackageName": "local-node-rig", + + /** + * (Optional) Selects a config profile from the rig package. The name must consist of + * lowercase alphanumeric words separated by hyphens, for example "sample-profile". + * If omitted, then the "default" profile will be used." + */ + "rigProfile": "default" +} diff --git a/rush-plugins/rush-npm-publish-plugin/eslint.config.js b/rush-plugins/rush-npm-publish-plugin/eslint.config.js new file mode 100644 index 00000000000..c15e6077310 --- /dev/null +++ b/rush-plugins/rush-npm-publish-plugin/eslint.config.js @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeTrustedToolProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node-trusted-tool'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); + +module.exports = [ + ...nodeTrustedToolProfile, + ...friendlyLocalsMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname + } + } + } +]; diff --git a/rush-plugins/rush-npm-publish-plugin/package.json b/rush-plugins/rush-npm-publish-plugin/package.json new file mode 100644 index 00000000000..ccce8f8f3be --- /dev/null +++ b/rush-plugins/rush-npm-publish-plugin/package.json @@ -0,0 +1,34 @@ +{ + "name": "@rushstack/rush-npm-publish-plugin", + "version": "5.167.0", + "description": "Rush plugin for publishing packages to npm registry", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/rushstack", + "directory": "rush-plugins/rush-npm-publish-plugin" + }, + "homepage": "https://rushjs.io", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "license": "MIT", + "scripts": { + "build": "heft build --clean", + "start": "heft test-watch", + "test": "heft test", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "dependencies": { + "@rushstack/node-core-library": "workspace:*", + "@rushstack/rush-sdk": "workspace:*", + "semver": "~7.5.4" + }, + "devDependencies": { + "@microsoft/rush-lib": "workspace:*", + "@rushstack/heft": "workspace:*", + "@rushstack/terminal": "workspace:*", + "@types/semver": "7.5.0", + "eslint": "~9.37.0", + "local-node-rig": "workspace:*" + } +} diff --git a/rush-plugins/rush-npm-publish-plugin/rush-plugin-manifest.json b/rush-plugins/rush-npm-publish-plugin/rush-plugin-manifest.json new file mode 100644 index 00000000000..f625c13bd74 --- /dev/null +++ b/rush-plugins/rush-npm-publish-plugin/rush-plugin-manifest.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-plugin-manifest.schema.json", + "plugins": [ + { + "pluginName": "rush-npm-publish-plugin", + "description": "Rush plugin for publishing packages to the npm registry", + "entryPoint": "lib/index.js", + "associatedCommands": ["publish"] + } + ] +} diff --git a/rush-plugins/rush-npm-publish-plugin/src/NpmPublishProvider.ts b/rush-plugins/rush-npm-publish-plugin/src/NpmPublishProvider.ts new file mode 100644 index 00000000000..ea355460571 --- /dev/null +++ b/rush-plugins/rush-npm-publish-plugin/src/NpmPublishProvider.ts @@ -0,0 +1,309 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as childProcess from 'node:child_process'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +import * as semver from 'semver'; + +import { FileSystem } from '@rushstack/node-core-library'; +import type { + IPublishProvider, + IPublishProviderPublishOptions, + IPublishProviderPackOptions, + IPublishProviderCheckExistsOptions, + IPublishProjectInfo +} from '@rushstack/rush-sdk'; + +/** + * Configuration options for the npm publish provider, read from + * the `providers.npm` section of `config/rush-publish.json`. + */ +export interface INpmProviderConfig { + registryUrl?: string; + npmAuthToken?: string; + tag?: string; + access?: string; +} + +/** + * Publish provider that publishes packages to the npm registry. + * @public + */ +export class NpmPublishProvider implements IPublishProvider { + public readonly providerName: string = 'npm'; + + public async publishAsync(options: IPublishProviderPublishOptions): Promise { + const { projects, tag, dryRun, logger } = options; + + for (const projectInfo of projects) { + const { project, newVersion, providerConfig } = projectInfo; + const config: INpmProviderConfig = (providerConfig as INpmProviderConfig) || {}; + + const packageName: string = project.packageName; + const publishFolder: string = project.publishFolder; + + logger.terminal.writeLine(`Publishing ${packageName}@${newVersion} to npm...`); + + const env: Record = { ...process.env }; + const args: string[] = ['publish']; + + // Set up registry URL + let registryPrefix: string = '//registry.npmjs.org/'; + if (config.registryUrl) { + env.npm_config_registry = config.registryUrl; + registryPrefix = config.registryUrl.substring(config.registryUrl.indexOf('//')); + } + + // Set up auth token + if (config.npmAuthToken) { + args.push(`--${registryPrefix}:_authToken=${config.npmAuthToken}`); + } + + // Set up npm publish home for .npmrc-publish + this._configureNpmrcPublishHome(project.rushConfiguration, env); + + // Add tag + const effectiveTag: string | undefined = tag || config.tag; + if (effectiveTag) { + args.push('--tag', effectiveTag); + } + + // Add access level + if (config.access) { + args.push('--access', config.access); + } + + // For pnpm, add --no-git-checks + if (project.rushConfiguration.packageManager === 'pnpm') { + args.push('--no-git-checks'); + } + + // Determine the package manager binary + const packageManagerToolFilename: string = + project.rushConfiguration.packageManager === 'yarn' + ? 'npm' + : project.rushConfiguration.packageManagerToolFilename; + + if (dryRun) { + logger.terminal.writeLine( + ` [DRY RUN] Would execute: ${packageManagerToolFilename} ${args.join(' ')}` + ); + logger.terminal.writeLine(` Working directory: ${publishFolder}`); + } else { + await this._executeCommandAsync(packageManagerToolFilename, args, publishFolder, env); + logger.terminal.writeLine(` Successfully published ${packageName}@${newVersion}`); + } + } + } + + public async checkExistsAsync(options: IPublishProviderCheckExistsOptions): Promise { + const { project, version, providerConfig } = options; + const config: INpmProviderConfig = (providerConfig as INpmProviderConfig) || {}; + + const env: Record = { ...process.env }; + const args: string[] = []; + + // Set up registry URL + if (config.registryUrl) { + env.npm_config_registry = config.registryUrl; + } + + // Set up auth token + if (config.npmAuthToken) { + let registryPrefix: string = '//registry.npmjs.org/'; + if (config.registryUrl) { + registryPrefix = config.registryUrl.substring(config.registryUrl.indexOf('//')); + } + args.push(`--${registryPrefix}:_authToken=${config.npmAuthToken}`); + } + + // Set up npm publish home for .npmrc-publish + this._configureNpmrcPublishHome(project.rushConfiguration, env); + + const publishedVersions: string[] = await this._getPublishedVersionsAsync( + project.packageName, + project.publishFolder, + env, + args + ); + + const parsedVersion: semver.SemVer | null = semver.parse(version); + if (!parsedVersion) { + throw new Error(`The package "${project.packageName}" has an invalid version "${version}"`); + } + + // Normalize "1.2.3-beta.4+extra567" --> "1.2.3-beta.4" + parsedVersion.build = []; + const normalizedVersion: string = parsedVersion.format(); + + return publishedVersions.indexOf(normalizedVersion) >= 0; + } + + public async packAsync(options: IPublishProviderPackOptions): Promise { + const { projects, releaseFolder, dryRun, logger } = options; + + for (const projectInfo of projects) { + const { project, newVersion } = projectInfo; + const packageName: string = project.packageName; + const publishFolder: string = project.publishFolder; + + logger.terminal.writeLine(`Packing ${packageName}@${newVersion} as npm tarball...`); + + const args: string[] = ['pack']; + const env: Record = { ...process.env }; + const packageManagerToolFilename: string = project.rushConfiguration.packageManagerToolFilename; + + if (dryRun) { + logger.terminal.writeLine( + ` [DRY RUN] Would execute: ${packageManagerToolFilename} ${args.join(' ')}` + ); + logger.terminal.writeLine(` Working directory: ${publishFolder}`); + } else { + await this._executeCommandAsync(packageManagerToolFilename, args, publishFolder, env); + + // Move the tarball to the release folder + const tarballName: string = this._calculateTarballName(project); + const tarballPath: string = path.join(publishFolder, tarballName); + + FileSystem.move({ + sourcePath: tarballPath, + destinationPath: path.join(releaseFolder, tarballName), + overwrite: true + }); + + logger.terminal.writeLine(` Packed ${packageName}@${newVersion} to ${tarballName}`); + } + } + } + + /** + * Calculate the tarball filename using npm's naming convention. + */ + private _calculateTarballName(project: IPublishProjectInfo['project']): string { + const packageName: string = project.packageName; + const name: string = packageName[0] === '@' ? packageName.substring(1).replace(/\//g, '-') : packageName; + + if (project.rushConfiguration.packageManager === 'yarn') { + return `${name}-v${project.packageJson.version}.tgz`; + } else { + return `${name}-${project.packageJson.version}.tgz`; + } + } + + /** + * Configure the HOME directory to use .npmrc-publish from the Rush config. + */ + private _configureNpmrcPublishHome( + rushConfiguration: IPublishProjectInfo['project']['rushConfiguration'], + env: Record + ): void { + const publishHomeFolder: string = path.join(rushConfiguration.commonTempFolder, 'publish-home'); + const publishHomePath: string = path.join(publishHomeFolder, '.npmrc'); + + if (FileSystem.exists(publishHomePath)) { + const userHomeEnvVariable: string = os.platform() === 'win32' ? 'USERPROFILE' : 'HOME'; + env[userHomeEnvVariable] = publishHomeFolder; + } + } + + /** + * Get published versions of a package from the npm registry. + */ + private async _getPublishedVersionsAsync( + packageName: string, + workingDirectory: string, + env: Record, + extraArgs: string[] + ): Promise { + try { + // Use npm view to get published versions + const args: string[] = ['view', packageName, 'versions', '--json', ...extraArgs]; + const output: string = await this._captureCommandOutputAsync('npm', args, workingDirectory, env); + + const parsed: unknown = JSON.parse(output); + if (Array.isArray(parsed)) { + return parsed.filter((v): v is string => typeof v === 'string' && semver.valid(v) !== null); + } + if (typeof parsed === 'string' && semver.valid(parsed) !== null) { + return [parsed]; + } + return []; + } catch { + // Package doesn't exist on registry + return []; + } + } + + /** + * Execute a command as a child process. + */ + private async _executeCommandAsync( + command: string, + args: string[], + workingDirectory: string, + env: Record + ): Promise { + return new Promise((resolve, reject) => { + const child: childProcess.ChildProcess = childProcess.spawn(command, args, { + cwd: workingDirectory, + env: env as NodeJS.ProcessEnv, + stdio: 'inherit' + }); + + child.on('close', (code: number | null) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Command "${command} ${args.join(' ')}" exited with code ${code}`)); + } + }); + + child.on('error', (error: Error) => { + reject(error); + }); + }); + } + + /** + * Execute a command and capture its stdout output. + */ + private async _captureCommandOutputAsync( + command: string, + args: string[], + workingDirectory: string, + env: Record + ): Promise { + return new Promise((resolve, reject) => { + const child: childProcess.ChildProcess = childProcess.spawn(command, args, { + cwd: workingDirectory, + env: env as NodeJS.ProcessEnv, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout: string = ''; + let stderr: string = ''; + + child.stdout!.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + child.stderr!.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + child.on('close', (code: number | null) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(`Command "${command} ${args.join(' ')}" failed with code ${code}: ${stderr}`)); + } + }); + + child.on('error', (error: Error) => { + reject(error); + }); + }); + } +} diff --git a/rush-plugins/rush-npm-publish-plugin/src/RushNpmPublishPlugin.ts b/rush-plugins/rush-npm-publish-plugin/src/RushNpmPublishPlugin.ts new file mode 100644 index 00000000000..bf4e83eb193 --- /dev/null +++ b/rush-plugins/rush-npm-publish-plugin/src/RushNpmPublishPlugin.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IRushPlugin, RushSession, RushConfiguration } from '@rushstack/rush-sdk'; + +const PLUGIN_NAME: string = 'NpmPublishPlugin'; + +/** + * @public + */ +export class RushNpmPublishPlugin implements IRushPlugin { + public readonly pluginName: string = PLUGIN_NAME; + + public apply(rushSession: RushSession, rushConfiguration: RushConfiguration): void { + rushSession.hooks.initialize.tap(this.pluginName, () => { + rushSession.registerPublishProviderFactory('npm', async () => { + const { NpmPublishProvider } = await import('./NpmPublishProvider'); + return new NpmPublishProvider(); + }); + }); + } +} diff --git a/rush-plugins/rush-npm-publish-plugin/src/index.ts b/rush-plugins/rush-npm-publish-plugin/src/index.ts new file mode 100644 index 00000000000..9f22fe5f835 --- /dev/null +++ b/rush-plugins/rush-npm-publish-plugin/src/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { RushNpmPublishPlugin } from './RushNpmPublishPlugin'; + +export default RushNpmPublishPlugin; diff --git a/rush-plugins/rush-npm-publish-plugin/src/test/NpmPublishProvider.test.ts b/rush-plugins/rush-npm-publish-plugin/src/test/NpmPublishProvider.test.ts new file mode 100644 index 00000000000..3e10cb75262 --- /dev/null +++ b/rush-plugins/rush-npm-publish-plugin/src/test/NpmPublishProvider.test.ts @@ -0,0 +1,571 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Mock child_process.spawn +jest.mock('node:child_process', () => { + const actual: typeof import('node:child_process') = jest.requireActual('node:child_process'); + return { + ...actual, + spawn: jest.fn() + }; +}); + +// Mock FileSystem.exists and FileSystem.move +jest.mock('@rushstack/node-core-library', () => { + const actual: typeof import('@rushstack/node-core-library') = jest.requireActual( + '@rushstack/node-core-library' + ); + return { + ...actual, + FileSystem: { + ...actual.FileSystem, + exists: jest.fn().mockReturnValue(false), + move: jest.fn() + } + }; +}); + +import { EventEmitter } from 'node:events'; +import * as childProcess from 'node:child_process'; + +import { FileSystem } from '@rushstack/node-core-library'; +import type { + IPublishProviderPublishOptions, + IPublishProviderPackOptions, + IPublishProviderCheckExistsOptions, + IPublishProjectInfo +} from '@rushstack/rush-sdk'; + +import { NpmPublishProvider } from '../NpmPublishProvider'; + +interface IMockChildProcess extends EventEmitter { + stdin: EventEmitter; + stdout: EventEmitter; + stderr: EventEmitter; +} + +function createMockSpawnProcess(exitCode: number = 0, stdoutData?: string): IMockChildProcess { + const cp: IMockChildProcess = Object.assign(new EventEmitter(), { + stdin: new EventEmitter(), + stdout: new EventEmitter(), + stderr: new EventEmitter() + }); + + setTimeout(() => { + if (stdoutData) { + cp.stdout.emit('data', Buffer.from(stdoutData)); + } + cp.emit('close', exitCode); + }, 0); + + return cp; +} + +interface IMockProject { + packageName: string; + publishFolder: string; + packageJson: { + version: string; + }; + rushConfiguration: { + packageManager: string; + packageManagerToolFilename: string; + commonTempFolder: string; + }; +} + +function createMockProject(overrides?: Partial): IMockProject { + return { + packageName: '@scope/test-package', + publishFolder: '/fake/project/folder', + packageJson: { + version: '1.0.0' + }, + rushConfiguration: { + packageManager: 'pnpm', + packageManagerToolFilename: '/fake/pnpm', + commonTempFolder: '/fake/common/temp' + }, + ...overrides + }; +} + +interface IMockLogger { + terminal: { + writeLine: jest.Mock; + }; +} + +function createMockLogger(): IMockLogger { + return { + terminal: { + writeLine: jest.fn() + } + }; +} + +describe(NpmPublishProvider.name, () => { + let provider: NpmPublishProvider; + + beforeEach(() => { + provider = new NpmPublishProvider(); + jest.clearAllMocks(); + (FileSystem.exists as jest.Mock).mockReturnValue(false); + }); + + describe('providerName', () => { + it('returns "npm"', () => { + expect(provider.providerName).toBe('npm'); + }); + }); + + describe('publishAsync', () => { + it('calls spawn with correct args for pnpm', async () => { + const mockProject: IMockProject = createMockProject(); + const mockLogger: IMockLogger = createMockLogger(); + + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPublishOptions = { + projects: [ + { + project: mockProject, + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + tag: undefined, + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPublishOptions; + + await provider.publishAsync(options); + + expect(childProcess.spawn).toHaveBeenCalledTimes(1); + const spawnArgs: unknown[] = (childProcess.spawn as jest.Mock).mock.calls[0]; + expect(spawnArgs[0]).toBe('/fake/pnpm'); + expect(spawnArgs[1]).toContain('publish'); + expect(spawnArgs[1]).toContain('--no-git-checks'); + }); + + it('uses npm when package manager is yarn', async () => { + const mockProject: IMockProject = createMockProject({ + rushConfiguration: { + packageManager: 'yarn', + packageManagerToolFilename: '/fake/yarn', + commonTempFolder: '/fake/common/temp' + } + }); + const mockLogger: IMockLogger = createMockLogger(); + + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPublishOptions = { + projects: [ + { + project: mockProject, + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + tag: undefined, + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPublishOptions; + + await provider.publishAsync(options); + + const spawnArgs: unknown[] = (childProcess.spawn as jest.Mock).mock.calls[0]; + expect(spawnArgs[0]).toBe('npm'); + }); + + it('adds tag when provided via options', async () => { + const mockProject: IMockProject = createMockProject(); + const mockLogger: IMockLogger = createMockLogger(); + + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPublishOptions = { + projects: [ + { + project: mockProject, + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + tag: 'beta', + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPublishOptions; + + await provider.publishAsync(options); + + const spawnArgs: unknown[] = (childProcess.spawn as jest.Mock).mock.calls[0]; + expect(spawnArgs[1]).toContain('--tag'); + expect(spawnArgs[1]).toContain('beta'); + }); + + it('logs dry run message without spawning', async () => { + const mockProject: IMockProject = createMockProject(); + const mockLogger: IMockLogger = createMockLogger(); + + const options: IPublishProviderPublishOptions = { + projects: [ + { + project: mockProject, + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + tag: undefined, + dryRun: true, + logger: mockLogger + } as unknown as IPublishProviderPublishOptions; + + await provider.publishAsync(options); + + expect(childProcess.spawn).not.toHaveBeenCalled(); + expect(mockLogger.terminal.writeLine).toHaveBeenCalledWith(expect.stringContaining('[DRY RUN]')); + }); + + it('applies registry URL and auth token from providerConfig', async () => { + const mockProject: IMockProject = createMockProject(); + const mockLogger: IMockLogger = createMockLogger(); + + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPublishOptions = { + projects: [ + { + project: mockProject, + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: { + registryUrl: 'https://custom.registry.com/npm/', + npmAuthToken: 'test-token-123' + } + } as unknown as IPublishProjectInfo + ], + tag: undefined, + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPublishOptions; + + await provider.publishAsync(options); + + const spawnArgs: unknown[] = (childProcess.spawn as jest.Mock).mock.calls[0]; + const args: string[] = spawnArgs[1] as string[]; + expect(args.some((arg: string) => arg.includes('_authToken=test-token-123'))).toBe(true); + + const spawnOptions: Record = spawnArgs[2] as Record; + const env: Record = (spawnOptions as { env: Record }).env; + expect(env.npm_config_registry).toBe('https://custom.registry.com/npm/'); + }); + + it('adds access level from providerConfig', async () => { + const mockProject: IMockProject = createMockProject(); + const mockLogger: IMockLogger = createMockLogger(); + + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPublishOptions = { + projects: [ + { + project: mockProject, + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: { + access: 'public' + } + } as unknown as IPublishProjectInfo + ], + tag: undefined, + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPublishOptions; + + await provider.publishAsync(options); + + const spawnArgs: unknown[] = (childProcess.spawn as jest.Mock).mock.calls[0]; + const args: string[] = spawnArgs[1] as string[]; + expect(args).toContain('--access'); + expect(args).toContain('public'); + }); + + it('rejects when spawn exits with non-zero code', async () => { + const mockProject: IMockProject = createMockProject(); + const mockLogger: IMockLogger = createMockLogger(); + + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(1)); + + const options: IPublishProviderPublishOptions = { + projects: [ + { + project: mockProject, + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + tag: undefined, + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPublishOptions; + + await expect(provider.publishAsync(options)).rejects.toThrow(/exited with code 1/); + }); + }); + + describe('checkExistsAsync', () => { + it('returns true when version exists in registry', async () => { + (childProcess.spawn as jest.Mock).mockReturnValue( + createMockSpawnProcess(0, JSON.stringify(['1.0.0', '1.1.0', '2.0.0'])) + ); + + const options: IPublishProviderCheckExistsOptions = { + project: createMockProject(), + version: '1.1.0', + providerConfig: undefined + } as unknown as IPublishProviderCheckExistsOptions; + + const result: boolean = await provider.checkExistsAsync(options); + expect(result).toBe(true); + }); + + it('returns false when version does not exist', async () => { + (childProcess.spawn as jest.Mock).mockReturnValue( + createMockSpawnProcess(0, JSON.stringify(['1.0.0', '1.1.0'])) + ); + + const options: IPublishProviderCheckExistsOptions = { + project: createMockProject(), + version: '2.0.0', + providerConfig: undefined + } as unknown as IPublishProviderCheckExistsOptions; + + const result: boolean = await provider.checkExistsAsync(options); + expect(result).toBe(false); + }); + + it('returns false when package does not exist (spawn fails)', async () => { + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(1)); + + const options: IPublishProviderCheckExistsOptions = { + project: createMockProject(), + version: '1.0.0', + providerConfig: undefined + } as unknown as IPublishProviderCheckExistsOptions; + + const result: boolean = await provider.checkExistsAsync(options); + expect(result).toBe(false); + }); + + it('normalizes build metadata when checking version', async () => { + (childProcess.spawn as jest.Mock).mockReturnValue( + createMockSpawnProcess(0, JSON.stringify(['1.0.0-beta.1'])) + ); + + const options: IPublishProviderCheckExistsOptions = { + project: createMockProject(), + version: '1.0.0-beta.1+build.123', + providerConfig: undefined + } as unknown as IPublishProviderCheckExistsOptions; + + const result: boolean = await provider.checkExistsAsync(options); + expect(result).toBe(true); + }); + + it('handles single version string response', async () => { + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0, JSON.stringify('1.0.0'))); + + const options: IPublishProviderCheckExistsOptions = { + project: createMockProject(), + version: '1.0.0', + providerConfig: undefined + } as unknown as IPublishProviderCheckExistsOptions; + + const result: boolean = await provider.checkExistsAsync(options); + expect(result).toBe(true); + }); + }); + + describe('packAsync', () => { + it('calls spawn with pack args and moves tarball to release folder', async () => { + const mockProject: IMockProject = createMockProject(); + const mockLogger: IMockLogger = createMockLogger(); + + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPackOptions = { + projects: [ + { + project: mockProject, + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + releaseFolder: '/fake/release', + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPackOptions; + + await provider.packAsync(options); + + expect(childProcess.spawn).toHaveBeenCalledTimes(1); + const spawnArgs: unknown[] = (childProcess.spawn as jest.Mock).mock.calls[0]; + expect(spawnArgs[0]).toBe('/fake/pnpm'); + expect(spawnArgs[1]).toEqual(['pack']); + expect((spawnArgs[2] as Record).cwd).toBe('/fake/project/folder'); + + // Verify tarball move - scoped package removes @ and replaces / + expect(FileSystem.move).toHaveBeenCalledWith({ + sourcePath: '/fake/project/folder/scope-test-package-1.0.0.tgz', + destinationPath: '/fake/release/scope-test-package-1.0.0.tgz', + overwrite: true + }); + }); + + it('calculates tarball name for unscoped packages', async () => { + const mockProject: IMockProject = createMockProject({ + packageName: 'simple-package' + }); + const mockLogger: IMockLogger = createMockLogger(); + + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPackOptions = { + projects: [ + { + project: mockProject, + newVersion: '2.0.0', + previousVersion: '1.0.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + releaseFolder: '/fake/release', + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPackOptions; + + await provider.packAsync(options); + + expect(FileSystem.move).toHaveBeenCalledWith({ + sourcePath: '/fake/project/folder/simple-package-1.0.0.tgz', + destinationPath: '/fake/release/simple-package-1.0.0.tgz', + overwrite: true + }); + }); + + it('adds v prefix for yarn package manager', async () => { + const mockProject: IMockProject = createMockProject({ + rushConfiguration: { + packageManager: 'yarn', + packageManagerToolFilename: '/fake/yarn', + commonTempFolder: '/fake/common/temp' + } + }); + const mockLogger: IMockLogger = createMockLogger(); + + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPackOptions = { + projects: [ + { + project: mockProject, + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + releaseFolder: '/fake/release', + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPackOptions; + + await provider.packAsync(options); + + // yarn uses /fake/yarn for packing (not 'npm' like publishing) + const spawnArgs: unknown[] = (childProcess.spawn as jest.Mock).mock.calls[0]; + expect(spawnArgs[0]).toBe('/fake/yarn'); + + // yarn tarball names have v prefix + expect(FileSystem.move).toHaveBeenCalledWith({ + sourcePath: '/fake/project/folder/scope-test-package-v1.0.0.tgz', + destinationPath: '/fake/release/scope-test-package-v1.0.0.tgz', + overwrite: true + }); + }); + + it('logs dry run message without spawning or moving files', async () => { + const mockProject: IMockProject = createMockProject(); + const mockLogger: IMockLogger = createMockLogger(); + + const options: IPublishProviderPackOptions = { + projects: [ + { + project: mockProject, + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + releaseFolder: '/fake/release', + dryRun: true, + logger: mockLogger + } as unknown as IPublishProviderPackOptions; + + await provider.packAsync(options); + + expect(childProcess.spawn).not.toHaveBeenCalled(); + expect(FileSystem.move).not.toHaveBeenCalled(); + expect(mockLogger.terminal.writeLine).toHaveBeenCalledWith(expect.stringContaining('[DRY RUN]')); + }); + }); + + describe('.npmrc-publish handling', () => { + it('sets HOME env when .npmrc exists in publish-home', async () => { + (FileSystem.exists as jest.Mock).mockReturnValue(true); + const mockLogger: IMockLogger = createMockLogger(); + + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPublishOptions = { + projects: [ + { + project: createMockProject(), + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + tag: undefined, + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPublishOptions; + + await provider.publishAsync(options); + + const spawnArgs: unknown[] = (childProcess.spawn as jest.Mock).mock.calls[0]; + const spawnOptions: Record = spawnArgs[2] as Record; + const env: Record = (spawnOptions as { env: Record }).env; + expect(env.HOME).toBe('/fake/common/temp/publish-home'); + }); + }); +}); diff --git a/rush-plugins/rush-npm-publish-plugin/tsconfig.json b/rush-plugins/rush-npm-publish-plugin/tsconfig.json new file mode 100644 index 00000000000..dac21d04081 --- /dev/null +++ b/rush-plugins/rush-npm-publish-plugin/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json" +} diff --git a/rush-plugins/rush-vscode-publish-plugin/config/rig.json b/rush-plugins/rush-vscode-publish-plugin/config/rig.json new file mode 100644 index 00000000000..bf9de6a1799 --- /dev/null +++ b/rush-plugins/rush-vscode-publish-plugin/config/rig.json @@ -0,0 +1,18 @@ +{ + // The "rig.json" file directs tools to look for their config files in an external package. + // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + /** + * (Required) The name of the rig package to inherit from. + * It should be an NPM package name with the "-rig" suffix. + */ + "rigPackageName": "local-node-rig", + + /** + * (Optional) Selects a config profile from the rig package. The name must consist of + * lowercase alphanumeric words separated by hyphens, for example "sample-profile". + * If omitted, then the "default" profile will be used." + */ + "rigProfile": "default" +} diff --git a/rush-plugins/rush-vscode-publish-plugin/eslint.config.js b/rush-plugins/rush-vscode-publish-plugin/eslint.config.js new file mode 100644 index 00000000000..c15e6077310 --- /dev/null +++ b/rush-plugins/rush-vscode-publish-plugin/eslint.config.js @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeTrustedToolProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node-trusted-tool'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); + +module.exports = [ + ...nodeTrustedToolProfile, + ...friendlyLocalsMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname + } + } + } +]; diff --git a/rush-plugins/rush-vscode-publish-plugin/package.json b/rush-plugins/rush-vscode-publish-plugin/package.json new file mode 100644 index 00000000000..b8204c95df3 --- /dev/null +++ b/rush-plugins/rush-vscode-publish-plugin/package.json @@ -0,0 +1,32 @@ +{ + "name": "@rushstack/rush-vscode-publish-plugin", + "version": "5.167.0", + "description": "Rush plugin for publishing VSIX packages to the VS Code Marketplace", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/rushstack", + "directory": "rush-plugins/rush-vscode-publish-plugin" + }, + "homepage": "https://rushjs.io", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "license": "MIT", + "scripts": { + "build": "heft build --clean", + "start": "heft test-watch", + "test": "heft test", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "dependencies": { + "@rushstack/node-core-library": "workspace:*", + "@rushstack/rush-sdk": "workspace:*" + }, + "devDependencies": { + "@microsoft/rush-lib": "workspace:*", + "@rushstack/heft": "workspace:*", + "@rushstack/terminal": "workspace:*", + "eslint": "~9.37.0", + "local-node-rig": "workspace:*" + } +} diff --git a/rush-plugins/rush-vscode-publish-plugin/rush-plugin-manifest.json b/rush-plugins/rush-vscode-publish-plugin/rush-plugin-manifest.json new file mode 100644 index 00000000000..b104837bb0d --- /dev/null +++ b/rush-plugins/rush-vscode-publish-plugin/rush-plugin-manifest.json @@ -0,0 +1,10 @@ +{ + "plugins": [ + { + "pluginName": "rush-vscode-publish-plugin", + "description": "Provides the 'vsix' publish target for publishing VSIX packages to the VS Code Marketplace.", + "entryPoint": "lib/RushVscodePublishPlugin.js", + "associatedCommands": ["publish"] + } + ] +} diff --git a/rush-plugins/rush-vscode-publish-plugin/src/RushVscodePublishPlugin.ts b/rush-plugins/rush-vscode-publish-plugin/src/RushVscodePublishPlugin.ts new file mode 100644 index 00000000000..09e3d783cd7 --- /dev/null +++ b/rush-plugins/rush-vscode-publish-plugin/src/RushVscodePublishPlugin.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IRushPlugin, RushSession, RushConfiguration } from '@rushstack/rush-sdk'; + +const PLUGIN_NAME: string = 'VscodePublishPlugin'; + +/** + * @public + */ +export class RushVscodePublishPlugin implements IRushPlugin { + public readonly pluginName: string = PLUGIN_NAME; + + public apply(rushSession: RushSession, rushConfiguration: RushConfiguration): void { + rushSession.hooks.initialize.tap(this.pluginName, () => { + rushSession.registerPublishProviderFactory('vsix', async () => { + const { VsixPublishProvider } = await import('./VsixPublishProvider'); + return new VsixPublishProvider(); + }); + }); + } +} diff --git a/rush-plugins/rush-vscode-publish-plugin/src/VsixPublishProvider.ts b/rush-plugins/rush-vscode-publish-plugin/src/VsixPublishProvider.ts new file mode 100644 index 00000000000..6db485d8a1a --- /dev/null +++ b/rush-plugins/rush-vscode-publish-plugin/src/VsixPublishProvider.ts @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as childProcess from 'node:child_process'; +import * as path from 'node:path'; + +import type { + IPublishProvider, + IPublishProviderPublishOptions, + IPublishProviderPackOptions, + IPublishProviderCheckExistsOptions +} from '@rushstack/rush-sdk'; + +/** + * Configuration options for the VSIX publish provider, read from + * the `providers.vsix` section of `config/rush-publish.json`. + */ +export interface IVsixProviderConfig { + /** + * Glob pattern for locating the VSIX file relative to the project's publish folder. + * @defaultValue 'dist/vsix/extension.vsix' + */ + vsixPathPattern?: string; + + /** + * If true, use Azure credential-based authentication with vsce. + * @defaultValue true + */ + useAzureCredential?: boolean; +} + +const DEFAULT_VSIX_PATH_PATTERN: string = 'dist/vsix/extension.vsix'; + +/** + * Publish provider that publishes VSIX packages to the VS Code Marketplace + * using the @vscode/vsce CLI. + * @public + */ +export class VsixPublishProvider implements IPublishProvider { + public readonly providerName: string = 'vsix'; + + public async publishAsync(options: IPublishProviderPublishOptions): Promise { + const { projects, dryRun, logger } = options; + + for (const projectInfo of projects) { + const { project, newVersion, providerConfig } = projectInfo; + const config: IVsixProviderConfig = (providerConfig as IVsixProviderConfig) || {}; + + const packageName: string = project.packageName; + const publishFolder: string = project.publishFolder; + + const vsixPathPattern: string = config.vsixPathPattern || DEFAULT_VSIX_PATH_PATTERN; + const vsixPath: string = path.resolve(publishFolder, vsixPathPattern); + const useAzureCredential: boolean = config.useAzureCredential !== false; + + logger.terminal.writeLine(`Publishing ${packageName}@${newVersion} to VS Code Marketplace...`); + + const args: string[] = ['publish', '--no-dependencies', '--packagePath', vsixPath]; + + if (useAzureCredential) { + args.push('--azure-credential'); + } + + if (dryRun) { + logger.terminal.writeLine(` [DRY RUN] Would execute: vsce ${args.join(' ')}`); + logger.terminal.writeLine(` Working directory: ${publishFolder}`); + } else { + await this._executeVsceAsync(args, publishFolder); + logger.terminal.writeLine(` Successfully published ${packageName}@${newVersion} to Marketplace`); + } + } + } + + public async packAsync(options: IPublishProviderPackOptions): Promise { + const { projects, releaseFolder, dryRun, logger } = options; + + for (const projectInfo of projects) { + const { project, newVersion } = projectInfo; + + const packageName: string = project.packageName; + const publishFolder: string = project.publishFolder; + + // Determine the output VSIX filename + const vsixFileName: string = `${packageName.replace(/[/@]/g, '-')}-${newVersion}.vsix`; + const outputPath: string = path.join(releaseFolder, vsixFileName); + + logger.terminal.writeLine(`Packing ${packageName}@${newVersion} as VSIX...`); + + // vsce package --out + const args: string[] = ['package', '--no-dependencies', '--out', outputPath]; + + if (dryRun) { + logger.terminal.writeLine(` [DRY RUN] Would execute: vsce ${args.join(' ')}`); + logger.terminal.writeLine(` Working directory: ${publishFolder}`); + } else { + await this._executeVsceAsync(args, publishFolder); + logger.terminal.writeLine(` Packed ${packageName}@${newVersion} to ${vsixFileName}`); + } + } + } + + /** + * The VS Code Marketplace does not provide a simple version-check API, + * so this always returns false (allowing publish to proceed). + */ + public async checkExistsAsync(options: IPublishProviderCheckExistsOptions): Promise { + return false; + } + + /** + * Execute the vsce CLI as a child process. + */ + private async _executeVsceAsync(args: string[], workingDirectory: string): Promise { + // Resolve vsce from the project's node_modules + const vsceCommand: string = process.platform === 'win32' ? 'vsce.cmd' : 'vsce'; + + return new Promise((resolve, reject) => { + const child: childProcess.ChildProcess = childProcess.spawn(vsceCommand, args, { + cwd: workingDirectory, + stdio: 'inherit' + }); + + child.on('close', (code: number | null) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Command "vsce ${args.join(' ')}" exited with code ${code}`)); + } + }); + + child.on('error', (error: Error) => { + reject(error); + }); + }); + } +} diff --git a/rush-plugins/rush-vscode-publish-plugin/src/index.ts b/rush-plugins/rush-vscode-publish-plugin/src/index.ts new file mode 100644 index 00000000000..553b27dd1f4 --- /dev/null +++ b/rush-plugins/rush-vscode-publish-plugin/src/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { RushVscodePublishPlugin } from './RushVscodePublishPlugin'; + +export default RushVscodePublishPlugin; diff --git a/rush-plugins/rush-vscode-publish-plugin/src/test/VsixPublishProvider.test.ts b/rush-plugins/rush-vscode-publish-plugin/src/test/VsixPublishProvider.test.ts new file mode 100644 index 00000000000..5a93ac035f5 --- /dev/null +++ b/rush-plugins/rush-vscode-publish-plugin/src/test/VsixPublishProvider.test.ts @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +jest.mock('node:child_process', () => { + const actual: typeof import('node:child_process') = jest.requireActual('node:child_process'); + return { + ...actual, + spawn: jest.fn() + }; +}); + +import { EventEmitter } from 'node:events'; +import * as childProcess from 'node:child_process'; + +import type { + IPublishProviderPublishOptions, + IPublishProviderPackOptions, + IPublishProviderCheckExistsOptions, + IPublishProjectInfo +} from '@rushstack/rush-sdk'; + +import { VsixPublishProvider } from '../VsixPublishProvider'; + +interface IMockChildProcess extends EventEmitter { + stdin: EventEmitter; + stdout: EventEmitter; + stderr: EventEmitter; +} + +function createMockSpawnProcess(exitCode: number = 0): IMockChildProcess { + const cp: IMockChildProcess = Object.assign(new EventEmitter(), { + stdin: new EventEmitter(), + stdout: new EventEmitter(), + stderr: new EventEmitter() + }); + + setTimeout(() => { + cp.emit('close', exitCode); + }, 0); + + return cp; +} + +interface IMockProject { + packageName: string; + publishFolder: string; +} + +function createMockProject(overrides?: Partial): IMockProject { + return { + packageName: '@scope/test-extension', + publishFolder: '/fake/extension/folder', + ...overrides + }; +} + +interface IMockLogger { + terminal: { + writeLine: jest.Mock; + }; +} + +function createMockLogger(): IMockLogger { + return { + terminal: { + writeLine: jest.fn() + } + }; +} + +describe(VsixPublishProvider.name, () => { + let provider: VsixPublishProvider; + + beforeEach(() => { + provider = new VsixPublishProvider(); + jest.clearAllMocks(); + }); + + describe('providerName', () => { + it('returns "vsix"', () => { + expect(provider.providerName).toBe('vsix'); + }); + }); + + describe('packAsync', () => { + it('calls vsce package with correct output path', async () => { + const mockLogger: IMockLogger = createMockLogger(); + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPackOptions = { + projects: [ + { + project: createMockProject(), + newVersion: '1.2.3', + previousVersion: '1.2.2', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + releaseFolder: '/fake/release', + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPackOptions; + + await provider.packAsync(options); + + expect(childProcess.spawn).toHaveBeenCalledTimes(1); + const spawnArgs: unknown[] = (childProcess.spawn as jest.Mock).mock.calls[0]; + const command: string = spawnArgs[0] as string; + const args: string[] = spawnArgs[1] as string[]; + + expect(command).toBe('vsce'); + expect(args).toContain('package'); + expect(args).toContain('--no-dependencies'); + expect(args).toContain('--out'); + // @scope/test-extension -> -scope-test-extension-1.2.3.vsix + expect(args).toContain('/fake/release/-scope-test-extension-1.2.3.vsix'); + }); + + it('produces correctly named VSIX file for unscoped package', async () => { + const mockLogger: IMockLogger = createMockLogger(); + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPackOptions = { + projects: [ + { + project: createMockProject({ packageName: 'my-vscode-ext' }), + newVersion: '2.0.0', + previousVersion: '1.0.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + releaseFolder: '/fake/release', + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPackOptions; + + await provider.packAsync(options); + + const spawnArgs: unknown[] = (childProcess.spawn as jest.Mock).mock.calls[0]; + const args: string[] = spawnArgs[1] as string[]; + expect(args).toContain('/fake/release/my-vscode-ext-2.0.0.vsix'); + }); + + it('logs dry run message without spawning', async () => { + const mockLogger: IMockLogger = createMockLogger(); + + const options: IPublishProviderPackOptions = { + projects: [ + { + project: createMockProject(), + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + releaseFolder: '/fake/release', + dryRun: true, + logger: mockLogger + } as unknown as IPublishProviderPackOptions; + + await provider.packAsync(options); + + expect(childProcess.spawn).not.toHaveBeenCalled(); + expect(mockLogger.terminal.writeLine).toHaveBeenCalledWith(expect.stringContaining('[DRY RUN]')); + }); + }); + + describe('publishAsync', () => { + it('calls vsce publish with default vsix path and azure credential', async () => { + const mockLogger: IMockLogger = createMockLogger(); + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPublishOptions = { + projects: [ + { + project: createMockProject(), + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + tag: undefined, + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPublishOptions; + + await provider.publishAsync(options); + + expect(childProcess.spawn).toHaveBeenCalledTimes(1); + const spawnArgs: unknown[] = (childProcess.spawn as jest.Mock).mock.calls[0]; + const args: string[] = spawnArgs[1] as string[]; + expect(args).toContain('publish'); + expect(args).toContain('--no-dependencies'); + expect(args).toContain('--packagePath'); + expect(args).toContain('--azure-credential'); + // Default vsix path + expect(args.some((a: string) => a.includes('dist/vsix/extension.vsix'))).toBe(true); + }); + + it('uses custom vsix path from providerConfig', async () => { + const mockLogger: IMockLogger = createMockLogger(); + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPublishOptions = { + projects: [ + { + project: createMockProject(), + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: { + vsixPathPattern: 'output/my-extension.vsix' + } + } as unknown as IPublishProjectInfo + ], + tag: undefined, + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPublishOptions; + + await provider.publishAsync(options); + + const spawnArgs: unknown[] = (childProcess.spawn as jest.Mock).mock.calls[0]; + const args: string[] = spawnArgs[1] as string[]; + expect(args.some((a: string) => a.includes('output/my-extension.vsix'))).toBe(true); + }); + + it('omits --azure-credential when useAzureCredential is false', async () => { + const mockLogger: IMockLogger = createMockLogger(); + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(0)); + + const options: IPublishProviderPublishOptions = { + projects: [ + { + project: createMockProject(), + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: { + useAzureCredential: false + } + } as unknown as IPublishProjectInfo + ], + tag: undefined, + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPublishOptions; + + await provider.publishAsync(options); + + const spawnArgs: unknown[] = (childProcess.spawn as jest.Mock).mock.calls[0]; + const args: string[] = spawnArgs[1] as string[]; + expect(args).not.toContain('--azure-credential'); + }); + + it('logs dry run message without spawning', async () => { + const mockLogger: IMockLogger = createMockLogger(); + + const options: IPublishProviderPublishOptions = { + projects: [ + { + project: createMockProject(), + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + tag: undefined, + dryRun: true, + logger: mockLogger + } as unknown as IPublishProviderPublishOptions; + + await provider.publishAsync(options); + + expect(childProcess.spawn).not.toHaveBeenCalled(); + expect(mockLogger.terminal.writeLine).toHaveBeenCalledWith(expect.stringContaining('[DRY RUN]')); + }); + + it('rejects when vsce exits with non-zero code', async () => { + const mockLogger: IMockLogger = createMockLogger(); + (childProcess.spawn as jest.Mock).mockReturnValue(createMockSpawnProcess(1)); + + const options: IPublishProviderPublishOptions = { + projects: [ + { + project: createMockProject(), + newVersion: '1.0.0', + previousVersion: '0.9.0', + changeType: 2, + providerConfig: undefined + } as unknown as IPublishProjectInfo + ], + tag: undefined, + dryRun: false, + logger: mockLogger + } as unknown as IPublishProviderPublishOptions; + + await expect(provider.publishAsync(options)).rejects.toThrow(/exited with code 1/); + }); + }); + + describe('checkExistsAsync', () => { + it('always returns false', async () => { + const options: IPublishProviderCheckExistsOptions = { + project: createMockProject(), + version: '1.0.0', + providerConfig: undefined + } as unknown as IPublishProviderCheckExistsOptions; + + const result: boolean = await provider.checkExistsAsync(options); + expect(result).toBe(false); + }); + }); +}); diff --git a/rush-plugins/rush-vscode-publish-plugin/tsconfig.json b/rush-plugins/rush-vscode-publish-plugin/tsconfig.json new file mode 100644 index 00000000000..dac21d04081 --- /dev/null +++ b/rush-plugins/rush-vscode-publish-plugin/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json" +} diff --git a/rush.json b/rush.json index ab816fc21f5..668dc3730e7 100644 --- a/rush.json +++ b/rush.json @@ -1433,6 +1433,12 @@ "reviewCategory": "libraries", "shouldPublish": false }, + { + "packageName": "@rushstack/rush-npm-publish-plugin", + "projectFolder": "rush-plugins/rush-npm-publish-plugin", + "reviewCategory": "libraries", + "versionPolicyName": "rush" + }, { "packageName": "@rushstack/rush-redis-cobuild-plugin", "projectFolder": "rush-plugins/rush-redis-cobuild-plugin", @@ -1457,31 +1463,41 @@ "reviewCategory": "libraries", "versionPolicyName": "rush" }, + { + "packageName": "@rushstack/rush-vscode-publish-plugin", + "projectFolder": "rush-plugins/rush-vscode-publish-plugin", + "reviewCategory": "libraries", + "versionPolicyName": "rush" + }, // "vscode-extensions" folder (alphabetical order) { "packageName": "rushstack", "projectFolder": "vscode-extensions/rush-vscode-extension", "reviewCategory": "vscode-extensions", - "tags": ["vsix"] + "tags": ["vsix"], + "shouldPublish": true }, { "packageName": "@rushstack/rush-vscode-command-webview", "projectFolder": "vscode-extensions/rush-vscode-command-webview", "reviewCategory": "vscode-extensions", - "tags": ["vsix"] + "tags": ["vsix"], + "shouldPublish": true }, { "packageName": "debug-certificate-manager", "projectFolder": "vscode-extensions/debug-certificate-manager-vscode-extension", "reviewCategory": "vscode-extensions", - "tags": ["vsix"] + "tags": ["vsix"], + "shouldPublish": true }, { "packageName": "playwright-local-browser-server", "projectFolder": "vscode-extensions/playwright-local-browser-server-vscode-extension", "reviewCategory": "vscode-extensions", - "tags": ["vsix"] + "tags": ["vsix"], + "shouldPublish": true }, // "webpack" folder (alphabetical order)