From 346dfca81ae028f258a6f1e72eeb46e952b5238c Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 31 Jul 2026 15:20:57 -0700 Subject: [PATCH 1/5] feat: publish-target plugin system (npm/custom/jsr/vscode/open-vsx built-in) Packages can now publish to multiple targets via per-package publishTargets config and a root targets map (type defaults + named reusable instances). Targets execute per-package with per-target retry state in GitHub release metadata, shared artifacts (one .vsix -> marketplace + Open VSX), and a registry-level pre-publish idempotency guard. JSR publishing (publish-time jsr.json version sync, claim-first bootstrap detection) is modeled on Drake Costa's (@Saeris) setup in mirrordown. Legacy publishCommand/skipNpmPublish fields keep working via back-compat mapping in the resolver. --- docs/configuration.md | 111 +++- packages/bumpy/src/commands/ci.ts | 26 +- packages/bumpy/src/commands/publish.ts | 199 ++++--- packages/bumpy/src/commands/status.ts | 32 +- packages/bumpy/src/core/config.ts | 13 +- packages/bumpy/src/core/prerelease.ts | 18 +- packages/bumpy/src/core/publish-pipeline.ts | 530 +++++++----------- packages/bumpy/src/core/snapshot.ts | 7 +- packages/bumpy/src/core/targets/custom.ts | 56 ++ packages/bumpy/src/core/targets/jsr.ts | 132 +++++ packages/bumpy/src/core/targets/npm.ts | 327 +++++++++++ packages/bumpy/src/core/targets/registry.ts | 216 +++++++ packages/bumpy/src/core/targets/types.ts | 107 ++++ packages/bumpy/src/core/targets/vscode.ts | 178 ++++++ packages/bumpy/src/core/workspace.ts | 7 +- packages/bumpy/src/types.ts | 52 ++ .../test/core/publish-multi-target.test.ts | 380 +++++++++++++ packages/bumpy/test/core/targets.test.ts | 241 ++++++++ packages/bumpy/test/helpers-shell-mock.ts | 7 + 19 files changed, 2179 insertions(+), 460 deletions(-) create mode 100644 packages/bumpy/src/core/targets/custom.ts create mode 100644 packages/bumpy/src/core/targets/jsr.ts create mode 100644 packages/bumpy/src/core/targets/npm.ts create mode 100644 packages/bumpy/src/core/targets/registry.ts create mode 100644 packages/bumpy/src/core/targets/types.ts create mode 100644 packages/bumpy/src/core/targets/vscode.ts create mode 100644 packages/bumpy/test/core/publish-multi-target.test.ts create mode 100644 packages/bumpy/test/core/targets.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index f24fba7..546c96a 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,81 @@ 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 | +| `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! + +#### 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 +254,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 +268,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 +289,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 +405,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..ef707d6 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,13 @@ import { type ReleaseMetadata, type PublishTargetState, } from '../core/github-release.ts'; +import { getPackageTargets, getNpmTarget, targetLabel } from '../core/targets/registry.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'; @@ -239,6 +238,7 @@ async function publishChannel( dryRun: opts.dryRun, tag: opts.tag ?? channel.tag, noPush: opts.noPush, + releaseKind: 'channel', }); } finally { if (restore) { @@ -348,7 +348,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 +387,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 +411,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 +426,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 +469,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 +527,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 +564,8 @@ async function runPublishFlow( { dryRun: opts.dryRun, tag: opts.tag, + releaseKind: opts.releaseKind, + completedTargets, }, catalogs, detectedPm, @@ -585,31 +586,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.reason === 'already on 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 (failed) { - const label = publishTargetLabel(targetName, registry); - info.metadata.targets[targetName] = { + } 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.reason !== 'already published') { + // Capability skips (e.g. prerelease on a target without prerelease support) + info.metadata.targets[outcome.target] = { + status: 'skipped', + reason: outcome.reason, + ...labelField, + }; + changed = true; + } + } + + // 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 +652,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 +690,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 +797,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 +833,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 +847,26 @@ 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. + const targets = getPackageTargets(pkg, config); + let anyUnknown = false; + for (const target of targets) { + if (!target.plugin.checkPublished) { + anyUnknown = true; + continue; + } + const published = await target.plugin.checkPublished(pkg, version, target.options); + if (published === false) return false; + if (published === null) anyUnknown = true; } + if (!anyUnknown) 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 +886,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 = (npm.options.registry as string | undefined) ?? pkg.bumpy?.registry; + 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..7029938 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 { resolvePackageRegistry } from '../core/github-release.ts'; +import { getPackageTargets, targetLabel } from '../core/targets/registry.ts'; import type { BumpFile, BumpyConfig, PackageConfig, PlannedRelease, WorkspacePackage } from '../types.ts'; interface StatusOptions { @@ -302,18 +303,21 @@ 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 = + typeof t.options.registry === 'string' && t.options.registry + ? t.options.registry + : t.type === 'npm' + ? resolvePackageRegistry(pkg, pkgConfig) + : 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..e496da0 100644 --- a/packages/bumpy/src/core/prerelease.ts +++ b/packages/bumpy/src/core/prerelease.ts @@ -3,6 +3,7 @@ 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 type { ResolvedChannel } from './channels.ts'; import type { ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; @@ -77,7 +78,14 @@ 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, if configured */ +export function npmTargetRegistry(pkg: WorkspacePackage): string | undefined { + const npm = getNpmTarget(pkg); + const registry = npm?.options.registry; + return typeof registry === 'string' && registry ? registry : pkg.bumpy?.registry; } /** Query published prerelease state for one package at a target version */ @@ -88,7 +96,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 +154,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 +169,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 +254,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..38ddfac 100644 --- a/packages/bumpy/src/core/publish-pipeline.ts +++ b/packages/bumpy/src/core/publish-pipeline.ts @@ -1,151 +1,60 @@ import { resolve } from 'node:path'; -import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'; import { unlink } 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; + reason?: string; } 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 +66,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 +80,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 +132,176 @@ 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.reason === 'already published' || o.reason === 'already on 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', 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', 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', 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', 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, + }; + + // 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 artifact files (tarballs, vsix) 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 unlink(path); } 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..d36b788 --- /dev/null +++ b/packages/bumpy/src/core/targets/jsr.ts @@ -0,0 +1,132 @@ +import { resolve } from 'node:path'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +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 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; + } +} + +/** Sync jsr.json's version field from the version being published */ +function syncJsrJsonVersion(pkgDir: string, version: string): void { + const jsrJsonPath = resolve(pkgDir, 'jsr.json'); + const jsr = JSON.parse(readFileSync(jsrJsonPath, 'utf-8')); + if (jsr.version !== version) { + jsr.version = version; + writeFileSync(jsrJsonPath, `${JSON.stringify(jsr, null, 2)}\n`); + } +} + +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 publish(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)`, + ); + } + + const args = [...JSR_BIN, 'publish', '--allow-dirty']; + if (ctx.options.allowSlowTypes === true) args.push('--allow-slow-types'); + if (Array.isArray(ctx.options.publishArgs)) args.push(...ctx.options.publishArgs.map(String)); + + if (ctx.dryRun) { + log.dim(` Would publish with: ${args.join(' ')}`); + return; + } + + // 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`, + ); + } + + syncJsrJsonVersion(ctx.pkg.dir, ctx.version); + 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..55b9008 --- /dev/null +++ b/packages/bumpy/src/core/targets/npm.ts @@ -0,0 +1,327 @@ +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; +} + +function effectiveRegistry( + 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 = effectiveRegistry(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 }, + + 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 + ? effectiveRegistry(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 = effectiveRegistry(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: effectiveRegistry(pkg, pkg.bumpy || {}, options), + repoSlug: extra.repoSlug, + }); + }, +}; diff --git a/packages/bumpy/src/core/targets/registry.ts b/packages/bumpy/src/core/targets/registry.ts new file mode 100644 index 0000000..609c016 --- /dev/null +++ b/packages/bumpy/src/core/targets/registry.ts @@ -0,0 +1,216 @@ +import { log } from '../../utils/logger.ts'; +import { npmTarget } from './npm.ts'; +import { customTarget } from './custom.ts'; +import { jsrTarget } from './jsr.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, + [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.type === 'npm') { + log.warn( + ` ${pkg.name}: dropping npm publish target "${target.name}" — package is "private": true (npm 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..1f0bae3 --- /dev/null +++ b/packages/bumpy/src/core/targets/types.ts @@ -0,0 +1,107 @@ +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; +} + +/** 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; + /** + * 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/vscode.ts b/packages/bumpy/src/core/targets/vscode.ts new file mode 100644 index 0000000..f0324f3 --- /dev/null +++ b/packages/bumpy/src/core/targets/vscode.ts @@ -0,0 +1,178 @@ +import { resolve } from 'node:path'; +import { runArgsAsync, runStreaming, sq } from '../../utils/shell.ts'; +import { log } from '../../utils/logger.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 = Array.isArray(ctx.options.packageArgs) ? ctx.options.packageArgs.map(String) : []; + // --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 = Array.isArray(ctx.options.publishArgs) ? ctx.options.publishArgs.map(String) : []; + 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 = Array.isArray(ctx.options.publishArgs) ? ctx.options.publishArgs.map(String) : []; + 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..17edf37 100644 --- a/packages/bumpy/src/core/workspace.ts +++ b/packages/bumpy/src/core/workspace.ts @@ -3,6 +3,7 @@ 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 type { BumpyConfig, WorkspacePackage } from '../types.ts'; export interface WorkspaceDiscoveryResult { @@ -133,6 +134,7 @@ async function loadWorkspacePackage( if (!name) return null; const bumpy = await loadPackageConfig(dir, config, name); + const isPrivate = !!pkg.private; return { name, @@ -140,11 +142,14 @@ 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, + // Resolve publish targets once at discovery so downstream consumers can answer + // "where does this package publish" without the root config in hand + targets: resolvePackageTargets({ name, private: isPrivate }, bumpy, config), }; } diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index e1b3c72..f040a8d 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,14 @@ 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[]; } 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..82c70f9 --- /dev/null +++ b/packages/bumpy/test/core/publish-multi-target.test.ts @@ -0,0 +1,380 @@ +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')!.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]!.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); + }); + }); + + 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..50874ea --- /dev/null +++ b/packages/bumpy/test/core/targets.test.ts @@ -0,0 +1,241 @@ +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 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('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 }); From 934a6bb2216e5722a161dbafe7abc6908f0f49ae Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 31 Jul 2026 15:27:03 -0700 Subject: [PATCH 2/5] feat: built-in pypi publish target Python packages ride bumpy's package.json spine via a private stub package; the target syncs [project].version into pyproject.toml at publish time, builds with uv into an isolated per-version dist dir, and uploads via uv publish (PyPI trusted publishing / UV_PUBLISH_TOKEN). PyPI project name comes from pyproject.toml. Prereleases/snapshots are skipped (PEP 440 doesn't cover semver suffixes). --- docs/configuration.md | 22 ++ packages/bumpy/src/core/publish-pipeline.ts | 6 +- packages/bumpy/src/core/targets/pypi.ts | 220 ++++++++++++++++++ packages/bumpy/src/core/targets/registry.ts | 2 + .../test/core/publish-multi-target.test.ts | 106 +++++++++ packages/bumpy/test/core/targets.test.ts | 32 +++ 6 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 packages/bumpy/src/core/targets/pypi.ts diff --git a/docs/configuration.md b/docs/configuration.md index 546c96a..be5577a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -126,6 +126,7 @@ A package can publish to any number of **targets** — npm is just the default o | -------------------- | ----------------------------------- | --------------------------------- | ------------------------------------------------------------ | | `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 | @@ -187,6 +188,27 @@ The legacy `publish` block is the npm type's default options — `targets.npm` a - 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. diff --git a/packages/bumpy/src/core/publish-pipeline.ts b/packages/bumpy/src/core/publish-pipeline.ts index 38ddfac..b20a99d 100644 --- a/packages/bumpy/src/core/publish-pipeline.ts +++ b/packages/bumpy/src/core/publish-pipeline.ts @@ -1,5 +1,5 @@ import { resolve } from 'node:path'; -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 } from '../utils/shell.ts'; @@ -291,12 +291,12 @@ async function publishOneTarget( } } -/** Delete shared artifact files (tarballs, vsix) built during a package's publish */ +/** 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 { - await unlink(path); + await rm(path, { recursive: true, force: true }); } catch { /* ignore */ } diff --git a/packages/bumpy/src/core/targets/pypi.ts b/packages/bumpy/src/core/targets/pypi.ts new file mode 100644 index 0000000..572661f --- /dev/null +++ b/packages/bumpy/src/core/targets/pypi.ts @@ -0,0 +1,220 @@ +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 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; + 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`); + } + 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 buildArtifact(ctx) { + syncPyprojectVersion(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 = Array.isArray(ctx.options.buildArgs) ? ctx.options.buildArgs.map(String) : []; + 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 info = loadPyproject(ctx.pkg); + if (!info) { + throw new Error(`${ctx.pkg.name}: pypi target requires a pyproject.toml`); + } + + const args = ['uv', 'publish']; + if (typeof ctx.options.index === 'string' && ctx.options.index) { + args.push('--publish-url', ctx.options.index); + } + if (Array.isArray(ctx.options.publishArgs)) args.push(...ctx.options.publishArgs.map(String)); + + 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 index 609c016..367a3d5 100644 --- a/packages/bumpy/src/core/targets/registry.ts +++ b/packages/bumpy/src/core/targets/registry.ts @@ -2,6 +2,7 @@ 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, @@ -21,6 +22,7 @@ const BUILT_IN_TARGETS: Record = { [npmTarget.type]: npmTarget, [customTarget.type]: customTarget, [jsrTarget.type]: jsrTarget, + [pypiTarget.type]: pypiTarget, [vscodeMarketplaceTarget.type]: vscodeMarketplaceTarget, [openVsxTarget.type]: openVsxTarget, }; diff --git a/packages/bumpy/test/core/publish-multi-target.test.ts b/packages/bumpy/test/core/publish-multi-target.test.ts index 82c70f9..0cc85a0 100644 --- a/packages/bumpy/test/core/publish-multi-target.test.ts +++ b/packages/bumpy/test/core/publish-multi-target.test.ts @@ -351,6 +351,112 @@ describe('publishPackages — multi-target', () => { }); }); + 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', { diff --git a/packages/bumpy/test/core/targets.test.ts b/packages/bumpy/test/core/targets.test.ts index 50874ea..f552375 100644 --- a/packages/bumpy/test/core/targets.test.ts +++ b/packages/bumpy/test/core/targets.test.ts @@ -12,6 +12,7 @@ import { 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 { @@ -208,6 +209,37 @@ describe('target helpers', () => { }); }); +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-')); From 384a494b0d81895540382aa694623d121cc1d926 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 31 Jul 2026 16:02:52 -0700 Subject: [PATCH 3/5] refactor: harden target system from review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prepare() lifecycle hook — publish-time version syncs (jsr.json, pyproject.toml) get a formal home instead of living in publish/buildArtifact - broken target config no longer breaks read-only commands: discovery records targetsError and publish flows refuse loudly instead - structured TargetOutcome.skipKind replaces magic reason strings coupled across pipeline and publish command - npmEffectiveRegistry() is the single registry fallback chain (options -> bumpy registry -> publishConfig.registry) for publish args, existence checks, prerelease counters, labels, and URLs - refusesPrivatePackages capability flag replaces hardcoded npm-type check in the resolver - jsr.json version sync uses the formatting-preserving updateJsonFields - per-target published checks run in parallel; shared stringArrayOption/ stringOption helpers de-triplicate option coercion --- .bumpy/publish-targets.md | 7 +++ packages/bumpy/src/commands/publish.ts | 35 ++++++++------ packages/bumpy/src/commands/status.ts | 9 +--- packages/bumpy/src/core/prerelease.ts | 7 ++- packages/bumpy/src/core/publish-pipeline.ts | 26 ++++++++-- packages/bumpy/src/core/targets/jsr.ts | 48 ++++++++++--------- packages/bumpy/src/core/targets/npm.ts | 19 +++++--- packages/bumpy/src/core/targets/pypi.ts | 23 ++++----- packages/bumpy/src/core/targets/registry.ts | 4 +- packages/bumpy/src/core/targets/types.ts | 12 +++++ packages/bumpy/src/core/targets/util.ts | 13 +++++ packages/bumpy/src/core/targets/vscode.ts | 7 +-- packages/bumpy/src/core/workspace.ts | 19 ++++++-- packages/bumpy/src/types.ts | 7 +++ .../test/core/publish-multi-target.test.ts | 2 + packages/bumpy/test/core/targets.test.ts | 27 +++++++++++ 16 files changed, 187 insertions(+), 78 deletions(-) create mode 100644 .bumpy/publish-targets.md create mode 100644 packages/bumpy/src/core/targets/util.ts 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/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index ef707d6..aff9dc1 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -33,6 +33,7 @@ import { 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'; @@ -77,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); @@ -600,7 +610,7 @@ async function runPublishFlow( const label = target ? targetLabel(target, pkg) : outcome.target; const labelField = label !== outcome.target ? { label } : {}; - if (outcome.status === 'success' || outcome.reason === 'already on registry') { + 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] = { @@ -618,7 +628,7 @@ async function runPublishFlow( ...labelField, }; changed = true; - } else if (outcome.reason !== 'already published') { + } else if (outcome.skipKind !== 'metadata') { // Capability skips (e.g. prerelease on a target without prerelease support) info.metadata.targets[outcome.target] = { status: 'skipped', @@ -849,19 +859,14 @@ async function checkIfPublished(pkg: WorkspacePackage, version: string, config: // 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. + // per-target retry can finish the job. Checks are independent registry + // queries, so they run in parallel. const targets = getPackageTargets(pkg, config); - let anyUnknown = false; - for (const target of targets) { - if (!target.plugin.checkPublished) { - anyUnknown = true; - continue; - } - const published = await target.plugin.checkPublished(pkg, version, target.options); - if (published === false) return false; - if (published === null) anyUnknown = true; - } - if (!anyUnknown) return true; + 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. Targets that can't answer (custom without checkPublished, network failures): // git tags track their published-ness @@ -899,7 +904,7 @@ async function findPackagesMissingFromNpm( const pkg = packages.get(release.name)!; const npm = getNpmTarget(pkg, config); if (!npm) return; - const registry = (npm.options.registry as string | undefined) ?? pkg.bumpy?.registry; + 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 7029938..43ab2ff 100644 --- a/packages/bumpy/src/commands/status.ts +++ b/packages/bumpy/src/commands/status.ts @@ -7,8 +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 { 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 { @@ -307,12 +307,7 @@ function getPublishTargets( ): Array<{ type: string; name: string; label: string; registry?: string }> { if (!pkg) return []; return getPackageTargets(pkg, config).map((t) => { - const registry = - typeof t.options.registry === 'string' && t.options.registry - ? t.options.registry - : t.type === 'npm' - ? resolvePackageRegistry(pkg, pkgConfig) - : undefined; + const registry = t.type === 'npm' ? npmEffectiveRegistry(pkg, pkgConfig, t.options) : undefined; return { type: t.type, name: t.name, diff --git a/packages/bumpy/src/core/prerelease.ts b/packages/bumpy/src/core/prerelease.ts index e496da0..3111c0a 100644 --- a/packages/bumpy/src/core/prerelease.ts +++ b/packages/bumpy/src/core/prerelease.ts @@ -4,6 +4,7 @@ import { readText, writeText, updateJsonFields, updateJsonNestedField } from '.. 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'; @@ -81,11 +82,9 @@ export function usesNpmRegistry(pkg: WorkspacePackage): boolean { return getNpmTarget(pkg) !== undefined; } -/** The registry a package's npm target publishes to, if configured */ +/** The registry a package's npm target publishes to (full fallback chain incl. publishConfig.registry) */ export function npmTargetRegistry(pkg: WorkspacePackage): string | undefined { - const npm = getNpmTarget(pkg); - const registry = npm?.options.registry; - return typeof registry === 'string' && registry ? registry : pkg.bumpy?.registry; + return npmEffectiveRegistry(pkg, pkg.bumpy || {}, getNpmTarget(pkg)?.options ?? {}); } /** Query published prerelease state for one package at a target version */ diff --git a/packages/bumpy/src/core/publish-pipeline.ts b/packages/bumpy/src/core/publish-pipeline.ts index b20a99d..e298cb6 100644 --- a/packages/bumpy/src/core/publish-pipeline.ts +++ b/packages/bumpy/src/core/publish-pipeline.ts @@ -36,7 +36,17 @@ export interface TargetOutcome { 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 { @@ -177,7 +187,7 @@ export async function publishPackages( // 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.reason === 'already published' || o.reason === 'already on registry'; + 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); @@ -228,18 +238,18 @@ async function publishOneTarget( // 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', reason: 'already published' }; + return { ...base, status: 'skipped', skipKind: 'metadata', reason: 'already published' }; } // 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', reason: 'snapshots not supported' }; + return { ...base, status: 'skipped', skipKind: 'capability', reason: 'snapshots not supported' }; } if (isPrerelease && !caps.prereleases) { log.dim(` Skipping ${target.name} — target does not support prerelease versions`); - return { ...base, status: 'skipped', reason: 'prereleases not supported' }; + return { ...base, status: 'skipped', skipKind: 'capability', reason: 'prereleases not supported' }; } // Registry-level idempotency guard: even without release metadata (gh unavailable, @@ -250,7 +260,7 @@ async function publishOneTarget( 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', reason: 'already on registry' }; + return { ...base, status: 'skipped', skipKind: 'registry', reason: 'already on registry' }; } } @@ -268,6 +278,12 @@ async function publishOneTarget( 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. + if (!opts.dryRun) { + 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) { diff --git a/packages/bumpy/src/core/targets/jsr.ts b/packages/bumpy/src/core/targets/jsr.ts index d36b788..f658438 100644 --- a/packages/bumpy/src/core/targets/jsr.ts +++ b/packages/bumpy/src/core/targets/jsr.ts @@ -1,9 +1,11 @@ import { resolve } from 'node:path'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +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'; /** @@ -52,16 +54,6 @@ async function jsrApiStatus(path: string): Promise { } } -/** Sync jsr.json's version field from the version being published */ -function syncJsrJsonVersion(pkgDir: string, version: string): void { - const jsrJsonPath = resolve(pkgDir, 'jsr.json'); - const jsr = JSON.parse(readFileSync(jsrJsonPath, 'utf-8')); - if (jsr.version !== version) { - jsr.version = version; - writeFileSync(jsrJsonPath, `${JSON.stringify(jsr, null, 2)}\n`); - } -} - export const jsrTarget: PublishTargetPlugin = { type: 'jsr', // JSR versions are semver (prereleases fine), but there are no dist-tags — so @@ -89,7 +81,7 @@ export const jsrTarget: PublishTargetPlugin = { return status === 200; }, - async publish(ctx) { + async prepare(ctx) { const id = scopeAndName(ctx.pkg); if (!id) { throw new Error(`${ctx.pkg.name}: JSR packages must be scoped (@scope/name)`); @@ -102,15 +94,6 @@ export const jsrTarget: PublishTargetPlugin = { ); } - const args = [...JSR_BIN, 'publish', '--allow-dirty']; - if (ctx.options.allowSlowTypes === true) args.push('--allow-slow-types'); - if (Array.isArray(ctx.options.publishArgs)) args.push(...ctx.options.publishArgs.map(String)); - - if (ctx.dryRun) { - log.dim(` Would publish with: ${args.join(' ')}`); - return; - } - // 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}`); @@ -121,7 +104,28 @@ export const jsrTarget: PublishTargetPlugin = { ); } - syncJsrJsonVersion(ctx.pkg.dir, ctx.version); + // 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 (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 }); }, diff --git a/packages/bumpy/src/core/targets/npm.ts b/packages/bumpy/src/core/targets/npm.ts index 55b9008..806207f 100644 --- a/packages/bumpy/src/core/targets/npm.ts +++ b/packages/bumpy/src/core/targets/npm.ts @@ -67,7 +67,14 @@ function npmOptions(config: BumpyConfig, options: TargetOptions): PublishConfig return { ...config.publish, ...options } as PublishConfig & TargetOptions; } -function effectiveRegistry( +/** + * 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, @@ -179,7 +186,7 @@ function buildPublishArgs(ctx: TargetPublishContext, tarball?: string): string[] args.push('--access', access); // Registry - const registry = effectiveRegistry(ctx.pkg, ctx.pkgConfig, ctx.options); + const registry = npmEffectiveRegistry(ctx.pkg, ctx.pkgConfig, ctx.options); if (registry) args.push('--registry', registry); // Dist tag @@ -233,7 +240,7 @@ export function parseTarballPath(output: string, cwd: string, pm: PackageManager export const npmTarget: PublishTargetPlugin = { type: 'npm', - capabilities: { distTags: true, prereleases: true, snapshots: true }, + capabilities: { distTags: true, prereleases: true, snapshots: true, refusesPrivatePackages: true }, detect(pkg) { return !pkg.private; @@ -243,7 +250,7 @@ export const npmTarget: PublishTargetPlugin = { // Refines the common cases (e.g. "GitHub Packages"); named instances otherwise // label themselves via the metadata key. const registry = pkg - ? effectiveRegistry(pkg, pkg.bumpy || {}, options) + ? npmEffectiveRegistry(pkg, pkg.bumpy || {}, options) : typeof options.registry === 'string' ? options.registry : undefined; @@ -280,7 +287,7 @@ export const npmTarget: PublishTargetPlugin = { async checkPublished(pkg, version, options) { try { const args = ['npm', 'info', `${pkg.name}@${version}`, 'version']; - const registry = effectiveRegistry(pkg, pkg.bumpy || {}, options); + const registry = npmEffectiveRegistry(pkg, pkg.bumpy || {}, options); if (registry) args.push('--registry', registry); const result = await runArgsAsync(args); return result.trim() === version; @@ -320,7 +327,7 @@ export const npmTarget: PublishTargetPlugin = { publishUrl(pkg, version, options, extra) { return buildPublishUrl(pkg.name, version, 'npm', { - registry: effectiveRegistry(pkg, pkg.bumpy || {}, options), + 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 index 572661f..1f99fca 100644 --- a/packages/bumpy/src/core/targets/pypi.ts +++ b/packages/bumpy/src/core/targets/pypi.ts @@ -4,6 +4,7 @@ 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'; /** @@ -172,12 +173,18 @@ export const pypiTarget: PublishTargetPlugin = { return 'python-dist'; }, - async buildArtifact(ctx) { + 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 = Array.isArray(ctx.options.buildArgs) ? ctx.options.buildArgs.map(String) : []; + 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 }); @@ -185,16 +192,10 @@ export const pypiTarget: PublishTargetPlugin = { }, async publish(ctx) { - const info = loadPyproject(ctx.pkg); - if (!info) { - throw new Error(`${ctx.pkg.name}: pypi target requires a pyproject.toml`); - } - const args = ['uv', 'publish']; - if (typeof ctx.options.index === 'string' && ctx.options.index) { - args.push('--publish-url', ctx.options.index); - } - if (Array.isArray(ctx.options.publishArgs)) args.push(...ctx.options.publishArgs.map(String)); + 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(' ')} `); diff --git a/packages/bumpy/src/core/targets/registry.ts b/packages/bumpy/src/core/targets/registry.ts index 367a3d5..c13c20e 100644 --- a/packages/bumpy/src/core/targets/registry.ts +++ b/packages/bumpy/src/core/targets/registry.ts @@ -175,9 +175,9 @@ export function resolvePackageTargets( ? resolveStringEntry(entry, config, pkg.name) : resolveInlineEntry(entry, config, pkg.name); - if (pkg.private && target.type === 'npm') { + if (pkg.private && target.plugin.capabilities.refusesPrivatePackages) { log.warn( - ` ${pkg.name}: dropping npm publish target "${target.name}" — package is "private": true (npm refuses to publish it)`, + ` ${pkg.name}: dropping publish target "${target.name}" — package is "private": true (${target.type} refuses to publish it)`, ); continue; } diff --git a/packages/bumpy/src/core/targets/types.ts b/packages/bumpy/src/core/targets/types.ts index 1f0bae3..b4b4bfd 100644 --- a/packages/bumpy/src/core/targets/types.ts +++ b/packages/bumpy/src/core/targets/types.ts @@ -12,6 +12,12 @@ export interface TargetCapabilities { 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 */ @@ -65,6 +71,12 @@ export interface PublishTargetPlugin { /** 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). Not called on dry runs. + */ + prepare?(ctx: TargetPublishContext): void | Promise; /** * Whether `version` is already live on this target. * Return null for "unknown" (caller falls back to git-tag tracking). 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 index f0324f3..6adfdcf 100644 --- a/packages/bumpy/src/core/targets/vscode.ts +++ b/packages/bumpy/src/core/targets/vscode.ts @@ -1,6 +1,7 @@ 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'; @@ -44,7 +45,7 @@ function vsixPath(ctx: TargetPublishContext): string { async function buildVsix(ctx: TargetPublishContext): Promise { const out = vsixPath(ctx); - const extraArgs = Array.isArray(ctx.options.packageArgs) ? ctx.options.packageArgs.map(String) : []; + 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; @@ -108,7 +109,7 @@ export const vscodeMarketplaceTarget: PublishTargetPlugin = { buildArtifact: buildVsix, async publish(ctx) { - const extraArgs = Array.isArray(ctx.options.publishArgs) ? ctx.options.publishArgs.map(String) : []; + 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) { @@ -161,7 +162,7 @@ export const openVsxTarget: PublishTargetPlugin = { buildArtifact: buildVsix, async publish(ctx) { - const extraArgs = Array.isArray(ctx.options.publishArgs) ? ctx.options.publishArgs.map(String) : []; + const extraArgs = stringArrayOption(ctx.options, 'publishArgs'); const args = [...OVSX_BIN, 'publish', ctx.artifactPath!, ...extraArgs]; if (ctx.dryRun) { log.dim(` Would publish with: ${args.join(' ')}`); diff --git a/packages/bumpy/src/core/workspace.ts b/packages/bumpy/src/core/workspace.ts index 17edf37..fb48ba3 100644 --- a/packages/bumpy/src/core/workspace.ts +++ b/packages/bumpy/src/core/workspace.ts @@ -4,6 +4,7 @@ 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 { @@ -136,6 +137,19 @@ async function loadWorkspacePackage( 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, version: (pkg.version as string) || '0.0.0', @@ -148,8 +162,7 @@ async function loadWorkspacePackage( peerDependencies: (pkg.peerDependencies as Record) || {}, optionalDependencies: (pkg.optionalDependencies as Record) || {}, bumpy, - // Resolve publish targets once at discovery so downstream consumers can answer - // "where does this package publish" without the root config in hand - targets: resolvePackageTargets({ name, private: isPrivate }, bumpy, config), + targets, + ...(targetsError ? { targetsError } : {}), }; } diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index f040a8d..5de3112 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -375,6 +375,13 @@ export interface WorkspacePackage { * `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 index 0cc85a0..c61fea5 100644 --- a/packages/bumpy/test/core/publish-multi-target.test.ts +++ b/packages/bumpy/test/core/publish-multi-target.test.ts @@ -129,6 +129,7 @@ describe('publishPackages — multi-target', () => { 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'); }); @@ -256,6 +257,7 @@ describe('publishPackages — multi-target', () => { 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) diff --git a/packages/bumpy/test/core/targets.test.ts b/packages/bumpy/test/core/targets.test.ts index f552375..1488066 100644 --- a/packages/bumpy/test/core/targets.test.ts +++ b/packages/bumpy/test/core/targets.test.ts @@ -209,6 +209,33 @@ describe('target helpers', () => { }); }); +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'); From c5900adbf5104f11eaf96b9eedd1ef7eeaca24d2 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 31 Jul 2026 16:08:38 -0700 Subject: [PATCH 4/5] fix: run prepare() validation on dry runs (plugins skip only file writes) --- packages/bumpy/src/core/publish-pipeline.ts | 9 +++++---- packages/bumpy/src/core/targets/jsr.ts | 2 +- packages/bumpy/src/core/targets/pypi.ts | 3 ++- packages/bumpy/src/core/targets/types.ts | 4 +++- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/bumpy/src/core/publish-pipeline.ts b/packages/bumpy/src/core/publish-pipeline.ts index e298cb6..4f0fb3d 100644 --- a/packages/bumpy/src/core/publish-pipeline.ts +++ b/packages/bumpy/src/core/publish-pipeline.ts @@ -279,10 +279,11 @@ async function publishOneTarget( }; // 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. - if (!opts.dryRun) { - await target.plugin.prepare?.(ctx); - } + // 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); diff --git a/packages/bumpy/src/core/targets/jsr.ts b/packages/bumpy/src/core/targets/jsr.ts index f658438..31e7edf 100644 --- a/packages/bumpy/src/core/targets/jsr.ts +++ b/packages/bumpy/src/core/targets/jsr.ts @@ -111,7 +111,7 @@ export const jsrTarget: PublishTargetPlugin = { 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 (jsrJson.version !== ctx.version) { + if (!ctx.dryRun && jsrJson.version !== ctx.version) { await updateJsonFields(jsrJsonPath, { version: ctx.version }); } }, diff --git a/packages/bumpy/src/core/targets/pypi.ts b/packages/bumpy/src/core/targets/pypi.ts index 1f99fca..6d238f8 100644 --- a/packages/bumpy/src/core/targets/pypi.ts +++ b/packages/bumpy/src/core/targets/pypi.ts @@ -116,11 +116,12 @@ function syncPyprojectVersion(ctx: TargetPublishContext): void { ); } 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`); } - writeFileSync(path, updated); + if (!ctx.dryRun) writeFileSync(path, updated); } export const pypiTarget: PublishTargetPlugin = { diff --git a/packages/bumpy/src/core/targets/types.ts b/packages/bumpy/src/core/targets/types.ts index b4b4bfd..7416090 100644 --- a/packages/bumpy/src/core/targets/types.ts +++ b/packages/bumpy/src/core/targets/types.ts @@ -74,7 +74,9 @@ export interface PublishTargetPlugin { /** * 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). Not called on dry runs. + * 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; /** From cfcbae1e44ad20a1e8b979984a1cefcf7399cc09 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 11 Sep 2026 22:26:06 -0700 Subject: [PATCH 5/5] refactor!: publish-target state model, per-target ordering, staged outcomes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State precedence is now registry → release metadata → git tag: the pipeline asks each target's checkPublished before every publish, metadata gates only what the registry can't answer (prior success, staged re-check, failed retry), and stale metadata keys are pruned. Tagging moves out of the pipeline into the publish flow: the tag follows HEAD until anything ships (success or staged), then freezes; push runs before the failure exit. A dependency failing on a target blocks dependents on that same target only. publish() may report a staged outcome — npmStaged records the stage id, holds the draft open, and the next run promotes it once live (snapshots never stage). Reconciliation lives in core/release-state.ts so a finalize command can reuse it. Targets run in two phases around the GitHub release: release-phase targets constitute it (draft held until done, then published); post-release targets consume it and run only once it's public, since a draft's assets aren't downloadable. homebrew/docker default to post-release; any target can set `phase`. Builds happen in the release pass only. New targets for CLI distribution: github-release-assets (uploads to the draft, so `release: published` fires only once binaries are attached), docker (buildx --push; :version, :latest on stable, dist-tag on channels/snapshots), homebrew (formula template with {{sha256 asset}} committed, tagged and pushed to the tap; token via env, never argv). Tests are hermetic w.r.t. the developer's git config (test/setup.ts preload). Every spawn in utils/shell.ts passes env: process.env explicitly — under Bun a child with no env gets the process's initial environment, so credential helpers that mutate process.env for one call never reached gh/git. Private packages that declare publishTargets are managed regardless of privatePackages.version. CLI-based registry probes are bounded by timeouts and report unknown on expiry, so a credential helper can't hang a release. BREAKING: publishCommand / checkPublished / skipNpmPublish are removed with a migration error; targets entries no longer inherit from each other; package.json may only reference targets by name (buildCommand and inline definitions need allowCustomCommands). Also: JSR identity from jsr.json name, marketplace checkPublished matches any version, preflight per distinct instance, no-target public packages still build and tag. --- .bumpy/publish-targets.md | 6 +- docs/cli.md | 5 +- docs/configuration.md | 103 ++++-- docs/prereleases.md | 2 +- packages/bumpy/bunfig.toml | 3 + packages/bumpy/src/commands/publish.ts | 336 +++++++++-------- packages/bumpy/src/core/config.ts | 57 ++- packages/bumpy/src/core/github-release.ts | 20 +- packages/bumpy/src/core/prerelease.ts | 11 +- packages/bumpy/src/core/publish-pipeline.ts | 302 +++++++++++----- packages/bumpy/src/core/release-state.ts | 99 +++++ packages/bumpy/src/core/snapshot.ts | 4 +- packages/bumpy/src/core/targets/custom.ts | 7 +- packages/bumpy/src/core/targets/docker.ts | 130 +++++++ .../src/core/targets/github-release-assets.ts | 108 ++++++ packages/bumpy/src/core/targets/homebrew.ts | 195 ++++++++++ packages/bumpy/src/core/targets/jsr.ts | 47 ++- packages/bumpy/src/core/targets/npm.ts | 40 ++- packages/bumpy/src/core/targets/registry.ts | 111 +++--- packages/bumpy/src/core/targets/types.ts | 30 +- packages/bumpy/src/core/targets/util.ts | 78 ++++ packages/bumpy/src/core/targets/vscode.ts | 40 ++- packages/bumpy/src/types.ts | 41 +-- packages/bumpy/src/utils/shell.ts | 42 ++- packages/bumpy/test/core/config.test.ts | 8 + packages/bumpy/test/core/prerelease.test.ts | 10 +- .../test/core/publish-multi-target.test.ts | 329 ++++++++++++++++- .../bumpy/test/core/publish-pipeline.test.ts | 56 ++- packages/bumpy/test/core/snapshot.test.ts | 6 +- .../bumpy/test/core/targets-release.test.ts | 339 ++++++++++++++++++ packages/bumpy/test/core/targets.test.ts | 96 ++--- packages/bumpy/test/helpers.ts | 7 +- packages/bumpy/test/setup.ts | 12 + packages/bumpy/test/utils/shell.test.ts | 6 + 34 files changed, 2180 insertions(+), 506 deletions(-) create mode 100644 packages/bumpy/src/core/release-state.ts create mode 100644 packages/bumpy/src/core/targets/docker.ts create mode 100644 packages/bumpy/src/core/targets/github-release-assets.ts create mode 100644 packages/bumpy/src/core/targets/homebrew.ts create mode 100644 packages/bumpy/test/core/targets-release.test.ts create mode 100644 packages/bumpy/test/setup.ts diff --git a/.bumpy/publish-targets.md b/.bumpy/publish-targets.md index a0591bb..c0b6aa4 100644 --- a/.bumpy/publish-targets.md +++ b/.bumpy/publish-targets.md @@ -1,7 +1,9 @@ --- -'@varlock/bumpy': minor +'@varlock/bumpy': major --- -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. +Publish-target plugin system: packages can now publish to multiple targets at once via per-package `publishTargets` and a root `targets` map of named, reusable instances. Built-in targets: `npm`, `jsr`, `pypi`, `vscode-marketplace`, `open-vsx`, `github-release-assets`, `docker`, `homebrew`, and `custom` (the shell-command escape hatch). Publish state is tracked per target in the GitHub release metadata, and the registry itself is asked before every publish, so a partial failure (npm succeeded, Open VSX errored) retries only what's missing and a lost draft never causes a duplicate publish. A dependency failing on a target blocks dependents on that same target only. 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. Staged npm publishes (`npmStaged`) are recorded as `staged` and hold the GitHub release as a draft until the version is approved and seen live. Targets run in two phases around the GitHub release: `release`-phase targets constitute it and are published together; `post-release` targets (`homebrew`, `docker`, or anything with `"phase": "post-release"`) consume it and run once it's public, so formulas and Dockerfiles that download release assets work. + +**Breaking:** the `publishCommand`, `checkPublished` and `skipNpmPublish` package fields are removed — bumpy fails with the migration when it sees them (`publishCommand`/`checkPublished` → a `custom` target entry in `publishTargets`; `skipNpmPublish: true` → `"publishTargets": []`). Root `targets` entries no longer inherit from each other (`targets.npm` is just the instance named `npm`; the `publish` block remains the defaults for every npm-type instance). A package's own `package.json` may only reference targets by name — inline target definitions (like `buildCommand`) require `allowCustomCommands`. 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/cli.md b/docs/cli.md index f3cde97..3cef9cf 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -109,9 +109,8 @@ With `--snapshot `, publish derives a throwaway prerelease version per pen **How bumpy detects unpublished packages:** -1. Custom `checkPublished` command (if configured per-package — see [`allowCustomCommands`](./configuration.md#custom-commands-and-allowcustomcommands)) -2. Git tags (for packages with `skipNpmPublish` or custom `publishCommand`) -3. npm registry query (default) +1. Each publish target that can answer is asked (`npm info` for npm, the JSR/PyPI APIs, a `custom` target's `checkPublished` command) — a package counts as published only when every target says so, so a partial publish re-enters the flow +2. Git tags, for targets that can't answer and for packages with `"publishTargets": []` ## `bumpy check` diff --git a/docs/configuration.md b/docs/configuration.md index be5577a..20ea3a4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,10 +22,10 @@ Bumpy is configured via `.bumpy/_config.json`, created by `bumpy init`. Per-pack | `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 (npm target defaults) | -| `targets` | `object` | `{}` | Publish target defaults + named reusable target instances (see [Publish targets](#publish-targets)) | +| `targets` | `object` | `{}` | Named, reusable publish 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) | +| `allowCustomCommands` | `boolean \| string[]` | `false` | Allow a package's `package.json` to define a `buildCommand` / inline publish targets (see below) | | `packages` | `object` | `{}` | Per-package config overrides (keyed by package name) | | `channels` | `object` | `{}` | Prerelease channels, keyed by channel name (see below) | | `snapshot` | `{ versionStrategy }` | `{ versionStrategy: "sha" }` | Snapshot release settings — how snapshot versions are made unique (see below) | @@ -35,7 +35,7 @@ Bumpy is configured via `.bumpy/_config.json`, created by `bumpy init`. Per-pack These are two different things, and bumpy treats them differently: - **Publishing to a private registry** (scoped package + `access: "restricted"` and/or a `registry`, _without_ `"private": true`) works like any other publish — bumpy versions, publishes, tags, and snapshots them normally. This is the recommended setup for private/internal packages. See [Publishing to a private registry](snapshots.md#publishing-to-a-private-registry). -- **`"private": true` in `package.json`** is npm's "never publish" marker (`npm publish` refuses it). bumpy never publishes these. `privatePackages` only controls whether they're _versioned_ (`version`) and _git-tagged_ (`tag`) — not published. Use this for apps and internal tooling you want bumpy to bump but never ship to a registry. +- **`"private": true` in `package.json`** is npm's "never publish" marker (`npm publish` refuses it). bumpy never publishes these to npm. `privatePackages` controls whether they're _versioned_ (`version`) and _git-tagged_ (`tag`) by default. Use this for apps and internal tooling you want bumpy to bump but never ship to a registry. A private package that declares [`publishTargets`](#publish-targets) (a marketplace extension, a PyPI stub, a CLI shipped as release assets) is always versioned and published to those targets — `"private": true` just keeps it off npm. ### Change detection and `package.json` fields @@ -103,6 +103,8 @@ The `publish` object controls how packages are packed and published: When `npmStaged` is enabled, bumpy uses `npm stage publish` instead of `npm publish`. This stages packages on npmjs.com, where they must be manually approved with 2FA before going live. This adds an extra security gate to your release process — even if CI credentials are compromised, packages can't be published without maintainer approval. +A staged publish is recorded as `staged` (🟡) in the draft GitHub release rather than as published, and the draft stays a draft — no dead npmjs.com link, no premature `release: published` event. Once the version is approved, the next `bumpy publish` run sees it live on the registry, records the success and finalizes the release. Snapshots are never staged (they must be installable immediately). The git tag is created at staging time — the staged artifact is already locked to that commit. + Requirements: - `publishManager` must be `"npm"` (the default) @@ -122,16 +124,19 @@ Requirements: 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 | +| 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 | +| `github-release-assets` | `gh release upload` | `gh` (`GH_TOKEN`) | Attaches binaries/checksums to the `name@version` release; no snapshots | +| `docker` | `docker buildx build --push` | `docker login` (e.g. GHCR) | Tags `:version`, `:latest` (stable), and the dist-tag | +| `homebrew` | commit + tag + push to a tap repo | `HOMEBREW_TAP_TOKEN` | Renders a formula template; 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): +Set a package's targets with `publishTargets` (in the root config's `packages` map, or — name references only — in the package's own `"bumpy"` config): ```jsonc { @@ -151,18 +156,16 @@ Set a package's targets with `publishTargets` (in the root config's `packages` m } ``` -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: +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. Public packages default to `["npm"]`, private packages to `[]`. -- 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 +**Named instances (`targets` map).** Root-level `targets` defines reusable instances, referenced by key from any package. A key that is a built-in type name (`"npm"`, `"jsr"`, …) configures the instance of that name (`"type"` is implied); any other key needs a `"type"`. Instances are complete on their own — nothing is inherited between them. For npm-type instances, the root `publish` block supplies the defaults every instance starts from. ```jsonc { + "publish": { "provenance": true }, // defaults for every npm-type instance "targets": { - "npm": { "provenance": true }, // defaults for all npm instances - "ghp": { "type": "npm", "registry": "https://npm.pkg.github.com" }, // named instance + "npm": { "access": "public" }, // the instance named "npm" + "ghp": { "type": "npm", "registry": "https://npm.pkg.github.com" }, // another npm instance }, "packages": { "@myorg/*": { "publishTargets": ["npm", "ghp"] }, // publish to both registries @@ -170,15 +173,58 @@ Each entry is either a **string** (a built-in type name, or a named instance fro } ``` -The legacy `publish` block is the npm type's default options — `targets.npm` and per-instance options layer on top of it. +**Execution + retries.** Packages publish in dependency order; within a package, targets run in declared order. One target failing doesn't block its siblings on the same package — but it does block the _same_ target on dependents (`app@jsr` never goes out referencing a `lib@jsr` that didn't land); blocked targets are recorded as failed and retried on the next run. Publish state is tracked per target in the draft GitHub release, so a partial failure (npm succeeded, Open VSX errored) retries only what's missing on the next CI run. Before every publish the registry itself is asked whether the version is already live (`checkPublished`), so a lost draft never causes a duplicate publish. + +**Tags and finalization.** The git tag `name@version` marks the commit a version's artifacts shipped from: it follows HEAD across failed attempts and freezes the first time anything ships. The draft GitHub release is finalized (published, firing `release: published`) once every _release-phase_ target is live. A [staged npm publish](#staged-publishing) holds the draft open until the version is approved — the next publish run sees it live and finalizes. -**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). +**Phases.** Targets run in two passes around the GitHub release. `release`-phase targets _constitute_ it (npm, JSR, PyPI, marketplaces, `github-release-assets`): the draft is held until they're done, then published. `post-release`-phase targets _consume_ it and run only once it's public — a Homebrew formula whose `url`s point at release assets, a Dockerfile that downloads them — because a draft release's assets aren't downloadable. `homebrew` and `docker` default to `post-release`; any target (e.g. a `custom` announcement command) can set `"phase": "post-release"`. If a package's release-phase targets don't all succeed (a failure, or a staged publish awaiting approval), its post-release targets wait for the next run. Builds happen in the release pass only. **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. +**Removed fields.** The pre-targets `publishCommand`, `checkPublished` and `skipNpmPublish` package fields are gone — bumpy fails with the migration when it sees them: `publishCommand`/`checkPublished` become a `{ "type": "custom", "name": "custom", "command": ..., "checkPublished": ... }` entry (naming it `custom` lets an in-flight release resume from its existing metadata), `skipNpmPublish: true` becomes `"publishTargets": []`. + +#### CLI binaries: release assets, Docker images, Homebrew + +A CLI shipped as native binaries typically fans out to three places after npm. `github-release-assets` runs in the release phase; `homebrew` and `docker` are post-release targets, so by the time they run the release is published and the formula's `url`s and the Dockerfile's downloads resolve: + +```jsonc +{ + "packages": { + "varlock": { + "buildCommand": "bun run build:binaries", // produces dist-sea/*.tar.gz + checksums.txt + "publishTargets": [ + "npm", + { + "type": "github-release-assets", + "files": ["dist-sea/*.tar.gz", "dist-sea/*.zip", "dist-sea/checksums.txt*"], + }, + { + "type": "homebrew", + "tap": "dmno-dev/homebrew-tap", + "template": "homebrew/varlock.rb.tmpl", + "assets": ["dist-sea/*.tar.gz"], + }, + { + "type": "docker", + "image": "ghcr.io/dmno-dev/varlock", + "context": "../..", + "dockerfile": "../../Dockerfile", + "platforms": ["linux/amd64", "linux/arm64"], + "buildArgs": { "VARLOCK_VERSION": "{{version}}" }, + }, + ], + }, + }, +} +``` + +**`github-release-assets`** uploads the files matching `files` (globs relative to the package dir; `{{version}}`/`{{name}}` substituted) to the package's GitHub release with `--clobber`. Because bumpy owns the release, the upload goes to the _draft_ — the release is only published, firing `release: published`, once the assets (and every other target) are done. Build the files first (`buildCommand`, or an earlier CI step). Needs the `gh` CLI with `contents: write`. + +**`docker`** runs `docker buildx build --push` with `--tag image:`, plus `image:latest` for stable releases (`"latest": false` to opt out) and `image:` on channel/snapshot publishes — so `ghcr.io/org/tool:next` works like `npm install tool@next`. `context`/`dockerfile` are relative to the package dir; `platforms` builds a multi-arch manifest; `buildArgs` values and extra `tags` get `{{version}}` substituted. Auth is the environment's (`docker/login-action` with `GITHUB_TOKEN` + `packages: write` for GHCR). Idempotency uses `docker manifest inspect`. + +**`homebrew`** renders a formula template from your repo and pushes it to the tap. The template is yours; bumpy fills `{{version}}`, `{{name}}`, and `{{sha256 }}` (the SHA-256 of a release asset found by basename in the `assets` globs — which is why the assets target goes first). It writes `formula` (default `Formula/.rb`) into a fresh clone of `tap` (or an existing checkout via `tapDir`, e.g. from `actions/checkout`), commits as `name@version`, tags the tap commit the same, and pushes. A workflow's `GITHUB_TOKEN` can't push to another repo: set `HOMEBREW_TAP_TOKEN` (falls back to `BUMPY_GH_TOKEN`/`GH_TOKEN`); it's passed to git through the environment, never on the command line. Idempotency reads the formula's `version` from the tap via the GitHub API. #### JSR notes @@ -277,11 +323,8 @@ 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 | | `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` | _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 | | `cascadeTo` | `object` | Explicit cascade targets — glob pattern mapped to `{ trigger, bumpAs }` | @@ -290,12 +333,12 @@ Per-package settings can be defined in two places: ### Custom commands and `allowCustomCommands` -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: +A `buildCommand` runs a shell command during publishing, and an inline `publishTargets` definition (`{ "type": "custom", "command": ... }`, `{ "type": "npm", "registry": ... }`, `publishArgs`, …) steers where and how a publish happens. 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. +- **Root config** (`.bumpy/_config.json` → `packages` and `targets`): always trusted — repo admins control this file. +- **Per-package config** (`package.json` → `"bumpy"`): may only **reference** targets by name. A `buildCommand` or an inline target definition there requires opt-in via `allowCustomCommands` in the root config, and fails loudly otherwise. -By default, custom commands defined in `package.json` are **ignored** with a warning. To enable them, set `allowCustomCommands` in `.bumpy/_config.json`: +To enable them, set `allowCustomCommands` in `.bumpy/_config.json`: ```json { @@ -311,7 +354,7 @@ 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. Referencing built-in or root-defined targets by name (`"publishTargets": ["vscode-marketplace"]`) is plain data and never requires `allowCustomCommands`. +This prevents a contributor from introducing arbitrary shell commands — or redirecting a publish to another registry, or injecting CLI flags — 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`. To give a package custom target options without opting in, define a named instance in the root `targets` map and reference it by name. ### Example: publishing a VSCode extension diff --git a/docs/prereleases.md b/docs/prereleases.md index b57c033..65c3f30 100644 --- a/docs/prereleases.md +++ b/docs/prereleases.md @@ -236,7 +236,7 @@ How `bumpy publish` (and the publish half of `bumpy ci release`) works on a chan **Trigger** — in CI, publish fires when the triggering push added files to `.bumpy//` (the push event's `before..after` range, falling back to the last commit). That's exactly what merging a release PR does; an ordinary feature merge never touches the channel dir, so it never causes a publish. This requires git history in the checkout — use `fetch-depth: 0`, which the [release workflow](github-actions.md) needs anyway. Running `bumpy publish` manually on the channel branch always publishes the cycle (manual = explicit intent). -**Idempotency & resume** — re-running on the same commit is a no-op: npm records the publishing commit (`gitHead`) in each version's metadata, so bumpy can tell "already published from this exact SHA — skip" apart from "needs the next counter." (Packages publishing outside npm — custom commands, `skipNpmPublish` — use their git tags for the same check.) If a publish fails partway, re-running resumes it package by package; `bumpy publish --filter` remains available as a manual fallback. +**Idempotency & resume** — re-running on the same commit is a no-op: npm records the publishing commit (`gitHead`) in each version's metadata, so bumpy can tell "already published from this exact SHA — skip" apart from "needs the next counter." (Packages without an npm target — custom targets, `publishTargets: []` — use their git tags for the same check.) If a publish fails partway, re-running resumes it package by package; `bumpy publish --filter` remains available as a manual fallback. **Order of operations** — publish packages topologically, then push tags, then create the GitHub release. Tags are the completion marker, so they go up only after the registry is fully consistent. diff --git a/packages/bumpy/bunfig.toml b/packages/bumpy/bunfig.toml index 3034047..fa0d140 100644 --- a/packages/bumpy/bunfig.toml +++ b/packages/bumpy/bunfig.toml @@ -3,3 +3,6 @@ [define] "__BUMPY_VERSION__" = "'dev'" + +[test] +preload = ["./test/setup.ts"] diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index aff9dc1..d5c900f 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -3,8 +3,14 @@ import { log, colorize } from '../utils/logger.ts'; import { loadConfig } from '../core/config.ts'; import { discoverWorkspace } from '../core/workspace.ts'; import { DependencyGraph } from '../core/dep-graph.ts'; -import { forcePushTag, hasUncommittedChanges, tagExists } from '../core/git.ts'; -import { publishPackages, willUseOidcExclusively } from '../core/publish-pipeline.ts'; +import { createTag, forcePushTag, hasUncommittedChanges, tagExists } from '../core/git.ts'; +import { + publishPackages, + mergePublishResults, + releaseShipped, + willUseOidcExclusively, + type PublishResult, +} from '../core/publish-pipeline.ts'; import { readBumpFiles } from '../core/bump-file.ts'; import { assembleReleasePlan } from '../core/release-plan.ts'; import { channelNames, resolveActiveChannel, type ResolvedChannel } from '../core/channels.ts'; @@ -19,9 +25,6 @@ import { createIndividualReleases, findReleaseByTag, createDraftRelease, - updateReleaseBody, - updateReleaseBodyStatus, - finalizeRelease, finalizeSupersededDrafts, composeReleaseBody, parseRepoSlug, @@ -32,7 +35,8 @@ import { type ReleaseMetadata, type PublishTargetState, } from '../core/github-release.ts'; -import { getPackageTargets, getNpmTarget, targetLabel } from '../core/targets/registry.ts'; +import { liveTargetState, reconcileRelease, type ReleaseInfo } from '../core/release-state.ts'; +import { getPackageTargets, getNpmTarget, targetLabel, targetSupportsRelease } 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'; @@ -358,7 +362,7 @@ async function publishSnapshot( depGraph, config, rootDir, - { dryRun: opts.dryRun, tag: snapshot.tag, noTag: true, releaseKind: 'snapshot' }, + { dryRun: opts.dryRun, tag: snapshot.tag, releaseKind: 'snapshot' }, catalogs, detectedPm, ); @@ -386,8 +390,20 @@ async function publishSnapshot( /** * The shared publish flow: OIDC checks, draft GitHub releases, topological publish, - * release metadata updates, tag pushes. Used by both the stable and channel paths. + * release metadata updates, git tags. Used by both the stable and channel paths. * Mutates `releasePlan.releases` as packages are filtered out (already published, etc.). + * + * State model — three sources of "is this version out", with fixed precedence: + * 1. The registry (each target's `checkPublished`): truth whenever it can answer. + * 2. Release metadata: memory for what the registry can't tell us — which targets + * already succeeded (skip), are staged (re-check), or failed (retry). + * 3. The git tag `name@version`: how packages with no queryable target are tracked. + * + * The tag marks the commit the artifacts shipped from. With gh, the draft release + * creates it on the remote at HEAD; it is moved along with HEAD on retries until the + * first run ships anything, then frozen. Without gh, bumpy creates it when something + * ships. The draft is finalized once every target is live — a `staged` target (npm + * 2FA approval pending) holds it open until a later run sees the version live. */ async function runPublishFlow( rootDir: string, @@ -405,6 +421,27 @@ async function runPublishFlow( }, ): Promise { let toPublish = releasePlan.releases; + const releaseKind = opts.releaseKind ?? 'stable'; + + // Drop packages none of whose targets can publish this kind of release (e.g. a + // marketplace-only extension on a channel prerelease). The pipeline would skip every + // target, and a draft release opened for it could never finalize. + const unpublishable = toPublish.filter((release) => { + const targets = getPackageTargets(packages.get(release.name)!, config); + const isPrerelease = semver.prerelease(release.newVersion) !== null; + return targets.length > 0 && !targets.some((t) => targetSupportsRelease(t, releaseKind, isPrerelease)); + }); + if (unpublishable.length > 0) { + for (const r of unpublishable) { + log.dim(` Skipping ${r.name}@${r.newVersion} — no publish target supports ${releaseKind} releases`); + } + toPublish = toPublish.filter((r) => !unpublishable.includes(r)); + releasePlan.releases = toPublish; + if (toPublish.length === 0) { + log.info('Nothing to publish — no target supports this kind of release.'); + return; + } + } if (opts.dryRun) { log.bold('Dry run — would publish:'); @@ -447,10 +484,7 @@ async function runPublishFlow( } // For each package, set up draft releases (if gh is available and not dry run) - const releaseMetadataByPkg = new Map< - string, - { tag: string; metadata: ReleaseMetadata; existingBody: string | null } - >(); + const releaseMetadataByPkg = new Map(); if (ghAvailable && !opts.dryRun) { for (const release of toPublish) { @@ -467,6 +501,7 @@ async function runPublishFlow( tag, metadata: existing.metadata, existingBody: existing.body, + isDraft: existing.isDraft, }); } else if (existing && !existing.metadata) { // Existing release without bumpy metadata — leave it alone (user-created or old-style) @@ -498,63 +533,58 @@ async function runPublishFlow( prerelease: semver.prerelease(release.newVersion) !== null, }); log.dim(` Created draft release: ${title}`); - releaseMetadataByPkg.set(release.name, { tag, metadata, existingBody: body }); + releaseMetadataByPkg.set(release.name, { tag, metadata, existingBody: body, isDraft: true }); } catch (err) { log.warn(` Failed to create draft release for ${tag}: ${err instanceof Error ? err.message : err}`); } } } - // Handle tag movement: if no targets succeeded yet, move tag to HEAD + // Tag movement: the tag marks the commit artifacts ship from. Until anything has + // shipped (success or staged — a staged artifact is already locked to its SHA at + // the registry) it follows HEAD; after that it is frozen. for (const release of toPublish) { const info = releaseMetadataByPkg.get(release.name); if (!info) continue; - const anySucceeded = Object.values(info.metadata.targets).some((t) => t.status === 'success'); - if (!anySucceeded) { - // Safe to move tag to HEAD - const tag = info.tag; - const headSha = getHeadSha(rootDir); - const tagSha = tryRunArgs(['git', 'rev-parse', tag], { cwd: rootDir }); - if (headSha && tagSha && headSha !== tagSha) { - // Count commits between tag and HEAD - const count = tryRunArgs(['git', 'rev-list', '--count', `${tag}..HEAD`], { cwd: rootDir }); - log.dim(` Moving version tag ${tag} to HEAD (includes ${count} commit(s) since versioning)`); - tryRunArgs(['git', 'tag', '-f', tag], { cwd: rootDir }); - } + const anyShipped = Object.values(info.metadata.targets).some( + (t) => t.status === 'success' || t.status === 'staged', + ); + const tag = info.tag; + const headSha = getHeadSha(rootDir); + const tagSha = tryRunArgs(['git', 'rev-parse', tag], { cwd: rootDir }); + if (!headSha || !tagSha || headSha === tagSha) continue; + const count = tryRunArgs(['git', 'rev-list', '--count', `${tag}..HEAD`], { cwd: rootDir }); + if (!anyShipped) { + log.dim(` Moving version tag ${tag} to HEAD (includes ${count} commit(s) since versioning)`); + tryRunArgs(['git', 'tag', '-f', tag], { cwd: rootDir }); } else { - // Tag stays — log divergence if any - const tag = info.tag; - const headSha = getHeadSha(rootDir); - const tagSha = tryRunArgs(['git', 'rev-parse', tag], { cwd: rootDir }); - if (headSha && tagSha && headSha !== tagSha) { - const count = tryRunArgs(['git', 'rev-list', '--count', `${tag}..HEAD`], { cwd: rootDir }); - log.warn( - ` HEAD is ${count} commit(s) ahead of version tag ${tag} — some targets already published from tagged commit`, - ); - } + log.warn( + ` HEAD is ${count} commit(s) ahead of version tag ${tag} — some targets already shipped from the tagged commit`, + ); } } } - // 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>(); + // Per-target resume: hand each package's recorded target states to the pipeline. + // Packages where ALL targets already succeeded are dropped entirely (and their + // release reconciled — it may still be a draft if a previous run crashed before + // finalizing, or a target that failed was since removed from config). + const priorStates = new Map>(); const alreadyPublished: string[] = []; for (const release of toPublish) { const info = releaseMetadataByPkg.get(release.name); if (!info) continue; + priorStates.set(release.name, info.metadata.targets); const targets = publishTargetsByPkg.get(release.name) || []; - 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) { + if (targets.length > 0 && targets.every((t) => info.metadata.targets[t.name]?.status === 'success')) { alreadyPublished.push(release.name); } } if (alreadyPublished.length > 0) { for (const name of alreadyPublished) { log.dim(` Skipping ${name} — all targets already published (per draft release metadata)`); + await reconcileRelease(releaseMetadataByPkg.get(name)!, publishTargetsByPkg.get(name) || [], false, rootDir); } toPublish = toPublish.filter((r) => !alreadyPublished.includes(r.name)); releasePlan.releases = toPublish; @@ -565,32 +595,9 @@ async function runPublishFlow( return; } - const result = await publishPackages( - releasePlan, - packages, - depGraph, - config, - rootDir, - { - dryRun: opts.dryRun, - tag: opts.tag, - releaseKind: opts.releaseKind, - completedTargets, - }, - catalogs, - detectedPm, - ); - - // Summary - if (result.published.length > 0) { - log.success(`🐸 Published ${result.published.length} package(s)`); - } - if (result.skipped.length > 0) { - log.dim(`Skipped ${result.skipped.length}: ${result.skipped.map((s) => s.name).join(', ')}`); - } - - // Update draft release metadata with results - if (ghAvailable && !opts.dryRun) { + // Record a pass's outcomes in the draft releases and finalize the ones that completed + const recordOutcomes = async (passResult: PublishResult): Promise => { + if (!ghAvailable || opts.dryRun) return; for (const release of releasePlan.releases) { const info = releaseMetadataByPkg.get(release.name); if (!info) continue; @@ -599,8 +606,8 @@ async function runPublishFlow( 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 outcomes = passResult.targetOutcomes.get(release.name) || []; + const pkgFailure = passResult.failed.find((f) => f.name === release.name); let changed = false; for (const outcome of outcomes) { @@ -611,12 +618,17 @@ async function runPublishFlow( 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 + // "already on registry" = the registry guard found the version live (metadata + // was stale or lost, or a staged publish has been approved) — record the success + info.metadata.targets[outcome.target] = target + ? liveTargetState(target, pkg, release.newVersion, repoSlug) + : { status: 'success', publishedAt: new Date().toISOString() }; + changed = true; + } else if (outcome.status === 'staged') { info.metadata.targets[outcome.target] = { - status: 'success', - publishedAt: new Date().toISOString(), - url: target?.plugin.publishUrl?.(pkg, release.newVersion, target.options, { repoSlug }), + status: 'staged', + stagedAt: new Date().toISOString(), + ...(outcome.ref ? { ref: outcome.ref } : {}), ...labelField, }; changed = true; @@ -628,8 +640,8 @@ async function runPublishFlow( ...labelField, }; changed = true; - } else if (outcome.skipKind !== 'metadata') { - // Capability skips (e.g. prerelease on a target without prerelease support) + } else if (outcome.skipKind === 'capability') { + // e.g. a marketplace target on a prerelease — terminal for this release info.metadata.targets[outcome.target] = { status: 'skipped', reason: outcome.reason, @@ -637,6 +649,7 @@ async function runPublishFlow( }; changed = true; } + // metadata / still-staged skips: state is already what it should be } // Package-level failure before any target ran (build / protocol resolution): @@ -655,61 +668,93 @@ async function runPublishFlow( } } - if (changed) { - try { - const updatedBody = info.existingBody - ? updateReleaseBodyStatus(info.existingBody, info.metadata) - : composeReleaseBody('', info.metadata); - await updateReleaseBody(info.tag, updatedBody, rootDir); - - // 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}`); - } - } catch (err) { - log.warn(` Failed to update release for ${info.tag}: ${err instanceof Error ? err.message : err}`); - } - } + await reconcileRelease(info, targets, changed, rootDir); } + }; + + const pipelineOpts = { dryRun: opts.dryRun, tag: opts.tag, releaseKind: opts.releaseKind, priorStates }; + + // Phase 1 — targets that constitute the release (npm, marketplaces, release assets). + // Once they're done the draft is published. + let result = await publishPackages( + releasePlan, + packages, + depGraph, + config, + rootDir, + { ...pipelineOpts, phase: 'release' }, + catalogs, + detectedPm, + ); + await recordOutcomes(result); + + // Phase 2 — targets that consume the published release (a Homebrew formula pointing + // at release assets, a Dockerfile that downloads them). A draft's assets aren't + // downloadable, so only packages whose release is public now take part; the rest + // (release-phase failure, staged publish awaiting approval) wait for the next run. + const postReleases = releasePlan.releases.filter((release) => { + const targets = publishTargetsByPkg.get(release.name) || []; + if (!targets.some((t) => t.phase === 'post-release')) return false; + const info = releaseMetadataByPkg.get(release.name); + if (!ghAvailable || opts.dryRun || !info || !info.isDraft) return true; + log.dim( + ` Holding ${release.name}@${release.newVersion} post-release targets — they run once the release is published`, + ); + return false; + }); + if (postReleases.length > 0) { + const postResult = await publishPackages( + { ...releasePlan, releases: postReleases }, + packages, + depGraph, + config, + rootDir, + { ...pipelineOpts, phase: 'post-release' }, + catalogs, + detectedPm, + ); + await recordOutcomes(postResult); + result = mergePublishResults(result, postResult); } - if (result.failed.length > 0) { - log.error(`Failed ${result.failed.length}: ${result.failed.map((f) => `${f.name} (${f.error})`).join(', ')}`); - process.exit(1); + // Summary + if (result.published.length > 0) { + log.success(`🐸 Published ${result.published.length} package(s)`); + } + if (result.staged.length > 0) { + log.info( + `🟡 Staged ${result.staged.length} package(s) — awaiting approval; re-run publish once approved to finalize`, + ); + } + if (result.skipped.length > 0) { + log.dim(`Skipped ${result.skipped.length}: ${result.skipped.map((s) => s.name).join(', ')}`); } - // Push tags — per-tag force push only for releases handled this run. - // - // We use `releasePlan.releases` (not result.published) so that packages with - // skipNpmPublish or private packages with `privatePackages.tag` enabled are - // covered too — their local tags are created in publish-pipeline regardless of - // whether npm publish ran. Failed packages are skipped (their local tag was - // not created). The `alreadyPublished` filter above has already stripped - // packages whose targets all succeeded in prior runs, so we never touch tags - // tied to a previously-published SHA. - // - // Force-push is necessary because `gh release create --draft --target SHA` - // creates the tag on the remote at draft-creation time. If a previous attempt - // failed and HEAD has since moved, the remote tag is at a stale SHA and a - // 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)); + // Git tags — `name@version` marks the commit a version's artifacts shipped from. + // Ensured for every release that shipped something this run (published, staged, or + // found already live by the registry guard) and for public packages with no targets + // (nothing to ship — the tag IS their published-ness). With gh the draft already put + // the tag on the remote and the tag-movement step kept it on HEAD; the force push + // re-points the remote to where the tag ended up. Runs before the failure exit so a + // partial success (npm ok, another target failed) still lands its tag. + const shippedTags: string[] = []; + for (const release of releasePlan.releases) { + const targets = publishTargetsByPkg.get(release.name) || []; + const outcomes = result.targetOutcomes.get(release.name) || []; + const buildFailed = outcomes.length === 0 && result.failed.some((f) => f.name === release.name); + const shipped = targets.length === 0 ? !buildFailed : releaseShipped(outcomes); + if (shipped) shippedTags.push(`${release.name}@${release.newVersion}`); + } + if (opts.dryRun) { + for (const tag of shippedTags) log.dim(` Would tag: ${tag}`); + } else if (shippedTags.length > 0) { const pushed: string[] = []; - log.step('Pushing tags...'); - for (const release of releasePlan.releases) { - if (failed.has(release.name) && !published.has(release.name)) continue; - const tag = `${release.name}@${release.newVersion}`; - if (!tagExists(tag, { cwd: rootDir })) continue; + for (const tag of shippedTags) { + if (!tagExists(tag, { cwd: rootDir })) { + createTag(tag, { cwd: rootDir }); + log.dim(` Tagged: ${tag}`); + } + if (opts.noPush) continue; try { forcePushTag(tag, { cwd: rootDir }); pushed.push(tag); @@ -720,6 +765,11 @@ async function runPublishFlow( if (pushed.length > 0) log.success(`Pushed ${pushed.length} tag(s) to remote`); } + if (result.failed.length > 0) { + log.error(`Failed ${result.failed.length}: ${result.failed.map((f) => `${f.name} (${f.error})`).join(', ')}`); + process.exit(1); + } + // Fallback: if gh isn't available, we can't use draft releases — use legacy individual releases if (!ghAvailable && result.published.length > 0) { const publishedReleases = releasePlan.releases.filter((r) => result.published.some((p) => p.name === r.name)); @@ -807,10 +857,9 @@ async function findUnpublishedWithCache( * Find packages whose current version is not yet published. * * Detection strategy (per package): - * 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) + * 1. Every target with a `checkPublished` implementation → ask the plugin (npm via + * `npm info`, JSR/PyPI via their APIs, custom via its check command) + * 2. Fallback → check git tags (how targets that can't answer are tracked) */ export async function findUnpublishedPackages( packages: Map, @@ -819,8 +868,10 @@ export async function findUnpublishedPackages( const unpublished: PlannedRelease[] = []; for (const [name, pkg] of packages) { - // Skip packages that publish nowhere - if (getPackageTargets(pkg, config).length === 0) continue; + // Private packages that publish nowhere never enter the flow. Public ones with no + // targets (`publishTargets: []`) still do: they are tracked (and tagged) via git + // tags, which is what the git-tag fallback in checkIfPublished answers. + if (pkg.private && getPackageTargets(pkg, config).length === 0) continue; // Skip ignored if (pkg.version === '0.0.0') continue; @@ -844,20 +895,9 @@ export async function findUnpublishedPackages( } async function checkIfPublished(pkg: WorkspacePackage, version: string, config: BumpyConfig): Promise { - const { runAsync, tryRunArgs } = await import('../utils/shell.ts'); - const pkgConfig = pkg.bumpy; - - // 1. Legacy custom check command (user-defined, runs in shell by design) - if (pkgConfig?.checkPublished) { - try { - const result = await runAsync(pkgConfig.checkPublished); - return result.trim() === version; - } catch { - return false; - } - } + const { tryRunArgs } = await import('../utils/shell.ts'); - // 2. A package is published only when EVERY target that can answer says so — + // 1. 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. @@ -868,8 +908,8 @@ async function checkIfPublished(pkg: WorkspacePackage, version: string, config: if (answers.some((a) => a === false)) return false; if (answers.length > 0 && answers.every((a) => a === true)) return true; - // 3. Targets that can't answer (custom without checkPublished, network failures): - // git tags track their published-ness + // 2. Targets that can't answer (custom without checkPublished, network failures) and + // packages with no targets at all: git tags track their published-ness const tag = `${pkg.name}@${version}`; return tryRunArgs(['git', 'tag', '-l', tag]) === tag; } diff --git a/packages/bumpy/src/core/config.ts b/packages/bumpy/src/core/config.ts index 1b934d6..7b5883b 100644 --- a/packages/bumpy/src/core/config.ts +++ b/packages/bumpy/src/core/config.ts @@ -57,25 +57,39 @@ export async function loadPackageConfig( // ignore } - // 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: 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), + // The pre-targets publish fields were removed (breaking) — fail with the migration + // rather than silently ignoring them + const legacy = (['publishCommand', 'skipNpmPublish', 'checkPublished'] as const).filter( + (k) => + (pkgJsonConfig as Record)[k] != null || (rootPkgConfig as Record)[k] != null, ); - if (hasInlineTargetCommands) disallowedKeys.push('publishTargets (inline commands)'); - if (disallowedKeys.length > 0 && !isCustomCommandAllowed(pkgName, rootConfig)) { - const fields = disallowedKeys.map((k) => `"${k}"`).join(', '); + if (legacy.length > 0) { throw new Error( - `Package "${pkgName}" defines custom command(s) (${fields}) in its package.json "bumpy" config, ` + + `Package "${pkgName}" uses removed config field(s) ${legacy.map((k) => `"${k}"`).join(', ')}. Migrate to "publishTargets":\n` + + ' publishCommand + checkPublished → "publishTargets": [{ "type": "custom", "name": "custom", "command": ..., "checkPublished": ... }]\n' + + ' skipNpmPublish: true → "publishTargets": []\n' + + '(name the custom instance "custom" so an in-flight release keeps resuming from its existing metadata)', + ); + } + + // Trust boundary: a package's own package.json may only *reference* publish targets + // by name. Anything that steers the credentialed publish — a build command, or an + // inline target definition carrying options (`registry` redirects it, `publishArgs` + // injects CLI flags, `command` runs a shell) — requires the root config to opt the + // package in. The root config itself (`packages` and `targets` maps) is always trusted. + const disallowed: string[] = []; + if (pkgJsonConfig.buildCommand != null) disallowed.push('buildCommand'); + if ((pkgJsonConfig.publishTargets ?? []).some((entry) => typeof entry === 'object')) { + disallowed.push('publishTargets (inline target definitions)'); + } + if (disallowed.length > 0 && !isCustomCommandAllowed(pkgName, rootConfig)) { + throw new Error( + `Package "${pkgName}" defines ${disallowed.map((k) => `"${k}"`).join(', ')} in its package.json "bumpy" config, ` + 'but the root config does not allow this.\n' + - 'Custom commands execute shell commands during publishing and must be explicitly enabled.\n\n' + + 'Build commands and inline target definitions steer publishing with CI credentials and must be explicitly enabled.\n\n' + 'To fix this, either:\n' + - ' 1. Move the command(s) to .bumpy/_config.json under "packages" (always trusted)\n' + + ' 1. Move it to .bumpy/_config.json (always trusted) — under "packages", or as a named ' + + 'instance in "targets" that the package references by name\n' + ` 2. Add "allowCustomCommands": true (or ["${pkgName}"]) to .bumpy/_config.json`, ); } @@ -189,8 +203,10 @@ export function getBumpyDir(rootDir: string): string { * 2. `config.ignore` glob match → skip * 3. Per-package `managed: true` → include (explicit opt-in, overrides private) * 4. `config.include` glob match → include (overrides private) - * 5. Private package + `config.privatePackages.version` false → skip - * 6. Otherwise → include + * 5. Private package that declares publish targets → include (it ships somewhere: + * a marketplace extension, a PyPI stub, a CLI distributed as release assets) + * 6. Private package + `config.privatePackages.version` false → skip + * 7. Otherwise → include */ export function isPackageManaged( pkgName: string, @@ -215,9 +231,12 @@ export function isPackageManaged( // 4. Included by glob (overrides private) if (config.include.some((pattern) => matchGlob(pkgName, pattern))) return true; - // 5. Private package check + // 5. "private": true only means "not npm"; explicit targets mean it publishes anyway + if (isPrivate && (pkgBumpy?.publishTargets?.length ?? 0) > 0) return true; + + // 6. Private package check if (isPrivate && !config.privatePackages.version) return false; - // 6. Default: managed + // 7. Default: managed return true; } diff --git a/packages/bumpy/src/core/github-release.ts b/packages/bumpy/src/core/github-release.ts index 2181762..bf024b6 100644 --- a/packages/bumpy/src/core/github-release.ts +++ b/packages/bumpy/src/core/github-release.ts @@ -18,7 +18,7 @@ export function getHeadSha(rootDir: string): string | null { * * Any errors are scrubbed so the token never appears in CI logs. */ -async function withReleaseToken(fn: () => Promise): Promise { +export async function withReleaseToken(fn: () => Promise): Promise { const token = process.env.BUMPY_GH_TOKEN; if (!token) return fn(); const original = process.env.GH_TOKEN; @@ -138,7 +138,16 @@ export function isGhAvailable(): boolean { const METADATA_START = ''; -export type PublishTargetStatus = 'pending' | 'success' | 'failed' | 'skipped'; +/** + * Per-target publish state in the release metadata: + * - pending: not attempted yet + * - success: live on the registry + * - staged: accepted by the registry but held for an out-of-band step (e.g. npm staged + * publishing's 2FA approval) — promoted to success once `checkPublished` sees it live + * - failed: retried on the next run + * - skipped: the target opted out of this release kind (or was superseded) + */ +export type PublishTargetStatus = 'pending' | 'success' | 'staged' | 'failed' | 'skipped'; export interface PublishTargetState { status: PublishTargetStatus; @@ -148,6 +157,10 @@ export interface PublishTargetState { reason?: string; supersededBy?: string; url?: string; + /** Registry handle for a staged publish (e.g. the npm stage id). Present while `status: 'staged'`. */ + ref?: string; + /** ISO timestamp of when the publish was staged (awaiting approval). */ + stagedAt?: string; /** Human-readable label, e.g. "GitHub Packages" for npm targets on a GHP registry. Falls back to the target key. */ label?: string; } @@ -193,6 +206,9 @@ export function formatPublishedToSection(targets: Record t.slice(pkg.name.length + 1), @@ -152,8 +152,9 @@ export async function buildChannelReleasePlan( stablePlan.releases.map(async (release) => { const pkg = packages.get(release.name); if (!pkg) return; - // Unpublishable packages can't participate in a registry-consumable cycle - if (pkg.private && !packagePublishes(pkg)) return; + // Unpublishable packages can't participate in a registry-consumable cycle (a + // marketplace-only extension counts: its targets don't take prereleases) + if (pkg.private && !packagePublishesFor(pkg, 'channel')) return; const target = release.newVersion; // stable target from the bump files const state = await getPublishedPrereleaseState(pkg, target, channel.preid, rootDir); @@ -253,7 +254,7 @@ export function channelDisplayPlan( const releases = stablePlan.releases .filter((r) => { const pkg = packages.get(r.name); - return !!pkg && !(pkg.private && !packagePublishes(pkg)); + return !!pkg && !(pkg.private && !packagePublishesFor(pkg, 'channel')); }) .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 4f0fb3d..fd945dd 100644 --- a/packages/bumpy/src/core/publish-pipeline.ts +++ b/packages/bumpy/src/core/publish-pipeline.ts @@ -4,12 +4,12 @@ import semver from 'semver'; import { readJson, updateJsonNestedField } from '../utils/fs.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 { getPackageTargets, targetSupportsRelease } from './targets/registry.ts'; +import type { ReleaseKind, ResolvedTarget, TargetPhase, TargetPublishContext } from './targets/types.ts'; +import type { PublishTargetState } from './github-release.ts'; import type { ReleasePlan, PlannedRelease, WorkspacePackage, BumpyConfig, PackageManager } from '../types.ts'; // Re-exported for callers/tests that historically imported these from the pipeline @@ -18,40 +18,57 @@ 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. + * Per-package target states recorded by previous runs (from the GitHub release + * metadata). Targets already `success` are skipped — per-target resume: if npm + * succeeded and open-vsx failed, the retry only re-runs open-vsx. `staged` targets + * are re-checked against the registry and skipped while still awaiting approval. */ - completedTargets?: Map>; + priorStates?: Map>; + /** + * Restrict the run to targets of one phase. The publish flow runs the `release` + * phase, publishes the GitHub release, then runs `post-release` (targets that need + * the release's public URLs). Unset = every target in one pass (snapshots, tests). + * Builds and protocol resolution happen in the release pass only. + */ + phase?: TargetPhase; } export interface TargetOutcome { /** Target instance name (the release-metadata key) */ target: string; type: string; - status: 'success' | 'failed' | 'skipped'; + /** + * - success: live on the registry + * - staged: accepted but held by the registry for an out-of-band step (npm 2FA approval) + * - failed: errored, or blocked because a dependency failed on this same target + * - skipped: see `skipKind` + */ + status: 'success' | 'staged' | 'failed' | 'skipped'; error?: string; + /** Registry handle for a staged publish (e.g. the npm stage id) */ + ref?: 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 + * - 'staged': a previous run staged it and it is still awaiting approval * - '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. + * success for release-metadata purposes. */ - skipKind?: 'metadata' | 'registry' | 'capability'; + skipKind?: 'metadata' | 'registry' | 'staged' | 'capability'; } export interface PublishResult { - /** Packages where at least one target published this run */ + /** Packages where at least one target went live this run */ published: { name: string; version: string }[]; + /** Packages where at least one target was staged (accepted, awaiting approval) this run */ + staged: { 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`) */ @@ -60,11 +77,51 @@ export interface PublishResult { targetOutcomes: Map; } +/** Combine the results of two pipeline passes (release + post-release phases) */ +export function mergePublishResults(a: PublishResult, b: PublishResult): PublishResult { + const byName = (x: T[], y: T[]) => [ + ...x, + ...y.filter((r) => !x.some((s) => s.name === r.name)), + ]; + const targetOutcomes = new Map(a.targetOutcomes); + for (const [name, outcomes] of b.targetOutcomes) { + targetOutcomes.set(name, [...(targetOutcomes.get(name) ?? []), ...outcomes]); + } + return { + published: byName(a.published, b.published), + staged: byName(a.staged, b.staged), + skipped: byName(a.skipped, b.skipped).filter((s) => !a.published.some((p) => p.name === s.name)), + failed: [ + ...a.failed.filter((f) => !b.failed.some((g) => g.name === f.name)), + ...b.failed.map((f) => { + const prior = a.failed.find((g) => g.name === f.name); + return prior ? { name: f.name, error: `${prior.error}; ${f.error}` } : f; + }), + ], + targetOutcomes, + }; +} + +/** + * Whether a package's run left the version out on some registry: something went live + * or was staged now, or the registry guard found it already live. The release + * orchestration uses this to decide that the version's git tag must exist. + */ +export function releaseShipped(outcomes: TargetOutcome[]): boolean { + return outcomes.some((o) => o.status === 'success' || o.status === 'staged' || o.skipKind === 'registry'); +} + /** * Publish all packages in the release plan. - * 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. + * + * Order: topological across packages (dependencies before dependents), declared + * order across each package's targets. One target failing does not block sibling + * targets on the same package — but it does block the SAME target on dependents: + * `B@jsr` must not go out referencing an `A@jsr` that never landed. Blocked targets + * are recorded as failures and retried on the next run, after the dependency. + * + * The pipeline publishes; it does not tag or touch GitHub releases — the caller + * orchestrates those from the outcomes. */ export async function publishPackages( releasePlan: ReleasePlan, @@ -76,7 +133,7 @@ export async function publishPackages( catalogs: CatalogMap = new Map(), detectedPm: PackageManager = 'npm', ): Promise { - const result: PublishResult = { published: [], skipped: [], failed: [], targetOutcomes: new Map() }; + const result: PublishResult = { published: [], staged: [], skipped: [], failed: [], targetOutcomes: new Map() }; const releaseKind = opts.releaseKind ?? 'stable'; // Topological sort for correct publish order @@ -92,12 +149,17 @@ export async function publishPackages( // Preflight each unique target instance once, before anything publishes. // A preflight throw aborts the whole run — better than failing halfway through. + // Instances are distinct by name AND options: inline entries in different packages + // share a name (it defaults to the type) while carrying different options, and each + // combination needs its own validation (npmStaged, provenance, ...). + const inPhase = (t: ResolvedTarget) => !opts.phase || t.phase === opts.phase; const preflighted = new Set(); for (const release of ordered) { const pkg = packages.get(release.name)!; - for (const target of getPackageTargets(pkg, config)) { - if (preflighted.has(target.name)) continue; - preflighted.add(target.name); + for (const target of getPackageTargets(pkg, config).filter(inPhase)) { + const key = `${target.name}\0${JSON.stringify(target.options)}`; + if (preflighted.has(key)) continue; + preflighted.add(key); await target.plugin.preflight?.({ rootDir, config, @@ -107,26 +169,38 @@ export async function publishPackages( } } + // Targets that failed (or were blocked) this run, per package — dependents consult + // this so a dependency's failure on target T blocks their own publish to T + const failedTargets = new Map>(); + 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' }); - } + const allTargets = getPackageTargets(pkg, config); + const targets = allTargets.filter(inPhase); + const prior = opts.priorStates?.get(release.name) ?? {}; + const buildPass = opts.phase !== 'post-release'; + + // The post-release pass only touches packages with post-release targets + if (!buildPass && targets.length === 0) continue; + + // Private packages with no targets publish nowhere and build nothing + if (allTargets.length === 0 && pkg.private) { + result.skipped.push({ name: release.name, reason: 'private' }); continue; } - log.step(`Publishing ${colorize(release.name, 'cyan')}@${release.newVersion}`); + // A public package with no targets (`publishTargets: []`) still goes through the + // build step below — dependents may bundle its output — and is tracked by git tag + if (targets.length > 0) { + log.step(`Publishing ${colorize(release.name, 'cyan')}@${release.newVersion}`); + } else if (allTargets.length === 0) { + log.step(`Preparing ${colorize(release.name, 'cyan')}@${release.newVersion} (no publish targets)`); + } else { + log.step( + `Building ${colorize(release.name, 'cyan')}@${release.newVersion} (targets run after the release is published)`, + ); + } const outcomes: TargetOutcome[] = []; result.targetOutcomes.set(release.name, outcomes); @@ -134,8 +208,8 @@ export async function publishPackages( const artifacts = new Map(); try { - // 1. Build (once per package, before any target) - if (pkgConfig.buildCommand) { + // 1. Build (once per package, before any target — release pass only) + if (pkgConfig.buildCommand && buildPass) { log.dim(` Building...`); if (!opts.dryRun) { await runStreaming(pkgConfig.buildCommand, { cwd: pkg.dir }); @@ -144,7 +218,8 @@ export async function publishPackages( // 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)); + const needsInPlaceResolve = + buildPass && allTargets.some((t) => t.plugin.needsProtocolResolution?.(t.options, config)); if (needsInPlaceResolve) { // Always write resolved protocols — dryRun only skips the actual publish commands await resolveProtocolsInPlace(pkg, packages, releasePlan, catalogs); @@ -152,56 +227,71 @@ export async function publishPackages( // 3. Publish each target const isPrerelease = semver.prerelease(release.newVersion) !== null; + const inPlanDeps = planDependencies(pkg, releaseMap); for (const target of targets) { - const outcome = await publishOneTarget(target, { - pkg, - pkgConfig, - release, - config, - rootDir, - opts, - releaseKind, - isPrerelease, - completed, - artifacts, - detectedPm, - }); + const blockedBy = inPlanDeps.find((dep) => failedTargets.get(dep)?.has(target.name)); + const outcome = blockedBy + ? blockedOutcome(target, blockedBy) + : await publishOneTarget(target, { + pkg, + pkgConfig, + release, + config, + rootDir, + opts, + releaseKind, + isPrerelease, + prior: prior[target.name], + artifacts, + detectedPm, + }); outcomes.push(outcome); if (outcome.status === 'failed') { log.error(` Failed to publish ${release.name} → ${target.name}: ${outcome.error}`); } } } catch (err) { - // Package-level failure (build / protocol resolution) — no target ran + // Package-level failure (build / protocol resolution) — no target ran, so every + // target counts as failed for dependents 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 }); + failedTargets.set(release.name, new Set(targets.map((t) => t.name))); await cleanupArtifacts(artifacts); continue; } await cleanupArtifacts(artifacts); - // 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); + if (allTargets.length === 0) { + result.skipped.push({ name: release.name, reason: 'no publish targets' }); + continue; } + if (targets.length === 0) continue; // built; its targets run in the post-release pass // Package-level classification + const succeededNow = outcomes.filter((o) => o.status === 'success'); + const stagedNow = outcomes.filter((o) => o.status === 'staged'); + const failedNow = outcomes.filter((o) => o.status === 'failed'); + if (failedNow.length > 0) { + failedTargets.set(release.name, new Set(failedNow.map((o) => o.target))); + } if (succeededNow.length > 0) { result.published.push({ name: release.name, version: release.newVersion }); + } + if (stagedNow.length > 0) { + result.staged.push({ name: release.name, version: release.newVersion }); + } + if (succeededNow.length > 0 || stagedNow.length > 0) { + const shipped = [...succeededNow, ...stagedNow]; const summary = targets.length === 1 ? '' - : ` (${succeededNow.map((o) => o.target).join(', ')}${failedNow.length ? ` — ${failedNow.length} failed` : ''})`; - log.success(` Published ${release.name}@${release.newVersion}${summary}`); + : ` (${shipped.map((o) => o.target).join(', ')}${failedNow.length ? ` — ${failedNow.length} failed` : ''})`; + const verb = succeededNow.length > 0 ? 'Published' : 'Staged'; + log.success(` ${verb} ${release.name}@${release.newVersion}${summary}`); } else if (failedNow.length === 0) { + const alreadyLive = (o: TargetOutcome) => o.skipKind === 'metadata' || o.skipKind === 'registry'; const reason = outcomes.every(alreadyLive) ? 'already published' : (outcomes[0]?.reason ?? 'all targets skipped'); result.skipped.push({ name: release.name, reason }); } @@ -216,6 +306,27 @@ export async function publishPackages( return result; } +/** Runtime dependencies of `pkg` that are part of this release plan (dev deps aren't installed by consumers) */ +function planDependencies(pkg: WorkspacePackage, releaseMap: Map): string[] { + const names = new Set(); + for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies'] as const) { + for (const dep of Object.keys(pkg[field])) { + if (releaseMap.has(dep)) names.add(dep); + } + } + return [...names]; +} + +function blockedOutcome(target: ResolvedTarget, dependency: string): TargetOutcome { + log.dim(` Skipping ${target.name} — dependency ${dependency} failed on ${target.name} this run`); + return { + target: target.name, + type: target.type, + status: 'failed', + error: `blocked: dependency ${dependency} failed on ${target.name} — retried on the next run`, + }; +} + async function publishOneTarget( target: ResolvedTarget, args: { @@ -227,41 +338,50 @@ async function publishOneTarget( opts: PublishOptions; releaseKind: ReleaseKind; isPrerelease: boolean; - completed: Set | undefined; + prior: PublishTargetState | undefined; artifacts: Map; detectedPm: PackageManager; }, ): Promise { - const { pkg, release, config, opts, releaseKind, isPrerelease, completed, artifacts } = args; + const { pkg, release, config, opts, releaseKind, isPrerelease, prior, 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)) { + // Already live per a previous run's release metadata — don't re-publish + if (prior?.status === 'success') { log.dim(` Skipping ${target.name} — already published (per release metadata)`); return { ...base, status: 'skipped', skipKind: 'metadata', reason: 'already published' }; } - // Capability gates + // Capability gates (planners drop packages where no target passes these; this is + // the per-target guard for mixed packages, e.g. npm + marketplace on a prerelease) 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' }; - } - 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' }; + if (!targetSupportsRelease(target, releaseKind, isPrerelease)) { + const snapshotGate = releaseKind === 'snapshot' && !caps.snapshots; + log.dim( + ` Skipping ${target.name} — target does not support ${snapshotGate ? 'snapshot releases' : 'prerelease versions'}`, + ); + const reason = snapshotGate ? 'snapshots not supported' : 'prereleases not supported'; + return { ...base, status: 'skipped', skipKind: 'capability', reason }; } - // 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) { + // The registry is the source of truth for "is it live": ask before every publish. + // Even without release metadata (gh unavailable, draft deleted), never publish a + // version that's already out — registries reject republishes with far less helpful + // errors. Runs before the artifact build so a fully-published package doesn't + // rebuild anything. Also how a staged publish is promoted once approved. + if (!opts.dryRun) { + const live = target.plugin.checkPublished + ? await target.plugin.checkPublished(pkg, release.newVersion, target.options).catch(() => null) + : null; + if (live === true) { log.dim(` Skipping ${target.name} — ${release.newVersion} already on registry`); return { ...base, status: 'skipped', skipKind: 'registry', reason: 'already on registry' }; } + if (prior?.status === 'staged') { + // Staged by a previous run and not live yet — re-staging would be a duplicate + log.dim(` Skipping ${target.name} — staged, awaiting approval${prior.ref ? ` (stage ${prior.ref})` : ''}`); + return { ...base, status: 'skipped', skipKind: 'staged', reason: 'awaiting approval', ref: prior.ref }; + } } try { @@ -301,7 +421,10 @@ async function publishOneTarget( ctx.artifactPath = artifacts.get(kind); } - await target.plugin.publish(ctx); + const hookResult = await target.plugin.publish(ctx); + if (hookResult?.status === 'staged') { + return { ...base, status: 'staged', ref: hookResult.ref }; + } return { ...base, status: 'success' }; } catch (err) { return { ...base, status: 'failed', error: err instanceof Error ? err.message : String(err) }; @@ -321,21 +444,6 @@ async function cleanupArtifacts(artifacts: Map): Promise { artifacts.clear(); } -function createGitTag(release: PlannedRelease, rootDir: string, opts: PublishOptions): void { - if (opts.noTag) return; - const tag = `${release.name}@${release.newVersion}`; - if (opts.dryRun) { - log.dim(` Would create tag: ${tag}`); - return; - } - if (tagExists(tag, { cwd: rootDir })) { - log.dim(` Tag ${tag} already exists, skipping`); - return; - } - createTag(tag, { cwd: rootDir }); - log.dim(` Tagged: ${tag}`); -} - /** * Resolve workspace:/catalog: protocols by rewriting package.json in-place. * Used for custom publish commands and "in-place" protocolResolution mode. diff --git a/packages/bumpy/src/core/release-state.ts b/packages/bumpy/src/core/release-state.ts new file mode 100644 index 0000000..70eaae6 --- /dev/null +++ b/packages/bumpy/src/core/release-state.ts @@ -0,0 +1,99 @@ +import { log } from '../utils/logger.ts'; +import { + composeReleaseBody, + finalizeRelease, + updateReleaseBody, + updateReleaseBodyStatus, + type PublishTargetState, + type ReleaseMetadata, +} from './github-release.ts'; +import { targetLabel } from './targets/registry.ts'; +import type { ResolvedTarget } from './targets/types.ts'; +import type { WorkspacePackage } from '../types.ts'; + +/** + * Per-target release state, shared by the publish flow and any out-of-band + * reconciliation (e.g. promoting staged publishes once they go live). + */ + +/** A GitHub release bumpy manages: its tag, parsed metadata, current body, and draft-ness */ +export interface ReleaseInfo { + tag: string; + metadata: ReleaseMetadata; + existingBody: string | null; + isDraft: boolean; +} + +/** The metadata entry for a target whose version is live on its registry */ +export function liveTargetState( + target: ResolvedTarget, + pkg: WorkspacePackage, + version: string, + repoSlug: string | undefined, +): PublishTargetState { + const label = targetLabel(target, pkg); + return { + status: 'success', + publishedAt: new Date().toISOString(), + url: target.plugin.publishUrl?.(pkg, version, target.options, { repoSlug }), + ...(label !== target.name ? { label } : {}), + }; +} + +/** + * Whether the release can be published: every release-phase target is live (success, + * or skipped — e.g. a marketplace target on a prerelease) and at least one succeeded. + * A `staged` target holds it open. Post-release targets consume the published release + * and never gate it; a package with no release-phase targets is complete immediately. + */ +export function releaseComplete(metadata: ReleaseMetadata, targets: ResolvedTarget[]): boolean { + const gating = targets.filter((t) => t.phase === 'release').map((t) => metadata.targets[t.name]); + if (gating.length === 0) return true; + return ( + gating.every((s) => s?.status === 'success' || s?.status === 'skipped') && + gating.some((s) => s?.status === 'success') + ); +} + +/** + * Write updated metadata back to the GitHub release, then finalize the draft once + * `releaseComplete` holds. + * + * Metadata keys for targets that are no longer configured (renamed/removed mid-release) + * are pruned unless they succeeded — a stale pending/failed entry would otherwise block + * finalization forever. + */ +export async function reconcileRelease( + info: ReleaseInfo, + targets: ResolvedTarget[], + changed: boolean, + rootDir: string, +): Promise { + const currentNames = new Set(targets.map((t) => t.name)); + for (const [name, state] of Object.entries(info.metadata.targets)) { + if (currentNames.has(name) || state.status === 'success') continue; + log.warn( + ` ${info.tag}: dropping "${name}" (${state.status}) from release metadata — target is no longer configured`, + ); + delete info.metadata.targets[name]; + changed = true; + } + if (!changed && !info.isDraft) return; + + try { + if (changed) { + const updatedBody = info.existingBody + ? updateReleaseBodyStatus(info.existingBody, info.metadata) + : composeReleaseBody('', info.metadata); + await updateReleaseBody(info.tag, updatedBody, rootDir); + } + + if (info.isDraft && releaseComplete(info.metadata, targets)) { + await finalizeRelease(info.tag, rootDir); + info.isDraft = false; + log.dim(` Finalized release: ${info.tag}`); + } + } catch (err) { + log.warn(` Failed to update release for ${info.tag}: ${err instanceof Error ? err.message : err}`); + } +} diff --git a/packages/bumpy/src/core/snapshot.ts b/packages/bumpy/src/core/snapshot.ts index 6a76f8d..48c2363 100644 --- a/packages/bumpy/src/core/snapshot.ts +++ b/packages/bumpy/src/core/snapshot.ts @@ -1,7 +1,7 @@ import semver from 'semver'; import { tryRunArgs } from '../utils/shell.ts'; import { fetchPublishedVersions, usesNpmRegistry, npmTargetRegistry } from './prerelease.ts'; -import { packagePublishes } from './targets/registry.ts'; +import { packagePublishesFor } from './targets/registry.ts'; import type { BumpyConfig, ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; /** @@ -129,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 && !packagePublishes(pkg)) return; + if (pkg.private && !packagePublishesFor(pkg, 'snapshot')) return; const target = release.newVersion; // stable target from the bump files const version = snapshotVersion(target, snapshot); diff --git a/packages/bumpy/src/core/targets/custom.ts b/packages/bumpy/src/core/targets/custom.ts index 66aec4a..893c9dc 100644 --- a/packages/bumpy/src/core/targets/custom.ts +++ b/packages/bumpy/src/core/targets/custom.ts @@ -4,13 +4,14 @@ 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. + * for registries without a built-in target. * * Options: * - `command` (string | string[], required) — publish command(s); `{{name}}` and * `{{version}}` are substituted (shell-quoted) * - `checkPublished` (string) — command printing the currently published version + * - `phase` (`"release"` | `"post-release"`, default release) — run after the GitHub + * release is published instead of before (for commands that consume release assets) * * Capabilities are wide open — the user's command owns the semantics, so bumpy * doesn't second-guess prereleases or snapshots here. @@ -36,7 +37,7 @@ export const customTarget: PublishTargetPlugin = { }, async publish(ctx) { - const raw = ctx.options.command ?? ctx.options.publishCommand; + const raw = ctx.options.command; 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`); diff --git a/packages/bumpy/src/core/targets/docker.ts b/packages/bumpy/src/core/targets/docker.ts new file mode 100644 index 0000000..53c363d --- /dev/null +++ b/packages/bumpy/src/core/targets/docker.ts @@ -0,0 +1,130 @@ +import { resolve } from 'node:path'; +import { runArgsAsync, runStreaming, tryRunArgs } from '../../utils/shell.ts'; +import { log } from '../../utils/logger.ts'; +import { shellWord, stringArrayOption, stringMapOption, stringOption, templateString } from './util.ts'; +import type { PublishTargetPlugin, TargetOptions, TargetPublishContext } from './types.ts'; + +/** + * Docker image target: builds and pushes an image tagged with the version (plus + * `latest` for stable releases and the dist-tag for channel/snapshot publishes, so + * `image:next` / `image:pr-123` work like npm dist-tags). + * + * Uses `docker buildx build --push`, which handles multi-platform manifests + * (`platforms`) in one go. Auth is left to the environment (`docker login`, or + * `docker/login-action` in CI) — for GHCR the workflow's `GITHUB_TOKEN` with + * `packages: write` is enough. + * + * Options: + * - `image` (string, required) — e.g. `ghcr.io/dmno-dev/varlock` + * - `context` (string, default `.`) — build context, relative to the package dir + * - `dockerfile` (string) — Dockerfile path, relative to the package dir + * - `platforms` (string[]) — e.g. `["linux/amd64", "linux/arm64"]` + * - `buildArgs` (object) — `--build-arg` values; `{{version}}`/`{{name}}` substituted + * - `tags` (string[]) — extra tags, `{{version}}` substituted + * - `latest` (boolean, default true) — also tag stable releases as `latest` + */ + +function imageName(options: TargetOptions): string { + const image = stringOption(options, 'image'); + if (!image) throw new Error('docker target requires an "image" option (e.g. "ghcr.io/owner/name")'); + return image; +} + +/** Tags this publish applies: version, latest (stable only), dist-tag, extras */ +export function dockerTags(ctx: TargetPublishContext): string[] { + const vars = { version: ctx.version, name: ctx.pkg.name }; + const tags = [ctx.version]; + if (ctx.releaseKind === 'stable' && ctx.options.latest !== false) tags.push('latest'); + if (ctx.distTag) tags.push(ctx.distTag); + for (const extra of stringArrayOption(ctx.options, 'tags')) tags.push(templateString(extra, vars)); + return [...new Set(tags)]; +} + +export function dockerBuildArgs(ctx: TargetPublishContext): string[] { + const image = imageName(ctx.options); + const vars = { version: ctx.version, name: ctx.pkg.name }; + const args = ['docker', 'buildx', 'build', '--push']; + for (const tag of dockerTags(ctx)) args.push('--tag', `${image}:${tag}`); + const platforms = stringArrayOption(ctx.options, 'platforms'); + if (platforms.length > 0) args.push('--platform', platforms.join(',')); + for (const [key, value] of Object.entries(stringMapOption(ctx.options, 'buildArgs'))) { + args.push('--build-arg', `${key}=${templateString(value, vars)}`); + } + const dockerfile = stringOption(ctx.options, 'dockerfile'); + if (dockerfile) args.push('--file', resolve(ctx.pkg.dir, dockerfile)); + args.push(resolve(ctx.pkg.dir, stringOption(ctx.options, 'context') ?? '.')); + return args; +} + +function registryHost(image: string): string { + const first = image.split('/')[0]!; + return first.includes('.') || first.includes(':') ? first : 'docker.io'; +} + +export const dockerTarget: PublishTargetPlugin = { + type: 'docker', + capabilities: { distTags: true, prereleases: true, snapshots: true }, + // Consumes the release (downloads its assets) — runs once it's published + phase: 'post-release', + + label(options) { + const image = stringOption(options, 'image'); + if (!image) return 'Docker'; + const host = registryHost(image); + if (host === 'ghcr.io') return 'GHCR'; + if (host === 'docker.io') return 'Docker Hub'; + return `Docker (${host})`; + }, + + async preflight(ctx) { + imageName(ctx.options); + if (!tryRunArgs(['docker', '--version'])) { + throw new Error('docker target requires the `docker` CLI (with buildx) to build and push images'); + } + }, + + async checkPublished(_pkg, version, options) { + const ref = `${imageName(options)}:${version}`; + try { + // Bounded: a credential helper waiting for a prompt must not hang the release + await runArgsAsync(['docker', 'manifest', 'inspect', ref], { timeoutMs: 60_000 }); + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // A missing tag is a definite "not published"; auth/network errors and timeouts are unknown + return /manifest unknown|not found|no such manifest|MANIFEST_UNKNOWN/i.test(msg) ? false : null; + } + }, + + async publish(ctx) { + const args = dockerBuildArgs(ctx); + if (ctx.dryRun) { + log.dim(` Would build and push with: ${args.join(' ')}`); + return; + } + log.dim(` Building and pushing: ${args.join(' ')}`); + // Stream — image builds are slow and chatty + await runStreaming(args.map(shellWord).join(' '), { cwd: ctx.pkg.dir }); + }, + + publishUrl(_pkg, _version, options, extra) { + const image = stringOption(options, 'image'); + if (!image) return undefined; + const host = registryHost(image); + const path = host === 'docker.io' ? image.replace(/^docker\.io\//, '') : image.slice(host.length + 1); + if (host === 'ghcr.io') { + // ghcr.io// lives under the repo's packages when the owner matches + const [owner, ...rest] = path.split('/'); + const name = rest.join('/'); + if (extra.repoSlug && owner && extra.repoSlug.toLowerCase().startsWith(`${owner.toLowerCase()}/`)) { + return `https://github.com/${extra.repoSlug}/pkgs/container/${encodeURIComponent(name)}`; + } + return undefined; + } + if (host === 'docker.io') { + const [ns, name] = path.includes('/') ? path.split('/') : ['library', path]; + return `https://hub.docker.com/r/${ns}/${name}`; + } + return undefined; + }, +}; diff --git a/packages/bumpy/src/core/targets/github-release-assets.ts b/packages/bumpy/src/core/targets/github-release-assets.ts new file mode 100644 index 0000000..de791cb --- /dev/null +++ b/packages/bumpy/src/core/targets/github-release-assets.ts @@ -0,0 +1,108 @@ +import { resolve } from 'node:path'; +import { basename } from 'node:path'; +import { runArgsAsync } from '../../utils/shell.ts'; +import { log } from '../../utils/logger.ts'; +import { isGhAvailable, withReleaseToken } from '../github-release.ts'; +import type { WorkspacePackage } from '../../types.ts'; +import { expandGlobs, stringArrayOption, templateString } from './util.ts'; +import type { PublishTargetPlugin, TargetOptions } from './types.ts'; + +/** + * GitHub Release assets target: uploads files (CLI binaries, checksums, signatures, …) + * to the package's GitHub release — the `name@version` release bumpy already manages. + * + * The upload goes to the draft, so the release is only published (firing + * `release: published`) once the assets are attached, alongside every other target. + * Files must exist when bumpy publishes: produce them with the package's + * `buildCommand` (or an earlier CI step). Uploads use `--clobber`, so re-running after + * a partial failure replaces what's there. + * + * Options: + * - `files` (string[], required) — globs relative to the package dir; `{{version}}` and + * `{{name}}` are substituted + * + * Auth: the `gh` CLI (`GH_TOKEN` / `BUMPY_GH_TOKEN`, `contents: write`). + */ + +function releaseTag(pkg: WorkspacePackage, version: string): string { + return `${pkg.name}@${version}`; +} + +function assetFiles(pkg: WorkspacePackage, version: string, options: TargetOptions): string[] { + const patterns = stringArrayOption(options, 'files').map((p) => templateString(p, { version, name: pkg.name })); + return expandGlobs(pkg.dir, patterns); +} + +export const githubReleaseAssetsTarget: PublishTargetPlugin = { + type: 'github-release-assets', + // A GitHub release exists for stable and channel versions; snapshots never get one + capabilities: { distTags: false, prereleases: true, snapshots: false }, + + label() { + return 'GitHub Release'; + }, + + async preflight(ctx) { + if (stringArrayOption(ctx.options, 'files').length === 0) { + throw new Error('github-release-assets target requires a "files" option (globs relative to the package dir)'); + } + if (!isGhAvailable()) { + throw new Error('github-release-assets target requires the `gh` CLI (authenticated) to upload assets'); + } + }, + + async checkPublished(pkg, version, options) { + const expected = assetFiles(pkg, version, options).map((f) => basename(f)); + if (expected.length === 0) return null; // not built yet — can't tell + try { + const output = await withReleaseToken(() => + runArgsAsync( + ['gh', 'release', 'view', releaseTag(pkg, version), '--json', 'assets', '--jq', '.assets[].name'], + { + timeoutMs: 60_000, + }, + ), + ); + const present = new Set( + output + .split('\n') + .map((l) => l.trim()) + .filter(Boolean), + ); + return expected.every((name) => present.has(name)); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return /release not found|not found/i.test(msg) ? false : null; + } + }, + + async publish(ctx) { + const files = assetFiles(ctx.pkg, ctx.version, ctx.options); + if (files.length === 0 && ctx.dryRun) { + // Dry runs skip the build, so the assets usually don't exist yet + log.dim( + ` Would upload assets matching ${JSON.stringify(stringArrayOption(ctx.options, 'files'))} to release ${releaseTag(ctx.pkg, ctx.version)}`, + ); + return; + } + if (files.length === 0) { + throw new Error( + `${ctx.pkg.name}: no files matched ${JSON.stringify(stringArrayOption(ctx.options, 'files'))} — ` + + `build them first (e.g. via "buildCommand")`, + ); + } + const tag = releaseTag(ctx.pkg, ctx.version); + const args = ['gh', 'release', 'upload', tag, ...files.map((f) => resolve(ctx.pkg.dir, f)), '--clobber']; + if (ctx.dryRun) { + log.dim(` Would upload ${files.length} asset(s) to release ${tag}: ${files.join(', ')}`); + return; + } + log.dim(` Uploading ${files.length} asset(s) to release ${tag}: ${files.join(', ')}`); + await withReleaseToken(() => runArgsAsync(args, { cwd: ctx.rootDir })); + }, + + publishUrl(pkg, version, _options, extra) { + if (!extra.repoSlug) return undefined; + return `https://github.com/${extra.repoSlug}/releases/tag/${encodeURIComponent(releaseTag(pkg, version))}`; + }, +}; diff --git a/packages/bumpy/src/core/targets/homebrew.ts b/packages/bumpy/src/core/targets/homebrew.ts new file mode 100644 index 0000000..3f24b1b --- /dev/null +++ b/packages/bumpy/src/core/targets/homebrew.ts @@ -0,0 +1,195 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, resolve } from 'node:path'; +import { runArgsAsync, tryRunArgs } from '../../utils/shell.ts'; +import { log } from '../../utils/logger.ts'; +import { isGhAvailable } from '../github-release.ts'; +import type { BumpyConfig, WorkspacePackage } from '../../types.ts'; +import { expandGlobs, stringArrayOption, stringOption, templateString, withEnv } from './util.ts'; +import type { PublishTargetPlugin, TargetOptions } from './types.ts'; + +/** + * Homebrew tap target: renders a formula from a template in the repo, commits it to + * the tap repository, tags the tap commit `name@version`, and pushes. + * + * The template is the user's (formula shape is theirs); bumpy supplies the + * placeholders and the git choreography: + * - `{{version}}`, `{{name}}` + * - `{{sha256 }}` — SHA-256 of a release asset, looked up by basename among the + * `assets` globs (the same files a `github-release-assets` target uploads — put that + * target first so the formula's `url`s resolve) + * + * Options: + * - `tap` (string, required) — the tap repo, e.g. `dmno-dev/homebrew-tap` + * - `template` (string, required) — formula template path, relative to the package dir + * - `formula` (string) — path inside the tap; default `Formula/.rb` + * - `assets` (string[]) — globs (relative to the package dir) that `{{sha256 …}}` searches + * - `tapDir` (string) — an existing checkout of the tap to use instead of cloning + * (relative to the package dir), e.g. from `actions/checkout` with its own token + * + * Auth: pushing to the tap needs a token with write access to THAT repo — a workflow's + * `GITHUB_TOKEN` can't. Set `HOMEBREW_TAP_TOKEN` (falls back to `BUMPY_GH_TOKEN`, + * `GH_TOKEN`); it is handed to git through the environment, never argv. + */ + +function tapSlug(options: TargetOptions): string { + const tap = stringOption(options, 'tap'); + if (!tap || !/^[^/\s]+\/[^/\s]+$/.test(tap)) { + throw new Error('homebrew target requires a "tap" option in owner/repo form (e.g. "dmno-dev/homebrew-tap")'); + } + return tap; +} + +function formulaPath(pkg: WorkspacePackage, options: TargetOptions): string { + return stringOption(options, 'formula') ?? `Formula/${basename(pkg.name)}.rb`; +} + +function tapToken(): string | undefined { + return process.env.HOMEBREW_TAP_TOKEN || process.env.BUMPY_GH_TOKEN || process.env.GH_TOKEN || undefined; +} + +/** Git env that authenticates github.com over https without putting the token in argv (what actions/checkout does) */ +function gitAuthEnv(): Record { + const token = tapToken(); + if (!token) return {}; + const basic = Buffer.from(`x-access-token:${token}`).toString('base64'); + return { + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', + GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basic}`, + }; +} + +/** Parse the `version "x.y.z"` line out of a formula */ +export function formulaVersion(formula: string): string | undefined { + return formula.match(/^\s*version\s+"([^"]+)"/m)?.[1]; +} + +/** Render a formula template: `{{version}}`, `{{name}}`, `{{sha256 }}` */ +export function renderFormula( + template: string, + vars: { version: string; name: string }, + sha256For: (file: string) => string, +): string { + const withHashes = template.replace(/\{\{\s*sha256\s+([^}\s]+)\s*\}\}/g, (_m, file: string) => sha256For(file)); + return templateString(withHashes, vars); +} + +function assetSha256Lookup(pkg: WorkspacePackage, options: TargetOptions): (file: string) => string { + const files = expandGlobs(pkg.dir, stringArrayOption(options, 'assets')); + return (file) => { + const match = files.find((f) => f === file || basename(f) === file); + if (!match) { + throw new Error( + `${pkg.name}: formula template references {{sha256 ${file}}} but no such file matched the "assets" globs ` + + `(${JSON.stringify(stringArrayOption(options, 'assets'))}) — build the release assets before publishing`, + ); + } + return createHash('sha256') + .update(readFileSync(resolve(pkg.dir, match))) + .digest('hex'); + }; +} + +async function git(args: string[], cwd: string, config: BumpyConfig): Promise { + const identity = ['-c', `user.name=${config.gitUser.name}`, '-c', `user.email=${config.gitUser.email}`]; + return withEnv(gitAuthEnv(), () => runArgsAsync(['git', ...identity, ...args], { cwd })); +} + +export const homebrewTarget: PublishTargetPlugin = { + type: 'homebrew', + // Formulas track stable versions only + capabilities: { distTags: false, prereleases: false, snapshots: false }, + // Consumes the release (downloads its assets) — runs once it's published + phase: 'post-release', + + label() { + return 'Homebrew'; + }, + + async preflight(ctx) { + tapSlug(ctx.options); + if (!stringOption(ctx.options, 'template')) { + throw new Error( + 'homebrew target requires a "template" option (formula template path, relative to the package dir)', + ); + } + if (!tryRunArgs(['git', '--version'])) throw new Error('homebrew target requires git'); + if (!ctx.dryRun && !ctx.options.tapDir && !tapToken()) { + log.warn(' No HOMEBREW_TAP_TOKEN set — pushing to the tap will need git credentials from the environment'); + } + }, + + async checkPublished(pkg, version, options) { + if (!isGhAvailable()) return null; + try { + const output = await runArgsAsync( + ['gh', 'api', `repos/${tapSlug(options)}/contents/${formulaPath(pkg, options)}`, '--jq', '.content'], + { timeoutMs: 60_000 }, + ); + const formula = Buffer.from(output.replace(/\s/g, ''), 'base64').toString('utf-8'); + return formulaVersion(formula) === version; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return /404|Not Found/i.test(msg) ? false : null; + } + }, + + async prepare(ctx) { + const template = resolve(ctx.pkg.dir, stringOption(ctx.options, 'template')!); + if (!existsSync(template)) { + throw new Error(`${ctx.pkg.name}: homebrew formula template not found at ${template}`); + } + }, + + async publish(ctx) { + const tap = tapSlug(ctx.options); + const formulaRel = formulaPath(ctx.pkg, ctx.options); + const template = readFileSync(resolve(ctx.pkg.dir, stringOption(ctx.options, 'template')!), 'utf-8'); + const tag = `${ctx.pkg.name}@${ctx.version}`; + + if (ctx.dryRun) { + // Dry runs skip the build, so assets (and their checksums) usually don't exist yet — + // render with placeholder hashes to validate the template + renderFormula(template, { version: ctx.version, name: ctx.pkg.name }, (file) => ``); + log.dim(` Would update ${tap}/${formulaRel} to ${ctx.version} and tag ${tag}`); + return; + } + + const rendered = renderFormula( + template, + { version: ctx.version, name: ctx.pkg.name }, + assetSha256Lookup(ctx.pkg, ctx.options), + ); + + let tapDir = stringOption(ctx.options, 'tapDir'); + if (tapDir) { + tapDir = resolve(ctx.pkg.dir, tapDir); + } else { + tapDir = mkdtempSync(resolve(tmpdir(), 'bumpy-homebrew-')); + log.dim(` Cloning ${tap}...`); + await git(['clone', '--depth', '1', `https://github.com/${tap}.git`, tapDir], ctx.rootDir, ctx.config); + } + + const formulaAbs = resolve(tapDir, formulaRel); + mkdirSync(dirname(formulaAbs), { recursive: true }); + writeFileSync(formulaAbs, rendered); + await git(['add', formulaRel], tapDir, ctx.config); + const staged = await git(['status', '--porcelain', '--', formulaRel], tapDir, ctx.config); + if (staged.trim()) { + await git(['commit', '-m', tag], tapDir, ctx.config); + } else { + log.dim(` ${formulaRel} already at ${ctx.version} — nothing to commit`); + } + if (!(await git(['tag', '-l', tag], tapDir, ctx.config)).trim()) { + await git(['tag', tag], tapDir, ctx.config); + } + log.dim(` Pushing ${tap} (${formulaRel} → ${ctx.version}, tag ${tag})`); + await git(['push', 'origin', 'HEAD', '--tags'], tapDir, ctx.config); + }, + + publishUrl(pkg, _version, options) { + return `https://github.com/${tapSlug(options)}/blob/HEAD/${formulaPath(pkg, options)}`; + }, +}; diff --git a/packages/bumpy/src/core/targets/jsr.ts b/packages/bumpy/src/core/targets/jsr.ts index 31e7edf..42d1484 100644 --- a/packages/bumpy/src/core/targets/jsr.ts +++ b/packages/bumpy/src/core/targets/jsr.ts @@ -1,5 +1,5 @@ import { resolve } from 'node:path'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { readJson, updateJsonFields } from '../../utils/fs.ts'; import { runArgsAsync } from '../../utils/shell.ts'; import { log } from '../../utils/logger.ts'; @@ -39,9 +39,33 @@ import type { PublishTargetPlugin } from './types.ts'; 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; +function jsrJsonPath(pkg: WorkspacePackage): string { + return resolve(pkg.dir, 'jsr.json'); +} + +/** + * The name the package publishes under on JSR: jsr.json's `name` when it declares one, + * otherwise the npm name. JSR scopes are a separate namespace from npm's, so the two + * legitimately differ (`@acme/foo` on npm, `@acme-js/foo` on JSR) — every registry + * query must use the JSR identity, never package.json's. + */ +export function jsrPackageName(pkg: WorkspacePackage): string { + const path = jsrJsonPath(pkg); + if (existsSync(path)) { + try { + const { name } = JSON.parse(readFileSync(path, 'utf-8')) as { name?: unknown }; + if (typeof name === 'string' && name) return name; + } catch { + // unreadable jsr.json — prepare() reports it; fall back to the npm name here + } + } + return pkg.name; +} + +function scopeAndName(pkg: WorkspacePackage): { full: string; scope: string; name: string } | null { + const full = jsrPackageName(pkg); + const match = full.match(/^@([^/]+)\/(.+)$/); + return match ? { full, scope: match[1]!, name: match[2]! } : null; } /** GET a JSR API path; returns the response status or null on network failure */ @@ -82,10 +106,6 @@ export const jsrTarget: PublishTargetPlugin = { }, 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( @@ -93,13 +113,20 @@ export const jsrTarget: PublishTargetPlugin = { `bumpy syncs it at publish time)`, ); } + const id = scopeAndName(ctx.pkg); + if (!id) { + throw new Error( + `${ctx.pkg.name}: JSR packages must be scoped (@scope/name) — set "name" in jsr.json ` + + `(currently "${jsrPackageName(ctx.pkg)}")`, + ); + } // 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 ` + + `${id.full} 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`, ); } @@ -131,6 +158,6 @@ export const jsrTarget: PublishTargetPlugin = { }, publishUrl(pkg, version) { - return buildPublishUrl(pkg.name, version, 'jsr'); + return buildPublishUrl(jsrPackageName(pkg), version, 'jsr'); }, }; diff --git a/packages/bumpy/src/core/targets/npm.ts b/packages/bumpy/src/core/targets/npm.ts index 806207f..90f8fd3 100644 --- a/packages/bumpy/src/core/targets/npm.ts +++ b/packages/bumpy/src/core/targets/npm.ts @@ -164,14 +164,40 @@ function getPackArgs(pm: PackageManager): string[] { } } +/** + * Whether this publish goes through npm staged publishing. Snapshots never stage: + * they are throwaway previews that must be installable immediately. + */ +function isStagedPublish(ctx: TargetPublishContext): boolean { + const o = npmOptions(ctx.config, ctx.options); + return !!o.npmStaged && o.publishManager === 'npm' && ctx.releaseKind !== 'snapshot'; +} + +/** + * Parse the stage id (a UUID) from `npm stage publish --json` output. + * npm returns `{ pkg: { name, version, stageId } }`; tolerate minor shape + * variations (array wrapper, top-level stageId) and return undefined if absent. + */ +export function parseStageId(output: string): string | undefined { + try { + const parsed = JSON.parse(output); + const entry = Array.isArray(parsed) ? parsed[0] : parsed; + const stageId = entry?.pkg?.stageId ?? entry?.stageId; + return typeof stageId === 'string' && stageId ? stageId : undefined; + } catch { + return undefined; + } +} + 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'); + if (isStagedPublish(ctx)) { + // `--json` yields `{ pkg: { stageId } }` so the stage id can be recorded in the release + args.push('npm', 'stage', 'publish', '--json'); } else if (publishManager === 'yarn') { args.push('yarn', 'npm', 'publish'); } else { @@ -289,7 +315,7 @@ export const npmTarget: PublishTargetPlugin = { 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); + const result = await runArgsAsync(args, { timeoutMs: 60_000 }); return result.trim() === version; } catch { return false; @@ -322,7 +348,13 @@ export const npmTarget: PublishTargetPlugin = { return; } log.dim(` Publishing: ${args.join(' ')}`); - await runArgsAsync(args, { cwd: ctx.pkg.dir }); + const output = await runArgsAsync(args, { cwd: ctx.pkg.dir }); + if (isStagedPublish(ctx)) { + // Staged on npmjs.com, not live — the release stays a draft until it's approved + const ref = parseStageId(output); + log.dim(` Staged on npm — awaiting 2FA approval${ref ? ` (stage ${ref})` : ''}`); + return { status: 'staged', ref }; + } }, publishUrl(pkg, version, options, extra) { diff --git a/packages/bumpy/src/core/targets/registry.ts b/packages/bumpy/src/core/targets/registry.ts index c13c20e..6ed9bc2 100644 --- a/packages/bumpy/src/core/targets/registry.ts +++ b/packages/bumpy/src/core/targets/registry.ts @@ -4,6 +4,9 @@ import { customTarget } from './custom.ts'; import { jsrTarget } from './jsr.ts'; import { pypiTarget } from './pypi.ts'; import { vscodeMarketplaceTarget, openVsxTarget } from './vscode.ts'; +import { githubReleaseAssetsTarget } from './github-release-assets.ts'; +import { dockerTarget } from './docker.ts'; +import { homebrewTarget } from './homebrew.ts'; import type { BumpyConfig, PackageConfig, @@ -11,7 +14,7 @@ import type { TargetDefinition, WorkspacePackage, } from '../../types.ts'; -import type { PublishTargetPlugin, ResolvedTarget, TargetOptions } from './types.ts'; +import type { PublishTargetPlugin, ReleaseKind, ResolvedTarget, TargetOptions, TargetPhase } from './types.ts'; /** * Built-in publish targets. These register through the same interface external @@ -25,6 +28,9 @@ const BUILT_IN_TARGETS: Record = { [pypiTarget.type]: pypiTarget, [vscodeMarketplaceTarget.type]: vscodeMarketplaceTarget, [openVsxTarget.type]: openVsxTarget, + [githubReleaseAssetsTarget.type]: githubReleaseAssetsTarget, + [dockerTarget.type]: dockerTarget, + [homebrewTarget.type]: homebrewTarget, }; export function getTargetPlugin(type: string): PublishTargetPlugin | undefined { @@ -45,6 +51,20 @@ function requirePlugin(type: string, context: string): PublishTargetPlugin { return plugin; } +/** The phase an instance runs in: its `phase` option, else the plugin's default */ +function resolvePhase(plugin: PublishTargetPlugin, options: TargetOptions): TargetPhase { + const override = options.phase; + if (override === 'release' || override === 'post-release') return override; + if (override !== undefined) { + throw new Error(`Invalid target "phase" ${JSON.stringify(override)} — expected "release" or "post-release"`); + } + return plugin.phase ?? 'release'; +} + +function instance(name: string, type: string, plugin: PublishTargetPlugin, options: TargetOptions): ResolvedTarget { + return { name, type, plugin, options, phase: resolvePhase(plugin, options) }; +} + /** Options from a root `targets` map entry, minus the structural `type` key */ function definitionOptions(def: TargetDefinition | undefined): TargetOptions { if (!def) return {}; @@ -52,18 +72,11 @@ function definitionOptions(def: TargetDefinition | undefined): TargetOptions { 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 + // A key matching a built-in type names an instance of that type (the entry, if any, + // holds that instance's options — nothing is inherited by other instances) if (BUILT_IN_TARGETS[ref]) { if (def?.type && def.type !== ref) { throw new Error( @@ -71,7 +84,7 @@ function resolveStringEntry(ref: string, config: BumpyConfig | undefined, pkgNam `rename the entry to define a separate named instance`, ); } - return { name: ref, type: ref, plugin: BUILT_IN_TARGETS[ref], options: definitionOptions(def) }; + return instance(ref, ref, BUILT_IN_TARGETS[ref], definitionOptions(def)); } // Named instance from the root targets map @@ -80,12 +93,7 @@ function resolveStringEntry(ref: string, config: BumpyConfig | undefined, pkgNam 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) }, - }; + return instance(ref, def.type, plugin, definitionOptions(def)); } if (!config) { @@ -110,45 +118,13 @@ function resolveInlineEntry( } 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) }]; + return instance(typeof name === 'string' && name ? name : type, type, plugin, options); } /** * Resolve the publish targets for a package: explicit `publishTargets` config if - * present, otherwise derived from the legacy fields / implicit npm default. + * present, otherwise the implicit default — npm for public packages, nothing for + * private ones. * * 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 @@ -159,14 +135,7 @@ export function resolvePackageTargets( 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 entries = pkgConfig.publishTargets ?? (pkg.private ? [] : ['npm']); const resolved: ResolvedTarget[] = []; for (const entry of entries) { @@ -207,6 +176,28 @@ export function packagePublishes(pkg: WorkspacePackage, config?: BumpyConfig): b return getPackageTargets(pkg, config).length > 0; } +/** + * Whether a target participates in this kind of release — the capability gates. + * Shared by the pipeline (which records a `capability` skip) and the planners (which + * drop packages nothing can publish, so no draft release is ever opened for them). + */ +export function targetSupportsRelease( + target: ResolvedTarget, + releaseKind: ReleaseKind, + isPrerelease: boolean, +): boolean { + const caps = target.plugin.capabilities; + if (releaseKind === 'snapshot' && !caps.snapshots) return false; + if (isPrerelease && !caps.prereleases) return false; + return true; +} + +/** Whether any of the package's targets can publish this kind of release (channel/snapshot versions are always prereleases) */ +export function packagePublishesFor(pkg: WorkspacePackage, releaseKind: ReleaseKind, config?: BumpyConfig): boolean { + const isPrerelease = releaseKind !== 'stable'; + return getPackageTargets(pkg, config).some((t) => targetSupportsRelease(t, releaseKind, isPrerelease)); +} + /** 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'); diff --git a/packages/bumpy/src/core/targets/types.ts b/packages/bumpy/src/core/targets/types.ts index 7416090..b224c2b 100644 --- a/packages/bumpy/src/core/targets/types.ts +++ b/packages/bumpy/src/core/targets/types.ts @@ -5,6 +5,16 @@ export type TargetOptions = Record; export type ReleaseKind = 'stable' | 'channel' | 'snapshot'; +/** + * When a target runs relative to the GitHub release: + * - `release`: constitutes the release — the draft is held until it's done (npm, jsr, + * marketplaces, release assets) + * - `post-release`: consumes the release — runs only once it's published, because it + * needs public release URLs (a Homebrew formula pointing at release assets, a + * Dockerfile that downloads them). Draft release assets aren't downloadable. + */ +export type TargetPhase = 'release' | 'post-release'; + export interface TargetCapabilities { /** Supports npm-style dist-tags (`--tag next`) */ distTags: boolean; @@ -66,6 +76,8 @@ export interface TargetPublishContext { export interface PublishTargetPlugin { type: string; capabilities: TargetCapabilities; + /** Default phase for instances of this plugin (an instance can override with a `phase` option). Default: `release`. */ + phase?: TargetPhase; /** 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. */ @@ -80,8 +92,10 @@ export interface PublishTargetPlugin { */ prepare?(ctx: TargetPublishContext): void | Promise; /** - * Whether `version` is already live on this target. - * Return null for "unknown" (caller falls back to git-tag tracking). + * Whether `version` is already live on this target. The registry is the source of + * truth: the pipeline asks before every publish (idempotency guard) and to promote + * `staged` targets once they go live. Return null for "unknown" (caller falls back + * to release metadata / git-tag tracking). */ checkPublished?(pkg: WorkspacePackage, version: string, options: TargetOptions): Promise; /** @@ -98,7 +112,7 @@ export interface PublishTargetPlugin { * commands and vsce, need this; npm's pack flow handles it in the tarball). */ needsProtocolResolution?(options: TargetOptions, config: BumpyConfig): boolean; - publish(ctx: TargetPublishContext): Promise; + publish(ctx: TargetPublishContext): Promise; /** Browsable URL for a published version, used in release notes. */ publishUrl?( pkg: WorkspacePackage, @@ -108,6 +122,15 @@ export interface PublishTargetPlugin { ): string | undefined; } +/** + * What `publish()` reports back. `void` means the version is live. `staged` means the + * registry accepted the artifact but holds it for an out-of-band step (npm staged + * publishing's 2FA approval, a marketplace review queue, ...): the release stays a + * draft and the target is re-checked with `checkPublished` on later runs until it is + * live. `ref` is the registry's handle for the pending item (e.g. the npm stage id). + */ +export type PublishHookResult = void | { status: 'staged'; ref?: string }; + /** A target instance resolved for a specific package: plugin + merged options + stable name */ export interface ResolvedTarget { /** @@ -118,4 +141,5 @@ export interface ResolvedTarget { type: string; plugin: PublishTargetPlugin; options: TargetOptions; + phase: TargetPhase; } diff --git a/packages/bumpy/src/core/targets/util.ts b/packages/bumpy/src/core/targets/util.ts index b9ca397..cc02293 100644 --- a/packages/bumpy/src/core/targets/util.ts +++ b/packages/bumpy/src/core/targets/util.ts @@ -1,3 +1,6 @@ +import { readdirSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import picomatch from 'picomatch'; import type { TargetOptions } from './types.ts'; /** Coerce a target option to a string array (options are untyped user config) */ @@ -11,3 +14,78 @@ export function stringOption(options: TargetOptions, key: string): string | unde const value = options[key]; return typeof value === 'string' && value ? value : undefined; } + +/** Coerce a target option to a string→string map (e.g. build args), or an empty map */ +export function stringMapOption(options: TargetOptions, key: string): Record { + const value = options[key]; + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries(Object.entries(value as Record).map(([k, v]) => [k, String(v)])); +} + +/** Substitute `{{name}}`-style placeholders. Unknown placeholders are left as-is. */ +export function templateString(input: string, vars: Record): string { + return input.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key: string) => vars[key] ?? match); +} + +/** + * Expand glob patterns (picomatch syntax) relative to `dir`, returning matched file + * paths relative to `dir` in a stable order. Walks the tree once; ignores node_modules. + */ +export function expandGlobs(dir: string, patterns: string[]): string[] { + if (patterns.length === 0) return []; + const isMatch = picomatch(patterns, { dot: true }); + const files: string[] = []; + const walk = (current: string) => { + let entries: string[]; + try { + entries = readdirSync(current); + } catch { + return; + } + for (const entry of entries) { + if (entry === 'node_modules' || entry === '.git') continue; + const full = join(current, entry); + let isDir = false; + try { + isDir = statSync(full).isDirectory(); + } catch { + continue; + } + if (isDir) { + walk(full); + } else { + const rel = relative(dir, full).split(sep).join('/'); + if (isMatch(rel)) files.push(rel); + } + } + }; + walk(dir); + return files.sort(); +} + +/** Quote a shell word only when it needs it (keeps logged/mocked commands readable) */ +export function shellWord(value: string): string { + return /^[\w@%+=:,./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\\''") + "'"; +} + +/** + * Run `fn` with extra environment variables set on the process, restoring the previous + * values afterwards. Used to hand credentials to child processes without putting them + * in argv (where they would show up in logs and process listings). + */ +export async function withEnv(vars: Record, fn: () => Promise): Promise { + const previous = new Map(); + for (const [key, value] of Object.entries(vars)) { + previous.set(key, process.env[key]); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return await fn(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} diff --git a/packages/bumpy/src/core/targets/vscode.ts b/packages/bumpy/src/core/targets/vscode.ts index 6adfdcf..c6511b0 100644 --- a/packages/bumpy/src/core/targets/vscode.ts +++ b/packages/bumpy/src/core/targets/vscode.ts @@ -60,10 +60,19 @@ async function buildVsix(ctx: TargetPublishContext): Promise { /** 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 { + const versions = await fetchPublishedVersions(args, (json) => [extract(json)]); + return versions?.[0] ?? null; +} + +/** Run a CLI and parse a list of published versions out of its JSON output. Null = unknown. */ +async function fetchPublishedVersions( + args: string[], + extract: (json: unknown) => unknown[] | undefined, +): Promise { try { - const output = await runArgsAsync(args); - const version = extract(JSON.parse(output)); - return typeof version === 'string' && version ? version : null; + const output = await runArgsAsync(args, { timeoutMs: 120_000 }); // npx may install the CLI first + const versions = (extract(JSON.parse(output)) ?? []).filter((v): v is string => typeof v === 'string' && !!v); + return versions.length > 0 ? versions : null; } catch { return null; } @@ -90,11 +99,12 @@ export const vscodeMarketplaceTarget: PublishTargetPlugin = { 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, + // `show` lists every published version — match against all of them, not just the + // latest, so retrying an older release after a newer one shipped still reads as live + const versions = await fetchPublishedVersions([...VSCE_BIN, 'show', extensionId(pkg), '--json'], (json) => + (json as { versions?: Array<{ version?: unknown }> })?.versions?.map((v) => v.version), ); - return published === null ? null : published === version; + return versions === null ? null : versions.includes(version); }, artifactKind() { @@ -143,12 +153,16 @@ export const openVsxTarget: PublishTargetPlugin = { 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; + const id = extensionId(pkg); + const metadataVersion = (json: unknown) => (json as { version?: unknown })?.version; + // `get --metadata` describes one version (the latest by default). Fall back to + // asking for the exact version so an older-but-live release isn't mistaken for + // unpublished; ovsx exits non-zero for a version that doesn't exist. + const latest = await fetchPublishedVersion([...OVSX_BIN, 'get', id, '--metadata'], metadataVersion); + if (latest === null) return null; + if (latest === version) return true; + const exact = await fetchPublishedVersion([...OVSX_BIN, 'get', `${id}@${version}`, '--metadata'], metadataVersion); + return exact === version; }, artifactKind() { diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index 5de3112..8497d2c 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -65,12 +65,11 @@ 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. + * A named target instance in the root config's `targets` map. The key is the instance + * name (used in release metadata and `publishTargets` references); `type` names the + * target plugin and may be omitted when the key itself is a built-in type name + * (`"npm": { "provenance": true }`). Remaining fields are the instance's options. + * Instances are complete on their own — nothing is inherited between them. */ export interface TargetDefinition { type?: string; @@ -199,21 +198,20 @@ export interface BumpyConfig { dependencyBumpRules: Partial>; privatePackages: { version: boolean; tag: boolean }; /** - * Allow per-package custom commands (buildCommand, publishCommand, checkPublished) - * defined in package.json "bumpy" fields. - * Commands defined in the root config's `packages` map are always trusted. + * Allow a package's own package.json "bumpy" config to steer publishing: a + * `buildCommand`, or inline `publishTargets` entries (objects with options) rather + * than plain name references. Everything in the root config is always trusted. * - * true = allow all packages to define custom commands + * true = allow all packages * string[] = allow only matching package names/globs - * false = only root-config commands are allowed (default) + * false = package.json may only reference targets by name (default) */ 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}. + * Named, reusable publish target instances, referenced from `publishTargets` by key. + * See {@link TargetDefinition}. */ targets: Record; /** Git identity used for CI commits. Defaults to bumpy-bot. */ @@ -234,19 +232,12 @@ export interface PackageConfig { 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}. + * Publish targets for this package. Defaults to `["npm"]` for public packages and + * `[]` for private ones. 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; /** Glob patterns to filter which changed files count toward marking this package as changed */ changedFilePatterns?: string[]; dependencyBumpRules?: Partial>; @@ -368,8 +359,8 @@ export interface WorkspacePackage { 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 + * Publish targets resolved at workspace discovery (from `publishTargets` config 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. diff --git a/packages/bumpy/src/utils/shell.ts b/packages/bumpy/src/utils/shell.ts index 01bcf10..ae27657 100644 --- a/packages/bumpy/src/utils/shell.ts +++ b/packages/bumpy/src/utils/shell.ts @@ -28,6 +28,11 @@ function checkIntercept(args: string[], opts?: { cwd?: string; input?: string }) return _interceptor(args, opts); } +// Every spawn passes `env: process.env` explicitly: under Bun a child with no `env` +// inherits the process's *initial* environment, not the live `process.env`, so the +// credentials helpers that set GH_TOKEN / git config vars for one call would silently +// not reach the child. + // ---- String-based commands (for static/trusted command strings only) ---- export function run(cmd: string, opts?: { cwd?: string; input?: string }): string { @@ -38,6 +43,7 @@ export function run(cmd: string, opts?: { cwd?: string; input?: string }): strin } return execSync(cmd, { cwd: opts?.cwd, + env: process.env, input: opts?.input, encoding: 'utf-8', stdio: [opts?.input ? 'pipe' : 'pipe', 'pipe', 'pipe'], @@ -51,7 +57,7 @@ export function runAsync(cmd: string, opts?: { cwd?: string; input?: string }): return Promise.resolve(result.result); } return new Promise((resolve, reject) => { - const child = exec(cmd, { cwd: opts?.cwd, encoding: 'utf-8' }, (err, stdout, stderr) => { + const child = exec(cmd, { cwd: opts?.cwd, env: process.env, encoding: 'utf-8' }, (err, stdout, stderr) => { if (err) { reject(new Error(`Command failed: ${cmd}\n${stdout}\n${stderr}`.trim())); } else { @@ -85,6 +91,7 @@ export function runStreaming(cmd: string, opts?: { cwd?: string; input?: string return new Promise((resolve, reject) => { const child = spawn(cmd, { cwd: opts?.cwd, + env: process.env, shell: true, stdio: [opts?.input ? 'pipe' : 'inherit', 'pipe', 'pipe'], }); @@ -134,14 +141,22 @@ export function runArgs(args: string[], opts?: { cwd?: string; input?: string }) const [cmd, ...rest] = args; return execFileSync(cmd!, rest, { cwd: opts?.cwd, + env: process.env, input: opts?.input, encoding: 'utf-8', stdio: [opts?.input ? 'pipe' : 'pipe', 'pipe', 'pipe'], }).trim(); } -/** Async version of runArgs */ -export function runArgsAsync(args: string[], opts?: { cwd?: string; input?: string }): Promise { +/** + * Async version of runArgs. `timeoutMs` kills the child (SIGKILL) and rejects — use it for + * anything that may block on a credential helper or a slow registry, so a probe can + * never hang a release. + */ +export function runArgsAsync( + args: string[], + opts?: { cwd?: string; input?: string; timeoutMs?: number }, +): Promise { const result = checkIntercept(args, opts); if (result?.intercepted) { if ('error' in result) return Promise.reject(new Error(result.error)); @@ -149,13 +164,20 @@ export function runArgsAsync(args: string[], opts?: { cwd?: string; input?: stri } const [cmd, ...rest] = args; return new Promise((resolve, reject) => { - const child = execFile(cmd!, rest, { cwd: opts?.cwd, encoding: 'utf-8' }, (err, stdout, stderr) => { - if (err) { - reject(new Error(`Command failed: ${args.join(' ')}\n${stdout}\n${stderr}`.trim())); - } else { - resolve(stdout.trim()); - } - }); + const child = execFile( + cmd!, + rest, + { cwd: opts?.cwd, env: process.env, encoding: 'utf-8', timeout: opts?.timeoutMs, killSignal: 'SIGKILL' }, + (err, stdout, stderr) => { + if (err) { + const timedOut = !!opts?.timeoutMs && (err as { killed?: boolean }).killed === true; + const why = timedOut ? `timed out after ${opts!.timeoutMs}ms` : 'failed'; + reject(new Error(`Command ${why}: ${args.join(' ')}\n${stdout}\n${stderr}`.trim())); + } else { + resolve(stdout.trim()); + } + }, + ); if (opts?.input) { child.stdin?.write(opts.input); child.stdin?.end(); diff --git a/packages/bumpy/test/core/config.test.ts b/packages/bumpy/test/core/config.test.ts index 493971e..689dcd0 100644 --- a/packages/bumpy/test/core/config.test.ts +++ b/packages/bumpy/test/core/config.test.ts @@ -73,4 +73,12 @@ describe('isPackageManaged', () => { test('per-package managed: false overrides everything', () => { expect(isPackageManaged('pkg-a', false, makeConfig({ include: ['pkg-a'] }), { managed: false })).toBe(false); }); + + test('private package with explicit publishTargets is managed; with none it follows privatePackages.version', () => { + const config = makeConfig({ privatePackages: { version: false, tag: false } }); + expect(isPackageManaged('ext', true, config, { publishTargets: ['vscode-marketplace'] })).toBe(true); + expect(isPackageManaged('ext', true, config, { publishTargets: [] })).toBe(false); + expect(isPackageManaged('app', true, config, {})).toBe(false); + expect(isPackageManaged('ext', true, config, { managed: false, publishTargets: ['open-vsx'] })).toBe(false); + }); }); diff --git a/packages/bumpy/test/core/prerelease.test.ts b/packages/bumpy/test/core/prerelease.test.ts index 27cbba1..67276ab 100644 --- a/packages/bumpy/test/core/prerelease.test.ts +++ b/packages/bumpy/test/core/prerelease.test.ts @@ -147,7 +147,7 @@ describe('channelDisplayPlan', () => { expect(display.releases.map((r) => r.newVersion)).toEqual(['1.2.0-rc.x']); }); - test('drops unpublishable packages, keeps private ones with a publishCommand', () => { + test('drops unpublishable packages, keeps private ones with a publish target', () => { const plan = makeReleasePlan([ makeRelease('core', '1.2.0'), makeRelease('internal', '0.5.0'), @@ -156,7 +156,13 @@ describe('channelDisplayPlan', () => { const packages = new Map([ ['core', makePkg('core', '1.1.0')], ['internal', makePkg('internal', '0.4.0', { private: true })], - ['cli', makePkg('cli', '1.9.0', { private: true, bumpy: { publishCommand: 'cargo publish' } })], + [ + 'cli', + makePkg('cli', '1.9.0', { + private: true, + bumpy: { publishTargets: [{ type: 'custom', command: 'cargo publish' }] }, + }), + ], ]); const display = channelDisplayPlan(plan, channel, packages); expect(display.releases.map((r) => r.name)).toEqual(['core', 'cli']); diff --git a/packages/bumpy/test/core/publish-multi-target.test.ts b/packages/bumpy/test/core/publish-multi-target.test.ts index c61fea5..4633761 100644 --- a/packages/bumpy/test/core/publish-multi-target.test.ts +++ b/packages/bumpy/test/core/publish-multi-target.test.ts @@ -6,7 +6,9 @@ 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 { publishPackages, releaseShipped, mergePublishResults } from '../../src/core/publish-pipeline.ts'; +import { releaseComplete } from '../../src/core/release-state.ts'; +import { resolvePackageTargets } from '../../src/core/targets/registry.ts'; import type { WorkspacePackage, ReleasePlan, PlannedRelease } from '../../src/types.ts'; import { DEFAULT_CONFIG } from '../../src/types.ts'; @@ -106,11 +108,80 @@ describe('publishPackages — multi-target', () => { // 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'); + // One target went out → the version shipped from this commit (the flow tags it) + expect(releaseShipped(outcomes)).toBe(true); }); - test('completedTargets skips already-published targets (per-target resume)', async () => { + test('custom target only honors "command" — no alias spelling ever runs', async () => { + const pkgDir = await setupPkg('alias'); + const pkg = makePkg('alias', '1.0.0', { + dir: pkgDir, + bumpy: { publishTargets: [{ type: 'custom', name: 'sneaky', publishCommand: 'echo sneaky' }] }, + }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const outcomes = result.targetOutcomes.get('alias')!; + expect(outcomes[0]!.status).toBe('failed'); + expect(outcomes[0]!.error).toMatch(/no "command" configured/); + expect(getCallsMatching('echo sneaky')).toHaveLength(0); + }); + + test('preflight validates every distinct instance, not just the first one sharing a name', async () => { + // Both inline entries are named "npm" (the type) but carry different options — + // the second one's npmStaged validation must still run and abort the whole run + const dirA = await setupPkg('plain'); + const dirB = await setupPkg('staged'); + const plain = makePkg('plain', '1.0.0', { dir: dirA, bumpy: { publishTargets: [{ type: 'npm' }] } }); + const staged = makePkg('staged', '1.0.0', { + dir: dirB, + bumpy: { publishTargets: [{ type: 'npm', npmStaged: true }] }, + }); + addMockRule({ match: 'npm --version', response: '10.0.0' }); + addMockRule({ match: /^npm (pack|publish)/, response: '[]' }); + + const { packages, depGraph, plan } = planFor(plain, staged); + await expect(publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {})).rejects.toThrow( + /npmStaged requires npm >= 11\.15\.0/, + ); + // Aborted before anything published + expect(getCallsMatching(/^npm publish/)).toHaveLength(0); + }); + + test('public package with no targets (publishTargets: []) still builds and gets its tag', async () => { + const pkgDir = await setupPkg('tool'); + const pkg = makePkg('tool', '1.0.0', { + dir: pkgDir, + bumpy: { publishTargets: [], buildCommand: 'build-tool' }, + }); + addMockRule({ match: 'build-tool', response: '' }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + // Dependents may bundle its build output, so the build runs even though nothing publishes + expect(getCallsMatching('build-tool')).toHaveLength(1); + expect(result.skipped).toEqual([{ name: 'tool', reason: 'no publish targets' }]); + expect(result.failed).toHaveLength(0); + }); + + test('private package with no targets neither builds nor tags by default', async () => { + const pkgDir = await setupPkg('internal'); + const pkg = makePkg('internal', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { buildCommand: 'build-internal' }, + }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(getCallsMatching('build-internal')).toHaveLength(0); + expect(result.skipped).toEqual([{ name: 'internal', reason: 'private' }]); + }); + + test('prior success in release metadata skips the target (per-target resume)', async () => { const pkgDir = await setupPkg('resume'); const pkg = makePkg('resume', '1.0.0', { dir: pkgDir, @@ -124,7 +195,7 @@ describe('publishPackages — multi-target', () => { const { packages, depGraph, plan } = planFor(pkg); const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, { - completedTargets: new Map([['resume', new Set(['done-already'])]]), + priorStates: new Map([['resume', { 'done-already': { status: 'success' as const } }]]), }); const outcomes = result.targetOutcomes.get('resume')!; @@ -231,7 +302,6 @@ describe('publishPackages — multi-target', () => { }; const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, { - noTag: true, releaseKind: 'snapshot', }); @@ -260,8 +330,117 @@ describe('publishPackages — multi-target', () => { 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'); + // Version is out even though nothing was published this run → still "shipped" + // (the flow ensures the tag) but not "published" + expect(result.published).toHaveLength(0); + expect(releaseShipped(outcomes)).toBe(true); + }); + + test('a staged target is re-checked: still pending → left alone, live → recorded as published', async () => { + const pkgDir = await setupPkg('stager'); + const pkg = makePkg('stager', '1.0.0', { + dir: pkgDir, + bumpy: { + publishTargets: [{ type: 'custom', name: 'gated', command: 'publish-cmd', checkPublished: 'check-cmd' }], + }, + }); + const prior = new Map([['stager', { gated: { status: 'staged' as const, ref: 'stage-123' } }]]); + const { packages, depGraph, plan } = planFor(pkg); + + // Not live yet: don't re-run the publish command (would duplicate the staged item) + addMockRule({ match: 'check-cmd', response: '1.0.0' }); + let result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, { priorStates: prior }); + let outcome = result.targetOutcomes.get('stager')![0]!; + expect(outcome.status).toBe('skipped'); + expect(outcome.skipKind).toBe('staged'); + expect(outcome.ref).toBe('stage-123'); + expect(getCallsMatching('publish-cmd')).toHaveLength(0); + expect(releaseShipped([outcome])).toBe(false); + + // Approved since: the registry answers first, and the flow records the success + addMockRule({ match: 'check-cmd', response: '1.0.1' }); + result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, { priorStates: prior }); + outcome = result.targetOutcomes.get('stager')![0]!; + expect(outcome.skipKind).toBe('registry'); + expect(getCallsMatching('publish-cmd')).toHaveLength(0); + }); + + test('a dependency failing on a target blocks dependents on that same target only', async () => { + const dirA = await setupPkg('lib-a'); + const dirB = await setupPkg('app-b'); + const a = makePkg('lib-a', '1.0.0', { + dir: dirA, + bumpy: { + publishTargets: [ + { type: 'custom', name: 'reg-x', command: 'echo a-x' }, + { type: 'custom', name: 'reg-y', command: 'echo a-y' }, + ], + }, + }); + const b = makePkg('app-b', '1.0.0', { + dir: dirB, + dependencies: { 'lib-a': '^1.0.0' }, + bumpy: { + publishTargets: [ + { type: 'custom', name: 'reg-x', command: 'echo b-x' }, + { type: 'custom', name: 'reg-y', command: 'echo b-y' }, + ], + }, + }); + addMockRule({ match: 'a-x', error: 'registry x down' }); + + const { packages, depGraph, plan } = planFor(a, b); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const bOutcomes = result.targetOutcomes.get('app-b')!; + // app-b@reg-x would reference a lib-a@reg-x that never landed → blocked, not published + expect(bOutcomes.find((o) => o.target === 'reg-x')!.status).toBe('failed'); + expect(bOutcomes.find((o) => o.target === 'reg-x')!.error).toMatch(/blocked: dependency lib-a failed on reg-x/); + expect(getCallsMatching('b-x')).toHaveLength(0); + // ...while reg-y, where lib-a succeeded, proceeds + expect(bOutcomes.find((o) => o.target === 'reg-y')!.status).toBe('success'); + expect(getCallsMatching('b-y')).toHaveLength(1); + expect(result.failed.map((f) => f.name).sort()).toEqual(['app-b', 'lib-a']); + }); + + describe('marketplace registry guards', () => { + async function setupExtension(name: string, targets: string[]) { + const pkgDir = await setupPkg(name, { publisher: 'acme', engines: { vscode: '^1.90.0' } }); + const pkg = makePkg(name, '1.0.0', { dir: pkgDir, private: true, bumpy: { publishTargets: targets } }); + pkg.packageJson.publisher = 'acme'; + pkg.packageJson.engines = { vscode: '^1.90.0' }; + return pkg; + } + + test('vscode-marketplace matches any published version, not just the latest', async () => { + const pkg = await setupExtension('ext-a', ['vscode-marketplace']); + // 1.0.1 is live but a newer 1.2.0 has since shipped (e.g. retrying an old release + // whose metadata was lost) — it must still read as published + addMockRule({ + match: /@vscode\/vsce show/, + response: JSON.stringify({ versions: [{ version: '1.2.0' }, { version: '1.0.1' }] }), + }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const outcomes = result.targetOutcomes.get('ext-a')!; + expect(outcomes[0]!.skipKind).toBe('registry'); + expect(getCallsMatching(/vsce (package|publish)/)).toHaveLength(0); + }); + + test('open-vsx falls back to an exact-version query when the latest differs', async () => { + const pkg = await setupExtension('ext-b', ['open-vsx']); + addMockRule({ match: /ovsx get acme\.ext-b --metadata/, response: JSON.stringify({ version: '1.2.0' }) }); + addMockRule({ match: /ovsx get acme\.ext-b@1\.0\.1 --metadata/, response: JSON.stringify({ version: '1.0.1' }) }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const outcomes = result.targetOutcomes.get('ext-b')!; + expect(outcomes[0]!.skipKind).toBe('registry'); + expect(getCallsMatching(/ovsx publish/)).toHaveLength(0); + }); }); describe('jsr target', () => { @@ -340,6 +519,28 @@ describe('publishPackages — multi-target', () => { expect(getCallsMatching('jsr publish')).toHaveLength(0); }); + test('registry queries use the jsr.json name, not the npm name', async () => { + // JSR scopes are a separate namespace — the npm package is @myorg/thing but it + // publishes to JSR as @jsr-org/thing. Only the JSR scope is claimed. + const pkgDir = await setupPkg('@myorg/thing'); + await writeJson(resolve(pkgDir, 'jsr.json'), { + name: '@jsr-org/thing', + version: '0.0.0', + exports: { '.': './src/index.ts' }, + }); + fetchResponses.set(/scopes\/jsr-org\/packages\/thing$/, 200); + fetchResponses.set('/versions/', 404); + addMockRule({ match: 'jsr publish', response: '' }); + const pkg = makePkg('@myorg/thing', '1.0.0', { dir: pkgDir, bumpy: { publishTargets: ['jsr'] } }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + // Querying @myorg/thing instead would have failed the claim check (404) + expect(result.failed).toHaveLength(0); + expect(getCallsMatching('jsr publish')).toHaveLength(1); + }); + test('version already on JSR is skipped via the registry guard', async () => { const pkg = await setupJsrPkg(); fetchResponses.set('/versions/', 200); // already published @@ -485,4 +686,116 @@ describe('publishPackages — multi-target', () => { 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); }); + + describe('phases (release vs post-release targets)', () => { + test('plugins default the phase; an instance option overrides it', () => { + const pkg = makePkg('cli', '1.0.0', { private: true }); + const targets = resolvePackageTargets( + pkg, + { + publishTargets: [ + { type: 'github-release-assets', files: ['x'] }, + { type: 'docker', image: 'ghcr.io/a/b' }, + { type: 'custom', name: 'announce', command: 'echo hi', phase: 'post-release' }, + { type: 'homebrew', name: 'brew-early', tap: 'a/b', template: 't', phase: 'release' }, + ], + }, + DEFAULT_CONFIG, + ); + expect(targets.map((t) => [t.name, t.phase])).toEqual([ + ['github-release-assets', 'release'], + ['docker', 'post-release'], + ['announce', 'post-release'], + ['brew-early', 'release'], + ]); + expect(() => + resolvePackageTargets( + pkg, + { publishTargets: [{ type: 'custom', command: 'x', phase: 'later' }] }, + DEFAULT_CONFIG, + ), + ).toThrow(/Invalid target "phase"/); + }); + + test('the release pass builds and runs only release-phase targets; the post-release pass runs the rest without rebuilding', async () => { + const pkgDir = await setupPkg('two-phase'); + const pkg = makePkg('two-phase', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { + buildCommand: 'build-it', + publishTargets: [ + { type: 'custom', name: 'registry', command: 'echo publish-registry' }, + { type: 'custom', name: 'announce', command: 'echo announce', phase: 'post-release' }, + ], + }, + }); + addMockRule({ match: 'build-it', response: '' }); + const { packages, depGraph, plan } = planFor(pkg); + + const first = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, { phase: 'release' }); + expect(first.targetOutcomes.get('two-phase')!.map((o) => o.target)).toEqual(['registry']); + expect(getCallsMatching('build-it')).toHaveLength(1); + expect(getCallsMatching('echo announce')).toHaveLength(0); + + const second = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, { phase: 'post-release' }); + expect(second.targetOutcomes.get('two-phase')!.map((o) => o.target)).toEqual(['announce']); + expect(getCallsMatching('build-it')).toHaveLength(1); // not rebuilt + expect(getCallsMatching('echo announce')).toHaveLength(1); + + const merged = mergePublishResults(first, second); + expect(merged.targetOutcomes.get('two-phase')!.map((o) => [o.target, o.status])).toEqual([ + ['registry', 'success'], + ['announce', 'success'], + ]); + expect(merged.published.map((p) => p.name)).toEqual(['two-phase']); + }); + + test('a package with only post-release targets still builds in the release pass and is otherwise untouched', async () => { + const pkgDir = await setupPkg('img-only'); + const pkg = makePkg('img-only', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { + buildCommand: 'build-img', + publishTargets: [{ type: 'custom', name: 'push-image', command: 'echo push', phase: 'post-release' }], + }, + }); + addMockRule({ match: 'build-img', response: '' }); + const { packages, depGraph, plan } = planFor(pkg); + + const first = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, { phase: 'release' }); + expect(getCallsMatching('build-img')).toHaveLength(1); + expect(first.targetOutcomes.get('img-only')).toEqual([]); + expect(first.published).toHaveLength(0); + expect(first.skipped).toHaveLength(0); + expect(first.failed).toHaveLength(0); + }); + + test('releaseComplete gates on release-phase targets only', () => { + const pkg = makePkg('cli', '1.0.0', { private: true }); + const targets = resolvePackageTargets( + pkg, + { + publishTargets: [ + { type: 'github-release-assets', files: ['x'] }, + { type: 'docker', image: 'ghcr.io/a/b' }, + ], + }, + DEFAULT_CONFIG, + ); + const meta = (assets: string, docker: string) => ({ + version: '1.0.1', + targets: { 'github-release-assets': { status: assets as never }, docker: { status: docker as never } }, + }); + // docker (post-release) pending never holds the release; assets does + expect(releaseComplete(meta('success', 'pending'), targets)).toBe(true); + expect(releaseComplete(meta('pending', 'success'), targets)).toBe(false); + expect(releaseComplete(meta('staged', 'pending'), targets)).toBe(false); + expect(releaseComplete(meta('skipped', 'pending'), targets)).toBe(false); // needs at least one success + // nothing gates a post-release-only package + const dockerOnly = targets.filter((t) => t.type === 'docker'); + expect(releaseComplete(meta('pending', 'pending'), dockerOnly)).toBe(true); + }); + }); }); diff --git a/packages/bumpy/test/core/publish-pipeline.test.ts b/packages/bumpy/test/core/publish-pipeline.test.ts index 4bfb7e9..4d6da95 100644 --- a/packages/bumpy/test/core/publish-pipeline.test.ts +++ b/packages/bumpy/test/core/publish-pipeline.test.ts @@ -50,7 +50,7 @@ describe('publishPackages', () => { 'my-pkg', makePkg('my-pkg', '1.0.0', { dir: pkgDir, - bumpy: { skipNpmPublish: true }, + bumpy: { publishTargets: [] }, }), ); @@ -94,7 +94,7 @@ describe('publishPackages', () => { makePkg('my-ext', '2.0.0', { dir: pkgDir, bumpy: { - publishCommand: 'echo published {{name}}@{{version}}', + publishTargets: [{ type: 'custom', command: 'echo published {{name}}@{{version}}' }], }, }), ); @@ -280,7 +280,7 @@ describe('publishPackages', () => { // Mock npm --version (for staged validation) and the publish command addMockRule({ match: 'npm --version', response: '11.15.0' }); - addMockRule({ match: 'npm stage publish', response: '' }); + addMockRule({ match: 'npm stage publish', response: JSON.stringify({ pkg: { stageId: 'stage-uuid-1' } }) }); const packages = new Map(); packages.set('staged-pkg', makePkg('staged-pkg', '1.0.0', { dir: pkgDir })); @@ -306,8 +306,54 @@ describe('publishPackages', () => { const result = await publishPackages(plan, packages, depGraph, STAGED_CONFIG, tmpDir, {}); - expect(result.published).toHaveLength(1); + // Staged is not live: reported separately, with the stage id for the release metadata + expect(result.published).toHaveLength(0); + expect(result.staged).toEqual([{ name: 'staged-pkg', version: '1.0.1' }]); + const outcome = result.targetOutcomes.get('staged-pkg')![0]!; + expect(outcome.status).toBe('staged'); + expect(outcome.ref).toBe('stage-uuid-1'); const publishCalls = getCallsMatching('npm stage publish'); - expect(publishCalls.length).toBeGreaterThanOrEqual(1); + expect(publishCalls).toHaveLength(1); + expect(publishCalls[0]!.command).toContain('--json'); + }); + + test('snapshots never stage — they must be installable immediately', async () => { + const pkgDir = resolve(tmpDir, 'packages/snap-pkg'); + await ensureDir(pkgDir); + await writeJson(resolve(pkgDir, 'package.json'), { name: 'snap-pkg', version: '1.0.0' }); + await setupGitRepo(); + addMockRule({ match: 'npm --version', response: '11.15.0' }); + addMockRule({ match: /^npm publish/, response: '' }); + + const packages = new Map(); + packages.set('snap-pkg', makePkg('snap-pkg', '1.0.0', { dir: pkgDir })); + const depGraph = new DependencyGraph(packages); + const plan: ReleasePlan = { + bumpFiles: [], + warnings: [], + releases: [ + { + name: 'snap-pkg', + type: 'patch', + oldVersion: '1.0.0', + newVersion: '1.0.1-pr-9-abc1234', + bumpFiles: [], + isDependencyBump: false, + isCascadeBump: false, + isGroupBump: false, + bumpSources: [], + }, + ], + }; + + const result = await publishPackages(plan, packages, depGraph, STAGED_CONFIG, tmpDir, { + releaseKind: 'snapshot', + tag: 'pr-9', + }); + + expect(result.staged).toHaveLength(0); + expect(result.published).toHaveLength(1); + expect(getCallsMatching('npm stage publish')).toHaveLength(0); + expect(getCallsMatching(/^npm publish/)[0]!.command).toContain('--tag pr-9'); }); }); diff --git a/packages/bumpy/test/core/snapshot.test.ts b/packages/bumpy/test/core/snapshot.test.ts index a722043..674926c 100644 --- a/packages/bumpy/test/core/snapshot.test.ts +++ b/packages/bumpy/test/core/snapshot.test.ts @@ -111,10 +111,10 @@ describe('snapshotVersion', () => { }); describe('buildSnapshotReleasePlan', () => { - // Use private packages with a custom publishCommand: publishable (kept in the plan) but + // Use private packages with a custom target: publishable (kept in the plan) but // not registry-backed, so no `npm info` network call happens in tests. const publishable = (name: string, version: string) => - makePkg(name, version, { private: true, bumpy: { publishCommand: 'echo publish' } }); + makePkg(name, version, { private: true, bumpy: { publishTargets: [{ type: 'custom', command: 'echo publish' }] } }); test('applies sha snapshot versions to each release', async () => { const packages = new Map([['a', publishable('a', '1.0.0')]]); @@ -133,7 +133,7 @@ describe('buildSnapshotReleasePlan', () => { expect(out.releases[0]!.newVersion).toBe('1.1.0-pr-9-deadbee'); }); - test('drops unpublishable private packages (no publishCommand)', async () => { + test('drops unpublishable private packages (no targets)', async () => { const packages = new Map([ ['a', publishable('a', '1.0.0')], ['b', makePkg('b', '2.0.0', { private: true })], // truly unpublishable diff --git a/packages/bumpy/test/core/targets-release.test.ts b/packages/bumpy/test/core/targets-release.test.ts new file mode 100644 index 0000000..2295574 --- /dev/null +++ b/packages/bumpy/test/core/targets-release.test.ts @@ -0,0 +1,339 @@ +import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { writeJson, writeText, 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 { dockerTarget, dockerBuildArgs } from '../../src/core/targets/docker.ts'; +import { githubReleaseAssetsTarget } from '../../src/core/targets/github-release-assets.ts'; +import { homebrewTarget, renderFormula, formulaVersion } from '../../src/core/targets/homebrew.ts'; +import { expandGlobs, templateString } from '../../src/core/targets/util.ts'; +import type { TargetPublishContext } from '../../src/core/targets/types.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('release-shaped targets (github-release-assets / docker / homebrew)', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-rel-targets-')); + 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[]) { + 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')), + } as ReleasePlan, + }; + } + + describe('util', () => { + test('templateString substitutes known placeholders and leaves unknown ones', () => { + expect(templateString('v{{version}}-{{ name }}-{{nope}}', { version: '1.2.3', name: 'x' })).toBe( + 'v1.2.3-x-{{nope}}', + ); + }); + + test('expandGlobs matches relative to the dir, skipping node_modules', async () => { + await ensureDir(resolve(tmpDir, 'dist')); + await ensureDir(resolve(tmpDir, 'node_modules/x')); + await writeText(resolve(tmpDir, 'dist/a.tar.gz'), 'a'); + await writeText(resolve(tmpDir, 'dist/b.zip'), 'b'); + await writeText(resolve(tmpDir, 'node_modules/x/c.tar.gz'), 'c'); + expect(expandGlobs(tmpDir, ['dist/*.tar.gz', '**/*.zip'])).toEqual(['dist/a.tar.gz', 'dist/b.zip']); + }); + }); + + describe('github-release-assets', () => { + test('uploads matched files to the name@version release with --clobber', async () => { + const pkgDir = await setupPkg('cli'); + await ensureDir(resolve(pkgDir, 'dist')); + await writeText(resolve(pkgDir, 'dist/cli-macos-arm64.tar.gz'), 'bin'); + await writeText(resolve(pkgDir, 'dist/checksums.txt'), 'sums'); + await writeText(resolve(pkgDir, 'dist/notes.md'), 'ignored'); + const pkg = makePkg('cli', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { + publishTargets: [{ type: 'github-release-assets', files: ['dist/*.tar.gz', 'dist/checksums.txt'] }], + }, + }); + + 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(['cli']); + const uploads = getCallsMatching(/^gh release upload/); + expect(uploads).toHaveLength(1); + expect(uploads[0]!.args.slice(0, 4)).toEqual(['gh', 'release', 'upload', 'cli@1.0.1']); + expect(uploads[0]!.args).toContain(resolve(pkgDir, 'dist/cli-macos-arm64.tar.gz')); + expect(uploads[0]!.args).toContain(resolve(pkgDir, 'dist/checksums.txt')); + expect(uploads[0]!.args).not.toContain(resolve(pkgDir, 'dist/notes.md')); + expect(uploads[0]!.args.at(-1)).toBe('--clobber'); + }); + + test('registry guard: assets already on the release are not re-uploaded', async () => { + const pkgDir = await setupPkg('cli2'); + await ensureDir(resolve(pkgDir, 'dist')); + await writeText(resolve(pkgDir, 'dist/cli2.tar.gz'), 'bin'); + addMockRule({ match: /gh release view cli2@1\.0\.1/, response: 'cli2.tar.gz\nother.txt' }); + const pkg = makePkg('cli2', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { publishTargets: [{ type: 'github-release-assets', files: ['dist/*.tar.gz'] }] }, + }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(result.targetOutcomes.get('cli2')![0]!.skipKind).toBe('registry'); + expect(getCallsMatching(/^gh release upload/)).toHaveLength(0); + }); + + test('fails clearly when nothing matched (assets not built)', async () => { + const pkgDir = await setupPkg('cli3'); + const pkg = makePkg('cli3', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { publishTargets: [{ type: 'github-release-assets', files: ['dist/*.tar.gz'] }] }, + }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const outcome = result.targetOutcomes.get('cli3')![0]!; + expect(outcome.status).toBe('failed'); + expect(outcome.error).toMatch(/no files matched/); + }); + + test('publishUrl points at the release page', () => { + const pkg = makePkg('@acme/cli', '1.0.0'); + expect(githubReleaseAssetsTarget.publishUrl!(pkg, '1.0.1', {}, { repoSlug: 'acme/cli' })).toBe( + 'https://github.com/acme/cli/releases/tag/%40acme%2Fcli%401.0.1', + ); + }); + }); + + describe('docker', () => { + function ctxFor(overrides: Partial = {}): TargetPublishContext { + const pkg = makePkg('varlock', '1.0.0', { dir: '/repo/packages/varlock' }); + return { + pkg, + pkgConfig: {}, + version: '1.2.0', + rootDir: '/repo', + config: DEFAULT_CONFIG, + options: { image: 'ghcr.io/dmno-dev/varlock' }, + dryRun: false, + releaseKind: 'stable', + packManager: 'npm', + ...overrides, + }; + } + + test('stable release: version + latest tags, platforms, build args, dockerfile, context', () => { + const args = dockerBuildArgs( + ctxFor({ + options: { + image: 'ghcr.io/dmno-dev/varlock', + context: '../..', + dockerfile: '../../Dockerfile', + platforms: ['linux/amd64', 'linux/arm64'], + buildArgs: { VARLOCK_VERSION: '{{version}}' }, + }, + }), + ); + expect(args).toEqual([ + 'docker', + 'buildx', + 'build', + '--push', + '--tag', + 'ghcr.io/dmno-dev/varlock:1.2.0', + '--tag', + 'ghcr.io/dmno-dev/varlock:latest', + '--platform', + 'linux/amd64,linux/arm64', + '--build-arg', + 'VARLOCK_VERSION=1.2.0', + '--file', + '/repo/Dockerfile', + '/repo', + ]); + }); + + test('channel release: version + dist-tag, never latest', () => { + const args = dockerBuildArgs(ctxFor({ version: '1.2.0-next.0', releaseKind: 'channel', distTag: 'next' })); + const tags = args.filter((_a, i) => args[i - 1] === '--tag'); + expect(tags).toEqual(['ghcr.io/dmno-dev/varlock:1.2.0-next.0', 'ghcr.io/dmno-dev/varlock:next']); + }); + + test('checkPublished: manifest present → true, unknown manifest → false, other errors → unknown', async () => { + const pkg = makePkg('varlock', '1.0.0'); + const opts = { image: 'ghcr.io/dmno-dev/varlock' }; + addMockRule({ match: 'docker manifest inspect ghcr.io/dmno-dev/varlock:1.0.0', response: '{}' }); + addMockRule({ match: 'docker manifest inspect ghcr.io/dmno-dev/varlock:1.0.1', error: 'manifest unknown' }); + addMockRule({ + match: 'docker manifest inspect ghcr.io/dmno-dev/varlock:1.0.2', + error: 'unauthorized: auth required', + }); + expect(await dockerTarget.checkPublished!(pkg, '1.0.0', opts)).toBe(true); + expect(await dockerTarget.checkPublished!(pkg, '1.0.1', opts)).toBe(false); + expect(await dockerTarget.checkPublished!(pkg, '1.0.2', opts)).toBeNull(); + }); + + test('publishes through the pipeline with buildx', async () => { + const pkgDir = await setupPkg('img'); + const pkg = makePkg('img', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { publishTargets: [{ type: 'docker', image: 'ghcr.io/acme/img' }] }, + }); + addMockRule({ match: 'docker --version', response: 'Docker version 27.0.0' }); + addMockRule({ match: 'docker manifest inspect', error: 'manifest unknown' }); + addMockRule({ match: 'docker buildx build', response: '' }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(result.failed).toHaveLength(0); + const builds = getCallsMatching('docker buildx build'); + expect(builds).toHaveLength(1); + expect(builds[0]!.command).toContain('--tag ghcr.io/acme/img:1.0.1'); + expect(builds[0]!.command).toContain('--tag ghcr.io/acme/img:latest'); + }); + + test('labels and URLs by registry', () => { + const pkg = makePkg('varlock', '1.0.0'); + expect(dockerTarget.label!({ image: 'ghcr.io/dmno-dev/varlock' })).toBe('GHCR'); + expect(dockerTarget.label!({ image: 'dmno/varlock' })).toBe('Docker Hub'); + expect( + dockerTarget.publishUrl!(pkg, '1.0.0', { image: 'ghcr.io/dmno-dev/varlock' }, { repoSlug: 'dmno-dev/varlock' }), + ).toBe('https://github.com/dmno-dev/varlock/pkgs/container/varlock'); + expect(dockerTarget.publishUrl!(pkg, '1.0.0', { image: 'dmno/varlock' }, {})).toBe( + 'https://hub.docker.com/r/dmno/varlock', + ); + }); + }); + + describe('homebrew', () => { + const TEMPLATE = `class Varlock < Formula + version "{{version}}" + on_macos do + url "https://github.com/dmno-dev/varlock/releases/download/varlock@#{version}/varlock-macos-arm64.tar.gz" + sha256 "{{sha256 varlock-macos-arm64.tar.gz}}" + end +end +`; + + test('renderFormula fills version and asset checksums; formulaVersion reads it back', () => { + const rendered = renderFormula(TEMPLATE, { version: '1.2.3', name: 'varlock' }, (file) => `sha-of-${file}`); + expect(rendered).toContain('version "1.2.3"'); + expect(rendered).toContain('sha256 "sha-of-varlock-macos-arm64.tar.gz"'); + expect(formulaVersion(rendered)).toBe('1.2.3'); + }); + + test('checkPublished reads the formula version from the tap via the GitHub API', async () => { + const pkg = makePkg('varlock', '1.0.0'); + const opts = { tap: 'dmno-dev/homebrew-tap' }; + const live = Buffer.from('class Varlock < Formula\n version "1.0.1"\nend\n').toString('base64'); + addMockRule({ match: 'gh api repos/dmno-dev/homebrew-tap/contents/Formula/varlock.rb', response: live }); + expect(await homebrewTarget.checkPublished!(pkg, '1.0.1', opts)).toBe(true); + expect(await homebrewTarget.checkPublished!(pkg, '1.0.2', opts)).toBe(false); + }); + + test('commits the rendered formula to the tap checkout, tags name@version, pushes', async () => { + const pkgDir = await setupPkg('varlock'); + // Release asset the formula's sha256 refers to + await ensureDir(resolve(pkgDir, 'dist')); + const asset = 'binary-bytes'; + await writeText(resolve(pkgDir, 'dist/varlock-macos-arm64.tar.gz'), asset); + await writeText(resolve(pkgDir, 'Formula.rb.tmpl'), TEMPLATE); + + // A tap repo with a remote, as actions/checkout would leave it + const bare = resolve(tmpDir, 'tap.git'); + gitInDir(['init', '--bare', bare], tmpDir); + const tapDir = resolve(tmpDir, 'homebrew-tap'); + gitInDir(['clone', '-q', bare, tapDir], tmpDir); + await writeText(resolve(tapDir, 'README.md'), 'tap'); + gitInDir(['add', '.'], tapDir); + gitInDir(['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init'], tapDir); + gitInDir(['push', '-q', 'origin', 'HEAD'], tapDir); + + const pkg = makePkg('varlock', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { + publishTargets: [ + { + type: 'homebrew', + tap: 'dmno-dev/homebrew-tap', + template: 'Formula.rb.tmpl', + assets: ['dist/*.tar.gz'], + tapDir: '../../homebrew-tap', + }, + ], + }, + }); + // gh api (checkPublished) → not found + addMockRule({ match: 'gh api repos/dmno-dev/homebrew-tap', error: 'HTTP 404: Not Found' }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(result.failed).toHaveLength(0); + const defaultBranch = gitInDir(['rev-parse', '--abbrev-ref', 'HEAD'], tapDir); + const formula = gitInDir(['show', `${defaultBranch}:Formula/varlock.rb`], bare); + expect(formula).toContain('version "1.0.1"'); + expect(formula).toContain(`sha256 "${createHash('sha256').update(asset).digest('hex')}"`); + expect(gitInDir(['tag', '-l', 'varlock@1.0.1'], bare)).toBe('varlock@1.0.1'); + expect(gitInDir(['log', '-1', '--format=%s', defaultBranch], bare)).toBe('varlock@1.0.1'); + // Re-run: formula unchanged → no new commit, still succeeds + const before = gitInDir(['rev-parse', defaultBranch], bare); + const again = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + expect(again.failed).toHaveLength(0); + expect(gitInDir(['rev-parse', defaultBranch], bare)).toBe(before); + expect(await readFile(resolve(tapDir, 'Formula/varlock.rb'), 'utf-8')).toContain('version "1.0.1"'); + }); + }); +}); diff --git a/packages/bumpy/test/core/targets.test.ts b/packages/bumpy/test/core/targets.test.ts index 1488066..7fce305 100644 --- a/packages/bumpy/test/core/targets.test.ts +++ b/packages/bumpy/test/core/targets.test.ts @@ -19,7 +19,7 @@ function configWithTargets(targets: BumpyConfig['targets']): BumpyConfig { return makeConfig({ targets }); } -describe('resolvePackageTargets — legacy field mapping', () => { +describe('resolvePackageTargets — defaults', () => { test('default: public package gets the implicit npm target', () => { const pkg = makePkg('lib', '1.0.0'); const targets = resolvePackageTargets(pkg, {}, makeConfig()); @@ -28,38 +28,12 @@ describe('resolvePackageTargets — legacy field mapping', () => { 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', () => { + test('default: private package 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', () => { + test('the implicit npm target picks up the root targets.npm instance options', () => { const pkg = makePkg('lib', '1.0.0'); const config = configWithTargets({ npm: { provenance: true } }); const targets = resolvePackageTargets(pkg, {}, config); @@ -105,22 +79,17 @@ describe('resolvePackageTargets — explicit publishTargets', () => { expect(targets[1]!.options.registry).toBe('https://npm.pkg.github.com'); }); - test('named instance inherits type defaults, its own options win', () => { + test('instances are complete on their own — nothing is inherited from targets.npm', () => { 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); + const targets = resolvePackageTargets(pkg, { publishTargets: ['npm', 'ghp'] }, config); + expect(targets[0]!.options).toEqual({ provenance: true, access: 'public' }); + expect(targets[1]!.options).toEqual({ registry: 'https://npm.pkg.github.com', access: 'restricted' }); + const inline = resolvePackageTargets(pkg, { publishTargets: [{ type: 'npm', provenance: false }] }, config); + expect(inline[0]!.options).toEqual({ provenance: false }); }); test('npm targets are dropped for private packages', () => { @@ -181,13 +150,13 @@ 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 + pkg.bumpy = { publishTargets: [] }; // 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 } }); + const nonePkg = makePkg('tool', '1.0.0', { bumpy: { publishTargets: [] } }); expect(packagePublishes(npmPkg)).toBe(true); expect(packagePublishes(nonePkg)).toBe(false); expect(getNpmTarget(npmPkg)?.type).toBe('npm'); @@ -281,7 +250,7 @@ describe('publishTargets trust gating (package.json config)', () => { 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/); + ).rejects.toThrow(/inline target definitions/); }); test('inline commands allowed with allowCustomCommands', async () => { @@ -290,11 +259,42 @@ describe('publishTargets trust gating (package.json 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(), - ); + test('name references are always allowed', async () => { + const result = await loadFromPkgJson({ publishTargets: ['vscode-marketplace', 'ghp'] }, makeConfig()); expect(result.publishTargets).toHaveLength(2); }); + + test('any inline target definition is gated — options steer the publish just like commands', async () => { + // A `registry` redirect or injected CLI flags reach the credentialed publish + // command — a compromised package.json must not be able to add them + await expect( + loadFromPkgJson({ publishTargets: [{ type: 'npm', registry: 'https://attacker.example' }] }, makeConfig()), + ).rejects.toThrow(/inline target definitions/); + await expect( + loadFromPkgJson( + { publishTargets: [{ type: 'npm', publishArgs: ['--registry', 'https://attacker.example'] }] }, + makeConfig(), + ), + ).rejects.toThrow(/inline target definitions/); + // ...unless the root config opts the package in + const allowed = await loadFromPkgJson( + { publishTargets: [{ type: 'npm', registry: 'https://example.com' }] }, + makeConfig({ allowCustomCommands: ['my-pkg'] }), + ); + expect(allowed.publishTargets).toHaveLength(1); + }); + + test('buildCommand in package.json is gated', async () => { + await expect(loadFromPkgJson({ buildCommand: 'make' }, makeConfig())).rejects.toThrow(/"buildCommand"/); + }); + + test('removed legacy fields fail with the migration', async () => { + await expect(loadFromPkgJson({ publishCommand: 'vsce publish' }, makeConfig())).rejects.toThrow( + /removed config field.*"publishCommand".*Migrate/s, + ); + await expect(loadFromPkgJson({ skipNpmPublish: true }, makeConfig())).rejects.toThrow(/"skipNpmPublish"/); + // ...also when they come from the root packages map + const rootLegacy = makeConfig({ packages: { 'my-pkg': { checkPublished: 'x' } as never } }); + await expect(loadFromPkgJson({}, rootLegacy)).rejects.toThrow(/"checkPublished"/); + }); }); diff --git a/packages/bumpy/test/helpers.ts b/packages/bumpy/test/helpers.ts index 846d60a..b72a0aa 100644 --- a/packages/bumpy/test/helpers.ts +++ b/packages/bumpy/test/helpers.ts @@ -93,8 +93,8 @@ export function makeReleasePlan(releases: PlannedRelease[], bumpFiles: BumpFile[ /** Create a temp directory and initialize a git repo in it */ export async function createTempGitRepo(): Promise { const dir = await mkdtemp(resolve(tmpdir(), 'bumpy-test-')); - execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }); - execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'pipe' }); + execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe', env: process.env }); + execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'pipe', env: process.env }); return dir; } @@ -105,5 +105,6 @@ export async function cleanupTempDir(dir: string): Promise { /** Run a git command in a directory (for test setup only) */ export function gitInDir(args: string[], cwd: string): string { - return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim(); + // env passed explicitly so the hermetic git settings from test/setup.ts reach git under Bun + return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: 'pipe', env: process.env }).trim(); } diff --git a/packages/bumpy/test/setup.ts b/packages/bumpy/test/setup.ts new file mode 100644 index 0000000..1d09a16 --- /dev/null +++ b/packages/bumpy/test/setup.ts @@ -0,0 +1,12 @@ +/** + * Test preload (see bunfig.toml): make git hermetic. Tests create real repos and + * commits, which must not depend on the developer's global git config — commit + * signing (a 1Password/gpg agent prompt would hang or fail), hooks, default branch, + * identity. Global/system config is disabled and an identity is provided via env. + */ +process.env.GIT_CONFIG_GLOBAL = '/dev/null'; +process.env.GIT_CONFIG_NOSYSTEM = '1'; +process.env.GIT_AUTHOR_NAME ??= 'bumpy-test'; +process.env.GIT_AUTHOR_EMAIL ??= 'bumpy-test@example.com'; +process.env.GIT_COMMITTER_NAME ??= 'bumpy-test'; +process.env.GIT_COMMITTER_EMAIL ??= 'bumpy-test@example.com'; diff --git a/packages/bumpy/test/utils/shell.test.ts b/packages/bumpy/test/utils/shell.test.ts index b8b55a0..ac025e7 100644 --- a/packages/bumpy/test/utils/shell.test.ts +++ b/packages/bumpy/test/utils/shell.test.ts @@ -36,4 +36,10 @@ describe('runArgsAsync error reporting', () => { /ARGS_STDOUT_FAILURE/, ); }); + + test('runArgsAsync timeoutMs kills a hung command and says so', async () => { + const start = Date.now(); + await expect(runArgsAsync(['sleep', '30'], { timeoutMs: 300 })).rejects.toThrow(/timed out after 300ms: sleep 30/); + expect(Date.now() - start).toBeLessThan(5000); + }); });