Skip to main content

Releasing

The packages that publish from this repository — @nubbin/core, @nubbin/react, @nubbin/next, @nubbin/store-fs, @nubbin/cli, @nubbin/studio and @nubbin/studio-ui — share a version.

Versions are derived, never edited

Which tool derives them, and what it beat, is release-please owns versions.

Nothing is recorded at commit time beyond the commit message, whose format commitlint already enforces. A fix:, feat:, perf: or refactor: touching a package is release-driving. The repository uses refactor: for user-facing architectural work, so it is registered explicitly; without that section release-please finds the commits but quietly reports that none are user-facing. Documentation, tests, chores, build work and CI remain hidden and do not create a release pull request by themselves.

The release workflow runs release-please on every push to main. It maintains one pull request, titled chore(repo): release main, carrying every manifest bump and every changelog entry earned since the last release. Merging that pull request is what makes a version real, and it is the only thing that edits a version field.

Merging it also creates seven GitHub releases and seven tags. Those releases are what permit a publish: the publish job is gated on the action's releases_created output, which is true only on the run that created them, and therefore true once per version.

What the configuration says

FileHolds
release-please-config.jsonhow versions are computed, and for which packages
.release-please-manifest.jsonthe version each package is at, which release-please rewrites

The settings there are load-bearing, and each fails quietly if it is wrong:

No versioning, prerelease or prerelease-type. With all three present — "versioning": "prerelease", "prerelease": true, and "prerelease-type": "rc" — every release is rc.8 after rc.7, forever. They were deleted to cut 0.1.0, and the suite fails if one comes back. Going back on a candidate line is adding all three again, deliberately.

Deleting them does not, by itself, leave the candidate line. Measured when 0.1.0 was cut: with the three gone, release-please still proposed a candidate — it bumped the minor digit and carried the rc suffix across rather than dropping it. A version that leaves the line has to be named:

chore(repo): cut 0.1.0 rather than another candidate

Release-As: 0.1.0

The footer has to survive the squash. GitHub composes a squash commit from the branch's messages as a bulleted body, and a footer folded into a bullet under a chore entry is not read — the first attempt proposed the same candidate again, with the footer sitting unread in the merge commit. Merge with that line as the entire squash body:

gh pr merge <n> --squash --subject "chore(repo): …" --body "Release-As: 0.1.0"

A "release-as" key in the config does the same job and is the wrong tool here: it stays after the release it was written for, capping every version that follows at the one it names.

"bump-minor-pre-major": true — a breaking change carries the prerelease part through a major bump rather than resetting it, so a feat!: before 1.0.0 moves the minor digit rather than the major one.

The linked-versions plugin, naming every component, so the packages move as one. Its components are the component names — the npm scope is stripped, so they read as core, react and their siblings. Each package sets component explicitly rather than relying on that.

"last-release-sha" — the commit that actually published the version in the manifest, so the first release pull request's changelog begins after it rather than at the beginning of the repository. It is never ignored, so it stays correct by being left alone; it is inert once a release pull request has merged, because the scan stops at that merge first.

packages/core also carries an extra-files entry for src/version.constants.ts, which is stamped into every artifact as compiledWith. The updater matches the x-release-please-version annotation on the line holding the value, and tests/coreVersionStamp.test.mjs fails the release pull request if the two ever disagree.

The commands

CommandDoes
pnpm publishableChecks the version stamp, builds, then runs the gates that read the artifact a consumer installs rather than the source — publint, attw, and the release test project
pnpm release-tagRefuses a stable publish while any version is a prerelease

There is no local publish command. pnpm verify includes publishable, so a pull request already proves the packages are publishable before anyone tries.

It must be pnpm that publishes

Workspace dependencies are written catalog: and workspace:*. No registry understands either. pnpm rewrites them to real versions when it packs; npm does not — so a package published with npm publish looks correct in the repository and fails on install for every consumer, with an error naming the protocol rather than the mistake.

tests/release/packagesInstallFromTarball.test.mjs inspects the packed manifest rather than the tool, so it holds whichever command produced the tarball — and then installs it, because npm rejects a surviving catalog: with an error naming the protocol rather than the specifier.

This is also why linked-versions is the plugin and node-workspace is not: the version-linking job is wanted, and the dependency-range rewriting is not.

Two behaviours that surprise people

publishConfig.tag is ignored. pnpm's publish path does not honour it — a dry run with the field set announced latest. The dist-tag is decided by the command, which is why the workflow passes --tag explicitly and why the field is absent from every manifest.

