Skip to content

[vs code extension publishing] - Allow rush change and version policies to be separate from npm publishing #5504

@TheLarkInn

Description

@TheLarkInn

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:

rush change  -->  rush version --bump  -->  rush publish
(collect)         (compute + apply)          (distribute)

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.

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 --> PublishPipeline
Loading

2.2 The Problem

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

4. 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"| RushPublish
Loading

4.2 Architectural Pattern

The design follows four patterns already established in the Rush codebase:

  1. 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.

  2. 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.

  3. 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.

  4. 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
rush-vscode-publish-plugin VSIX publish provider using @vscode/vsce Rush plugin (autoinstaller) Leverages existing heft-vscode-extension-plugin patterns
PublishAction refactor 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"]
}

5.2 RushConfigurationProject Changes

File: libraries/rush-lib/src/api/RushConfigurationProject.ts

Add a new publishTargets property:

// In IRushConfigurationProjectJson (line ~29)
export interface IRushConfigurationProjectJson {
  // ... existing fields ...
  publishTarget?: string | string[];  // NEW
}

// In RushConfigurationProject class
private readonly _publishTargets: ReadonlyArray<string>;

// In constructor (after line ~327)
const rawTarget = projectJson.publishTarget;
if (rawTarget === undefined) {
  this._publishTargets = ['npm'];  // Infer npm when omitted
} else if (typeof rawTarget === '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.
 */
public get publishTargets(): ReadonlyArray<string> {
  return this._publishTargets;
}

The existing shouldPublish getter remains unchanged:

public get shouldPublish(): boolean {
  return this._shouldPublish || !!this.versionPolicyName;
}

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 constructor
if (this._shouldPublish && this.packageJson.private && this._publishTargets.includes('npm')) {
  throw new Error(
    `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

import type { RushConfigurationProject } from '../api/RushConfigurationProject';
import type { ILogger } from './logging/Logger';

/**
 * Information about a project that needs to be published.
 */
export interface IPublishProjectInfo {
  /** The Rush project configuration */
  readonly project: RushConfigurationProject;
  /** The new version string after bumping */
  readonly newVersion: string;
  /** The previous version string before bumping */
  readonly previousVersion: string;
  /** The change type that triggered this publish */
  readonly changeType: string;
  /** Provider-specific configuration for this project, loaded from config/publish.json */
  readonly providerConfig: Record<string, unknown>;
}

/**
 * Options passed to the publish provider's publishAsync method.
 */
export interface IPublishProviderPublishOptions {
  /** Projects to publish, filtered to those matching this provider's target */
  readonly projects: ReadonlyArray<IPublishProjectInfo>;
  /** The npm tag to apply (e.g., 'latest', 'next') */
  readonly tag?: string;
  /** Whether this is a dry run */
  readonly dryRun: boolean;
  /** Logger instance for output */
  readonly logger: ILogger;
}

/**
 * Options passed to the publish provider's checkExistsAsync method.
 */
export interface IPublishProviderCheckExistsOptions {
  /** The project to check */
  readonly project: RushConfigurationProject;
  /** The version to check for */
  readonly version: string;
  /** Provider-specific configuration for this project */
  readonly providerConfig: Record<string, unknown>;
}

/**
 * Options passed to the publish provider's packAsync method.
 */
export interface IPublishProviderPackOptions {
  /** Projects to pack, filtered to those matching this provider's target */
  readonly projects: ReadonlyArray<IPublishProjectInfo>;
  /** The folder where packed artifacts should be placed (from --release-folder or default) */
  readonly releaseFolder: string;
  /** Whether this is a dry run */
  readonly dryRun: boolean;
  /** Logger instance for output */
  readonly logger: ILogger;
}

/**
 * Interface that publish provider plugins must implement.
 * Modeled after ICloudBuildCacheProvider.
 */
export interface IPublishProvider {
  /** Human-readable name for this provider */
  readonly providerName: 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.
 */
export type PublishProviderFactory = () => Promise<IPublishProvider>;

5.4 RushSession Registration API

File: libraries/rush-lib/src/pluginFramework/RushSession.ts

Add registration methods following the existing pattern:

// New private field
private _publishProviderFactories: Map<string, PublishProviderFactory> = new Map();

/**
 * 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();
 * });
 * ```
 */
public registerPublishProviderFactory(
  publishTargetName: string,
  factory: PublishProviderFactory
): void {
  if (this._publishProviderFactories.has(publishTargetName)) {
    throw new Error(
      `A publish provider factory has already been registered for target "${publishTargetName}".`
    );
  }
  this._publishProviderFactories.set(publishTargetName, factory);
}

/**
 * Retrieves a previously registered publish provider factory.
 */
public getPublishProviderFactory(
  publishTargetName: string
): PublishProviderFactory | undefined {
  return this._publishProviderFactories.get(publishTargetName);
}

5.5 PublishAction Refactor

File: libraries/rush-lib/src/cli/actions/PublishAction.ts

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 project
const publishConfig: IPublishJson | undefined =
  await PUBLISH_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 target
const projectsByTarget: Map<string, IPublishProjectInfo[]> = new Map();
for (const [packageName, changeInfo] of allPackageChanges) {
  const project = this.rushConfiguration.projectsByName.get(packageName)!;
  if (!project.shouldPublish) continue;

  const publishConfig = await this._loadPublishConfigAsync(project);

  for (const target of project.publishTargets) {
    if (target === 'none') continue; // Version-only projects skip publishing

    // Extract the provider-specific section from the project's publish config
    const providerConfig = publishConfig?.providers?.[target] ?? {};

    let targetProjects = 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 providers
for (const [targetName, projects] of projectsByTarget) {
  const factory = this._rushSession.getPublishProviderFactory(targetName);
  if (!factory) {
    throw new Error(
      `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.`
    );
  }

  const provider = await factory();
  const results = await provider.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:

// New method: _packProjectViaProvidersAsync
// Mirrors _publishProjectViaProvidersAsync but calls packAsync
const releaseFolder: string = this._releaseFolder.value
  ? this._releaseFolder.value
  : path.join(this.rushConfiguration.commonTempFolder, 'artifacts', 'packages');

FileSystem.ensureFolder(releaseFolder);

for (const target of project.publishTargets) {
  if (target === 'none') continue;
  const provider = await this._getProviderAsync(target, project.packageName);
  const providerConfig = await this._getProviderConfigAsync(project, target);

  await provider.packAsync({
    projects: [{ project, newVersion: version, previousVersion: version,
                 changeType: ChangeType.none, providerConfig }],
    releaseFolder,
    dryRun,
    logger
  });
}

The _publishAllAsync branching at line 411 changes from:

if (this._pack.value) {
  await this._npmPackAsync(packageName, packageConfig);  // OLD: hardcoded npm

to:

if (this._pack.value) {
  await this._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.

Structure:

rush-plugins/rush-npm-publish-plugin/
  package.json
  rush-plugin-manifest.json
  src/
    index.ts
    RushNpmPublishPlugin.ts
    NpmPublishProvider.ts

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:

import type { IRushPlugin } from '@rushstack/rush-sdk';
import type { RushSession, RushConfiguration } from '@rushstack/rush-sdk';

const PLUGIN_NAME: string = 'rush-npm-publish-plugin';

export class RushNpmPublishPlugin implements IRushPlugin {
  public readonly pluginName: string = PLUGIN_NAME;

  public apply(rushSession: RushSession, rushConfiguration: RushConfiguration): void {
    rushSession.hooks.initialize.tap(PLUGIN_NAME, () => {
      rushSession.registerPublishProviderFactory('npm', async () => {
        const { NpmPublishProvider } = await import('./NpmPublishProvider');
        return new NpmPublishProvider(rushConfiguration);
      });
    });
  }
}

export default RushNpmPublishPlugin;

The NpmPublishProvider class encapsulates the existing logic from:

  • PublishAction._npmPublishAsync() (line 439) → publishAsync
  • PublishAction._packageExistsAsync() (line 488) → checkExistsAsync
  • PublishAction._npmPackAsync() (line 503) → packAsync
  • PublishAction._calculateTarballName() (line 531) → private helper in NpmPublishProvider
  • PublishAction._addSharedNpmConfig() (npm config handling)
  • npmrcUtilities.ts for .npmrc-publish management

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.

Structure:

rush-plugins/rush-vscode-publish-plugin/
  package.json
  rush-plugin-manifest.json
  src/
    index.ts
    RushVscodePublishPlugin.ts
    VsixPublishProvider.ts

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 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:

export class VsixPublishProvider implements IPublishProvider {
  public readonly providerName: string = 'vsix';

  public async publishAsync(options: IPublishProviderPublishOptions): Promise<Map<string, boolean>> {
    const results = new Map<string, boolean>();

    for (const projectInfo of options.projects) {
      const vsixConfig = projectInfo.providerConfig as IVsixProviderConfig;
      const vsixPath = 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)
      const args = ['publish', '--no-dependencies', '--packagePath', vsixPath];
      if (vsixConfig.useAzureCredential !== false) {
        args.push('--azure-credential');
      }

      const result = await Executable.waitForExitAsync(
        Executable.spawn(process.execPath, [vsceScriptPath, ...args])
      );

      results.set(projectInfo.project.packageName, result.exitCode === 0);
    }

    return results;
  }

  public async checkExistsAsync(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.
    return false;
  }
}

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 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:

{
  "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/publish.schema.json",
  "providers": {
    "vsix": {
      "vsixPathPattern": "dist/vsix/extension.vsix",
      "useAzureCredential": true
    }
  }
}

Example: config/publish.json in 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.json in 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.json mechanism. A project can override specific values in its own config/publish.json, and the ProjectConfigurationFile rig resolution merges them.

Loading via ProjectConfigurationFile:

const PUBLISH_CONFIGURATION_FILE: ProjectConfigurationFile<IPublishJson> =
  new ProjectConfigurationFile<IPublishJson>({
    projectRelativeFilePath: 'config/publish.json',
    jsonSchemaObject: publishSchemaJson,
    propertyInheritance: {
      providers: {
        inheritanceType: InheritanceType.custom,
        inheritanceFunction: (
          child: Record<string, unknown> | undefined,
          parent: Record<string, unknown> | undefined
        ) => {
          if (!child) return parent;
          if (!parent) return child;
          // Deep merge: child provider sections override parent
          return { ...parent, ...child };
        }
      }
    }
  });

Comparison with build cache plugin pattern:

Aspect Build Cache Plugins Publish Plugins
Config location common/config/rush/build-cache.json (global) config/publish.json (per-project, riggable)
Factory receives Section from build-cache.json Nothing -- config via IPublishProjectInfo.providerConfig
Config resolution Direct file read ProjectConfigurationFile + RigConfig.loadForProjectFolderAsync()
Inheritance None (single global file) Rig inheritance with custom merge
Per-project variation Same config for all projects Different config per project/rig

5.9 PublishAction Lifecycle Hooks

To enable the plugin system, new hooks are added to RushLifecycleHooks:

// In RushLifeCycle.ts
export class RushLifecycleHooks {
  // ... existing hooks ...

  /**
   * Fires before the publish command begins processing.
   * Allows plugins to perform setup (authentication, validation).
   */
  public readonly beforePublish: AsyncSeriesHook<IPublishCommand> =
    new AsyncSeriesHook<IPublishCommand>(['command']);

  /**
   * Fires after all publish providers have completed.
   * Allows plugins to perform cleanup or reporting.
   */
  public readonly afterPublish: AsyncSeriesHook<IPublishResult> =
    new AsyncSeriesHook<IPublishResult>(['result']);
}

These hooks are invoked by the refactored PublishAction:

// In PublishAction.runAsync()
await this._rushSession.hooks.beforePublish.promise({ actionName: this.actionName });

// ... dispatch to providers ...

await this._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"]:

// rush.json - vscode-extensions section
{
  "packageName": "rushstack",
  "projectFolder": "vscode-extensions/rush-vscode-extension",
  "reviewCategory": "vscode-extensions",
  "tags": ["vsix"],
  "shouldPublish": true,          // NEW: enables rush change / rush version
  "publishTarget": ["vsix"]       // NEW: routes to VSIX publish provider
},
{
  "packageName": "@rushstack/rush-vscode-command-webview",
  "projectFolder": "vscode-extensions/rush-vscode-command-webview",
  "reviewCategory": "vscode-extensions",
  "tags": ["vsix"],
  "shouldPublish": true,
  "publishTarget": ["vsix"]
},
{
  "packageName": "debug-certificate-manager",
  "projectFolder": "vscode-extensions/debug-certificate-manager-vscode-extension",
  "reviewCategory": "vscode-extensions",
  "tags": ["vsix"],
  "shouldPublish": true,
  "publishTarget": ["vsix"]
},
{
  "packageName": "playwright-local-browser-server",
  "projectFolder": "vscode-extensions/playwright-local-browser-server-vscode-extension",
  "reviewCategory": "vscode-extensions",
  "tags": ["vsix"],
  "shouldPublish": true,
  "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:

  1. rush version --bump updates package.json version fields (already npm-agnostic)
  2. vsce package reads the version from package.json and embeds it in the VSIX manifest
  3. 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:

{
  "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)

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:

  1. Collects results from all providers
  2. Reports failures per-project
  3. Continues publishing other targets even if one target fails (e.g., if npm publish fails, VSIX publish still runs)
  4. 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].

Metadata

Metadata

Assignees

No one assigned

    Labels

    effort: easyProbably a quick fix. Want to contribute? :-)effort: mediumNeeds a somewhat experienced developerenhancementThe issue is asking for a new feature or design changeneeds designThe next step is for someone to propose the details of an approach for solving the problem

    Type

    No type

    Projects

    Status

    General Discussions

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions