LeastGrant

Rendered from RELEASING.md in the repository at v0.2.0. This page is a view of that file, not a copy of it.

Releasing LeastGrant

A release is a tag. Everything else is automatic.

# on main, with a clean tree
npm version patch          # or minor / major — edits package.json and commits
git push origin main
git push origin v0.1.1     # one tag, explicitly

Pushing vX.Y.Z starts .github/workflows/release.yml, which verifies the tree, publishes to npm, and creates the GitHub Release with the tarball attached. Merging to main never publishes anything.

Push the tag on its own line. git push --tags pushing four or more new tags at once fires no workflow at all — GitHub caps tag events at three.


What has to happen once, by hand

npm cannot bind a trusted publisher to a package that does not exist yet, so the very first publish has to come from a person. After that there are no secrets in this repository and no manual publishing, ever.

Do these in order. Steps 1–3 are on npmjs.com, step 4 is on GitHub.

1. Claim the name with one interactive publish

From a clean checkout of the tag you want to be 0.1.0:

git status --porcelain          # must be empty
npm ci
npm run build && npm test
npm login                       # browser 2FA; yields a 2-hour session, not a stored token
npm publish

prepublishOnly rebuilds and re-runs the tests before this goes out, and npm publish from the working directory is the only path in this project that still uses it.

No token is created and nothing is stored in the repository. Classic npm tokens were revoked in November 2025; npm login now issues a short-lived session credential on your machine only.

This first release will not have provenance — provenance is generated by the registry for OIDC publishes, and this one is not. That is expected and is the only release it applies to. If you later push the v0.1.0 tag, the workflow notices the version is already on the registry, skips publishing, still checks that the bytes match what it built, and reports the missing attestation as a note rather than failing. Provenance is only required of a publish the workflow performed itself.

2. Configure the trusted publisher

npmjs.com → leastgrant → Settings → Trusted Publisher → GitHub Actions.

Enter exactly:

FieldValue
Organization or userleastgrant
Repositoryleastgrant
Workflow filenamerelease.yml
Environment namenpm-release
Allowed actionsnpm publish

Or from the CLI (npm ≥ 11.15.0):

npm trust github leastgrant --repo leastgrant/leastgrant \
  --file release.yml --env npm-release --allow-publish

Three things bite here, so check them:

  • The workflow filename is a bare filename, not a path. release.yml, never .github/workflows/release.yml.
  • Every field is case-sensitive, and npm does not validate any of it when you save. A mistake surfaces only at the next release, as E404 or ENEEDAUTH — errors that read like the package is missing rather than like a configuration mismatch. The publish step prints exactly these values in its error output for that reason.
  • Renaming release.yml breaks publishing. If you ever move or rename it, update this configuration in the same change.

3. Turn off token publishing

npmjs.com → leastgrant → Settings → Publishing accessRequire two-factor authentication and disallow tokens.

This is what makes the guarantee real rather than a preference: with it set, a stolen granular token cannot publish LeastGrant at all. Trusted publishing is unaffected — it does not use tokens.

4. Create the npm-release environment

GitHub → Settings → Environments → New environmentnpm-release.

Leave required reviewers off. Pushing a tag is already the deliberate act; an approval click on top of it is ceremony, not security.

Do set, under Deployment branches and tags, a tag rule of v*. That is what the environment is for: it means only a run started from a version tag can enter the environment, and only a run inside the environment can obtain the OIDC token npm will accept.

Optionally, also protect the tags themselves: Settings → Rules → New ruleset → target Tags, pattern v*, with Restrict creations to maintainers. Then a release can only be started by someone who is allowed to start one.


What the workflow guarantees

Four jobs, each gated on the one before. Nothing is published unless every one of them is green.

