Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ on:
type: boolean
default: true

# lerna.json sets no commit message; adding "[skip ci]" there would
# silently stop this workflow from publishing.
# bin/version.ts commits the bump as "Publish" with no "[skip ci]";
# adding one would silently stop this workflow from publishing.

jobs:
check:
Expand Down
199 changes: 111 additions & 88 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,45 @@ guards that run in CI live in `bin/check-tarballs.ts` and

## Versioning model

- Lerna runs in independent mode (`lerna.json`), so every workspace
carries its own version and its own git tag of the form
`@ethdebug/<name>@<version>`.
- Every workspace carries its own version and its own annotated git
tag of the form `@ethdebug/<name>@<version>`. Lerna runs in
independent mode (`lerna.json`) so that `lerna changed` reports
each workspace on its own, but Lerna does not bump versions:
`bin/version.ts` does (see "Why not `lerna version`" below).
- The version of `@ethdebug/format` is the version of the
specification itself.
- Prereleases use a plain numeric suffix: `0.1.0-0`, `0.1.0-1`, and
so on. Write them as `0.1.0-<n>`.
- Prereleases carry a named identifier: `X.Y.Z-draft.<n>` for
`@ethdebug/format`, because a prerelease of the spec is a draft,
and `X.Y.Z-preview.<n>` for every other workspace, because those
packages work but implement a draft. Releases before `0.1.0-draft.0`
used a plain number (`0.1.0-0` to `0.1.0-2`); they sort before the
named ones.
- Ten workspaces can take part in a release: the seven public
packages (`format`, `pointers`, `evm`, `bugc`, `bugc-react`,
`pointers-react`, `programs-react`) and the three private ones
(`format-web`, `bug-playground`, `conformance`). Private packages
get versions and tags like the others, but the publish script
skips them.
- Lerna bumps only the packages that changed since their last tag,
plus the packages that depend on them, and it tags only those. The
first release (`0.1.0-1`) moved all ten because no package had a
tag yet. To move all ten in lockstep on a later release, add
`--force-publish` to the `lerna version` command below.
(`format-web`, `bug-playground`, `conformance`). Private workspaces
get versions and tags like the others, but the publish script skips
them.
- What moves in a release: every workspace whose directory changed
since its own tag (changes to `CHANGELOG.md` and to test files do
not count), `@ethdebug/format` when `schemas/` changed (the schemas
live outside the package directory but ship inside it), and every
workspace that depends on a moving one, directly or transitively.
Every workspace depends on `@ethdebug/format`, so a specification
change moves all ten.
- Series convention, for now: all workspaces start a `major.minor`
series together and graduate together with the spec; between those
events each workspace moves only when it or a dependency changed,
with its own counter. Revisit this when one package needs a long
preview, or its own keyword, while the others release stable. Until
then, while any workspace is in a preview, a stable release of
another workspace graduates that preview too.
- npm dist-tags: a stable version publishes under `latest` (unless a
higher stable version exists, then `release-<major>.<minor>`); a
prerelease publishes under `latest` while the package has no stable
version on the registry, and under its identifier (`draft`,
`preview`) after that. After a graduation the `draft`/`preview`
tag stays on the last prerelease until the next series starts.

## Cutting a release

