@repo-toolkit/publish-package
v0.25.0
Published
Build, stage, and publish a single package to npm
Maintainers
Readme
@repo-toolkit/publish-package
Build, stage, and publish a single package to npm.
Installation
pnpm add -D @repo-toolkit/publish-packageCLI
repo-toolkit-publish-packageWhen package.json.version already contains the real release version, you can
omit --version. If it still uses the placeholder, pass --version
explicitly.
Useful flags:
--config <path>--cwd <path>--root-dir <path>--package-json <path>--version <version>(alias:--tag)--bump <major|minor|patch>(mutually exclusive with--version)--npm-tag <dist-tag>--publish-dir <path>--preserve-publish-dir--version-placeholder <text>--package-files <file>[,<file>](replaces defaults)--include-package-file <path>(repeatable, additive)--no-default-package-files--root-files <file>[,<file>](replaces defaults)--include-root-file <path>(repeatable, additive)--no-default-root-files--build-command <command>--skip-build--access <level>--registry <url>--otp <code>--provenance--dry-run--publish-access <level>--prepare-only--allow-private-template(see Private source templates)
Version selection
Exactly one of the following decides the release version:
--version <v>/ configversion— an explicit semver version (one leadingvis stripped). Mutually exclusive with--bump, regardless of whether the values came from the CLI or the config file. CLI values override config values for the same field.--bump <major|minor|patch>/ configbump— resolves the prior published version withnpm view <name> version --json(honoring--registry) and applies the bump. Only a confirmed package-absence response starts from0.0.0(so a patch bump yields0.0.1). Authentication failures, timeouts, network errors, registry 5xx responses, malformed JSON, and invalid registry versions are fatal — they are never treated as "first release".- Neither —
package.json.version(which must not still be the version placeholder).
Prepare-only vs npm dry-run
--prepare-only/prepareOnly: trueprepares every artifact (staging, builds, manifest generation, overlays, validation, and packing when required) and then returns without invokingnpm publishat all. The prepared stage directories and tarballs are retained so you can inspect them; their locations are returned asPreparedPackageArtifact[](stageDir, optionaltarballPath) and default to<publishDir>/.artifacts/<id>/per recipe.--dry-runis different: it forwards--dry-runtonpm publish, which means npm is still invoked (and performs its own dry-run checks).
Prepare-only is the safe choice when you want inspectable artifacts without talking to npm; dry-run is a final npm-side rehearsal.
When a publish is requested, every artifact finishes preparation and packing before the first npm publish runs — a build, overlay, validation, or pack failure for any artifact publishes nothing.
Private source templates
By default a source manifest with "private": true is refused. --allow-private-template / allowPrivateTemplate: true is an explicit safety opt-in that permits using such a manifest as a non-publishable template: the generated release manifest never carries a private field, regardless of the opt-in.
Publish layout
publishDir remains the build-output directory (default dist). The layout choice only affects how that directory appears inside the published npm tarball. Flattened publishing remains the default.
- Default (flattened,
preservePublishDir: false): the contents ofpublishDirbecome the npm package root. A build filedist/index.jsis published aspackage/index.jsand manifest fields are rewritten ("main": "dist/index.js"→"main": "./index.js", likewisemodule/types/bin/exports/imports).- Tarball:
package/index.js,package/index.d.ts,package/README.md,package/LICENSE - Manifest:
{ "main": "./index.js", "types": "./index.d.ts" }
- Tarball:
- Opt-in preserved (
--preserve-publish-dir/preservePublishDir: true): the configured directory is retained inside the package.dist/index.jsis published aspackage/dist/index.jsand manifest paths keep their prefix ("main": "./dist/index.js").- Tarball:
package/dist/index.js,package/dist/index.d.ts,package/README.md,package/LICENSE - Manifest:
{ "main": "./dist/index.js", "types": "./dist/index.d.ts" } - A custom
publishDirsuch asartifacts/npmis retained aspackage/artifacts/npm/**, not reduced to its basename.
- Tarball:
Preserved mode uses an isolated temporary staging root rather than publishing from the source package root, so only the build output, generated package.json, and configured packageFiles/rootFiles are included — repository src/, tests, and other source files are not published wholesale.
JavaScript API
import { publishPackage } from '@repo-toolkit/publish-package';
publishPackage({
cwd: '/path/to/package',
version: '1.2.3',
rootFiles: ['LICENSE'],
packageFiles: ['README.md', 'CHANGELOG.md'],
publishDir: 'dist',
versionPlaceholder: '0.0.0-PLACEHOLDER',
dryRun: true,
});Exports
Supported public helpers consumed by the sibling CLIs and external consumers:
createPublishPackageJson(...)— rewrite a package manifest for publish.resolvePublishPackagePlan(options)— resolve file/version/publish metadata without publishing.publishPackage(options)— run the build/copy/npm-publish pipeline for one package.inferNpmTag(version)— derive the npm dist-tag from a version string.isPlainObject(value)— shared object guard used by the config loaders and manifest rewriters.normalizeVersion(rawVersion)— strip a leadingvand reject empty input.parseFlags(argv, specs, options?)— the shared hand-rolled CLI parser. Throws on unknown arguments in strict mode (default) and returnsnullfor-h/--help. Bare--separators (as inserted bypnpm run/npm runpassthrough) are skipped and parsing continues after them; genuinely unknown flags after--are still rejected in strict mode.readValue(argv, index, flag),splitListArg(value)— building blocks forparseFlags.loadConfigFile<T>(path, cwd?),resolveConfigPath(path, cwd?)— JSON / ESM / CJS config loader.resolveCliOptions<T>({ result, buildOptions, cwd })— merge a parsed CLI result with aconfigfile, CLI values win.canPrompt(),promptText(opts),promptForRequiredValue(opts)— interactive prompt helpers;INTERACTIVE_FLAGis the canonical--interactive/-ispec.ProcessRunner,ProcessRunOptions,defaultProcessRunner— injectable subprocess runner (see below).DEPENDENCY_FIELDS,DEFAULT_VERSION_PLACEHOLDER,DEFAULT_PUBLISH_DIR,DEFAULT_PACKAGE_FILES,DEFAULT_ROOT_FILES,DEFAULT_BUILD_COMMAND,DEFAULT_ACCESS,DEFAULT_PUBLISH_FILES_FIELD— the package's defaults, exported so downstream packages reuse them rather than hard-coding.validateSourceManifest,validateRootManifest— manifest shape validators used byresolvePublishPackagePlan.
The implementation lives in focused internal modules (./flags, ./prompt, ./runner, ./helpers, ./manifest, ./plan, ./publish) and is re-exported from the package root. Downstream packages import only via @repo-toolkit/publish-package.
Process runner
publishPackage executes the build command and npm publish through a ProcessRunner. The default runner spawns via execFileSync and inherits stdio; tests inject a fake runner to assert exact invocations without contacting a real npm registry.
import { publishPackage, defaultProcessRunner, type ProcessRunner } from '@repo-toolkit/publish-package';
const runner: ProcessRunner = {
run(executable, args, options) {
/* ... */
},
runShell(command, options) {
/* ... */
},
};
publishPackage({ cwd: '/pkg', version: '1.2.3', dryRun: true, runner });The minimum supported platform contract is Node 20 with bash available on PATH (the runner's runShell invokes bash -c). The npm OTP is forwarded through npm's npm_config_otp environment variable so it does not appear in argv / process listings, and any OTP value that leaks into a runner error message is redacted.
Options
cwd(string) Package root directory. Defaults toprocess.cwd().rootDir(string) Directory to sourcerootFilesfrom. Defaults tocwd.packageJsonPath(string) Source package.json path. Defaults topackage.json.version(string) Target package version. Defaults topackage.json.version. A leadingvis stripped. Rejected if not valid semver. Mutually exclusive withbump.bump('major' | 'minor' | 'patch') Registry-derived version bump (see Version selection). Mutually exclusive withversion.npmTag(string) npm dist-tag. Defaults to the prereleasepreid.packageFiles(string[]) Files copied from the package root into the publish dir (default:['README.md', 'CHANGELOG.md', 'llms.txt']). Missing files are skipped. Subpaths are flattened (docs/llms.txt→dist/llms.txt).includePackageFiles(string[]) Additional files appended topackageFiles.noDefaultPackageFiles(boolean) Skip copying default package files.rootFiles(string[]) Files copied fromrootDirinto the publish dir (default:['LICENSE']). Missing files are skipped.includeRootFiles(string[]) Additional files appended torootFiles.noDefaultRootFiles(boolean) Skip copying default root files.publishDir(string) Publish directory inside the package root (default:dist).preservePublishDir(boolean) Keep the configuredpublishDirinside the npm package instead of flattening it to the package root (default:false).versionPlaceholder(string) Placeholder rewritten to the target version (default:0.0.0-PLACEHOLDER).buildCommand(string) Command used to build the publish dir (default:pnpm build).skipBuild(boolean) Skip the build step.access(string) npm publish access level (default:public).registry(string) npm registry URL.otp(string) npm OTP code. Forwarded to npm through its environment (npm_config_otp), never as a--otpargument.provenance(boolean) Request npm provenance attestation.dryRun(boolean) Forward--dry-runtonpm publish(npm is still invoked — see Prepare-only vs npm dry-run).publishAccess(string) InjectpublishConfig.accessinto generated manifests. Metadata only; the npm--accessargument remains the authorization mechanism.prepareOnly(boolean) Prepare and pack every artifact without invokingnpm publish. Returns retainedPreparedPackageArtifact[].allowPrivateTemplate(boolean) Explicit opt-in to use aprivate: truesource manifest as a template. The generated manifest never containsprivate(see Private source templates).artifacts(PackageArtifactRecipe[]) Opt-in per-package artifact recipes (see Artifact recipes). Cannot be combined with a source manifestadditionalNamesfield.internalPackageNames(string[] | Set<string>) Names treated as internal workspace packages for dependency-range rewriting.runner(ProcessRunner) Injectable subprocess runner (see Process runner). Defaults todefaultProcessRunner, which spawns viaexecFileSyncwith inherited stdio.
Artifact recipes
When artifacts is supplied, each recipe describes one independently staged, validated, and published package. Without recipes, the package's single flattened default artifact is used. Each recipe takes:
id(string) stable artifact id (safe path segment, unique)packageName(string) final published package name (unique across recipes)stageDir(string, optional) isolated stage directory relative to the package root; defaults to<publishDir>/.artifacts/<id>build/validate(functions, optional) hooks receiving a constrainedPackageArtifactContext(absolute cwd/rootDir/stageDir, version, artifact id/name, runner). Throwing fails the artifact.manifestOverlay(object or function, optional) overlay merged into the generated manifest after build, before validation.requireTarball(boolean) pack the stage withnpm pack --json --ignore-scriptsand publish the exact tarball.preserveSourceFiles(boolean) keep the source manifest'sfilesallow-list in the generated manifest instead of the safe default['**/*', '!**/*.map'].publishAccess(string) per-artifactpublishConfig.access, overriding the option-levelpublishAccess.
Manifest overlays follow a protected-field deny-list: private, scripts, devDependencies, and packageManager are always rejected; version and name may not conflict with the release version and recipe package name; exports must pass the same shape validation as source manifests. After merging, dependency-range rewriting and full manifest validation re-run.
Executable hooks require a JavaScript config (.mjs/.cjs, default export or a named artifacts export). A JSON config may only express the declarative fields above (plus a static object manifestOverlay); a JSON config that names a hook or a non-object overlay fails with an error telling you to use a JavaScript config.
// publish.config.mjs — one standard artifact and two variant artifacts
/** @type {import('@repo-toolkit/publish-package').PublishPackageOptions} */
export default {
version: '1.2.3',
prepareOnly: false,
artifacts: [
{
id: 'widget',
packageName: '@example/widget',
requireTarball: true,
build({ stageDir }) {
// build the standard widget bundle into stageDir
},
},
{
id: 'widget-min',
packageName: '@example/widget-min',
requireTarball: true,
build({ stageDir }) {
// build the minified variant into its own isolated stageDir
},
manifestOverlay({ packageName }) {
return { exports: { '.': './index.min.js' }, description: `${packageName} (minified)` };
},
validate({ stageDir }) {
// throw if the staged artifact fails package-specific checks
},
},
{
id: 'widget-debug',
packageName: '@example/widget-debug',
preserveSourceFiles: true,
build({ stageDir }) {
// build the debug variant
},
},
],
};Docs
The longer guide lives in the workspace documentation site under
website/docs/packages/publish-package.md.
