diff --git a/.bumpy/snapshot-releases.md b/.bumpy/snapshot-releases.md new file mode 100644 index 0000000..ecbc5d3 --- /dev/null +++ b/.bumpy/snapshot-releases.md @@ -0,0 +1,9 @@ +--- +'@varlock/bumpy': minor +--- + +Add snapshot releases — transient, one-off preview publishes for private packages (the private-registry counterpart to pkg.pr.new). + +`bumpy publish --snapshot ` computes the pending release plan, derives a unique prerelease version per package (e.g. `1.4.0-pr-123-a1b2c3d`), exact-pins in-plan internal deps, publishes to a non-`latest` dist-tag (default: the snapshot name), then restores the working tree. It never consumes bump files, writes changelogs, commits, creates git tags, or makes GitHub releases. `bumpy ci release --snapshot ` runs the whole thing and, on a PR, posts/updates a comment with the published versions and install instructions. Requires pending bump files; mutually exclusive with `--channel`. + +Version uniqueness is configurable via the new `snapshot.versionStrategy` option: `"sha"` (default — `--`, idempotent per commit so re-runs skip) or `"timestamp"`. Consumers install via the dist-tag regardless, so the exact version string is just an implementation detail. diff --git a/docs/cli.md b/docs/cli.md index 7b79741..d92c4a3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -91,18 +91,22 @@ bumpy publish bumpy publish --dry-run bumpy publish --tag beta bumpy publish --filter "@myorg/*" +bumpy publish --snapshot pr-123 ``` -| Flag | Description | -| ------------------ | ------------------------------------------------------------ | -| `--dry-run` | Preview what would be published without actually doing it | -| `--tag ` | npm dist-tag (e.g., `next`, `beta`) | -| `--no-push` | Skip pushing git tags to the remote | -| `--filter ` | Only publish matching packages (supports globs) | -| `--channel ` | Channel override (default: inferred from the current branch) | +| Flag | Description | +| ------------------- | ---------------------------------------------------------------------------------------------------- | +| `--dry-run` | Preview what would be published without actually doing it | +| `--tag ` | npm dist-tag (e.g., `next`, `beta`) | +| `--no-push` | Skip pushing git tags to the remote | +| `--filter ` | Only publish matching packages (supports globs) | +| `--channel ` | Channel override (default: inferred from the current branch) | +| `--snapshot ` | Publish a transient [snapshot](snapshots.md#snapshot-releases) (mutually exclusive with `--channel`) | On a [prerelease channel](prereleases.md) branch, publish derives prerelease versions (targets from the cycle's bump files, counters from the registry), transiently writes them into the working tree so pack/build see them, publishes the whole cycle to the channel's dist-tag with exact-pinned inter-cycle deps, then restores the files. Nothing version-shaped is ever committed. +With `--snapshot `, publish derives a throwaway prerelease version per pending package (e.g. `1.4.0-pr-123-a1b2c3d`), publishes them to a non-`latest` dist-tag (default: the snapshot name), then restores the working tree. It never consumes bump files, writes changelogs, commits, tags, or creates GitHub releases — it's the private-registry counterpart to [pkg.pr.new](https://pkg.pr.new). Requires pending bump files. See [Snapshot releases](snapshots.md#snapshot-releases). + **How bumpy detects unpublished packages:** 1. Custom `checkPublished` command (if configured per-package — see [`allowCustomCommands`](./configuration.md#custom-commands-and-allowcustomcommands)) @@ -239,18 +243,22 @@ CI command for releases. Has two modes: **Auto-publish mode (`--auto-publish`):** Versions and publishes directly on merge without an intermediate PR. **Not recommended** — you lose the version-PR preview/review gate, so every merge to main with a bump file ships immediately. It's also incompatible with the [split-job workflow](github-actions.md#release-workflow-recommended-split-jobs) (since both paths happen in one run). The credential surface itself is the same as a single-job non-auto-publish workflow — the cost here is purely the loss of the preview gate. +**Snapshot mode (`--snapshot `):** Publishes a transient [snapshot](snapshots.md#snapshot-releases) and, on a PR, posts/updates a comment with the published versions and install instructions. A single self-contained step — no version-PR/publish split, no branch routing — so it can run from any branch (typically a feature PR). Incompatible with `--expect-mode` and `--auto-publish`. + ```bash bumpy ci release bumpy ci release --auto-publish bumpy ci release --auto-publish --tag beta +bumpy ci release --snapshot pr-123 ``` | Flag | Description | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--expect-mode ` | Assert detected mode: `version-pr` or `publish`. Errors if the detected mode differs. Use to gate split-job workflows so a job can't silently fall into the wrong path. | | `--auto-publish` | Version + publish directly instead of creating a PR | -| `--tag ` | npm dist-tag (for `--auto-publish`) | +| `--tag ` | npm dist-tag (for `--auto-publish`, or the snapshot dist-tag) | | `--branch ` | Version PR branch name (default: `bumpy/version-packages`) | +| `--snapshot ` | Publish a transient [snapshot](snapshots.md#snapshot-releases) and comment install instructions on the PR | Requires `GH_TOKEN`. When `BUMPY_GH_TOKEN` is set, it is automatically used to push the version branch and create/edit the PR so that PR workflows trigger (see [GitHub Actions setup](github-actions.md#token-setup)). diff --git a/docs/configuration.md b/docs/configuration.md index 4a5a85c..f24fba7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,27 +6,35 @@ Bumpy is configured via `.bumpy/_config.json`, created by `bumpy init`. Per-pack ## Global config (`.bumpy/_config.json`) -| Option | Type | Default | Description | -| ---------------------------- | -------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------ | -| `baseBranch` | `string` | `"main"` | Branch used for release comparisons | -| `access` | `"public" \| "restricted"` | `"public"` | Default npm publish access level | -| `changelog` | `false \| string \| [string, options]` | `"default"` | Changelog formatter — `"default"`, `"github"`, path to a custom formatter, or `false` to disable | -| `fixed` | `string[][]` | `[]` | Package groups that always bump together to the same version | -| `linked` | `string[][]` | `[]` | Package groups that share the highest bump level | -| `ignore` | `string[]` | `[]` | Package name globs to exclude from versioning | -| `include` | `string[]` | `[]` | Package name globs to explicitly include (overrides `ignore` and `privatePackages`) | -| `privatePackages` | `{ version, tag }` | `{ version: false, tag: false }` | Whether to version and/or create git tags for private packages | -| `updateInternalDependencies` | `"patch" \| "minor" \| "out-of-range"` | `"out-of-range"` | When to update internal dependency version ranges | -| `dependencyBumpRules` | `object` | see below | Controls how bumps propagate through dependency types | -| `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 | -| `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) | -| `packages` | `object` | `{}` | Per-package config overrides (keyed by package name) | -| `channels` | `object` | `{}` | Prerelease channels, keyed by channel name (see below) | +| Option | Type | Default | Description | +| ---------------------------- | -------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `baseBranch` | `string` | `"main"` | Branch used for release comparisons | +| `access` | `"public" \| "restricted"` | `"public"` | Default npm publish access level | +| `changelog` | `false \| string \| [string, options]` | `"default"` | Changelog formatter — `"default"`, `"github"`, path to a custom formatter, or `false` to disable | +| `fixed` | `string[][]` | `[]` | Package groups that always bump together to the same version | +| `linked` | `string[][]` | `[]` | Package groups that share the highest bump level | +| `ignore` | `string[]` | `[]` | Package name globs to exclude from versioning | +| `include` | `string[]` | `[]` | Package name globs to explicitly include (overrides `ignore` and `privatePackages`) | +| `privatePackages` | `{ version, tag }` | `{ version: false, tag: false }` | Whether to version and/or create git tags for `"private": true` packages (never published — see below) | +| `updateInternalDependencies` | `"patch" \| "minor" \| "out-of-range"` | `"out-of-range"` | When to update internal dependency version ranges | +| `dependencyBumpRules` | `object` | see below | Controls how bumps propagate through dependency types | +| `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 | +| `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) | +| `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) | + +### Private packages and private registries + +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. ### Change detection and `package.json` fields @@ -142,6 +150,21 @@ The `channels` object maps long-lived branches to prerelease lines. See [prerele Channel names become `.bumpy//` subdirectories (holding bump files that shipped on the channel), so they must be filesystem-safe and can't start with `_` or collide with reserved entries. +### Snapshot releases + +The `snapshot` object configures one-off transient previews published with `bumpy publish --snapshot `. See [snapshots.md → Snapshot releases](snapshots.md#snapshot-releases) for the full workflow. + +```jsonc +{ + "snapshot": { + // How snapshot versions are made unique (consumers install via the tag regardless): + // "sha" → 1.4.0-pr-123-a1b2c3d (short git SHA; idempotent per commit; default) + // "timestamp" → 1.4.0-pr-123-20260623123456 (always unique) + "versionStrategy": "sha", + }, +} +``` + ## Per-package config Per-package settings can be defined in two places: diff --git a/docs/prereleases.md b/docs/prereleases.md index d65d734..fab8c1c 100644 --- a/docs/prereleases.md +++ b/docs/prereleases.md @@ -4,6 +4,8 @@ Prerelease versioning lets you ship `1.2.0-rc.0`, `1.2.0-beta.1`, etc. before th Bumpy's model is **branch-based**: you nominate one or more long-lived branches (e.g. `next`, `beta`) in your config as prerelease channels. CI runs the same release workflow on those branches as it does on `main` — only the version suffix and dist-tag change. When you're ready to ship stable, you merge the channel branch into `main` and the ordinary stable release flow takes over. +> Want a **one-off, throwaway preview** of a single PR or commit rather than a managed release line? That's a snapshot, not a channel — see [Snapshots & PR previews](./snapshots.md) (pkg.pr.new for public packages, `bumpy publish --snapshot` for private ones). + **Prerelease versions are never committed to git.** On a channel branch, every `package.json` keeps the last stable version — identical to `main`. Prerelease versions are computed at publish time and exist only in the npm registry and in git tags. No `pre enter` / `pre exit` commands. No mode files. No version churn in your branches. No hidden state that can poison unrelated merges. @@ -20,19 +22,18 @@ This is why there's no prerelease counter to corrupt, no suffix to strip at prom Channels are designed for **long-lived release lines** — an ongoing `next` / `beta` / `rc` cycle that accumulates changes over days or weeks before promotion to stable. They're worth setting up when you expect to ship multiple prereleases through the same cycle. -**For anything short-lived or ephemeral, use [pkg.pr.new](https://pkg.pr.new) instead.** - -pkg.pr.new publishes throwaway packages from any PR, commit, or branch — no version planning, no branch discipline, no bump files. It pairs naturally with bumpy: bumpy owns the managed release lines, pkg.pr.new owns the ephemeral previews. Between the two, most teams need nothing else. +**For anything short-lived or ephemeral, use a [snapshot](./snapshots.md) instead** — either [pkg.pr.new](./snapshots.md#pkgprnew-public-packages) (public packages) or [`bumpy publish --snapshot`](./snapshots.md#snapshot-releases) (private packages). Both are covered on the [Snapshots & PR previews](./snapshots.md) page. Rough rule of thumb: -| You want… | Use | -| --------------------------------------------------------- | -------------------------------- | -| Preview a single PR for review | [pkg.pr.new](https://pkg.pr.new) | -| Per-commit canary from `main` | [pkg.pr.new](https://pkg.pr.new) | -| One-off snapshot from a branch for ad-hoc testing | [pkg.pr.new](https://pkg.pr.new) | -| Ship a `1.2.0-rc.N` line for weeks of integration testing | Bumpy channels (this doc) | -| Parallel `@next` + `@beta` lines for different audiences | Bumpy channels (this doc) | +| You want… | Use | +| --------------------------------------------------------- | ----------------------------------------------------------------- | +| Preview a single PR for review (public packages) | [pkg.pr.new](./snapshots.md#pkgprnew-public-packages) | +| Preview a single PR for review (private packages) | [`bumpy publish --snapshot`](./snapshots.md#snapshot-releases) | +| Per-commit canary from `main` | [pkg.pr.new](./snapshots.md#pkgprnew-public-packages) / snapshots | +| One-off snapshot from a branch for ad-hoc testing | [snapshots](./snapshots.md#snapshot-releases) | +| Ship a `1.2.0-rc.N` line for weeks of integration testing | Bumpy channels (this doc) | +| Parallel `@next` + `@beta` lines for different audiences | Bumpy channels (this doc) | --- @@ -158,7 +159,7 @@ PR authors do nothing different. They: Bump files don't carry channel metadata. The branch they land on determines the channel; their location tracks whether they've shipped. -> Reviewing a feature PR and want to install it before merge? That's [pkg.pr.new](https://pkg.pr.new)'s job, not a channel publish. Channels only kick in once a PR has merged into the channel branch. +> Reviewing a feature PR and want to install it before merge? That's a job for a [one-off preview](./snapshots.md) (pkg.pr.new or a snapshot), not a channel publish. Channels only kick in once a PR has merged into the channel branch. ### Versioning a prerelease @@ -421,8 +422,7 @@ The directory used to hold shipped bump files matches the channel name: `.bumpy/ These are intentionally out of scope for the initial channel feature. If any of these is a blocker for you, please open an issue. -- **Ephemeral / preview / canary releases** — use [pkg.pr.new](https://pkg.pr.new) instead. It owns short-lived publishing (per-PR, per-commit, per-branch); bumpy channels are deliberately scoped to managed long-running release lines. See [When to use channels — and when not to](#when-to-use-channels--and-when-not-to) above. -- **Workflow-dispatch one-off prereleases** — planned. The no-commit architecture makes this nearly free: a one-off is the same compute-and-publish step run from any SHA with an explicit preid and dist-tag, no branch state required. It will likely follow shortly after channels. +- **Ephemeral / preview / canary releases** — covered on the [Snapshots & PR previews](./snapshots.md) page (snapshot releases for private packages, pkg.pr.new for public). Bumpy channels stay scoped to managed long-running release lines. See [When to use channels — and when not to](#when-to-use-channels--and-when-not-to) above. - **Stable (maintenance) channels** — long-lived branches like `1.x` publishing stable versions to a non-`latest` dist-tag. Future work; the config schema already leaves room (see note above). - **Prerelease changelog in the published tarball** — injecting the rendered cycle changelog into prerelease artifacts at publish time (derived content goes in the artifact, never in git). Possible later nice-to-have. - **Per-bump-file channel routing** — declaring `channel: beta` inside a bump file's frontmatter. Not planned; channels stay branch-derived to keep the mental model simple. diff --git a/docs/snapshots.md b/docs/snapshots.md new file mode 100644 index 0000000..1b8dfa0 --- /dev/null +++ b/docs/snapshots.md @@ -0,0 +1,145 @@ +# Snapshots & PR previews + +A **snapshot** (or preview) is a throwaway, one-off publish of a single PR or commit — "what the next version would be, published right now" — so reviewers can install and test a change before it merges. Unlike [prerelease channels](./prereleases.md), there's no dedicated branch and no committed state: snapshots leave no trace in git. + +Bumpy supports two tools here, depending on where your packages live: + +- **Public packages** → [pkg.pr.new](#pkgprnew-public-packages) — a zero-setup external service that publishes previews to its own storage. +- **Private packages** → [`bumpy publish --snapshot`](#snapshot-releases) — publishes a transient preview to the private registry you already use. + +> **"Private" here means a package you publish to a private registry** — a scoped package with [`access: "restricted"`](./configuration.md#publishing-config) and/or a per-package [`registry`](./configuration.md#per-package-config), installed by your team with normal `npm install`. It does **not** mean a package marked `"private": true` in `package.json` — that's npm's "never publish" flag, which `npm publish` refuses by design, so bumpy skips those everywhere ([details](#publishing-to-a-private-registry)). + +> Looking for a long-lived `next` / `beta` / `rc` release line instead of a one-off preview? That's a [prerelease channel](./prereleases.md), not a snapshot. + +## pkg.pr.new (public packages) + +For **public** packages, [pkg.pr.new](https://pkg.pr.new) is the zero-setup way to publish a throwaway preview from any PR or commit. It publishes to its own storage (not npm) and comments install URLs on the PR, so reviewers can `npm i https://pkg.pr.new/your-pkg@` without you managing versions or dist-tags. Bumpy doesn't run it — it's an independent tool that pairs alongside your bumpy release workflow. + +Two setup steps: + +**1. Install the GitHub App** — [github.com/apps/pkg-pr-new](https://github.com/apps/pkg-pr-new), on the repo you want previews for. This is the easy-to-miss step: publishing fails without it. + +**2. Add a workflow** that builds your packages and runs `pkg-pr-new publish` once: + +```yaml +# .github/workflows/preview.yml +name: Preview release +on: [push, pull_request] + +permissions: {} + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 20 } + - run: npm ci + - run: npm run build + # Run this exactly once per workflow — it's how pkg.pr.new avoids spam. + - run: npx pkg-pr-new publish './packages/*' +``` + +It auto-comments the install URLs on the PR; no token wiring needed beyond the App. Because its storage is ephemeral, it's fine to run on **every** commit (unlike private snapshots, which you typically label-gate so a real registry doesn't fill up). For monorepos, pass explicit paths or a glob (`'./packages/*'`); for compact install URLs your package needs a valid `repository` field in `package.json`. See the [pkg.pr.new docs](https://github.com/stackblitz-labs/pkg.pr.new) for the full set of flags (`--compact`, `--comment`, `--template`, …). + +> pkg.pr.new can't serve **private** packages — it publishes to public storage. For those, use [snapshot releases](#snapshot-releases) below, which publish to the private registry you already use. + +## Snapshot releases + +A **snapshot** is a throwaway, one-off publish of your pending release — "what the next version would be, published right now" — under a non-`latest` dist-tag. Unlike channels, it needs no dedicated branch and leaves no trace in git: no bump files consumed, no changelog, no commit, no git tag, no GitHub release. It's the private-registry counterpart to [pkg.pr.new](#pkgprnew-public-packages). + +```sh +bumpy publish --snapshot pr-123 +``` + +This computes the pending release plan, derives a unique prerelease version per package, writes those versions into the working tree, publishes them to the `@pr-123` dist-tag, and restores the working tree. Install with `npm i your-pkg@pr-123` — the tag always points at the newest snapshot for that name. + +A snapshot **requires pending bump files** — it previews exactly the release you've planned, so with nothing to release it's a no-op. (This is the main difference from pkg.pr.new, which snapshots any commit regardless of intent.) + +### Version format + +The snapshot name is both the version preid and the default dist-tag. Consumers always install via the tag (`npm i your-pkg@pr-123`), so the exact version string is mostly an implementation detail — `snapshot.versionStrategy` just controls how re-runs behave: + +| Strategy | Version | Notes | +| --------------- | ----------------------------- | ------------------------------------------------------------------------------- | +| `sha` (default) | `1.4.0-pr-123-a1b2c3d` | Short git SHA. **Idempotent per commit** — re-running on the same commit skips. | +| `timestamp` | `1.4.0-pr-123-20260623123456` | UTC timestamp. Always unique; never idempotent. | + +```jsonc +// .bumpy/_config.json +{ + "snapshot": { "versionStrategy": "sha" }, +} +``` + +In-cycle internal dependencies are exact-pinned (just like channels), so a set of snapshot packages always installs as a coherent group. Override the dist-tag independently with `--tag`: + +```sh +bumpy publish --snapshot sha-a1b2c3d --tag pr-123 # version preid "sha-a1b2c3d", dist-tag "@pr-123" +``` + +### In CI + +`bumpy ci release --snapshot ` runs the whole thing and, on a PR, posts/updates a comment with the published versions and install instructions. There's no `ci plan` / `ci release` split — snapshots are a single self-contained step that can run from any branch. + +```yaml +# .github/workflows/snapshot.yml +on: + pull_request: + types: [opened, synchronize, labeled] + +jobs: + snapshot: + # Opt-in per PR via a label so you don't fill the registry with every PR's builds + if: contains(github.event.pull_request.labels.*.name, 'snapshot') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - uses: ./.github/actions/setup # your registry auth (.npmrc / NPM_TOKEN) + - run: bunx bumpy ci release --snapshot pr-${{ github.event.pull_request.number }} + env: + NPM_TOKEN: ${{ secrets.PRIVATE_REGISTRY_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # for the PR comment +``` + +Label-gating is the recommended default: pkg.pr.new can fire on every commit because its storage expires, but a real registry keeps every snapshot version until a retention policy prunes it. Trigger on whatever event you like — it's just a CLI command. + +> **Forks.** PRs from forks running on `pull_request` get a read-only token and no secrets, so they can't publish or comment. This is usually fine for private packages (contributors are internal); if you need fork snapshots, the same constraints (and `pull_request_target` caveats) apply as for [the check comment](./github-actions.md). + +### What a snapshot does and doesn't do + +| Does | Doesn't | +| ------------------------------------------------------ | ----------------------------------- | +| Compute the release plan from pending bump files | Consume, move, or delete bump files | +| Derive a unique prerelease version per package | Write changelogs | +| Exact-pin in-cycle internal dependencies | Create a version PR | +| Publish to a non-`latest` dist-tag (default: the name) | Create git tags or GitHub releases | +| Restore the working tree afterward | Commit anything | + +Snapshots and channels are mutually exclusive on a single command (`--snapshot` + `--channel` is an error) — they're distinct release models. + +## Publishing to a private registry + +Snapshots — and normal `bumpy publish` — work with private registries out of the box; there's no separate "private" mode. The setup is the standard npm one: + +- **Scope + restricted access.** Name the package under your org scope and set [`access: "restricted"`](./configuration.md#publishing-config) (globally, or per-package). That's npm's mechanism for "published, but not public." +- **Point at the registry.** Use the per-package [`registry`](./configuration.md#per-package-config) option, or npm's native `publishConfig.registry` / `.npmrc` (which npm honors automatically). Auth works exactly as for public packages — `NPM_TOKEN`, OIDC, or a pre-configured `.npmrc`. +- **Don't set `"private": true`.** That field is npm's _refuse-to-publish_ marker — `npm publish` errors on it and `--access` can't override it. bumpy mirrors that: a `"private": true` package is never published by any flow (snapshot, channel, or stable). It can still be versioned and git-tagged if you opt in via [`privatePackages`](./configuration.md#private-packages-and-private-registries), but it won't be sent to a registry. Reserve `"private": true` for things you truly never publish (apps, internal tooling); use `access: "restricted"` for "private but published." + +```jsonc +// package.json — a package published privately (NOT "private": true) +{ + "name": "@acme/widgets", + "version": "1.4.0", + "publishConfig": { "registry": "https://npm.acme.internal" }, +} +``` + +```jsonc +// .bumpy/_config.json +{ "access": "restricted" } +``` + +With that, `bumpy publish --snapshot pr-123` publishes `@acme/widgets@1.4.0-pr-123-` to `https://npm.acme.internal` under the `@pr-123` dist-tag, and your team installs it with `npm i @acme/widgets@pr-123`. diff --git a/packages/bumpy/src/cli.ts b/packages/bumpy/src/cli.ts index 698ea81..c1f0bb0 100644 --- a/packages/bumpy/src/cli.ts +++ b/packages/bumpy/src/cli.ts @@ -122,6 +122,11 @@ async function main() { const { ciReleaseCommand } = await import('./commands/ci.ts'); const expectModeFlag = ciFlags['expect-mode']; const autoPublishFlag = ciFlags['auto-publish'] === true; + if (ciFlags.snapshot === true) { + log.error('--snapshot requires a name, e.g. `bumpy ci release --snapshot pr-123`.'); + process.exit(1); + } + const snapshotFlag = ciFlags.snapshot as string | undefined; if (expectModeFlag !== undefined && expectModeFlag !== 'version-pr' && expectModeFlag !== 'publish') { log.error(`Invalid --expect-mode value: "${expectModeFlag}". Must be "version-pr" or "publish".`); process.exit(1); @@ -133,11 +138,18 @@ async function main() { log.error('--expect-mode and --auto-publish cannot be used together.'); process.exit(1); } + // Snapshots are a self-contained one-shot release — none of the version-PR / + // publish split (and its flags) applies. + if (snapshotFlag !== undefined && (expectModeFlag !== undefined || autoPublishFlag)) { + log.error('--snapshot cannot be combined with --expect-mode or --auto-publish.'); + process.exit(1); + } await ciReleaseCommand(rootDir, { autoPublish: autoPublishFlag, assertMode: expectModeFlag as 'version-pr' | 'publish' | undefined, tag: ciFlags.tag as string | undefined, branch: ciFlags.branch as string | undefined, + snapshot: snapshotFlag, }); } else if (subcommand === 'setup') { const { ciSetupCommand } = await import('./commands/ci-setup.ts'); @@ -152,12 +164,17 @@ async function main() { case 'publish': { const rootDir = await findRoot(); const { publishCommand } = await import('./commands/publish.ts'); + if (flags.snapshot === true) { + log.error('--snapshot requires a name, e.g. `bumpy publish --snapshot pr-123`.'); + process.exit(1); + } await publishCommand(rootDir, { dryRun: flags['dry-run'] === true, tag: flags.tag as string | undefined, noPush: flags['no-push'] === true, filter: flags.filter as string | undefined, channel: flags.channel as string | undefined, + snapshot: flags.snapshot as string | undefined, }); break; } @@ -207,6 +224,7 @@ function printHelp() { (on a channel branch: moves pending bump files into .bumpy//) publish Publish versioned packages (on a channel branch: derives prerelease versions and publishes to the channel dist-tag) + (--snapshot : transient preview publish to a throwaway dist-tag) ci check PR check — report pending releases, comment on PR ci plan Report what ci release would do (JSON + GitHub Actions outputs) ci release Release — create version PR or auto-publish @@ -237,6 +255,8 @@ function printHelp() { --no-push Skip pushing git tags to remote --filter Publish only matching packages (e.g., "@myorg/*") --channel Publish a prerelease channel (default: inferred from the current branch) + --snapshot Publish a transient snapshot (e.g. "pr-123") to a throwaway dist-tag; + does not consume bump files, commit, tag, or create releases Version options: --commit Create a git commit with the version changes @@ -250,8 +270,10 @@ function printHelp() { CI release options: --expect-mode Assert detected mode: "version-pr" or "publish" (errors if mismatched) --auto-publish Version + publish directly (default: create version PR) - --tag npm dist-tag for auto-publish + --tag npm dist-tag for auto-publish (or the snapshot dist-tag) --branch Branch name for version PR (default: bumpy/version-packages) + --snapshot Publish a transient snapshot (e.g. "pr-123") and comment install + instructions on the PR; no version PR, no bump-file changes ${colorize('https://bumpy.varlock.dev', 'dim')} `); diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 9d5ce12..6296665 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -14,6 +14,7 @@ import { type ResolvedChannel, } from '../core/channels.ts'; import { buildChannelReleasePlan, channelDisplayPlan, formatChannelVersionSummary } from '../core/prerelease.ts'; +import type { ResolvedSnapshot } from '../core/snapshot.ts'; import { runArgs, runArgsAsync, tryRunArgs } from '../utils/shell.ts'; import { randomName } from '../utils/names.ts'; import { detectPackageManager } from '../utils/package-manager.ts'; @@ -429,8 +430,9 @@ function writeGitHubOutput(key: string, value: string): void { interface ReleaseOptions { autoPublish?: boolean; // skip the version-PR step and version+publish in one shot assertMode?: 'version-pr' | 'publish'; // refuse to run if detected mode doesn't match — see CiPlanMode - tag?: string; // npm dist-tag for auto-publish + tag?: string; // npm dist-tag for auto-publish (or the snapshot dist-tag) branch?: string; // branch name for version PR (default: "bumpy/version-packages") + snapshot?: string; // publish a transient snapshot under this name + comment install instructions on the PR } /** @@ -443,6 +445,13 @@ export async function ciReleaseCommand(rootDir: string, opts: ReleaseOptions): P const config = await loadConfig(rootDir); ensureGitIdentity(rootDir, config); + // Snapshots are a one-shot transient release that can run from any branch (typically a + // feature PR), so they bypass the base/channel branch routing entirely. + if (opts.snapshot !== undefined) { + await ciSnapshotRelease(rootDir, opts); + return; + } + // Channel branches get the channel flow; unknown branches are refused (when channels // are configured) so a misconfigured workflow can't publish from a feature branch. const releaseBranch = detectReleaseBranch(rootDir); @@ -543,6 +552,34 @@ async function autoPublish(rootDir: string, config: BumpyConfig, plan: ReleasePl await publishCommand(rootDir, { tag }); } +// ---- snapshot release flow ---- + +/** + * CI snapshot release: publish a transient snapshot and (if this is a PR) comment install + * instructions. One self-contained step — no version PR, no bump-file changes, no branch + * routing. Typically wired to a labeled `pull_request` workflow: + * + * bumpy ci release --snapshot pr-${{ github.event.pull_request.number }} + * + * Re-running on a new commit republishes and floats the dist-tag (and the comment updates + * in place). On forks the publish token and PR-comment token are unavailable — expected, + * since snapshots target a registry only trusted contributors can publish to. + */ +async function ciSnapshotRelease(rootDir: string, opts: ReleaseOptions): Promise { + const { publishCommand } = await import('./publish.ts'); + const outcome = await publishCommand(rootDir, { snapshot: opts.snapshot, tag: opts.tag }); + if (!outcome || outcome.published.length === 0) return; + + const prNumber = detectPrNumber(); + if (!prNumber) { + log.dim(' No PR detected — skipping snapshot install-instructions comment.'); + return; + } + const pm = await detectPackageManager(rootDir); + const comment = formatSnapshotComment(outcome.snapshot, outcome.published, pm); + await postOrUpdatePrComment(prNumber, comment, rootDir, SNAPSHOT_COMMENT_MARKER); +} + // ---- Token-aware push ---- /** @@ -1128,6 +1165,55 @@ function pmRunCommand(pm: PackageManager): string { return 'npx bumpy'; } +/** Install command for a `name@spec` package spec, in the PR's package manager */ +function pmInstallCommand(pm: PackageManager, spec: string): string { + if (pm === 'bun') return `bun add ${spec}`; + if (pm === 'pnpm') return `pnpm add ${spec}`; + if (pm === 'yarn') return `yarn add ${spec}`; + return `npm i ${spec}`; +} + +/** + * Comment posted on a PR after a snapshot publish: which packages went out, and how to + * install them from the throwaway dist-tag. Maintained in place across re-runs via its + * own marker (separate from the release-plan comment). + */ +export function formatSnapshotComment( + snapshot: ResolvedSnapshot, + published: { name: string; version: string }[], + pm: PackageManager, +): string { + const lines: string[] = [ + `bumpy-frog`, + '', + `**Snapshot published** to the \`@${snapshot.tag}\` dist-tag — a throwaway preview of this PR, not a stable release.`, + '
', + '', + '#### Published packages', + '', + ]; + for (const p of published) { + lines.push(`- \`${p.name}@${p.version}\``); + } + lines.push(''); + lines.push('Install the latest snapshot for this PR:'); + lines.push(''); + lines.push('```bash'); + for (const p of published) { + lines.push(pmInstallCommand(pm, `${p.name}@${snapshot.tag}`)); + } + lines.push('```'); + lines.push(''); + lines.push( + `> The \`@${snapshot.tag}\` tag always points at the newest snapshot from this PR — pushing new commits republishes it. ` + + `Exact versions above are pinned to each other so they install as a coherent set.`, + ); + lines.push(''); + lines.push('---'); + lines.push(`_This comment is maintained by [bumpy](https://bumpy.varlock.dev)._`); + return lines.join('\n'); +} + export function formatReleasePlanComment( plan: ReleasePlan, bumpFiles: BumpFile[], @@ -1489,14 +1575,21 @@ function formatVersionPrBody( } const COMMENT_MARKER = ''; +const SNAPSHOT_COMMENT_MARKER = ''; -async function postOrUpdatePrComment(prNumber: string, body: string, rootDir: string): Promise { +async function postOrUpdatePrComment( + prNumber: string, + body: string, + rootDir: string, + marker: string = COMMENT_MARKER, +): Promise { const validPr = validatePrNumber(prNumber); - const markedBody = `${COMMENT_MARKER}\n${body}`; + const markedBody = `${marker}\n${body}`; try { - // Find existing bumpy comment using gh with jq - const jqFilter = `.comments[] | select(.body | startswith("${COMMENT_MARKER}")) | .url | capture("issuecomment-(?[0-9]+)$") | .id`; + // Find existing bumpy comment using gh with jq. The marker keeps each kind of comment + // (release plan vs snapshot) independent, so they don't overwrite each other. + const jqFilter = `.comments[] | select(.body | startswith("${marker}")) | .url | capture("issuecomment-(?[0-9]+)$") | .id`; const existingComment = tryRunArgs(['gh', 'pr', 'view', validPr, '--json', 'comments', '--jq', jqFilter], { cwd: rootDir, }); diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index 7567f3d..da573c4 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -8,7 +8,13 @@ import { publishPackages, willUseOidcExclusively } from '../core/publish-pipelin import { readBumpFiles } from '../core/bump-file.ts'; import { assembleReleasePlan } from '../core/release-plan.ts'; import { channelNames, resolveActiveChannel, type ResolvedChannel } from '../core/channels.ts'; -import { buildChannelReleasePlan, writeChannelVersionsInPlace } from '../core/prerelease.ts'; +import { buildChannelReleasePlan, writeTransientVersionsInPlace } from '../core/prerelease.ts'; +import { + buildSnapshotReleasePlan, + resolveSnapshot, + assertSnapshotPrerelease, + type ResolvedSnapshot, +} from '../core/snapshot.ts'; import { createIndividualReleases, findReleaseByTag, @@ -45,6 +51,8 @@ interface PublishCommandOptions { filter?: string; /** Channel name override (otherwise inferred from the current branch) */ channel?: string; + /** Publish a transient snapshot under this name (mutually exclusive with channel) */ + snapshot?: string; /** Recovered bump files from a version commit — used for GitHub release body generation */ recoveredBumpFiles?: import('../types.ts').BumpFile[]; /** Package names to exclude from publishing (e.g., packages with pending non-none bumps) */ @@ -61,7 +69,10 @@ interface PublishCommandOptions { * here — targets from the cycle's bump files, counters from the registry — written * transiently into the working tree, published to the channel's dist-tag, and restored. */ -export async function publishCommand(rootDir: string, opts: PublishCommandOptions): Promise { +export async function publishCommand( + rootDir: string, + opts: PublishCommandOptions, +): Promise { const config = await loadConfig(rootDir); const { packages, catalogs } = await discoverWorkspace(rootDir, config); const { packageManager: detectedPm } = await detectWorkspaces(rootDir); @@ -72,6 +83,15 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption process.exit(1); } + // Snapshots are a distinct, transient release model — never mixed with the channel flow. + if (opts.snapshot !== undefined) { + if (opts.channel !== undefined) { + log.error('--snapshot and --channel cannot be used together — they are distinct release models.'); + process.exit(1); + } + return await publishSnapshot(rootDir, config, packages, catalogs, detectedPm, depGraph, opts); + } + const channel = resolveActiveChannel(rootDir, config, opts.channel); if (channel) { await publishChannel(rootDir, config, packages, catalogs, detectedPm, depGraph, channel, opts); @@ -210,7 +230,7 @@ async function publishChannel( // pack/build see them; always restored afterwards — prereleases never land in git. let restore: (() => Promise) | null = null; if (!opts.dryRun) { - restore = await writeChannelVersionsInPlace(plan, packages); + restore = await writeTransientVersionsInPlace(plan, packages); } try { @@ -228,6 +248,132 @@ async function publishChannel( } } +/** + * Publish a transient snapshot from the pending bump files. + * + * Snapshots are throwaway previews — "what the next release would be", published now under + * a non-`latest` dist-tag (default: the snapshot name). The computed plan is written into the + * working tree, published, then restored. Unlike the stable/channel flows this never consumes + * bump files, writes changelogs, commits, creates git tags, or makes GitHub releases. + * + * Strict by design: a snapshot requires pending bump files. With nothing to release there's + * no version plan to snapshot, so we stop with a clear message rather than guessing. + * + * Returns the resolved snapshot and the packages actually published (empty for dry runs or + * when everything was already published) so callers like `ci release` can comment on the PR. + */ +export interface SnapshotPublishOutcome { + snapshot: ResolvedSnapshot; + published: { name: string; version: string }[]; +} + +async function publishSnapshot( + rootDir: string, + config: BumpyConfig, + packages: Map, + catalogs: CatalogMap, + detectedPm: PackageManager, + depGraph: DependencyGraph, + opts: PublishCommandOptions, +): Promise { + const snapshot = resolveSnapshot(opts.snapshot!, config, rootDir, { tag: opts.tag }); + + const { bumpFiles, errors: parseErrors } = await readBumpFiles(rootDir, { channels: channelNames(config) }); + if (parseErrors.length > 0) { + for (const err of parseErrors) log.error(err); + process.exit(1); + } + + // Targets come from the normal stable plan; snapshots don't widen the cascade the way + // channels do (no prereleasePreid) — they preview exactly the pending release. + const stablePlan = assembleReleasePlan(bumpFiles, packages, depGraph, config); + if (stablePlan.releases.length === 0) { + log.info( + `No pending releases to snapshot — snapshots require pending bump files.\n` + + ` Run \`bumpy add\` to declare the changes you want to preview.`, + ); + return null; + } + + log.bold(`Snapshot "${snapshot.name}" — dist-tag @${snapshot.tag} (strategy: ${snapshot.strategy})\n`); + + const { plan, alreadyPublished, warnings } = await buildSnapshotReleasePlan(stablePlan, snapshot, packages); + for (const w of warnings) log.warn(w); + for (const skip of alreadyPublished) { + log.dim(` Skipping ${skip.name}@${skip.version} — this snapshot was already published`); + } + + if (plan.releases.length === 0) { + log.info('Nothing to publish — every package in the plan was already published for this snapshot.'); + return { snapshot, published: [] }; + } + + // Snapshot versions must always be prereleases — a stable version here would land on @latest. + for (const r of plan.releases) assertSnapshotPrerelease(r.newVersion); + + // Filter restricts what gets published; the in-place rewrite below still covers the whole + // plan so in-plan dependency pins stay consistent. + let toPublish = plan.releases; + if (opts.filter) { + const { matchGlob } = await import('../core/config.ts'); + const patterns = opts.filter.split(',').map((p) => p.trim()); + toPublish = toPublish.filter((r) => patterns.some((p) => matchGlob(r.name, p))); + if (toPublish.length === 0) { + log.info('No snapshot packages match the filter.'); + return { snapshot, published: [] }; + } + } + + if (opts.dryRun) { + log.bold('Dry run — would publish:'); + } else { + log.bold('Publishing:'); + } + for (const r of toPublish) console.log(` ${r.name}@${colorize(r.newVersion, 'cyan')}`); + console.log(); + + // Transiently write versions + exact pins so build/pack see them; always restored — + // snapshot versions never land in git. + let restore: (() => Promise) | null = null; + if (!opts.dryRun) { + restore = await writeTransientVersionsInPlace(plan, packages); + } + + let published: { name: string; version: string }[] = []; + try { + const publishPlan: ReleasePlan = { bumpFiles: [], releases: toPublish, warnings: [] }; + const result = await publishPackages( + publishPlan, + packages, + depGraph, + config, + rootDir, + { dryRun: opts.dryRun, tag: snapshot.tag, noTag: true }, + catalogs, + detectedPm, + ); + published = result.published; + + if (result.published.length > 0) { + log.success(`🐸 Published ${result.published.length} snapshot package(s) to @${snapshot.tag}`); + } + if (result.skipped.length > 0) { + log.dim(`Skipped ${result.skipped.length}: ${result.skipped.map((s) => s.name).join(', ')}`); + } + if (result.failed.length > 0) { + log.error(`Failed ${result.failed.length}: ${result.failed.map((f) => `${f.name} (${f.error})`).join(', ')}`); + process.exit(1); + } + } finally { + if (restore) { + await restore(); + log.dim(' Restored package.json files (snapshot versions are not committed)'); + } + } + + return { snapshot, published }; +} + /** * The shared publish flow: OIDC checks, draft GitHub releases, topological publish, * release metadata updates, tag pushes. Used by both the stable and channel paths. diff --git a/packages/bumpy/src/core/config.ts b/packages/bumpy/src/core/config.ts index 4980c42..e9962a1 100644 --- a/packages/bumpy/src/core/config.ts +++ b/packages/bumpy/src/core/config.ts @@ -126,6 +126,10 @@ function mergeConfig(defaults: BumpyConfig, user: Partial): BumpyCo ...defaults.channels, ...user.channels, }, + snapshot: { + ...defaults.snapshot, + ...user.snapshot, + }, }; } diff --git a/packages/bumpy/src/core/prerelease.ts b/packages/bumpy/src/core/prerelease.ts index 8d3a828..d762ffe 100644 --- a/packages/bumpy/src/core/prerelease.ts +++ b/packages/bumpy/src/core/prerelease.ts @@ -47,7 +47,7 @@ export function nextPrereleaseVersion(target: string, preid: string, existingCou } /** Fetch all published versions of a package from the registry */ -async function fetchPublishedVersions(name: string, registry?: string): Promise { +export async function fetchPublishedVersions(name: string, registry?: string): Promise { const args = ['npm', 'info', name, 'versions', '--json']; if (registry) args.push('--registry', registry); try { @@ -76,7 +76,7 @@ async function fetchGitHead(name: string, version: string, registry?: string): P } /** Whether a package publishes through the npm registry (vs custom command / git-tag tracking) */ -function usesNpmRegistry(pkg: WorkspacePackage): boolean { +export function usesNpmRegistry(pkg: WorkspacePackage): boolean { return !pkg.bumpy?.publishCommand && !pkg.bumpy?.skipNpmPublish && !pkg.private; } @@ -185,19 +185,20 @@ export async function buildChannelReleasePlan( } /** - * Transiently write computed prerelease versions (and exact pins for in-cycle deps) - * into the working tree's package.json files. Returns a restore function that puts - * the original contents back — call it in a `finally` after publishing. + * Transiently write computed versions (and exact pins for in-plan deps) into the + * working tree's package.json files. Returns a restore function that puts the + * original contents back — call it in a `finally` after publishing. Used by both + * the channel prerelease flow and snapshot releases — neither commits its versions. * * Versions must be on disk before build/pack so that: - * - PM pack picks up the prerelease version for the tarball + * - PM pack picks up the version for the tarball * - builds that bake in the version (banners, __VERSION__) see the right one * - * In-cycle dependencies are pinned EXACTLY (`"1.2.0-rc.0"`, no range) so any - * combination of packages installed from the channel dist-tag resolves to the + * In-plan dependencies are pinned EXACTLY (`"1.2.0-rc.0"`, no range) so any + * combination of packages installed from the dist-tag resolves to the * coherent set it was published with. */ -export async function writeChannelVersionsInPlace( +export async function writeTransientVersionsInPlace( plan: ReleasePlan, packages: Map, ): Promise<() => Promise> { diff --git a/packages/bumpy/src/core/publish-pipeline.ts b/packages/bumpy/src/core/publish-pipeline.ts index 8c64394..49eefc2 100644 --- a/packages/bumpy/src/core/publish-pipeline.ts +++ b/packages/bumpy/src/core/publish-pipeline.ts @@ -13,6 +13,8 @@ import type { ReleasePlan, PlannedRelease, WorkspacePackage, BumpyConfig, Packag 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; } export interface PublishResult { @@ -421,6 +423,7 @@ function parseTarballPath(output: string, cwd: string, pm: PackageManager): stri } 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}`); diff --git a/packages/bumpy/src/core/snapshot.ts b/packages/bumpy/src/core/snapshot.ts new file mode 100644 index 0000000..9fbea83 --- /dev/null +++ b/packages/bumpy/src/core/snapshot.ts @@ -0,0 +1,167 @@ +import semver from 'semver'; +import { tryRunArgs } from '../utils/shell.ts'; +import { fetchPublishedVersions, usesNpmRegistry } from './prerelease.ts'; +import type { BumpyConfig, ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; + +/** + * Snapshot releases publish the pending release plan transiently — a throwaway + * preview of "what the next release would be", under a non-`latest` dist-tag — without + * consuming bump files, writing changelogs, committing, or creating git/GitHub releases. + * + * The target (major.minor.patch) comes from the normal release plan; the snapshot name + * is both the version preid and (by default) the dist-tag. What makes each publish unique + * is the configured `versionStrategy`: + * - `sha` → `--` — idempotent per commit (re-runs skip) + * - `timestamp` → `--` — always unique + */ + +export type SnapshotVersionStrategy = BumpyConfig['snapshot']['versionStrategy']; + +/** A snapshot request with name sanitized and per-run suffix resolved */ +export interface ResolvedSnapshot { + /** Raw name as passed on the CLI (for messages) */ + rawName: string; + /** Sanitized name — used as the version preid and the default dist-tag */ + name: string; + /** npm dist-tag the snapshot publishes to (explicit `--tag` wins, else the name) */ + tag: string; + strategy: SnapshotVersionStrategy; + /** Version suffix shared across every package in the run (the short sha or timestamp) */ + suffix: string; +} + +/** + * Turn a name into a valid semver prerelease identifier / npm dist-tag: lowercase, + * non-alphanumeric runs collapsed to `-`, leading/trailing `-` trimmed. So a branch + * name like `feature/Foo_Bar` becomes `feature-foo-bar`. + */ +export function sanitizeSnapshotName(name: string): string { + const cleaned = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (!cleaned) { + throw new Error(`Invalid snapshot name "${name}" — must contain at least one alphanumeric character.`); + } + return cleaned; +} + +/** Short (7-char) HEAD sha, or null outside a git repo */ +function shortHeadSha(rootDir: string): string | null { + const sha = tryRunArgs(['git', 'rev-parse', '--short=7', 'HEAD'], { cwd: rootDir }); + return sha && /^[0-9a-f]{7,}$/.test(sha) ? sha : null; +} + +/** Compact UTC timestamp `YYYYMMDDHHmmss` (numeric, stable-sorting) */ +function utcTimestamp(date: Date): string { + const p = (n: number, len = 2) => String(n).padStart(len, '0'); + return ( + `${p(date.getUTCFullYear(), 4)}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}` + + `${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}` + ); +} + +/** + * Resolve a snapshot request: sanitize the name, pick the dist-tag, and compute the + * per-run version suffix for `sha`/`timestamp`. `now` is injectable for deterministic tests. + */ +export function resolveSnapshot( + rawName: string, + config: BumpyConfig, + rootDir: string, + opts: { tag?: string; now?: Date } = {}, +): ResolvedSnapshot { + const name = sanitizeSnapshotName(rawName); + const strategy = config.snapshot.versionStrategy; + + let suffix: string; + if (strategy === 'sha') { + const sha = shortHeadSha(rootDir); + if (!sha) { + throw new Error( + 'Snapshot versionStrategy "sha" needs a git commit to derive the version from, but HEAD could not be resolved.\n' + + ' Commit your changes (or switch `snapshot.versionStrategy` to "timestamp").', + ); + } + suffix = sha; + } else { + suffix = utcTimestamp(opts.now ?? new Date()); + } + + return { rawName, name, tag: opts.tag ?? name, strategy, suffix }; +} + +/** Compose the snapshot version for one target: `--` */ +export function snapshotVersion(target: string, snapshot: ResolvedSnapshot): string { + return `${target}-${snapshot.name}-${snapshot.suffix}`; +} + +export interface SnapshotReleasePlanResult { + /** The plan with snapshot versions applied (packages already published from this commit removed) */ + plan: ReleasePlan; + /** Packages skipped because this exact snapshot version was already published */ + alreadyPublished: Array<{ name: string; version: string }>; + warnings: string[]; +} + +/** + * Transform a stable release plan (targets from bump files) into a snapshot plan: each + * release's newVersion becomes the snapshot version. Unpublishable private packages are + * dropped — a snapshot has to be installable from a registry to be useful. + * + * Idempotency: if the exact snapshot version is already on the registry, the package is + * skipped rather than re-published (npm forbids republishing). For `sha` this means + * re-running on the same commit is a no-op; `timestamp` versions essentially never collide. + */ +export async function buildSnapshotReleasePlan( + stablePlan: ReleasePlan, + snapshot: ResolvedSnapshot, + packages: Map, +): Promise { + const warnings = [...stablePlan.warnings]; + const alreadyPublished: Array<{ name: string; version: string }> = []; + const releases: PlannedRelease[] = []; + + await Promise.all( + stablePlan.releases.map(async (release) => { + 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; + + const target = release.newVersion; // stable target from the bump files + const version = snapshotVersion(target, snapshot); + + // The version embeds its own uniqueness (sha/timestamp), so a collision means we + // 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); + if (versions.includes(version)) { + alreadyPublished.push({ name: release.name, version }); + return; + } + } + + releases.push({ ...release, newVersion: version }); + }), + ); + + releases.sort((a, b) => a.name.localeCompare(b.name)); + return { plan: { bumpFiles: stablePlan.bumpFiles, releases, warnings }, alreadyPublished, warnings }; +} + +/** One-line summary of a snapshot plan's versions, for logs and PR comments */ +export function formatSnapshotVersionSummary(releases: PlannedRelease[]): string { + if (releases.length === 0) return ''; + if (releases.length === 1) return `${releases[0]!.name}@${releases[0]!.newVersion}`; + return `${releases.length} packages`; +} + +/** Guard: a snapshot version must be a valid prerelease (never collides with a stable release) */ +export function assertSnapshotPrerelease(version: string): void { + if (semver.prerelease(version) === null) { + throw new Error(`Snapshot version "${version}" is not a prerelease — refusing to publish (would land on @latest).`); + } +} diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index 957bb6e..a0e7eab 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -111,6 +111,18 @@ export interface ChannelConfig { }; } +export interface SnapshotConfig { + /** + * How snapshot versions are made unique. The snapshot name is always the prerelease + * preid and the default dist-tag (`bumpy publish --snapshot pr-123` → `@pr-123`), so + * consumers install via the tag regardless — this only affects the underlying version. + * - `"sha"` → `--` (idempotent per commit; default). Re-running + * on the same commit produces the same version, which is skipped if already published. + * - `"timestamp"` → `--` (always unique; never idempotent). + */ + versionStrategy: 'sha' | 'timestamp'; +} + export interface BumpyConfig { baseBranch: string; access: 'public' | 'restricted'; @@ -121,6 +133,12 @@ export interface BumpyConfig { * they are derived from bump files, the registry, and git tags at publish time. */ channels: Record; + /** + * Snapshot release settings. Snapshots publish the pending release plan transiently + * under a throwaway dist-tag (e.g. for previewing a PR from a private registry) without + * consuming bump files, writing changelogs, committing, or tagging. See `bumpy publish --snapshot`. + */ + snapshot: SnapshotConfig; /** * Customize the commit message used when versioning. * A string starting with "./" is treated as a path to a module that exports @@ -221,6 +239,7 @@ export const DEFAULT_CONFIG: BumpyConfig = { baseBranch: 'main', access: 'public', channels: {}, + snapshot: { versionStrategy: 'sha' }, versionCommitMessage: undefined, changedFilePatterns: ['**'], ignoredPackageJsonFields: ['devDependencies'], diff --git a/packages/bumpy/test/core/prerelease.test.ts b/packages/bumpy/test/core/prerelease.test.ts index b7f2e71..27cbba1 100644 --- a/packages/bumpy/test/core/prerelease.test.ts +++ b/packages/bumpy/test/core/prerelease.test.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path'; import { extractPrereleaseCounters, nextPrereleaseVersion, - writeChannelVersionsInPlace, + writeTransientVersionsInPlace, formatChannelVersionSummary, channelDisplayPlan, } from '../../src/core/prerelease.ts'; @@ -47,7 +47,7 @@ describe('nextPrereleaseVersion', () => { }); }); -describe('writeChannelVersionsInPlace', () => { +describe('writeTransientVersionsInPlace', () => { test('writes prerelease versions and exact-pins in-cycle deps, then restores', async () => { const dir = await createTempGitRepo(); try { @@ -89,7 +89,7 @@ describe('writeChannelVersionsInPlace', () => { makeRelease('plugin', '1.0.1-rc.1', { oldVersion: '1.0.0' }), ]); - const restore = await writeChannelVersionsInPlace(plan, packages); + const restore = await writeTransientVersionsInPlace(plan, packages); const writtenCore = JSON.parse(await readFile(resolve(coreDir, 'package.json'), 'utf-8')); const writtenPlugin = JSON.parse(await readFile(resolve(pluginDir, 'package.json'), 'utf-8')); diff --git a/packages/bumpy/test/core/snapshot.test.ts b/packages/bumpy/test/core/snapshot.test.ts new file mode 100644 index 0000000..a722043 --- /dev/null +++ b/packages/bumpy/test/core/snapshot.test.ts @@ -0,0 +1,193 @@ +import { describe, test, expect, afterEach } from 'bun:test'; +import semver from 'semver'; +import { + sanitizeSnapshotName, + resolveSnapshot, + snapshotVersion, + buildSnapshotReleasePlan, + assertSnapshotPrerelease, + formatSnapshotVersionSummary, + type ResolvedSnapshot, +} from '../../src/core/snapshot.ts'; +import { makePkg, makeConfig, makeRelease, makeReleasePlan, createTempGitRepo, cleanupTempDir } from '../helpers.ts'; + +describe('sanitizeSnapshotName', () => { + test('passes through clean names', () => { + expect(sanitizeSnapshotName('pr-123')).toBe('pr-123'); + }); + + test('lowercases and collapses invalid runs to hyphens', () => { + expect(sanitizeSnapshotName('feature/Foo_Bar')).toBe('feature-foo-bar'); + expect(sanitizeSnapshotName('PR #123')).toBe('pr-123'); + }); + + test('trims leading/trailing hyphens', () => { + expect(sanitizeSnapshotName('--pr-123--')).toBe('pr-123'); + expect(sanitizeSnapshotName('/feature/x/')).toBe('feature-x'); + }); + + test('produces a valid semver prerelease identifier', () => { + const name = sanitizeSnapshotName('feature/Foo_Bar'); + expect(semver.valid(`1.2.3-${name}.0`)).not.toBeNull(); + }); + + test('throws when nothing alphanumeric remains', () => { + expect(() => sanitizeSnapshotName('///')).toThrow(/at least one alphanumeric/); + expect(() => sanitizeSnapshotName(' ')).toThrow(); + }); +}); + +describe('resolveSnapshot', () => { + let dir = ''; + afterEach(async () => { + if (dir) await cleanupTempDir(dir); + dir = ''; + }); + + test('sha strategy resolves the short HEAD sha as the suffix', async () => { + dir = await createTempGitRepo(); + const config = makeConfig({ snapshot: { versionStrategy: 'sha' } }); + const snap = resolveSnapshot('pr-123', config, dir); + expect(snap.name).toBe('pr-123'); + expect(snap.tag).toBe('pr-123'); + expect(snap.strategy).toBe('sha'); + expect(snap.suffix).toMatch(/^[0-9a-f]{7}$/); + }); + + test('timestamp strategy uses an injected date', () => { + const config = makeConfig({ snapshot: { versionStrategy: 'timestamp' } }); + const now = new Date(Date.UTC(2026, 5, 23, 12, 34, 56)); // June is month index 5 + const snap = resolveSnapshot('pr-123', config, '/nonexistent', { now }); + expect(snap.suffix).toBe('20260623123456'); + }); + + test('explicit tag overrides the default (name)', () => { + const config = makeConfig({ snapshot: { versionStrategy: 'timestamp' } }); + const snap = resolveSnapshot('sha-abc', config, '/x', { tag: 'pr-7' }); + expect(snap.name).toBe('sha-abc'); + expect(snap.tag).toBe('pr-7'); + }); + + test('sanitizes the name into the preid and default tag', () => { + const config = makeConfig({ snapshot: { versionStrategy: 'timestamp' } }); + const snap = resolveSnapshot('feature/Foo', config, '/x'); + expect(snap.name).toBe('feature-foo'); + expect(snap.tag).toBe('feature-foo'); + }); + + test('sha strategy throws when HEAD cannot be resolved', () => { + const config = makeConfig({ snapshot: { versionStrategy: 'sha' } }); + expect(() => resolveSnapshot('pr-1', config, '/definitely/not/a/repo')).toThrow(/sha/); + }); +}); + +describe('snapshotVersion', () => { + const base = (overrides: Partial): ResolvedSnapshot => ({ + rawName: 'pr-123', + name: 'pr-123', + tag: 'pr-123', + strategy: 'sha', + suffix: 'a1b2c3d', + ...overrides, + }); + + test('sha → --', () => { + const v = snapshotVersion('1.4.0', base({ strategy: 'sha', suffix: 'a1b2c3d' })); + expect(v).toBe('1.4.0-pr-123-a1b2c3d'); + expect(semver.valid(v)).toBe(v); + expect(semver.prerelease(v)).not.toBeNull(); + }); + + test('timestamp → --', () => { + const v = snapshotVersion('2.0.0', base({ strategy: 'timestamp', suffix: '20260623123456' })); + expect(v).toBe('2.0.0-pr-123-20260623123456'); + expect(semver.valid(v)).toBe(v); + }); + + test('snapshot versions sort below their stable target', () => { + const v = snapshotVersion('1.4.0', base({})); + expect(semver.lt(v, '1.4.0')).toBe(true); + }); +}); + +describe('buildSnapshotReleasePlan', () => { + // Use private packages with a custom publishCommand: 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' } }); + + test('applies sha snapshot versions to each release', async () => { + const packages = new Map([['a', publishable('a', '1.0.0')]]); + const plan = makeReleasePlan([makeRelease('a', '1.1.0', { type: 'minor' })]); + const snapshot: ResolvedSnapshot = { + rawName: 'pr-9', + name: 'pr-9', + tag: 'pr-9', + strategy: 'sha', + suffix: 'deadbee', + }; + + const { plan: out, alreadyPublished } = await buildSnapshotReleasePlan(plan, snapshot, packages); + expect(alreadyPublished).toEqual([]); + expect(out.releases).toHaveLength(1); + expect(out.releases[0]!.newVersion).toBe('1.1.0-pr-9-deadbee'); + }); + + test('drops unpublishable private packages (no publishCommand)', async () => { + const packages = new Map([ + ['a', publishable('a', '1.0.0')], + ['b', makePkg('b', '2.0.0', { private: true })], // truly unpublishable + ]); + const plan = makeReleasePlan([makeRelease('a', '1.1.0'), makeRelease('b', '2.0.1')]); + const snapshot: ResolvedSnapshot = { + rawName: 'pr-1', + name: 'pr-1', + tag: 'pr-1', + strategy: 'timestamp', + suffix: '20260101000000', + }; + + const { plan: out } = await buildSnapshotReleasePlan(plan, snapshot, packages); + expect(out.releases.map((r) => r.name)).toEqual(['a']); + }); + + test('sorts releases by name', async () => { + const packages = new Map([ + ['z', publishable('z', '1.0.0')], + ['a', publishable('a', '1.0.0')], + ]); + const plan = makeReleasePlan([makeRelease('z', '1.1.0'), makeRelease('a', '1.1.0')]); + const snapshot: ResolvedSnapshot = { + rawName: 'pr-1', + name: 'pr-1', + tag: 'pr-1', + strategy: 'sha', + suffix: 'abcdef0', + }; + + const { plan: out } = await buildSnapshotReleasePlan(plan, snapshot, packages); + expect(out.releases.map((r) => r.name)).toEqual(['a', 'z']); + }); +}); + +describe('assertSnapshotPrerelease', () => { + test('passes for prerelease versions', () => { + expect(() => assertSnapshotPrerelease('1.0.0-pr-1-abc')).not.toThrow(); + }); + + test('throws for stable versions (would land on @latest)', () => { + expect(() => assertSnapshotPrerelease('1.0.0')).toThrow(/not a prerelease/); + }); +}); + +describe('formatSnapshotVersionSummary', () => { + test('empty', () => { + expect(formatSnapshotVersionSummary([])).toBe(''); + }); + test('single', () => { + expect(formatSnapshotVersionSummary([makeRelease('a', '1.0.0-pr-1-abc')])).toBe('a@1.0.0-pr-1-abc'); + }); + test('multiple', () => { + expect(formatSnapshotVersionSummary([makeRelease('a', '1.0.0'), makeRelease('b', '2.0.0')])).toBe('2 packages'); + }); +});