[feat] release: interactive release client, drop the CI linked-deps guard

`pnpm run release` (scripts/release.mjs, zx + enquirer + commander) replaces
the hand sequence of bump, changelog, gate, tag, push. It picks packages from a
list annotated with what npmjs already has, computes versions from the manifest,
collects notes in $EDITOR seeded with the commits since the package's last tag,
and prepends them to CHANGELOG.md in the format release.yml's parser expects.

The gate (lint:ci -> typecheck -> test:coverage -> build -> verify-pack) runs
against the bumped tree *before* the commit, so a failure leaves nothing to
unpick -- it offers to restore instead. Tags go out dependency-first, and each
version is polled on npmjs before the next tag is pushed.

That polling is what lets release.yml lose its `check linked deps are released`
step: the ordering is now enforced before CI ever sees a tag, rather than after.
linked-deps.mjs stays as a hand-check. The accepted cost is that a tag pushed
some other way is no longer caught.

Three things found by running it rather than reading it:

- Tags are annotated (`-a -m`). A lightweight tag is rejected outright under
  tag.forceSignAnnotated, which is set on the machine this was written on.
- pnpm 11 forwards the `--` in `pnpm run release -- --dry-run` literally, and
  commander reads a bare `--` as "the rest are positionals". The script takes no
  positionals, so it strips it and both spellings work.
- Prompts refuse with a message naming the flag that avoids them when stdin is
  not a TTY, instead of hanging as an unsettled top-level await.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCzYTAm9QUhvLNr2EpdagJ