A publisher treats "already on the registry" as success. pnpm publish skips such a package and exits 0; its own --force flag documents the behaviour it overrides. So a green release run is not evidence that anything was sent. The workflow reads the registry before publishing, package by package: one already at the version is skipped by name and never sent again, the ones the registry is missing become the publish set, and a run in which nothing is missing fails with the version named. The silence becomes a logged skip or a red build — never a green no-op. Refusing over the whole set was rejected: a publish can land partially, and a re-run then refuses over the packages that landed, stranding the one that did not.

Publishing from CI

The release workflow is the only way to publish. It is not dispatchable: a dispatch takes a ref and a dist-tag from whoever runs it, and both are now derived — the ref is main, and the dist-tag comes from the version, rc for a prerelease and latest for a stable one.

Two gates stand in front of the publish job, and they answer different questions:

GateAsks
releases_created == 'true'is this a version that has not been released?
the verify run for this commitdoes this commit pass lint, typecheck and the suites?

The second is a poll rather than a read, because verify and release are triggered by the same push and start together. Before it existed, the publish path ran publishable and nothing else, so a commit failing every test could publish.

Merging anything else to main while a release commit is being verified cancels that verification, and the publish then refuses. verify is keyed ${{ github.workflow }}-${{ github.ref }} with cancel-in-progress, so the next push to main kills the run belonging to the release commit; the publish job reads cancelled, and nothing publishes from a commit that did not pass. It costs a re-run of verify on the release commit followed by a re-run of the publish job — and a second approval. Land other work before the release pull request, or after the publish.

Publishing then waits on the npm environment, which restricts deployments to main and carries a required reviewer. Merging the release pull request proposes a release; approving that deployment performs one.

A package published for the first time cannot be published by CI. Trusted publishing is configured per package, on a page that exists only once the package does — so the token exchange for a name npm has never seen returns a 404, pnpm falls back to no credentials, and the registry answers the unauthenticated PUT with another 404 rather than disclosing whether the name exists. Cutting a release that introduces a package publishes the rest and stops there. For a name npm has seen before but cannot yet authenticate, configuring its trusted publisher and re-running the failed publish job sends the stranded package alone.

A re-run replays the workflow file from its own commit, not from main. So that recovery works only where the run being re-run already had the per-package publish step; a release cut before it refuses again, with the message that release had, however the workflow reads now. A package stranded by such a release is published by the bootstrap below, which is the same act performed for a different reason. For a name npm has never seen, the bootstrap is the only publish there can be.

The bootstrap is one authenticated publish by a person:

npm login
pnpm --filter @nubbin/<name> publish --access public --no-git-checks

pnpm, never npm. The manifest's dependencies are written workspace:* and catalog:, and pnpm resolves both as it packs; npm publish ships the literal specifiers and the package cannot be installed. Configure the trusted publisher immediately afterwards, and CI owns every release that follows.

No npm token is stored. id-token: write lets npm exchange an OIDC claim for a short-lived credential scoped to that one workflow, which cannot be extracted or reused, and is what signs the provenance attestation.

The last step reads the registry back, because the publish output is not evidence that a package landed — an npm view of the version a consumer would resolve is.

Git hooks do not run in CI

Every workflow sets LEFTHOOK: "0". pnpm install runs prepare, which installs lefthook's hooks, so without it a workflow step that pushes runs the whole pre-push suite — against a checkout that may not have built yet, duplicating checks the workflow already runs as jobs.

What lives outside this repository

Three things are set up once, and a release cannot happen without all three:

WhereWhatState
GitHub → Settings → EnvironmentsAn environment named npm, restricted to main, with a required reviewerdone
npmjs.com → each package → SettingsA trusted publisher naming this repository and release.ymldone
GitHub → Settings → SecretsRELEASE_PLEASE_TOKENrequired

RELEASE_PLEASE_TOKEN is a fine-grained personal access token with read and write on Contents, Pull requests and Issues for this repository. GITHUB_TOKEN cannot stand in for it, for two independent reasons: this repository has Allow GitHub Actions to create and approve pull requests switched off, so that token cannot open the release pull request at all; and a pull request opened with it triggers no workflow run, so verify and commitlint — both required status checks — would never report on the release pull request and it could never be merged.

The workflow checks for the secret and fails with that explanation rather than letting it surface as an authentication error inside the action.

Tags

Each release is tagged per package, <component>-v<version>core-v…, react-v…, next-v…, store-fs-v…. A tag is the durable record tying a published version to the commit that produced it, and release-please reads them: when a release is missing, it looks for the tag its manifest version implies and takes the previous release's commit from there.

After publishing

The registry does not serve a publish immediately, which is why the read-back retries rather than reading once. Once it settles, check the artifact rather than the output that produced it:

npm view @nubbin/core dist-tags
npm install @nubbin/core # in an empty directory, then import it

Installing into a clean directory is the only check that covers the whole path, including whether a workspace dependency resolved to a version that exists.