Skip to content

The release pipeline

shiprig release runs a configurable step pipeline defined in .changeset/release.jsonc — the orchestration layer on top of version, tag, and publish. It's the Go port of net-changesets' release orchestrator.

sh
shiprig release
shiprig release --dry-run        # preview the interpolated plan; nothing ships
shiprig release --dry-build      # build artifacts locally, publish nothing
shiprig release --only build,publish   # run just these steps
shiprig release --from publish   # resume at a step after a failure
shiprig release --yes            # approve every confirm gate (CI)

Built-in steps

With no order configured, the pipeline runs these steps in order. Any step can be reordered, disabled, replaced, or have custom steps slotted between them.

StepWhat it does
versionBump versions + write CHANGELOG.md (the shared engine)
commitCommit the version/changelog changes (message via message)
buildBuild release artifacts — runs early as a packaging preflight so a broken build fails before anything ships
signCode-sign built artifacts in place (desktop ecosystems)
publishPush to the native registries (idempotent, registry-aware)
tagCreate the git tags for the released versions
pushPush commits + tags to the remote
releaseCreate the forge (GitHub/GitLab/Gitea) release + upload assets (idempotent)
issuesComment on / close issues referenced by released commits (GitHub today)

version, commit, publish, tag, and push are command-based (they shell out to tool, default shiprig); build, sign, release, and issues are native handlers.

.changeset/release.jsonc

The pipeline file is JSONC (comments + trailing commas welcome). Top-level keys:

jsonc
{
  "tool": "shiprig",        // command backing the built-in steps (e.g. "npx changeset")
  "shell": "portable",      // "portable" (default, in-process, cross-OS) or "system"
  "order": ["version", "build", "publish", "tag", "push", "release"],
  "vars": { /* … */ },      // reusable / captured / computed values
  "hooks": { /* … */ },     // before / after / onError around the whole run
  "steps": { /* … */ },     // per-step overrides keyed by step name
}

Per-step configuration

Each entry under steps overrides one step:

KeyMeaning
enabledtrue / false / omit (use default)
ifA Tengo expression gating the step at runtime — falsy skips it (with a reason)
ecosystemsRestrict the step to these ecosystems (node, dotnet, go, rust, tauri, electron); skipped if the release has none of them
nameDisplay name shown in the plan + output
runCustom command(s) — a shell string, an argv array, or a mix
scriptA Tengo script action (instead of run)
argsExtra args appended to a built-in step's command (e.g. ["--otp", "${vars.otp}"])
messageCommit message for the commit step (default chore: release)
confirmtrue (default prompt), a custom prompt string, or false (no gate)
dryRunPer-step dry-run behavior: true executes in dry-run, false hides it, or a command/array to run instead
before / afterCommand(s) to run around the step's action
forge / forgeURLForge selection for the release step (see below)

A step name that isn't a built-in becomes a custom step — add it to order and give it a run or script.

Variables

${...} placeholders interpolate into commands, hooks, and other vars. Two families:

Built-in release variables (populated from the release plan):

VariableValue
${version} / ${tag} / ${changelog}Single-package shortcuts
${version.<key>} / ${tag.<key>} / ${changelog.<key>}Per-package, keyed by short package address
${versions}Comma-separated name@version list
${tags}Array of tags
${releaseUrl.<key>} / ${releaseUrls}Forge release URLs (filled by the release step)
${issues}Resolved issue numbers from released commits
${env.NAME}The merged environment (see .env)

User-defined vars come in three forms:

jsonc
"vars": {
  "basePath": "dist/pkg",                 // literal: reusable config, not masked
  "basePath2": { "value": "dist/pkg" },   // explicit literal form
  "otp": { "command": "op item get npm --otp", "lazy": true },  // captured: stdout, masked, lazy
  "channel": { "script": "ctx.version ? 'next' : 'latest'" },   // computed: Tengo expr
}
  • Literal — a bare string or { "value": "…" }. No side effects, not masked (it's config, not a secret) — ideal for paths reused across steps.
  • Captured{ "command": "…" }. The command's trimmed stdout becomes the value and is masked from logs. Add "lazy": true to defer it until first use (fresh, time-limited secrets like an OTP).
  • Computed{ "script": "<tengo-expr>" } evaluated over the script context. Not masked.

Tengo scripting

A step's action (or a computed var, or an if gate) can be a Tengo script instead of a shell command — a small embedded language that runs identically on every OS. Steps gate on if:

jsonc
"steps": {
  "publish": { "if": "ctx.version" },     // skip when there's nothing to publish
  "notify": {
    "script": "sh(`echo released ` + ctx.tag); log('done')"
  }
}

Available modules/globals: text, fmt, math, times, rand, json, base64, hex. Side-effecting helpers: sh(cmd), cp(...), mv(...), rm(...), mkdir(...), log(msg), fail(msg).

The script context ctx exposes dryRun, env, packages, versions, tags, issues, and — for single-package releases — the scalars ctx.version, ctx.tag, ctx.changelog.

Cross-platform shell & file ops

"shell": "portable" (the default) runs shell-string commands through an in-process interpreter, so release.jsonc behaves the same on Linux, macOS, and Windows. "shell": "system" uses the OS shell instead. Either way, argv-array commands run directly. The portable shell ships cross-platform builtins — cp (-r/-R), mv, rm (-r/-f), mkdir (-p) — also reachable from Tengo as cp(...) / mv(...) / rm(...) / mkdir(...).

Forge releases

The release step creates a release on your forge and uploads built assets. Forge selection lives on that step:

jsonc
"steps": {
  "release": {
    "forge": "auto",       // auto (detect from origin) | github | gitlab | gitea | none
    "forgeURL": ""         // base URL for self-hosted GitLab/Gitea
  }
}

auto detects GitHub.com → github, GitLab.com → gitlab, others need an explicit value. none (or --git-only) degrades to tags only — the issues step and forge URLs are skipped. Release creation and asset upload are idempotent.

Publish authentication

Per-ecosystem auth and OIDC trusted publishing are configured under each ecosystem block in the release config:

jsonc
"npm":    { "auth": "op://CI/npm/token" },          // 1Password secret reference
"cargo":  { "auth": "env:CARGO_REGISTRY_TOKEN" },   // an environment variable
"dotnet": { "auth": "cmd:op item get nuget --fields apikey", "oidc": "auto" }
  • auth takes a secret reference: op://vault/item/field (1Password, via op read), env:NAME, or cmd:… (a command's stdout). Resolved secrets are masked from logs.
  • oidc is "auto" (use OIDC trusted publishing when a CI OIDC context is present) or "off" (force a token). Supported for npm, crates.io, and NuGet.org.

Precedence per registry: an explicit auth ref wins; otherwise OIDC when a CI context is present and not turned off; otherwise the ambient environment. See the publish-auth guide for the full matrix.

Signing (desktop ecosystems)

Tauri and Electron releases can be code-signed via a signing block: build-time signing through signing.env (e.g. macOS CSC_* / APPLE_* for electron-builder / Tauri) and post-build signers under signing.signers (Azure Trusted Signing for .exe/.msi, rcodesign/codesign for .dmg/.app). Artifacts are signed in place by the sign step before release attaches them. --dry-build previews the signer commands without contacting a signing service.

Where the release config lives

shiprig resolves the pipeline file from one of these locations. If more than one exists it stops and lists them rather than guessing (a .json + .jsonc pair counts as two); with none, the built-in defaults run.

  • .changeset/release.jsonc · .changeset/release.json
  • .changeset/shiprig.jsonc · .changeset/shiprig.json
  • release.jsonc · release.json · shiprig.jsonc · shiprig.json (repo root)
  • a "shiprig" (or "release") key inside .rig.json:
jsonc
// .rig.json
{
  "shiprig": {
    "order": ["version", "build", "publish", "tag", "push", "release"]
    // …the same keys as a standalone release config
  }
}
  • a "release" key inside the changeset config file (.changeset/config.json / changerig.jsonc):
jsonc
// .changeset/config.json — one file for both tools
{
  "versioning": { "source": "commits" },
  "ignore": [],
  "release": {
    "order": ["version", "publish", "tag", "release"]
    // …the pipeline; changerig ignores this key
  }
}

shiprig release --config <file> overrides discovery with an explicit path.

One file for both tools

shiprig is a superset of changerig, so you can keep both configs in a single file instead of two — it's optional, and existing two-file setups keep working unchanged. It goes both ways:

  • changeset config at the top level + a release key (a config.json, as above), or
  • the pipeline at the top level + a changeset key (a shiprig.jsonc):
jsonc
// .changeset/shiprig.jsonc
{
  "$schema": "https://rigsmith.dev/schemas/shiprig.json",
  "order": ["version", "publish", "tag", "release"],
  "changeset": { "versioning": { "source": "commits" }, "ignore": [] }
}

Both tools read whichever file you choose. If you end up with two files, shiprig doctor flags it and offers to merge them into one .changeset/shiprig.jsonc. Defining the same config in two places is a loud error (shiprig never guesses).

Environment & .env

Before running, shiprig release loads .env and .env.local from the repo root and layers them under the ambient shell environment (.env < .env.local < exported variables — a real export always wins). That merged environment is what every part of the run sees:

  • ${env.NAME} placeholders in steps, hooks, and vars resolve from it;
  • the commands each step runs (publish, tag, push) inherit it;
  • forge releases run with it, so gh finds its token;
  • shiprig init's token preflight checks it, so a token kept in a local .env reads as ✓ set rather than a false ⚠.

This means a release token can live in .env.local (git-ignored) instead of being exported in every shell. The .env files themselves are read, never written or printed.

Secret masking only redacts values it has been given — the ones captured through vars or resolved from auth references. A value interpolated straight into a command with ${env.NAME} is not automatically masked, so avoid putting a raw secret on a command line that gets logged.

Pass --no-env to drop the .env/.env.local layer for a run (the ambient shell environment still flows through) — handy when a stray local .env would otherwise shadow what you've exported.

Dry-run vs dry-build

  • --dry-run interpolates and prints the full plan but executes nothing, except steps explicitly marked "dryRun": true (or given a dry-run command).
  • --dry-build runs only the build step to produce artifacts locally (a snapshot), then stops — it publishes, tags, and pushes nothing. Global hooks and captured vars are dropped so it can't trigger OTP prompts. Requires an enabled build step.

Implementation

The pipeline lives in internal/shiprig/pipeline + internal/shiprig/forge; see the feature-parity audit for the delivered surface.

Released under the MIT License.