Expand All @@ -43,78 +64,76 @@ guards that run in CI live in `bin/check-tarballs.ts` and
yarn lerna list --all --json
```

3. Update the changelogs before bumping. The root `CHANGELOG.md`
tracks the spec version; each public package under
3. Preview the release and cut the changelogs. The root
`CHANGELOG.md` tracks the spec version; each public package under
`packages/*/CHANGELOG.md` tracks that package's own version.

See which packages the next step will move:

```console
yarn lerna changed
yarn tsx bin/version.ts [keyword] [--all] --dry-run
```

The bump in step 4 moves every package that command lists, plus
every package that depends on one of them.

For the root file, and for each package about to be bumped,
rename its `## Unreleased` heading to
`## <version> — <YYYY-MM-DD>`: that package's own new version,
then today's date. Leave a fresh, empty `## Unreleased` heading
above the section you renamed. A package that is not being bumped
needs no change.

When you rename `## Unreleased` in the root file, reconcile its
entries against the previous published version. Each `Producers:`
and `Consumers:` line states the net effect for a party that
moves from that version to the new one. If one Unreleased entry
reverses an obligation of another Unreleased entry, neither
impact line keeps that obligation; the summaries may still tell
the history.

A package that is bumped only because a dependency of it changed
has nothing under `## Unreleased`. Give it a `### Changed` entry
reading "Updated `@ethdebug/<dep>` to `<version>`.", so that every
published version has a section of its own.

Commit the renamed files on their own, right before the version
bump in the next step:
The dry run changes nothing. It lists every workspace that will
move, with its old and new version and the reason (`changed`,
`schemas`, `dependent`, `graduates`, or `all`), and it lists each
changelog that is not cut yet, with the exact `## <version>`
heading it expects. The keyword is one of:

| keyword | use |
| ---------------- | --------------------------------------------- |
| `prerelease` | an ordinary release (the default) |
| `patch` | a stable fix; also graduates every prerelease |
| `preminor --all` | start the next `0.(Y+1).0` series with drafts |
| `premajor --all` | start the next major series with drafts |
| `minor --all` | release the next minor series stable at once |
| `major --all` | release the next major series stable at once |

`--all` moves every workspace. The series-start keywords require
it and refuse to run while any workspace is a prerelease. Only
`preminor` and `premajor` know an exception: they run when every
workspace is a prerelease of the same `major.minor.patch` (the
draft and preview identifiers differ by design), which starts the
next draft series without a stable release in between. `minor` and
`major` have no exception, because on a prerelease they graduate in
place; run `patch` first while any prerelease exists.

For each listed file, rename its `## Unreleased` heading to the
heading the dry run printed (`## <version> — <YYYY-MM-DD>`, that
file's own version, then today's date) and leave a fresh, empty
`## Unreleased` heading above it. A file whose section would be
empty gets one sentence: `No changes to the specification.` in the
root file, ``Updated `@ethdebug/<dep>` to `<version>`.`` or
`No changes.` in a package file, so that every published version
has a section of its own. The root file is needed only when
`@ethdebug/format` moves.

Reconcile the root file's Unreleased entries against the previous
published version: each `Producers:` and `Consumers:` line states
the net effect for a party that moves from that version to the new
one, so an obligation that a later entry in the same section
reverses appears in neither impact line.

Commit the cut on its own:

```console
git add CHANGELOG.md packages/*/CHANGELOG.md
git commit -m "docs: cut changelog entries for <version>"
git commit -m "docs: cut changelog entries for release"
```

Pre-flight check: in each file you touched, the only remaining
`## Unreleased` section is the empty one at the top. Never publish
with entries still sitting under `## Unreleased` in a changelog
for a package (or the spec) being released.

4. Bump to an explicit version. Lerna commits the result as
`Publish` and creates one tag per bumped workspace on that
commit:
4. Bump. The script checks that you are on `main` with a clean tree,
that the nearest annotated tag is a release tag, that no tag for a
new version exists, and that every changelog is cut; then it
writes the versions and the internal dependency ranges into the
`package.json` files, commits them as `Publish` with hooks
disabled, and creates one annotated tag per moving workspace:

```console
yarn lerna version 0.1.0-<n> --no-push --no-commit-hooks --yes
yarn tsx bin/version.ts [keyword] [--all]
```

