[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:
co-authored by
Claude Opus 5
parent
0aa0be5542
commit
2019626618
@@ -125,32 +125,14 @@ jobs:
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check the linked workspace packages are already released
|
||||
env:
|
||||
NAME: ${{ steps.target.outputs.name }}
|
||||
DIR: ${{ steps.target.outputs.dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `pnpm publish` turns `workspace:*` into the version the linked
|
||||
# package declares at this commit. If that version is not on the
|
||||
# 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"
|
||||
# There used to be a *check linked deps are released* step here, refusing
|
||||
# to publish a package whose `workspace:` dependency was not yet on npmjs.
|
||||
# It was removed: `scripts/release.mjs` is what creates release tags now,
|
||||
# and it already pushes them dependency-first and waits for each version to
|
||||
# resolve on npmjs before pushing the next — so the ordering is enforced
|
||||
# before CI ever sees a tag, rather than after. `node scripts/linked-deps.mjs
|
||||
# <dir>` still prints what a package would bake in, if you want to check by
|
||||
# hand. A tag pushed some other way is no longer caught.
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint:ci
|
||||
|
||||
@@ -142,13 +142,27 @@ One pass per invocation, `nopy.main.ts` orchestrating:
|
||||
variables (prompt, or read them back from the session on replay) → run
|
||||
`before` hooks → resolve `manifest.dependencies(vars)` (dynamic: it receives
|
||||
the *collected* variables) → emit the deploy call → run `after` hooks. There
|
||||
is no separate topological sort; ordering falls out of the recursion, and a
|
||||
`${cubeId}:${host}` set makes emission idempotent. Hooks get a `HookContext`
|
||||
is no separate topological sort; emission is post-order, so the ordering *is*
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
`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
|
||||
|
||||
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;
|
||||
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
|
||||
time. `release.yml` additionally refuses to ship a package whose linked
|
||||
dependency is not yet on npmjs (`scripts/linked-deps.mjs`) — npmjs is the
|
||||
registry you cannot take a mistake back from.
|
||||
time. `release.yml` used to additionally refuse to ship a package whose linked
|
||||
dependency was not yet on npmjs (`scripts/linked-deps.mjs`); that step is gone,
|
||||
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
|
||||
|
||||
`logConfigToFlags()` is exported and tested but nothing feeds its output into the
|
||||
built pyinfra command, so `log.verbosity` / `log.debug` in `.nopyrc.json`
|
||||
currently have no effect. Treat `docs/REFACTORING.md` as a plan, not a record.
|
||||
`logConfigToFlags()` is now consumed by `buildDeployCall`, so `log.verbosity` /
|
||||
`log.debug` in `.nopyrc.json` finally do what the README says. Note the
|
||||
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
|
||||
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
|
||||
`@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`
|
||||
will stop the first `nopy` release until `nopy-cubes` ships.
|
||||
released there. That used to be caught by the *check linked deps are released*
|
||||
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;
|
||||
`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
|
||||
list of behaviour that surprises a reader (`--json` printing nothing on success,
|
||||
`DeployCall.dependencies` always empty, `ExecutionResult.stdout` never populated,
|
||||
no cycle detection, and `self-update` reporting an empty dist-tag as an
|
||||
unreachable registry). `CubePackageRef` is referenced by the exported
|
||||
`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
|
||||
and `self-update` reporting an empty dist-tag as an unreachable registry).
|
||||
`DOCS-AUDIT.md` tracks the drift in the remaining
|
||||
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
|
||||
tenth) are both closed. The keyman README now quotes `helpText()` verbatim and a
|
||||
|
||||
+75
-17
@@ -131,7 +131,7 @@ tarball.
|
||||
|
||||
```
|
||||
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
|
||||
→ publish to Gitea → publish to npmjs → delete .npmrc
|
||||
→ 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
|
||||
the whole gate.
|
||||
|
||||
*Check linked deps are released* asks npmjs whether every `workspace:` dependency
|
||||
of the package being released already exists at the version pnpm is about to
|
||||
bake in (`scripts/linked-deps.mjs` → `npm view`). Tagging `nopy-v1.3.0` while
|
||||
`@bitsquare/nopy-cubes@1.1.0` is still unpublished would otherwise ship a tarball
|
||||
nobody can install, and npmjs only lets you unpublish for 72 hours. The check is
|
||||
npmjs-only: it runs before any credentials are written, and npmjs is the registry
|
||||
where the mistake is permanent.
|
||||
There used to be a *check linked deps are released* step between install and
|
||||
lint, refusing to publish a package whose `workspace:` dependency was not yet on
|
||||
npmjs. It is gone: [`scripts/release.mjs`](#cutting-a-release) is what creates
|
||||
release tags now, and it pushes them dependency-first and waits for each version
|
||||
to resolve on npmjs before pushing the next — so the ordering is enforced before
|
||||
CI sees a tag rather than after. The trade is that a tag pushed by hand is no
|
||||
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
|
||||
|
||||
@@ -236,14 +237,60 @@ edit is discarded with the workspace and is never committed.
|
||||
|
||||
## 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`.
|
||||
2. Add a changelog entry (see below).
|
||||
3. Commit, merge to `main`, and let the snapshot workflow go green.
|
||||
4. Tag that commit and push the tag:
|
||||
|
||||
```sh
|
||||
git tag nopy-v1.2.0
|
||||
git push origin nopy-v1.2.0
|
||||
git tag nopy-v<version>
|
||||
git push origin nopy-v<version>
|
||||
```
|
||||
|
||||
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)
|
||||
```
|
||||
|
||||
Release `nopy` first and the run stops at the *check linked deps* step, telling
|
||||
you the `nopy-cubes` version it wanted is not on npmjs. That is the guard working;
|
||||
release `nopy-cubes`, then re-tag. `node scripts/publish-order.mjs` prints the
|
||||
order if you would rather not reason about it.
|
||||
`pnpm run release` handles this for you — it sorts the selection over the
|
||||
`workspace:` edges and will not push `nopy`'s tag until `nopy-cubes`'s new
|
||||
version answers on npmjs. Releasing by hand, you own it: tag `nopy` first and its
|
||||
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
|
||||
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
|
||||
|
||||
Neither package has a `CHANGELOG.md` yet. Without one, the Gitea release body is
|
||||
just the install snippet — nothing fails.
|
||||
`pnpm run release` writes these for you — it opens `$EDITOR` seeded with the
|
||||
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
|
||||
whose text contains the version string, and takes every line until the next `## `
|
||||
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
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
|
||||
+6
-1
@@ -14,6 +14,7 @@
|
||||
"test:coverage": "pnpm -r run test:coverage",
|
||||
"coverage:summary": "node scripts/coverage-summary.mjs",
|
||||
"registry:status": "node scripts/registry-status.mjs",
|
||||
"release": "node scripts/release.mjs",
|
||||
"try:snapshot": "node scripts/try-snapshot.mjs",
|
||||
"typecheck": "tsc --build",
|
||||
"lint": "biome check .",
|
||||
@@ -31,7 +32,11 @@
|
||||
"@bitsquare/nopy-cubes-core": "workspace:*",
|
||||
"@logtape/logtape": "^2.2.4",
|
||||
"@types/node": "^26.1.1",
|
||||
"commander": "^15.0.0",
|
||||
"enquirer": "^2.4.1",
|
||||
"semver": "^7.8.5",
|
||||
"simple-git-hooks": "^2.13.1",
|
||||
"typescript": "^7.0.2"
|
||||
"typescript": "^7.0.2",
|
||||
"zx": "^8.8.5"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+12
@@ -26,12 +26,24 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^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:
|
||||
specifier: ^2.13.1
|
||||
version: 2.13.1
|
||||
typescript:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
zx:
|
||||
specifier: ^8.8.5
|
||||
version: 8.8.5
|
||||
|
||||
packages/keyman:
|
||||
dependencies:
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
*
|
||||
* The version is the one the linked package declares *right now*, which is
|
||||
* 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
|
||||
* before shipping a manifest that points at a version nobody can install.
|
||||
* So this is the list of versions that must already be on the registry before
|
||||
* shipping the package, or the tarball points at something nobody can install.
|
||||
*
|
||||
* 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';
|
||||
|
||||
@@ -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' }
|
||||
);
|
||||
Reference in New Issue
Block a user