diff --git a/.bumpy/publish-targets.md b/.bumpy/publish-targets.md new file mode 100644 index 0000000..a0591bb --- /dev/null +++ b/.bumpy/publish-targets.md @@ -0,0 +1,7 @@ +--- +'@varlock/bumpy': minor +--- + +Publish-target plugin system: packages can now publish to multiple targets at once via per-package `publishTargets` and a root `targets` map (type-level defaults + named reusable instances). Built-in targets: `npm`, `jsr`, `pypi`, `vscode-marketplace`, `open-vsx`, and `custom` (the existing shell-command escape hatch). Publish state is tracked per target in the GitHub release metadata, so a partial failure (npm succeeded, Open VSX errored) retries only the failed target; a registry-level guard also prevents republishing versions that are already live even when metadata is missing. Targets publishing the same artifact share one build — a single `.vsix` goes to both the VS Code Marketplace and Open VSX, byte-identical. Marketplace/JSR/PyPI targets sit out snapshots and (where unsupported) prereleases as recorded skips instead of failures. Legacy `publishCommand`/`skipNpmPublish` fields keep working unchanged. + +JSR publishing (publish-time `jsr.json` version sync, claim-first bootstrap detection) is modeled on [Drake Costa's](https://github.com/Saeris) setup in [mirrordown](https://github.com/mirrordown/mirrordown) — thanks Drake! diff --git a/docs/configuration.md b/docs/configuration.md index f24fba7..be5577a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,7 +21,8 @@ Bumpy is configured via `.bumpy/_config.json`, created by `bumpy init`. Per-pack | `versionCommitMessage` | `string` | — | Customize the version commit message (see below) | | `changedFilePatterns` | `string[]` | `["**"]` | Glob patterns to filter which changed files count toward marking a package as changed | | `ignoredPackageJsonFields` | `string[]` | `["devDependencies"]` | `package.json` fields whose change alone doesn't require a bump file (see below) | -| `publish` | `object` | see below | Publishing pipeline config | +| `publish` | `object` | see below | Publishing pipeline config (npm target defaults) | +| `targets` | `object` | `{}` | Publish target defaults + named reusable target instances (see [Publish targets](#publish-targets)) | | `gitUser` | `{ name, email }` | bumpy-bot | Git identity for CI commits | | `versionPr` | `{ title, branch, preamble }` | see below | Customize the version PR | | `allowCustomCommands` | `boolean \| string[]` | `false` | Allow per-package custom commands from `package.json` (see below) | @@ -117,6 +118,103 @@ Requirements: } ``` +### Publish targets + +A package can publish to any number of **targets** — npm is just the default one. Each target is an instance of a target type; the built-in types are: + +| Type | Publishes via | Auth | Notes | +| -------------------- | ----------------------------------- | --------------------------------- | ------------------------------------------------------------ | +| `npm` | `npm publish` (or configured PM) | OIDC / `NPM_TOKEN` / `.npmrc` | Supports dist-tags, prereleases, snapshots, staged publishes | +| `jsr` | `npx jsr publish` | OIDC (linked GitHub repo) | Requires a `jsr.json`; no dist-tags, so no snapshots | +| `pypi` | `uv build` + `uv publish` | OIDC / `UV_PUBLISH_TOKEN` | Requires a `pyproject.toml`; stable versions only | +| `vscode-marketplace` | `vsce publish --packagePath ` | `VSCE_PAT` (or Azure credentials) | Stable versions only — the Marketplace rejects prereleases | +| `open-vsx` | `ovsx publish ` | `OVSX_PAT` | Stable versions only | +| `custom` | your shell command(s) | yours | The declarative escape hatch for anything else | + +Set a package's targets with `publishTargets` (in the root config's `packages` map or the package's own `"bumpy"` config): + +```jsonc +{ + "packages": { + "my-lib": { "publishTargets": ["npm"] }, // the implicit default for public packages + "my-vscode-extension": { + // a private package can publish to marketplaces while never touching npm + "publishTargets": ["vscode-marketplace", "open-vsx"], + }, + "my-cli": { + "publishTargets": [ + "npm", + { "type": "custom", "name": "homebrew", "command": "./scripts/update-tap.sh {{version}}" }, + ], + }, + }, +} +``` + +Each entry is either a **string** (a built-in type name, or a named instance from the root `targets` map) or an **inline definition** (`{ "type": ..., ...options }`). The instance `name` (defaults to the type) keys the per-target publish state in the GitHub release metadata, so keep it stable. + +**Shared config (`targets` map).** Root-level `targets` holds two kinds of entries: + +- a key matching a built-in type → **type-level defaults** for every instance of that type +- any other key → a **named, reusable instance** (requires `"type"`), referenced by name from any package + +```jsonc +{ + "targets": { + "npm": { "provenance": true }, // defaults for all npm instances + "ghp": { "type": "npm", "registry": "https://npm.pkg.github.com" }, // named instance + }, + "packages": { + "@myorg/*": { "publishTargets": ["npm", "ghp"] }, // publish to both registries + }, +} +``` + +The legacy `publish` block is the npm type's default options — `targets.npm` and per-instance options layer on top of it. + +**Execution + retries.** Targets run in declared order; one target failing doesn't block its siblings. Publish state is tracked per target in the draft GitHub release, so a partial failure (npm succeeded, Open VSX errored) retries only the failed target on the next CI run. The git tag is created as soon as any target succeeds; the release is finalized once every target has succeeded (or been skipped). + +**Shared artifacts.** Targets that publish the same artifact share one build: `vscode-marketplace` and `open-vsx` both publish the `.vsix` that `vsce package` produces, so it's built once and uploaded to both registries — the two published extensions are guaranteed byte-identical. + +**Capabilities.** Marketplace targets don't participate in [snapshot releases](snapshots.md) or prerelease [channels](prereleases.md) (the VS Code Marketplace only accepts plain `x.y.z` versions), and JSR skips snapshots (no dist-tags to install them from) — those publishes record the target as `skipped` rather than failing. + +**Legacy fields.** `publishCommand` maps to a `custom` target and `skipNpmPublish` to an empty target list; both keep working, but `publishTargets` wins if present. + +#### JSR notes + +- `jsr.json` must exist (name + exports), but commit its `version` as `"0.0.0"` and forget it — bumpy syncs it from package.json into the working tree at publish time. Publishes run with `--allow-dirty` for this reason. +- `workspace:`/`catalog:` dependency specifiers in package.json are resolved automatically before publishing (JSR reads npm ranges from package.json and silently drops protocol specifiers). +- JSR has **no create-on-first-publish**: claim each package in your scope on jsr.io first, and link the GitHub repo to publish token-lessly via OIDC (`id-token: write`). Unclaimed packages fail with guidance instead of publishing. +- Options: `allowSlowTypes: true` passes `--allow-slow-types`; `publishArgs` appends anything else. +- Credit: the JSR publishing behavior here (publish-time version sync, claim-first bootstrap) is modeled on [Drake Costa's](https://github.com/Saeris) setup in [mirrordown](https://github.com/mirrordown/mirrordown) — thanks Drake! + +#### PyPI notes + +bumpy's versioning spine is `package.json`, so a Python package in the workspace gets a **stub `package.json`** next to its `pyproject.toml`: + +```json +{ + "name": "my-py-tool", + "version": "1.2.0", + "private": true, + "bumpy": { "publishTargets": ["pypi"] } +} +``` + +Bump files, changelogs, and the release PR all flow through the stub; at publish time the target syncs the version into `pyproject.toml` (`[project].version` — commit any placeholder), builds with `uv build` into an isolated per-version directory (so stale `dist/` artifacts can never ride along), and uploads with `uv publish`. + +- **Auth**: [PyPI trusted publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) works token-lessly on GitHub Actions with `id-token: write` — `uv publish` picks it up automatically. Otherwise set `UV_PUBLISH_TOKEN`. +- The PyPI project name comes from `pyproject.toml` `[project].name`, not the stub's npm name. +- `dynamic = ["version"]` (setuptools-scm etc.) can't be synced — use a static version. +- PEP 440 doesn't cover bumpy's semver prerelease/snapshot suffixes, so channel prereleases and snapshots record the target as `skipped`. +- Options: `index` (alternative upload URL), `buildArgs` / `publishArgs`. + +#### VS Code extension notes + +- The `.vsix` is packaged with `vsce package --no-dependencies` by default — vsce's npm-based dependency detection breaks in workspace monorepos and silently ships broken extensions. Bundled extensions (the norm) don't need it; set `dependencies: true` on the target to restore vsce's default. `packageArgs` / `publishArgs` append extra flags to the respective step. +- Marketplace auth: `VSCE_PAT` by default, or set `azureCredential: true` to publish with `--azure-credential` (short-lived tokens minted via Azure OIDC — pair with `azure/login` in CI, no long-lived PAT secret). +- If the extension bundles a workspace sibling from `devDependencies`, list it in [`releaseTriggeringDevDeps`](#release-triggering-devdependencies) so the extension re-releases when the bundled package changes. + ### Version PR config The `versionPr` object customizes the PR that `bumpy ci release` creates: @@ -178,10 +276,11 @@ Per-package settings can be defined in two places: | -------------------------- | -------------------------- | -------------------------------------------------------------------------------------- | | `managed` | `boolean` | Opt this package in or out of versioning | | `access` | `"public" \| "restricted"` | Override the global access level | -| `publishCommand` | `string \| string[]` | Custom command(s) to publish this package (replaces npm publish) | +| `publishTargets` | `array` | Where this package publishes (see [Publish targets](#publish-targets)) | +| `publishCommand` | `string \| string[]` | _Legacy_ — custom publish command(s); prefer a `custom` entry in `publishTargets` | | `buildCommand` | `string` | Command to run before publishing | | `registry` | `string` | Custom npm registry URL | -| `skipNpmPublish` | `boolean` | Don't publish to npm (still creates git tags) | +| `skipNpmPublish` | `boolean` | _Legacy_ — don't publish to npm (still creates git tags); prefer `publishTargets: []` | | `checkPublished` | `string` | Custom command that outputs the currently published version | | `changedFilePatterns` | `string[]` | Glob patterns for changed-file detection (replaces root setting, not merged) | | `dependencyBumpRules` | `object` | Per-package override for dependency propagation rules | @@ -191,7 +290,7 @@ Per-package settings can be defined in two places: ### Custom commands and `allowCustomCommands` -The `publishCommand`, `buildCommand`, and `checkPublished` fields run shell commands during publishing. Because these execute with CI credentials, bumpy distinguishes between two trust levels: +The `publishCommand`, `buildCommand`, and `checkPublished` fields — and inline `publishTargets` entries carrying a `command`/`checkPublished` — run shell commands during publishing. Because these execute with CI credentials, bumpy distinguishes between two trust levels: - **Root config** (`.bumpy/_config.json` → `packages`): always trusted — repo admins control this file. - **Per-package config** (`package.json` → `"bumpy"`): requires opt-in via `allowCustomCommands` in the root config. @@ -212,35 +311,36 @@ Or restrict to specific packages/globs: } ``` -This prevents a contributor from introducing arbitrary shell commands via a package's `package.json` without the root config explicitly allowing it. +This prevents a contributor from introducing arbitrary shell commands via a package's `package.json` without the root config explicitly allowing it. Referencing built-in or root-defined targets by name (`"publishTargets": ["vscode-marketplace"]`) is plain data and never requires `allowCustomCommands`. -### Example: custom publish for a VSCode extension +### Example: publishing a VSCode extension -In `.bumpy/_config.json` (recommended — no `allowCustomCommands` needed): +Use the built-in targets — they package the `.vsix` once (via `vsce package`) and publish it to both registries, skip prerelease/snapshot publishes automatically, and track each registry separately for retries: ```json { "packages": { "my-vscode-extension": { - "publishCommand": "vsce publish", - "skipNpmPublish": true + "publishTargets": ["vscode-marketplace", "open-vsx"] } } } ``` -Or in the package's `package.json` (requires `allowCustomCommands`): +Or in the package's own `package.json` (no `allowCustomCommands` needed — target references are plain data): ```json { "name": "my-vscode-extension", + "private": true, "bumpy": { - "publishCommand": "vsce publish", - "skipNpmPublish": true + "publishTargets": ["vscode-marketplace", "open-vsx"] } } ``` +Mark the extension `"private": true` so it never goes to npm; explicit `publishTargets` still publish it to the marketplaces. Auth comes from `VSCE_PAT` / `OVSX_PAT` environment variables in CI. + ### Example: cascade from core to plugins (source-side) ```json @@ -327,10 +427,15 @@ See the [Changelog Formatters](./changelog-formatters.md) docs for full details "provenance": true, "npmStaged": true }, + "targets": { + "ghp": { "type": "npm", "registry": "https://npm.pkg.github.com" } + }, "packages": { "@myorg/vscode-extension": { - "publishCommand": "vsce publish", - "skipNpmPublish": true + "publishTargets": ["vscode-marketplace", "open-vsx"] + }, + "@myorg/cli": { + "publishTargets": ["npm", "ghp"] } }, "allowCustomCommands": ["@myorg/deploy-*"] diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 7fe35b3..90be08d 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -22,7 +22,8 @@ import { createHash } from 'node:crypto'; import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { resolveCommitMessage } from '../core/commit-message.ts'; -import type { BumpyConfig, BumpFile, PackageConfig, PackageManager, ReleasePlan, PlannedRelease } from '../types.ts'; +import { getPackageTargets } from '../core/targets/registry.ts'; +import type { BumpyConfig, BumpFile, PackageManager, ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; // ---- PAT-scoped gh helpers ---- @@ -381,7 +382,7 @@ interface PlanRelease { bumpFiles: string[]; isDependencyBump: boolean; isCascadeBump: boolean; - publishTargets: Array<{ type: string }>; + publishTargets: Array<{ type: string; name: string }>; } interface PlanOutput { @@ -485,7 +486,7 @@ function formatPlanRelease( isDependencyBump: boolean; isCascadeBump: boolean; }, - packages: Map, + packages: Map, config: BumpyConfig, ): PlanRelease { const pkg = packages.get(r.name); @@ -498,27 +499,10 @@ function formatPlanRelease( bumpFiles: r.bumpFiles, isDependencyBump: r.isDependencyBump, isCascadeBump: r.isCascadeBump, - publishTargets: getPublishTargets(pkg, config), + publishTargets: pkg ? getPackageTargets(pkg, config).map((t) => ({ type: t.type, name: t.name })) : [], }; } -function getPublishTargets( - pkg: { private: boolean; bumpy?: PackageConfig } | undefined, - _config: BumpyConfig, -): Array<{ type: string }> { - if (!pkg) return []; - const pkgConfig = pkg.bumpy || {}; - if (pkg.private && !pkgConfig.publishCommand) return []; - const targets: Array<{ type: string }> = []; - if (pkgConfig.publishCommand) { - targets.push({ type: 'custom' }); - } - if (!pkgConfig.publishCommand && !pkgConfig.skipNpmPublish) { - targets.push({ type: 'npm' }); - } - return targets; -} - /** Write a key=value pair to $GITHUB_OUTPUT if available */ function writeGitHubOutput(key: string, value: string): void { const outputFile = process.env.GITHUB_OUTPUT; diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index da573c4..aff9dc1 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -24,9 +24,6 @@ import { finalizeRelease, finalizeSupersededDrafts, composeReleaseBody, - buildPublishUrl, - publishTargetLabel, - resolvePackageRegistry, parseRepoSlug, isGhAvailable, getHeadSha, @@ -35,11 +32,14 @@ import { type ReleaseMetadata, type PublishTargetState, } from '../core/github-release.ts'; +import { getPackageTargets, getNpmTarget, targetLabel } from '../core/targets/registry.ts'; +import { npmEffectiveRegistry } from '../core/targets/npm.ts'; +import type { ResolvedTarget } from '../core/targets/types.ts'; import { loadFormatter } from '../core/changelog.ts'; import { detectWorkspaces } from '../utils/package-manager.ts'; import { CI_PLAN_CACHE_PATH } from './ci.ts'; import { runArgsAsync, tryRunArgs } from '../utils/shell.ts'; -import type { BumpyConfig, PackageConfig, ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; +import type { BumpyConfig, ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; import type { CatalogMap } from '../utils/package-manager.ts'; import type { PackageManager } from '../types.ts'; @@ -78,6 +78,15 @@ export async function publishCommand( const { packageManager: detectedPm } = await detectWorkspaces(rootDir); const depGraph = new DependencyGraph(packages); + // Discovery tolerates broken target config so read-only commands keep working; + // publishing with one is never safe — fail before any release side effects. + const brokenTargets = [...packages.values()].filter((p) => p.targetsError); + if (brokenTargets.length > 0) { + log.error('Invalid publish target configuration — fix before publishing:'); + for (const p of brokenTargets) log.error(` • ${p.name}: ${p.targetsError}`); + process.exit(1); + } + if (!opts.dryRun && hasUncommittedChanges({ cwd: rootDir })) { log.warn('You have uncommitted changes. Commit or stash them before publishing.'); process.exit(1); @@ -239,6 +248,7 @@ async function publishChannel( dryRun: opts.dryRun, tag: opts.tag ?? channel.tag, noPush: opts.noPush, + releaseKind: 'channel', }); } finally { if (restore) { @@ -348,7 +358,7 @@ async function publishSnapshot( depGraph, config, rootDir, - { dryRun: opts.dryRun, tag: snapshot.tag, noTag: true }, + { dryRun: opts.dryRun, tag: snapshot.tag, noTag: true, releaseKind: 'snapshot' }, catalogs, detectedPm, ); @@ -387,7 +397,12 @@ async function runPublishFlow( detectedPm: PackageManager, depGraph: DependencyGraph, releasePlan: ReleasePlan, - opts: { dryRun?: boolean; tag?: string; noPush?: boolean }, + opts: { + dryRun?: boolean; + tag?: string; + noPush?: boolean; + releaseKind?: import('../core/targets/types.ts').ReleaseKind; + }, ): Promise { let toPublish = releasePlan.releases; @@ -406,7 +421,7 @@ async function runPublishFlow( // Only checks when OIDC is the only available auth (no token fallback), to avoid // false positives for users with id-token: write enabled solely for provenance. if (willUseOidcExclusively(rootDir)) { - const newPackages = await findPackagesMissingFromNpm(toPublish, packages); + const newPackages = await findPackagesMissingFromNpm(toPublish, packages, config); if (newPackages.length > 0) { const logFn = opts.dryRun ? log.warn : log.error; logFn(`Trusted publishing (OIDC) cannot create a new package. The following don't exist on npm yet:`); @@ -421,24 +436,14 @@ async function runPublishFlow( const formatter = config.changelog !== false ? await loadFormatter(config.changelog, rootDir) : undefined; const ghAvailable = isGhAvailable(); - // Determine publish targets for each package - const publishTargetsByPkg = new Map(); - // Registry context per package, used to label targets and build correct release URLs. - const registryByPkg = new Map(); + // Determine publish targets for each package (resolved at workspace discovery) + const publishTargetsByPkg = new Map(); + // Repo slug per package, used to build correct release URLs (e.g. GitHub Packages). + const repoSlugByPkg = new Map(); for (const release of toPublish) { const pkg = packages.get(release.name)!; - const pkgConfig = pkg.bumpy || {}; - const targets: string[] = []; - if (pkgConfig.publishCommand) { - targets.push('custom'); - } else if (!pkgConfig.skipNpmPublish) { - targets.push('npm'); - } - publishTargetsByPkg.set(release.name, targets); - registryByPkg.set(release.name, { - registry: resolvePackageRegistry(pkg, pkgConfig), - repoSlug: parseRepoSlug(pkg.packageJson.repository) ?? process.env.GITHUB_REPOSITORY, - }); + publishTargetsByPkg.set(release.name, getPackageTargets(pkg, config)); + repoSlugByPkg.set(release.name, parseRepoSlug(pkg.packageJson.repository) ?? process.env.GITHUB_REPOSITORY); } // For each package, set up draft releases (if gh is available and not dry run) @@ -474,11 +479,11 @@ async function runPublishFlow( ? await generateReleaseBody(release, releasePlan.bumpFiles, formatter) : buildReleaseBody(release, releasePlan.bumpFiles); - const { registry } = registryByPkg.get(release.name) || {}; + const pkg = packages.get(release.name)!; const initialTargets: Record = {}; for (const t of targets) { - const label = publishTargetLabel(t, registry); - initialTargets[t] = { status: 'pending', ...(label !== t ? { label } : {}) }; + const label = targetLabel(t, pkg); + initialTargets[t.name] = { status: 'pending', ...(label !== t.name ? { label } : {}) }; } const metadata: ReleaseMetadata = { version: release.newVersion, @@ -532,14 +537,18 @@ async function runPublishFlow( } } - // Filter out packages where all targets already succeeded (from previous runs) + // Per-target resume: collect targets that already succeeded in previous runs (from + // release metadata). Packages where ALL targets succeeded are dropped entirely; + // partially-published packages re-run only their missing targets. + const completedTargets = new Map>(); const alreadyPublished: string[] = []; for (const release of toPublish) { const info = releaseMetadataByPkg.get(release.name); if (!info) continue; const targets = publishTargetsByPkg.get(release.name) || []; - const allDone = targets.every((t) => info.metadata.targets[t]?.status === 'success'); - if (allDone) { + const done = new Set(targets.filter((t) => info.metadata.targets[t.name]?.status === 'success').map((t) => t.name)); + if (done.size > 0) completedTargets.set(release.name, done); + if (targets.length > 0 && done.size === targets.length) { alreadyPublished.push(release.name); } } @@ -565,6 +574,8 @@ async function runPublishFlow( { dryRun: opts.dryRun, tag: opts.tag, + releaseKind: opts.releaseKind, + completedTargets, }, catalogs, detectedPm, @@ -585,31 +596,60 @@ async function runPublishFlow( if (!info) continue; const targets = publishTargetsByPkg.get(release.name) || []; - const published = result.published.find((p) => p.name === release.name); - const failed = result.failed.find((f) => f.name === release.name); + const targetsByName = new Map(targets.map((t) => [t.name, t])); + const pkg = packages.get(release.name)!; + const repoSlug = repoSlugByPkg.get(release.name); + const outcomes = result.targetOutcomes.get(release.name) || []; + const pkgFailure = result.failed.find((f) => f.name === release.name); - const { registry, repoSlug } = registryByPkg.get(release.name) || {}; let changed = false; - for (const targetName of targets) { - // Skip already-succeeded targets - if (info.metadata.targets[targetName]?.status === 'success') continue; - - if (published) { - const label = publishTargetLabel(targetName, registry); - info.metadata.targets[targetName] = { + for (const outcome of outcomes) { + // Never downgrade a target that already succeeded in a previous run + if (info.metadata.targets[outcome.target]?.status === 'success') continue; + const target = targetsByName.get(outcome.target); + const label = target ? targetLabel(target, pkg) : outcome.target; + const labelField = label !== outcome.target ? { label } : {}; + + if (outcome.status === 'success' || outcome.skipKind === 'registry') { + // "already on registry" = the pre-publish registry guard found the version + // live (metadata was stale or lost) — record it as the success it is + info.metadata.targets[outcome.target] = { status: 'success', publishedAt: new Date().toISOString(), - url: buildPublishUrl(release.name, release.newVersion, targetName, { registry, repoSlug }), - ...(label !== targetName ? { label } : {}), + url: target?.plugin.publishUrl?.(pkg, release.newVersion, target.options, { repoSlug }), + ...labelField, + }; + changed = true; + } else if (outcome.status === 'failed') { + info.metadata.targets[outcome.target] = { + status: 'failed', + error: outcome.error, + lastAttempt: new Date().toISOString(), + ...labelField, + }; + changed = true; + } else if (outcome.skipKind !== 'metadata') { + // Capability skips (e.g. prerelease on a target without prerelease support) + info.metadata.targets[outcome.target] = { + status: 'skipped', + reason: outcome.reason, + ...labelField, }; changed = true; - } else if (failed) { - const label = publishTargetLabel(targetName, registry); - info.metadata.targets[targetName] = { + } + } + + // Package-level failure before any target ran (build / protocol resolution): + // mark all still-pending targets failed so the next run retries them. + if (outcomes.length === 0 && pkgFailure) { + for (const t of targets) { + if (info.metadata.targets[t.name]?.status === 'success') continue; + const label = targetLabel(t, pkg); + info.metadata.targets[t.name] = { status: 'failed', - error: failed.error, + error: pkgFailure.error, lastAttempt: new Date().toISOString(), - ...(label !== targetName ? { label } : {}), + ...(label !== t.name ? { label } : {}), }; changed = true; } @@ -622,8 +662,12 @@ async function runPublishFlow( : composeReleaseBody('', info.metadata); await updateReleaseBody(info.tag, updatedBody, rootDir); - // Finalize if all targets succeeded - const allSucceeded = Object.values(info.metadata.targets).every((t) => t.status === 'success'); + // Finalize once every target reached a terminal state (success, or skipped — + // e.g. a marketplace target on a prerelease) and at least one succeeded + const states = Object.values(info.metadata.targets); + const allSucceeded = + states.some((t) => t.status === 'success') && + states.every((t) => t.status === 'success' || t.status === 'skipped'); if (allSucceeded) { await finalizeRelease(info.tag, rootDir); log.dim(` Finalized release: ${info.tag}`); @@ -656,11 +700,14 @@ async function runPublishFlow( // plain `git push --tags` would reject. Force is safe here because the local // tag was just created at the SHA we successfully published from. if (!opts.dryRun && !opts.noPush && result.published.length > 0) { + // Skip only fully-failed packages — a partial success (npm ok, another target + // failed) still created its tag in the pipeline and should be pushed. + const published = new Set(result.published.map((p) => p.name)); const failed = new Set(result.failed.map((f) => f.name)); const pushed: string[] = []; log.step('Pushing tags...'); for (const release of releasePlan.releases) { - if (failed.has(release.name)) continue; + if (failed.has(release.name) && !published.has(release.name)) continue; const tag = `${release.name}@${release.newVersion}`; if (!tagExists(tag, { cwd: rootDir })) continue; try { @@ -760,23 +807,24 @@ async function findUnpublishedWithCache( * Find packages whose current version is not yet published. * * Detection strategy (per package): - * 1. Custom `checkPublished` command → run it, compare output to current version - * 2. `skipNpmPublish` or custom `publishCommand` → check git tags - * 3. Default → check npm registry via `npm info` + * 1. Custom `checkPublished` command (legacy field) → run it, compare output + * 2. npm-type target → check the npm registry via `npm info` + * 3. Other targets with a `checkPublished` implementation → ask the plugin + * 4. Fallback → check git tags (how non-npm publishes are tracked) */ export async function findUnpublishedPackages( packages: Map, - _config: BumpyConfig, + config: BumpyConfig, ): Promise { const unpublished: PlannedRelease[] = []; for (const [name, pkg] of packages) { - // Skip private packages unless they have custom publish config - if (pkg.private && !pkg.bumpy?.publishCommand) continue; + // Skip packages that publish nowhere + if (getPackageTargets(pkg, config).length === 0) continue; // Skip ignored if (pkg.version === '0.0.0') continue; - const isPublished = await checkIfPublished(name, pkg.version, pkg.bumpy); + const isPublished = await checkIfPublished(pkg, pkg.version, config); if (!isPublished) { unpublished.push({ name, @@ -795,10 +843,11 @@ export async function findUnpublishedPackages( return unpublished; } -async function checkIfPublished(name: string, version: string, pkgConfig?: PackageConfig): Promise { - const { runAsync, runArgsAsync, tryRunArgs } = await import('../utils/shell.ts'); +async function checkIfPublished(pkg: WorkspacePackage, version: string, config: BumpyConfig): Promise { + const { runAsync, tryRunArgs } = await import('../utils/shell.ts'); + const pkgConfig = pkg.bumpy; - // 1. Custom check command (user-defined, runs in shell by design) + // 1. Legacy custom check command (user-defined, runs in shell by design) if (pkgConfig?.checkPublished) { try { const result = await runAsync(pkgConfig.checkPublished); @@ -808,21 +857,21 @@ async function checkIfPublished(name: string, version: string, pkgConfig?: Packa } } - // 2. Non-npm packages — check git tags - if (pkgConfig?.skipNpmPublish || pkgConfig?.publishCommand) { - const tag = `${name}@${version}`; - return tryRunArgs(['git', 'tag', '-l', tag]) === tag; - } + // 2. A package is published only when EVERY target that can answer says so — + // "npm succeeded but JSR failed" must re-enter the publish flow so the + // per-target retry can finish the job. Checks are independent registry + // queries, so they run in parallel. + const targets = getPackageTargets(pkg, config); + const answers = await Promise.all( + targets.map((target) => target.plugin.checkPublished?.(pkg, version, target.options) ?? null), + ); + if (answers.some((a) => a === false)) return false; + if (answers.length > 0 && answers.every((a) => a === true)) return true; - // 3. Default — check npm registry - try { - const args = ['npm', 'info', `${name}@${version}`, 'version']; - if (pkgConfig?.registry) args.push('--registry', pkgConfig.registry); - const result = await runArgsAsync(args); - return result === version; - } catch { - return false; - } + // 3. Targets that can't answer (custom without checkPublished, network failures): + // git tags track their published-ness + const tag = `${pkg.name}@${version}`; + return tryRunArgs(['git', 'tag', '-l', tag]) === tag; } /** @@ -842,20 +891,21 @@ async function packageExistsOnNpm(name: string, registry?: string): Promise, + config: BumpyConfig, ): Promise { const missing: string[] = []; await Promise.all( toPublish.map(async (release) => { const pkg = packages.get(release.name)!; - const pkgConfig = pkg.bumpy || {}; - if (pkgConfig.publishCommand || pkgConfig.skipNpmPublish) return; - if (pkg.private && !pkgConfig.publishCommand) return; - const exists = await packageExistsOnNpm(release.name, pkgConfig.registry); + const npm = getNpmTarget(pkg, config); + if (!npm) return; + const registry = npmEffectiveRegistry(pkg, pkg.bumpy || {}, npm.options); + const exists = await packageExistsOnNpm(release.name, registry); if (!exists) missing.push(release.name); }), ); diff --git a/packages/bumpy/src/commands/status.ts b/packages/bumpy/src/commands/status.ts index 3cd9850..43ab2ff 100644 --- a/packages/bumpy/src/commands/status.ts +++ b/packages/bumpy/src/commands/status.ts @@ -7,7 +7,8 @@ import { assembleReleasePlan } from '../core/release-plan.ts'; import { getCurrentBranch, getChangedFiles } from '../core/git.ts'; import { channelNames, resolveActiveChannel, type ResolvedChannel } from '../core/channels.ts'; import { buildChannelReleasePlan } from '../core/prerelease.ts'; -import { publishTargetLabel, resolvePackageRegistry } from '../core/github-release.ts'; +import { getPackageTargets, targetLabel } from '../core/targets/registry.ts'; +import { npmEffectiveRegistry } from '../core/targets/npm.ts'; import type { BumpFile, BumpyConfig, PackageConfig, PlannedRelease, WorkspacePackage } from '../types.ts'; interface StatusOptions { @@ -302,18 +303,16 @@ function printRelease(r: PlannedRelease, packages: Map function getPublishTargets( pkg: WorkspacePackage | undefined, pkgConfig: Partial, - _config: BumpyConfig, -): Array<{ type: string; label: string; registry?: string }> { + config: BumpyConfig, +): Array<{ type: string; name: string; label: string; registry?: string }> { if (!pkg) return []; - // Private packages with no custom command won't publish - if (pkg.private && !pkgConfig.publishCommand) return []; - const targets: Array<{ type: string; label: string; registry?: string }> = []; - if (pkgConfig.publishCommand) { - targets.push({ type: 'custom', label: 'custom' }); - } - if (!pkgConfig.publishCommand && !pkgConfig.skipNpmPublish) { - const registry = resolvePackageRegistry(pkg, pkgConfig); - targets.push({ type: 'npm', label: publishTargetLabel('npm', registry), ...(registry ? { registry } : {}) }); - } - return targets; + return getPackageTargets(pkg, config).map((t) => { + const registry = t.type === 'npm' ? npmEffectiveRegistry(pkg, pkgConfig, t.options) : undefined; + return { + type: t.type, + name: t.name, + label: targetLabel(t, pkg), + ...(registry ? { registry } : {}), + }; + }); } diff --git a/packages/bumpy/src/core/config.ts b/packages/bumpy/src/core/config.ts index e9962a1..1b934d6 100644 --- a/packages/bumpy/src/core/config.ts +++ b/packages/bumpy/src/core/config.ts @@ -60,7 +60,14 @@ export async function loadPackageConfig( // Block custom commands from per-package config unless the root explicitly allows them. // Commands defined in the root config's `packages` map are always trusted. const CUSTOM_CMD_KEYS = ['buildCommand', 'publishCommand', 'checkPublished'] as const; - const disallowedKeys = CUSTOM_CMD_KEYS.filter((k) => pkgJsonConfig[k] != null); + const disallowedKeys: string[] = CUSTOM_CMD_KEYS.filter((k) => pkgJsonConfig[k] != null); + // Inline publishTargets entries that carry shell commands are custom commands too. + // String references and command-free option bags are plain data and always allowed. + const TARGET_CMD_KEYS = ['command', 'checkPublished', 'buildCommand']; + const hasInlineTargetCommands = (pkgJsonConfig.publishTargets ?? []).some( + (entry) => typeof entry === 'object' && TARGET_CMD_KEYS.some((k) => entry[k] != null), + ); + if (hasInlineTargetCommands) disallowedKeys.push('publishTargets (inline commands)'); if (disallowedKeys.length > 0 && !isCustomCommandAllowed(pkgName, rootConfig)) { const fields = disallowedKeys.map((k) => `"${k}"`).join(', '); throw new Error( @@ -118,6 +125,10 @@ function mergeConfig(defaults: BumpyConfig, user: Partial): BumpyCo ...defaults.publish, ...user.publish, }, + targets: { + ...defaults.targets, + ...user.targets, + }, packages: { ...defaults.packages, ...user.packages, diff --git a/packages/bumpy/src/core/prerelease.ts b/packages/bumpy/src/core/prerelease.ts index d762ffe..3111c0a 100644 --- a/packages/bumpy/src/core/prerelease.ts +++ b/packages/bumpy/src/core/prerelease.ts @@ -3,6 +3,8 @@ import semver from 'semver'; import { readText, writeText, updateJsonFields, updateJsonNestedField } from '../utils/fs.ts'; import { runArgsAsync, tryRunArgs } from '../utils/shell.ts'; import { listTags } from './git.ts'; +import { getNpmTarget, packagePublishes } from './targets/registry.ts'; +import { npmEffectiveRegistry } from './targets/npm.ts'; import type { ResolvedChannel } from './channels.ts'; import type { ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; @@ -77,7 +79,12 @@ async function fetchGitHead(name: string, version: string, registry?: string): P /** Whether a package publishes through the npm registry (vs custom command / git-tag tracking) */ export function usesNpmRegistry(pkg: WorkspacePackage): boolean { - return !pkg.bumpy?.publishCommand && !pkg.bumpy?.skipNpmPublish && !pkg.private; + return getNpmTarget(pkg) !== undefined; +} + +/** The registry a package's npm target publishes to (full fallback chain incl. publishConfig.registry) */ +export function npmTargetRegistry(pkg: WorkspacePackage): string | undefined { + return npmEffectiveRegistry(pkg, pkg.bumpy || {}, getNpmTarget(pkg)?.options ?? {}); } /** Query published prerelease state for one package at a target version */ @@ -88,7 +95,7 @@ export async function getPublishedPrereleaseState( rootDir: string, ): Promise { if (usesNpmRegistry(pkg)) { - const versions = await fetchPublishedVersions(pkg.name, pkg.bumpy?.registry); + const versions = await fetchPublishedVersions(pkg.name, npmTargetRegistry(pkg)); return { counters: extractPrereleaseCounters(versions, target, preid), stablePublished: versions.includes(target), @@ -146,7 +153,7 @@ export async function buildChannelReleasePlan( const pkg = packages.get(release.name); if (!pkg) return; // Unpublishable packages can't participate in a registry-consumable cycle - if (pkg.private && !pkg.bumpy?.publishCommand) return; + if (pkg.private && !packagePublishes(pkg)) return; const target = release.newVersion; // stable target from the bump files const state = await getPublishedPrereleaseState(pkg, target, channel.preid, rootDir); @@ -161,7 +168,7 @@ export async function buildChannelReleasePlan( if (!opts.forDisplay && state.counters.length > 0 && headSha) { const latest = `${target}-${channel.preid}.${Math.max(...state.counters)}`; const publishedFromHead = usesNpmRegistry(pkg) - ? (await fetchGitHead(pkg.name, latest, pkg.bumpy?.registry)) === headSha + ? (await fetchGitHead(pkg.name, latest, npmTargetRegistry(pkg))) === headSha : tryRunArgs(['git', 'rev-parse', `refs/tags/${pkg.name}@${latest}`], { cwd: rootDir }) === headSha; if (publishedFromHead) { alreadyPublished.push({ name: release.name, version: latest }); @@ -246,7 +253,7 @@ export function channelDisplayPlan( const releases = stablePlan.releases .filter((r) => { const pkg = packages.get(r.name); - return !!pkg && !(pkg.private && !pkg.bumpy?.publishCommand); + return !!pkg && !(pkg.private && !packagePublishes(pkg)); }) .map((r) => ({ ...r, newVersion: `${r.newVersion}-${channel.preid}.x` })); return { ...stablePlan, releases }; diff --git a/packages/bumpy/src/core/publish-pipeline.ts b/packages/bumpy/src/core/publish-pipeline.ts index 49eefc2..4f0fb3d 100644 --- a/packages/bumpy/src/core/publish-pipeline.ts +++ b/packages/bumpy/src/core/publish-pipeline.ts @@ -1,151 +1,70 @@ import { resolve } from 'node:path'; -import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'; -import { unlink } from 'node:fs/promises'; +import { rm } from 'node:fs/promises'; +import semver from 'semver'; import { readJson, updateJsonNestedField } from '../utils/fs.ts'; -import { runStreaming, runArgsAsync, tryRunArgs, sq } from '../utils/shell.ts'; +import { runStreaming } from '../utils/shell.ts'; import { log, colorize } from '../utils/logger.ts'; import { createTag, tagExists } from './git.ts'; import { DependencyGraph } from './dep-graph.ts'; import { stripProtocol } from './semver.ts'; import { resolveCatalogDep, type CatalogMap } from '../utils/package-manager.ts'; +import { getPackageTargets } from './targets/registry.ts'; +import type { ReleaseKind, ResolvedTarget, TargetPublishContext } from './targets/types.ts'; import type { ReleasePlan, PlannedRelease, WorkspacePackage, BumpyConfig, PackageManager } from '../types.ts'; +// Re-exported for callers/tests that historically imported these from the pipeline +export { detectOidcProvider, willUseOidcExclusively } from './targets/npm.ts'; + export interface PublishOptions { dryRun?: boolean; tag?: string; // npm dist-tag (e.g., "next", "beta") /** Skip creating git tags (snapshot releases are ephemeral and never tagged) */ noTag?: boolean; + /** What kind of release this is — targets can opt out of snapshots/prereleases */ + releaseKind?: ReleaseKind; + /** + * Target instance names that already succeeded in a previous run, per package + * (from GitHub release metadata). These are skipped, giving per-target resume: + * if npm succeeded and open-vsx failed, the retry only re-runs open-vsx. + */ + completedTargets?: Map>; +} + +export interface TargetOutcome { + /** Target instance name (the release-metadata key) */ + target: string; + type: string; + status: 'success' | 'failed' | 'skipped'; + error?: string; + /** Human-readable skip explanation (display only — logic switches on skipKind) */ + reason?: string; + /** + * Why a target was skipped, structurally: + * - 'metadata': release metadata already records success (per-target resume) + * - 'registry': the pre-publish guard found the version live on the registry + * - 'capability': the target opted out of this release kind (snapshot/prerelease) + * Metadata/registry skips mean the version IS live — consumers treat them as + * success for tagging and release-metadata purposes. + */ + skipKind?: 'metadata' | 'registry' | 'capability'; } export interface PublishResult { + /** Packages where at least one target published this run */ published: { name: string; version: string }[]; + /** Packages that published nothing (no targets, all targets skipped, private, ...) */ skipped: { name: string; reason: string }[]; + /** Packages where at least one target failed (may also appear in `published`) */ failed: { name: string; error: string }[]; -} - -/** - * Detect which CI OIDC provider is available for npm trusted publishing. - * Returns the provider name or null if none detected. - * - * Supported providers: - * - GitHub Actions: `ACTIONS_ID_TOKEN_REQUEST_URL` (set when `id-token: write` permission is granted) - * - GitLab CI: `GITLAB_CI` + `NPM_ID_TOKEN` - * - CircleCI: `CIRCLECI` + `NPM_ID_TOKEN` - */ -export function detectOidcProvider(): 'github-actions' | 'gitlab' | 'circleci' | null { - if (process.env.ACTIONS_ID_TOKEN_REQUEST_URL) return 'github-actions'; - if (process.env.GITLAB_CI && process.env.NPM_ID_TOKEN) return 'gitlab'; - if (process.env.CIRCLECI && process.env.NPM_ID_TOKEN) return 'circleci'; - return null; -} - -/** - * Returns true when OIDC trusted publishing is the only available npm auth path: - * an OIDC provider is detected AND no token env vars or .npmrc auth are present. - * - * Used to gate checks that only matter when OIDC will definitely be used — e.g. - * erroring when a brand-new package can't be bootstrapped via trusted publishing. - * Detection alone is leaky (id-token: write is also set for provenance), so this - * helper avoids false positives when a token fallback exists. - */ -export function willUseOidcExclusively(rootDir: string): boolean { - if (!detectOidcProvider()) return false; - if (process.env.NPM_TOKEN || process.env.NODE_AUTH_TOKEN) return false; - const npmrcPath = resolve(rootDir, '.npmrc'); - const existingNpmrc = existsSync(npmrcPath) ? readFileSync(npmrcPath, 'utf-8') : ''; - return !existingNpmrc.includes(':_authToken='); -} - -const OIDC_NPM_UPGRADE_HINTS: Record = { - 'github-actions': 'Add `actions/setup-node@v6` with `node-version: lts/*` to your workflow', - gitlab: 'Use a Node.js image with npm >= 11.5.1 or run `npm install -g npm@latest`', - circleci: 'Use a Node.js image with npm >= 11.5.1 or run `sudo npm install -g npm@latest`', -}; - -/** Compare semver triples: returns true if version >= minimum */ -function npmVersionAtLeast(version: string, minimum: [number, number, number]): boolean { - const [major, minor, patch] = version.split('.').map(Number); - const [minMajor, minMinor, minPatch] = minimum; - if (major! > minMajor) return true; - if (major! < minMajor) return false; - if (minor! > minMinor) return true; - if (minor! < minMinor) return false; - return patch! >= minPatch; -} - -const MIN_NPM_OIDC: [number, number, number] = [11, 5, 1]; -const MIN_NPM_STAGED: [number, number, number] = [11, 15, 0]; - -/** - * Set up npm authentication for publishing. - * - * Handles three scenarios: - * 1. **Trusted publishing (OIDC)** — GitHub Actions, GitLab CI, or CircleCI with OIDC configured. - * npm >= 11.5.1 authenticates automatically via OIDC token exchange. - * No secret needed, but we check the npm version and warn if too old. - * 2. **Token-based auth** — `NPM_TOKEN` or `NODE_AUTH_TOKEN` env var. - * Writes a project-level `.npmrc` so npm can authenticate. - * 3. **Pre-configured** — user already has `.npmrc` with auth (e.g. via `actions/setup-node`). - */ -function setupNpmAuth(rootDir: string, publishManager: string): void { - // Only relevant when publishing via npm CLI - if (publishManager !== 'npm') return; - - const npmrcPath = resolve(rootDir, '.npmrc'); - const existingNpmrc = existsSync(npmrcPath) ? readFileSync(npmrcPath, 'utf-8') : ''; - const hasAuthConfigured = existingNpmrc.includes(':_authToken='); - - // If auth is already configured (e.g. via actions/setup-node), nothing to do - if (hasAuthConfigured) { - log.dim(' Using existing .npmrc auth configuration'); - return; - } - - // Scenario 1: OIDC trusted publishing - const oidcProvider = detectOidcProvider(); - if (oidcProvider) { - const npmVersion = tryRunArgs(['npm', '--version']); - if (npmVersion) { - if (!npmVersionAtLeast(npmVersion, MIN_NPM_OIDC)) { - log.warn(` npm ${npmVersion} detected — trusted publishing (OIDC) requires npm >= ${MIN_NPM_OIDC.join('.')}`); - log.warn(` ${OIDC_NPM_UPGRADE_HINTS[oidcProvider]}`); - } else { - log.dim(` OIDC detected (${oidcProvider}) — npm ${npmVersion} will authenticate via trusted publishing`); - } - } - return; - } - - // Scenario 2: Token-based auth via environment variable - // Support NPM_TOKEN (common convention) by mapping to NODE_AUTH_TOKEN (what npm reads from .npmrc) - const token = process.env.NODE_AUTH_TOKEN || process.env.NPM_TOKEN; - if (token) { - if (process.env.NPM_TOKEN && !process.env.NODE_AUTH_TOKEN) { - process.env.NODE_AUTH_TOKEN = token; - } - const authLine = '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}'; - if (existingNpmrc) { - appendFileSync(npmrcPath, `\n${authLine}\n`); - } else { - writeFileSync(npmrcPath, `${authLine}\n`); - } - log.dim(' Configured .npmrc with auth token'); - return; - } - - // No auth detected — warn - if (process.env.CI) { - log.warn(' No npm authentication detected. Publishing will likely fail.'); - log.warn(' Options:'); - log.warn(' • Trusted publishing (OIDC): add `id-token: write` permission + npm >= 11.5.1'); - log.warn(' • Token auth: set NPM_TOKEN or NODE_AUTH_TOKEN environment variable'); - log.warn(' • Manual: add `actions/setup-node` with `registry-url` to your workflow'); - } + /** Per-package, per-target outcomes for this run */ + targetOutcomes: Map; } /** * Publish all packages in the release plan. - * Order: topological (dependencies published before dependents). + * Order: topological across packages (dependencies published before dependents), + * declared order across each package's targets. One target failing is recorded and + * does not block sibling targets — retries are per-target via release metadata. */ export async function publishPackages( releasePlan: ReleasePlan, @@ -157,36 +76,8 @@ export async function publishPackages( catalogs: CatalogMap = new Map(), detectedPm: PackageManager = 'npm', ): Promise { - const result: PublishResult = { published: [], skipped: [], failed: [] }; - const publishConfig = config.publish; - - // Set up npm authentication before publishing - setupNpmAuth(rootDir, publishConfig.publishManager); - - // Validate npm-specific publish options - if (publishConfig.provenance && publishConfig.publishManager !== 'npm') { - throw new Error('provenance requires publishManager "npm" — provenance attestation is an npm-specific feature'); - } - - if (publishConfig.npmStaged) { - if (publishConfig.publishManager !== 'npm') { - throw new Error('npmStaged requires publishManager "npm" — staged publishing is an npm-specific feature'); - } - const npmVersion = tryRunArgs(['npm', '--version']); - if (!npmVersion) { - throw new Error(`npmStaged is enabled but npm was not found — install npm >= ${MIN_NPM_STAGED.join('.')}`); - } - if (!npmVersionAtLeast(npmVersion, MIN_NPM_STAGED)) { - throw new Error( - `npmStaged requires npm >= ${MIN_NPM_STAGED.join('.')} (found ${npmVersion})\n` + - ` Upgrade npm: npm install -g npm@latest`, - ); - } - log.dim(`Staged publishing enabled — packages will require 2FA approval on npmjs.com`); - } - - // Resolve "auto" pack manager to detected PM - const packManager = publishConfig.packManager === 'auto' ? detectedPm : publishConfig.packManager; + const result: PublishResult = { published: [], skipped: [], failed: [], targetOutcomes: new Map() }; + const releaseKind = opts.releaseKind ?? 'stable'; // Topological sort for correct publish order const topoOrder = depGraph.topologicalSort(packages); @@ -199,23 +90,51 @@ export async function publishPackages( if (release) ordered.push(release); } + // Preflight each unique target instance once, before anything publishes. + // A preflight throw aborts the whole run — better than failing halfway through. + const preflighted = new Set(); for (const release of ordered) { const pkg = packages.get(release.name)!; - const pkgConfig = pkg.bumpy || {}; + for (const target of getPackageTargets(pkg, config)) { + if (preflighted.has(target.name)) continue; + preflighted.add(target.name); + await target.plugin.preflight?.({ + rootDir, + config, + options: target.options, + dryRun: !!opts.dryRun, + }); + } + } - // Skip private packages unless they have a custom publish command - if (pkg.private && !pkgConfig.publishCommand) { - if (config.privatePackages.tag) { + for (const release of ordered) { + const pkg = packages.get(release.name)!; + const pkgConfig = pkg.bumpy || {}; + const targets = getPackageTargets(pkg, config); + const completed = opts.completedTargets?.get(release.name); + + // Packages with no targets publish nowhere; git tags still apply per config + if (targets.length === 0) { + if (pkg.private) { + if (config.privatePackages.tag) createGitTag(release, rootDir, opts); + result.skipped.push({ name: release.name, reason: 'private' }); + } else { + // Legacy skipNpmPublish behavior: no publish, but still tag the version createGitTag(release, rootDir, opts); + result.skipped.push({ name: release.name, reason: 'no publish targets' }); } - result.skipped.push({ name: release.name, reason: 'private' }); continue; } log.step(`Publishing ${colorize(release.name, 'cyan')}@${release.newVersion}`); + const outcomes: TargetOutcome[] = []; + result.targetOutcomes.set(release.name, outcomes); + // Artifacts shared across this package's targets, keyed by artifact kind + const artifacts = new Map(); + try { - // 1. Build + // 1. Build (once per package, before any target) if (pkgConfig.buildCommand) { log.dim(` Building...`); if (!opts.dryRun) { @@ -223,203 +142,183 @@ export async function publishPackages( } } - // 2. Resolve workspace:/catalog: protocols if using in-place mode - // (for pack mode, the PM pack command handles this; for custom commands, always resolve) - const needsInPlaceResolve = pkgConfig.publishCommand || publishConfig.protocolResolution === 'in-place'; + // 2. Resolve workspace:/catalog: protocols in-place when any target reads the + // manifest from the package dir (custom commands, vsce, npm in-place mode) + const needsInPlaceResolve = targets.some((t) => t.plugin.needsProtocolResolution?.(t.options, config)); if (needsInPlaceResolve) { - // Always write resolved protocols — dryRun only skips the actual publish command + // Always write resolved protocols — dryRun only skips the actual publish commands await resolveProtocolsInPlace(pkg, packages, releasePlan, catalogs); } - // 3. Publish - if (pkgConfig.publishCommand) { - // Custom publish command(s) - const commands = Array.isArray(pkgConfig.publishCommand) - ? pkgConfig.publishCommand - : [pkgConfig.publishCommand]; - - for (const cmd of commands) { - // Shell-quote substituted values to prevent injection via package names/versions - const expanded = cmd - .replace(/\{\{version\}\}/g, sq(release.newVersion)) - .replace(/\{\{name\}\}/g, sq(release.name)); - log.dim(` Running: ${expanded}`); - if (!opts.dryRun) { - await runStreaming(expanded, { cwd: pkg.dir }); - } + // 3. Publish each target + const isPrerelease = semver.prerelease(release.newVersion) !== null; + for (const target of targets) { + const outcome = await publishOneTarget(target, { + pkg, + pkgConfig, + release, + config, + rootDir, + opts, + releaseKind, + isPrerelease, + completed, + artifacts, + detectedPm, + }); + outcomes.push(outcome); + if (outcome.status === 'failed') { + log.error(` Failed to publish ${release.name} → ${target.name}: ${outcome.error}`); } - } else if (!pkgConfig.skipNpmPublish) { - // Standard publish flow - if (publishConfig.protocolResolution === 'pack') { - await packThenPublish(pkg, pkgConfig, config, packManager, opts); - } else { - // "in-place" already resolved above; "none" skips resolution - await npmPublishDirect(pkg, pkgConfig, config, opts); - } - } else { - result.skipped.push({ name: release.name, reason: 'skipNpmPublish' }); - createGitTag(release, rootDir, opts); - continue; } - - // 3. Git tag - createGitTag(release, rootDir, opts); - - result.published.push({ name: release.name, version: release.newVersion }); - log.success(` Published ${release.name}@${release.newVersion}`); } catch (err) { + // Package-level failure (build / protocol resolution) — no target ran const errMsg = err instanceof Error ? err.message : String(err); log.error(` Failed to publish ${release.name}: ${errMsg}`); result.failed.push({ name: release.name, error: errMsg }); + await cleanupArtifacts(artifacts); + continue; } - } - - return result; -} - -/** - * Pack with the PM (which resolves workspace:/catalog: protocols into the tarball), - * then publish the tarball with npm (which supports OIDC/provenance). - */ -async function packThenPublish( - pkg: WorkspacePackage, - pkgConfig: WorkspacePackage['bumpy'] & {}, - config: BumpyConfig, - packManager: PackageManager, - opts: PublishOptions, -): Promise { - const packArgs = getPackArgs(packManager); - log.dim(` Packing with: ${packArgs.join(' ')}`); - if (opts.dryRun) { - const publishArgs = buildPublishArgs(pkg, pkgConfig, config, opts, ''); - log.dim(` Would publish with: ${publishArgs.join(' ')}`); - return; - } + await cleanupArtifacts(artifacts); - // Pack and capture the tarball filename - const packOutput = await runArgsAsync(packArgs, { cwd: pkg.dir }); - const tarball = parseTarballPath(packOutput, pkg.dir, packManager); + // 4. Git tag — the tag asserts "this version exists somewhere", so it is created + // as soon as any target has succeeded (now, in a previous run, or out-of-band + // per the registry guard) + const succeededNow = outcomes.filter((o) => o.status === 'success'); + const failedNow = outcomes.filter((o) => o.status === 'failed'); + const alreadyLive = (o: TargetOutcome) => o.skipKind === 'metadata' || o.skipKind === 'registry'; + const anySuccess = succeededNow.length > 0 || (completed?.size ?? 0) > 0 || outcomes.some(alreadyLive); + if (anySuccess) { + createGitTag(release, rootDir, opts); + } - try { - // Publish the tarball - const publishArgs = buildPublishArgs(pkg, pkgConfig, config, opts, tarball); - log.dim(` Publishing: ${publishArgs.join(' ')}`); - await runArgsAsync(publishArgs, { cwd: pkg.dir }); - } finally { - // Clean up tarball - try { - await unlink(tarball); - } catch { - /* ignore */ + // Package-level classification + if (succeededNow.length > 0) { + result.published.push({ name: release.name, version: release.newVersion }); + const summary = + targets.length === 1 + ? '' + : ` (${succeededNow.map((o) => o.target).join(', ')}${failedNow.length ? ` — ${failedNow.length} failed` : ''})`; + log.success(` Published ${release.name}@${release.newVersion}${summary}`); + } else if (failedNow.length === 0) { + const reason = outcomes.every(alreadyLive) ? 'already published' : (outcomes[0]?.reason ?? 'all targets skipped'); + result.skipped.push({ name: release.name, reason }); + } + if (failedNow.length > 0) { + result.failed.push({ + name: release.name, + error: failedNow.map((o) => `${o.target}: ${o.error}`).join('; '), + }); } } -} -/** Publish directly from the package directory (no tarball) */ -async function npmPublishDirect( - pkg: WorkspacePackage, - pkgConfig: WorkspacePackage['bumpy'] & {}, - config: BumpyConfig, - opts: PublishOptions, -): Promise { - const args = buildPublishArgs(pkg, pkgConfig, config, opts); - log.dim(` Running: ${args.join(' ')}`); - if (!opts.dryRun) { - await runArgsAsync(args, { cwd: pkg.dir }); - } + return result; } -function getPackArgs(pm: PackageManager): string[] { - switch (pm) { - case 'pnpm': - return ['pnpm', 'pack', '--json']; - case 'bun': - return ['bun', 'pm', 'pack']; - case 'yarn': - return ['yarn', 'pack']; - case 'npm': - default: - return ['npm', 'pack', '--json']; +async function publishOneTarget( + target: ResolvedTarget, + args: { + pkg: WorkspacePackage; + pkgConfig: WorkspacePackage['bumpy'] & {}; + release: PlannedRelease; + config: BumpyConfig; + rootDir: string; + opts: PublishOptions; + releaseKind: ReleaseKind; + isPrerelease: boolean; + completed: Set | undefined; + artifacts: Map; + detectedPm: PackageManager; + }, +): Promise { + const { pkg, release, config, opts, releaseKind, isPrerelease, completed, artifacts } = args; + const base = { target: target.name, type: target.type }; + + // Already succeeded in a previous run (per release metadata) — don't re-publish + if (completed?.has(target.name)) { + log.dim(` Skipping ${target.name} — already published (per release metadata)`); + return { ...base, status: 'skipped', skipKind: 'metadata', reason: 'already published' }; } -} -function buildPublishArgs( - pkg: WorkspacePackage, - pkgConfig: WorkspacePackage['bumpy'] & {}, - config: BumpyConfig, - opts: PublishOptions, - tarball?: string, -): string[] { - const publishManager = config.publish.publishManager; - const args: string[] = []; - - // Base command - if (config.publish.npmStaged && publishManager === 'npm') { - args.push('npm', 'stage', 'publish'); - } else if (publishManager === 'yarn') { - args.push('yarn', 'npm', 'publish'); - } else { - args.push(publishManager, 'publish'); + // Capability gates + const caps = target.plugin.capabilities; + if (releaseKind === 'snapshot' && !caps.snapshots) { + log.dim(` Skipping ${target.name} — target does not support snapshot releases`); + return { ...base, status: 'skipped', skipKind: 'capability', reason: 'snapshots not supported' }; } - - // Tarball path (if pack-then-publish) - if (tarball) args.push(tarball); - - // Access - const access = pkgConfig?.access || config.access; - args.push('--access', access); - - // Registry - if (pkgConfig?.registry) args.push('--registry', pkgConfig.registry); - - // Dist tag - if (opts.tag) args.push('--tag', opts.tag); - - // Provenance attestation - if (config.publish.provenance && publishManager === 'npm') { - args.push('--provenance'); + if (isPrerelease && !caps.prereleases) { + log.dim(` Skipping ${target.name} — target does not support prerelease versions`); + return { ...base, status: 'skipped', skipKind: 'capability', reason: 'prereleases not supported' }; } - // Extra user-configured args - if (config.publish.publishArgs.length > 0) { - args.push(...config.publish.publishArgs); + // Registry-level idempotency guard: even without release metadata (gh unavailable, + // draft deleted), never publish a version that's already live — registries reject + // republishes with far less helpful errors. Runs before the artifact build so a + // fully-published package doesn't rebuild anything. + if (!opts.dryRun && target.plugin.checkPublished) { + const published = await target.plugin.checkPublished(pkg, release.newVersion, target.options).catch(() => null); + if (published === true) { + log.dim(` Skipping ${target.name} — ${release.newVersion} already on registry`); + return { ...base, status: 'skipped', skipKind: 'registry', reason: 'already on registry' }; + } } - return args; + try { + const ctx: TargetPublishContext = { + pkg, + pkgConfig: args.pkgConfig, + version: release.newVersion, + rootDir: args.rootDir, + config, + options: target.options, + distTag: caps.distTags ? opts.tag : undefined, + dryRun: !!opts.dryRun, + releaseKind, + packManager: args.detectedPm, + }; + + // Per-target pre-publish step (e.g. publish-time version sync into jsr.json / + // pyproject.toml). Runs after all skip gates so a skipped target never mutates + // files. Also runs on dry runs — its validation (missing manifests, unclaimed + // packages) is exactly what dry runs exist to surface; plugins skip only their + // file writes when ctx.dryRun is set. + await target.plugin.prepare?.(ctx); + + // Shared artifact: build once per (package, kind), reuse across sibling targets + const kind = target.plugin.artifactKind?.(target.options, config); + if (kind) { + if (!artifacts.has(kind)) { + if (opts.dryRun) { + artifacts.set(kind, `<${kind}>`); + } else { + if (!target.plugin.buildArtifact) { + throw new Error(`target "${target.name}" declares artifact kind "${kind}" but has no buildArtifact`); + } + artifacts.set(kind, await target.plugin.buildArtifact(ctx)); + } + } + ctx.artifactPath = artifacts.get(kind); + } + + await target.plugin.publish(ctx); + return { ...base, status: 'success' }; + } catch (err) { + return { ...base, status: 'failed', error: err instanceof Error ? err.message : String(err) }; + } } -/** - * Parse the tarball path from pack command output. - * npm/pnpm use --json for structured output; bun/yarn fall back to regex parsing. - */ -function parseTarballPath(output: string, cwd: string, pm: PackageManager): string { - // npm and pnpm support --json which gives us a deterministic filename - if (pm === 'npm' || pm === 'pnpm') { +/** Delete shared artifacts (tarballs, vsix files, python dist dirs) built during a package's publish */ +async function cleanupArtifacts(artifacts: Map): Promise { + for (const path of artifacts.values()) { + if (path.startsWith('<')) continue; // dry-run placeholder try { - const parsed = JSON.parse(output); - // npm returns an array, pnpm returns an object or array - const entry = Array.isArray(parsed) ? parsed[0] : parsed; - if (entry?.filename) { - return resolve(cwd, entry.filename); - } + await rm(path, { recursive: true, force: true }); } catch { - // JSON parse failed — fall through to regex + /* ignore */ } } - - // Fallback for bun/yarn or if JSON parsing failed: - // extract any .tgz path — handles both bare filenames and quoted paths (yarn) - const tgzMatch = output.match(/(?:^|["'\s])([^\s"']*\.tgz)/m); - if (tgzMatch) { - const tarball = tgzMatch[1]!; - return tarball.startsWith('/') ? tarball : resolve(cwd, tarball); - } - - // Last resort: last non-empty line - const lines = output.trim().split('\n').filter(Boolean); - const lastLine = lines[lines.length - 1]?.trim() || ''; - return lastLine.startsWith('/') ? lastLine : resolve(cwd, lastLine); + artifacts.clear(); } function createGitTag(release: PlannedRelease, rootDir: string, opts: PublishOptions): void { diff --git a/packages/bumpy/src/core/snapshot.ts b/packages/bumpy/src/core/snapshot.ts index 9fbea83..6a76f8d 100644 --- a/packages/bumpy/src/core/snapshot.ts +++ b/packages/bumpy/src/core/snapshot.ts @@ -1,6 +1,7 @@ import semver from 'semver'; import { tryRunArgs } from '../utils/shell.ts'; -import { fetchPublishedVersions, usesNpmRegistry } from './prerelease.ts'; +import { fetchPublishedVersions, usesNpmRegistry, npmTargetRegistry } from './prerelease.ts'; +import { packagePublishes } from './targets/registry.ts'; import type { BumpyConfig, ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; /** @@ -128,7 +129,7 @@ export async function buildSnapshotReleasePlan( const pkg = packages.get(release.name); if (!pkg) return; // Unpublishable packages can't be installed from a dist-tag — nothing to snapshot - if (pkg.private && !pkg.bumpy?.publishCommand) return; + if (pkg.private && !packagePublishes(pkg)) return; const target = release.newVersion; // stable target from the bump files const version = snapshotVersion(target, snapshot); @@ -137,7 +138,7 @@ export async function buildSnapshotReleasePlan( // already published this exact snapshot — skip to stay idempotent (re-run on the // same commit). Only registry-backed packages can be checked this way. if (usesNpmRegistry(pkg)) { - const versions = await fetchPublishedVersions(pkg.name, pkg.bumpy?.registry); + const versions = await fetchPublishedVersions(pkg.name, npmTargetRegistry(pkg)); if (versions.includes(version)) { alreadyPublished.push({ name: release.name, version }); return; diff --git a/packages/bumpy/src/core/targets/custom.ts b/packages/bumpy/src/core/targets/custom.ts new file mode 100644 index 0000000..66aec4a --- /dev/null +++ b/packages/bumpy/src/core/targets/custom.ts @@ -0,0 +1,56 @@ +import { runAsync, runStreaming, sq } from '../../utils/shell.ts'; +import { log } from '../../utils/logger.ts'; +import type { PublishTargetPlugin } from './types.ts'; + +/** + * The "custom" target: user-supplied shell command(s), the declarative escape hatch + * for registries without a built-in target. Also what the legacy `publishCommand` / + * `checkPublished` package fields map onto. + * + * Options: + * - `command` (string | string[], required) — publish command(s); `{{name}}` and + * `{{version}}` are substituted (shell-quoted) + * - `checkPublished` (string) — command printing the currently published version + * + * Capabilities are wide open — the user's command owns the semantics, so bumpy + * doesn't second-guess prereleases or snapshots here. + */ +export const customTarget: PublishTargetPlugin = { + type: 'custom', + capabilities: { distTags: false, prereleases: true, snapshots: true }, + + needsProtocolResolution() { + // Custom commands read the manifest straight from the package dir + return true; + }, + + async checkPublished(_pkg, version, options) { + const cmd = options.checkPublished; + if (typeof cmd !== 'string' || !cmd) return null; // unknown — caller falls back to git tags + try { + const result = await runAsync(cmd); + return result.trim() === version; + } catch { + return false; + } + }, + + async publish(ctx) { + const raw = ctx.options.command ?? ctx.options.publishCommand; + const commands = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : []; + if (commands.length === 0) { + throw new Error(`custom target "${ctx.pkg.name}" has no "command" configured`); + } + + for (const cmd of commands) { + // Shell-quote substituted values to prevent injection via package names/versions + const expanded = String(cmd) + .replace(/\{\{version\}\}/g, sq(ctx.version)) + .replace(/\{\{name\}\}/g, sq(ctx.pkg.name)); + log.dim(` Running: ${expanded}`); + if (!ctx.dryRun) { + await runStreaming(expanded, { cwd: ctx.pkg.dir }); + } + } + }, +}; diff --git a/packages/bumpy/src/core/targets/jsr.ts b/packages/bumpy/src/core/targets/jsr.ts new file mode 100644 index 0000000..31e7edf --- /dev/null +++ b/packages/bumpy/src/core/targets/jsr.ts @@ -0,0 +1,136 @@ +import { resolve } from 'node:path'; +import { existsSync } from 'node:fs'; +import { readJson, updateJsonFields } from '../../utils/fs.ts'; +import { runArgsAsync } from '../../utils/shell.ts'; +import { log } from '../../utils/logger.ts'; +import { buildPublishUrl } from '../github-release.ts'; +import type { WorkspacePackage } from '../../types.ts'; +import { stringArrayOption } from './util.ts'; +import type { PublishTargetPlugin } from './types.ts'; + +/** + * JSR (jsr.io) target. + * + * JSR publishes TypeScript source described by `jsr.json` (name, version, exports). + * Two version-sync facts shape this target: + * - `jsr.json` has its own `version` field, but it does NOT need to be committed in + * the release PR — the target syncs it from package.json into the working tree at + * publish time (commit it as `0.0.0` and forget it). That's also why publishes run + * with `--allow-dirty`: the tree is intentionally modified (version sync here, + * workspace:/catalog: resolution by the pipeline — JSR reads npm dependency ranges + * from package.json, and deno silently drops deps with protocol specifiers). + * - JSR has no create-on-first-publish: packages must be claimed in the scope on + * jsr.io first. The publish step detects an unclaimed package and says so, rather + * than surfacing deno's less helpful error. + * + * Auth: token-less OIDC trusted publishing from GitHub Actions (`id-token: write`) + * once the package's GitHub repo is linked on jsr.io; `JSR_TOKEN` otherwise. + * + * Options: + * - `allowSlowTypes` (boolean, default false) — pass `--allow-slow-types` + * - `publishArgs` (string[]) — extra args for `jsr publish` + * + * Credit: the publish-time version sync, catalog-resolution requirement, and + * claim-first bootstrap behavior are all lessons from Drake Costa's (@Saeris — + * https://github.com/Saeris) JSR publishing setup in mirrordown, an early bumpy + * adopter. Thanks Drake! + */ + +const JSR_BIN = ['npx', '--yes', 'jsr']; +const JSR_API = 'https://api.jsr.io'; + +function scopeAndName(pkg: WorkspacePackage): { scope: string; name: string } | null { + const match = pkg.name.match(/^@([^/]+)\/(.+)$/); + return match ? { scope: match[1]!, name: match[2]! } : null; +} + +/** GET a JSR API path; returns the response status or null on network failure */ +async function jsrApiStatus(path: string): Promise { + try { + const res = await fetch(`${JSR_API}${path}`); + return res.status; + } catch { + return null; + } +} + +export const jsrTarget: PublishTargetPlugin = { + type: 'jsr', + // JSR versions are semver (prereleases fine), but there are no dist-tags — so + // snapshots, which are only reachable via a throwaway tag, don't make sense. + capabilities: { distTags: false, prereleases: true, snapshots: false }, + + detect(pkg) { + return existsSync(resolve(pkg.dir, 'jsr.json')); + }, + + label() { + return 'JSR'; + }, + + needsProtocolResolution() { + // JSR resolves npm deps from package.json — protocol specifiers must be concrete + return true; + }, + + async checkPublished(pkg, version, _options) { + const id = scopeAndName(pkg); + if (!id) return null; // JSR packages are always scoped + const status = await jsrApiStatus(`/scopes/${id.scope}/packages/${id.name}/versions/${version}`); + if (status === null) return null; // network hiccup — unknown + return status === 200; + }, + + async prepare(ctx) { + const id = scopeAndName(ctx.pkg); + if (!id) { + throw new Error(`${ctx.pkg.name}: JSR packages must be scoped (@scope/name)`); + } + const jsrJsonPath = resolve(ctx.pkg.dir, 'jsr.json'); + if (!existsSync(jsrJsonPath)) { + throw new Error( + `${ctx.pkg.name}: jsr target requires a jsr.json (name + exports; version can stay "0.0.0" — ` + + `bumpy syncs it at publish time)`, + ); + } + + // JSR has no create-on-first-publish — fail with actionable guidance instead of + // deno's opaque error when the package hasn't been claimed in the scope yet. + const pkgStatus = await jsrApiStatus(`/scopes/${id.scope}/packages/${id.name}`); + if (pkgStatus === 404) { + throw new Error( + `${ctx.pkg.name} is not claimed on JSR — create it in the @${id.scope} scope first ` + + `(jsr.io → scope → Create package), and link the GitHub repo for token-less OIDC publishing`, + ); + } + + // Sync jsr.json's version from the release (formatting-preserving in-place edit). + // A jsr.json without a version field can't be synced — say so instead of letting + // deno fail with a less helpful parse error. + const jsrJson = await readJson<{ version?: unknown }>(jsrJsonPath); + if (typeof jsrJson.version !== 'string') { + throw new Error(`${ctx.pkg.name}: jsr.json has no "version" field — add one (any placeholder, e.g. "0.0.0")`); + } + if (!ctx.dryRun && jsrJson.version !== ctx.version) { + await updateJsonFields(jsrJsonPath, { version: ctx.version }); + } + }, + + async publish(ctx) { + const args = [...JSR_BIN, 'publish', '--allow-dirty']; + if (ctx.options.allowSlowTypes === true) args.push('--allow-slow-types'); + args.push(...stringArrayOption(ctx.options, 'publishArgs')); + + if (ctx.dryRun) { + log.dim(` Would publish with: ${args.join(' ')}`); + return; + } + + log.dim(` Publishing: ${args.join(' ')}`); + await runArgsAsync(args, { cwd: ctx.pkg.dir }); + }, + + publishUrl(pkg, version) { + return buildPublishUrl(pkg.name, version, 'jsr'); + }, +}; diff --git a/packages/bumpy/src/core/targets/npm.ts b/packages/bumpy/src/core/targets/npm.ts new file mode 100644 index 0000000..806207f --- /dev/null +++ b/packages/bumpy/src/core/targets/npm.ts @@ -0,0 +1,334 @@ +import { resolve } from 'node:path'; +import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'; +import { runArgsAsync, tryRunArgs } from '../../utils/shell.ts'; +import { log } from '../../utils/logger.ts'; +import { buildPublishUrl, publishTargetLabel, resolvePackageRegistry } from '../github-release.ts'; +import type { BumpyConfig, PackageConfig, PackageManager, PublishConfig, WorkspacePackage } from '../../types.ts'; +import type { PublishTargetPlugin, TargetOptions, TargetPublishContext } from './types.ts'; + +/** + * Detect which CI OIDC provider is available for npm trusted publishing. + * Returns the provider name or null if none detected. + * + * Supported providers: + * - GitHub Actions: `ACTIONS_ID_TOKEN_REQUEST_URL` (set when `id-token: write` permission is granted) + * - GitLab CI: `GITLAB_CI` + `NPM_ID_TOKEN` + * - CircleCI: `CIRCLECI` + `NPM_ID_TOKEN` + */ +export function detectOidcProvider(): 'github-actions' | 'gitlab' | 'circleci' | null { + if (process.env.ACTIONS_ID_TOKEN_REQUEST_URL) return 'github-actions'; + if (process.env.GITLAB_CI && process.env.NPM_ID_TOKEN) return 'gitlab'; + if (process.env.CIRCLECI && process.env.NPM_ID_TOKEN) return 'circleci'; + return null; +} + +/** + * Returns true when OIDC trusted publishing is the only available npm auth path: + * an OIDC provider is detected AND no token env vars or .npmrc auth are present. + * + * Used to gate checks that only matter when OIDC will definitely be used — e.g. + * erroring when a brand-new package can't be bootstrapped via trusted publishing. + * Detection alone is leaky (id-token: write is also set for provenance), so this + * helper avoids false positives when a token fallback exists. + */ +export function willUseOidcExclusively(rootDir: string): boolean { + if (!detectOidcProvider()) return false; + if (process.env.NPM_TOKEN || process.env.NODE_AUTH_TOKEN) return false; + const npmrcPath = resolve(rootDir, '.npmrc'); + const existingNpmrc = existsSync(npmrcPath) ? readFileSync(npmrcPath, 'utf-8') : ''; + return !existingNpmrc.includes(':_authToken='); +} + +const OIDC_NPM_UPGRADE_HINTS: Record = { + 'github-actions': 'Add `actions/setup-node@v6` with `node-version: lts/*` to your workflow', + gitlab: 'Use a Node.js image with npm >= 11.5.1 or run `npm install -g npm@latest`', + circleci: 'Use a Node.js image with npm >= 11.5.1 or run `sudo npm install -g npm@latest`', +}; + +/** Compare semver triples: returns true if version >= minimum */ +export function npmVersionAtLeast(version: string, minimum: [number, number, number]): boolean { + const [major, minor, patch] = version.split('.').map(Number); + const [minMajor, minMinor, minPatch] = minimum; + if (major! > minMajor) return true; + if (major! < minMajor) return false; + if (minor! > minMinor) return true; + if (minor! < minMinor) return false; + return patch! >= minPatch; +} + +const MIN_NPM_OIDC: [number, number, number] = [11, 5, 1]; +const MIN_NPM_STAGED: [number, number, number] = [11, 15, 0]; + +/** + * The npm target's effective options: the legacy root `publish` block provides + * defaults, overridden by `targets.npm` / instance options (merged by the resolver). + */ +function npmOptions(config: BumpyConfig, options: TargetOptions): PublishConfig & TargetOptions { + return { ...config.publish, ...options } as PublishConfig & TargetOptions; +} + +/** + * The registry an npm-type target instance publishes to, resolved through the full + * fallback chain: instance options -> bumpy `registry` field -> package.json + * `publishConfig.registry`. The single source of truth for "which registry" — + * every consumer (publish args, existence checks, prerelease counters, labels, + * URLs) must go through this so they can never disagree. + */ +export function npmEffectiveRegistry( + pkg: WorkspacePackage, + pkgConfig: PackageConfig, + options: TargetOptions, +): string | undefined { + if (typeof options.registry === 'string' && options.registry) return options.registry; + return resolvePackageRegistry(pkg, pkgConfig); +} + +/** + * Set up npm authentication for publishing. + * + * Handles three scenarios: + * 1. **Trusted publishing (OIDC)** — GitHub Actions, GitLab CI, or CircleCI with OIDC configured. + * npm >= 11.5.1 authenticates automatically via OIDC token exchange. + * No secret needed, but we check the npm version and warn if too old. + * 2. **Token-based auth** — `NPM_TOKEN` or `NODE_AUTH_TOKEN` env var. + * Writes a project-level `.npmrc` so npm can authenticate. + * 3. **Pre-configured** — user already has `.npmrc` with auth (e.g. via `actions/setup-node`). + */ +function setupNpmAuth(rootDir: string, publishManager: string): void { + // Only relevant when publishing via npm CLI + if (publishManager !== 'npm') return; + + const npmrcPath = resolve(rootDir, '.npmrc'); + const existingNpmrc = existsSync(npmrcPath) ? readFileSync(npmrcPath, 'utf-8') : ''; + const hasAuthConfigured = existingNpmrc.includes(':_authToken='); + + // If auth is already configured (e.g. via actions/setup-node), nothing to do + if (hasAuthConfigured) { + log.dim(' Using existing .npmrc auth configuration'); + return; + } + + // Scenario 1: OIDC trusted publishing + const oidcProvider = detectOidcProvider(); + if (oidcProvider) { + const npmVersion = tryRunArgs(['npm', '--version']); + if (npmVersion) { + if (!npmVersionAtLeast(npmVersion, MIN_NPM_OIDC)) { + log.warn(` npm ${npmVersion} detected — trusted publishing (OIDC) requires npm >= ${MIN_NPM_OIDC.join('.')}`); + log.warn(` ${OIDC_NPM_UPGRADE_HINTS[oidcProvider]}`); + } else { + log.dim(` OIDC detected (${oidcProvider}) — npm ${npmVersion} will authenticate via trusted publishing`); + } + } + return; + } + + // Scenario 2: Token-based auth via environment variable + // Support NPM_TOKEN (common convention) by mapping to NODE_AUTH_TOKEN (what npm reads from .npmrc) + const token = process.env.NODE_AUTH_TOKEN || process.env.NPM_TOKEN; + if (token) { + if (process.env.NPM_TOKEN && !process.env.NODE_AUTH_TOKEN) { + process.env.NODE_AUTH_TOKEN = token; + } + const authLine = '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}'; + if (existingNpmrc) { + appendFileSync(npmrcPath, `\n${authLine}\n`); + } else { + writeFileSync(npmrcPath, `${authLine}\n`); + } + log.dim(' Configured .npmrc with auth token'); + return; + } + + // No auth detected — warn + if (process.env.CI) { + log.warn(' No npm authentication detected. Publishing will likely fail.'); + log.warn(' Options:'); + log.warn(' • Trusted publishing (OIDC): add `id-token: write` permission + npm >= 11.5.1'); + log.warn(' • Token auth: set NPM_TOKEN or NODE_AUTH_TOKEN environment variable'); + log.warn(' • Manual: add `actions/setup-node` with `registry-url` to your workflow'); + } +} + +function getPackArgs(pm: PackageManager): string[] { + switch (pm) { + case 'pnpm': + return ['pnpm', 'pack', '--json']; + case 'bun': + return ['bun', 'pm', 'pack']; + case 'yarn': + return ['yarn', 'pack']; + case 'npm': + default: + return ['npm', 'pack', '--json']; + } +} + +function buildPublishArgs(ctx: TargetPublishContext, tarball?: string): string[] { + const o = npmOptions(ctx.config, ctx.options); + const publishManager = o.publishManager; + const args: string[] = []; + + // Base command + if (o.npmStaged && publishManager === 'npm') { + args.push('npm', 'stage', 'publish'); + } else if (publishManager === 'yarn') { + args.push('yarn', 'npm', 'publish'); + } else { + args.push(publishManager, 'publish'); + } + + // Tarball path (if pack-then-publish) + if (tarball) args.push(tarball); + + // Access + const access = (ctx.options.access as string | undefined) || ctx.pkgConfig.access || ctx.config.access; + args.push('--access', access); + + // Registry + const registry = npmEffectiveRegistry(ctx.pkg, ctx.pkgConfig, ctx.options); + if (registry) args.push('--registry', registry); + + // Dist tag + if (ctx.distTag) args.push('--tag', ctx.distTag); + + // Provenance attestation + if (o.provenance && publishManager === 'npm') { + args.push('--provenance'); + } + + // Extra user-configured args + if (Array.isArray(o.publishArgs) && o.publishArgs.length > 0) { + args.push(...o.publishArgs); + } + + return args; +} + +/** + * Parse the tarball path from pack command output. + * npm/pnpm use --json for structured output; bun/yarn fall back to regex parsing. + */ +export function parseTarballPath(output: string, cwd: string, pm: PackageManager): string { + // npm and pnpm support --json which gives us a deterministic filename + if (pm === 'npm' || pm === 'pnpm') { + try { + const parsed = JSON.parse(output); + // npm returns an array, pnpm returns an object or array + const entry = Array.isArray(parsed) ? parsed[0] : parsed; + if (entry?.filename) { + return resolve(cwd, entry.filename); + } + } catch { + // JSON parse failed — fall through to regex + } + } + + // Fallback for bun/yarn or if JSON parsing failed: + // extract any .tgz path — handles both bare filenames and quoted paths (yarn) + const tgzMatch = output.match(/(?:^|["'\s])([^\s"']*\.tgz)/m); + if (tgzMatch) { + const tarball = tgzMatch[1]!; + return tarball.startsWith('/') ? tarball : resolve(cwd, tarball); + } + + // Last resort: last non-empty line + const lines = output.trim().split('\n').filter(Boolean); + const lastLine = lines[lines.length - 1]?.trim() || ''; + return lastLine.startsWith('/') ? lastLine : resolve(cwd, lastLine); +} + +export const npmTarget: PublishTargetPlugin = { + type: 'npm', + capabilities: { distTags: true, prereleases: true, snapshots: true, refusesPrivatePackages: true }, + + detect(pkg) { + return !pkg.private; + }, + + label(options, pkg) { + // Refines the common cases (e.g. "GitHub Packages"); named instances otherwise + // label themselves via the metadata key. + const registry = pkg + ? npmEffectiveRegistry(pkg, pkg.bumpy || {}, options) + : typeof options.registry === 'string' + ? options.registry + : undefined; + return publishTargetLabel('npm', registry); + }, + + async preflight(ctx) { + const o = npmOptions(ctx.config, ctx.options); + + if (o.provenance && o.publishManager !== 'npm') { + throw new Error('provenance requires publishManager "npm" — provenance attestation is an npm-specific feature'); + } + + if (o.npmStaged) { + if (o.publishManager !== 'npm') { + throw new Error('npmStaged requires publishManager "npm" — staged publishing is an npm-specific feature'); + } + const npmVersion = tryRunArgs(['npm', '--version']); + if (!npmVersion) { + throw new Error(`npmStaged is enabled but npm was not found — install npm >= ${MIN_NPM_STAGED.join('.')}`); + } + if (!npmVersionAtLeast(npmVersion, MIN_NPM_STAGED)) { + throw new Error( + `npmStaged requires npm >= ${MIN_NPM_STAGED.join('.')} (found ${npmVersion})\n` + + ` Upgrade npm: npm install -g npm@latest`, + ); + } + log.dim(`Staged publishing enabled — packages will require 2FA approval on npmjs.com`); + } + + setupNpmAuth(ctx.rootDir, o.publishManager); + }, + + async checkPublished(pkg, version, options) { + try { + const args = ['npm', 'info', `${pkg.name}@${version}`, 'version']; + const registry = npmEffectiveRegistry(pkg, pkg.bumpy || {}, options); + if (registry) args.push('--registry', registry); + const result = await runArgsAsync(args); + return result.trim() === version; + } catch { + return false; + } + }, + + artifactKind(options, config) { + const o = { ...config.publish, ...options } as PublishConfig; + return o.protocolResolution === 'pack' ? 'npm-tarball' : undefined; + }, + + needsProtocolResolution(options, config) { + const o = { ...config.publish, ...options } as PublishConfig; + return o.protocolResolution === 'in-place'; + }, + + async buildArtifact(ctx) { + const o = npmOptions(ctx.config, ctx.options); + const packManager = o.packManager === 'auto' ? ctx.packManager : o.packManager; + const packArgs = getPackArgs(packManager); + log.dim(` Packing with: ${packArgs.join(' ')}`); + const packOutput = await runArgsAsync(packArgs, { cwd: ctx.pkg.dir }); + return parseTarballPath(packOutput, ctx.pkg.dir, packManager); + }, + + async publish(ctx) { + const args = buildPublishArgs(ctx, ctx.artifactPath); + if (ctx.dryRun) { + log.dim(` Would publish with: ${args.join(' ')}`); + return; + } + log.dim(` Publishing: ${args.join(' ')}`); + await runArgsAsync(args, { cwd: ctx.pkg.dir }); + }, + + publishUrl(pkg, version, options, extra) { + return buildPublishUrl(pkg.name, version, 'npm', { + registry: npmEffectiveRegistry(pkg, pkg.bumpy || {}, options), + repoSlug: extra.repoSlug, + }); + }, +}; diff --git a/packages/bumpy/src/core/targets/pypi.ts b/packages/bumpy/src/core/targets/pypi.ts new file mode 100644 index 0000000..6d238f8 --- /dev/null +++ b/packages/bumpy/src/core/targets/pypi.ts @@ -0,0 +1,222 @@ +import { resolve } from 'node:path'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { runArgsAsync, tryRunArgs } from '../../utils/shell.ts'; +import { log } from '../../utils/logger.ts'; +import type { WorkspacePackage } from '../../types.ts'; +import { stringArrayOption, stringOption } from './util.ts'; +import type { PublishTargetPlugin, TargetPublishContext } from './types.ts'; + +/** + * PyPI target — publishes a Python package living inside the (package.json-driven) + * workspace. bumpy's versioning spine stays package.json: give the Python package a + * stub `package.json` (`"private": true` + `bumpy.publishTargets: ["pypi"]`) and this + * target syncs the version into `pyproject.toml` at publish time — the same + * publish-time-sync model as the jsr target, so nothing extra is committed in the + * release PR. + * + * Build/upload run through `uv` (`uv build` / `uv publish`): + * - builds into an isolated per-version out-dir, so stale artifacts in `dist/` from + * earlier builds can never be uploaded alongside the new release + * - `uv publish` supports PyPI **trusted publishing** (OIDC) natively on GitHub + * Actions (`id-token: write`), or a token via `UV_PUBLISH_TOKEN` + * + * The PyPI project name comes from `pyproject.toml` `[project].name` (the source of + * truth — npm names, especially scoped ones, are not valid PyPI names). + * + * Capabilities: PyPI has no dist-tags, and PEP 440 versions don't cover bumpy's + * semver prerelease/snapshot suffixes (`-next.0`, `-preview-abc123` are invalid + * there), so channel prereleases and snapshots are skipped, not attempted. + * + * Options: + * - `index` (string) — alternative index URL passed to `uv publish --publish-url` + * - `buildArgs` / `publishArgs` (string[]) — extra args for the respective step + */ + +const OUT_DIR_PREFIX = '.bumpy-pypi-dist'; + +interface PyprojectInfo { + raw: string; + /** [project].name */ + name?: string; + /** [project].version (undefined when absent or listed in `dynamic`) */ + version?: string; + dynamicVersion: boolean; +} + +/** + * Minimal pyproject.toml inspection: extracts `name`/`version` from the `[project]` + * table via line matching (full TOML parsing is overkill for two flat keys). + */ +export function parsePyproject(raw: string): PyprojectInfo { + const projectSection = extractTomlSection(raw, 'project'); + const name = matchTomlString(projectSection, 'name'); + const version = matchTomlString(projectSection, 'version'); + const dynamic = projectSection.match(/^\s*dynamic\s*=\s*\[([^\]]*)\]/m)?.[1] ?? ''; + return { + raw, + name, + version, + dynamicVersion: /["']version["']/.test(dynamic), + }; +} + +/** The lines of one `[section]` table (up to the next top-level `[table]` header) */ +function extractTomlSection(raw: string, section: string): string { + const match = raw.match(new RegExp(`^\\[${section}\\]\\s*$([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, 'm')); + return match?.[1] ?? ''; +} + +function matchTomlString(sectionBody: string, key: string): string | undefined { + return sectionBody.match(new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']*)["']`, 'm'))?.[1]; +} + +/** + * Rewrite `[project].version` in-place, preserving all other formatting. + * Returns the updated content, or null if the version key couldn't be located. + */ +export function updatePyprojectVersion(raw: string, newVersion: string): string | null { + const sectionStart = raw.match(/^\[project\]\s*$/m); + if (sectionStart?.index === undefined) return null; + const bodyStart = sectionStart.index + sectionStart[0].length; + const rest = raw.slice(bodyStart); + const nextSection = rest.search(/^\[/m); + const body = nextSection === -1 ? rest : rest.slice(0, nextSection); + + const versionLine = body.match(/^(\s*version\s*=\s*)(["'])[^"']*\2/m); + if (versionLine?.index === undefined) return null; + + const absolute = bodyStart + versionLine.index; + return ( + raw.slice(0, absolute) + + `${versionLine[1]}${versionLine[2]}${newVersion}${versionLine[2]}` + + raw.slice(absolute + versionLine[0].length) + ); +} + +function loadPyproject(pkg: WorkspacePackage): PyprojectInfo | null { + const path = resolve(pkg.dir, 'pyproject.toml'); + if (!existsSync(path)) return null; + return parsePyproject(readFileSync(path, 'utf-8')); +} + +/** PEP 503 normalization for URL/API paths: lowercase, runs of -_. collapse to - */ +function normalizePypiName(name: string): string { + return name.toLowerCase().replace(/[-_.]+/g, '-'); +} + +/** Sync pyproject.toml's [project].version from the version being published */ +function syncPyprojectVersion(ctx: TargetPublishContext): void { + const path = resolve(ctx.pkg.dir, 'pyproject.toml'); + const info = parsePyproject(readFileSync(path, 'utf-8')); + if (info.dynamicVersion) { + throw new Error( + `${ctx.pkg.name}: pyproject.toml declares a dynamic version — bumpy can't sync it. ` + + `Use a static [project] version (commit any placeholder; bumpy rewrites it at publish time).`, + ); + } + if (info.version === ctx.version) return; + // Validate even on dry runs — only the write is skipped + const updated = updatePyprojectVersion(info.raw, ctx.version); + if (updated === null) { + throw new Error(`${ctx.pkg.name}: could not find a static [project] version in pyproject.toml to sync`); + } + if (!ctx.dryRun) writeFileSync(path, updated); +} + +export const pypiTarget: PublishTargetPlugin = { + type: 'pypi', + capabilities: { distTags: false, prereleases: false, snapshots: false }, + + detect(pkg) { + return existsSync(resolve(pkg.dir, 'pyproject.toml')); + }, + + label() { + return 'PyPI'; + }, + + async preflight(ctx) { + const uvVersion = tryRunArgs(['uv', '--version']); + if (!uvVersion) { + throw new Error( + 'pypi target requires the `uv` CLI for building and publishing — ' + + 'install it (https://docs.astral.sh/uv/) or use a custom target with your own commands', + ); + } + // Auth: trusted publishing (OIDC) needs no secret on GitHub Actions; otherwise a token + if ( + !ctx.dryRun && + !process.env.UV_PUBLISH_TOKEN && + !process.env.ACTIONS_ID_TOKEN_REQUEST_URL && + !ctx.options.index + ) { + log.warn(' No PyPI auth detected — set UV_PUBLISH_TOKEN or configure trusted publishing (OIDC)'); + } + }, + + async checkPublished(pkg, version, options) { + // Only pypi.org is queryable generically; custom indexes fall back to git tags + if (options.index) return null; + const info = loadPyproject(pkg); + if (!info?.name) return null; + try { + const res = await fetch(`https://pypi.org/pypi/${normalizePypiName(info.name)}/${version}/json`); + if (res.status === 200) return true; + if (res.status === 404) return false; + return null; + } catch { + return null; // network hiccup — unknown + } + }, + + artifactKind() { + return 'python-dist'; + }, + + async prepare(ctx) { + if (!existsSync(resolve(ctx.pkg.dir, 'pyproject.toml'))) { + throw new Error(`${ctx.pkg.name}: pypi target requires a pyproject.toml`); + } + syncPyprojectVersion(ctx); + }, + + async buildArtifact(ctx) { + // Isolated out-dir: `dist/` may hold stale builds of other versions, and + // uploading a directory wholesale is how old artifacts leak into a release + const outDir = resolve(ctx.pkg.dir, `${OUT_DIR_PREFIX}-${ctx.version}`); + const buildArgs = stringArrayOption(ctx.options, 'buildArgs'); + const args = ['uv', 'build', '--out-dir', outDir, ...buildArgs]; + log.dim(` Building: ${args.join(' ')}`); + await runArgsAsync(args, { cwd: ctx.pkg.dir }); + return outDir; + }, + + async publish(ctx) { + const args = ['uv', 'publish']; + const index = stringOption(ctx.options, 'index'); + if (index) args.push('--publish-url', index); + args.push(...stringArrayOption(ctx.options, 'publishArgs')); + + if (ctx.dryRun) { + log.dim(` Would publish with: ${args.join(' ')} `); + return; + } + + // Explicit file list (no shell globbing) from the isolated build dir + const distFiles = (await readdir(ctx.artifactPath!)).map((f) => resolve(ctx.artifactPath!, f)); + if (distFiles.length === 0) { + throw new Error(`${ctx.pkg.name}: uv build produced no distributions in ${ctx.artifactPath}`); + } + args.push(...distFiles); + + log.dim(` Publishing: ${args.join(' ')}`); + await runArgsAsync(args, { cwd: ctx.pkg.dir }); + }, + + publishUrl(pkg, version) { + const info = loadPyproject(pkg); + if (!info?.name) return undefined; + return `https://pypi.org/project/${normalizePypiName(info.name)}/${version}/`; + }, +}; diff --git a/packages/bumpy/src/core/targets/registry.ts b/packages/bumpy/src/core/targets/registry.ts new file mode 100644 index 0000000..c13c20e --- /dev/null +++ b/packages/bumpy/src/core/targets/registry.ts @@ -0,0 +1,218 @@ +import { log } from '../../utils/logger.ts'; +import { npmTarget } from './npm.ts'; +import { customTarget } from './custom.ts'; +import { jsrTarget } from './jsr.ts'; +import { pypiTarget } from './pypi.ts'; +import { vscodeMarketplaceTarget, openVsxTarget } from './vscode.ts'; +import type { + BumpyConfig, + PackageConfig, + PackageTargetEntry, + TargetDefinition, + WorkspacePackage, +} from '../../types.ts'; +import type { PublishTargetPlugin, ResolvedTarget, TargetOptions } from './types.ts'; + +/** + * Built-in publish targets. These register through the same interface external + * plugins will eventually load through — being built-in is a packaging choice, + * not an architectural one. + */ +const BUILT_IN_TARGETS: Record = { + [npmTarget.type]: npmTarget, + [customTarget.type]: customTarget, + [jsrTarget.type]: jsrTarget, + [pypiTarget.type]: pypiTarget, + [vscodeMarketplaceTarget.type]: vscodeMarketplaceTarget, + [openVsxTarget.type]: openVsxTarget, +}; + +export function getTargetPlugin(type: string): PublishTargetPlugin | undefined { + return BUILT_IN_TARGETS[type]; +} + +export function knownTargetTypes(): string[] { + return Object.keys(BUILT_IN_TARGETS); +} + +function requirePlugin(type: string, context: string): PublishTargetPlugin { + const plugin = BUILT_IN_TARGETS[type]; + if (!plugin) { + throw new Error( + `Unknown publish target type "${type}" (${context}). Known types: ${knownTargetTypes().join(', ')}`, + ); + } + return plugin; +} + +/** Options from a root `targets` map entry, minus the structural `type` key */ +function definitionOptions(def: TargetDefinition | undefined): TargetOptions { + if (!def) return {}; + const { type: _type, ...options } = def; + return options; +} + +/** + * Type-level default options for `type`: the root `targets` map entry whose key IS the + * type name. (Named instances layer their own options on top of these.) + */ +function typeDefaults(type: string, config?: BumpyConfig): TargetOptions { + return definitionOptions(config?.targets?.[type]); +} + +function resolveStringEntry(ref: string, config: BumpyConfig | undefined, pkgName: string): ResolvedTarget { + const def = config?.targets?.[ref]; + + // A key matching a built-in type is type-level defaults, not a redirection + if (BUILT_IN_TARGETS[ref]) { + if (def?.type && def.type !== ref) { + throw new Error( + `targets["${ref}"] sets type "${def.type}", but "${ref}" is a built-in target type — ` + + `rename the entry to define a separate named instance`, + ); + } + return { name: ref, type: ref, plugin: BUILT_IN_TARGETS[ref], options: definitionOptions(def) }; + } + + // Named instance from the root targets map + if (def) { + if (typeof def.type !== 'string' || !def.type) { + throw new Error(`targets["${ref}"] must declare a "type" — it doesn't match any built-in target type`); + } + const plugin = requirePlugin(def.type, `targets["${ref}"]`); + return { + name: ref, + type: def.type, + plugin, + options: { ...typeDefaults(def.type, config), ...definitionOptions(def) }, + }; + } + + if (!config) { + throw new Error( + `Cannot resolve publish target "${ref}" for "${pkgName}" without the root config — ` + + `it is not a built-in target type`, + ); + } + throw new Error( + `Package "${pkgName}" references unknown publish target "${ref}" — ` + + `not a built-in type (${knownTargetTypes().join(', ')}) and not defined in the root config's "targets" map`, + ); +} + +function resolveInlineEntry( + entry: PackageTargetEntry, + config: BumpyConfig | undefined, + pkgName: string, +): ResolvedTarget { + if (typeof entry.type !== 'string' || !entry.type) { + throw new Error(`Package "${pkgName}" has a publishTargets entry without a "type"`); + } + const plugin = requirePlugin(entry.type, `package "${pkgName}" publishTargets`); + const { type, name, ...options } = entry; + return { + name: typeof name === 'string' && name ? name : type, + type, + plugin, + options: { ...typeDefaults(type, config), ...options }, + }; +} + +/** + * Map the legacy per-package fields (`publishCommand`, `skipNpmPublish`, `private`) + * onto target instances. Instance names intentionally match the metadata keys the + * previous pipeline wrote ("npm", "custom") so in-flight releases resume cleanly. + */ +function resolveLegacyTargets( + pkg: Pick, + pkgConfig: PackageConfig, + config?: BumpyConfig, +): ResolvedTarget[] { + if (pkgConfig.publishCommand) { + return [ + { + name: 'custom', + type: 'custom', + plugin: customTarget, + options: { + ...typeDefaults('custom', config), + command: pkgConfig.publishCommand, + ...(pkgConfig.checkPublished ? { checkPublished: pkgConfig.checkPublished } : {}), + }, + }, + ]; + } + if (pkg.private || pkgConfig.skipNpmPublish) return []; + return [{ name: 'npm', type: 'npm', plugin: npmTarget, options: typeDefaults('npm', config) }]; +} + +/** + * Resolve the publish targets for a package: explicit `publishTargets` config if + * present, otherwise derived from the legacy fields / implicit npm default. + * + * npm-type targets are dropped for `"private": true` packages (npm refuses to publish + * them) — this is what lets a private VS Code extension publish to the marketplace + * while never touching npm. + */ +export function resolvePackageTargets( + pkg: Pick, + pkgConfig: PackageConfig, + config?: BumpyConfig, +): ResolvedTarget[] { + const entries = pkgConfig.publishTargets; + if (entries === undefined) { + return resolveLegacyTargets(pkg as WorkspacePackage, pkgConfig, config); + } + + if (pkgConfig.publishCommand || pkgConfig.skipNpmPublish) { + log.warn(` ${pkg.name}: "publishTargets" is set — ignoring legacy "publishCommand"/"skipNpmPublish" fields`); + } + + const resolved: ResolvedTarget[] = []; + for (const entry of entries) { + const target = + typeof entry === 'string' + ? resolveStringEntry(entry, config, pkg.name) + : resolveInlineEntry(entry, config, pkg.name); + + if (pkg.private && target.plugin.capabilities.refusesPrivatePackages) { + log.warn( + ` ${pkg.name}: dropping publish target "${target.name}" — package is "private": true (${target.type} refuses to publish it)`, + ); + continue; + } + if (resolved.some((t) => t.name === target.name)) { + throw new Error( + `Package "${pkg.name}" has duplicate publish target name "${target.name}" — ` + + `give one instance an explicit unique "name" (it keys the release metadata)`, + ); + } + resolved.push(target); + } + return resolved; +} + +/** + * Publish targets for a package: the instances attached at workspace discovery, or a + * lazy resolution for hand-constructed packages (tests, partial contexts). Without a + * root config, named-instance references can't resolve — pass `config` when you have it. + */ +export function getPackageTargets(pkg: WorkspacePackage, config?: BumpyConfig): ResolvedTarget[] { + if (pkg.targets) return pkg.targets; + return resolvePackageTargets(pkg, pkg.bumpy || {}, config); +} + +/** Whether this package publishes anywhere at all */ +export function packagePublishes(pkg: WorkspacePackage, config?: BumpyConfig): boolean { + return getPackageTargets(pkg, config).length > 0; +} + +/** First npm-type target instance for a package, if any (registry queries use its options) */ +export function getNpmTarget(pkg: WorkspacePackage, config?: BumpyConfig): ResolvedTarget | undefined { + return getPackageTargets(pkg, config).find((t) => t.type === 'npm'); +} + +/** Display label for a resolved target (plugin label, falling back to the instance name) */ +export function targetLabel(target: ResolvedTarget, pkg?: WorkspacePackage): string { + return target.plugin.label?.(target.options, pkg) ?? target.name; +} diff --git a/packages/bumpy/src/core/targets/types.ts b/packages/bumpy/src/core/targets/types.ts new file mode 100644 index 0000000..7416090 --- /dev/null +++ b/packages/bumpy/src/core/targets/types.ts @@ -0,0 +1,121 @@ +import type { BumpyConfig, PackageConfig, PackageManager, WorkspacePackage } from '../../types.ts'; + +/** Free-form option bag for a target instance (merged from type defaults + instance config) */ +export type TargetOptions = Record; + +export type ReleaseKind = 'stable' | 'channel' | 'snapshot'; + +export interface TargetCapabilities { + /** Supports npm-style dist-tags (`--tag next`) */ + distTags: boolean; + /** Can publish semver prerelease versions (e.g. `1.2.0-rc.0`) */ + prereleases: boolean; + /** Participates in transient snapshot releases (`bumpy publish --snapshot`) */ + snapshots: boolean; + /** + * The registry refuses `"private": true` packages (npm's marker). Instances of + * such targets are dropped from private packages at resolve time — which is what + * lets a private VS Code extension publish to marketplaces while never touching npm. + */ + refusesPrivatePackages?: boolean; +} + +/** Context for the once-per-target-instance preflight hook, run before any publish */ +export interface TargetPreflightContext { + rootDir: string; + config: BumpyConfig; + options: TargetOptions; + dryRun: boolean; +} + +/** Context for per-package target operations (publish, buildArtifact) */ +export interface TargetPublishContext { + pkg: WorkspacePackage; + /** Merged per-package bumpy config (legacy fields like `registry`/`access` live here) */ + pkgConfig: PackageConfig; + /** The version being published (already written to the package manifest) */ + version: string; + rootDir: string; + config: BumpyConfig; + /** Merged options for this target instance */ + options: TargetOptions; + /** npm-style dist-tag for this publish, when the release flow provides one */ + distTag?: string; + dryRun: boolean; + releaseKind: ReleaseKind; + /** Path to the shared artifact, when the plugin declares an artifactKind */ + artifactPath?: string; + /** Detected workspace package manager (pack strategies may use it) */ + packManager: PackageManager; +} + +/** + * A publish target plugin. Built-in targets (npm, custom, vscode-marketplace, open-vsx) + * implement this interface; it is also the seam future external plugins load through. + * + * Lifecycle within one `bumpy publish` run: + * 1. `preflight` — once per resolved target instance, before anything publishes + * (auth/tooling validation; throw to abort the whole run) + * 2. per package, in topo order: + * a. `artifactKind`/`buildArtifact` — artifacts are cached per package by kind, so + * multiple targets sharing a kind (e.g. one .vsix → marketplace + Open VSX) get + * the same file + * b. `publish` — one target failing does not block sibling targets; state is + * recorded per target in the GitHub release metadata and retried on the next run + */ +export interface PublishTargetPlugin { + type: string; + capabilities: TargetCapabilities; + /** Heuristic: does this package look like it should use this target? (used for suggestions, never auto-applied) */ + detect?(pkg: WorkspacePackage): boolean; + /** Human-readable label for release notes / status output. Falls back to the instance name. */ + label?(options: TargetOptions, pkg?: WorkspacePackage): string; + preflight?(ctx: TargetPreflightContext): void | Promise; + /** + * Per-package pre-publish step, run after the skip gates (capabilities, resume, + * registry guard) and before artifact building. The home for publish-time version + * syncing into ecosystem manifests (jsr.json, pyproject.toml). Also called on dry + * runs so config validation surfaces there — check `ctx.dryRun` and skip file + * mutations only. + */ + prepare?(ctx: TargetPublishContext): void | Promise; + /** + * Whether `version` is already live on this target. + * Return null for "unknown" (caller falls back to git-tag tracking). + */ + checkPublished?(pkg: WorkspacePackage, version: string, options: TargetOptions): Promise; + /** + * Artifact kind this target publishes from (e.g. "vsix", "npm-tarball"). + * Targets on the same package sharing a kind share one built artifact. + * Return undefined to publish directly from the package directory. + */ + artifactKind?(options: TargetOptions, config: BumpyConfig): string | undefined; + /** Build the artifact and return its absolute path. Required when artifactKind returns a kind. */ + buildArtifact?(ctx: TargetPublishContext): Promise; + /** + * Whether workspace:/catalog: protocols must be resolved in the package.json on disk + * before this target runs (targets that read the manifest directly, e.g. custom + * commands and vsce, need this; npm's pack flow handles it in the tarball). + */ + needsProtocolResolution?(options: TargetOptions, config: BumpyConfig): boolean; + publish(ctx: TargetPublishContext): Promise; + /** Browsable URL for a published version, used in release notes. */ + publishUrl?( + pkg: WorkspacePackage, + version: string, + options: TargetOptions, + extra: { repoSlug?: string }, + ): string | undefined; +} + +/** A target instance resolved for a specific package: plugin + merged options + stable name */ +export interface ResolvedTarget { + /** + * Instance name — the stable key for this target in release metadata. Renaming it + * mid-release breaks partial-failure resume, so names should be stable. + */ + name: string; + type: string; + plugin: PublishTargetPlugin; + options: TargetOptions; +} diff --git a/packages/bumpy/src/core/targets/util.ts b/packages/bumpy/src/core/targets/util.ts new file mode 100644 index 0000000..b9ca397 --- /dev/null +++ b/packages/bumpy/src/core/targets/util.ts @@ -0,0 +1,13 @@ +import type { TargetOptions } from './types.ts'; + +/** Coerce a target option to a string array (options are untyped user config) */ +export function stringArrayOption(options: TargetOptions, key: string): string[] { + const value = options[key]; + return Array.isArray(value) ? value.map(String) : []; +} + +/** Coerce a target option to a non-empty string, or undefined */ +export function stringOption(options: TargetOptions, key: string): string | undefined { + const value = options[key]; + return typeof value === 'string' && value ? value : undefined; +} diff --git a/packages/bumpy/src/core/targets/vscode.ts b/packages/bumpy/src/core/targets/vscode.ts new file mode 100644 index 0000000..6adfdcf --- /dev/null +++ b/packages/bumpy/src/core/targets/vscode.ts @@ -0,0 +1,179 @@ +import { resolve } from 'node:path'; +import { runArgsAsync, runStreaming, sq } from '../../utils/shell.ts'; +import { log } from '../../utils/logger.ts'; +import { stringArrayOption } from './util.ts'; +import type { WorkspacePackage } from '../../types.ts'; +import type { PublishTargetPlugin, TargetPublishContext } from './types.ts'; + +/** + * VS Code Marketplace and Open VSX targets. + * + * Both publish the same packaged `.vsix`, so they share the "vsix" artifact kind — + * the pipeline builds it once (via `vsce package`) and hands the same file to both. + * Publishing from a prebuilt vsix also keeps vsce/ovsx from re-running their own + * version bumps or builds. + * + * Auth: vsce reads `VSCE_PAT`, ovsx reads `OVSX_PAT` (both native to the CLIs). + * The CLIs are invoked through `npx --yes`, so a repo-local devDependency wins and + * otherwise the published CLI is fetched on demand. + * + * The Marketplace requires plain `major.minor.patch` versions — semver prerelease + * suffixes are rejected — so both targets opt out of prereleases and snapshots. + */ + +const VSCE_BIN = ['npx', '--yes', '@vscode/vsce']; +const OVSX_BIN = ['npx', '--yes', 'ovsx']; + +function extensionId(pkg: WorkspacePackage): string { + const publisher = pkg.packageJson.publisher; + if (typeof publisher !== 'string' || !publisher) { + throw new Error(`${pkg.name}: VS Code extension targets require a "publisher" field in package.json`); + } + return `${publisher}.${pkg.name}`; +} + +function looksLikeVscodeExtension(pkg: WorkspacePackage): boolean { + const engines = pkg.packageJson.engines; + const hasVscodeEngine = !!engines && typeof engines === 'object' && 'vscode' in (engines as Record); + return hasVscodeEngine && typeof pkg.packageJson.publisher === 'string'; +} + +function vsixPath(ctx: TargetPublishContext): string { + // vsce's default filename, made explicit so both targets agree on the path + return resolve(ctx.pkg.dir, `${ctx.pkg.name}-${ctx.version}.vsix`); +} + +async function buildVsix(ctx: TargetPublishContext): Promise { + const out = vsixPath(ctx); + const extraArgs = stringArrayOption(ctx.options, 'packageArgs'); + // --no-dependencies by default: vsce's npm-based dependency detection breaks in + // workspace monorepos (workspace:/catalog: protocols, hoisted node_modules) and + // silently ships a broken vsix. Bundled extensions (the norm) don't need it; + // set `dependencies: true` on the target to restore vsce's default behavior. + const depArgs = ctx.options.dependencies === true ? [] : ['--no-dependencies']; + const cmd = [...VSCE_BIN, 'package', ...depArgs, '--out', sq(out), ...extraArgs.map(sq)].join(' '); + log.dim(` Packaging vsix: ${cmd}`); + // Stream output — vsce runs the extension's (pre)publish build, which can be chatty/slow + await runStreaming(cmd, { cwd: ctx.pkg.dir }); + return out; +} + +/** Run a CLI and parse the published version out of its JSON output. Null = unknown. */ +async function fetchPublishedVersion(args: string[], extract: (json: unknown) => unknown): Promise { + try { + const output = await runArgsAsync(args); + const version = extract(JSON.parse(output)); + return typeof version === 'string' && version ? version : null; + } catch { + return null; + } +} + +export const vscodeMarketplaceTarget: PublishTargetPlugin = { + type: 'vscode-marketplace', + capabilities: { distTags: false, prereleases: false, snapshots: false }, + + detect: looksLikeVscodeExtension, + + label() { + return 'VS Code Marketplace'; + }, + + async preflight(ctx) { + // azureCredential: auth via Azure OIDC (`azure/login` in CI) instead of a + // long-lived VSCE_PAT — vsce mints a short-lived token per publish + if (ctx.options.azureCredential === true) return; + if (!ctx.dryRun && !process.env.VSCE_PAT && !process.env.AZURE_TENANT_ID) { + log.warn(' VSCE_PAT is not set — vsce will need another credential source (e.g. azureCredential) to publish'); + } + }, + + async checkPublished(pkg, version, _options) { + if (!looksLikeVscodeExtension(pkg)) return null; // no publisher — can't query + const published = await fetchPublishedVersion( + [...VSCE_BIN, 'show', extensionId(pkg), '--json'], + (json) => (json as { versions?: Array<{ version?: unknown }> })?.versions?.[0]?.version, + ); + return published === null ? null : published === version; + }, + + artifactKind() { + return 'vsix'; + }, + + needsProtocolResolution() { + // vsce reads package.json from the package dir when packaging + return true; + }, + + buildArtifact: buildVsix, + + async publish(ctx) { + const extraArgs = stringArrayOption(ctx.options, 'publishArgs'); + const authArgs = ctx.options.azureCredential === true ? ['--azure-credential'] : []; + const args = [...VSCE_BIN, 'publish', '--packagePath', ctx.artifactPath!, ...authArgs, ...extraArgs]; + if (ctx.dryRun) { + log.dim(` Would publish with: ${args.join(' ')}`); + return; + } + log.dim(` Publishing: ${args.join(' ')}`); + await runArgsAsync(args, { cwd: ctx.pkg.dir }); + }, + + publishUrl(pkg) { + return `https://marketplace.visualstudio.com/items?itemName=${extensionId(pkg)}`; + }, +}; + +export const openVsxTarget: PublishTargetPlugin = { + type: 'open-vsx', + capabilities: { distTags: false, prereleases: false, snapshots: false }, + + detect: looksLikeVscodeExtension, + + label() { + return 'Open VSX'; + }, + + async preflight(ctx) { + if (!ctx.dryRun && !process.env.OVSX_PAT) { + log.warn(' OVSX_PAT is not set — ovsx publish will likely fail'); + } + }, + + async checkPublished(pkg, version, _options) { + if (!looksLikeVscodeExtension(pkg)) return null; // no publisher — can't query + const publisher = String(pkg.packageJson.publisher ?? ''); + const published = await fetchPublishedVersion( + [...OVSX_BIN, 'get', `${publisher}.${pkg.name}`, '--metadata'], + (json) => (json as { version?: unknown })?.version, + ); + return published === null ? null : published === version; + }, + + artifactKind() { + return 'vsix'; + }, + + needsProtocolResolution() { + return true; + }, + + buildArtifact: buildVsix, + + async publish(ctx) { + const extraArgs = stringArrayOption(ctx.options, 'publishArgs'); + const args = [...OVSX_BIN, 'publish', ctx.artifactPath!, ...extraArgs]; + if (ctx.dryRun) { + log.dim(` Would publish with: ${args.join(' ')}`); + return; + } + log.dim(` Publishing: ${args.join(' ')}`); + await runArgsAsync(args, { cwd: ctx.pkg.dir }); + }, + + publishUrl(pkg, version) { + const publisher = String(pkg.packageJson.publisher ?? ''); + return `https://open-vsx.org/extension/${publisher}/${pkg.name}/${version}`; + }, +}; diff --git a/packages/bumpy/src/core/workspace.ts b/packages/bumpy/src/core/workspace.ts index 623fe88..fb48ba3 100644 --- a/packages/bumpy/src/core/workspace.ts +++ b/packages/bumpy/src/core/workspace.ts @@ -3,6 +3,8 @@ import { readdir, stat } from 'node:fs/promises'; import { readJson, exists } from '../utils/fs.ts'; import { detectWorkspaces, type CatalogMap } from '../utils/package-manager.ts'; import { loadPackageConfig, isPackageManaged } from './config.ts'; +import { resolvePackageTargets } from './targets/registry.ts'; +import { log } from '../utils/logger.ts'; import type { BumpyConfig, WorkspacePackage } from '../types.ts'; export interface WorkspaceDiscoveryResult { @@ -133,6 +135,20 @@ async function loadWorkspacePackage( if (!name) return null; const bumpy = await loadPackageConfig(dir, config, name); + const isPrivate = !!pkg.private; + + // Resolve publish targets once at discovery so downstream consumers can answer + // "where does this package publish" without the root config in hand. A config + // mistake here must not brick read-only commands (status/add/check) — record the + // error and let publish flows refuse loudly instead. + let targets: WorkspacePackage['targets'] = []; + let targetsError: string | undefined; + try { + targets = resolvePackageTargets({ name, private: isPrivate }, bumpy, config); + } catch (err) { + targetsError = err instanceof Error ? err.message : String(err); + log.warn(`${name}: invalid publish target config — ${targetsError}`); + } return { name, @@ -140,11 +156,13 @@ async function loadWorkspacePackage( dir: resolve(dir), relativeDir: relative(rootDir, dir) || '.', packageJson: pkg, - private: !!pkg.private, + private: isPrivate, dependencies: (pkg.dependencies as Record) || {}, devDependencies: (pkg.devDependencies as Record) || {}, peerDependencies: (pkg.peerDependencies as Record) || {}, optionalDependencies: (pkg.optionalDependencies as Record) || {}, bumpy, + targets, + ...(targetsError ? { targetsError } : {}), }; } diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index e1b3c72..5de3112 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -64,6 +64,35 @@ export const DEP_TYPES: DepType[] = ['dependencies', 'devDependencies', 'peerDep // ---- Config ---- +/** + * A target entry in the root config's `targets` map. + * - Key matches a built-in target type (e.g. "npm", "vscode-marketplace") → type-level + * defaults applied to every instance of that type. + * - Any other key → a named, reusable target instance; `type` is required and the key + * becomes the instance name (used in release metadata and `publishTargets` references). + * Remaining fields are target-type-specific options. + */ +export interface TargetDefinition { + type?: string; + [option: string]: unknown; +} + +/** An inline target entry in a package's `publishTargets` list */ +export interface PackageTargetEntry { + type: string; + /** Instance name — the stable key used in release metadata. Defaults to `type`. */ + name?: string; + [option: string]: unknown; +} + +/** + * Per-package publish target list. Each entry is either: + * - a string referencing a built-in type ("npm", "jsr") or a named instance from the + * root config's `targets` map, or + * - an inline definition (`{ type, ...options }`). + */ +export type PublishTargetsInput = Array; + export interface PublishConfig { /** Package manager to use for packing. "auto" detects from lockfile. Default: "auto" */ packManager: 'auto' | 'npm' | 'pnpm' | 'bun' | 'yarn'; @@ -181,6 +210,12 @@ export interface BumpyConfig { allowCustomCommands: boolean | string[]; packages: Record; publish: PublishConfig; + /** + * Publish target configuration shared across packages. Keys matching a built-in + * target type set type-level defaults; other keys define named, reusable instances + * (must include `type`). See {@link TargetDefinition}. + */ + targets: Record; /** Git identity used for CI commits. Defaults to bumpy-bot. */ gitUser: { name: string; email: string }; /** Version PR settings */ @@ -198,9 +233,17 @@ export interface PackageConfig { /** Explicitly opt in or out of version management (overrides private/ignore/include) */ managed?: boolean; access?: 'public' | 'restricted'; + /** + * Publish targets for this package. Overrides the implicit default (npm for public + * packages) and the legacy `publishCommand`/`skipNpmPublish` fields. See + * {@link PublishTargetsInput}. + */ + publishTargets?: PublishTargetsInput; + /** @deprecated Use `publishTargets: [{ type: "custom", command: ... }]` instead. */ publishCommand?: string | string[]; buildCommand?: string; registry?: string; + /** @deprecated Use `publishTargets` without an "npm" entry (e.g. `[]`) instead. */ skipNpmPublish?: boolean; /** Command to check if a version is already published. Should output the published version string. */ checkPublished?: string; @@ -254,6 +297,7 @@ export const DEFAULT_CONFIG: BumpyConfig = { allowCustomCommands: false, packages: {}, publish: { ...DEFAULT_PUBLISH_CONFIG }, + targets: {}, gitUser: { name: 'bumpy-bot', email: '276066384+bumpy-bot@users.noreply.github.com' }, versionPr: { title: '🐸 Versioned release', @@ -323,6 +367,21 @@ export interface WorkspacePackage { peerDependencies: Record; optionalDependencies: Record; bumpy?: PackageConfig; // per-package config from package.json or .bumpy.config.json + /** + * Publish targets resolved at workspace discovery (from `publishTargets` config, + * legacy fields, or the implicit npm default). Attached by `discoverWorkspace` so + * downstream consumers don't need the root config to answer "where does this + * package publish". May be absent for hand-constructed packages (tests) — use + * `getPackageTargets()` from core/targets to resolve lazily. + */ + targets?: import('./core/targets/types.ts').ResolvedTarget[]; + /** + * Set when target resolution failed at discovery (unknown target name, invalid + * targets-map entry, ...). Discovery stays usable so read-only commands (status, + * add, check) keep working with a warning; publish flows refuse to run until the + * config is fixed. + */ + targetsError?: string; } export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; diff --git a/packages/bumpy/test/core/publish-multi-target.test.ts b/packages/bumpy/test/core/publish-multi-target.test.ts new file mode 100644 index 0000000..c61fea5 --- /dev/null +++ b/packages/bumpy/test/core/publish-multi-target.test.ts @@ -0,0 +1,488 @@ +import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; +import { resolve } from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { writeJson, ensureDir } from '../../src/utils/fs.ts'; +import { makePkg, gitInDir } from '../helpers.ts'; +import { installShellMock, uninstallShellMock, addMockRule, getCallsMatching } from '../helpers-shell-mock.ts'; +import { DependencyGraph } from '../../src/core/dep-graph.ts'; +import { publishPackages } from '../../src/core/publish-pipeline.ts'; +import type { WorkspacePackage, ReleasePlan, PlannedRelease } from '../../src/types.ts'; +import { DEFAULT_CONFIG } from '../../src/types.ts'; + +function makeRelease(name: string, oldVersion: string, newVersion: string): PlannedRelease { + return { + name, + type: 'patch', + oldVersion, + newVersion, + bumpFiles: [], + isDependencyBump: false, + isCascadeBump: false, + isGroupBump: false, + bumpSources: [], + }; +} + +describe('publishPackages — multi-target', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-mt-test-')); + installShellMock(); + }); + + afterEach(async () => { + uninstallShellMock(); + await rm(tmpDir, { recursive: true }); + }); + + async function setupPkg(name: string, pkgJson: Record = {}): Promise { + const pkgDir = resolve(tmpDir, `packages/${name}`); + await ensureDir(pkgDir); + await writeJson(resolve(pkgDir, 'package.json'), { name, version: '1.0.0', ...pkgJson }); + gitInDir(['init'], tmpDir); + gitInDir(['add', '.'], tmpDir); + gitInDir(['commit', '-m', 'init', '--allow-empty'], tmpDir); + return pkgDir; + } + + function planFor(...pkgs: WorkspacePackage[]): { + packages: Map; + depGraph: DependencyGraph; + plan: ReleasePlan; + } { + const packages = new Map(pkgs.map((p) => [p.name, p])); + return { + packages, + depGraph: new DependencyGraph(packages), + plan: { bumpFiles: [], warnings: [], releases: pkgs.map((p) => makeRelease(p.name, '1.0.0', '1.0.1')) }, + }; + } + + test('two targets on one package both publish, with per-target outcomes', async () => { + const pkgDir = await setupPkg('multi'); + const pkg = makePkg('multi', '1.0.0', { + dir: pkgDir, + bumpy: { + publishTargets: [ + { type: 'custom', name: 'a', command: 'echo publish-a' }, + { type: 'custom', name: 'b', command: 'echo publish-b' }, + ], + }, + }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(result.published).toHaveLength(1); + expect(result.failed).toHaveLength(0); + const outcomes = result.targetOutcomes.get('multi')!; + expect(outcomes.map((o) => [o.target, o.status])).toEqual([ + ['a', 'success'], + ['b', 'success'], + ]); + }); + + test('one target failing does not block its sibling; package is published AND failed', async () => { + const pkgDir = await setupPkg('flaky'); + const pkg = makePkg('flaky', '1.0.0', { + dir: pkgDir, + bumpy: { + publishTargets: [ + { type: 'custom', name: 'bad', command: 'fail-cmd' }, + { type: 'custom', name: 'good', command: 'echo ok' }, + ], + }, + }); + addMockRule({ match: 'fail-cmd', error: 'boom' }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const outcomes = result.targetOutcomes.get('flaky')!; + expect(outcomes.find((o) => o.target === 'bad')!.status).toBe('failed'); + expect(outcomes.find((o) => o.target === 'good')!.status).toBe('success'); + // Partial success: counted as published (tag exists) and failed (exit code / retry) + expect(result.published.map((p) => p.name)).toEqual(['flaky']); + expect(result.failed.map((f) => f.name)).toEqual(['flaky']); + // Tag was created because one target succeeded + expect(gitInDir(['tag', '-l', 'flaky@1.0.1'], tmpDir)).toBe('flaky@1.0.1'); + }); + + test('completedTargets skips already-published targets (per-target resume)', async () => { + const pkgDir = await setupPkg('resume'); + const pkg = makePkg('resume', '1.0.0', { + dir: pkgDir, + bumpy: { + publishTargets: [ + { type: 'custom', name: 'done-already', command: 'echo again' }, + { type: 'custom', name: 'pending', command: 'echo finally' }, + ], + }, + }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, { + completedTargets: new Map([['resume', new Set(['done-already'])]]), + }); + + const outcomes = result.targetOutcomes.get('resume')!; + expect(outcomes.find((o) => o.target === 'done-already')!.status).toBe('skipped'); + expect(outcomes.find((o) => o.target === 'done-already')!.skipKind).toBe('metadata'); + expect(outcomes.find((o) => o.target === 'done-already')!.reason).toBe('already published'); + expect(outcomes.find((o) => o.target === 'pending')!.status).toBe('success'); + }); + + test('vscode-marketplace and open-vsx share one vsix artifact', async () => { + const pkgDir = await setupPkg('my-ext', { publisher: 'acme', engines: { vscode: '^1.90.0' } }); + const pkg = makePkg('my-ext', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { publishTargets: ['vscode-marketplace', 'open-vsx'] }, + }); + pkg.packageJson.publisher = 'acme'; + pkg.packageJson.engines = { vscode: '^1.90.0' }; + + addMockRule({ match: '@vscode/vsce package', response: '' }); + addMockRule({ match: '@vscode/vsce publish', response: '' }); + addMockRule({ match: /ovsx publish/, response: '' }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(result.failed).toHaveLength(0); + expect(result.published).toHaveLength(1); + // vsix built exactly once, then published to both registries from the same file + expect(getCallsMatching('@vscode/vsce package')).toHaveLength(1); + const vscePublish = getCallsMatching('@vscode/vsce publish'); + const ovsxPublish = getCallsMatching(/^npx --yes ovsx publish/); + expect(vscePublish).toHaveLength(1); + expect(ovsxPublish).toHaveLength(1); + expect(vscePublish[0]!.command).toContain('--packagePath'); + expect(vscePublish[0]!.command).toContain('my-ext-1.0.1.vsix'); + expect(ovsxPublish[0]!.command).toContain('my-ext-1.0.1.vsix'); + }); + + test('azureCredential option publishes via Azure OIDC instead of a PAT', async () => { + const pkgDir = await setupPkg('azure-ext', { publisher: 'acme', engines: { vscode: '^1.90.0' } }); + const pkg = makePkg('azure-ext', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { publishTargets: [{ type: 'vscode-marketplace', azureCredential: true }] }, + }); + pkg.packageJson.publisher = 'acme'; + + addMockRule({ match: '@vscode/vsce package', response: '' }); + addMockRule({ match: '@vscode/vsce publish', response: '' }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(result.failed).toHaveLength(0); + expect(getCallsMatching('@vscode/vsce publish')[0]!.command).toContain('--azure-credential'); + }); + + test('marketplace targets skip prerelease versions', async () => { + const pkgDir = await setupPkg('pre-ext', { publisher: 'acme', engines: { vscode: '^1.90.0' } }); + const pkg = makePkg('pre-ext', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { + publishTargets: ['vscode-marketplace', { type: 'custom', name: 'mirror', command: 'echo ok' }], + }, + }); + pkg.packageJson.publisher = 'acme'; + + const packages = new Map([[pkg.name, pkg]]); + const depGraph = new DependencyGraph(packages); + const plan: ReleasePlan = { + bumpFiles: [], + warnings: [], + releases: [makeRelease('pre-ext', '1.0.0', '1.1.0-rc.0')], + }; + + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const outcomes = result.targetOutcomes.get('pre-ext')!; + expect(outcomes.find((o) => o.target === 'vscode-marketplace')!.status).toBe('skipped'); + expect(outcomes.find((o) => o.target === 'vscode-marketplace')!.reason).toBe('prereleases not supported'); + // The custom target still publishes the prerelease + expect(outcomes.find((o) => o.target === 'mirror')!.status).toBe('success'); + // No vsce invocation at all + expect(getCallsMatching('@vscode/vsce')).toHaveLength(0); + }); + + test('marketplace targets skip snapshot releases', async () => { + const pkgDir = await setupPkg('snap-ext', { publisher: 'acme' }); + const pkg = makePkg('snap-ext', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { publishTargets: ['open-vsx'] }, + }); + pkg.packageJson.publisher = 'acme'; + + const packages = new Map([[pkg.name, pkg]]); + const depGraph = new DependencyGraph(packages); + const plan: ReleasePlan = { + bumpFiles: [], + warnings: [], + releases: [makeRelease('snap-ext', '1.0.0', '1.0.1-preview-abc1234')], + }; + + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, { + noTag: true, + releaseKind: 'snapshot', + }); + + const outcomes = result.targetOutcomes.get('snap-ext')!; + expect(outcomes[0]!.status).toBe('skipped'); + expect(outcomes[0]!.reason).toBe('snapshots not supported'); + expect(result.published).toHaveLength(0); + expect(result.skipped.map((s) => s.name)).toEqual(['snap-ext']); + }); + + test('registry guard: target already live on the registry is skipped, not re-published', async () => { + const pkgDir = await setupPkg('guarded'); + const pkg = makePkg('guarded', '1.0.0', { + dir: pkgDir, + bumpy: { + publishTargets: [{ type: 'custom', name: 'mirror', command: 'publish-cmd', checkPublished: 'check-cmd' }], + }, + }); + addMockRule({ match: 'check-cmd', response: '1.0.1' }); // reports the target version as live + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const outcomes = result.targetOutcomes.get('guarded')!; + expect(outcomes[0]!.status).toBe('skipped'); + expect(outcomes[0]!.skipKind).toBe('registry'); + expect(outcomes[0]!.reason).toBe('already on registry'); + expect(getCallsMatching('publish-cmd')).toHaveLength(0); + // Version exists → tag still created (resume semantics) + expect(gitInDir(['tag', '-l', 'guarded@1.0.1'], tmpDir)).toBe('guarded@1.0.1'); + }); + + describe('jsr target', () => { + const realFetch = globalThis.fetch; + let fetchResponses: Map; + + beforeEach(() => { + fetchResponses = new Map(); + globalThis.fetch = (async (url: string | URL) => { + const u = String(url); + for (const [pattern, status] of fetchResponses) { + if (typeof pattern === 'string' ? u.includes(pattern) : pattern.test(u)) { + return new Response('{}', { status }); + } + } + return new Response('{}', { status: 404 }); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + async function setupJsrPkg(opts: { claimed?: boolean } = {}) { + const pkgDir = await setupPkg('@myorg/mdit-thing'); + await writeJson(resolve(pkgDir, 'jsr.json'), { + name: '@myorg/mdit-thing', + version: '0.0.0', + exports: { '.': './src/index.ts' }, + }); + // package claimed on JSR (200) unless the test says otherwise; version never published + fetchResponses.set(/packages\/mdit-thing$/, opts.claimed === false ? 404 : 200); + fetchResponses.set('/versions/', 404); + addMockRule({ match: 'jsr publish', response: '' }); + return makePkg('@myorg/mdit-thing', '1.0.0', { + dir: pkgDir, + bumpy: { publishTargets: ['jsr'] }, + }); + } + + test('syncs jsr.json version at publish time and publishes with --allow-dirty', async () => { + const pkg = await setupJsrPkg(); + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(result.failed).toHaveLength(0); + expect(result.published.map((p) => p.name)).toEqual(['@myorg/mdit-thing']); + + const publishCalls = getCallsMatching('jsr publish'); + expect(publishCalls).toHaveLength(1); + expect(publishCalls[0]!.command).toContain('--allow-dirty'); + expect(publishCalls[0]!.command).not.toContain('--allow-slow-types'); + + // jsr.json version was synced from the release (committed as 0.0.0) + const { readJson } = await import('../../src/utils/fs.ts'); + const jsrJson = await readJson<{ version: string }>(resolve(pkg.dir, 'jsr.json')); + expect(jsrJson.version).toBe('1.0.1'); + }); + + test('allowSlowTypes option adds the flag', async () => { + const pkg = await setupJsrPkg(); + pkg.bumpy = { publishTargets: [{ type: 'jsr', allowSlowTypes: true }] }; + const { packages, depGraph, plan } = planFor(pkg); + await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(getCallsMatching('jsr publish')[0]!.command).toContain('--allow-slow-types'); + }); + + test('unclaimed package fails with claim guidance instead of publishing', async () => { + const pkg = await setupJsrPkg({ claimed: false }); + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(result.failed).toHaveLength(1); + expect(result.failed[0]!.error).toContain('not claimed on JSR'); + expect(getCallsMatching('jsr publish')).toHaveLength(0); + }); + + test('version already on JSR is skipped via the registry guard', async () => { + const pkg = await setupJsrPkg(); + fetchResponses.set('/versions/', 200); // already published + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const outcomes = result.targetOutcomes.get('@myorg/mdit-thing')!; + expect(outcomes[0]!.status).toBe('skipped'); + expect(outcomes[0]!.reason).toBe('already on registry'); + expect(getCallsMatching('jsr publish')).toHaveLength(0); + }); + }); + + describe('pypi target', () => { + const realFetch = globalThis.fetch; + let pypiVersionStatus = 404; + + beforeEach(() => { + pypiVersionStatus = 404; + globalThis.fetch = (async (url: string | URL) => { + const u = String(url); + if (u.startsWith('https://pypi.org/pypi/')) return new Response('{}', { status: pypiVersionStatus }); + return new Response('{}', { status: 404 }); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + const PYPROJECT = [ + '[build-system]', + 'requires = ["hatchling"]', + '', + '[project]', + 'name = "My_Py.Tool"', + 'version = "0.0.0"', + 'description = "demo"', + '', + '[tool.other]', + 'version = "9.9.9"', + ].join('\n'); + + async function setupPyPkg() { + const pkgDir = await setupPkg('py-tool', { private: true }); + const { writeText } = await import('../../src/utils/fs.ts'); + await writeText(resolve(pkgDir, 'pyproject.toml'), PYPROJECT); + addMockRule({ match: 'uv --version', response: 'uv 0.9.0' }); + addMockRule({ match: 'uv build', response: '' }); + addMockRule({ match: 'uv publish', response: '' }); + return makePkg('py-tool', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { publishTargets: ['pypi'] }, + }); + } + + test('syncs pyproject version, builds isolated dist, publishes explicit files', async () => { + const pkg = await setupPyPkg(); + // simulate uv build producing distributions in the requested out-dir + const { ensureDir: mkdir, writeText } = await import('../../src/utils/fs.ts'); + const outDir = resolve(pkg.dir, '.bumpy-pypi-dist-1.0.1'); + await mkdir(outDir); + await writeText(resolve(outDir, 'my_py_tool-1.0.1-py3-none-any.whl'), ''); + await writeText(resolve(outDir, 'my_py_tool-1.0.1.tar.gz'), ''); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(result.failed).toHaveLength(0); + expect(result.published.map((p) => p.name)).toEqual(['py-tool']); + + // version synced into [project] only — [tool.other] version untouched + const { readText } = await import('../../src/utils/fs.ts'); + const toml = await readText(resolve(pkg.dir, 'pyproject.toml')); + expect(toml).toContain('version = "1.0.1"'); + expect(toml).toContain('version = "9.9.9"'); + + const build = getCallsMatching(/^uv build/); + expect(build).toHaveLength(1); + expect(build[0]!.command).toContain('--out-dir'); + const publish = getCallsMatching(/^uv publish/); + expect(publish).toHaveLength(1); + expect(publish[0]!.command).toContain('my_py_tool-1.0.1-py3-none-any.whl'); + expect(publish[0]!.command).toContain('my_py_tool-1.0.1.tar.gz'); + }); + + test('version already on PyPI is skipped via the registry guard (PEP 503 name normalization)', async () => { + const pkg = await setupPyPkg(); + pypiVersionStatus = 200; + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const outcomes = result.targetOutcomes.get('py-tool')!; + expect(outcomes[0]!.status).toBe('skipped'); + expect(outcomes[0]!.reason).toBe('already on registry'); + expect(getCallsMatching(/^uv publish/)).toHaveLength(0); + }); + + test('prerelease versions are skipped (PEP 440 mismatch)', async () => { + const pkg = await setupPyPkg(); + const packages = new Map([[pkg.name, pkg]]); + const depGraph = new DependencyGraph(packages); + const plan: ReleasePlan = { + bumpFiles: [], + warnings: [], + releases: [makeRelease('py-tool', '1.0.0', '1.1.0-next.0')], + }; + + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + const outcomes = result.targetOutcomes.get('py-tool')!; + expect(outcomes[0]!.status).toBe('skipped'); + expect(outcomes[0]!.reason).toBe('prereleases not supported'); + // preflight's `uv --version` check still runs; no build/publish happens + expect(getCallsMatching(/^uv (build|publish)/)).toHaveLength(0); + }); + }); + + test('npm + named GitHub Packages instance publish to both registries', async () => { + const pkgDir = await setupPkg('dual-reg'); + const pkg = makePkg('dual-reg', '1.0.0', { + dir: pkgDir, + bumpy: { publishTargets: ['npm', 'ghp'] }, + }); + const config = { + ...DEFAULT_CONFIG, + publish: { ...DEFAULT_CONFIG.publish, protocolResolution: 'in-place' as const }, + targets: { ghp: { type: 'npm', registry: 'https://npm.pkg.github.com' } }, + }; + // resolve targets with the config that defines the named instance + const { resolvePackageTargets } = await import('../../src/core/targets/registry.ts'); + pkg.targets = resolvePackageTargets(pkg, pkg.bumpy!, config); + + addMockRule({ match: /^npm publish/, response: '' }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, config, tmpDir, {}); + + expect(result.failed).toHaveLength(0); + const publishes = getCallsMatching(/^npm publish/); + expect(publishes).toHaveLength(2); + expect(publishes.some((c) => c.command.includes('--registry https://npm.pkg.github.com'))).toBe(true); + expect(publishes.some((c) => !c.command.includes('--registry'))).toBe(true); + }); +}); diff --git a/packages/bumpy/test/core/targets.test.ts b/packages/bumpy/test/core/targets.test.ts new file mode 100644 index 0000000..1488066 --- /dev/null +++ b/packages/bumpy/test/core/targets.test.ts @@ -0,0 +1,300 @@ +import { test, expect, describe } from 'bun:test'; +import { resolve } from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { writeJson } from '../../src/utils/fs.ts'; +import { makePkg, makeConfig } from '../helpers.ts'; +import { + resolvePackageTargets, + getPackageTargets, + getNpmTarget, + packagePublishes, + targetLabel, +} from '../../src/core/targets/registry.ts'; +import { loadPackageConfig } from '../../src/core/config.ts'; +import { parsePyproject, updatePyprojectVersion } from '../../src/core/targets/pypi.ts'; +import type { BumpyConfig } from '../../src/types.ts'; + +function configWithTargets(targets: BumpyConfig['targets']): BumpyConfig { + return makeConfig({ targets }); +} + +describe('resolvePackageTargets — legacy field mapping', () => { + test('default: public package gets the implicit npm target', () => { + const pkg = makePkg('lib', '1.0.0'); + const targets = resolvePackageTargets(pkg, {}, makeConfig()); + expect(targets).toHaveLength(1); + expect(targets[0]!.name).toBe('npm'); + expect(targets[0]!.type).toBe('npm'); + }); + + test('publishCommand maps to a custom target named "custom" (matches old metadata keys)', () => { + const pkg = makePkg('ext', '1.0.0'); + const targets = resolvePackageTargets( + pkg, + { publishCommand: 'vsce publish', checkPublished: 'my-check' }, + makeConfig(), + ); + expect(targets).toHaveLength(1); + expect(targets[0]!.name).toBe('custom'); + expect(targets[0]!.type).toBe('custom'); + expect(targets[0]!.options.command).toBe('vsce publish'); + expect(targets[0]!.options.checkPublished).toBe('my-check'); + }); + + test('skipNpmPublish yields no targets', () => { + const pkg = makePkg('tool', '1.0.0'); + expect(resolvePackageTargets(pkg, { skipNpmPublish: true }, makeConfig())).toHaveLength(0); + }); + + test('private package without publishCommand yields no targets', () => { + const pkg = makePkg('app', '1.0.0', { private: true }); + expect(resolvePackageTargets(pkg, {}, makeConfig())).toHaveLength(0); + }); + + test('private package with publishCommand keeps its custom target', () => { + const pkg = makePkg('ext', '1.0.0', { private: true }); + const targets = resolvePackageTargets(pkg, { publishCommand: 'do-publish' }, makeConfig()); + expect(targets).toHaveLength(1); + expect(targets[0]!.type).toBe('custom'); + }); + + test('legacy npm target picks up root targets.npm type defaults', () => { + const pkg = makePkg('lib', '1.0.0'); + const config = configWithTargets({ npm: { provenance: true } }); + const targets = resolvePackageTargets(pkg, {}, config); + expect(targets[0]!.options.provenance).toBe(true); + }); +}); + +describe('resolvePackageTargets — explicit publishTargets', () => { + test('string entries resolve built-in types', () => { + const pkg = makePkg('lib', '1.0.0'); + const targets = resolvePackageTargets(pkg, { publishTargets: ['npm'] }, makeConfig()); + expect(targets).toHaveLength(1); + expect(targets[0]!.type).toBe('npm'); + }); + + test('multiple targets on one package', () => { + const pkg = makePkg('ext', '1.0.0', { private: true }); + const targets = resolvePackageTargets(pkg, { publishTargets: ['vscode-marketplace', 'open-vsx'] }, makeConfig()); + expect(targets.map((t) => t.type)).toEqual(['vscode-marketplace', 'open-vsx']); + }); + + test('inline entry with options', () => { + const pkg = makePkg('lib', '1.0.0'); + const targets = resolvePackageTargets( + pkg, + { publishTargets: [{ type: 'custom', name: 'cdn', command: 'upload {{version}}' }] }, + makeConfig(), + ); + expect(targets[0]!.name).toBe('cdn'); + expect(targets[0]!.type).toBe('custom'); + expect(targets[0]!.options.command).toBe('upload {{version}}'); + }); + + test('named instance from root targets map', () => { + const pkg = makePkg('lib', '1.0.0'); + const config = configWithTargets({ + ghp: { type: 'npm', registry: 'https://npm.pkg.github.com' }, + }); + const targets = resolvePackageTargets(pkg, { publishTargets: ['npm', 'ghp'] }, config); + expect(targets).toHaveLength(2); + expect(targets[1]!.name).toBe('ghp'); + expect(targets[1]!.type).toBe('npm'); + expect(targets[1]!.options.registry).toBe('https://npm.pkg.github.com'); + }); + + test('named instance inherits type defaults, its own options win', () => { + const pkg = makePkg('lib', '1.0.0'); + const config = configWithTargets({ + npm: { provenance: true, access: 'public' }, + ghp: { type: 'npm', registry: 'https://npm.pkg.github.com', access: 'restricted' }, + }); + const targets = resolvePackageTargets(pkg, { publishTargets: ['ghp'] }, config); + expect(targets[0]!.options.provenance).toBe(true); // inherited from targets.npm + expect(targets[0]!.options.access).toBe('restricted'); // instance wins + }); + + test('inline entry options win over type defaults', () => { + const pkg = makePkg('lib', '1.0.0'); + const config = configWithTargets({ npm: { provenance: true } }); + const targets = resolvePackageTargets(pkg, { publishTargets: [{ type: 'npm', provenance: false }] }, config); + expect(targets[0]!.options.provenance).toBe(false); + }); + + test('npm targets are dropped for private packages', () => { + const pkg = makePkg('ext', '1.0.0', { private: true }); + const targets = resolvePackageTargets(pkg, { publishTargets: ['npm', 'open-vsx'] }, makeConfig()); + expect(targets.map((t) => t.type)).toEqual(['open-vsx']); + }); + + test('duplicate instance names throw', () => { + const pkg = makePkg('lib', '1.0.0'); + expect(() => + resolvePackageTargets( + pkg, + { + publishTargets: [ + { type: 'custom', command: 'a' }, + { type: 'custom', command: 'b' }, + ], + }, + makeConfig(), + ), + ).toThrow(/duplicate publish target name "custom"/); + }); + + test('unknown string reference throws', () => { + const pkg = makePkg('lib', '1.0.0'); + expect(() => resolvePackageTargets(pkg, { publishTargets: ['cargo'] }, makeConfig())).toThrow( + /unknown publish target "cargo"/, + ); + }); + + test('unknown inline type throws', () => { + const pkg = makePkg('lib', '1.0.0'); + expect(() => resolvePackageTargets(pkg, { publishTargets: [{ type: 'cargo' }] }, makeConfig())).toThrow( + /Unknown publish target type "cargo"/, + ); + }); + + test('root targets key colliding with a built-in type cannot redirect it', () => { + const pkg = makePkg('lib', '1.0.0'); + const config = configWithTargets({ npm: { type: 'custom', command: 'evil' } }); + expect(() => resolvePackageTargets(pkg, { publishTargets: ['npm'] }, config)).toThrow(/built-in target type/); + }); + + test('named instance without a type throws', () => { + const pkg = makePkg('lib', '1.0.0'); + const config = configWithTargets({ mystery: { registry: 'x' } }); + expect(() => resolvePackageTargets(pkg, { publishTargets: ['mystery'] }, config)).toThrow(/must declare a "type"/); + }); + + test('explicit empty list yields no targets', () => { + const pkg = makePkg('tool', '1.0.0'); + expect(resolvePackageTargets(pkg, { publishTargets: [] }, makeConfig())).toHaveLength(0); + }); +}); + +describe('target helpers', () => { + test('getPackageTargets prefers targets attached at discovery', () => { + const pkg = makePkg('lib', '1.0.0'); + pkg.targets = resolvePackageTargets(pkg, { publishTargets: ['npm', 'open-vsx'] }, makeConfig()); + pkg.bumpy = { skipNpmPublish: true }; // would resolve to [] — must be ignored + expect(getPackageTargets(pkg).map((t) => t.type)).toEqual(['npm', 'open-vsx']); + }); + + test('packagePublishes / getNpmTarget', () => { + const npmPkg = makePkg('lib', '1.0.0'); + const nonePkg = makePkg('tool', '1.0.0', { bumpy: { skipNpmPublish: true } }); + expect(packagePublishes(npmPkg)).toBe(true); + expect(packagePublishes(nonePkg)).toBe(false); + expect(getNpmTarget(npmPkg)?.type).toBe('npm'); + expect(getNpmTarget(nonePkg)).toBeUndefined(); + }); + + test('targetLabel: npm on GitHub Packages registry', () => { + const pkg = makePkg('lib', '1.0.0'); + const config = configWithTargets({ ghp: { type: 'npm', registry: 'https://npm.pkg.github.com' } }); + const targets = resolvePackageTargets(pkg, { publishTargets: ['ghp'] }, config); + expect(targetLabel(targets[0]!, pkg)).toBe('GitHub Packages'); + }); + + test('targetLabel: marketplace targets', () => { + const pkg = makePkg('ext', '1.0.0', { private: true }); + const targets = resolvePackageTargets(pkg, { publishTargets: ['vscode-marketplace', 'open-vsx'] }, makeConfig()); + expect(targetLabel(targets[0]!, pkg)).toBe('VS Code Marketplace'); + expect(targetLabel(targets[1]!, pkg)).toBe('Open VSX'); + }); +}); + +describe('lenient discovery on broken target config', () => { + test('discoverWorkspace records targetsError instead of throwing', async () => { + const { discoverWorkspace } = await import('../../src/core/workspace.ts'); + const dir = await mkdtemp(resolve(tmpdir(), 'bumpy-targets-lenient-')); + try { + await writeJson(resolve(dir, 'package.json'), { name: 'root', private: true, workspaces: ['packages/*'] }); + const pkgDir = resolve(dir, 'packages/broken'); + const { ensureDir } = await import('../../src/utils/fs.ts'); + await ensureDir(pkgDir); + await writeJson(resolve(pkgDir, 'package.json'), { + name: 'broken', + version: '1.0.0', + bumpy: { publishTargets: ['does-not-exist'] }, + }); + + // Read-only discovery must survive the bad reference... + const { packages } = await discoverWorkspace(dir, makeConfig()); + const pkg = packages.get('broken')!; + expect(pkg.targets).toEqual([]); + // ...but record the error so publish flows can refuse loudly + expect(pkg.targetsError).toContain('does-not-exist'); + } finally { + await rm(dir, { recursive: true }); + } + }); +}); + +describe('pypi pyproject.toml helpers', () => { + const TOML = ['[project]', 'name = "my-tool"', 'version = "1.0.0"', '', '[tool.uv]', 'dev = true'].join('\n'); + + test('parsePyproject extracts name/version from [project] only', () => { + const info = parsePyproject(TOML); + expect(info.name).toBe('my-tool'); + expect(info.version).toBe('1.0.0'); + expect(info.dynamicVersion).toBe(false); + }); + + test('parsePyproject detects dynamic version', () => { + const info = parsePyproject('[project]\nname = "x"\ndynamic = ["version", "readme"]\n'); + expect(info.version).toBeUndefined(); + expect(info.dynamicVersion).toBe(true); + }); + + test('updatePyprojectVersion rewrites only the [project] version, preserving formatting', () => { + const withOther = `# comment\n${TOML}\n\n[tool.other]\nversion = "3.3.3"\n`; + const updated = updatePyprojectVersion(withOther, '2.5.0')!; + expect(updated).toContain('version = "2.5.0"'); + expect(updated).toContain('version = "3.3.3"'); + expect(updated).toContain('# comment'); + expect(updated).not.toContain('"1.0.0"'); + }); + + test('updatePyprojectVersion returns null when no static version exists', () => { + expect(updatePyprojectVersion('[project]\nname = "x"\n', '1.0.0')).toBeNull(); + expect(updatePyprojectVersion('[tool.poetry]\nversion = "1.0.0"\n', '2.0.0')).toBeNull(); + }); +}); + +describe('publishTargets trust gating (package.json config)', () => { + async function loadFromPkgJson(bumpy: unknown, rootConfig: BumpyConfig) { + const dir = await mkdtemp(resolve(tmpdir(), 'bumpy-targets-trust-')); + try { + await writeJson(resolve(dir, 'package.json'), { name: 'my-pkg', version: '1.0.0', bumpy }); + return await loadPackageConfig(dir, rootConfig, 'my-pkg'); + } finally { + await rm(dir, { recursive: true }); + } + } + + test('inline commands in package.json publishTargets are blocked by default', async () => { + await expect( + loadFromPkgJson({ publishTargets: [{ type: 'custom', command: 'rm -rf /' }] }, makeConfig()), + ).rejects.toThrow(/custom command/); + }); + + test('inline commands allowed with allowCustomCommands', async () => { + const config = makeConfig({ allowCustomCommands: true }); + const result = await loadFromPkgJson({ publishTargets: [{ type: 'custom', command: 'ok' }] }, config); + expect(result.publishTargets).toHaveLength(1); + }); + + test('string references and data-only options are always allowed', async () => { + const result = await loadFromPkgJson( + { publishTargets: ['vscode-marketplace', { type: 'npm', registry: 'https://example.com' }] }, + makeConfig(), + ); + expect(result.publishTargets).toHaveLength(2); + }); +}); diff --git a/packages/bumpy/test/helpers-shell-mock.ts b/packages/bumpy/test/helpers-shell-mock.ts index 1b02f4e..d4958d6 100644 --- a/packages/bumpy/test/helpers-shell-mock.ts +++ b/packages/bumpy/test/helpers-shell-mock.ts @@ -57,6 +57,13 @@ export function installShellMock(opts: { interceptGh?: boolean } = {}) { rules.push({ match: /^gh /, response: '{}' }); } + // Registry lookups (publish-target checkPublished guards) must never hit the + // network in tests. Default to "not found" (= not published, publish proceeds); + // override with addMockRule for tests asserting already-published behavior. + rules.push({ match: /^npm info /, error: 'E404 not found' }); + rules.push({ match: /@vscode\/vsce show/, error: 'not found' }); + rules.push({ match: /ovsx get/, error: 'not found' }); + _setInterceptor((args, opts) => { const cmdString = args.join(' '); calls.push({ command: cmdString, args: [...args], opts });