Each flag matters:
- The version MUST be explicit. `yarn lerna version prerelease`
does not produce `0.1.0-1` from `0.1.0-0`: Lerna resolves the
prerelease identifier as `--preid || existing preid || "alpha"`,
and a numeric prerelease has no identifier, so the result is
`0.1.0-alpha.0`.
- `--no-commit-hooks`: the repository's pre-commit hook runs
lint-staged, which would rewrite files in the `Publish` commit.
- `--no-push`: Lerna would otherwise run
`git push --follow-tags --no-verify --atomic <remote> <branch>`
and, when the error text mentions "atomic", silently retry
WITHOUT `--atomic`. The push happens by hand in step 6 instead:
no non-atomic fallback, no `--no-verify` skipping the pre-push
hooks, and step 5's inspection happens before anything reaches
the remote.
- `--yes`: skips the confirmation prompt. Preview with the command
in step 2 first; do not use `--no-git-tag-version` as a
preview, because it still rewrites every `package.json`.
The script never pushes. If it fails after it started writing, it
prints the undo commands for the stage it reached. The dry run of
step 3 reports the same guards and findings as this run, but it
always exits 0, so read its output rather than its exit status.

5. Inspect the result before pushing:

Expand Down Expand Up @@ -143,17 +162,7 @@ guards that run in CI live in `bin/check-tarballs.ts` and
again, confirm that no tag for the version exists on the remote:

```console
git ls-remote --tags origin | grep '0.1.0-<n>' # must be empty
```

If Lerna's own push did run (`--no-push` was forgotten), its
non-atomic retry may have left the remote with tags but no commit,
or the reverse. See what landed with `git ls-remote --tags origin`
and `git ls-remote origin main`, then either finish the push with
the command above or delete each stray remote tag:

```console
git push origin :refs/tags/<tag>
git ls-remote --tags origin | grep '@<version>$' # must be empty
```

7. Watch the workflow and confirm the result on the registry:
Expand Down Expand Up @@ -197,7 +206,8 @@ manual dispatch. It has two jobs.
re-run safe. Any registry error other than "package not found"
aborts the run.
4. Checks the tarball contents (see "Guards" below), then runs
`npm publish` with `--access public`, `--tag latest` and
`npm publish` with `--access public`, the dist-tag chosen by the
rule in "Versioning model", and
`--registry https://registry.npmjs.org`, plus `--provenance` when
running under GitHub Actions. The first failed publish stops the
run; the summary at the end lists the published, skipped and
Expand Down Expand Up @@ -248,11 +258,14 @@ run.

```console
for tag in $(git tag --points-at <publish-commit>); do
git tag -f "$tag" <fix-commit>
git tag -f -a "$tag" -m "$tag" <fix-commit>
done
git push --force origin $(git tag --points-at <fix-commit>)
```

The tags must stay annotated: `lerna changed` ignores lightweight
tags.

The manifests already carry the version, so the tag-to-manifest
check still passes.

Expand Down Expand Up @@ -281,6 +294,17 @@ Requirements:
Add `--dry-run` to see what would happen without publishing. Outside
GitHub Actions the script does not pass `--provenance`.

## Why not `lerna version`

Lerna 8 cannot produce this scheme. A keyword bump on a numeric
prerelease yields `alpha` (`--preid || existing || "alpha"`); an
explicit version moves every workspace whose prerelease number is
truthy, which is lockstep for `-1` and above and changed-only for
`-0`; and one run takes one `--preid`, so `draft` and `preview` cannot
start a series together. `bin/version.ts` therefore computes each
workspace's next version with `semver` and makes the commit and the
tags itself. Lerna still runs scripts and detects changes.

## Guards

- `bin/check-tarballs.ts` (CI, `run-tests` job) lists the files that
Expand Down Expand Up @@ -309,10 +333,9 @@ GitHub Actions the script does not pass `--provenance`.
the group when a newer one queues, so a `Publish` commit whose run
shows "cancelled" must be re-run from the Actions UI or
dispatched by hand.
- Prereleases are published under the `latest` dist-tag, so a plain
`npm install @ethdebug/format` installs a prerelease. At the first
stable release, either publish prereleases under `next` or move
`latest` with `npm dist-tag` afterwards.
- CI sets exactly one dist-tag per publish (trusted publishing covers
`npm publish` only, not `npm dist-tag`). After a graduation the
`draft` and `preview` tags keep pointing at the last prerelease.
- npm's January 2027 change removes direct publishing with granular
access tokens that bypass 2FA. It does not affect OIDC trusted
publishing, and this repository stores no token, so nothing has to
Expand Down
Loading
Loading