# release

A small Rust CLI that bumps a project's version, commits, tags, pushes, and
optionally publishes -- across a handful of package managers.

## Installation

### Homebrew

```sh
brew install smartcrabai/tap/release
```

### Shell installer (macOS / Linux)

```sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/smartcrabai/release/releases/latest/download/release-installer.sh | sh
```

### Cargo

```sh
cargo install smartcrab-release
```

### Prebuilt binaries

Download the archive for your platform from the [GitHub Releases](https://github.com/smartcrabai/release/releases/latest) page. Supported targets:

- `aarch64-apple-darwin`
- `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`
- `x86_64-pc-windows-msvc`, `aarch64-pc-windows-msvc`

## Usage

```sh
release [patch|minor|major] [-P|--no-publish] [-p|--only-publish] [--dry-run] [--backend <name>]
```

The positional argument defaults to `patch`.

### Flags

| Flag            | Description                                                                |
| --------------- | -------------------------------------------------------------------------- |
| `-P`, `--no-publish` | Skip the publish step (existing `cargo-release` compatibility).       |
| `-p`, `--only-publish` | Only run the publish step; skip bump/commit/tag/push (mutually exclusive with `--no-publish`). |
| `--dry-run`     | Print the steps that would run without writing files or running commands. |
| `--backend <n>` | Override auto-detection. One of `cargo`, `pnpm`, `bun`, `go`, `dotnet`, `julia`, `uv`. |

## Supported package managers

| Backend | Detection (first match wins)                          | Version location                                | Lockfile update              | Publish                                       |
| ------- | ----------------------------------------------------- | ----------------------------------------------- | ---------------------------- | --------------------------------------------- |
| cargo   | `Cargo.toml`                                          | `[package].version` / `[workspace.package].version` | `cargo update --workspace` (requires Cargo >= 1.66) | `cargo publish` (workspace: `-p <name>`)      |
| uv      | `pyproject.toml` + `uv.lock`                          | `[project].version`                             | `uv lock`                    | `uv publish`                                  |
| pnpm    | `package.json` + `pnpm-lock.yaml` (or `package.json` alone as fallback) | `"version"` in `package.json`      | `pnpm install --lockfile-only` | `pnpm publish --no-git-checks`              |
| bun     | `package.json` + `bun.lock` or `bun.lockb`            | `"version"` in `package.json`                   | `bun install`                | `bun publish`                                 |
| julia   | `Project.toml` containing `uuid` and `version`        | top-level `version`                             | -- (Manifest is your problem) | -- (no-op)                                     |
| dotnet  | `*.sln` / `*.csproj` / `*.fsproj` / `Directory.Build.props` | `<Version>...</Version>`                  | `dotnet restore`             | `dotnet pack -c Release` (API key is yours)   |
| go      | `go.mod`                                              | -- (tag only; previous tag is read from git)     | --                            | -- (no-op; tags are the release)               |

## Workspace / monorepo support

For projects that define multiple packages in a single repository the tool
updates every member's version in lockstep with the root version -- reading
only the root version and writing the new version to every discovered member
manifest. Glob patterns use single-segment wildcards (`packages/*`,
`crates/*`, ...); `**` and `!`-negated patterns are skipped with a warning.

| Backend | Workspace source                                             | Lockstep updates                                                    |
| ------- | ------------------------------------------------------------ | ------------------------------------------------------------------- |
| cargo   | `[workspace] members = [...]` (virtual or root-package)      | each member's `[package].version`; skipped when `[workspace.package].version` is used; intra-workspace dependency `version` requirements (including `[workspace.dependencies]`) are updated to the new version |
| uv      | `[tool.uv.workspace] members = [...]` in root `pyproject.toml` | each member's `[project].version`                                 |
| pnpm    | `pnpm-workspace.yaml` `packages:`                            | each member's `package.json` `"version"`                            |
| bun     | `"workspaces"` array or object in root `package.json`        | each member's `package.json` `"version"`                            |
| dotnet  | `Directory.Build.props` (centralized), `*.sln`, or recursive `*.csproj`/`*.fsproj` discovery | `<Version>` in each project file (no-op for projects lacking the element); central `Directory.Build.props` updates only that file |
| go      | -- (no version files; the git tag is the release)             | -- (not applicable)                                                  |
| julia   | -- (no standard workspace layout)                             | -- (not applicable)                                                  |

For `bun`, publishable workspace packages are published in dependency order
(each package's intra-workspace dependencies are published before it). Cycles
in the dependency graph abort the publish step. `pnpm` delegates ordering to
`pnpm -r publish`, which already publishes in topological order.

## Skipping publish via manifest fields

In addition to the `--no-publish` flag, the tool honors each package manager's
native "do not publish" marker. When every package targeted by the publish step
is marked non-publishable, the step is silently skipped (a friendly notice is
printed) instead of running and erroring out.

| Backend | Marker                                                                              |
| ------- | ----------------------------------------------------------------------------------- |
| cargo   | `[package].publish = false` or `publish = []` (also resolves `publish.workspace = true` against `[workspace.package].publish`) |
| pnpm    | `"private": true` in `package.json`                                                 |
| bun     | `"private": true` in `package.json`                                                 |

For workspace setups the per-member marker is respected: publishable members
are published while non-publishable members are filtered out. The publish step
runs only when at least one package would actually be published. uv, dotnet,
julia, and go currently leave publish decisions to the underlying tool itself.

## Disabling publish for a repository (`.release.toml`)

For repositories where the package itself is meant to be published, but
publishing is handled by CI instead of this tool -- e.g. GitHub Actions using
OIDC trusted publishing -- add a `.release.toml` file at the project root with:

```toml
publish = false
```

When set, the publish step is always skipped, regardless of backend or
manifest markers. This applies to the normal bump/commit/tag/push/publish flow
*and* to `--only-publish`, which is skipped gracefully too (it prints a
message and exits successfully rather than publishing) -- the point is to
prevent a local `release` invocation from accidentally publishing outside of
CI's provenance-carrying pipeline. `publish = true` is accepted for
explicitness but matches the default (publish enabled). A missing
`.release.toml`, or one without a `publish` key, leaves publish enabled.

`.release.toml` is looked up starting from the current directory and walking
up through ancestor directories, stopping at (and including) the top level of
the git repository -- the first ancestor containing a `.git` entry. This means
a `.release.toml` at the repository root also applies when `release` is run
from a subdirectory (e.g. a member of a monorepo), while directories outside
the repository (e.g. your home directory) are never consulted.

Config errors are always fatal and are checked before any bump or git
operation runs: a `.release.toml` that fails to parse as TOML, whose
`publish` key is not a boolean, that contains an unknown top-level key, or
that cannot be read (e.g. it is a directory rather than a file) all abort the
command immediately, even when `--no-publish` is passed.

## Git workflow

For every backend the tool:

1. Requires a clean working tree and the `main` branch (warnings only under `--dry-run`).
2. Runs `git pull --ff-only origin main`.
3. Bumps the version (`patch` / `minor` / `major`).
4. Updates the lockfile where applicable.
5. Stages the manifest (and lockfile), commits as `chore: bump version to X.Y.Z`,
   tags `vX.Y.Z`, and pushes both `main` and the tag to `origin`.
6. Publishes unless `--no-publish` is passed.

Under `--dry-run` nothing is written, no external commands run, and no
git mutations happen -- each intended action is logged as `would run: ...` or
`would write: ...`.
