You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Rush Publishing v2: Decoupled Versioning and Plugin-Based Publishing
Document Metadata
Details
Author(s)
Sean Larkin
Status
Draft (WIP)
Team / Owner
Rush Stack
Created / Last Updated
2026-02-12
1. Executive Summary
This RFC proposes decoupling Rush's version bumping system from npm publishing and introducing a plugin-based publish architecture. Today, the shouldPublish flag in rush.json simultaneously gates version bumping (changelogs, semver increments) and npm publishing -- making it impossible for non-npm artifacts like VS Code extensions to participate in the rush change / rush version workflow. The proposed solution introduces: (1) decoupling shouldPublish from npm-only semantics, (2) a publishTarget array that routes publishing to one or more backends, and (3) a publish plugin system (registerPublishProviderFactory) modeled after the existing build cache plugin pattern. Provider configuration is riggable per-project via config/publish.json (following the config/rush-project.json pattern) rather than a global options file. The npm publish provider is always built-in, and publishTarget: ["npm"] is inferred when omitted for backward compatibility. This enables the 4 VS Code extensions in vscode-extensions/ to use rush change and rush version --bump while being published as VSIX files through a dedicated plugin rather than npm publish.
2. Context and Motivation
2.1 Current State
Rush's version management pipeline is a three-stage workflow:
The first two stages (rush change and rush version --bump) are already npm-agnostic -- they operate on JSON change files, package.json version fields, and CHANGELOG generation without touching any package registry. The third stage (rush publish) is hardcoded to npm publish.
Research reference: [research/docs/2026-02-10-rush-version-bump-system-and-vs-code-extension-versioning.md, Section 8] documents that ChangeManager, VersionManager, ChangelogGenerator, VersionPolicyConfiguration, and PublishUtilities.findChangeRequestsAsync() contain zero npm-specific code.
The architectural separation already exists in the codebase:
Operation
Version Calculation
npm Publishing
rush version --bump
Yes
No
rush publish --include-all
No
Yes
rush publish (change-based)
Yes
Yes
However, participation in any of these workflows requires shouldPublish: true (or a versionPolicyName), which is the sole gating mechanism.
User Impact: VS Code extension developers in the rushstack monorepo cannot use rush change to document their changes, rush version --bump to increment versions, or generate CHANGELOGs. Version management for the 4 extensions (rushstack, debug-certificate-manager, playwright-local-browser-server, @rushstack/rush-vscode-command-webview) must be done manually.
Ecosystem Impact: GitHub Issue #3342 documents demand for non-npm publishing targets. Any monorepo with artifacts that aren't npm packages (Docker images, VS Code extensions, NuGet packages, Python packages, mobile apps) faces this same limitation.
Technical Debt: The shouldPublish flag conflates two orthogonal concerns. Research reference: [research/docs/2026-02-10-rush-version-bump-system-and-vs-code-extension-versioning.md, Section 5] catalogs 10 locations where shouldPublish gates version-bumping behavior, and 3 additional locations where it gates npm-specific publishing behavior -- all through the same boolean.
The specific coupling points are:
Location
File:Line
Gates
Change file creation
ChangeAction.ts:375
Version bumping
Empty change file creation
ChangeManager.ts:27
Version bumping
Version bump skip
PublishUtilities.ts:373
Version bumping
Package change write
PublishUtilities.ts:408
Version bumping
Dependency propagation
PublishUtilities.ts:502
Version bumping
Changelog generation
ChangelogGenerator.ts:288
Version bumping
Dependency change tracking
VersionManager.ts:330
Version bumping
npm publish execution
PublishAction.ts:372
npm publishing
Git tag creation
PublishAction.ts:426
npm publishing
npm config injection
PublishAction.ts:443
npm publishing
3. Goals and Non-Goals
3.1 Functional Goals
VS Code extensions can participate in the rush change workflow (prompted for change descriptions/bump types)
VS Code extensions receive version bumps via rush version --bump (updated package.json version, CHANGELOG generation)
rush publish can dispatch to different publish backends (npm, VSIX) based on project configuration
A new registerPublishProviderFactory API on RushSession allows plugins to register custom publish providers
An npm publish plugin ships as the default (always built-in) publish provider, preserving backward compatibility
A VSIX publish plugin integrates with the existing heft-vscode-extension-plugin infrastructure
Projects can opt into versioning without opting into any automated publishing (version-only mode via publishTarget: ["none"])
publishTarget supports arrays, enabling a single project to publish to multiple targets (e.g., ["npm", "internal-registry"])
All publish provider configuration is riggable per-project via config/publish.json (no global options file)
VS Code extension versions always match their package.json version (managed by rush version --bump)
3.2 Non-Goals (Out of Scope)
We will NOT change the change file format (JSON structure in common/changes/)
We will NOT modify version policy types (lockstep/individual remain as-is)
We will NOT build a generic artifact publishing framework (only npm and VSIX in this iteration)
We will NOT migrate existing shouldPublish: true projects -- they continue to work identically
We will NOT modify the rush version --bump command semantics -- only its gating logic
We will NOT create a new rush.json schema version -- changes are additive
We will NOT address the heft-vscode-extension-plugin build pipeline (packaging/signing) -- only the publish dispatch
The design follows four patterns already established in the Rush codebase:
Factory Registration Pattern (from build cache plugins): Publish providers register via rushSession.registerPublishProviderFactory(name, factory), identical to how registerCloudBuildCacheProviderFactory works today. The factory receives provider-specific configuration read from the project's riggable config file, mirroring how build cache plugin factories receive their section from build-cache.json. Research reference: [research/docs/2026-02-07-rush-plugin-architecture.md, Section 4.1] documents RushSession's existing registration methods.
Strategy Pattern (from version policies): Different publish targets are handled by different IPublishProvider implementations, similar to how LockStepVersionPolicy and IndividualVersionPolicy are strategy implementations of VersionPolicy. Research reference: [research/docs/2026-02-10-rush-version-bump-system-and-vs-code-extension-versioning.md, Section 2] documents the version policy strategy pattern.
Associated Commands Pattern (from existing plugins): Publish plugins declare "associatedCommands": ["publish"] in their manifest, so they only load when rush publish runs. Research reference: [research/docs/2026-02-07-existing-rush-plugins.md, Section "Plugin Infrastructure"] documents the associated commands mechanism.
Riggable Configuration Pattern (from rush-project.json): Provider options are stored in a per-project config/publish.json file that is resolved through the rig system using ProjectConfigurationFile and RigConfig.loadForProjectFolderAsync(). This follows the exact same pattern as config/rush-project.json -- projects can define their own config or inherit from their rig package. The ProjectConfigurationFile class provides schema validation and propertyInheritance for merging rig defaults with project overrides.
4.3 Key Components
Component
Responsibility
Technology
Justification
shouldPublish gate refactor
Enable versioning for all shouldPublish projects regardless of publish target
TypeScript, rush-lib
Minimal change: shouldPublish already gates versioning; we just need to stop conflating it with npm-only publishing
publishTarget field (array)
Routes projects to one or more publish providers
JSON array field in rush.json
Additive schema change; defaults to ["npm"] when omitted for backward compatibility; supports multi-target publishing
config/publish.json (riggable)
Per-project provider configuration with rig inheritance
JSON config + ProjectConfigurationFile
Follows config/rush-project.json pattern; no global options file; rig packages can provide shared defaults
IPublishProvider interface
Contract for publish provider implementations
TypeScript interface in rush-lib
Mirrors the ICloudBuildCacheProvider pattern
registerPublishProviderFactory()
Plugin registration API on RushSession
TypeScript method
Follows exact pattern of registerCloudBuildCacheProviderFactory()
rush-npm-publish-plugin
Default npm publish provider (extracted from PublishAction)
Rush plugin (always built-in)
Preserves existing behavior; same loading mechanism as S3/Azure cache plugins; listed in publishOnlyDependencies
Dispatch to registered providers instead of hardcoded npm
TypeScript, rush-lib
The publish action becomes an orchestrator rather than an implementor
5. Detailed Design
5.1 rush.json Schema Changes
The rush.json project entry schema (libraries/rush-lib/src/schemas/rush.schema.json) gains one new optional field:
{
"publishTarget": {
"description": "Specifies the publish targets for this project. Determines which publish provider plugins handle publishing. Each entry maps to a registered publish provider. Common values: 'npm', 'vsix', 'none'. When set to ['none'], the project participates in versioning but is not published by any provider. When omitted, defaults to ['npm'] for backward compatibility.",
"oneOf": [
{ "type": "string" },
{
"type": "array",
"items": { "type": "string" },
"minItems": 1
}
]
}
}
Default behavior: When publishTarget is omitted, it defaults to ["npm"] for backward compatibility. Projects with shouldPublish: true and no publishTarget behave exactly as they do today. A string value is normalized to a single-element array (e.g., "vsix" becomes ["vsix"]).
Example rush.json entries:
// Existing npm package (unchanged, backward compatible -- publishTarget inferred as ["npm"])
{
"packageName": "@rushstack/node-core-library",
"projectFolder": "libraries/node-core-library",
"reviewCategory": "libraries",
"shouldPublish": true
}
// VS Code extension (new: participates in versioning, publishes as VSIX)
{
"packageName": "rushstack",
"projectFolder": "vscode-extensions/rush-vscode-extension",
"reviewCategory": "vscode-extensions",
"tags": ["vsix"],
"shouldPublish": true,
"publishTarget": ["vsix"]
}
// Project that publishes to both npm and an internal registry
{
"packageName": "@rushstack/dual-publish-lib",
"projectFolder": "libraries/dual-publish-lib",
"shouldPublish": true,
"publishTarget": ["npm", "internal-registry"]
}
// Library that needs versioning but no automated publishing
{
"packageName": "@rushstack/internal-lib",
"projectFolder": "libraries/internal-lib",
"shouldPublish": true,
"publishTarget": ["none"]
}
// In IRushConfigurationProjectJson (line ~29)exportinterfaceIRushConfigurationProjectJson{// ... existing fields ...publishTarget?: string|string[];// NEW}// In RushConfigurationProject classprivatereadonly _publishTargets: ReadonlyArray<string>;// In constructor (after line ~327)constrawTarget=projectJson.publishTarget;if(rawTarget===undefined){this._publishTargets=['npm'];// Infer npm when omitted}elseif(typeofrawTarget==='string'){this._publishTargets=[rawTarget];// Normalize string to array}else{this._publishTargets=rawTarget;}// New public getter/** * Specifies the publish targets for this project. Determines which publish * provider plugins handle publishing during `rush publish`. * * Common values: 'npm', 'vsix', 'none'. * When the array contains 'none', the project participates in versioning * but is not published by any provider. * When omitted in rush.json, defaults to ['npm'] for backward compatibility. */publicgetpublishTargets(): ReadonlyArray<string>{returnthis._publishTargets;}
The existing shouldPublish getter remains unchanged:
This means shouldPublish continues to gate version bumping for all projects. The new publishTargets field only affects the rush publish command's dispatch logic.
Validation: In the constructor, add validations:
publishTarget: ["none"] is incompatible with versionPolicyName on lockstep policies (since lockstep policies inherently couple versioning with publishing intent). Individual version policies can use publishTarget: ["none"].
"none" cannot be combined with other targets in the same array (e.g., ["npm", "none"] is invalid).
The private: true validation is relaxed for non-npm targets:
// Updated validation in RushConfigurationProject constructorif(this._shouldPublish&&this.packageJson.private&&this._publishTargets.includes('npm')){thrownewError(`The project "${packageName}" specifies "shouldPublish": true with `+`publishTarget including "npm", but the package.json file specifies "private": true.`);}
5.3 IPublishProvider Interface
New file:libraries/rush-lib/src/pluginFramework/IPublishProvider.ts
importtype{RushConfigurationProject}from'../api/RushConfigurationProject';importtype{ILogger}from'./logging/Logger';/** * Information about a project that needs to be published. */exportinterfaceIPublishProjectInfo{/** The Rush project configuration */readonlyproject: RushConfigurationProject;/** The new version string after bumping */readonlynewVersion: string;/** The previous version string before bumping */readonlypreviousVersion: string;/** The change type that triggered this publish */readonlychangeType: string;/** Provider-specific configuration for this project, loaded from config/publish.json */readonlyproviderConfig: Record<string,unknown>;}/** * Options passed to the publish provider's publishAsync method. */exportinterfaceIPublishProviderPublishOptions{/** Projects to publish, filtered to those matching this provider's target */readonlyprojects: ReadonlyArray<IPublishProjectInfo>;/** The npm tag to apply (e.g., 'latest', 'next') */readonlytag?: string;/** Whether this is a dry run */readonlydryRun: boolean;/** Logger instance for output */readonlylogger: ILogger;}/** * Options passed to the publish provider's checkExistsAsync method. */exportinterfaceIPublishProviderCheckExistsOptions{/** The project to check */readonlyproject: RushConfigurationProject;/** The version to check for */readonlyversion: string;/** Provider-specific configuration for this project */readonlyproviderConfig: Record<string,unknown>;}/** * Options passed to the publish provider's packAsync method. */exportinterfaceIPublishProviderPackOptions{/** Projects to pack, filtered to those matching this provider's target */readonlyprojects: ReadonlyArray<IPublishProjectInfo>;/** The folder where packed artifacts should be placed (from --release-folder or default) */readonlyreleaseFolder: string;/** Whether this is a dry run */readonlydryRun: boolean;/** Logger instance for output */readonlylogger: ILogger;}/** * Interface that publish provider plugins must implement. * Modeled after ICloudBuildCacheProvider. */exportinterfaceIPublishProvider{/** Human-readable name for this provider */readonlyproviderName: string;/** * Publishes the specified projects. * @returns A map from package name to success/failure status. */publishAsync(options: IPublishProviderPublishOptions): Promise<Map<string,boolean>>;/** * Packs the specified projects into distributable artifacts for this * provider's target. Each provider defines what "packing" means: * - npm: runs `<packageManager> pack` to produce a `.tgz` tarball * - vsix: runs `vsce package` to produce a `.vsix` file * * Artifacts are written to the `releaseFolder` specified in options. * Called when `rush publish --pack` is invoked. */packAsync(options: IPublishProviderPackOptions): Promise<void>;/** * Checks whether a specific version of a project already exists in the * target registry/marketplace. */checkExistsAsync(options: IPublishProviderCheckExistsOptions): Promise<boolean>;}/** * Factory function type for creating publish providers. * The factory is called once per `rush publish` invocation. * No global config is passed -- all provider configuration comes * from per-project config/publish.json via IPublishProjectInfo.providerConfig. */exporttypePublishProviderFactory=()=>Promise<IPublishProvider>;
Add registration methods following the existing pattern:
// New private fieldprivate _publishProviderFactories: Map<string,PublishProviderFactory>=newMap();/** * Registers a factory function that creates an IPublishProvider for the * specified publish target name. * * This mirrors the registerCloudBuildCacheProviderFactory pattern. * The factory takes no arguments -- provider-specific configuration * is loaded per-project from riggable config/publish.json and passed * via IPublishProjectInfo.providerConfig. * * @example * ```typescript * rushSession.registerPublishProviderFactory('vsix', async () => { * return new VsixPublishProvider(); * }); * ``` */publicregisterPublishProviderFactory(publishTargetName: string,factory: PublishProviderFactory): void{if(this._publishProviderFactories.has(publishTargetName)){thrownewError(`A publish provider factory has already been registered for target "${publishTargetName}".`);}this._publishProviderFactories.set(publishTargetName,factory);}/** * Retrieves a previously registered publish provider factory. */publicgetPublishProviderFactory(publishTargetName: string
): PublishProviderFactory|undefined{returnthis._publishProviderFactories.get(publishTargetName);}
The PublishAction currently hardcodes npm publishing in _npmPublishAsync() (line 439) and _packageExistsAsync() (line 488). The refactor replaces these with provider dispatch:
Phase 1: Load Per-Project Configuration
Before dispatch, load each project's config/publish.json via the riggable config system:
// Load riggable publish config for a projectconstpublishConfig: IPublishJson|undefined=awaitPUBLISH_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync(terminal,project.projectFolder,rigConfig);
Phase 2: Provider Resolution
In _publishChangesAsync() (line 278) and _publishAllAsync() (line 361), after computing the set of projects to publish, group them by publish target. Since publishTargets is an array, a single project may appear in multiple groups:
// Group projects by publish targetconstprojectsByTarget: Map<string,IPublishProjectInfo[]>=newMap();for(const[packageName,changeInfo]ofallPackageChanges){constproject=this.rushConfiguration.projectsByName.get(packageName)!;if(!project.shouldPublish)continue;constpublishConfig=awaitthis._loadPublishConfigAsync(project);for(consttargetofproject.publishTargets){if(target==='none')continue;// Version-only projects skip publishing// Extract the provider-specific section from the project's publish configconstproviderConfig=publishConfig?.providers?.[target]??{};lettargetProjects=projectsByTarget.get(target);if(!targetProjects){targetProjects=[];projectsByTarget.set(target,targetProjects);}targetProjects.push({
project,newVersion: changeInfo.newVersion!,previousVersion: changeInfo.oldVersion,changeType: ChangeType[changeInfo.changeType!],
providerConfig
});}}
Phase 3: Provider Dispatch
// Dispatch to registered providersfor(const[targetName,projects]ofprojectsByTarget){constfactory=this._rushSession.getPublishProviderFactory(targetName);if(!factory){thrownewError(`No publish provider registered for target "${targetName}". `+`Projects with this target: ${projects.map(p=>p.project.packageName).join(', ')}. `+`Ensure a plugin is configured that registers a "${targetName}" publish provider.`);}constprovider=awaitfactory();constresults=awaitprovider.publishAsync({
projects,tag: this._npmTag.value,dryRun: !shouldCommit,
logger
});// Process results...}
Phase 4: Pack Dispatch
When --pack is used, PublishAction dispatches to each provider's packAsync method instead of publishAsync. This replaces the hardcoded _npmPackAsync call:
if(this._pack.value){awaitthis._packProjectViaProvidersAsync(packageConfig);// NEW: routes through providers
The _npmPackAsync and _calculateTarballName private methods are deleted from PublishAction — their logic moves into NpmPublishProvider.packAsync.
Phase 5: Extract npm-specific code
The existing _npmPublishAsync(), _packageExistsAsync(), .npmrc-publish handling, and npm-specific flag construction move into the rush-npm-publish-plugin.
5.6 rush-npm-publish-plugin (Always Built-In)
New package:rush-plugins/rush-npm-publish-plugin/
This is a permanently built-in plugin (registered in PluginManager alongside the cache plugins) that extracts the existing npm publish logic from PublishAction. It is listed in rush-lib's publishOnlyDependencies (same mechanism as the S3, Azure, and HTTP build cache plugins). The npm provider will always ship with Rush -- it will never be moved to an autoinstaller since virtually all Rush users depend on npm publishing.
The packAsync method runs <packageManager> pack in the project's publishFolder and moves the resulting .tgz tarball to the release folder specified in IPublishProviderPackOptions.releaseFolder. Tarball naming follows npm conventions (scoped packages remove @ and replace / with -; yarn prepends v before the version).
It reads npm-specific options (e.g., registryUrl) from IPublishProjectInfo.providerConfig, which comes from the project's riggable config/publish.json under the "npm" provider key.
5.7 rush-vscode-publish-plugin (Autoinstaller)
New package:rush-plugins/rush-vscode-publish-plugin/
This is an autoinstaller-based plugin that publishes VSIX files to the VS Code Marketplace using the @vscode/vsce tool.
The VsixPublishProvider implements IPublishProvider and delegates to @vscode/vsce for both publishing and packing. It reads VSIX-specific options from IPublishProjectInfo.providerConfig (sourced from the project's config/publish.json under the "vsix" key). It follows the same patterns as VSCodeExtensionPublishPlugin.ts in heft-plugins/heft-vscode-extension-plugin/.
The packAsync method runs vsce package --no-dependencies --out <releaseFolder>/<name>.vsix to produce a distributable VSIX file. The publishAsync method runs vsce publish --packagePath <vsix> to publish to the Marketplace:
exportclassVsixPublishProviderimplementsIPublishProvider{publicreadonlyproviderName: string='vsix';publicasyncpublishAsync(options: IPublishProviderPublishOptions): Promise<Map<string,boolean>>{constresults=newMap<string,boolean>();for(constprojectInfoofoptions.projects){constvsixConfig=projectInfo.providerConfigasIVsixProviderConfig;constvsixPath=path.resolve(projectInfo.project.projectFolder,vsixConfig.vsixPathPattern??'dist/vsix/extension.vsix');if(options.dryRun){options.logger.terminal.writeLine(`[DRY RUN] Would publish ${vsixPath}`);results.set(projectInfo.project.packageName,true);continue;}// Delegate to @vscode/vsce CLI (same approach as heft-vscode-extension-plugin)constargs=['publish','--no-dependencies','--packagePath',vsixPath];if(vsixConfig.useAzureCredential!==false){args.push('--azure-credential');}constresult=awaitExecutable.waitForExitAsync(Executable.spawn(process.execPath,[vsceScriptPath, ...args]));results.set(projectInfo.project.packageName,result.exitCode===0);}returnresults;}publicasynccheckExistsAsync(options: IPublishProviderCheckExistsOptions): Promise<boolean>{// VS Code Marketplace doesn't have a simple "does this version exist" check// like npm. Return false to always attempt publishing.returnfalse;}}
Instead of a global common/config/rush/publish-config.json, publish provider configuration lives in a per-project riggable config file at config/publish.json. This follows the established pattern of config/rush-project.json which uses ProjectConfigurationFile with rig resolution.
Why riggable per-project config instead of global config:
The config/rush-project.json pattern is proven and well-understood in the codebase
Rig packages can provide shared defaults for all projects of a given type (e.g., the heft-vscode-extension-rig could provide VSIX publish defaults)
Projects can override rig defaults without modifying global state
No cross-project configuration coupling -- each project is self-contained
The build cache plugins use build-cache.json as a global config, but publish providers have more variation per-project (e.g., different VSIX paths, different npm registries per scope)
Schema (publish.schema.json):
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Publish Configuration",
"description": "Per-project configuration for publish providers. Resolved through the rig system.",
"type": "object",
"properties": {
"providers": {
"description": "Provider-specific configuration keyed by publish target name.",
"type": "object",
"additionalProperties": {
"type": "object",
"description": "Configuration options for a specific publish provider. Schema varies by provider."
}
}
},
"additionalProperties": false
}
Example: config/publish.json in a VS Code extension project:
Projects using this rig inherit these defaults via the standard config/rig.json mechanism. A project can override specific values in its own config/publish.json, and the ProjectConfigurationFile rig resolution merges them.
To enable the plugin system, new hooks are added to RushLifecycleHooks:
// In RushLifeCycle.tsexportclassRushLifecycleHooks{// ... existing hooks .../** * Fires before the publish command begins processing. * Allows plugins to perform setup (authentication, validation). */publicreadonlybeforePublish: AsyncSeriesHook<IPublishCommand>=newAsyncSeriesHook<IPublishCommand>(['command']);/** * Fires after all publish providers have completed. * Allows plugins to perform cleanup or reporting. */publicreadonlyafterPublish: AsyncSeriesHook<IPublishResult>=newAsyncSeriesHook<IPublishResult>(['result']);}
These hooks are invoked by the refactored PublishAction:
// In PublishAction.runAsync()awaitthis._rushSession.hooks.beforePublish.promise({actionName: this.actionName});// ... dispatch to providers ...awaitthis._rushSession.hooks.afterPublish.promise({ results });
5.10 VS Code Extension Project Configuration Changes
The 4 VS Code extension projects in rush.json gain shouldPublish: true and publishTarget: ["vsix"]:
Note:@rushstack/vscode-shared remains shouldPublish: false since it is an internal library not published independently.
Validation concern: These extensions have "private": true in their package.json (since they are not npm packages). The current constructor validation at RushConfigurationProject.ts:331-336 throws when shouldPublish: true and package.json has "private": true. This validation is relaxed: the check only applies when publishTargets includes "npm" (see Section 5.2).
5.11 Version Synchronization for VS Code Extensions
The vsce tool reads the extension version directly from package.json via its readManifest() function (confirmed in @vscode/vsce@3.2.1 source at out/package.js:1027). This means:
rush version --bump updates package.json version fields (already npm-agnostic)
vsce package reads the version from package.json and embeds it in the VSIX manifest
vsce publish --packagePath publishes the VSIX with the version from its manifest
Therefore, VS Code extension versions always match. The version in package.json is the single source of truth, managed by rush version --bump, and automatically picked up by vsce. No additional synchronization logic is needed -- the existing version pipeline handles this naturally.
This is a key advantage of reusing the existing version management pipeline rather than inventing a separate versioning system for non-npm artifacts.
5.12 Version Policy for VS Code Extensions
The VS Code extensions should use individual versioning (no version policy name needed). Each extension maintains its own version in its package.json and receives independent bumps based on its change files. This is the default behavior for shouldPublish: true projects without a versionPolicyName.
If a lockstep policy is desired in the future (e.g., to keep all extensions at the same version), a new lockstep policy could be added to common/config/rush/version-policies.json:
This is an optional future enhancement, not required for the initial implementation.
5.13 Data Flow (Complete)
Developer makes changes to VS Code extension
|
v
rush change
(ChangeAction.ts)
- shouldPublish gate passes (shouldPublish: true)
- Prompts for comment + bump type
- Writes JSON to common/changes/rushstack/
|
v
rush version --bump
(VersionManager.bumpAsync())
- Reads change files
- Computes new version via semver.inc()
- Updates package.json version field <-- vsce reads this automatically
- Generates CHANGELOG.json and CHANGELOG.md
- Deletes processed change files
- Commits to git
|
v
rush publish [--pack]
(PublishAction - refactored)
1. Loads config/publish.json for each project (via rig system)
2. Groups projects by publishTargets (array -- project may appear in multiple groups)
3. For publishTargets: ["none"] --> skip
4. If --pack: dispatch to provider.packAsync()
5. Else: dispatch to provider.publishAsync()
|
+-- npm publish provider
| (rush-npm-publish-plugin, built-in)
| - publishAsync: npm publish --registry ...
| - packAsync: <packageManager> pack → .tgz → release folder
| - checkExistsAsync: npm view versions
| - .npmrc-publish handling
|
+-- VSIX publish provider
(rush-vscode-publish-plugin, autoinstaller)
- publishAsync: vsce publish --azure-credential
- packAsync: vsce package → .vsix → release folder
- checkExistsAsync: always false
6. Alternatives Considered
Option
Pros
Cons
Reason for Rejection
A: New shouldVersion flag separate from shouldPublish
Clean semantic separation; no ambiguity
Breaks backward compatibility; requires migrating all shouldPublish entries; two flags to manage
Doubles the configuration surface area. shouldPublish already works for the versioning case -- we just need to stop conflating it with npm-only publishing.
B: Custom rush publish shell command
No rush-lib changes; community workaround exists (DEV article)
Loses integration with change files; no _packageExistsAsync equivalent; duplicates version bump logic; no dry-run support
Not a first-class solution; maintenance burden on each consumer.
C: Heft-only publish pipeline (no rush publish involvement)
VS Code extensions already have heft-vscode-extension-plugin; no rush-lib changes needed
Loses rush change integration; no changelogs; version bumps are manual; no coordination with lockstep policies
Throws away the entire version management pipeline for non-npm artifacts.
D: Tag-based conditional publishing (check tags: ["vsix"] in PublishAction)
Minimal code change; uses existing metadata
Hardcodes tag names in rush-lib; not extensible to future targets; violates single-responsibility principle
Tags are metadata, not behavior configuration. Using them as dispatch keys creates hidden coupling.
E: Global publish-config.json (single config file in common/config/rush/)
Simple; single location for all publish settings
Not riggable; no per-project variation; doesn't match the config/rush-project.json pattern; forces all projects to share a single config
Per-project riggable config is more flexible and consistent with established patterns. Projects in different rigs may have very different publishing needs.
F: publishTarget as single string
Simpler schema; fewer edge cases
Cannot publish to multiple targets from one project; limits future extensibility
Array support is trivially more complex but enables important use cases (dual publishing, multi-registry).
G: publishTarget field + plugin system + riggable config + array support (Selected)
Extensible; backward compatible; leverages existing plugin and rig patterns; clean separation of concerns; additive schema change; supports multi-target publishing
More upfront work than alternatives; requires extracting npm code into a plugin
Selected. The plugin pattern is proven in the codebase (cache providers). The riggable config pattern is proven (rush-project.json). The combination cleanly separates version management from artifact distribution.
7. Cross-Cutting Concerns
7.1 Backward Compatibility
This is the highest-priority concern. Every existing shouldPublish: true project must continue to work without any configuration changes.
Guarantees:
publishTarget defaults to ["npm"] when omitted -- inferred for all existing projects
The rush-npm-publish-plugin is a permanently built-in plugin (registered by PluginManager like cache plugins) -- no user configuration required
The shouldPublish getter returns the same value for all existing projects
rush change, rush version --bump are semantically unchanged
rush publish produces identical behavior for npm-target projects
The shouldPublish: true + private: true validation only changes for non-npm targets
Existing projects without config/publish.json continue to work -- providers use sensible defaults when no per-project config is present
7.2 rush publish CLI Flag Compatibility
The existing rush publish flags must work correctly with the new dispatch:
Flag
npm provider
VSIX provider
Behavior
--include-all
Publish all npm-target projects
Publish all VSIX-target projects
Each provider gets its filtered set
--version-policy
Filter by policy
Filter by policy
Same gating applies
--tag
npm dist-tag
Ignored (VSIX has no tag concept)
Provider-specific interpretation
--npm-auth-token
Used for registry auth
Ignored
Provider-specific
--publish
Enables actual publishing
Enables actual publishing
Universal flag
--pack
packAsync: <packageManager> pack → .tgz
packAsync: vsce package → .vsix
Dispatched to IPublishProvider.packAsync (required method)
New provider-specific flags may be needed. These can be contributed by the plugins via the commandLineJsonFilePath mechanism in rush-plugin-manifest.json. Research reference: [research/docs/2026-02-07-rush-plugin-architecture.md, Section 6.2] documents how plugins contribute CLI commands.
7.3 Git Workflow
The existing two-commit workflow during rush version --bump (changelogs first, then package.json updates) is unaffected since it happens before publishing.
During rush publish, git tags are currently created by _gitAddTagsAsync() (line 421). This logic should be generalized:
npm packages: tag format <packageName>_v<version> (existing)
VSIX packages: tag format <packageName>_v<version> (same format, since the package name is unique within the monorepo)
7.4 Error Handling
Each publish provider handles its own errors and returns a Map<string, boolean> from publishAsync(). The PublishAction orchestrator:
Collects results from all providers
Reports failures per-project
Continues publishing other targets even if one target fails (e.g., if npm publish fails, VSIX publish still runs)
Returns non-zero exit code if any publication failed
For multi-target projects (e.g., ["npm", "vsix"]), failure in one target does not prevent the other from running.
7.5 Observability
Each provider logs to the terminal via the ILogger passed in options
The PublishAction logs which provider is handling which projects
The afterPublish hook enables telemetry plugins to report publish outcomes
8. Migration, Rollout, and Testing
8.1 Deployment Strategy
This is a multi-phase rollout designed to minimize risk:
Phase 1: Schema + publishTarget field -- Add the publishTarget field (array support) to the schema and RushConfigurationProject. Default to ["npm"] when omitted. No behavior change for any existing project.
Phase 2: IPublishProvider interface + RushSession registration -- Add the new interface and registration API. No plugins registered yet; PublishAction still uses hardcoded npm logic as fallback.
Phase 3: Riggable config/publish.json -- Add the ProjectConfigurationFile for config/publish.json with rig resolution and schema validation. No behavior change yet.
Phase 4: Extract rush-npm-publish-plugin -- Move npm publishing logic into a built-in plugin. The plugin registers for publishTarget: "npm". Add to rush-lib's publishOnlyDependencies. Includes publishAsync, packAsync (extracted from _npmPackAsync), and checkExistsAsync. Behavior is identical to pre-refactor.
Phase 5: Refactor PublishAction to dispatch -- PublishAction uses registered providers instead of hardcoded logic for both --publish and --pack. The --pack branch calls _packProjectViaProvidersAsync which dispatches to provider.packAsync. Delete _npmPackAsync and _calculateTarballName. The npm plugin handles all existing projects. Regression test.
Phase 6: Create rush-vscode-publish-plugin -- Build the VSIX publish provider as an autoinstaller plugin.
Phase 7: Add rig defaults -- Add config/publish.json to heft-vscode-extension-rig with default VSIX provider configuration.
Phase 8: Enable VS Code extensions -- Set shouldPublish: true and publishTarget: ["vsix"] on the 4 extension projects. Relax the private: true validation. Run rush change to verify they appear in prompts.
Phase 9: Publish hooks -- Add beforePublish and afterPublish hooks. These are additive and don't affect existing behavior.
8.2 Test Plan
Unit Tests:
RushConfigurationProject: Test publishTargets getter with various configurations (omitted infers ["npm"], string normalized to array, explicit array)
RushConfigurationProject: Test that shouldPublish: true + private: true + publishTarget: ["vsix"] does NOT throw
RushConfigurationProject: Test that shouldPublish: true + private: true + publishTarget: ["npm"] DOES throw (existing behavior)
RushConfigurationProject: Test that ["npm", "none"] is rejected as invalid
RushSession: Test registerPublishProviderFactory and getPublishProviderFactory registration and retrieval
RushSession: Test duplicate registration throws error
Riggable config: Test config/publish.json loading with rig resolution, property inheritance, and missing file fallback
Integration Tests:
PublishAction: Test dispatch to mock providers based on publishTargets
PublishAction: Test that projects with publishTarget: ["none"] are skipped
PublishAction: Test that missing provider for a target throws descriptive error
PublishAction: Test --include-all with mixed npm and VSIX targets
PublishAction: Test multi-target project dispatches to all its targets
PublishAction: Test that per-project config/publish.json config is correctly passed to providers via providerConfig
ChangeAction: Test that VSIX-target projects appear in rush change prompts
VersionManager: Test that rush version --bump processes VSIX-target projects
Pack Tests (packAsync):
NpmPublishProvider: Test packAsync spawns <packageManager> pack in the project's publishFolder
NpmPublishProvider: Test packAsync moves tarball to release folder with correct name (scoped, unscoped, yarn v prefix)
NpmPublishProvider: Test packAsync dry run mode (logs but does not spawn)
VsixPublishProvider: Test packAsync spawns vsce package --no-dependencies --out <releaseFolder>/<name>.vsix
VsixPublishProvider: Test packAsync dry run mode (logs but does not spawn)
PublishAction: Test --pack dispatches to provider.packAsync instead of the removed _npmPackAsync
PublishAction: Test --pack with multi-target project dispatches to all providers' packAsync
PublishAction: Test --release-folder is passed through to IPublishProviderPackOptions.releaseFolder
End-to-End Tests:
Build a VS Code extension, run rush change, rush version --bump, verify version increment in package.json and CHANGELOG generation
Run rush publish --publish with both npm and VSIX projects, verify each provider receives the correct project set
Run rush publish --pack and verify each provider's packAsync produces its artifact type (.tgz for npm, .vsix for VSIX)
Verify a project with publishTarget: ["npm", "vsix"] dispatches to both providers for both --publish and --pack
Test fixtures:
Create a test fixture repo (similar to existing fixtures in libraries/rush-lib/src/cli/test/) with:
An npm package with shouldPublish: true (default publishTargets inferred as ["npm"])
A VSIX project with shouldPublish: true, publishTarget: ["vsix"]
A multi-target project with shouldPublish: true, publishTarget: ["npm", "vsix"]
A version-only project with shouldPublish: true, publishTarget: ["none"]
Rig packages with config/publish.json defaults for testing inheritance
9. Resolved Design Decisions
The following questions from the original draft have been resolved:
publishTarget supports arrays. A project can publish to multiple targets (e.g., ["npm", "internal-registry"]). A string value is normalized to a single-element array. This is a first-class feature, not a future enhancement.
The npm plugin remains permanently built-in. It is listed in rush-lib's publishOnlyDependencies alongside the build cache plugins. Since virtually all Rush users depend on npm publishing, keeping it built-in avoids a disruptive migration and ensures zero-config for the common case.
No global publish-config.json. All publish provider configuration is per-project and riggable via config/publish.json, resolved through ProjectConfigurationFile + RigConfig.loadForProjectFolderAsync(). This follows the config/rush-project.json pattern and enables rig packages to provide shared defaults.
publishTarget: ["npm"] is inferred when omitted. When shouldPublish: true and no publishTarget is specified, the field defaults to ["npm"] for backward compatibility. This means existing projects require zero configuration changes.
VS Code extension versions always match. The vsce tool reads the version directly from package.json via readManifest(). Since rush version --bump updates package.json, the versions are naturally synchronized. No additional version synchronization logic is needed.
The VSIX publish plugin lives in rush-plugins/. It implements IRushPlugin (not a Heft plugin), so rush-plugins/ is the correct location. It shares the @vscode/vsce@3.2.1 dependency version with heft-vscode-extension-plugin, managed by its own autoinstaller.
packAsync is a required method on IPublishProvider. The --pack flag is dispatched through providers rather than hardcoded to npm. Each provider implements packAsync to produce its artifact type (npm → .tgz, vsix → .vsix). For multi-target projects, all providers' packAsync methods are called, producing all artifact types in the release folder. The release folder is passed via IPublishProviderPackOptions.releaseFolder. Git tagging (--apply-git-tags-on-pack) remains in the PublishAction orchestrator. Detailed spec: [specs/rush-publish-pack-as-provider-hook.md]
10. Remaining Open Questions
How should rush publish --include-all interact with publishTarget: ["none"] projects? The proposed behavior is to skip them entirely (they opted out of publishing). Should there be a warning? A --include-none-targets override?
How does this interact with rush version --ensure-version-policy? The ensure mode aligns project versions to policy constraints. It currently skips non-shouldPublish projects. Once VS Code extensions have shouldPublish: true, they will participate in ensure if assigned a version policy. This is desired behavior but should be validated.
Should config/publish.json support provider-level schema validation? The current design uses a generic additionalProperties: { type: "object" } for provider sections. Should each registered provider contribute its own JSON schema for validation (similar to how optionsSchema works in plugin manifests)?
How should multi-target publishing interact with --pack? Resolved: packAsync is a required method on IPublishProvider. Each provider produces its artifact type. A project with publishTarget: ["npm", "vsix"] produces both .tgz and .vsix files. See [specs/rush-publish-pack-as-provider-hook.md].
Rush Publishing v2: Decoupled Versioning and Plugin-Based Publishing
1. Executive Summary
This RFC proposes decoupling Rush's version bumping system from npm publishing and introducing a plugin-based publish architecture. Today, the
shouldPublishflag inrush.jsonsimultaneously gates version bumping (changelogs, semver increments) and npm publishing -- making it impossible for non-npm artifacts like VS Code extensions to participate in therush change/rush versionworkflow. The proposed solution introduces: (1) decouplingshouldPublishfrom npm-only semantics, (2) apublishTargetarray that routes publishing to one or more backends, and (3) a publish plugin system (registerPublishProviderFactory) modeled after the existing build cache plugin pattern. Provider configuration is riggable per-project viaconfig/publish.json(following theconfig/rush-project.jsonpattern) rather than a global options file. The npm publish provider is always built-in, andpublishTarget: ["npm"]is inferred when omitted for backward compatibility. This enables the 4 VS Code extensions invscode-extensions/to userush changeandrush version --bumpwhile being published as VSIX files through a dedicated plugin rather thannpm publish.2. Context and Motivation
2.1 Current State
Rush's version management pipeline is a three-stage workflow:
The first two stages (
rush changeandrush version --bump) are already npm-agnostic -- they operate on JSON change files,package.jsonversion fields, and CHANGELOG generation without touching any package registry. The third stage (rush publish) is hardcoded tonpm publish.Research reference: [research/docs/2026-02-10-rush-version-bump-system-and-vs-code-extension-versioning.md, Section 8] documents that
ChangeManager,VersionManager,ChangelogGenerator,VersionPolicyConfiguration, andPublishUtilities.findChangeRequestsAsync()contain zero npm-specific code.The architectural separation already exists in the codebase:
rush version --bumprush publish --include-allrush publish(change-based)However, participation in any of these workflows requires
shouldPublish: true(or aversionPolicyName), which is the sole gating mechanism.Architecture diagram (current state):
flowchart TB classDef gate fill:#e53e3e,stroke:#c53030,stroke-width:2px,color:#ffffff,font-weight:600 classDef agnostic fill:#48bb78,stroke:#38a169,stroke-width:2px,color:#ffffff,font-weight:600 classDef npmSpecific fill:#ed8936,stroke:#dd6b20,stroke-width:2px,color:#ffffff,font-weight:600 classDef project fill:#4a90e2,stroke:#357abd,stroke-width:2px,color:#ffffff,font-weight:600 subgraph Projects["rush.json projects"] NpmPkg["npm packages<br>shouldPublish: true"]:::project VscodeExt["VS Code extensions<br>shouldPublish: (absent)"]:::project end Gate{{"shouldPublish<br>gate"}}:::gate subgraph VersionPipeline["Version Pipeline (npm-agnostic)"] RushChange["rush change<br>ChangeAction.ts:375"]:::agnostic RushVersion["rush version --bump<br>VersionManager.bumpAsync()"]:::agnostic Changelog["CHANGELOG generation<br>ChangelogGenerator.ts:288"]:::agnostic end subgraph PublishPipeline["Publish Pipeline (npm-coupled)"] RushPublish["rush publish<br>PublishAction.ts:34"]:::npmSpecific NpmPublish["npm publish<br>_npmPublishAsync():439"]:::npmSpecific end NpmPkg -->|"passes"| Gate VscodeExt -->|"BLOCKED"| Gate Gate --> VersionPipeline VersionPipeline --> PublishPipeline2.2 The Problem
User Impact: VS Code extension developers in the rushstack monorepo cannot use
rush changeto document their changes,rush version --bumpto increment versions, or generate CHANGELOGs. Version management for the 4 extensions (rushstack,debug-certificate-manager,playwright-local-browser-server,@rushstack/rush-vscode-command-webview) must be done manually.Ecosystem Impact: GitHub Issue #3342 documents demand for non-npm publishing targets. Any monorepo with artifacts that aren't npm packages (Docker images, VS Code extensions, NuGet packages, Python packages, mobile apps) faces this same limitation.
Technical Debt: The
shouldPublishflag conflates two orthogonal concerns. Research reference: [research/docs/2026-02-10-rush-version-bump-system-and-vs-code-extension-versioning.md, Section 5] catalogs 10 locations whereshouldPublishgates version-bumping behavior, and 3 additional locations where it gates npm-specific publishing behavior -- all through the same boolean.The specific coupling points are:
ChangeAction.ts:375ChangeManager.ts:27PublishUtilities.ts:373PublishUtilities.ts:408PublishUtilities.ts:502ChangelogGenerator.ts:288VersionManager.ts:330PublishAction.ts:372PublishAction.ts:426PublishAction.ts:4433. Goals and Non-Goals
3.1 Functional Goals
rush changeworkflow (prompted for change descriptions/bump types)rush version --bump(updatedpackage.jsonversion, CHANGELOG generation)rush publishcan dispatch to different publish backends (npm, VSIX) based on project configurationregisterPublishProviderFactoryAPI onRushSessionallows plugins to register custom publish providersheft-vscode-extension-plugininfrastructurepublishTarget: ["none"])publishTargetsupports arrays, enabling a single project to publish to multiple targets (e.g.,["npm", "internal-registry"])config/publish.json(no global options file)package.jsonversion (managed byrush version --bump)3.2 Non-Goals (Out of Scope)
common/changes/)shouldPublish: trueprojects -- they continue to work identicallyrush version --bumpcommand semantics -- only its gating logicrush.jsonschema version -- changes are additiveheft-vscode-extension-pluginbuild pipeline (packaging/signing) -- only the publish dispatch4. Proposed Solution (High-Level Design)
4.1 System Architecture Diagram
flowchart TB classDef gate fill:#5a67d8,stroke:#4c51bf,stroke-width:2px,color:#ffffff,font-weight:600 classDef agnostic fill:#48bb78,stroke:#38a169,stroke-width:2px,color:#ffffff,font-weight:600 classDef plugin fill:#ed8936,stroke:#dd6b20,stroke-width:2px,color:#ffffff,font-weight:600 classDef project fill:#4a90e2,stroke:#357abd,stroke-width:2px,color:#ffffff,font-weight:600 classDef config fill:#718096,stroke:#4a5568,stroke-width:2px,color:#ffffff,font-weight:600 subgraph Projects["rush.json projects"] NpmPkg["npm packages<br>shouldPublish: true<br>(publishTarget inferred: npm)"]:::project VscodeExt["VS Code extensions<br>shouldPublish: true<br>publishTarget: [vsix]"]:::project MultiTarget["Multi-target projects<br>shouldPublish: true<br>publishTarget: [npm, vsix]"]:::project VersionOnly["Version-only projects<br>shouldPublish: true<br>publishTarget: [none]"]:::project end subgraph VersionPipeline["Version Pipeline (unchanged, npm-agnostic)"] RushChange["rush change"]:::agnostic RushVersion["rush version --bump"]:::agnostic Changelog["CHANGELOG generation"]:::agnostic end subgraph PublishDispatch["Publish Dispatch (new)"] RushPublish["rush publish<br>(orchestrator)"]:::gate subgraph Plugins["Publish Provider Plugins"] NpmPlugin["npm publish plugin<br>(always built-in)"]:::plugin VsixPlugin["vsix publish plugin<br>(autoinstaller)"]:::plugin CustomPlugin["custom plugin<br>(future)"]:::plugin end end subgraph Configuration["Per-Project Configuration (riggable)"] PublishJson["config/publish.json<br>(riggable via rig system)"]:::config RushSession["RushSession<br>registerPublishProviderFactory()"]:::config end Projects --> VersionPipeline VersionPipeline --> PublishDispatch RushPublish -->|"publishTarget: npm<br>(default)"| NpmPlugin RushPublish -->|"publishTarget: vsix"| VsixPlugin RushPublish -->|"publishTarget: custom"| CustomPlugin NpmPlugin -.->|"registers"| RushSession VsixPlugin -.->|"registers"| RushSession CustomPlugin -.->|"registers"| RushSession PublishJson -.->|"configures per-project"| RushPublish4.2 Architectural Pattern
The design follows four patterns already established in the Rush codebase:
Factory Registration Pattern (from build cache plugins): Publish providers register via
rushSession.registerPublishProviderFactory(name, factory), identical to howregisterCloudBuildCacheProviderFactoryworks today. The factory receives provider-specific configuration read from the project's riggable config file, mirroring how build cache plugin factories receive their section frombuild-cache.json. Research reference: [research/docs/2026-02-07-rush-plugin-architecture.md, Section 4.1] documentsRushSession's existing registration methods.Strategy Pattern (from version policies): Different publish targets are handled by different
IPublishProviderimplementations, similar to howLockStepVersionPolicyandIndividualVersionPolicyare strategy implementations ofVersionPolicy. Research reference: [research/docs/2026-02-10-rush-version-bump-system-and-vs-code-extension-versioning.md, Section 2] documents the version policy strategy pattern.Associated Commands Pattern (from existing plugins): Publish plugins declare
"associatedCommands": ["publish"]in their manifest, so they only load whenrush publishruns. Research reference: [research/docs/2026-02-07-existing-rush-plugins.md, Section "Plugin Infrastructure"] documents the associated commands mechanism.Riggable Configuration Pattern (from
rush-project.json): Provider options are stored in a per-projectconfig/publish.jsonfile that is resolved through the rig system usingProjectConfigurationFileandRigConfig.loadForProjectFolderAsync(). This follows the exact same pattern asconfig/rush-project.json-- projects can define their own config or inherit from their rig package. TheProjectConfigurationFileclass provides schema validation andpropertyInheritancefor merging rig defaults with project overrides.4.3 Key Components
shouldPublishgate refactorshouldPublishprojects regardless of publish targetshouldPublishalready gates versioning; we just need to stop conflating it with npm-only publishingpublishTargetfield (array)["npm"]when omitted for backward compatibility; supports multi-target publishingconfig/publish.json(riggable)ProjectConfigurationFileconfig/rush-project.jsonpattern; no global options file; rig packages can provide shared defaultsIPublishProviderinterfaceICloudBuildCacheProviderpatternregisterPublishProviderFactory()RushSessionregisterCloudBuildCacheProviderFactory()rush-npm-publish-pluginPublishAction)publishOnlyDependenciesrush-vscode-publish-plugin@vscode/vsceheft-vscode-extension-pluginpatternsPublishActionrefactor5. Detailed Design
5.1 rush.json Schema Changes
The
rush.jsonproject entry schema (libraries/rush-lib/src/schemas/rush.schema.json) gains one new optional field:{ "publishTarget": { "description": "Specifies the publish targets for this project. Determines which publish provider plugins handle publishing. Each entry maps to a registered publish provider. Common values: 'npm', 'vsix', 'none'. When set to ['none'], the project participates in versioning but is not published by any provider. When omitted, defaults to ['npm'] for backward compatibility.", "oneOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" }, "minItems": 1 } ] } }Default behavior: When
publishTargetis omitted, it defaults to["npm"]for backward compatibility. Projects withshouldPublish: trueand nopublishTargetbehave exactly as they do today. A string value is normalized to a single-element array (e.g.,"vsix"becomes["vsix"]).Example rush.json entries:
5.2
RushConfigurationProjectChangesFile:
libraries/rush-lib/src/api/RushConfigurationProject.tsAdd a new
publishTargetsproperty:The existing
shouldPublishgetter remains unchanged:This means
shouldPublishcontinues to gate version bumping for all projects. The newpublishTargetsfield only affects therush publishcommand's dispatch logic.Validation: In the constructor, add validations:
publishTarget: ["none"]is incompatible withversionPolicyNameon lockstep policies (since lockstep policies inherently couple versioning with publishing intent). Individual version policies can usepublishTarget: ["none"]."none"cannot be combined with other targets in the same array (e.g.,["npm", "none"]is invalid).private: truevalidation is relaxed for non-npm targets:5.3
IPublishProviderInterfaceNew file:
libraries/rush-lib/src/pluginFramework/IPublishProvider.ts5.4
RushSessionRegistration APIFile:
libraries/rush-lib/src/pluginFramework/RushSession.tsAdd registration methods following the existing pattern:
5.5
PublishActionRefactorFile:
libraries/rush-lib/src/cli/actions/PublishAction.tsThe
PublishActioncurrently hardcodes npm publishing in_npmPublishAsync()(line 439) and_packageExistsAsync()(line 488). The refactor replaces these with provider dispatch:Phase 1: Load Per-Project Configuration
Before dispatch, load each project's
config/publish.jsonvia the riggable config system:Phase 2: Provider Resolution
In
_publishChangesAsync()(line 278) and_publishAllAsync()(line 361), after computing the set of projects to publish, group them by publish target. SincepublishTargetsis an array, a single project may appear in multiple groups:Phase 3: Provider Dispatch
Phase 4: Pack Dispatch
When
--packis used,PublishActiondispatches to each provider'spackAsyncmethod instead ofpublishAsync. This replaces the hardcoded_npmPackAsynccall:The
_publishAllAsyncbranching at line 411 changes from:to:
The
_npmPackAsyncand_calculateTarballNameprivate methods are deleted fromPublishAction— their logic moves intoNpmPublishProvider.packAsync.Phase 5: Extract npm-specific code
The existing
_npmPublishAsync(),_packageExistsAsync(),.npmrc-publishhandling, and npm-specific flag construction move into therush-npm-publish-plugin.5.6
rush-npm-publish-plugin(Always Built-In)New package:
rush-plugins/rush-npm-publish-plugin/This is a permanently built-in plugin (registered in
PluginManageralongside the cache plugins) that extracts the existing npm publish logic fromPublishAction. It is listed in rush-lib'spublishOnlyDependencies(same mechanism as the S3, Azure, and HTTP build cache plugins). The npm provider will always ship with Rush -- it will never be moved to an autoinstaller since virtually all Rush users depend on npm publishing.Structure:
rush-plugin-manifest.json:{ "plugins": [ { "pluginName": "rush-npm-publish-plugin", "description": "Default publish provider for npm registries", "entryPoint": "lib/index.js", "associatedCommands": ["publish"] } ] }RushNpmPublishPlugin.ts:The
NpmPublishProviderclass encapsulates the existing logic from:PublishAction._npmPublishAsync()(line 439) →publishAsyncPublishAction._packageExistsAsync()(line 488) →checkExistsAsyncPublishAction._npmPackAsync()(line 503) →packAsyncPublishAction._calculateTarballName()(line 531) → private helper inNpmPublishProviderPublishAction._addSharedNpmConfig()(npm config handling)npmrcUtilities.tsfor.npmrc-publishmanagementThe
packAsyncmethod runs<packageManager> packin the project'spublishFolderand moves the resulting.tgztarball to the release folder specified inIPublishProviderPackOptions.releaseFolder. Tarball naming follows npm conventions (scoped packages remove@and replace/with-; yarn prependsvbefore the version).It reads npm-specific options (e.g.,
registryUrl) fromIPublishProjectInfo.providerConfig, which comes from the project's riggableconfig/publish.jsonunder the"npm"provider key.5.7
rush-vscode-publish-plugin(Autoinstaller)New package:
rush-plugins/rush-vscode-publish-plugin/This is an autoinstaller-based plugin that publishes VSIX files to the VS Code Marketplace using the
@vscode/vscetool.Structure:
rush-plugin-manifest.json:{ "plugins": [ { "pluginName": "rush-vscode-publish-plugin", "description": "Publish provider for VS Code extensions (VSIX files)", "entryPoint": "lib/index.js", "associatedCommands": ["publish"] } ] }VsixPublishProvider.ts:The
VsixPublishProviderimplementsIPublishProviderand delegates to@vscode/vscefor both publishing and packing. It reads VSIX-specific options fromIPublishProjectInfo.providerConfig(sourced from the project'sconfig/publish.jsonunder the"vsix"key). It follows the same patterns asVSCodeExtensionPublishPlugin.tsinheft-plugins/heft-vscode-extension-plugin/.The
packAsyncmethod runsvsce package --no-dependencies --out <releaseFolder>/<name>.vsixto produce a distributable VSIX file. ThepublishAsyncmethod runsvsce publish --packagePath <vsix>to publish to the Marketplace:5.8 Per-Project Riggable Configuration (
config/publish.json)Instead of a global
common/config/rush/publish-config.json, publish provider configuration lives in a per-project riggable config file atconfig/publish.json. This follows the established pattern ofconfig/rush-project.jsonwhich usesProjectConfigurationFilewith rig resolution.Why riggable per-project config instead of global config:
config/rush-project.jsonpattern is proven and well-understood in the codebaseheft-vscode-extension-rigcould provide VSIX publish defaults)build-cache.jsonas a global config, but publish providers have more variation per-project (e.g., different VSIX paths, different npm registries per scope)Schema (
publish.schema.json):{ "$schema": "http://json-schema.org/draft-04/schema#", "title": "Publish Configuration", "description": "Per-project configuration for publish providers. Resolved through the rig system.", "type": "object", "properties": { "providers": { "description": "Provider-specific configuration keyed by publish target name.", "type": "object", "additionalProperties": { "type": "object", "description": "Configuration options for a specific publish provider. Schema varies by provider." } } }, "additionalProperties": false }Example:
config/publish.jsonin a VS Code extension project:{ "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/publish.schema.json", "providers": { "vsix": { "vsixPathPattern": "dist/vsix/extension.vsix", "useAzureCredential": true } } }Example:
config/publish.jsonin an npm package project (optional -- defaults are sensible):{ "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/publish.schema.json", "providers": { "npm": { "registryUrl": "https://registry.npmjs.org/" } } }Example:
config/publish.jsonin a rig package (provides defaults for all extension projects):Located at
rigs/heft-vscode-extension-rig/profiles/default/config/publish.json:{ "providers": { "vsix": { "vsixPathPattern": "dist/vsix/extension.vsix", "useAzureCredential": true } } }Projects using this rig inherit these defaults via the standard
config/rig.jsonmechanism. A project can override specific values in its ownconfig/publish.json, and theProjectConfigurationFilerig resolution merges them.Loading via
ProjectConfigurationFile:Comparison with build cache plugin pattern:
common/config/rush/build-cache.json(global)config/publish.json(per-project, riggable)build-cache.jsonIPublishProjectInfo.providerConfigProjectConfigurationFile+RigConfig.loadForProjectFolderAsync()5.9
PublishActionLifecycle HooksTo enable the plugin system, new hooks are added to
RushLifecycleHooks:These hooks are invoked by the refactored
PublishAction:5.10 VS Code Extension Project Configuration Changes
The 4 VS Code extension projects in
rush.jsongainshouldPublish: trueandpublishTarget: ["vsix"]:Note:
@rushstack/vscode-sharedremainsshouldPublish: falsesince it is an internal library not published independently.Validation concern: These extensions have
"private": truein theirpackage.json(since they are not npm packages). The current constructor validation atRushConfigurationProject.ts:331-336throws whenshouldPublish: trueandpackage.jsonhas"private": true. This validation is relaxed: the check only applies whenpublishTargetsincludes"npm"(see Section 5.2).5.11 Version Synchronization for VS Code Extensions
The
vscetool reads the extension version directly frompackage.jsonvia itsreadManifest()function (confirmed in@vscode/vsce@3.2.1source atout/package.js:1027). This means:rush version --bumpupdatespackage.jsonversion fields (already npm-agnostic)vsce packagereads the version frompackage.jsonand embeds it in the VSIX manifestvsce publish --packagePathpublishes the VSIX with the version from its manifestTherefore, VS Code extension versions always match. The version in
package.jsonis the single source of truth, managed byrush version --bump, and automatically picked up byvsce. No additional synchronization logic is needed -- the existing version pipeline handles this naturally.This is a key advantage of reusing the existing version management pipeline rather than inventing a separate versioning system for non-npm artifacts.
5.12 Version Policy for VS Code Extensions
The VS Code extensions should use individual versioning (no version policy name needed). Each extension maintains its own version in its
package.jsonand receives independent bumps based on its change files. This is the default behavior forshouldPublish: trueprojects without aversionPolicyName.If a lockstep policy is desired in the future (e.g., to keep all extensions at the same version), a new lockstep policy could be added to
common/config/rush/version-policies.json:{ "policyName": "vscode-extensions", "definitionName": "lockStepVersion", "version": "1.0.0", "nextBump": "patch", "mainProject": "rushstack" }This is an optional future enhancement, not required for the initial implementation.
5.13 Data Flow (Complete)
6. Alternatives Considered
shouldVersionflag separate fromshouldPublishshouldPublishentries; two flags to manageshouldPublishalready works for the versioning case -- we just need to stop conflating it with npm-only publishing.rush publishshell command_packageExistsAsyncequivalent; duplicates version bump logic; no dry-run supportrush publishinvolvement)heft-vscode-extension-plugin; no rush-lib changes neededrush changeintegration; no changelogs; version bumps are manual; no coordination with lockstep policiestags: ["vsix"]inPublishAction)publish-config.json(single config file incommon/config/rush/)config/rush-project.jsonpattern; forces all projects to share a single configpublishTargetas single stringpublishTargetfield + plugin system + riggable config + array support (Selected)7. Cross-Cutting Concerns
7.1 Backward Compatibility
This is the highest-priority concern. Every existing
shouldPublish: trueproject must continue to work without any configuration changes.Guarantees:
publishTargetdefaults to["npm"]when omitted -- inferred for all existing projectsrush-npm-publish-pluginis a permanently built-in plugin (registered byPluginManagerlike cache plugins) -- no user configuration requiredshouldPublishgetter returns the same value for all existing projectsrush change,rush version --bumpare semantically unchangedrush publishproduces identical behavior for npm-target projectsshouldPublish: true+private: truevalidation only changes for non-npm targetsconfig/publish.jsoncontinue to work -- providers use sensible defaults when no per-project config is present7.2
rush publishCLI Flag CompatibilityThe existing
rush publishflags must work correctly with the new dispatch:--include-all--version-policy--tag--npm-auth-token--publish--packpackAsync:<packageManager> pack→.tgzpackAsync:vsce package→.vsixIPublishProvider.packAsync(required method)New provider-specific flags may be needed. These can be contributed by the plugins via the
commandLineJsonFilePathmechanism inrush-plugin-manifest.json. Research reference: [research/docs/2026-02-07-rush-plugin-architecture.md, Section 6.2] documents how plugins contribute CLI commands.7.3 Git Workflow
The existing two-commit workflow during
rush version --bump(changelogs first, then package.json updates) is unaffected since it happens before publishing.During
rush publish, git tags are currently created by_gitAddTagsAsync()(line 421). This logic should be generalized:<packageName>_v<version>(existing)<packageName>_v<version>(same format, since the package name is unique within the monorepo)7.4 Error Handling
Each publish provider handles its own errors and returns a
Map<string, boolean>frompublishAsync(). ThePublishActionorchestrator:For multi-target projects (e.g.,
["npm", "vsix"]), failure in one target does not prevent the other from running.7.5 Observability
ILoggerpassed in optionsPublishActionlogs which provider is handling which projectsafterPublishhook enables telemetry plugins to report publish outcomes8. Migration, Rollout, and Testing
8.1 Deployment Strategy
This is a multi-phase rollout designed to minimize risk:
publishTargetfield -- Add thepublishTargetfield (array support) to the schema andRushConfigurationProject. Default to["npm"]when omitted. No behavior change for any existing project.IPublishProviderinterface +RushSessionregistration -- Add the new interface and registration API. No plugins registered yet;PublishActionstill uses hardcoded npm logic as fallback.config/publish.json-- Add theProjectConfigurationFileforconfig/publish.jsonwith rig resolution and schema validation. No behavior change yet.rush-npm-publish-plugin-- Move npm publishing logic into a built-in plugin. The plugin registers forpublishTarget: "npm". Add to rush-lib'spublishOnlyDependencies. IncludespublishAsync,packAsync(extracted from_npmPackAsync), andcheckExistsAsync. Behavior is identical to pre-refactor.PublishActionto dispatch --PublishActionuses registered providers instead of hardcoded logic for both--publishand--pack. The--packbranch calls_packProjectViaProvidersAsyncwhich dispatches toprovider.packAsync. Delete_npmPackAsyncand_calculateTarballName. The npm plugin handles all existing projects. Regression test.rush-vscode-publish-plugin-- Build the VSIX publish provider as an autoinstaller plugin.config/publish.jsontoheft-vscode-extension-rigwith default VSIX provider configuration.shouldPublish: trueandpublishTarget: ["vsix"]on the 4 extension projects. Relax theprivate: truevalidation. Runrush changeto verify they appear in prompts.beforePublishandafterPublishhooks. These are additive and don't affect existing behavior.8.2 Test Plan
Unit Tests:
RushConfigurationProject: TestpublishTargetsgetter with various configurations (omitted infers["npm"], string normalized to array, explicit array)RushConfigurationProject: Test thatshouldPublish: true+private: true+publishTarget: ["vsix"]does NOT throwRushConfigurationProject: Test thatshouldPublish: true+private: true+publishTarget: ["npm"]DOES throw (existing behavior)RushConfigurationProject: Test that["npm", "none"]is rejected as invalidRushSession: TestregisterPublishProviderFactoryandgetPublishProviderFactoryregistration and retrievalRushSession: Test duplicate registration throws errorconfig/publish.jsonloading with rig resolution, property inheritance, and missing file fallbackIntegration Tests:
PublishAction: Test dispatch to mock providers based onpublishTargetsPublishAction: Test that projects withpublishTarget: ["none"]are skippedPublishAction: Test that missing provider for a target throws descriptive errorPublishAction: Test--include-allwith mixed npm and VSIX targetsPublishAction: Test multi-target project dispatches to all its targetsPublishAction: Test that per-projectconfig/publish.jsonconfig is correctly passed to providers viaproviderConfigChangeAction: Test that VSIX-target projects appear inrush changepromptsVersionManager: Test thatrush version --bumpprocesses VSIX-target projectsPack Tests (
packAsync):NpmPublishProvider: TestpackAsyncspawns<packageManager> packin the project'spublishFolderNpmPublishProvider: TestpackAsyncmoves tarball to release folder with correct name (scoped, unscoped, yarnvprefix)NpmPublishProvider: TestpackAsyncdry run mode (logs but does not spawn)VsixPublishProvider: TestpackAsyncspawnsvsce package --no-dependencies --out <releaseFolder>/<name>.vsixVsixPublishProvider: TestpackAsyncdry run mode (logs but does not spawn)PublishAction: Test--packdispatches toprovider.packAsyncinstead of the removed_npmPackAsyncPublishAction: Test--packwith multi-target project dispatches to all providers'packAsyncPublishAction: Test--release-folderis passed through toIPublishProviderPackOptions.releaseFolderEnd-to-End Tests:
rush change,rush version --bump, verify version increment inpackage.jsonand CHANGELOG generationrush publish --publishwith both npm and VSIX projects, verify each provider receives the correct project setrush publish --packand verify each provider'spackAsyncproduces its artifact type (.tgzfor npm,.vsixfor VSIX)publishTarget: ["npm", "vsix"]dispatches to both providers for both--publishand--packTest fixtures:
Create a test fixture repo (similar to existing fixtures in
libraries/rush-lib/src/cli/test/) with:shouldPublish: true(defaultpublishTargetsinferred as["npm"])shouldPublish: true, publishTarget: ["vsix"]shouldPublish: true, publishTarget: ["npm", "vsix"]shouldPublish: true, publishTarget: ["none"]config/publish.jsondefaults for testing inheritance9. Resolved Design Decisions
The following questions from the original draft have been resolved:
publishTargetsupports arrays. A project can publish to multiple targets (e.g.,["npm", "internal-registry"]). A string value is normalized to a single-element array. This is a first-class feature, not a future enhancement.The npm plugin remains permanently built-in. It is listed in rush-lib's
publishOnlyDependenciesalongside the build cache plugins. Since virtually all Rush users depend on npm publishing, keeping it built-in avoids a disruptive migration and ensures zero-config for the common case.No global
publish-config.json. All publish provider configuration is per-project and riggable viaconfig/publish.json, resolved throughProjectConfigurationFile+RigConfig.loadForProjectFolderAsync(). This follows theconfig/rush-project.jsonpattern and enables rig packages to provide shared defaults.publishTarget: ["npm"]is inferred when omitted. WhenshouldPublish: trueand nopublishTargetis specified, the field defaults to["npm"]for backward compatibility. This means existing projects require zero configuration changes.VS Code extension versions always match. The
vscetool reads the version directly frompackage.jsonviareadManifest(). Sincerush version --bumpupdatespackage.json, the versions are naturally synchronized. No additional version synchronization logic is needed.The VSIX publish plugin lives in
rush-plugins/. It implementsIRushPlugin(not a Heft plugin), sorush-plugins/is the correct location. It shares the@vscode/vsce@3.2.1dependency version withheft-vscode-extension-plugin, managed by its own autoinstaller.packAsyncis a required method onIPublishProvider. The--packflag is dispatched through providers rather than hardcoded to npm. Each provider implementspackAsyncto produce its artifact type (npm →.tgz, vsix →.vsix). For multi-target projects, all providers'packAsyncmethods are called, producing all artifact types in the release folder. The release folder is passed viaIPublishProviderPackOptions.releaseFolder. Git tagging (--apply-git-tags-on-pack) remains in thePublishActionorchestrator. Detailed spec: [specs/rush-publish-pack-as-provider-hook.md]10. Remaining Open Questions
How should
rush publish --include-allinteract withpublishTarget: ["none"]projects? The proposed behavior is to skip them entirely (they opted out of publishing). Should there be a warning? A--include-none-targetsoverride?How does this interact with
rush version --ensure-version-policy? Theensuremode aligns project versions to policy constraints. It currently skips non-shouldPublishprojects. Once VS Code extensions haveshouldPublish: true, they will participate inensureif assigned a version policy. This is desired behavior but should be validated.Should
config/publish.jsonsupport provider-level schema validation? The current design uses a genericadditionalProperties: { type: "object" }for provider sections. Should each registered provider contribute its own JSON schema for validation (similar to howoptionsSchemaworks in plugin manifests)?How should multi-target publishing interact withResolved:--pack?packAsyncis a required method onIPublishProvider. Each provider produces its artifact type. A project withpublishTarget: ["npm", "vsix"]produces both.tgzand.vsixfiles. See [specs/rush-publish-pack-as-provider-hook.md].