JobDoesCan
guardtag ↔ package agreement, offlineread
verifythe whole CI matrix, on a clean checkoutread
publishbuild, inspect, install, publish, proveread + mint an OIDC token
releaseattach the tarball, write the release noteswrite releases
  • A release only happens from a version tag. The workflow declares only tags:, so a branch push cannot trigger it at all.
  • The tag must match the package. scripts/release-guard.mjs refuses if v1.2.3 does not mean "version": "1.2.3", if the name is not leastgrant, if repository/homepage/bugs do not point at this repository, if a runtime dependency has appeared, or if the tag carries SemVer build metadata that npm cannot represent.
  • The tag must be on main. A tag on some other commit is refused, so a release cannot be cut from an unreviewed branch.
  • Everything is built from one commit. Every checkout pins ${{ github.sha }}, not the tag, so moving the tag mid-run cannot change what ships. Nothing reuses a dependency cache.
  • The published bytes are the verified bytes. The job packs once, inspects that tarball, installs it into an empty directory and runs it, then publishes. npm pack is byte-reproducible and npm publish --ignore-scripts does not re-run prepublishOnly, so the tree cannot change in between — and afterwards the job compares the registry's dist.integrity against the sha512 of the tarball it verified. If they differ, the build fails.
  • No long-lived credential exists. Publishing is OIDC. There is no NPM_TOKEN in this repository, and a test asserts there never will be.
  • Provenance is required, not hoped for. npm generates it automatically for a public package published from a public repo through a trusted publisher. The job then installs the published version and runs npm audit signatures; a missing attestation fails the release, because its absence would mean the publish did not go through OIDC.
  • Least privilege. permissions: {} at the top of every workflow. The job that can publish cannot write to the repository; the job that can write releases cannot mint a token.
  • Pinned actions. Every action is pinned to a full commit SHA, and only first-party actions/* are used. gh comes preinstalled on the runner, so creating the release needs no action at all.

test/release-workflow.test.ts asserts all of the above against the workflow files, so an edit that quietly removes one fails the ordinary test suite.

Prereleases

A tag like v0.2.0-rc.1 is detected by the guard, published under the next dist-tag rather than latest, and marked as a prerelease on GitHub. Nobody running npm install leastgrant gets it by accident.


When something goes wrong

The workflow is safe to re-run. Select the tag under Actions → Release → Run workflow, which repeats every check.

npm succeeded, the GitHub Release failed. Re-run. The publish job sees the version already on the registry, skips publishing, and still verifies that what is published matches what it just built. The release job then creates the release. This is also the path a re-run takes after the manual bootstrap publish above.

The GitHub Release exists, npm failed. Fix the cause and re-run. The release job updates the existing release in place (gh release edit plus gh release upload --clobber) rather than failing on it.

Both failed, or the tag was wrong. Delete the tag and start again — nothing was published:

git push --delete origin v0.1.1
git tag -d v0.1.1

Published a bad version. Do not delete it; npm no longer allows unpublishing after 72 hours and consumers may already have it. Publish a fixed version, then deprecate the bad one:

npm deprecate leastgrant@0.1.1 "Broken release; use 0.1.2"

E404 or ENEEDAUTH from npm publish. The trusted publisher does not match this run. Compare the table in step 2 against npmjs.com character for character — owner, repository, release.yml, npm-release, and that npm publish is an allowed action.

The tarball is not what you expected. Every release attaches leastgrant-<version>.tgz and its .sha256. That file is byte-identical to what is on the registry; the job proves it before the release is created.


Verifying a release, as anybody else

npm view leastgrant@0.1.1 dist.integrity     # matches the sha512 in the run log
npm install leastgrant@0.1.1
npm audit signatures                          # registry signature + provenance attestation

The provenance attestation links the published tarball to the commit and the workflow run that produced it, signed by Sigstore and recorded in a public transparency log. It is visible on the package page on npmjs.com.

The example above is 0.1.1 rather than 0.1.0 deliberately. 0.1.0 was published by hand, because npm cannot bind a trusted publisher to a package that does not exist yet, so it has a registry signature but no attestation. npm audit signatures will say so, and that is expected for that one version. Its integrity was still checked against the artifact the workflow built, and its GitHub Release says the same thing.