diff --git a/.bumpy/publish-targets.md b/.bumpy/publish-targets.md new file mode 100644 index 0000000..c0b6aa4 --- /dev/null +++ b/.bumpy/publish-targets.md @@ -0,0 +1,9 @@ +--- +'@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 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 f24fba7..20ea3a4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,10 +21,11 @@ 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` | `{}` | 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) | @@ -34,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 @@ -102,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) @@ -117,6 +120,147 @@ Requirements: } ``` +### Publish targets + +A package can publish to any number of **targets** — npm is just the default one. Each target is an instance of a target type; the built-in types are: + +| Type | Publishes via | Auth | Notes | +| ----------------------- | ----------------------------------- | --------------------------------- | ----------------------------------------------------------------------- | +| `npm` | `npm publish` (or configured PM) | OIDC / `NPM_TOKEN` / `.npmrc` | Supports dist-tags, prereleases, snapshots, staged publishes | +| `jsr` | `npx jsr publish` | OIDC (linked GitHub repo) | Requires a `jsr.json`; no dist-tags, so no snapshots | +| `pypi` | `uv build` + `uv publish` | OIDC / `UV_PUBLISH_TOKEN` | Requires a `pyproject.toml`; stable versions only | +| `vscode-marketplace` | `vsce publish --packagePath ` | `VSCE_PAT` (or Azure credentials) | Stable versions only — the Marketplace rejects prereleases | +| `open-vsx` | `ovsx publish ` | `OVSX_PAT` | Stable versions only | +| `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 — name references only — in 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. Public packages default to `["npm"]`, private packages to `[]`. + +**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": { "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 + }, +} +``` + +**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. + +**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. + +**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 + +- `jsr.json` must exist (name + exports), but commit its `version` as `"0.0.0"` and forget it — bumpy syncs it from package.json into the working tree at publish time. Publishes run with `--allow-dirty` for this reason. +- `workspace:`/`catalog:` dependency specifiers in package.json are resolved automatically before publishing (JSR reads npm ranges from package.json and silently drops protocol specifiers). +- JSR has **no create-on-first-publish**: claim each package in your scope on jsr.io first, and link the GitHub repo to publish token-lessly via OIDC (`id-token: write`). Unclaimed packages fail with guidance instead of publishing. +- Options: `allowSlowTypes: true` passes `--allow-slow-types`; `publishArgs` appends anything else. +- Credit: the JSR publishing behavior here (publish-time version sync, claim-first bootstrap) is modeled on [Drake Costa's](https://github.com/Saeris) setup in [mirrordown](https://github.com/mirrordown/mirrordown) — thanks Drake! + +#### PyPI notes + +bumpy's versioning spine is `package.json`, so a Python package in the workspace gets a **stub `package.json`** next to its `pyproject.toml`: + +```json +{ + "name": "my-py-tool", + "version": "1.2.0", + "private": true, + "bumpy": { "publishTargets": ["pypi"] } +} +``` + +Bump files, changelogs, and the release PR all flow through the stub; at publish time the target syncs the version into `pyproject.toml` (`[project].version` — commit any placeholder), builds with `uv build` into an isolated per-version directory (so stale `dist/` artifacts can never ride along), and uploads with `uv publish`. + +- **Auth**: [PyPI trusted publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) works token-lessly on GitHub Actions with `id-token: write` — `uv publish` picks it up automatically. Otherwise set `UV_PUBLISH_TOKEN`. +- The PyPI project name comes from `pyproject.toml` `[project].name`, not the stub's npm name. +- `dynamic = ["version"]` (setuptools-scm etc.) can't be synced — use a static version. +- PEP 440 doesn't cover bumpy's semver prerelease/snapshot suffixes, so channel prereleases and snapshots record the target as `skipped`. +- Options: `index` (alternative upload URL), `buildArgs` / `publishArgs`. + +#### VS Code extension notes + +- The `.vsix` is packaged with `vsce package --no-dependencies` by default — vsce's npm-based dependency detection breaks in workspace monorepos and silently ships broken extensions. Bundled extensions (the norm) don't need it; set `dependencies: true` on the target to restore vsce's default. `packageArgs` / `publishArgs` append extra flags to the respective step. +- Marketplace auth: `VSCE_PAT` by default, or set `azureCredential: true` to publish with `--azure-credential` (short-lived tokens minted via Azure OIDC — pair with `azure/login` in CI, no long-lived PAT secret). +- If the extension bundles a workspace sibling from `devDependencies`, list it in [`releaseTriggeringDevDeps`](#release-triggering-devdependencies) so the extension re-releases when the bundled package changes. + ### Version PR config The `versionPr` object customizes the PR that `bumpy ci release` creates: @@ -178,11 +322,9 @@ 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)) | | `buildCommand` | `string` | Command to run before publishing | | `registry` | `string` | Custom npm registry URL | -| `skipNpmPublish` | `boolean` | Don't publish to npm (still creates git tags) | -| `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 }` | @@ -191,12 +333,12 @@ 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: +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 { @@ -212,35 +354,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 — 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: 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 +470,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/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/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..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,14 +25,8 @@ import { createIndividualReleases, findReleaseByTag, createDraftRelease, - updateReleaseBody, - updateReleaseBodyStatus, - finalizeRelease, finalizeSupersededDrafts, composeReleaseBody, - buildPublishUrl, - publishTargetLabel, - resolvePackageRegistry, parseRepoSlug, isGhAvailable, getHeadSha, @@ -35,11 +35,15 @@ import { type ReleaseMetadata, type PublishTargetState, } from '../core/github-release.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'; import { detectWorkspaces } from '../utils/package-manager.ts'; import { CI_PLAN_CACHE_PATH } from './ci.ts'; import { runArgsAsync, tryRunArgs } from '../utils/shell.ts'; -import type { BumpyConfig, PackageConfig, ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; +import type { BumpyConfig, ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; import type { CatalogMap } from '../utils/package-manager.ts'; import type { PackageManager } from '../types.ts'; @@ -78,6 +82,15 @@ export async function publishCommand( const { packageManager: detectedPm } = await detectWorkspaces(rootDir); const depGraph = new DependencyGraph(packages); + // Discovery tolerates broken target config so read-only commands keep working; + // publishing with one is never safe — fail before any release side effects. + const brokenTargets = [...packages.values()].filter((p) => p.targetsError); + if (brokenTargets.length > 0) { + log.error('Invalid publish target configuration — fix before publishing:'); + for (const p of brokenTargets) log.error(` • ${p.name}: ${p.targetsError}`); + process.exit(1); + } + if (!opts.dryRun && hasUncommittedChanges({ cwd: rootDir })) { log.warn('You have uncommitted changes. Commit or stash them before publishing.'); process.exit(1); @@ -239,6 +252,7 @@ async function publishChannel( dryRun: opts.dryRun, tag: opts.tag ?? channel.tag, noPush: opts.noPush, + releaseKind: 'channel', }); } finally { if (restore) { @@ -348,7 +362,7 @@ async function publishSnapshot( depGraph, config, rootDir, - { dryRun: opts.dryRun, tag: snapshot.tag, noTag: true }, + { dryRun: opts.dryRun, tag: snapshot.tag, releaseKind: 'snapshot' }, catalogs, detectedPm, ); @@ -376,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, @@ -387,9 +413,35 @@ 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; + 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:'); @@ -406,7 +458,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,31 +473,18 @@ 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) - 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) { @@ -462,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) @@ -474,11 +514,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, @@ -493,59 +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`, + ); } } } - // Filter out packages where all targets already succeeded (from previous runs) + // 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 allDone = targets.every((t) => info.metadata.targets[t]?.status === 'success'); - if (allDone) { + 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; @@ -556,113 +595,166 @@ async function runPublishFlow( return; } - const result = await publishPackages( - releasePlan, - packages, - depGraph, - config, - rootDir, - { - dryRun: opts.dryRun, - tag: opts.tag, - }, - 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; 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 = passResult.targetOutcomes.get(release.name) || []; + const pkgFailure = passResult.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] = { - status: 'success', - publishedAt: new Date().toISOString(), - url: buildPublishUrl(release.name, release.newVersion, targetName, { registry, repoSlug }), - ...(label !== targetName ? { label } : {}), + for (const outcome of outcomes) { + // Never downgrade a target that already succeeded in a previous run + if (info.metadata.targets[outcome.target]?.status === 'success') continue; + const target = targetsByName.get(outcome.target); + const label = target ? targetLabel(target, pkg) : outcome.target; + const labelField = label !== outcome.target ? { label } : {}; + + if (outcome.status === 'success' || outcome.skipKind === 'registry') { + // "already on registry" = the 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: 'staged', + stagedAt: new Date().toISOString(), + ...(outcome.ref ? { ref: outcome.ref } : {}), + ...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: failed.error, + error: outcome.error, lastAttempt: new Date().toISOString(), - ...(label !== targetName ? { label } : {}), + ...labelField, + }; + changed = true; + } 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, + ...labelField, }; changed = true; } + // metadata / still-staged skips: state is already what it should be } - if (changed) { - try { - const updatedBody = info.existingBody - ? updateReleaseBodyStatus(info.existingBody, info.metadata) - : 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'); - 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}`); + // 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: pkgFailure.error, + lastAttempt: new Date().toISOString(), + ...(label !== t.name ? { label } : {}), + }; + changed = true; } } + + 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) { - 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)) 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); @@ -673,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)); @@ -760,23 +857,25 @@ 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. 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, - _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; + // 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; - const isPublished = await checkIfPublished(name, pkg.version, pkg.bumpy); + const isPublished = await checkIfPublished(pkg, pkg.version, config); if (!isPublished) { unpublished.push({ name, @@ -795,34 +894,24 @@ 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 { tryRunArgs } = await import('../utils/shell.ts'); - // 1. 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; - } - } - - // 2. Non-npm packages — check git tags - if (pkgConfig?.skipNpmPublish || pkgConfig?.publishCommand) { - const tag = `${name}@${version}`; - return tryRunArgs(['git', 'tag', '-l', tag]) === tag; - } + // 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. + const targets = getPackageTargets(pkg, config); + const answers = await Promise.all( + targets.map((target) => target.plugin.checkPublished?.(pkg, version, target.options) ?? null), + ); + if (answers.some((a) => a === false)) return false; + if (answers.length > 0 && answers.every((a) => a === true)) return true; - // 3. Default — check npm registry - try { - const args = ['npm', 'info', `${name}@${version}`, 'version']; - if (pkgConfig?.registry) args.push('--registry', pkgConfig.registry); - const result = await runArgsAsync(args); - return result === version; - } catch { - return false; - } + // 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; } /** @@ -842,20 +931,21 @@ async function packageExistsOnNpm(name: string, registry?: string): Promise, + config: BumpyConfig, ): Promise { const missing: string[] = []; await Promise.all( toPublish.map(async (release) => { const pkg = packages.get(release.name)!; - const pkgConfig = pkg.bumpy || {}; - if (pkgConfig.publishCommand || pkgConfig.skipNpmPublish) return; - if (pkg.private && !pkgConfig.publishCommand) return; - const exists = await packageExistsOnNpm(release.name, pkgConfig.registry); + const npm = getNpmTarget(pkg, config); + if (!npm) return; + const registry = npmEffectiveRegistry(pkg, pkg.bumpy || {}, npm.options); + const exists = await packageExistsOnNpm(release.name, registry); if (!exists) missing.push(release.name); }), ); diff --git a/packages/bumpy/src/commands/status.ts b/packages/bumpy/src/commands/status.ts index 3cd9850..43ab2ff 100644 --- a/packages/bumpy/src/commands/status.ts +++ b/packages/bumpy/src/commands/status.ts @@ -7,7 +7,8 @@ import { assembleReleasePlan } from '../core/release-plan.ts'; import { getCurrentBranch, getChangedFiles } from '../core/git.ts'; import { channelNames, resolveActiveChannel, type ResolvedChannel } from '../core/channels.ts'; import { buildChannelReleasePlan } from '../core/prerelease.ts'; -import { publishTargetLabel, resolvePackageRegistry } from '../core/github-release.ts'; +import { getPackageTargets, targetLabel } from '../core/targets/registry.ts'; +import { npmEffectiveRegistry } from '../core/targets/npm.ts'; import type { BumpFile, BumpyConfig, PackageConfig, PlannedRelease, WorkspacePackage } from '../types.ts'; interface StatusOptions { @@ -302,18 +303,16 @@ function printRelease(r: PlannedRelease, packages: Map function getPublishTargets( pkg: WorkspacePackage | undefined, pkgConfig: Partial, - _config: BumpyConfig, -): Array<{ type: string; label: string; registry?: string }> { + config: BumpyConfig, +): Array<{ type: string; name: string; label: string; registry?: string }> { if (!pkg) return []; - // Private packages with no custom command won't publish - if (pkg.private && !pkgConfig.publishCommand) return []; - const targets: Array<{ type: string; label: string; registry?: string }> = []; - if (pkgConfig.publishCommand) { - targets.push({ type: 'custom', label: 'custom' }); - } - if (!pkgConfig.publishCommand && !pkgConfig.skipNpmPublish) { - const registry = resolvePackageRegistry(pkg, pkgConfig); - targets.push({ type: 'npm', label: publishTargetLabel('npm', registry), ...(registry ? { registry } : {}) }); - } - return targets; + return getPackageTargets(pkg, config).map((t) => { + const registry = t.type === 'npm' ? npmEffectiveRegistry(pkg, pkgConfig, t.options) : undefined; + return { + type: t.type, + name: t.name, + label: targetLabel(t, pkg), + ...(registry ? { registry } : {}), + }; + }); } diff --git a/packages/bumpy/src/core/config.ts b/packages/bumpy/src/core/config.ts index e9962a1..7b5883b 100644 --- a/packages/bumpy/src/core/config.ts +++ b/packages/bumpy/src/core/config.ts @@ -57,18 +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 = CUSTOM_CMD_KEYS.filter((k) => pkgJsonConfig[k] != null); - if (disallowedKeys.length > 0 && !isCustomCommandAllowed(pkgName, rootConfig)) { - const fields = disallowedKeys.map((k) => `"${k}"`).join(', '); + // 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 (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`, ); } @@ -118,6 +139,10 @@ function mergeConfig(defaults: BumpyConfig, user: Partial): BumpyCo ...defaults.publish, ...user.publish, }, + targets: { + ...defaults.targets, + ...user.targets, + }, packages: { ...defaults.packages, ...user.packages, @@ -178,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, @@ -204,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 { 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), }; } - // Non-npm packages (custom publish command / skipNpmPublish) — derive from git tags, + // Packages without an npm target — derive from git tags, // matching how the stable flow tracks their published-ness. const tagVersions = listTags(`${pkg.name}@${target}-${preid}.*`, { cwd: rootDir }).map((t) => t.slice(pkg.name.length + 1), @@ -145,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 && !pkg.bumpy?.publishCommand) 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); @@ -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 && !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 49eefc2..fd945dd 100644 --- a/packages/bumpy/src/core/publish-pipeline.ts +++ b/packages/bumpy/src/core/publish-pipeline.ts @@ -1,151 +1,127 @@ import { resolve } from 'node:path'; -import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'; -import { unlink } from 'node:fs/promises'; +import { rm } from 'node:fs/promises'; +import semver from 'semver'; import { readJson, updateJsonNestedField } from '../utils/fs.ts'; -import { runStreaming, runArgsAsync, tryRunArgs, sq } from '../utils/shell.ts'; +import { runStreaming } from '../utils/shell.ts'; import { log, colorize } from '../utils/logger.ts'; -import { createTag, tagExists } from './git.ts'; import { DependencyGraph } from './dep-graph.ts'; import { stripProtocol } from './semver.ts'; import { resolveCatalogDep, type CatalogMap } from '../utils/package-manager.ts'; +import { getPackageTargets, 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 +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; + /** + * 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. + */ + 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; + /** + * - 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 release-metadata purposes. + */ + skipKind?: 'metadata' | 'registry' | 'staged' | 'capability'; } export interface PublishResult { + /** 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`) */ failed: { name: string; error: string }[]; + /** Per-package, per-target outcomes for this run */ + targetOutcomes: Map; } -/** - * 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; +/** 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, + }; } -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`). + * 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. */ -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'); - } +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 (dependencies published before dependents). + * + * 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, @@ -157,36 +133,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: [], staged: [], skipped: [], failed: [], targetOutcomes: new Map() }; + const releaseKind = opts.releaseKind ?? 'stable'; // Topological sort for correct publish order const topoOrder = depGraph.topologicalSort(packages); @@ -199,242 +147,301 @@ 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. + // 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).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, + options: target.options, + dryRun: !!opts.dryRun, + }); + } + } + + // 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 allTargets = getPackageTargets(pkg, config); + const targets = allTargets.filter(inPhase); + const prior = opts.priorStates?.get(release.name) ?? {}; + const buildPass = opts.phase !== 'post-release'; - // Skip private packages unless they have a custom publish command - if (pkg.private && !pkgConfig.publishCommand) { - if (config.privatePackages.tag) { - createGitTag(release, rootDir, opts); - } + // 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); + // Artifacts shared across this package's targets, keyed by artifact kind + const artifacts = new Map(); try { - // 1. Build - 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 }); } } - // 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 = + buildPass && allTargets.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 }); - } - } - } 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); + // 3. Publish each target + const isPrerelease = semver.prerelease(release.newVersion) !== null; + const inPlanDeps = planDependencies(pkg, releaseMap); + for (const target of targets) { + 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}`); } - } 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, 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; } - } - return result; -} + await cleanupArtifacts(artifacts); -/** - * 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; - } - - // Pack and capture the tarball filename - const packOutput = await runArgsAsync(packArgs, { cwd: pkg.dir }); - const tarball = parseTarballPath(packOutput, pkg.dir, packManager); - - 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 */ + 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 + ? '' + : ` (${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 }); + } + if (failedNow.length > 0) { + result.failed.push({ + name: release.name, + error: failedNow.map((o) => `${o.target}: ${o.error}`).join('; '), + }); } } + + return result; } -/** 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 }); +/** 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 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 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`, + }; } -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'); +async function publishOneTarget( + target: ResolvedTarget, + args: { + pkg: WorkspacePackage; + pkgConfig: WorkspacePackage['bumpy'] & {}; + release: PlannedRelease; + config: BumpyConfig; + rootDir: string; + opts: PublishOptions; + releaseKind: ReleaseKind; + isPrerelease: boolean; + prior: PublishTargetState | undefined; + artifacts: Map; + detectedPm: PackageManager; + }, +): Promise { + const { pkg, release, config, opts, releaseKind, isPrerelease, prior, artifacts } = args; + const base = { target: target.name, type: target.type }; + + // 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' }; } - // 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'); + // 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 (!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 }; } - // Extra user-configured args - if (config.publish.publishArgs.length > 0) { - args.push(...config.publish.publishArgs); + // 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 }; + } } - return args; -} - -/** - * 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') { - 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); + try { + const ctx: TargetPublishContext = { + pkg, + pkgConfig: args.pkgConfig, + version: release.newVersion, + rootDir: args.rootDir, + config, + options: target.options, + distTag: caps.distTags ? opts.tag : undefined, + dryRun: !!opts.dryRun, + releaseKind, + packManager: args.detectedPm, + }; + + // Per-target pre-publish step (e.g. publish-time version sync into jsr.json / + // pyproject.toml). Runs after all skip gates so a skipped target never mutates + // files. Also runs on dry runs — its validation (missing manifests, unclaimed + // packages) is exactly what dry runs exist to surface; plugins skip only their + // file writes when ctx.dryRun is set. + await target.plugin.prepare?.(ctx); + + // Shared artifact: build once per (package, kind), reuse across sibling targets + const kind = target.plugin.artifactKind?.(target.options, config); + if (kind) { + if (!artifacts.has(kind)) { + if (opts.dryRun) { + artifacts.set(kind, `<${kind}>`); + } else { + if (!target.plugin.buildArtifact) { + throw new Error(`target "${target.name}" declares artifact kind "${kind}" but has no buildArtifact`); + } + artifacts.set(kind, await target.plugin.buildArtifact(ctx)); + } } - } catch { - // JSON parse failed — fall through to regex + ctx.artifactPath = artifacts.get(kind); } - } - // 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); + 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) }; } - - // 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); } -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; +/** 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 rm(path, { recursive: true, force: true }); + } catch { + /* ignore */ + } } - createTag(tag, { cwd: rootDir }); - log.dim(` Tagged: ${tag}`); + artifacts.clear(); } /** 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 9fbea83..48c2363 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 { packagePublishesFor } 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 && !packagePublishesFor(pkg, 'snapshot')) 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..893c9dc --- /dev/null +++ b/packages/bumpy/src/core/targets/custom.ts @@ -0,0 +1,57 @@ +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. + * + * 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. + */ +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; + 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/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 new file mode 100644 index 0000000..42d1484 --- /dev/null +++ b/packages/bumpy/src/core/targets/jsr.ts @@ -0,0 +1,163 @@ +import { resolve } from 'node:path'; +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'; +import { buildPublishUrl } from '../github-release.ts'; +import type { WorkspacePackage } from '../../types.ts'; +import { stringArrayOption } from './util.ts'; +import type { PublishTargetPlugin } from './types.ts'; + +/** + * JSR (jsr.io) target. + * + * JSR publishes TypeScript source described by `jsr.json` (name, version, exports). + * Two version-sync facts shape this target: + * - `jsr.json` has its own `version` field, but it does NOT need to be committed in + * the release PR — the target syncs it from package.json into the working tree at + * publish time (commit it as `0.0.0` and forget it). That's also why publishes run + * with `--allow-dirty`: the tree is intentionally modified (version sync here, + * workspace:/catalog: resolution by the pipeline — JSR reads npm dependency ranges + * from package.json, and deno silently drops deps with protocol specifiers). + * - JSR has no create-on-first-publish: packages must be claimed in the scope on + * jsr.io first. The publish step detects an unclaimed package and says so, rather + * than surfacing deno's less helpful error. + * + * Auth: token-less OIDC trusted publishing from GitHub Actions (`id-token: write`) + * once the package's GitHub repo is linked on jsr.io; `JSR_TOKEN` otherwise. + * + * Options: + * - `allowSlowTypes` (boolean, default false) — pass `--allow-slow-types` + * - `publishArgs` (string[]) — extra args for `jsr publish` + * + * Credit: the publish-time version sync, catalog-resolution requirement, and + * claim-first bootstrap behavior are all lessons from Drake Costa's (@Saeris — + * https://github.com/Saeris) JSR publishing setup in mirrordown, an early bumpy + * adopter. Thanks Drake! + */ + +const JSR_BIN = ['npx', '--yes', 'jsr']; +const JSR_API = 'https://api.jsr.io'; + +function 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 */ +async function jsrApiStatus(path: string): Promise { + try { + const res = await fetch(`${JSR_API}${path}`); + return res.status; + } catch { + return null; + } +} + +export const jsrTarget: PublishTargetPlugin = { + type: 'jsr', + // JSR versions are semver (prereleases fine), but there are no dist-tags — so + // snapshots, which are only reachable via a throwaway tag, don't make sense. + capabilities: { distTags: false, prereleases: true, snapshots: false }, + + detect(pkg) { + return existsSync(resolve(pkg.dir, 'jsr.json')); + }, + + label() { + return 'JSR'; + }, + + needsProtocolResolution() { + // JSR resolves npm deps from package.json — protocol specifiers must be concrete + return true; + }, + + async checkPublished(pkg, version, _options) { + const id = scopeAndName(pkg); + if (!id) return null; // JSR packages are always scoped + const status = await jsrApiStatus(`/scopes/${id.scope}/packages/${id.name}/versions/${version}`); + if (status === null) return null; // network hiccup — unknown + return status === 200; + }, + + async prepare(ctx) { + const 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 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( + `${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`, + ); + } + + // Sync jsr.json's version from the release (formatting-preserving in-place edit). + // A jsr.json without a version field can't be synced — say so instead of letting + // deno fail with a less helpful parse error. + const jsrJson = await readJson<{ version?: unknown }>(jsrJsonPath); + if (typeof jsrJson.version !== 'string') { + throw new Error(`${ctx.pkg.name}: jsr.json has no "version" field — add one (any placeholder, e.g. "0.0.0")`); + } + if (!ctx.dryRun && jsrJson.version !== ctx.version) { + await updateJsonFields(jsrJsonPath, { version: ctx.version }); + } + }, + + async publish(ctx) { + const args = [...JSR_BIN, 'publish', '--allow-dirty']; + if (ctx.options.allowSlowTypes === true) args.push('--allow-slow-types'); + args.push(...stringArrayOption(ctx.options, 'publishArgs')); + + if (ctx.dryRun) { + log.dim(` Would publish with: ${args.join(' ')}`); + return; + } + + log.dim(` Publishing: ${args.join(' ')}`); + await runArgsAsync(args, { cwd: ctx.pkg.dir }); + }, + + publishUrl(pkg, version) { + return buildPublishUrl(jsrPackageName(pkg), 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..90f8fd3 --- /dev/null +++ b/packages/bumpy/src/core/targets/npm.ts @@ -0,0 +1,366 @@ +import { resolve } from 'node:path'; +import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'; +import { runArgsAsync, tryRunArgs } from '../../utils/shell.ts'; +import { log } from '../../utils/logger.ts'; +import { buildPublishUrl, publishTargetLabel, resolvePackageRegistry } from '../github-release.ts'; +import type { BumpyConfig, PackageConfig, PackageManager, PublishConfig, WorkspacePackage } from '../../types.ts'; +import type { PublishTargetPlugin, TargetOptions, TargetPublishContext } from './types.ts'; + +/** + * Detect which CI OIDC provider is available for npm trusted publishing. + * Returns the provider name or null if none detected. + * + * Supported providers: + * - GitHub Actions: `ACTIONS_ID_TOKEN_REQUEST_URL` (set when `id-token: write` permission is granted) + * - GitLab CI: `GITLAB_CI` + `NPM_ID_TOKEN` + * - CircleCI: `CIRCLECI` + `NPM_ID_TOKEN` + */ +export function detectOidcProvider(): 'github-actions' | 'gitlab' | 'circleci' | null { + if (process.env.ACTIONS_ID_TOKEN_REQUEST_URL) return 'github-actions'; + if (process.env.GITLAB_CI && process.env.NPM_ID_TOKEN) return 'gitlab'; + if (process.env.CIRCLECI && process.env.NPM_ID_TOKEN) return 'circleci'; + return null; +} + +/** + * Returns true when OIDC trusted publishing is the only available npm auth path: + * an OIDC provider is detected AND no token env vars or .npmrc auth are present. + * + * Used to gate checks that only matter when OIDC will definitely be used — e.g. + * erroring when a brand-new package can't be bootstrapped via trusted publishing. + * Detection alone is leaky (id-token: write is also set for provenance), so this + * helper avoids false positives when a token fallback exists. + */ +export function willUseOidcExclusively(rootDir: string): boolean { + if (!detectOidcProvider()) return false; + if (process.env.NPM_TOKEN || process.env.NODE_AUTH_TOKEN) return false; + const npmrcPath = resolve(rootDir, '.npmrc'); + const existingNpmrc = existsSync(npmrcPath) ? readFileSync(npmrcPath, 'utf-8') : ''; + return !existingNpmrc.includes(':_authToken='); +} + +const OIDC_NPM_UPGRADE_HINTS: Record = { + 'github-actions': 'Add `actions/setup-node@v6` with `node-version: lts/*` to your workflow', + gitlab: 'Use a Node.js image with npm >= 11.5.1 or run `npm install -g npm@latest`', + circleci: 'Use a Node.js image with npm >= 11.5.1 or run `sudo npm install -g npm@latest`', +}; + +/** Compare semver triples: returns true if version >= minimum */ +export function npmVersionAtLeast(version: string, minimum: [number, number, number]): boolean { + const [major, minor, patch] = version.split('.').map(Number); + const [minMajor, minMinor, minPatch] = minimum; + if (major! > minMajor) return true; + if (major! < minMajor) return false; + if (minor! > minMinor) return true; + if (minor! < minMinor) return false; + return patch! >= minPatch; +} + +const MIN_NPM_OIDC: [number, number, number] = [11, 5, 1]; +const MIN_NPM_STAGED: [number, number, number] = [11, 15, 0]; + +/** + * The npm target's effective options: the legacy root `publish` block provides + * defaults, overridden by `targets.npm` / instance options (merged by the resolver). + */ +function npmOptions(config: BumpyConfig, options: TargetOptions): PublishConfig & TargetOptions { + return { ...config.publish, ...options } as PublishConfig & TargetOptions; +} + +/** + * The registry an npm-type target instance publishes to, resolved through the full + * fallback chain: instance options -> bumpy `registry` field -> package.json + * `publishConfig.registry`. The single source of truth for "which registry" — + * every consumer (publish args, existence checks, prerelease counters, labels, + * URLs) must go through this so they can never disagree. + */ +export function npmEffectiveRegistry( + pkg: WorkspacePackage, + pkgConfig: PackageConfig, + options: TargetOptions, +): string | undefined { + if (typeof options.registry === 'string' && options.registry) return options.registry; + return resolvePackageRegistry(pkg, pkgConfig); +} + +/** + * Set up npm authentication for publishing. + * + * Handles three scenarios: + * 1. **Trusted publishing (OIDC)** — GitHub Actions, GitLab CI, or CircleCI with OIDC configured. + * npm >= 11.5.1 authenticates automatically via OIDC token exchange. + * No secret needed, but we check the npm version and warn if too old. + * 2. **Token-based auth** — `NPM_TOKEN` or `NODE_AUTH_TOKEN` env var. + * Writes a project-level `.npmrc` so npm can authenticate. + * 3. **Pre-configured** — user already has `.npmrc` with auth (e.g. via `actions/setup-node`). + */ +function setupNpmAuth(rootDir: string, publishManager: string): void { + // Only relevant when publishing via npm CLI + if (publishManager !== 'npm') return; + + const npmrcPath = resolve(rootDir, '.npmrc'); + const existingNpmrc = existsSync(npmrcPath) ? readFileSync(npmrcPath, 'utf-8') : ''; + const hasAuthConfigured = existingNpmrc.includes(':_authToken='); + + // If auth is already configured (e.g. via actions/setup-node), nothing to do + if (hasAuthConfigured) { + log.dim(' Using existing .npmrc auth configuration'); + return; + } + + // Scenario 1: OIDC trusted publishing + const oidcProvider = detectOidcProvider(); + if (oidcProvider) { + const npmVersion = tryRunArgs(['npm', '--version']); + if (npmVersion) { + if (!npmVersionAtLeast(npmVersion, MIN_NPM_OIDC)) { + log.warn(` npm ${npmVersion} detected — trusted publishing (OIDC) requires npm >= ${MIN_NPM_OIDC.join('.')}`); + log.warn(` ${OIDC_NPM_UPGRADE_HINTS[oidcProvider]}`); + } else { + log.dim(` OIDC detected (${oidcProvider}) — npm ${npmVersion} will authenticate via trusted publishing`); + } + } + return; + } + + // Scenario 2: Token-based auth via environment variable + // Support NPM_TOKEN (common convention) by mapping to NODE_AUTH_TOKEN (what npm reads from .npmrc) + const token = process.env.NODE_AUTH_TOKEN || process.env.NPM_TOKEN; + if (token) { + if (process.env.NPM_TOKEN && !process.env.NODE_AUTH_TOKEN) { + process.env.NODE_AUTH_TOKEN = token; + } + const authLine = '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}'; + if (existingNpmrc) { + appendFileSync(npmrcPath, `\n${authLine}\n`); + } else { + writeFileSync(npmrcPath, `${authLine}\n`); + } + log.dim(' Configured .npmrc with auth token'); + return; + } + + // No auth detected — warn + if (process.env.CI) { + log.warn(' No npm authentication detected. Publishing will likely fail.'); + log.warn(' Options:'); + log.warn(' • Trusted publishing (OIDC): add `id-token: write` permission + npm >= 11.5.1'); + log.warn(' • Token auth: set NPM_TOKEN or NODE_AUTH_TOKEN environment variable'); + log.warn(' • Manual: add `actions/setup-node` with `registry-url` to your workflow'); + } +} + +function getPackArgs(pm: PackageManager): string[] { + switch (pm) { + case 'pnpm': + return ['pnpm', 'pack', '--json']; + case 'bun': + return ['bun', 'pm', 'pack']; + case 'yarn': + return ['yarn', 'pack']; + case 'npm': + default: + return ['npm', 'pack', '--json']; + } +} + +/** + * 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 (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 { + args.push(publishManager, 'publish'); + } + + // Tarball path (if pack-then-publish) + if (tarball) args.push(tarball); + + // Access + const access = (ctx.options.access as string | undefined) || ctx.pkgConfig.access || ctx.config.access; + args.push('--access', access); + + // Registry + const registry = npmEffectiveRegistry(ctx.pkg, ctx.pkgConfig, ctx.options); + if (registry) args.push('--registry', registry); + + // Dist tag + if (ctx.distTag) args.push('--tag', ctx.distTag); + + // Provenance attestation + if (o.provenance && publishManager === 'npm') { + args.push('--provenance'); + } + + // Extra user-configured args + if (Array.isArray(o.publishArgs) && o.publishArgs.length > 0) { + args.push(...o.publishArgs); + } + + return args; +} + +/** + * Parse the tarball path from pack command output. + * npm/pnpm use --json for structured output; bun/yarn fall back to regex parsing. + */ +export function parseTarballPath(output: string, cwd: string, pm: PackageManager): string { + // npm and pnpm support --json which gives us a deterministic filename + if (pm === 'npm' || pm === 'pnpm') { + try { + const parsed = JSON.parse(output); + // npm returns an array, pnpm returns an object or array + const entry = Array.isArray(parsed) ? parsed[0] : parsed; + if (entry?.filename) { + return resolve(cwd, entry.filename); + } + } catch { + // JSON parse failed — fall through to regex + } + } + + // Fallback for bun/yarn or if JSON parsing failed: + // extract any .tgz path — handles both bare filenames and quoted paths (yarn) + const tgzMatch = output.match(/(?:^|["'\s])([^\s"']*\.tgz)/m); + if (tgzMatch) { + const tarball = tgzMatch[1]!; + return tarball.startsWith('/') ? tarball : resolve(cwd, tarball); + } + + // Last resort: last non-empty line + const lines = output.trim().split('\n').filter(Boolean); + const lastLine = lines[lines.length - 1]?.trim() || ''; + return lastLine.startsWith('/') ? lastLine : resolve(cwd, lastLine); +} + +export const npmTarget: PublishTargetPlugin = { + type: 'npm', + capabilities: { distTags: true, prereleases: true, snapshots: true, refusesPrivatePackages: true }, + + detect(pkg) { + return !pkg.private; + }, + + label(options, pkg) { + // Refines the common cases (e.g. "GitHub Packages"); named instances otherwise + // label themselves via the metadata key. + const registry = pkg + ? npmEffectiveRegistry(pkg, pkg.bumpy || {}, options) + : typeof options.registry === 'string' + ? options.registry + : undefined; + return publishTargetLabel('npm', registry); + }, + + async preflight(ctx) { + const o = npmOptions(ctx.config, ctx.options); + + if (o.provenance && o.publishManager !== 'npm') { + throw new Error('provenance requires publishManager "npm" — provenance attestation is an npm-specific feature'); + } + + if (o.npmStaged) { + if (o.publishManager !== 'npm') { + throw new Error('npmStaged requires publishManager "npm" — staged publishing is an npm-specific feature'); + } + const npmVersion = tryRunArgs(['npm', '--version']); + if (!npmVersion) { + throw new Error(`npmStaged is enabled but npm was not found — install npm >= ${MIN_NPM_STAGED.join('.')}`); + } + if (!npmVersionAtLeast(npmVersion, MIN_NPM_STAGED)) { + throw new Error( + `npmStaged requires npm >= ${MIN_NPM_STAGED.join('.')} (found ${npmVersion})\n` + + ` Upgrade npm: npm install -g npm@latest`, + ); + } + log.dim(`Staged publishing enabled — packages will require 2FA approval on npmjs.com`); + } + + setupNpmAuth(ctx.rootDir, o.publishManager); + }, + + async checkPublished(pkg, version, options) { + try { + const args = ['npm', 'info', `${pkg.name}@${version}`, 'version']; + const registry = npmEffectiveRegistry(pkg, pkg.bumpy || {}, options); + if (registry) args.push('--registry', registry); + const result = await runArgsAsync(args, { timeoutMs: 60_000 }); + 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(' ')}`); + 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) { + return buildPublishUrl(pkg.name, version, 'npm', { + registry: npmEffectiveRegistry(pkg, pkg.bumpy || {}, options), + repoSlug: extra.repoSlug, + }); + }, +}; diff --git a/packages/bumpy/src/core/targets/pypi.ts b/packages/bumpy/src/core/targets/pypi.ts new file mode 100644 index 0000000..6d238f8 --- /dev/null +++ b/packages/bumpy/src/core/targets/pypi.ts @@ -0,0 +1,222 @@ +import { resolve } from 'node:path'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { runArgsAsync, tryRunArgs } from '../../utils/shell.ts'; +import { log } from '../../utils/logger.ts'; +import type { WorkspacePackage } from '../../types.ts'; +import { stringArrayOption, stringOption } from './util.ts'; +import type { PublishTargetPlugin, TargetPublishContext } from './types.ts'; + +/** + * PyPI target — publishes a Python package living inside the (package.json-driven) + * workspace. bumpy's versioning spine stays package.json: give the Python package a + * stub `package.json` (`"private": true` + `bumpy.publishTargets: ["pypi"]`) and this + * target syncs the version into `pyproject.toml` at publish time — the same + * publish-time-sync model as the jsr target, so nothing extra is committed in the + * release PR. + * + * Build/upload run through `uv` (`uv build` / `uv publish`): + * - builds into an isolated per-version out-dir, so stale artifacts in `dist/` from + * earlier builds can never be uploaded alongside the new release + * - `uv publish` supports PyPI **trusted publishing** (OIDC) natively on GitHub + * Actions (`id-token: write`), or a token via `UV_PUBLISH_TOKEN` + * + * The PyPI project name comes from `pyproject.toml` `[project].name` (the source of + * truth — npm names, especially scoped ones, are not valid PyPI names). + * + * Capabilities: PyPI has no dist-tags, and PEP 440 versions don't cover bumpy's + * semver prerelease/snapshot suffixes (`-next.0`, `-preview-abc123` are invalid + * there), so channel prereleases and snapshots are skipped, not attempted. + * + * Options: + * - `index` (string) — alternative index URL passed to `uv publish --publish-url` + * - `buildArgs` / `publishArgs` (string[]) — extra args for the respective step + */ + +const OUT_DIR_PREFIX = '.bumpy-pypi-dist'; + +interface PyprojectInfo { + raw: string; + /** [project].name */ + name?: string; + /** [project].version (undefined when absent or listed in `dynamic`) */ + version?: string; + dynamicVersion: boolean; +} + +/** + * Minimal pyproject.toml inspection: extracts `name`/`version` from the `[project]` + * table via line matching (full TOML parsing is overkill for two flat keys). + */ +export function parsePyproject(raw: string): PyprojectInfo { + const projectSection = extractTomlSection(raw, 'project'); + const name = matchTomlString(projectSection, 'name'); + const version = matchTomlString(projectSection, 'version'); + const dynamic = projectSection.match(/^\s*dynamic\s*=\s*\[([^\]]*)\]/m)?.[1] ?? ''; + return { + raw, + name, + version, + dynamicVersion: /["']version["']/.test(dynamic), + }; +} + +/** The lines of one `[section]` table (up to the next top-level `[table]` header) */ +function extractTomlSection(raw: string, section: string): string { + const match = raw.match(new RegExp(`^\\[${section}\\]\\s*$([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, 'm')); + return match?.[1] ?? ''; +} + +function matchTomlString(sectionBody: string, key: string): string | undefined { + return sectionBody.match(new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']*)["']`, 'm'))?.[1]; +} + +/** + * Rewrite `[project].version` in-place, preserving all other formatting. + * Returns the updated content, or null if the version key couldn't be located. + */ +export function updatePyprojectVersion(raw: string, newVersion: string): string | null { + const sectionStart = raw.match(/^\[project\]\s*$/m); + if (sectionStart?.index === undefined) return null; + const bodyStart = sectionStart.index + sectionStart[0].length; + const rest = raw.slice(bodyStart); + const nextSection = rest.search(/^\[/m); + const body = nextSection === -1 ? rest : rest.slice(0, nextSection); + + const versionLine = body.match(/^(\s*version\s*=\s*)(["'])[^"']*\2/m); + if (versionLine?.index === undefined) return null; + + const absolute = bodyStart + versionLine.index; + return ( + raw.slice(0, absolute) + + `${versionLine[1]}${versionLine[2]}${newVersion}${versionLine[2]}` + + raw.slice(absolute + versionLine[0].length) + ); +} + +function loadPyproject(pkg: WorkspacePackage): PyprojectInfo | null { + const path = resolve(pkg.dir, 'pyproject.toml'); + if (!existsSync(path)) return null; + return parsePyproject(readFileSync(path, 'utf-8')); +} + +/** PEP 503 normalization for URL/API paths: lowercase, runs of -_. collapse to - */ +function normalizePypiName(name: string): string { + return name.toLowerCase().replace(/[-_.]+/g, '-'); +} + +/** Sync pyproject.toml's [project].version from the version being published */ +function syncPyprojectVersion(ctx: TargetPublishContext): void { + const path = resolve(ctx.pkg.dir, 'pyproject.toml'); + const info = parsePyproject(readFileSync(path, 'utf-8')); + if (info.dynamicVersion) { + throw new Error( + `${ctx.pkg.name}: pyproject.toml declares a dynamic version — bumpy can't sync it. ` + + `Use a static [project] version (commit any placeholder; bumpy rewrites it at publish time).`, + ); + } + if (info.version === ctx.version) return; + // Validate even on dry runs — only the write is skipped + const updated = updatePyprojectVersion(info.raw, ctx.version); + if (updated === null) { + throw new Error(`${ctx.pkg.name}: could not find a static [project] version in pyproject.toml to sync`); + } + if (!ctx.dryRun) writeFileSync(path, updated); +} + +export const pypiTarget: PublishTargetPlugin = { + type: 'pypi', + capabilities: { distTags: false, prereleases: false, snapshots: false }, + + detect(pkg) { + return existsSync(resolve(pkg.dir, 'pyproject.toml')); + }, + + label() { + return 'PyPI'; + }, + + async preflight(ctx) { + const uvVersion = tryRunArgs(['uv', '--version']); + if (!uvVersion) { + throw new Error( + 'pypi target requires the `uv` CLI for building and publishing — ' + + 'install it (https://docs.astral.sh/uv/) or use a custom target with your own commands', + ); + } + // Auth: trusted publishing (OIDC) needs no secret on GitHub Actions; otherwise a token + if ( + !ctx.dryRun && + !process.env.UV_PUBLISH_TOKEN && + !process.env.ACTIONS_ID_TOKEN_REQUEST_URL && + !ctx.options.index + ) { + log.warn(' No PyPI auth detected — set UV_PUBLISH_TOKEN or configure trusted publishing (OIDC)'); + } + }, + + async checkPublished(pkg, version, options) { + // Only pypi.org is queryable generically; custom indexes fall back to git tags + if (options.index) return null; + const info = loadPyproject(pkg); + if (!info?.name) return null; + try { + const res = await fetch(`https://pypi.org/pypi/${normalizePypiName(info.name)}/${version}/json`); + if (res.status === 200) return true; + if (res.status === 404) return false; + return null; + } catch { + return null; // network hiccup — unknown + } + }, + + artifactKind() { + return 'python-dist'; + }, + + async prepare(ctx) { + if (!existsSync(resolve(ctx.pkg.dir, 'pyproject.toml'))) { + throw new Error(`${ctx.pkg.name}: pypi target requires a pyproject.toml`); + } + syncPyprojectVersion(ctx); + }, + + async buildArtifact(ctx) { + // Isolated out-dir: `dist/` may hold stale builds of other versions, and + // uploading a directory wholesale is how old artifacts leak into a release + const outDir = resolve(ctx.pkg.dir, `${OUT_DIR_PREFIX}-${ctx.version}`); + const buildArgs = stringArrayOption(ctx.options, 'buildArgs'); + const args = ['uv', 'build', '--out-dir', outDir, ...buildArgs]; + log.dim(` Building: ${args.join(' ')}`); + await runArgsAsync(args, { cwd: ctx.pkg.dir }); + return outDir; + }, + + async publish(ctx) { + const args = ['uv', 'publish']; + const index = stringOption(ctx.options, 'index'); + if (index) args.push('--publish-url', index); + args.push(...stringArrayOption(ctx.options, 'publishArgs')); + + if (ctx.dryRun) { + log.dim(` Would publish with: ${args.join(' ')} `); + return; + } + + // Explicit file list (no shell globbing) from the isolated build dir + const distFiles = (await readdir(ctx.artifactPath!)).map((f) => resolve(ctx.artifactPath!, f)); + if (distFiles.length === 0) { + throw new Error(`${ctx.pkg.name}: uv build produced no distributions in ${ctx.artifactPath}`); + } + args.push(...distFiles); + + log.dim(` Publishing: ${args.join(' ')}`); + await runArgsAsync(args, { cwd: ctx.pkg.dir }); + }, + + publishUrl(pkg, version) { + const info = loadPyproject(pkg); + if (!info?.name) return undefined; + return `https://pypi.org/project/${normalizePypiName(info.name)}/${version}/`; + }, +}; diff --git a/packages/bumpy/src/core/targets/registry.ts b/packages/bumpy/src/core/targets/registry.ts new file mode 100644 index 0000000..6ed9bc2 --- /dev/null +++ b/packages/bumpy/src/core/targets/registry.ts @@ -0,0 +1,209 @@ +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 { githubReleaseAssetsTarget } from './github-release-assets.ts'; +import { dockerTarget } from './docker.ts'; +import { homebrewTarget } from './homebrew.ts'; +import type { + BumpyConfig, + PackageConfig, + PackageTargetEntry, + TargetDefinition, + WorkspacePackage, +} from '../../types.ts'; +import type { PublishTargetPlugin, ReleaseKind, ResolvedTarget, TargetOptions, TargetPhase } from './types.ts'; + +/** + * Built-in publish targets. These register through the same interface external + * plugins will eventually load through — being built-in is a packaging choice, + * not an architectural one. + */ +const BUILT_IN_TARGETS: Record = { + [npmTarget.type]: npmTarget, + [customTarget.type]: customTarget, + [jsrTarget.type]: jsrTarget, + [pypiTarget.type]: pypiTarget, + [vscodeMarketplaceTarget.type]: vscodeMarketplaceTarget, + [openVsxTarget.type]: openVsxTarget, + [githubReleaseAssetsTarget.type]: githubReleaseAssetsTarget, + [dockerTarget.type]: dockerTarget, + [homebrewTarget.type]: homebrewTarget, +}; + +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; +} + +/** 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 {}; + const { type: _type, ...options } = def; + return options; +} + +function resolveStringEntry(ref: string, config: BumpyConfig | undefined, pkgName: string): ResolvedTarget { + const def = config?.targets?.[ref]; + + // 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( + `targets["${ref}"] sets type "${def.type}", but "${ref}" is a built-in target type — ` + + `rename the entry to define a separate named instance`, + ); + } + return instance(ref, ref, BUILT_IN_TARGETS[ref], 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 instance(ref, def.type, plugin, 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 instance(typeof name === 'string' && name ? name : type, type, plugin, options); +} + +/** + * Resolve the publish targets for a package: explicit `publishTargets` config if + * 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 + * while never touching npm. + */ +export function resolvePackageTargets( + pkg: Pick, + pkgConfig: PackageConfig, + config?: BumpyConfig, +): ResolvedTarget[] { + const entries = pkgConfig.publishTargets ?? (pkg.private ? [] : ['npm']); + + const resolved: ResolvedTarget[] = []; + for (const entry of entries) { + const target = + typeof entry === 'string' + ? resolveStringEntry(entry, config, pkg.name) + : resolveInlineEntry(entry, config, pkg.name); + + if (pkg.private && target.plugin.capabilities.refusesPrivatePackages) { + log.warn( + ` ${pkg.name}: dropping publish target "${target.name}" — package is "private": true (${target.type} refuses to publish it)`, + ); + continue; + } + if (resolved.some((t) => t.name === target.name)) { + throw new Error( + `Package "${pkg.name}" has duplicate publish target name "${target.name}" — ` + + `give one instance an explicit unique "name" (it keys the release metadata)`, + ); + } + resolved.push(target); + } + return resolved; +} + +/** + * Publish targets for a package: the instances attached at workspace discovery, or a + * lazy resolution for hand-constructed packages (tests, partial contexts). Without a + * root config, named-instance references can't resolve — pass `config` when you have it. + */ +export function getPackageTargets(pkg: WorkspacePackage, config?: BumpyConfig): ResolvedTarget[] { + if (pkg.targets) return pkg.targets; + return resolvePackageTargets(pkg, pkg.bumpy || {}, config); +} + +/** Whether this package publishes anywhere at all */ +export function packagePublishes(pkg: WorkspacePackage, config?: BumpyConfig): boolean { + return getPackageTargets(pkg, config).length > 0; +} + +/** + * 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'); +} + +/** 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..b224c2b --- /dev/null +++ b/packages/bumpy/src/core/targets/types.ts @@ -0,0 +1,145 @@ +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'; + +/** + * 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; + /** Can publish semver prerelease versions (e.g. `1.2.0-rc.0`) */ + prereleases: boolean; + /** Participates in transient snapshot releases (`bumpy publish --snapshot`) */ + snapshots: boolean; + /** + * The registry refuses `"private": true` packages (npm's marker). Instances of + * such targets are dropped from private packages at resolve time — which is what + * lets a private VS Code extension publish to marketplaces while never touching npm. + */ + refusesPrivatePackages?: boolean; +} + +/** Context for the once-per-target-instance preflight hook, run before any publish */ +export interface TargetPreflightContext { + rootDir: string; + config: BumpyConfig; + options: TargetOptions; + dryRun: boolean; +} + +/** Context for per-package target operations (publish, buildArtifact) */ +export interface TargetPublishContext { + pkg: WorkspacePackage; + /** Merged per-package bumpy config (legacy fields like `registry`/`access` live here) */ + pkgConfig: PackageConfig; + /** The version being published (already written to the package manifest) */ + version: string; + rootDir: string; + config: BumpyConfig; + /** Merged options for this target instance */ + options: TargetOptions; + /** npm-style dist-tag for this publish, when the release flow provides one */ + distTag?: string; + dryRun: boolean; + releaseKind: ReleaseKind; + /** Path to the shared artifact, when the plugin declares an artifactKind */ + artifactPath?: string; + /** Detected workspace package manager (pack strategies may use it) */ + packManager: PackageManager; +} + +/** + * A publish target plugin. Built-in targets (npm, custom, vscode-marketplace, open-vsx) + * implement this interface; it is also the seam future external plugins load through. + * + * Lifecycle within one `bumpy publish` run: + * 1. `preflight` — once per resolved target instance, before anything publishes + * (auth/tooling validation; throw to abort the whole run) + * 2. per package, in topo order: + * a. `artifactKind`/`buildArtifact` — artifacts are cached per package by kind, so + * multiple targets sharing a kind (e.g. one .vsix → marketplace + Open VSX) get + * the same file + * b. `publish` — one target failing does not block sibling targets; state is + * recorded per target in the GitHub release metadata and retried on the next run + */ +export interface PublishTargetPlugin { + type: string; + capabilities: TargetCapabilities; + /** 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. */ + label?(options: TargetOptions, pkg?: WorkspacePackage): string; + preflight?(ctx: TargetPreflightContext): void | Promise; + /** + * Per-package pre-publish step, run after the skip gates (capabilities, resume, + * registry guard) and before artifact building. The home for publish-time version + * syncing into ecosystem manifests (jsr.json, pyproject.toml). Also called on dry + * runs so config validation surfaces there — check `ctx.dryRun` and skip file + * mutations only. + */ + prepare?(ctx: TargetPublishContext): void | Promise; + /** + * Whether `version` is already live on this target. 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; + /** + * 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; +} + +/** + * 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 { + /** + * 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; + phase: TargetPhase; +} diff --git a/packages/bumpy/src/core/targets/util.ts b/packages/bumpy/src/core/targets/util.ts new file mode 100644 index 0000000..cc02293 --- /dev/null +++ b/packages/bumpy/src/core/targets/util.ts @@ -0,0 +1,91 @@ +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) */ +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; +} + +/** 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 new file mode 100644 index 0000000..c6511b0 --- /dev/null +++ b/packages/bumpy/src/core/targets/vscode.ts @@ -0,0 +1,193 @@ +import { resolve } from 'node:path'; +import { runArgsAsync, runStreaming, sq } from '../../utils/shell.ts'; +import { log } from '../../utils/logger.ts'; +import { stringArrayOption } from './util.ts'; +import type { WorkspacePackage } from '../../types.ts'; +import type { PublishTargetPlugin, TargetPublishContext } from './types.ts'; + +/** + * VS Code Marketplace and Open VSX targets. + * + * Both publish the same packaged `.vsix`, so they share the "vsix" artifact kind — + * the pipeline builds it once (via `vsce package`) and hands the same file to both. + * Publishing from a prebuilt vsix also keeps vsce/ovsx from re-running their own + * version bumps or builds. + * + * Auth: vsce reads `VSCE_PAT`, ovsx reads `OVSX_PAT` (both native to the CLIs). + * The CLIs are invoked through `npx --yes`, so a repo-local devDependency wins and + * otherwise the published CLI is fetched on demand. + * + * The Marketplace requires plain `major.minor.patch` versions — semver prerelease + * suffixes are rejected — so both targets opt out of prereleases and snapshots. + */ + +const VSCE_BIN = ['npx', '--yes', '@vscode/vsce']; +const OVSX_BIN = ['npx', '--yes', 'ovsx']; + +function extensionId(pkg: WorkspacePackage): string { + const publisher = pkg.packageJson.publisher; + if (typeof publisher !== 'string' || !publisher) { + throw new Error(`${pkg.name}: VS Code extension targets require a "publisher" field in package.json`); + } + return `${publisher}.${pkg.name}`; +} + +function looksLikeVscodeExtension(pkg: WorkspacePackage): boolean { + const engines = pkg.packageJson.engines; + const hasVscodeEngine = !!engines && typeof engines === 'object' && 'vscode' in (engines as Record); + return hasVscodeEngine && typeof pkg.packageJson.publisher === 'string'; +} + +function vsixPath(ctx: TargetPublishContext): string { + // vsce's default filename, made explicit so both targets agree on the path + return resolve(ctx.pkg.dir, `${ctx.pkg.name}-${ctx.version}.vsix`); +} + +async function buildVsix(ctx: TargetPublishContext): Promise { + const out = vsixPath(ctx); + const extraArgs = stringArrayOption(ctx.options, 'packageArgs'); + // --no-dependencies by default: vsce's npm-based dependency detection breaks in + // workspace monorepos (workspace:/catalog: protocols, hoisted node_modules) and + // silently ships a broken vsix. Bundled extensions (the norm) don't need it; + // set `dependencies: true` on the target to restore vsce's default behavior. + const depArgs = ctx.options.dependencies === true ? [] : ['--no-dependencies']; + const cmd = [...VSCE_BIN, 'package', ...depArgs, '--out', sq(out), ...extraArgs.map(sq)].join(' '); + log.dim(` Packaging vsix: ${cmd}`); + // Stream output — vsce runs the extension's (pre)publish build, which can be chatty/slow + await runStreaming(cmd, { cwd: ctx.pkg.dir }); + return out; +} + +/** Run a CLI and parse the published version out of its JSON output. Null = unknown. */ +async function fetchPublishedVersion(args: string[], extract: (json: unknown) => unknown): Promise { + 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, { 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; + } +} + +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 + // `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 versions === null ? null : versions.includes(version); + }, + + artifactKind() { + return 'vsix'; + }, + + needsProtocolResolution() { + // vsce reads package.json from the package dir when packaging + return true; + }, + + buildArtifact: buildVsix, + + async publish(ctx) { + const extraArgs = stringArrayOption(ctx.options, 'publishArgs'); + const authArgs = ctx.options.azureCredential === true ? ['--azure-credential'] : []; + const args = [...VSCE_BIN, 'publish', '--packagePath', ctx.artifactPath!, ...authArgs, ...extraArgs]; + if (ctx.dryRun) { + log.dim(` Would publish with: ${args.join(' ')}`); + return; + } + log.dim(` Publishing: ${args.join(' ')}`); + await runArgsAsync(args, { cwd: ctx.pkg.dir }); + }, + + publishUrl(pkg) { + return `https://marketplace.visualstudio.com/items?itemName=${extensionId(pkg)}`; + }, +}; + +export const openVsxTarget: PublishTargetPlugin = { + type: 'open-vsx', + capabilities: { distTags: false, prereleases: false, snapshots: false }, + + detect: looksLikeVscodeExtension, + + label() { + return 'Open VSX'; + }, + + async preflight(ctx) { + if (!ctx.dryRun && !process.env.OVSX_PAT) { + log.warn(' OVSX_PAT is not set — ovsx publish will likely fail'); + } + }, + + async checkPublished(pkg, version, _options) { + if (!looksLikeVscodeExtension(pkg)) return null; // no publisher — can't query + const 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() { + return 'vsix'; + }, + + needsProtocolResolution() { + return true; + }, + + buildArtifact: buildVsix, + + async publish(ctx) { + const extraArgs = stringArrayOption(ctx.options, 'publishArgs'); + const args = [...OVSX_BIN, 'publish', ctx.artifactPath!, ...extraArgs]; + if (ctx.dryRun) { + log.dim(` Would publish with: ${args.join(' ')}`); + return; + } + log.dim(` Publishing: ${args.join(' ')}`); + await runArgsAsync(args, { cwd: ctx.pkg.dir }); + }, + + publishUrl(pkg, version) { + const publisher = String(pkg.packageJson.publisher ?? ''); + return `https://open-vsx.org/extension/${publisher}/${pkg.name}/${version}`; + }, +}; diff --git a/packages/bumpy/src/core/workspace.ts b/packages/bumpy/src/core/workspace.ts index 623fe88..fb48ba3 100644 --- a/packages/bumpy/src/core/workspace.ts +++ b/packages/bumpy/src/core/workspace.ts @@ -3,6 +3,8 @@ import { readdir, stat } from 'node:fs/promises'; import { readJson, exists } from '../utils/fs.ts'; import { detectWorkspaces, type CatalogMap } from '../utils/package-manager.ts'; import { loadPackageConfig, isPackageManaged } from './config.ts'; +import { resolvePackageTargets } from './targets/registry.ts'; +import { log } from '../utils/logger.ts'; import type { BumpyConfig, WorkspacePackage } from '../types.ts'; export interface WorkspaceDiscoveryResult { @@ -133,6 +135,20 @@ async function loadWorkspacePackage( if (!name) return null; const bumpy = await loadPackageConfig(dir, config, name); + const isPrivate = !!pkg.private; + + // Resolve publish targets once at discovery so downstream consumers can answer + // "where does this package publish" without the root config in hand. A config + // mistake here must not brick read-only commands (status/add/check) — record the + // error and let publish flows refuse loudly instead. + let targets: WorkspacePackage['targets'] = []; + let targetsError: string | undefined; + try { + targets = resolvePackageTargets({ name, private: isPrivate }, bumpy, config); + } catch (err) { + targetsError = err instanceof Error ? err.message : String(err); + log.warn(`${name}: invalid publish target config — ${targetsError}`); + } return { name, @@ -140,11 +156,13 @@ async function loadWorkspacePackage( dir: resolve(dir), relativeDir: relative(rootDir, dir) || '.', packageJson: pkg, - private: !!pkg.private, + private: isPrivate, dependencies: (pkg.dependencies as Record) || {}, devDependencies: (pkg.devDependencies as Record) || {}, peerDependencies: (pkg.peerDependencies as Record) || {}, optionalDependencies: (pkg.optionalDependencies as Record) || {}, bumpy, + targets, + ...(targetsError ? { targetsError } : {}), }; } diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index e1b3c72..8497d2c 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -64,6 +64,34 @@ export const DEP_TYPES: DepType[] = ['dependencies', 'devDependencies', 'peerDep // ---- Config ---- +/** + * 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; + [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'; @@ -170,17 +198,22 @@ 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; + /** + * 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. */ gitUser: { name: string; email: string }; /** Version PR settings */ @@ -198,12 +231,13 @@ export interface PackageConfig { /** Explicitly opt in or out of version management (overrides private/ignore/include) */ managed?: boolean; access?: 'public' | 'restricted'; - publishCommand?: string | string[]; + /** + * Publish targets for this package. Defaults to `["npm"]` for public packages and + * `[]` for private ones. See {@link PublishTargetsInput}. + */ + publishTargets?: PublishTargetsInput; buildCommand?: string; registry?: string; - 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>; @@ -254,6 +288,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 +358,21 @@ export interface WorkspacePackage { peerDependencies: Record; optionalDependencies: Record; bumpy?: PackageConfig; // per-package config from package.json or .bumpy.config.json + /** + * Publish targets resolved at workspace discovery (from `publishTargets` config or + * the implicit npm default). Attached by `discoverWorkspace` so + * downstream consumers don't need the root config to answer "where does this + * package publish". May be absent for hand-constructed packages (tests) — use + * `getPackageTargets()` from core/targets to resolve lazily. + */ + targets?: import('./core/targets/types.ts').ResolvedTarget[]; + /** + * Set when target resolution failed at discovery (unknown target name, invalid + * targets-map entry, ...). Discovery stays usable so read-only commands (status, + * add, check) keep working with a warning; publish flows refuse to run until the + * config is fixed. + */ + targetsError?: string; } export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; diff --git a/packages/bumpy/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 new file mode 100644 index 0000000..4633761 --- /dev/null +++ b/packages/bumpy/test/core/publish-multi-target.test.ts @@ -0,0 +1,801 @@ +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, 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'; + +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']); + // One target went out → the version shipped from this commit (the flow tags it) + expect(releaseShipped(outcomes)).toBe(true); + }); + + 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, + 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, { + priorStates: new Map([['resume', { 'done-already': { status: 'success' as const } }]]), + }); + + const outcomes = result.targetOutcomes.get('resume')!; + expect(outcomes.find((o) => o.target === 'done-already')!.status).toBe('skipped'); + expect(outcomes.find((o) => o.target === 'done-already')!.skipKind).toBe('metadata'); + expect(outcomes.find((o) => o.target === 'done-already')!.reason).toBe('already published'); + expect(outcomes.find((o) => o.target === 'pending')!.status).toBe('success'); + }); + + test('vscode-marketplace and open-vsx share one vsix artifact', async () => { + const pkgDir = await setupPkg('my-ext', { publisher: 'acme', engines: { vscode: '^1.90.0' } }); + const pkg = makePkg('my-ext', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { publishTargets: ['vscode-marketplace', 'open-vsx'] }, + }); + pkg.packageJson.publisher = 'acme'; + pkg.packageJson.engines = { vscode: '^1.90.0' }; + + addMockRule({ match: '@vscode/vsce package', response: '' }); + addMockRule({ match: '@vscode/vsce publish', response: '' }); + addMockRule({ match: /ovsx publish/, response: '' }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(result.failed).toHaveLength(0); + expect(result.published).toHaveLength(1); + // vsix built exactly once, then published to both registries from the same file + expect(getCallsMatching('@vscode/vsce package')).toHaveLength(1); + const vscePublish = getCallsMatching('@vscode/vsce publish'); + const ovsxPublish = getCallsMatching(/^npx --yes ovsx publish/); + expect(vscePublish).toHaveLength(1); + expect(ovsxPublish).toHaveLength(1); + expect(vscePublish[0]!.command).toContain('--packagePath'); + expect(vscePublish[0]!.command).toContain('my-ext-1.0.1.vsix'); + expect(ovsxPublish[0]!.command).toContain('my-ext-1.0.1.vsix'); + }); + + test('azureCredential option publishes via Azure OIDC instead of a PAT', async () => { + const pkgDir = await setupPkg('azure-ext', { publisher: 'acme', engines: { vscode: '^1.90.0' } }); + const pkg = makePkg('azure-ext', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { publishTargets: [{ type: 'vscode-marketplace', azureCredential: true }] }, + }); + pkg.packageJson.publisher = 'acme'; + + addMockRule({ match: '@vscode/vsce package', response: '' }); + addMockRule({ match: '@vscode/vsce publish', response: '' }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(result.failed).toHaveLength(0); + expect(getCallsMatching('@vscode/vsce publish')[0]!.command).toContain('--azure-credential'); + }); + + test('marketplace targets skip prerelease versions', async () => { + const pkgDir = await setupPkg('pre-ext', { publisher: 'acme', engines: { vscode: '^1.90.0' } }); + const pkg = makePkg('pre-ext', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { + publishTargets: ['vscode-marketplace', { type: 'custom', name: 'mirror', command: 'echo ok' }], + }, + }); + pkg.packageJson.publisher = 'acme'; + + const packages = new Map([[pkg.name, pkg]]); + const depGraph = new DependencyGraph(packages); + const plan: ReleasePlan = { + bumpFiles: [], + warnings: [], + releases: [makeRelease('pre-ext', '1.0.0', '1.1.0-rc.0')], + }; + + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const outcomes = result.targetOutcomes.get('pre-ext')!; + expect(outcomes.find((o) => o.target === 'vscode-marketplace')!.status).toBe('skipped'); + expect(outcomes.find((o) => o.target === 'vscode-marketplace')!.reason).toBe('prereleases not supported'); + // The custom target still publishes the prerelease + expect(outcomes.find((o) => o.target === 'mirror')!.status).toBe('success'); + // No vsce invocation at all + expect(getCallsMatching('@vscode/vsce')).toHaveLength(0); + }); + + test('marketplace targets skip snapshot releases', async () => { + const pkgDir = await setupPkg('snap-ext', { publisher: 'acme' }); + const pkg = makePkg('snap-ext', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { publishTargets: ['open-vsx'] }, + }); + pkg.packageJson.publisher = 'acme'; + + const packages = new Map([[pkg.name, pkg]]); + const depGraph = new DependencyGraph(packages); + const plan: ReleasePlan = { + bumpFiles: [], + warnings: [], + releases: [makeRelease('snap-ext', '1.0.0', '1.0.1-preview-abc1234')], + }; + + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, { + releaseKind: 'snapshot', + }); + + const outcomes = result.targetOutcomes.get('snap-ext')!; + expect(outcomes[0]!.status).toBe('skipped'); + expect(outcomes[0]!.reason).toBe('snapshots not supported'); + expect(result.published).toHaveLength(0); + expect(result.skipped.map((s) => s.name)).toEqual(['snap-ext']); + }); + + test('registry guard: target already live on the registry is skipped, not re-published', async () => { + const pkgDir = await setupPkg('guarded'); + const pkg = makePkg('guarded', '1.0.0', { + dir: pkgDir, + bumpy: { + publishTargets: [{ type: 'custom', name: 'mirror', command: 'publish-cmd', checkPublished: 'check-cmd' }], + }, + }); + addMockRule({ match: 'check-cmd', response: '1.0.1' }); // reports the target version as live + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const outcomes = result.targetOutcomes.get('guarded')!; + expect(outcomes[0]!.status).toBe('skipped'); + expect(outcomes[0]!.skipKind).toBe('registry'); + expect(outcomes[0]!.reason).toBe('already on registry'); + expect(getCallsMatching('publish-cmd')).toHaveLength(0); + // Version 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', () => { + 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('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 + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const outcomes = result.targetOutcomes.get('@myorg/mdit-thing')!; + expect(outcomes[0]!.status).toBe('skipped'); + expect(outcomes[0]!.reason).toBe('already on registry'); + expect(getCallsMatching('jsr publish')).toHaveLength(0); + }); + }); + + describe('pypi target', () => { + const realFetch = globalThis.fetch; + let pypiVersionStatus = 404; + + beforeEach(() => { + pypiVersionStatus = 404; + globalThis.fetch = (async (url: string | URL) => { + const u = String(url); + if (u.startsWith('https://pypi.org/pypi/')) return new Response('{}', { status: pypiVersionStatus }); + return new Response('{}', { status: 404 }); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + const PYPROJECT = [ + '[build-system]', + 'requires = ["hatchling"]', + '', + '[project]', + 'name = "My_Py.Tool"', + 'version = "0.0.0"', + 'description = "demo"', + '', + '[tool.other]', + 'version = "9.9.9"', + ].join('\n'); + + async function setupPyPkg() { + const pkgDir = await setupPkg('py-tool', { private: true }); + const { writeText } = await import('../../src/utils/fs.ts'); + await writeText(resolve(pkgDir, 'pyproject.toml'), PYPROJECT); + addMockRule({ match: 'uv --version', response: 'uv 0.9.0' }); + addMockRule({ match: 'uv build', response: '' }); + addMockRule({ match: 'uv publish', response: '' }); + return makePkg('py-tool', '1.0.0', { + dir: pkgDir, + private: true, + bumpy: { publishTargets: ['pypi'] }, + }); + } + + test('syncs pyproject version, builds isolated dist, publishes explicit files', async () => { + const pkg = await setupPyPkg(); + // simulate uv build producing distributions in the requested out-dir + const { ensureDir: mkdir, writeText } = await import('../../src/utils/fs.ts'); + const outDir = resolve(pkg.dir, '.bumpy-pypi-dist-1.0.1'); + await mkdir(outDir); + await writeText(resolve(outDir, 'my_py_tool-1.0.1-py3-none-any.whl'), ''); + await writeText(resolve(outDir, 'my_py_tool-1.0.1.tar.gz'), ''); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + expect(result.failed).toHaveLength(0); + expect(result.published.map((p) => p.name)).toEqual(['py-tool']); + + // version synced into [project] only — [tool.other] version untouched + const { readText } = await import('../../src/utils/fs.ts'); + const toml = await readText(resolve(pkg.dir, 'pyproject.toml')); + expect(toml).toContain('version = "1.0.1"'); + expect(toml).toContain('version = "9.9.9"'); + + const build = getCallsMatching(/^uv build/); + expect(build).toHaveLength(1); + expect(build[0]!.command).toContain('--out-dir'); + const publish = getCallsMatching(/^uv publish/); + expect(publish).toHaveLength(1); + expect(publish[0]!.command).toContain('my_py_tool-1.0.1-py3-none-any.whl'); + expect(publish[0]!.command).toContain('my_py_tool-1.0.1.tar.gz'); + }); + + test('version already on PyPI is skipped via the registry guard (PEP 503 name normalization)', async () => { + const pkg = await setupPyPkg(); + pypiVersionStatus = 200; + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + + const outcomes = result.targetOutcomes.get('py-tool')!; + expect(outcomes[0]!.status).toBe('skipped'); + expect(outcomes[0]!.reason).toBe('already on registry'); + expect(getCallsMatching(/^uv publish/)).toHaveLength(0); + }); + + test('prerelease versions are skipped (PEP 440 mismatch)', async () => { + const pkg = await setupPyPkg(); + const packages = new Map([[pkg.name, pkg]]); + const depGraph = new DependencyGraph(packages); + const plan: ReleasePlan = { + bumpFiles: [], + warnings: [], + releases: [makeRelease('py-tool', '1.0.0', '1.1.0-next.0')], + }; + + const result = await publishPackages(plan, packages, depGraph, DEFAULT_CONFIG, tmpDir, {}); + const outcomes = result.targetOutcomes.get('py-tool')!; + expect(outcomes[0]!.status).toBe('skipped'); + expect(outcomes[0]!.reason).toBe('prereleases not supported'); + // preflight's `uv --version` check still runs; no build/publish happens + expect(getCallsMatching(/^uv (build|publish)/)).toHaveLength(0); + }); + }); + + test('npm + named GitHub Packages instance publish to both registries', async () => { + const pkgDir = await setupPkg('dual-reg'); + const pkg = makePkg('dual-reg', '1.0.0', { + dir: pkgDir, + bumpy: { publishTargets: ['npm', 'ghp'] }, + }); + const config = { + ...DEFAULT_CONFIG, + publish: { ...DEFAULT_CONFIG.publish, protocolResolution: 'in-place' as const }, + targets: { ghp: { type: 'npm', registry: 'https://npm.pkg.github.com' } }, + }; + // resolve targets with the config that defines the named instance + const { resolvePackageTargets } = await import('../../src/core/targets/registry.ts'); + pkg.targets = resolvePackageTargets(pkg, pkg.bumpy!, config); + + addMockRule({ match: /^npm publish/, response: '' }); + + const { packages, depGraph, plan } = planFor(pkg); + const result = await publishPackages(plan, packages, depGraph, config, tmpDir, {}); + + expect(result.failed).toHaveLength(0); + const publishes = getCallsMatching(/^npm publish/); + expect(publishes).toHaveLength(2); + expect(publishes.some((c) => c.command.includes('--registry https://npm.pkg.github.com'))).toBe(true); + expect(publishes.some((c) => !c.command.includes('--registry'))).toBe(true); + }); + + 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 new file mode 100644 index 0000000..7fce305 --- /dev/null +++ b/packages/bumpy/test/core/targets.test.ts @@ -0,0 +1,300 @@ +import { test, expect, describe } from 'bun:test'; +import { resolve } from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { writeJson } from '../../src/utils/fs.ts'; +import { makePkg, makeConfig } from '../helpers.ts'; +import { + resolvePackageTargets, + getPackageTargets, + getNpmTarget, + packagePublishes, + targetLabel, +} from '../../src/core/targets/registry.ts'; +import { loadPackageConfig } from '../../src/core/config.ts'; +import { parsePyproject, updatePyprojectVersion } from '../../src/core/targets/pypi.ts'; +import type { BumpyConfig } from '../../src/types.ts'; + +function configWithTargets(targets: BumpyConfig['targets']): BumpyConfig { + return makeConfig({ targets }); +} + +describe('resolvePackageTargets — defaults', () => { + 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('default: private package yields no targets', () => { + const pkg = makePkg('app', '1.0.0', { private: true }); + expect(resolvePackageTargets(pkg, {}, makeConfig())).toHaveLength(0); + }); + + 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); + 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('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: ['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', () => { + 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 = { 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: { publishTargets: [] } }); + expect(packagePublishes(npmPkg)).toBe(true); + expect(packagePublishes(nonePkg)).toBe(false); + expect(getNpmTarget(npmPkg)?.type).toBe('npm'); + expect(getNpmTarget(nonePkg)).toBeUndefined(); + }); + + test('targetLabel: npm on GitHub Packages registry', () => { + const pkg = makePkg('lib', '1.0.0'); + const config = configWithTargets({ ghp: { type: 'npm', registry: 'https://npm.pkg.github.com' } }); + const targets = resolvePackageTargets(pkg, { publishTargets: ['ghp'] }, config); + expect(targetLabel(targets[0]!, pkg)).toBe('GitHub Packages'); + }); + + test('targetLabel: marketplace targets', () => { + const pkg = makePkg('ext', '1.0.0', { private: true }); + const targets = resolvePackageTargets(pkg, { publishTargets: ['vscode-marketplace', 'open-vsx'] }, makeConfig()); + expect(targetLabel(targets[0]!, pkg)).toBe('VS Code Marketplace'); + expect(targetLabel(targets[1]!, pkg)).toBe('Open VSX'); + }); +}); + +describe('lenient discovery on broken target config', () => { + test('discoverWorkspace records targetsError instead of throwing', async () => { + const { discoverWorkspace } = await import('../../src/core/workspace.ts'); + const dir = await mkdtemp(resolve(tmpdir(), 'bumpy-targets-lenient-')); + try { + await writeJson(resolve(dir, 'package.json'), { name: 'root', private: true, workspaces: ['packages/*'] }); + const pkgDir = resolve(dir, 'packages/broken'); + const { ensureDir } = await import('../../src/utils/fs.ts'); + await ensureDir(pkgDir); + await writeJson(resolve(pkgDir, 'package.json'), { + name: 'broken', + version: '1.0.0', + bumpy: { publishTargets: ['does-not-exist'] }, + }); + + // Read-only discovery must survive the bad reference... + const { packages } = await discoverWorkspace(dir, makeConfig()); + const pkg = packages.get('broken')!; + expect(pkg.targets).toEqual([]); + // ...but record the error so publish flows can refuse loudly + expect(pkg.targetsError).toContain('does-not-exist'); + } finally { + await rm(dir, { recursive: true }); + } + }); +}); + +describe('pypi pyproject.toml helpers', () => { + const TOML = ['[project]', 'name = "my-tool"', 'version = "1.0.0"', '', '[tool.uv]', 'dev = true'].join('\n'); + + test('parsePyproject extracts name/version from [project] only', () => { + const info = parsePyproject(TOML); + expect(info.name).toBe('my-tool'); + expect(info.version).toBe('1.0.0'); + expect(info.dynamicVersion).toBe(false); + }); + + test('parsePyproject detects dynamic version', () => { + const info = parsePyproject('[project]\nname = "x"\ndynamic = ["version", "readme"]\n'); + expect(info.version).toBeUndefined(); + expect(info.dynamicVersion).toBe(true); + }); + + test('updatePyprojectVersion rewrites only the [project] version, preserving formatting', () => { + const withOther = `# comment\n${TOML}\n\n[tool.other]\nversion = "3.3.3"\n`; + const updated = updatePyprojectVersion(withOther, '2.5.0')!; + expect(updated).toContain('version = "2.5.0"'); + expect(updated).toContain('version = "3.3.3"'); + expect(updated).toContain('# comment'); + expect(updated).not.toContain('"1.0.0"'); + }); + + test('updatePyprojectVersion returns null when no static version exists', () => { + expect(updatePyprojectVersion('[project]\nname = "x"\n', '1.0.0')).toBeNull(); + expect(updatePyprojectVersion('[tool.poetry]\nversion = "1.0.0"\n', '2.0.0')).toBeNull(); + }); +}); + +describe('publishTargets trust gating (package.json config)', () => { + async function loadFromPkgJson(bumpy: unknown, rootConfig: BumpyConfig) { + const dir = await mkdtemp(resolve(tmpdir(), 'bumpy-targets-trust-')); + try { + await writeJson(resolve(dir, 'package.json'), { name: 'my-pkg', version: '1.0.0', bumpy }); + return await loadPackageConfig(dir, rootConfig, 'my-pkg'); + } finally { + await rm(dir, { recursive: true }); + } + } + + test('inline commands in package.json publishTargets are blocked by default', async () => { + await expect( + loadFromPkgJson({ publishTargets: [{ type: 'custom', command: 'rm -rf /' }] }, makeConfig()), + ).rejects.toThrow(/inline target definitions/); + }); + + 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('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-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 }); 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); + }); });