This document describes how the @ethdebug/* packages in this
repository are versioned and published to npm. The automation lives
in .github/workflows/publish.yml and bin/publish-tagged.ts; the
guards that run in CI live in bin/check-tarballs.ts and
bin/smoke-tarballs.ts.
- 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 thatlerna changedreports each workspace on its own, but Lerna does not bump versions:bin/version.tsdoes (see "Why notlerna version" below). - The version of
@ethdebug/formatis the version of the specification itself. - Prereleases carry a named identifier:
X.Y.Z-draft.<n>for@ethdebug/format, because a prerelease of the spec is a draft, andX.Y.Z-preview.<n>for every other workspace, because those packages work but implement a draft. Releases before0.1.0-draft.0used a plain number (0.1.0-0to0.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 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.mdand to test files do not count),@ethdebug/formatwhenschemas/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.minorseries 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, thenrelease-<major>.<minor>); a prerelease publishes underlatestwhile the package has no stable version on the registry, and under its identifier (draft,preview) after that. After a graduation thedraft/previewtag stays on the last prerelease until the next series starts.
-
Start from a clean checkout of
mainthat is up to date with the remote, with CI green on the tip commit:git checkout main && git pull --ff-only git status --short # must print nothing
-
Preview the current versions of all ten workspaces:
yarn lerna list --all --json -
Preview the release and cut the changelogs. The root
CHANGELOG.mdtracks the spec version; each public package underpackages/*/CHANGELOG.mdtracks that package's own version.yarn tsx bin/version.ts [keyword] [--all] --dry-runThe dry run changes nothing. It lists every workspace that will move, with its old and new version and the reason (
changed,schemas,dependent,graduates, orall), and it lists each changelog that is not cut yet, with the exact## <version>heading it expects. The keyword is one of:keyword use prereleasean ordinary release (the default) patcha stable fix; also graduates every prerelease preminor --allstart the next 0.(Y+1).0series with draftspremajor --allstart the next major series with drafts minor --allrelease the next minor series stable at once major --allrelease the next major series stable at once --allmoves every workspace. The series-start keywords require it and refuse to run while any workspace is a prerelease. Onlypreminorandpremajorknow an exception: they run when every workspace is a prerelease of the samemajor.minor.patch(the draft and preview identifiers differ by design), which starts the next draft series without a stable release in between.minorandmajorhave no exception, because on a prerelease they graduate in place; runpatchfirst while any prerelease exists.For each listed file, rename its
## Unreleasedheading 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## Unreleasedheading 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>`.orNo changes.in a package file, so that every published version has a section of its own. The root file is needed only when@ethdebug/formatmoves.Reconcile the root file's Unreleased entries against the previous published version: each
Producers:andConsumers: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:
git add CHANGELOG.md packages/*/CHANGELOG.md git commit -m "docs: cut changelog entries for release"
-
Bump. The script checks that you are on
mainwith 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 thepackage.jsonfiles, commits them asPublishwith hooks disabled, and creates one annotated tag per moving workspace:yarn tsx bin/version.ts [keyword] [--all]When
@ethdebug/formatmoves, thePublishcommit also rewrites the specification version in the schema examples, soschemas/may appear in that commit beside the manifests; the dry run prints the count of version literals it rewrites.A
schemas/: no example names the specification versionproblem means everyethdebugblock was removed from the examples; anames X, expected Yproblem means one drifted from the version@ethdebug/formatcarries. Edit the example and re-run.After the bump, run
yarn buildbefore runningyarn test packages/formatagain: the generatedsrc/version.tsstill names the old version until the build regenerates it. CI does this step in the publish workflow.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.
-
Inspect the result before pushing:
git show --stat HEAD git tag --points-at HEAD # expect one tag per bumped workspace
-
Push the commit and the tags in one atomic operation:
git push --atomic origin main --follow-tagsIf the push is rejected because
mainmoved, nothing reached the remote. Recover with:git tag -d $(git tag --points-at HEAD) git fetch origin git reset --hard origin/main
Then repeat from step 1. Never
git pull --rebaseover thePublishcommit: the tags stay on the old commit. Before pushing again, confirm that no tag for the version exists on the remote:git ls-remote --tags origin | grep '@<version>$' # must be empty -
Watch the workflow and confirm the result on the registry:
gh run list --workflow publish.yml --limit 3 gh run watch npm view @ethdebug/format versions --json \ --registry https://registry.npmjs.org
The registry can lag by about a minute after a publish.
.github/workflows/publish.yml runs on every push to main and on
manual dispatch. It has two jobs.
checklooks for tags of the form@ethdebug/...that point atHEADand sets ataggedoutput. It needscontents: readonly.publishruns whentaggedis true or when the workflow was dispatched by hand. It installs npm 11, installs dependencies with the frozen lockfile, runsyarn test, then runsyarn tsx bin/publish-tagged.ts. A dispatched run adds--dry-rununless thedry-runinput is set to false. The job holdsid-token: write, which lets npm obtain an OIDC token from GitHub and publish through trusted publishing; no npm token is stored in the repository. The job runs in thepublishconcurrency group, so two releases never publish at the same time.
bin/publish-tagged.ts does the following:
- Reads the tags at
HEADand matches them to workspaces. A tag whose version differs from the manifest version is an error. Private workspaces are skipped. - Sorts the selected packages in dependency order, so that
@ethdebug/formatpublishes before the packages that depend on it. - For each package, asks the registry for the published versions. A version that is already published is skipped, which makes a re-run safe. Any registry error other than "package not found" aborts the run.
- Checks the tarball contents (see "Guards" below), then runs
npm publishwith--access public, the dist-tag chosen by the rule in "Versioning model", and--registry https://registry.npmjs.org, plus--provenancewhen running under GitHub Actions. The first failed publish stops the run; the summary at the end lists the published, skipped and failed packages.
The script strips every npm_config_* variable from the environment
before it spawns npm, and passes the registry explicitly. Yarn 1
injects npm_config_registry=https://registry.yarnpkg.com into the
environment of every script it runs, and npm would otherwise try to
publish there.
Each public package has a trusted publisher configured on
npmjs.com under the package's settings: publisher GitHub Actions,
organization ethdebug, repository format, workflow filename
publish.yml, no environment, with "Allow npm publish" checked. A
trusted publisher always allows npm stage publish; whether it also
allows a direct npm publish depends on that checkbox, and the
workflow needs it (see "Known limits").
A trusted publisher can only be configured on a package that already exists on the registry, and the publish script aborts on the first failed publish, in dependency order. A brand-new package without a trusted publisher therefore fails in CI and blocks every package sorted after it. Publish a brand-new package by hand BEFORE pushing the release tags (see "Local fallback" below; tag locally, publish, then push), configure its trusted publisher, and only then let CI run.
-
A failed or cancelled run can be re-run from the Actions UI ("Re-run jobs") or with
gh run rerun <run-id>. Packages that already published are skipped. -
To start a run by hand for the tags at the tip of
main:gh workflow run publish.yml --ref main -f dry-run=falseWithout
-f dry-run=falsethe dispatched run is a dry run. -
A re-run re-uses the commit that triggered the run, including its copy of the workflow and the script. If the publish tooling itself needs a fix, merge the fix to
main, move the release tags to the fix commit, then dispatch the workflow:for tag in $(git tag --points-at <publish-commit>); do 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 changedignores lightweight tags.The manifests already carry the version, so the tag-to-manifest check still passes.
The same script can publish from a developer machine, for example for the first publish of a new package:
yarn install --frozen-lockfile
yarn build
yarn tsx bin/publish-tagged.tsRequirements:
- The release tags must point at
HEAD. - npm 11 or later, logged in:
npm whoami --registry https://registry.npmjs.orgmust print your user. Name the registry explicitly: a shell spawned by yarn carriesnpm_config_registry=https://registry.yarnpkg.com. - A real interactive terminal. npm prompts for a one-time password for every package it publishes, so the script cannot run from a non-interactive shell.
Add --dry-run to see what would happen without publishing. Outside
GitHub Actions the script does not pass --provenance.
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.
bin/check-tarballs.ts(CI,run-testsjob) lists the files thatnpm packwould put in each public package's tarball and fails if any file lies outsidepackage.json,README*,LICENSE*,CHANGELOG*,dist/src/ordist/bin/, or if any.test.or.tsbuildinfofile is included. It catches a wrongfilesfield, test files leaking into the package, and stale build output. The publish script runs the same check before every publish. npm shipspackage.json,README*andLICENSE*whateverfilessays, but notCHANGELOG.md, so every public package lists it infiles.bin/smoke-tarballs.ts(CI,run-testsjob) packs every public package, installs each tarball into a throwaway consumer project together with the tarballs of its sibling dependencies, imports the package's entry point, and for@ethdebug/bugcalso runsbugc --help. It catches a brokenmainorexports, a missing runtime dependency, output that was not built, and abinthat does not run.yarn vitest run --project bintests the pure parts of these scripts.
- The
publishconcurrency group protects a job that has started, not a run that is still pending. GitHub cancels a pending run in the group when a newer one queues, so aPublishcommit whose run shows "cancelled" must be re-run from the Actions UI or dispatched by hand. - CI sets exactly one dist-tag per publish (trusted publishing covers
npm publishonly, notnpm dist-tag). After a graduation thedraftandpreviewtags 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
change by then. What WOULD force a rewrite is switching any
package's trusted publisher to stage-only (unchecking "Allow npm
publish"): the registry then rejects
npm publish, accepts onlynpm stage publish, and a human 2FA promote step becomes mandatory. Tracked in issue #296. - Trusted publishing needs npm 11.5 or later. The workflow installs npm 11 explicitly because the runner's default npm is older.