This commit is contained in:
Benjamin Diedrichsen
2026-09-01 12:31:17 +02:00
co-authored by Claude Opus 5
parent 0aa0be5542
commit 2019626618
7 changed files with 1016 additions and 62 deletions
+8 -26
View File
@@ -125,32 +125,14 @@ jobs:
- name: Install - name: Install
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Check the linked workspace packages are already released # There used to be a *check linked deps are released* step here, refusing
env: # to publish a package whose `workspace:` dependency was not yet on npmjs.
NAME: ${{ steps.target.outputs.name }} # It was removed: `scripts/release.mjs` is what creates release tags now,
DIR: ${{ steps.target.outputs.dir }} # and it already pushes them dependency-first and waits for each version to
run: | # resolve on npmjs before pushing the next — so the ordering is enforced
set -euo pipefail # before CI ever sees a tag, rather than after. `node scripts/linked-deps.mjs
# `pnpm publish` turns `workspace:*` into the version the linked # <dir>` still prints what a package would bake in, if you want to check by
# package declares at this commit. If that version is not on the # hand. A tag pushed some other way is no longer caught.
# registry yet, the release installs to a broken tree — and npmjs
# only lets you unpublish for 72 hours. Release the dependency first:
# nopy-cubes, then nopy, then any bundle.
#
# npmjs only: it is the irreversible one, and it needs no credentials
# to read, which this step does not have yet.
missing=0
for spec in $(node scripts/linked-deps.mjs "$DIR" | tr ' ' '@'); do
# Scoped, not `--registry`: `@scope:registry` outranks it, so a bare
# flag can be silently overridden by any project-level .npmrc.
if npm view "$spec" version --@bitsquare:registry="$NPMJS_REGISTRY" >/dev/null 2>&1; then
echo "${spec} is published"
else
echo "::error::${NAME} depends on ${spec}, which is not on npmjs. Release it first."
missing=1
fi
done
exit "$missing"
- name: Lint - name: Lint
run: pnpm run lint:ci run: pnpm run lint:ci
+42 -16
View File
@@ -142,13 +142,27 @@ One pass per invocation, `nopy.main.ts` orchestrating:
variables (prompt, or read them back from the session on replay) → run variables (prompt, or read them back from the session on replay) → run
`before` hooks → resolve `manifest.dependencies(vars)` (dynamic: it receives `before` hooks → resolve `manifest.dependencies(vars)` (dynamic: it receives
the *collected* variables) → emit the deploy call → run `after` hooks. There the *collected* variables) → emit the deploy call → run `after` hooks. There
is no separate topological sort; ordering falls out of the recursion, and a is no separate topological sort; emission is post-order, so the ordering *is*
`${cubeId}:${host}` set makes emission idempotent. Hooks get a `HookContext` topological without an algorithm computing it, and a `${cubeId}:${host}` set
makes emission idempotent. Hooks get a `HookContext`
whose `exec(id, vars)` re-enters `resolveCube`, so a hook can pull in a cube whose `exec(id, vars)` re-enters `resolveCube`, so a hook can pull in a cube
that is not a declared dependency. that is not a declared dependency.
6. **`nopy.executor.ts`** — runs the built `pyinfra <host> -y --data K=V ... --chdir <cubeDir> <script>` Cycles are caught by a separate **resolution stack** — a (cube, host) pair
re-entered while still resolving raises with the whole path named. It has to
be separate from `resolvedCubes`, which is written *after* the descent and so
never sees a cycle at all, and which cannot be widened into a "seen" set
because re-entering a finished cube with different `param` overrides is
exactly what a dependency or a hook is for.
6. **`nopy.executor.ts`** — runs the built
`pyinfra <host> -y [-vv] [--debug] --data K=V ... --chdir <cubeDir> <script>`
commands through execa with inherited stdio, sequentially, stopping at the commands through execa with inherited stdio, sequentially, stopping at the
first failure unless `continueOnError`. first failure unless `continueOnError`. `DeployCall.command` is a true argv
and is spawned **without a shell**: it used to be joined into one string and
run through `execa({shell: true})`, which made every `--data` value shell
syntax — a password or a variable holding `;` or `$(…)` was executed. The only
thing that joins it back into a string is `maskCommand()`, for display, which
shell-quotes as it goes so `--print-only` output stays pasteable. The
verbosity/debug flags come from `config.log` through `logConfigToFlags()`.
### Variables ### Variables
@@ -277,6 +291,14 @@ another edge to the publish order. Extract it if a third CLI appears.
Tag-driven, one package at a time; see `README.PUBLISH.md`. Tag-driven, one package at a time; see `README.PUBLISH.md`.
`pnpm run release` (`scripts/release.mjs`, zx + enquirer + commander) is the
front door: pick packages, pick versions, write notes into `CHANGELOG.md`, run
the gate **against the bumped tree before committing** so a failure leaves
nothing to unpick, then commit, tag and push. Tags go out dependency-first and it
polls npmjs for each version before pushing the next — which is what replaced the
CI-side linked-deps guard. Tags are annotated (`-a -m`), not lightweight: a
lightweight tag is rejected outright under `tag.forceSignAnnotated`.
### Registry resolution ### Registry resolution
The repo commits a root `.npmrc` mapping `@bitsquare:registry` to the Gitea The repo commits a root `.npmrc` mapping `@bitsquare:registry` to the Gitea
@@ -357,22 +379,28 @@ Three things the `workspace:*` links added, all of them non-obvious:
`scripts/publish-order.mjs` topologically sorts over the `workspace:` edges; `scripts/publish-order.mjs` topologically sorts over the `workspace:` edges;
the snapshot workflow stamps *every* version first and only then publishes in the snapshot workflow stamps *every* version first and only then publishes in
that order, because `pnpm publish` reads the linked package's version at pack that order, because `pnpm publish` reads the linked package's version at pack
time. `release.yml` additionally refuses to ship a package whose linked time. `release.yml` used to additionally refuse to ship a package whose linked
dependency is not yet on npmjs (`scripts/linked-deps.mjs`) — npmjs is the dependency was not yet on npmjs (`scripts/linked-deps.mjs`); that step is gone,
registry you cannot take a mistake back from. and `scripts/release.mjs` enforces the same ordering earlier instead — it
pushes tags dependency-first and polls npmjs for each version before pushing
the next. `linked-deps.mjs` survives as a hand-check. A tag pushed some other
way is no longer caught, which is the accepted cost.
## Known drift ## Known drift
`logConfigToFlags()` is exported and tested but nothing feeds its output into the `logConfigToFlags()` is now consumed by `buildDeployCall`, so `log.verbosity` /
built pyinfra command, so `log.verbosity` / `log.debug` in `.nopyrc.json` `log.debug` in `.nopyrc.json` finally do what the README says. Note the
currently have no effect. Treat `docs/REFACTORING.md` as a plan, not a record. consequence: `packages/nopy/.nopyrc.json` has always asked for
`"verbosity": "trace", "debug": true`, and a run from that directory now actually
gets `-vvv --debug`. Treat `docs/REFACTORING.md` as a plan, not a record.
The publish lane has now run against the Gitea registry: all four packages are The publish lane has now run against the Gitea registry: all four packages are
there under `@main`, and `pnpm run try:snapshot` installs them into a throwaway there under `@main`, and `pnpm run try:snapshot` installs them into a throwaway
project with npm and runs the binary. The npmjs lane has only ever published project with npm and runs the binary. The npmjs lane has only ever published
`@bitsquare/nopy`; `keyman`, `nopy-cubes` and `nopy-cubes-core` have never been `@bitsquare/nopy`; `keyman`, `nopy-cubes` and `nopy-cubes-core` have never been
released there, so the *check linked deps are released* guard in `release.yml` released there. That used to be caught by the *check linked deps are released*
will stop the first `nopy` release until `nopy-cubes` ships. guard in `release.yml`; now it is `pnpm run release` that holds `nopy`'s tag back
until `nopy-cubes` answers on npmjs.
Nothing checks that a bundle and the CLI reading it are compatible versions; Nothing checks that a bundle and the CLI reading it are compatible versions;
`nopy.engines` was considered and deferred. `docs/CUBE-PACKAGES.md` is where all `nopy.engines` was considered and deferred. `docs/CUBE-PACKAGES.md` is where all
@@ -383,10 +411,8 @@ from the plan.
`src/index.ts` plus the authoring package; its *Known gaps* section is the short `src/index.ts` plus the authoring package; its *Known gaps* section is the short
list of behaviour that surprises a reader (`--json` printing nothing on success, list of behaviour that surprises a reader (`--json` printing nothing on success,
`DeployCall.dependencies` always empty, `ExecutionResult.stdout` never populated, `DeployCall.dependencies` always empty, `ExecutionResult.stdout` never populated,
no cycle detection, and `self-update` reporting an empty dist-tag as an and `self-update` reporting an empty dist-tag as an unreachable registry).
unreachable registry). `CubePackageRef` is referenced by the exported `DOCS-AUDIT.md` tracks the drift in the remaining
`NopyConfig` but is not itself re-exported, so a consumer cannot name the type —
one line, not yet fixed. `DOCS-AUDIT.md` tracks the drift in the remaining
documents; §2.9 (the nopy README shipping yarn-workspace instructions to npmjs) documents; §2.9 (the nopy README shipping yarn-workspace instructions to npmjs)
and §2.10 (the keyman README describing four of nine operations and inventing a and §2.10 (the keyman README describing four of nine operations and inventing a
tenth) are both closed. The keyman README now quotes `helpText()` verbatim and a tenth) are both closed. The keyman README now quotes `helpText()` verbatim and a
+75 -17
View File
@@ -131,7 +131,7 @@ tarball.
``` ```
checkout → resolve tag → check secrets checkout → resolve tag → check secrets
→ pnpm → node → cache → install → check linked deps are released → pnpm → node → cache → install
→ lint:ci → typecheck → test:coverage → build → verify-pack → lint:ci → typecheck → test:coverage → build → verify-pack
→ publish to Gitea → publish to npmjs → delete .npmrc → publish to Gitea → publish to npmjs → delete .npmrc
→ create the Gitea release → step summary → create the Gitea release → step summary
@@ -141,13 +141,14 @@ Tag resolution and the secret check run **before** anything is installed or
built, so a malformed tag or a missing token fails in seconds instead of after built, so a malformed tag or a missing token fails in seconds instead of after
the whole gate. the whole gate.
*Check linked deps are released* asks npmjs whether every `workspace:` dependency There used to be a *check linked deps are released* step between install and
of the package being released already exists at the version pnpm is about to lint, refusing to publish a package whose `workspace:` dependency was not yet on
bake in (`scripts/linked-deps.mjs` → `npm view`). Tagging `nopy-v1.3.0` while npmjs. It is gone: [`scripts/release.mjs`](#cutting-a-release) is what creates
`@bitsquare/nopy-cubes@1.1.0` is still unpublished would otherwise ship a tarball release tags now, and it pushes them dependency-first and waits for each version
nobody can install, and npmjs only lets you unpublish for 72 hours. The check is to resolve on npmjs before pushing the next — so the ordering is enforced before
npmjs-only: it runs before any credentials are written, and npmjs is the registry CI sees a tag rather than after. The trade is that a tag pushed by hand is no
where the mistake is permanent. longer caught; `node scripts/linked-deps.mjs <dir>` still prints what a package
would bake in if you want to check yourself.
## The verification gate ## The verification gate
@@ -236,14 +237,60 @@ edit is discarded with the workspace and is never committed.
## Cutting a release ## Cutting a release
```sh
pnpm run release
```
`scripts/release.mjs` does the whole sequence: pick the packages, pick each
version, write the release notes, run the gate, commit, tag and push. Everything
below describes what it does and how to do it by hand.
It runs in this order, and the order is the point:
1. **Preflight.** Refuses a dirty working tree (a release commit must contain the
bump and nothing else), warns if you are not on `main`, and refuses to run
when `main` is behind the remote — a tag on a stale commit ships a tree
nobody reviewed.
2. **Pick.** A checklist of the publishable packages, each annotated with its
local version and what npmjs already has. If you select a package that others
link to, it says so and offers to add them.
3. **Version.** `patch`/`minor`/`major`/`prerelease` computed from the manifest,
or type your own. Versions already on npmjs, and versions whose tag exists,
are shown struck out and cannot be chosen. A version that will not move
`latest` gets a warning rather than a refusal.
4. **Notes.** Opens `$EDITOR` seeded with the commits since the package's last
tag, and prepends the result to `packages/<pkg>/CHANGELOG.md` in the format
the release body parser expects.
5. **Verify.** `lint:ci → typecheck → test:coverage → build → verify-pack`,
against the bumped tree and **before** the commit, so a failure leaves nothing
to unpick — it offers to restore the tree instead.
6. **Commit, tag, push.** One commit, one annotated tag per package, then the
branch, then the tags **dependency-first**. After each tag it polls npmjs
until that exact version resolves before pushing the next.
Useful flags:
```sh
pnpm run release -- --dry-run # print the plan, change nothing
pnpm run release -- -p nopy -v minor # skip the pickers
pnpm run release -- -p nopy-cubes nopy # several, ordered automatically
pnpm run release -- --no-verify # skip the gate (it still runs in CI)
pnpm run release -- --no-wait # push tags back to back
```
The push uses `SKIP_SIMPLE_GIT_HOOKS=1`, because the `pre-push` gate is the same
one step 5 just ran against the same tree.
### By hand
1. Bump `version` in `packages/<pkg>/package.json`. 1. Bump `version` in `packages/<pkg>/package.json`.
2. Add a changelog entry (see below). 2. Add a changelog entry (see below).
3. Commit, merge to `main`, and let the snapshot workflow go green. 3. Commit, merge to `main`, and let the snapshot workflow go green.
4. Tag that commit and push the tag: 4. Tag that commit and push the tag:
```sh ```sh
git tag nopy-v1.2.0 git tag nopy-v<version>
git push origin nopy-v1.2.0 git push origin nopy-v<version>
``` ```
The tag name is `<directory>-v<version>` — the directory under `packages/`, not The tag name is `<directory>-v<version>` — the directory under `packages/`, not
@@ -267,10 +314,13 @@ waiting for each run to go green:
nopy-cubes → nopy, nopy-cubes-core (these two are independent of each other) nopy-cubes → nopy, nopy-cubes-core (these two are independent of each other)
``` ```
Release `nopy` first and the run stops at the *check linked deps* step, telling `pnpm run release` handles this for you — it sorts the selection over the
you the `nopy-cubes` version it wanted is not on npmjs. That is the guard working; `workspace:` edges and will not push `nopy`'s tag until `nopy-cubes`'s new
release `nopy-cubes`, then re-tag. `node scripts/publish-order.mjs` prints the version answers on npmjs. Releasing by hand, you own it: tag `nopy` first and its
order if you would rather not reason about it. run publishes a tarball requiring a `nopy-cubes` version that does not exist, and
npmjs only lets you unpublish for 72 hours. `node scripts/publish-order.mjs`
prints the order, and `node scripts/linked-deps.mjs <dir>` prints exactly which
versions a package would bake in.
Bumping `nopy-cubes` means bumping the packages that depend on it in the same Bumping `nopy-cubes` means bumping the packages that depend on it in the same
change — the `workspace:*` range resolves to whatever version is in the workspace change — the `workspace:*` range resolves to whatever version is in the workspace
@@ -299,10 +349,12 @@ What a successful run leaves behind:
## Changelogs and release notes ## Changelogs and release notes
Neither package has a `CHANGELOG.md` yet. Without one, the Gitea release body is `pnpm run release` writes these for you — it opens `$EDITOR` seeded with the
just the install snippet — nothing fails. commits since the package's last tag and prepends a `## <version> — <date>`
section, creating the file the first time. A package with no `CHANGELOG.md` is
fine: the Gitea release body degrades to the install snippet and nothing fails.
When you add one, `release.yml` extracts the section for the version being `release.yml` extracts the section for the version being
released. The parser is deliberately dumb: it looks for the first `## ` heading released. The parser is deliberately dumb: it looks for the first `## ` heading
whose text contains the version string, and takes every line until the next `## ` whose text contains the version string, and takes every line until the next `## `
heading. Any of these work: heading. Any of these work:
@@ -644,6 +696,12 @@ node scripts/publish-order.mjs # the order to release in
node scripts/linked-deps.mjs packages/nopy # what must be on the registry first node scripts/linked-deps.mjs packages/nopy # what must be on the registry first
``` ```
Rehearse a release without touching anything:
```sh
pnpm run release -- --dry-run
```
See what is on each registry, and which versions Gitea has that npmjs does not: See what is on each registry, and which versions Gitea has that npmjs does not:
```sh ```sh
+6 -1
View File
@@ -14,6 +14,7 @@
"test:coverage": "pnpm -r run test:coverage", "test:coverage": "pnpm -r run test:coverage",
"coverage:summary": "node scripts/coverage-summary.mjs", "coverage:summary": "node scripts/coverage-summary.mjs",
"registry:status": "node scripts/registry-status.mjs", "registry:status": "node scripts/registry-status.mjs",
"release": "node scripts/release.mjs",
"try:snapshot": "node scripts/try-snapshot.mjs", "try:snapshot": "node scripts/try-snapshot.mjs",
"typecheck": "tsc --build", "typecheck": "tsc --build",
"lint": "biome check .", "lint": "biome check .",
@@ -31,7 +32,11 @@
"@bitsquare/nopy-cubes-core": "workspace:*", "@bitsquare/nopy-cubes-core": "workspace:*",
"@logtape/logtape": "^2.2.4", "@logtape/logtape": "^2.2.4",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"commander": "^15.0.0",
"enquirer": "^2.4.1",
"semver": "^7.8.5",
"simple-git-hooks": "^2.13.1", "simple-git-hooks": "^2.13.1",
"typescript": "^7.0.2" "typescript": "^7.0.2",
"zx": "^8.8.5"
} }
} }
+12
View File
@@ -26,12 +26,24 @@ importers:
'@types/node': '@types/node':
specifier: ^26.1.1 specifier: ^26.1.1
version: 26.1.1 version: 26.1.1
commander:
specifier: ^15.0.0
version: 15.0.0
enquirer:
specifier: ^2.4.1
version: 2.4.1
semver:
specifier: ^7.8.5
version: 7.8.5
simple-git-hooks: simple-git-hooks:
specifier: ^2.13.1 specifier: ^2.13.1
version: 2.13.1 version: 2.13.1
typescript: typescript:
specifier: ^7.0.2 specifier: ^7.0.2
version: 7.0.2 version: 7.0.2
zx:
specifier: ^8.8.5
version: 8.8.5
packages/keyman: packages/keyman:
dependencies: dependencies:
+7 -2
View File
@@ -5,10 +5,15 @@
* *
* The version is the one the linked package declares *right now*, which is * The version is the one the linked package declares *right now*, which is
* exactly what `pnpm publish` will substitute for `workspace:*` when it packs. * exactly what `pnpm publish` will substitute for `workspace:*` when it packs.
* A release can therefore check that each of them is already on the registry * So this is the list of versions that must already be on the registry before
* before shipping a manifest that points at a version nobody can install. * shipping the package, or the tarball points at something nobody can install.
* *
* node scripts/linked-deps.mjs packages/nopy * node scripts/linked-deps.mjs packages/nopy
*
* `release.yml` no longer runs this as a gate. `scripts/release.mjs` enforces
* the same ordering earlier and more cheaply, by pushing tags dependency-first
* and waiting for each version to appear on npmjs before pushing the next. This
* stays as the hand-check for when you want to see the list yourself.
*/ */
import fs from 'node:fs'; import fs from 'node:fs';
+866
View File
@@ -0,0 +1,866 @@
#!/usr/bin/env node
/**
* Interactive release client: pick packages, choose versions, hand tags to CI.
*
* `release.yml` is tag-driven and deliberately dumb — the tag names the package,
* `package.json` names the version, and the run fails if they disagree. Getting
* to a good tag is the fiddly part, and it is all manual today: bump the right
* manifest, write a changelog the release body can quote, run the gate, tag with
* the `<directory>-v<version>` spelling, and push the tags dependency-first
* because `pnpm publish` bakes a linked package's *current* version into its
* dependent's tarball at pack time. This script does that sequence.
*
* pnpm run release # pick packages and versions
* pnpm run release -- -p nopy -v minor # non-interactive version choice
* pnpm run release -- --dry-run # print the plan, change nothing
*
* Order of operations is load-bearing: versions and changelogs are written to
* the working tree, the gate runs against *that* tree, and only then is anything
* committed. A failing gate therefore leaves no commit to unpick — the script
* offers to restore the tree instead.
*
* After each tag is pushed it polls npmjs until that exact version resolves.
* That is both the ordering barrier (a dependent must not be tagged until the
* version pnpm will bake into it exists on the registry) and the final proof
* that the release actually landed, rather than that CI accepted the tag.
*/
import os from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import Enquirer from 'enquirer';
import semver from 'semver';
import { $, chalk, fs } from 'zx';
const PACKAGES_DIR = 'packages';
const RANGE_FIELDS = ['dependencies', 'peerDependencies', 'optionalDependencies'];
const NPMJS_REGISTRY = 'https://registry.npmjs.org/';
const FETCH_TIMEOUT_MS = 15_000;
const POLL_INTERVAL_MS = 15_000;
// Terminals that report no size make enquirer render zero choices and submit
// silently — see the long note on `terminalSize` in nopy.prompts.ts.
const MIN_ROWS = 24;
const MIN_COLS = 80;
$.verbose = false;
// ---------------------------------------------------------------------------
// Workspace
// ---------------------------------------------------------------------------
const readManifest = (dir) => JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
/** Every publishable package, alphabetically by directory. */
function readWorkspace() {
return fs
.readdirSync(PACKAGES_DIR)
.map((name) => path.join(PACKAGES_DIR, name))
.filter((dir) => fs.existsSync(path.join(dir, 'package.json')))
.map((dir) => ({ dir, slug: path.basename(dir), manifest: readManifest(dir) }))
.filter(({ manifest }) => !manifest.private)
.sort((a, b) => a.dir.localeCompare(b.dir));
}
/** The other workspace packages this one links to, as directories. */
function linkedDirs(pkg, byName) {
return RANGE_FIELDS.flatMap((field) => Object.entries(pkg.manifest[field] ?? {}))
.filter(([, range]) => range.startsWith('workspace:'))
.map(([name]) => byName.get(name)?.dir)
.filter((dir) => dir !== undefined);
}
/**
* Topological order over the `workspace:` edges — dependencies first.
*
* The same ordering `scripts/publish-order.mjs` prints, recomputed here rather
* than shelled out to because this needs the subset being released, not all of
* `packages/`. Alphabetical among packages the graph does not separate, so a
* plan printed twice reads the same both times.
*/
function dependencyOrder(packages) {
const byName = new Map(packages.map((pkg) => [pkg.manifest.name, pkg]));
const byDir = new Map(packages.map((pkg) => [pkg.dir, pkg]));
const ordered = [];
const emitted = new Set();
const visiting = new Set();
const visit = (pkg) => {
if (emitted.has(pkg.dir)) return;
if (visiting.has(pkg.dir)) throw new Error(`Dependency cycle in the workspace, at ${pkg.dir}`);
visiting.add(pkg.dir);
for (const dir of linkedDirs(pkg, byName)) {
const dependency = byDir.get(dir);
if (dependency) visit(dependency);
}
visiting.delete(pkg.dir);
emitted.add(pkg.dir);
ordered.push(pkg);
};
for (const pkg of [...packages].sort((a, b) => a.dir.localeCompare(b.dir))) visit(pkg);
return ordered;
}
// ---------------------------------------------------------------------------
// npmjs
// ---------------------------------------------------------------------------
/**
* Fetches a packument from npmjs, normalising every failure into a printable
* shape rather than throwing — an unreachable registry should soften the picker,
* not abort the release.
*/
async function packument(name) {
let response;
try {
response = await fetch(`${NPMJS_REGISTRY}${encodeURIComponent(name)}`, {
headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
} catch (error) {
return { reachable: false, note: error.name === 'TimeoutError' ? 'timed out' : 'unreachable' };
}
if (response.status === 404) return { reachable: true, published: false, versions: [], tags: {} };
if (!response.ok) return { reachable: true, note: `HTTP ${response.status}` };
let body;
try {
body = await response.json();
} catch {
return { reachable: true, note: 'unparseable response' };
}
if (body.error) return { reachable: true, published: false, versions: [], tags: {} };
return {
reachable: true,
published: true,
tags: body['dist-tags'] ?? {},
versions: Object.keys(body.versions ?? {}),
};
}
/**
* Whether one exact version resolves on npmjs.
*
* The per-version endpoint rather than the packument: it 404s until the publish
* lands, where a cached packument can answer without the new version in it and
* read as "not yet" long after it is there.
*/
async function versionExists(name, version) {
try {
const response = await fetch(
`${NPMJS_REGISTRY}${encodeURIComponent(name)}/${encodeURIComponent(version)}`,
{
headers: { accept: 'application/json', 'cache-control': 'no-cache' },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
}
);
return response.ok;
} catch {
// A blip mid-poll is not an answer; the next tick asks again.
return false;
}
}
/** Polls until `name@version` resolves on npmjs, or the deadline passes. */
async function waitForRelease(name, version, timeoutMs) {
const started = Date.now();
const label = `${name}@${version}`;
process.stdout.write(` waiting for ${label} on npmjs `);
for (;;) {
if (await versionExists(name, version)) {
const seconds = Math.round((Date.now() - started) / 1000);
console.log(chalk.green(` published after ${seconds}s`));
return true;
}
if (Date.now() - started > timeoutMs) {
console.log(chalk.red(' timed out'));
return false;
}
process.stdout.write('.');
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
}
// ---------------------------------------------------------------------------
// git
// ---------------------------------------------------------------------------
const git = async (...args) => (await $`git ${args}`).stdout.trim();
async function currentBranch() {
return await git('rev-parse', '--abbrev-ref', 'HEAD');
}
async function isClean() {
return (await git('status', '--porcelain')) === '';
}
/** Local tags plus whatever the remote has, so a re-tag is caught either way. */
async function existingTags(remote) {
const local = (await git('tag', '--list')).split('\n').filter(Boolean);
let remoteTags = [];
try {
const output = await git('ls-remote', '--tags', remote);
remoteTags = output
.split('\n')
.filter(Boolean)
.map((line) => line.split('refs/tags/')[1])
.filter((tag) => tag && !tag.endsWith('^{}'));
} catch {
// Offline, or no such remote. The local list still catches the common case
// and `git push` will reject a duplicate anyway.
}
return new Set([...local, ...remoteTags]);
}
/** The newest `<slug>-v*` tag by semver, or undefined if the package is unreleased. */
function lastTagFor(slug, tags) {
const prefix = `${slug}-v`;
return [...tags]
.filter((tag) => tag.startsWith(prefix) && semver.valid(tag.slice(prefix.length)))
.sort((a, b) => semver.rcompare(a.slice(prefix.length), b.slice(prefix.length)))[0];
}
// ---------------------------------------------------------------------------
// Prompts
// ---------------------------------------------------------------------------
const terminalSize = () => ({
rows: Math.max(process.stdout.rows || 0, MIN_ROWS),
columns: Math.max(process.stdout.columns || 0, MIN_COLS),
});
/**
* Runs an enquirer prompt, refusing outright when there is no terminal to run it
* in.
*
* Without the guard a piped or CI invocation hangs on a prompt nobody can
* answer, and node reports it as `unsettled top-level await` — which says
* nothing about the missing flag that would have avoided the prompt.
*/
function ask(Kind, options) {
if (!process.stdin.isTTY) {
throw new Error(
`'${options.message}' needs an answer, but stdin is not a terminal.\n` +
'Supply --package / --version (and --no-changelog) to run non-interactively.'
);
}
return new Enquirer[Kind]({ ...options, ...terminalSize() }).run();
}
/**
* A yes/no question. Unlike {@link ask} this has a defensible answer without a
* terminal — the default — because every caller is an optional extra step.
*/
async function confirm(message, initial = false) {
if (!process.stdin.isTTY) {
console.log(chalk.dim(` ${message} → ${initial ? 'yes' : 'no'} (not a terminal)`));
return initial;
}
return await new Enquirer.Confirm({ name: 'ok', message, initial, ...terminalSize() }).run();
}
/** One line per package, so the picker shows what npmjs already has. */
function describeForPicker(pkg, registry) {
const local = `local ${pkg.manifest.version}`;
if (!registry.reachable) return `${local} · npmjs ${registry.note}`;
if (registry.note) return `${local} · npmjs ${registry.note}`;
if (!registry.published) return `${local} · not on npmjs`;
const latest = registry.tags?.latest;
return `${local} · npmjs latest ${latest ?? '(none)'}`;
}
async function selectPackages(candidates, registries) {
const selected = await ask('MultiSelect', {
name: 'packages',
message: 'Which packages are you releasing?',
hint: '(space to select, enter to confirm)',
choices: candidates.map((pkg) => ({
name: pkg.slug,
message: `${pkg.manifest.name.padEnd(28)} ${describeForPicker(pkg, registries.get(pkg.manifest.name))}`,
})),
validate: (value) => (value.length > 0 ? true : 'Select at least one package.'),
});
return selected;
}
/**
* Resolves a version for one package, either from `--version` or interactively.
*
* Bumps are computed from the manifest, not from npmjs: the manifest is the
* repo's source of truth and the thing `release.yml` compares the tag against.
* Where npmjs is ahead — which it is for every package still carrying the old
* `1.0.0-alpha5` — that is surfaced as a warning rather than a different base,
* because silently jumping the local version is how you lose a bump.
*/
async function chooseVersion(pkg, registry, requested, tags) {
const current = pkg.manifest.version;
const taken = new Set(registry.versions ?? []);
const KEYWORDS = ['patch', 'minor', 'major', 'prerelease'];
const check = (version) => {
if (!semver.valid(version)) return `'${version}' is not a valid semver version.`;
if (taken.has(version)) return `${pkg.manifest.name}@${version} is already on npmjs.`;
if (tags.has(`${pkg.slug}-v${version}`)) return `Tag ${pkg.slug}-v${version} already exists.`;
return true;
};
if (requested) {
const version = KEYWORDS.includes(requested)
? semver.inc(current, requested, requested === 'prerelease' ? 'rc' : undefined)
: requested;
const problem = check(version);
if (problem !== true) throw new Error(problem);
return version;
}
const bump = (release, identifier) => semver.inc(current, release, identifier);
const choices = [
{ name: bump('patch'), message: `patch ${current} → ${bump('patch')}` },
{ name: bump('minor'), message: `minor ${current} → ${bump('minor')}` },
{ name: bump('major'), message: `major ${current} → ${bump('major')}` },
{
name: bump('prerelease', 'rc'),
// Publishes under `next`, never `latest` — the rule in release.yml is
// purely "does the version contain a `-`".
message: `prerelease ${current} → ${bump('prerelease', 'rc')} (dist-tag: next)`,
},
{ name: 'custom', message: 'custom…' },
].map((choice) => {
const problem = choice.name === 'custom' ? true : check(choice.name);
return problem === true
? choice
: { ...choice, message: `${choice.message} ${problem}`, disabled: true };
});
const picked = await ask('Select', {
name: 'version',
message: `Version for ${pkg.manifest.name} (currently ${current})`,
choices,
});
if (picked !== 'custom') {
const problem = check(picked);
if (problem !== true) throw new Error(problem);
return picked;
}
return await ask('Input', {
name: 'version',
message: `Version for ${pkg.manifest.name}`,
initial: bump('patch'),
validate: check,
});
}
// ---------------------------------------------------------------------------
// Changelog
// ---------------------------------------------------------------------------
const CHANGELOG_HEADER = '# Changelog\n';
/**
* Collects release notes in `$EDITOR`, seeded with the commits since the
* package's last tag.
*
* The seed is the point: `release.yml` quotes this section verbatim into the
* Gitea release body, and a summary written next to the actual commit list is a
* better one than a summary written from memory. Lines starting with `#` are
* stripped, so the seed can carry instructions without them leaking into the
* release.
*/
async function collectNotes(pkg, version, tags) {
const editor = process.env.VISUAL || process.env.EDITOR;
const lastTag = lastTagFor(pkg.slug, tags);
let log = '';
try {
const range = lastTag ? `${lastTag}..HEAD` : 'HEAD';
log = await git('log', range, '--oneline', '--no-decorate', '-n', '40', '--', pkg.dir);
} catch {
// A shallow clone or a brand new package — the template still works.
}
if (!process.stdin.isTTY) {
// Nothing here can prompt, and an editor would have no terminal to draw on.
// Missing notes are survivable — the release body degrades to the install
// snippet — so say so and carry on rather than failing the release.
console.log(chalk.yellow(` no TTY — skipping release notes for ${pkg.manifest.name}.`));
return '';
}
if (!editor) {
// One line beats nothing when there is no editor to fall back on.
const summary = await ask('Input', {
name: 'notes',
message: `Release notes for ${pkg.manifest.name}@${version} (blank to skip, $EDITOR unset)`,
});
return summary.trim() ? `- ${summary.trim()}` : '';
}
const seed = [
'',
`# Release notes for ${pkg.manifest.name}@${version}.`,
'# Lines starting with "#" are ignored. Leave the file empty to skip.',
'#',
`# Commits since ${lastTag ?? 'the beginning'} touching ${pkg.dir}:`,
...(log ? log.split('\n').map((line) => `# ${line}`) : ['# (none)']),
'',
].join('\n');
const file = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'release-notes-')),
`${pkg.slug}-${version}.md`
);
fs.writeFileSync(file, seed);
try {
// The editor owns the terminal — `stdio: inherit` is what makes a full-screen
// vim usable here rather than a scrambled buffer.
await $({ stdio: 'inherit' })`${editor} ${file}`;
return fs
.readFileSync(file, 'utf-8')
.split('\n')
.filter((line) => !line.startsWith('#'))
.join('\n')
.trim();
} finally {
fs.rmSync(path.dirname(file), { recursive: true, force: true });
}
}
/** Prepends a section for `version`, creating the file if the package has none. */
function writeChangelog(pkg, version, notes, today) {
const file = path.join(pkg.dir, 'CHANGELOG.md');
const existed = fs.existsSync(file);
const section = `## ${version} — ${today}\n\n${notes}\n`;
if (!existed) {
fs.writeFileSync(file, `${CHANGELOG_HEADER}\n${section}`);
return { file, created: true };
}
// Insert above the newest existing section, so the file stays newest-first and
// the new entry lands *below* any `# Changelog` title rather than above it.
const body = fs.readFileSync(file, 'utf-8');
const at = body.startsWith('## ') ? 0 : body.indexOf('\n## ') + 1;
const updated =
at === 0 && !body.startsWith('## ')
? `${body.replace(/\n*$/, '\n')}\n${section}`
: `${body.slice(0, at)}${section}\n${body.slice(at)}`;
fs.writeFileSync(file, updated);
return { file, created: false };
}
// ---------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------
async function preflight(remote) {
const root = await git('rev-parse', '--show-toplevel');
if (path.resolve(root) !== path.resolve(process.cwd())) {
throw new Error(`Run this from the repository root (${root}).`);
}
if (!(await isClean())) {
throw new Error(
'The working tree has uncommitted changes.\n' +
'A release commit must contain the version bump and nothing else — commit or stash first.'
);
}
const branch = await currentBranch();
console.log(chalk.dim(` fetching ${remote}…`));
try {
await $`git fetch ${remote} --tags --quiet`;
} catch {
console.log(chalk.yellow(` could not reach ${remote} — continuing with local refs only.`));
return { branch };
}
// Behind the remote is the dangerous one: the tag would point at a commit that
// is not what `main` will look like, and the release would ship a tree nobody
// reviewed. Ahead is merely unpushed, and this script pushes.
const behind = await git('rev-list', '--count', `HEAD..${remote}/${branch}`).catch(() => '0');
if (behind !== '0') {
throw new Error(
`${branch} is ${behind} commit(s) behind ${remote}/${branch}. Pull before releasing.`
);
}
return { branch };
}
/** The gate, run against the bumped working tree. Same three commands as CI. */
async function verify() {
const steps = [
['lint', ['run', 'lint:ci']],
['typecheck', ['run', 'typecheck']],
['test', ['run', 'test:coverage']],
['build', ['run', 'build']],
];
for (const [label, args] of steps) {
console.log(chalk.bold(`\n ▸ pnpm ${args.join(' ')}`));
const result = await $({ stdio: 'inherit', nothrow: true })`pnpm ${args}`;
if (result.exitCode !== 0) return label;
}
console.log(chalk.bold('\n ▸ node scripts/verify-pack.mjs'));
const packed = await $({ stdio: 'inherit', nothrow: true })`node scripts/verify-pack.mjs`;
return packed.exitCode === 0 ? null : 'verify-pack';
}
/** Puts the tree back the way preflight found it — which was clean, by contract. */
async function restore(touched) {
for (const { file, created } of touched) {
if (created) fs.rmSync(file, { force: true });
}
const tracked = touched.filter((entry) => !entry.created).map((entry) => entry.file);
if (tracked.length > 0) await $`git checkout -- ${tracked}`;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function release(options) {
const workspace = readWorkspace();
const byName = new Map(workspace.map((pkg) => [pkg.manifest.name, pkg]));
const bySlug = new Map(workspace.map((pkg) => [pkg.slug, pkg]));
console.log(chalk.bold('\nRelease\n'));
const { branch } = await preflight(options.remote);
if (branch !== 'main') {
console.log(
chalk.yellow(
` You are on '${branch}', not 'main'. Tags release from any branch, but the\n` +
' snapshot lane and the review flow both assume main.'
)
);
if (!options.yes && !(await confirm(`Release from '${branch}' anyway?`))) return 1;
}
const tags = await existingTags(options.remote);
console.log(chalk.dim(' reading npmjs…\n'));
const registries = new Map(
await Promise.all(
workspace.map(async (pkg) => [pkg.manifest.name, await packument(pkg.manifest.name)])
)
);
// --- pick packages -------------------------------------------------------
let slugs;
if (options.package?.length) {
slugs = options.package.map((given) => {
const pkg = bySlug.get(given) ?? byName.get(given);
if (!pkg) {
throw new Error(
`Unknown package '${given}'. Known: ${workspace.map((p) => p.slug).join(', ')}`
);
}
return pkg.slug;
});
} else {
slugs = await selectPackages(workspace, registries);
}
let selected = dependencyOrder(slugs.map((slug) => bySlug.get(slug)));
if (options.version && selected.length !== 1) {
throw new Error('--version applies to a single package; select one with --package.');
}
// --- warn about dependents ----------------------------------------------
// `pnpm publish` resolves `workspace:*` to whatever the linked package
// declares at pack time, so bumping a library silently changes what its
// dependents' *next* release will require. Better to decide that here than to
// discover it in a tarball.
const selectedDirs = new Set(selected.map((pkg) => pkg.dir));
const dependents = workspace.filter(
(pkg) =>
!selectedDirs.has(pkg.dir) && linkedDirs(pkg, byName).some((dir) => selectedDirs.has(dir))
);
if (dependents.length > 0) {
const names = dependents.map((p) => p.manifest.name).join(', ');
console.log(
chalk.yellow(
`\n ${names} ${dependents.length === 1 ? 'links' : 'link'} to a package you are\n` +
' releasing. It is not in this release, but its next one will require the new\n' +
' version whether or not you meant it to.\n'
)
);
// Not offered under --version (that flag names one version, and applying it
// to a package the user did not ask for is worse than making them re-run)
// and not under --dry-run, which must not change the plan it is printing.
const mayAdd = !options.yes && !options.version && !options.dryRun;
if (mayAdd && (await confirm('Add them to this release?'))) {
selected = dependencyOrder([...selected, ...dependents]);
}
}
// --- versions ------------------------------------------------------------
const plan = [];
for (const pkg of selected) {
const version = await chooseVersion(
pkg,
registries.get(pkg.manifest.name),
options.version,
tags
);
const latest = registries.get(pkg.manifest.name)?.tags?.latest;
if (latest && semver.valid(latest) && semver.lte(version, latest)) {
console.log(
chalk.yellow(
` ! ${version} is not above npmjs latest (${latest}) — 'latest' will not move to it.`
)
);
}
plan.push({ pkg, version, tag: `${pkg.slug}-v${version}` });
}
// --- notes ---------------------------------------------------------------
// Not under --dry-run: a preview of the plan should not make you sit down and
// write release notes for a release you have not committed to yet.
if (options.changelog && !options.dryRun) {
for (const entry of plan) {
entry.notes = await collectNotes(entry.pkg, entry.version, tags);
}
}
// --- plan ----------------------------------------------------------------
const distTag = (version) => (version.includes('-') ? 'next' : 'latest');
const message = `release: ${plan.map((e) => `${e.pkg.manifest.name}@${e.version}`).join(', ')}`;
console.log(chalk.bold('\n Plan\n'));
for (const entry of plan) {
const { pkg, version, tag } = entry;
console.log(` ${chalk.bold(pkg.manifest.name)} ${pkg.manifest.version} → ${version}`);
console.log(` tag ${tag}`);
console.log(` dist-tag ${distTag(version)}`);
const notes = entry.notes ? `${entry.notes.split('\n').length} line(s)` : '—';
console.log(
` changelog ${options.dryRun && options.changelog ? '(prompted later)' : notes}`
);
}
console.log(`\n commit ${message}`);
console.log(` push ${options.remote} ${branch}, then tags in the order above`);
console.log(
options.wait
? ` wait for each version on npmjs (up to ${options.waitTimeout}s each)\n`
: ' wait no — tags are pushed back to back\n'
);
if (options.dryRun) {
console.log(chalk.dim(' --dry-run: nothing was changed.\n'));
return 0;
}
if (!options.yes && !(await confirm('Proceed?', true))) {
console.log(' Nothing was changed.');
return 1;
}
// --- write ---------------------------------------------------------------
const today = new Date().toISOString().slice(0, 10);
const touched = [];
for (const entry of plan) {
// `npm pkg set`, the same edit the workflows make, rather than a hand-rolled
// rewrite of the manifest.
await $({ cwd: entry.pkg.dir })`npm pkg set version=${entry.version}`;
touched.push({ file: path.join(entry.pkg.dir, 'package.json'), created: false });
if (entry.notes) {
touched.push(writeChangelog(entry.pkg, entry.version, entry.notes, today));
}
}
console.log(chalk.green(`\n wrote ${touched.length} file(s)`));
// --- verify --------------------------------------------------------------
if (options.verify) {
const failed = await verify();
if (failed) {
console.log(chalk.red(`\n ${failed} failed. Nothing has been committed.`));
if (options.yes || (await confirm('Restore the version and changelog edits?', true))) {
await restore(touched);
console.log(' Tree restored.');
} else {
console.log(' Edits left in place for inspection.');
}
return 1;
}
console.log(chalk.green('\n gate passed'));
}
// --- commit and tag ------------------------------------------------------
await $`git add -- ${touched.map((entry) => entry.file)}`;
await $`git commit -m ${message}`;
console.log(chalk.green(` committed ${await git('rev-parse', '--short=7', 'HEAD')}`));
for (const entry of plan) {
// Annotated (`-a -m`), never lightweight. A lightweight tag is rejected
// outright under `tag.forceSignAnnotated`/`tag.gpgSign`, which is a common
// enough setting to have hit this on the first real run; and the annotation
// is worth having anyway, since it puts the notes in `git show <tag>`.
const body = [`${entry.pkg.manifest.name}@${entry.version}`, entry.notes]
.filter(Boolean)
.join('\n\n');
await $`git tag -a ${entry.tag} -m ${body}`;
}
console.log(chalk.green(` tagged ${plan.map((e) => e.tag).join(', ')}`));
// --- push ----------------------------------------------------------------
// The gate just ran against this exact tree, so the pre-push hook would only
// run it a second time. `quiet` is spelled out because a `$({…})` instance
// does not inherit the module-level `$.verbose`, and git writes push progress
// to stderr — this script reports each push itself.
const push = $({ env: { ...process.env, SKIP_SIMPLE_GIT_HOOKS: '1' }, quiet: true });
await push`git push ${options.remote} HEAD:${branch}`;
console.log(chalk.green(` pushed ${branch}`));
const pending = [];
for (const entry of plan) {
await push`git push ${options.remote} ${entry.tag}`;
console.log(chalk.green(` pushed ${entry.tag}`));
if (!options.wait) continue;
const landed = await waitForRelease(
entry.pkg.manifest.name,
entry.version,
options.waitTimeout * 1000
);
if (!landed) {
pending.push(entry);
console.log(
chalk.red(
` ${entry.pkg.manifest.name}@${entry.version} has not appeared on npmjs.\n` +
' Check the Release run before pushing anything that depends on it.'
)
);
// Deliberately no rollback: the tag is pushed and CI may still be mid-run.
// Deleting it here would race the publish it is waiting for.
break;
}
}
// --- report --------------------------------------------------------------
const blocked = pending[0];
const shipped = blocked ? plan.slice(0, plan.indexOf(blocked)) : plan;
console.log(chalk.bold('\n Done\n'));
for (const entry of shipped) {
console.log(` ${entry.pkg.manifest.name}@${entry.version} (${distTag(entry.version)})`);
console.log(chalk.dim(` npm install -g ${entry.pkg.manifest.name}@${entry.version}`));
}
if (blocked) {
// Every tag was created locally before the first push, so the ones after the
// blockage exist here and simply have not gone out.
const unpushed = plan.slice(plan.indexOf(blocked) + 1);
console.log(chalk.yellow(`\n Blocked on ${blocked.pkg.manifest.name}@${blocked.version}.`));
if (unpushed.length > 0) {
console.log(
chalk.yellow(
` Tagged locally but not pushed: ${unpushed.map((e) => e.tag).join(', ')}\n` +
` Push them once the blocked release lands:\n` +
unpushed.map((e) => ` git push ${options.remote} ${e.tag}`).join('\n')
)
);
}
console.log('');
return 1;
}
console.log('');
return 0;
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const program = new Command();
program
.name('release')
.description('Interactively version, verify, tag and push a release.')
.option('-p, --package <name...>', 'Packages to release (directory or npm name)')
.option('-v, --version <spec>', 'Version or bump (patch|minor|major|prerelease), one package')
.option('-n, --dry-run', 'Print the plan and change nothing')
.option('-y, --yes', 'Skip confirmations (assumes yes)')
.option('--remote <name>', 'Git remote to push to', 'origin')
.option('--no-verify', 'Skip the lint/typecheck/test/build gate')
.option('--no-changelog', 'Do not prompt for release notes')
.option('--no-wait', 'Do not poll npmjs between tag pushes')
.option('--wait-timeout <seconds>', 'How long to wait for each version', Number, 1200)
.addHelpText(
'after',
`
Examples:
$ pnpm run release Pick packages and versions
$ pnpm run release -- -p nopy -v minor Bump nopy's minor, no picker
$ pnpm run release -- -p nopy-cubes nopy Release both, dependency-first
$ pnpm run release -- --dry-run Show the plan only
The tag is '<directory>-v<version>' — 'nopy-v1.2.0', not the npm name. Tags are
pushed dependency-first and each version is confirmed on npmjs before the next
tag goes out, because pnpm bakes a linked package's version into its dependent's
tarball at pack time.
`
)
.action(async (options) => {
try {
process.exitCode = await release(options);
} catch (error) {
// A cancelled enquirer prompt rejects with '' — that is a user backing
// out, not a failure worth a stack trace.
if (error === '' || error === undefined) {
console.log('\n Cancelled. Nothing was changed.\n');
process.exitCode = 1;
return;
}
// Indented per line, not just the first — several of these messages are
// two or three lines and the continuation used to hang off the margin.
const text = String(error.message ?? error)
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
console.error(chalk.red(`\n${text}\n`));
process.exitCode = 1;
}
});
// `pnpm run release -- --dry-run` forwards the `--` itself rather than eating it
// (measured on pnpm 11), and commander reads a bare `--` as "the rest are
// positionals" — so the habitual spelling died with *too many arguments*. This
// script takes no positional arguments at all, so a `--` can only ever be that
// artefact; dropping it makes both spellings work.
await program.parseAsync(
process.argv.slice(2).filter((argument) => argument !== '--'),
{ from: 'user' }
);