Skip to content

Releasing ESI.ts ​

Implements: REL-01, REL-02, REL-03, REL-04, REL-05, DOC-01 · requirements stated in CHARTER.md Part 8

How a version of @lgriffin/esi.ts goes from merged commits to a published, signed package. The workflows are the fact: .github/workflows/release-please.yml, .github/workflows/release.yml, release-please-config.json and .release-please-manifest.json. Where this guide and a workflow disagree, the workflow wins and the guide is the bug.

For what runs at commit, push and PR time, see QUALITY-GATES.md. For why the signing and provenance controls exist, see SECURITY.md.


The path of a release ​

conventional commits on master
        │
        ▼
release-please.yml ──► release PR (version bump + changelog)
        │ merge
        ▼
tag vX.Y.Z + GitHub release published
        │
        ▼
release.yml
  validate-release ──┬─► publish-npm            (release event only)
                     ├─► publish-github         (release event only)
                     ├─► deploy-docs
                     └─► create-assets ──► sign-and-publish-assets (release event only)
                                                        │
                                                 notify-success
  1. Commits land on master through a pull request. Each commit message follows the conventional-commit format (REL-01). The commit-msg Husky hook runs commitlint against @commitlint/config-conventional.
  2. release-please runs on every push to master. It reads the commits since the last release, works out the next version, and opens or updates a release pull request, but only once a feat:, a breaking change (type!: or BREAKING CHANGE:) or a Release-As: footer has landed since the last tag. Fixes, chores and dependency bumps on their own open no release pull request; they wait and ship in the next minor or major (see Release cadence). That pull request edits package.json, .release-please-manifest.json, CHANGELOG.md and the extra file src/core/constants.ts.
  3. Merging the release pull request makes release-please create the vX.Y.Z tag and the GitHub release.
  4. release.yml runs twice, once for the tag push (v*.*.*) and once for the release: published event. Both runs validate and build the assets. Only the release run, or a workflow_dispatch run on the tag, publishes packages, deploys the documentation, signs assets and then dispatches post-publish-canary.yml; those jobs are guarded by the event name.

Jobs in release.yml ​

JobNeedsRuns onDoes
validate-release—tag push and releaseThe publish gate, below
publish-npmvalidate-release, consumer-contractrelease onlyRe-checks checksums.txt, then npm publish <tarball> --provenance of the tarball create-assets packed, to registry.npmjs.org
publish-githubvalidate-release, consumer-contractrelease onlyThe same tarball, npm publish <tarball> --provenance to npm.pkg.github.com
deploy-docsvalidate-releaserelease onlyCalls docs-site.yml: npm run docs:site (the VitePress site with TypeDoc under /api/), validate:versions -- --site, then deploys docs-site/.vitepress/dist to the gh-pages branch
create-assetsvalidate-releasetag push and releasenpm pack (fails unless exactly one tarball), the CycloneDX SBOM (npm run release:sbom), docs.tar.gz of the API reference, checksums.txt (SHA-256); uploads as artifact
consumer-contractcreate-assetstag push and releaseThe consumer contract (npm run test:consumer -- --tarball) against the tarball create-assets packed: Node 22 and 24, oldest, repository and latest TypeScript
sign-and-publish-assetscreate-assets, consumer-contractrelease onlyKeyless cosign sign-blob on the tarball, SBOM and docs archive; SLSA build provenance for the same three (actions/attest-build-provenance, checked with gh attestation verify); then gh release upload of all assets plus README.md, LICENSE and NOTICE
dispatch-canarypublish-npm, sign-and-publish-assetswhen publish-npm passedDispatches post-publish-canary.yml on the tag (actions: write)
notify-successall of the abovewhen publish-npm passedLog line only

The workflow holds top-level contents: read. Only publish-npm, publish-github and sign-and-publish-assets receive id-token: write, and signing sits in its own job so that no build step shares a job with the ability to mint an OIDC token.

release.yml also has a workflow_dispatch trigger. Dispatch it on the tag, gh workflow run release.yml --ref vX.Y.Z; validate-release fails on any other ref, and on a tag that does not match package.json. release-please.yml has a manual trigger with a force-release box, for shipping a patch on purpose (below).

The release app token ​

Events raised with the workflow's GITHUB_TOKEN start no other workflow. A release PR opened with it waits for someone to approve its CI runs, and the tag and release it creates never start release.yml or post-publish-canary.yml. So release-please.yml runs release-please with a GitHub App installation token when the app is configured:

NameKindValue
RELEASE_APP_CLIENT_IDActions variableThe app's Client ID
RELEASE_APP_PRIVATE_KEYActions secretA private key generated for the app (.pem)

The app is installed on this repository only, with repository permissions Contents, Pull requests and Issues set to read and write, no webhook, and nothing else. The token step asks for those three permissions only.

Without both, release-please falls back to GITHUB_TOKEN. Its release PR's CI then has to be approved in the Actions UI, and the dispatch-release job starts release.yml on the new tag (workflow_dispatch is exempt from the rule), unless a dispatched run for that tag already exists. Either way release.yml dispatches the canary itself once publish-npm has succeeded.

The same fallback applies when minting fails, for example because the app is not installed on the repository (the token API answers 404) or the key was deleted. The run carries a "Release app token" warning annotation saying so, and the release still goes out.

nightly-recorded-payloads.yml opens its payload-drift pull request with the same app token, asking for Contents and Pull requests only, so ci.yml runs on it and ci-success reports (#572). Without the app it opens the pull request with GITHUB_TOKEN and a "Pull request token" warning; that pull request needs a manual push or re-run before it can merge.


Release cadence ​

Releases are cut for new features and breaking changes only. A gate step in release-please.yml scans the commits since the last vX.Y.Z tag and skips release-please's pull-request step unless one of them is a feat:, carries ! or a BREAKING CHANGE: footer, or has a Release-As: footer. The release step always runs, so merging an existing release pull request still tags and publishes.

Once a release pull request is open (label autorelease: pending), the gate lets every later push through, so later fixes keep updating it, and they appear under Fixed in the changelog of that minor. To ship a fix on its own (a security or regression fix that cannot wait), run the workflow by hand with force-release:

bash
gh workflow run release-please.yml -f force-release=true

That opens a patch release pull request; merge it as usual. A Release-As: X.Y.Z footer on a commit does the same without the manual run.

Version bumps ​

release-please uses the node release type with bump-minor-pre-major: false, so semantic-versioning rules apply as written. Deciding which bump a change needs, and how to mark it, is in SEMVER.md.

CommitBumpChangelog section
type!: or a BREAKING CHANGE: footer, any typemajoras for the type
feat:minorAdded
fix:patch¹Fixed
perf:, refactor:, chore:patch¹Changed
docs:patch¹Documentation
test:patch¹Testing
build:, ci:, style:, revert: (allowed by commitlint)nonenot listed

¹ Patch-level commits do not open a release on their own; they ride the next minor or major unless forced (see Release cadence).

The section mapping lives in changelog-sections in release-please-config.json. A type missing from that list is accepted by commitlint but does not appear in the changelog.

Two practical consequences:

  • Squash titles matter. A squash merge keeps only the pull request title as the commit, so the title carries the type and the !.
  • Dropping a supported Node line is a major bump (REL-05). It needs a ! and a changelog line, not a quiet edit to engines.
  • A public API break must be declared. The api-semver job in ci.yml fails a pull request whose etc/esi.ts.api.md lost or changed a line unless one of its commits is type!: or has a BREAKING CHANGE: footer. If the change breaks no consumer, add an API-Compatible: <why> trailer instead. See GATE-03 in QUALITY-GATES.md. Squash merging is allowed: when a declared break spans several commits, the gate also requires the pull request title to be type!:, because that title becomes the squash commit. Edit the title and re-run the failed jobs; no new push is needed.

The changelog ​

CHANGELOG.md follows Keep a Changelog and Semantic Versioning.

  • Work in progress is recorded under ## [Unreleased] using the sections Added, Changed, Deprecated, Removed, Fixed, Security, and Breaking Changes where needed.
  • A release turns the unreleased block into ## [X.Y.Z] - YYYY-MM-DD.
  • validate-release fails if CHANGELOG.md has no heading starting ## [X.Y.Z] for the version in package.json. Both the hand-written heading and release-please's linked heading satisfy that check.
  • Entries describe the consumer-visible change and name the public symbol, in the voice of the existing entries.
  • A change merged with a merge commit is listed once. GitHub's merge commit repeats the pull request's title in its body, and release-please reads that as a second commit, so such a change used to appear twice (#377). After release-please updates the release pull request, release-please.yml runs npm run release:dedupe-changelog. It drops a merge commit's line from the branch's CHANGELOG.md and from the pull request body (which becomes the GitHub release) only when a commit on the merged branch has exactly the same subject and that commit's own line, with its closes links, is in the same notes. A pull request title that summarises differently named commits keeps its line, and so does a merge whose branch commits release-please left out, so nothing that shipped disappears. Runs of the workflow take turns (concurrency: release-please), so two cannot rewrite the release pull request at once.

Bumped but not published (REL-04) ​

If a version number is written to package.json and the changelog but the package is never published, the changelog records it as unreleased rather than letting the number disappear. Mark the heading, for example ## [9.8.0] - 2026-09-10 (not published), and fold its entries into the next published version's notes by reference.

The reverse also applies. If versions were published without a changelog entry, backfill the entry once from the tag's commit range (git log vA..vB --oneline) so a reader can see every number that exists on npm.

The changelog meets this rule from 7.4.0 through 9.9.0; see Known state for the numbers outside that range.


What gates a publish (REL-02) ​

validate-release runs on Node 22 against the tagged commit. Every step blocks the publish.

StepCommandBlocks
Tag matches versionpackage.json version equals the tag, then npm run validate:versions●
Lintnpm run lint●
Formattingnpm run format:check●
Dead codenpx knip●
Dependency auditnpm run audit:check (allowlist with expiry, see SECURITY.md)●
Changelog entry for versiongrep "## [X.Y.Z]" CHANGELOG.md●
Buildnpm run build●
Schema driftnpm run schema:drift:ci●
Generated types freshnpm run generate:types, then git diff --exit-code on src/types/generated/ and src/core/endpoints/esi-*.generated.ts●
Generated operations freshnpm run spec:generate:check●
Auth/scope alignmentnpm run validate:auth-scopes (DES-04, both directions)●
Endpoint definitionsnpm run validate:esi (ARCH-02)●
EARS spec auditnpm run spec:audit, then npm run validate:spec-consistency●
Test suitenpm run test:all (unit + BDD, BDD, integration, fuzz, tsd)●
Recorded payload replaynpm run contract:replay●
Fault cataloguenpm run faults -- --ci●
Live contract testsnpm run contract:live; a 503 from ESI is a warning, as on pull requests●
API reference buildsnpm run docs●

The shrink-only ratchets in spec:audit, contract:replay and the fault catalogue compare with the previous release tag (SPEC_AUDIT_BASE_REF, CONTRACT_BASE_REF and FAULTS_BASE_REF set to git describe --tags --abbrev=0 HEAD^, on a checkout with full history and tags), so an exception added since the last release fails the release, and a master that has since shrunk a list cannot block releasing an earlier commit. With no earlier tag the job fails rather than falling back to origin/master. Schema drift still uses the tag itself as its base. Not in the release gate, although they gate pull requests: the API surface diff and the SemVer gate, which need a base branch, and the consumer contract, which release.yml runs as its own job on the packed tarball.

Run the same checks locally before tagging:

bash
npm run check:all      # lint, format, build, coverage, knip, validate:esi, validate:spec, validate:versions, spec:audit
npm run audit:check
npm run schema:drift:ci
npm run test:all

One tarball, everywhere ​

create-assets runs npm pack once. That tarball is what the consumer matrix installs, what cosign signs, what goes on the GitHub Release, and — since esi-23g.42 — what both publish-npm and publish-github upload, rather than each rebuilding from a fresh checkout.

Both publish jobs re-verify checksums.txt before uploading. If the bytes changed between packing and publishing, the artefact cosign signed and the artefact npm serves are already different, and the run stops rather than shipping the discrepancy.

That is what makes the canary's signatures check meaningful: the provenance attestation, the cosign bundle and the npm tarball all describe the same bytes.

What is published where ​

DestinationArtefactIntegrity
npm registry (registry.npmjs.org)@lgriffin/esi.ts tarball, dist/ + README, LICENSE, NOTICE, CHANGELOGnpm provenance (SLSA v1 attestation)
GitHub Packages (npm.pkg.github.com)Same packagePublished with --provenance
GitHub release assetslgriffin-esi.ts-X.Y.Z.tgz, lgriffin-esi.ts-X.Y.Z.cdx.json (SBOM), docs.tar.gz, README.md, LICENSE, NOTICEchecksums.txt (SHA-256); a .sigstore.json bundle per archive and SBOM from keyless cosign; lgriffin-esi.ts-X.Y.Z.intoto.jsonl, the SLSA provenance for all three
GitHub PagesThe documentation site, with the TypeDoc API reference under /api/—

The published file list is the files field in package.json. publishConfig.access is public.

The post-publish canary (tier P) ​

Everything above the publish step checks what CI built. post-publish-canary.yml checks what the registry serves, which is not always the same artefact: publish-npm uploads the tarball the consumer matrix tested, but the registry can still serve something else, and a broken exports map is invisible until somebody installs it.

release.yml dispatches it once publish-npm has succeeded and the assets job has finished. It installs the version into an empty directory with nothing from this repository on disk, and establishes four things:

CheckWhat it proves
registrynpm serves that exact version, and its tarball resolves
signaturesnpm audit signatures verifies the registry signature and the provenance attestation
subpathsevery documented sub-path loads under both require and import
liveone real call to public ESI returns data

A check that did not report, or was skipped, counts as a failure — "we did not look" must not read the same as "we looked and it was fine". A failure opens one issue per version, labelled release-verification, and re-running comments on it rather than opening another.

Run it by hand against any published version:

bash
npm run release:canary -- --version 10.0.0          # from a checkout
gh workflow run post-publish-canary.yml -f version=10.0.0

Its own signal is a version that predates a sub-path. 9.0.0 has no ./sde, so the canary fails on it with ERR_PACKAGE_PATH_NOT_EXPORTED, which is the shape a broken exports map takes; 10.0.0 verifies all four checks. That is the fire drill the plan asked for, without publishing a deliberately broken pre-release.

If the canary fails: deprecate and fix forward ​

A published version cannot be replaced or re-uploaded. npm allows unpublish only within 72 hours and only when nothing depends on it, and using it breaks anyone who already installed. So:

  1. Deprecate it, naming the problem and the version to use instead. The version stays installable for anyone already pinned to it, and everybody else gets a warning on install:

    bash
    npm deprecate '@lgriffin/esi.ts@X.Y.Z' 'Broken exports map; use X.Y.Z+1'
  2. Fix forward. A patch release, or a major if the fix changes the public contract — the fact that the broken version shipped does not change how guides/SEMVER.md classifies the fix.

  3. Re-run the canary against the new version and close the release-verification issue with the passing run linked.

  4. Write down what the gates missed as an issue against the runway epic. A canary failure means every tier before it passed on something consumers could not use, and that gap is the more useful finding.


Verifying a release ​

npm provenance. In a project that depends on the package:

bash
npm audit signatures
npm view @lgriffin/esi.ts@X.Y.Z dist.attestations

The attestation links the tarball to the release.yml run and commit that built it.

Checksums. Download the assets from the GitHub release, then:

bash
sha256sum -c checksums.txt

cosign signatures. Signing is keyless, so the identity to check is the workflow, not a key:

bash
cosign verify-blob lgriffin-esi.ts-X.Y.Z.tgz \
  --bundle lgriffin-esi.ts-X.Y.Z.tgz.sigstore.json \
  --certificate-identity "https://github.com/lgriffin/ESI.ts/.github/workflows/release.yml@refs/tags/vX.Y.Z" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Releases from 10.0.0 on carry a Sigstore bundle (.sigstore.json) per asset. Earlier releases carried a separate .sig and .pem, verified with --signature and --certificate instead of --bundle.

Repeat with docs.tar.gz for the documentation archive.

Build provenance. From the first release after this change, each release carries lgriffin-esi.ts-X.Y.Z.intoto.jsonl: one SLSA provenance attestation naming the tarball, the SBOM and docs.tar.gz by SHA-256, signed through Sigstore by release.yml and also recorded on the repository. With the GitHub CLI, either form verifies:

bash
gh attestation verify lgriffin-esi.ts-X.Y.Z.tgz --repo lgriffin/ESI.ts
gh attestation verify lgriffin-esi.ts-X.Y.Z.tgz --repo lgriffin/ESI.ts \
  --bundle lgriffin-esi.ts-X.Y.Z.intoto.jsonl \
  --signer-workflow lgriffin/ESI.ts/.github/workflows/release.yml

sign-and-publish-assets runs the second command on all three assets before it uploads anything, so a release never carries a provenance file that does not verify. The file is also what OpenSSF Scorecard's Signed-Releases check looks for.

SBOM (SEC-06). From the first release after this change, each release carries lgriffin-esi.ts-X.Y.Z.cdx.json, a CycloneDX 1.5 JSON bill of materials for that tarball, listed in checksums.txt and signed like the tarball:

bash
cosign verify-blob lgriffin-esi.ts-X.Y.Z.cdx.json \
  --bundle lgriffin-esi.ts-X.Y.Z.cdx.json.sigstore.json \
  --certificate-identity "https://github.com/lgriffin/ESI.ts/.github/workflows/release.yml@refs/tags/vX.Y.Z" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

jq -r '.metadata.component.purl, (.components[] | "\(.name)@\(.version)")' lgriffin-esi.ts-X.Y.Z.cdx.json

What it describes: the runtime dependency tree of the tarball's own package.json (dependencies and everything they pull in), at the versions in this repository's package-lock.json — the ones the release was tested with. A consumer's install resolves the same ranges and can pick newer patches, so the SBOM states what was shipped and tested, not what every install will contain. Dev dependencies are not in it, and neither are the optional peers (js-yaml, adm-zip), which a default install does not fetch. The build bundles no third-party package, so the dependency tree is the whole inventory.

npm run release:sbom -- --tarball <file> generates it. scripts/release/release-sbom-core.ts fails the release if the document is not CycloneDX, does not name @lgriffin/esi.ts at the tagged version with its purl, or leaves out a runtime dependency, and explains why it does not use npm sbom --omit dev on the checkout (that drops zod and part of pino's tree, because dev tools share them). tests/tdd/release-sbom/ runs the generator against the repository on every npm test.


Version strings (REL-03) ​

LocationWritten byChecked by
package.jsonrelease-pleasenpm run validate:versions
package-lock.jsonrelease-please (node release type)npm ci in CI
.release-please-manifest.jsonrelease-please—
src/core/constants.ts (PACKAGE_VERSION, sent in the User-Agent)release-please extra-filesnpm run validate:versions
README release line (metric:version)release-please extra-filesnpm run validate:versions
etc/doc-metrics.json (version)release-please extra-filesnpm run validate:versions
docs-site version menuread from package.json at buildnpm run validate:versions -- --site

scripts/package/validate-versions.ts compares package.json with PACKAGE_VERSION, checks that the docs-site selector is still read from package.json, and with --site checks the built site; it exits non-zero on a mismatch. It runs in ci.yml static-analysis, release.yml validate-release and npm run check:all, and with --site in the documentation job and docs-site.yml. It also runs the DOC-04 metrics check, which compares the README's release line and every other marked count with etc/doc-metrics.json and the source.


Support window ​

The supported major versions are listed once, in the root SECURITY.md. Update that table in the same pull request as a major release.

The package declares "engines": { "node": ">=22.12.0" } (REL-05) and supports TypeScript 5.4 or later for consumers (OLDEST_TYPESCRIPT in scripts/quality/consumer-contract-core.ts; zod 4's declarations need NoInfer, added in 5.4). The consumer contract checks both floors on every pull request and release: its oldest-TypeScript row runs on Node 22.12 exactly, since a bare 22 would install the newest 22.x. Pull requests run unit tests on Node 22 and 24. Release jobs and the rest of CI run on Node 22, which is also the version in .nvmrc. Node 22 is the floor from 11.0.0 because Node 18 and 20 are end of life, and 22.12 within it because it is the first 22.x to load an ES module through require() without a flag; 10.x is the line for Node 18 and 20.


Known state ​

Recorded 2026-09-16. Each item contradicts a requirement above and belongs in a bead, not a softened sentence.

  • npm publishing still uses a long-lived token. Both publish jobs now upload the tarball create-assets packed and cosign signed (esi-23g.42), so the registries and the GitHub Release carry the same bytes. publish-npm still authenticates with the NPM_TOKEN secret; moving it to npm trusted publishing needs the maintainer to configure the trusted publisher on npmjs.com first (SECURITY.md, ROADMAP Phase 6 item 3).
  • Recent GitHub releases are missing their signed assets. v10.2.0 carries the tarball, SBOM, docs archive, their cosign bundles and checksums.txt; v10.2.2 and v10.2.3 carry no assets at all (checked 2026-09-27), so sign-and-publish-assets did not run or did not finish for them. OpenSSF Scorecard's Signed-Releases check reads the last five releases, so it stays low until the releases in that window are signed; no release before this change has a .intoto.jsonl.
  • release-please needed repository permission to open its pull request. It computed the version but failed with "GitHub Actions is not permitted to create or approve pull requests" until that repository setting was enabled (2026-09-16). Releases 9.8.0 and 9.9.0 were hand-written chore: release X.Y.Z commits.
  • Releases created with the workflow's GITHUB_TOKEN do not trigger other workflows. 10.1.0 and 10.1.1 were tagged and released on GitHub but never published to npm, because nothing dispatched release.yml. release-please.yml now uses the release app token when it is configured, and dispatches release.yml and the canary itself when it is not.
  • v9.7.0 has no signed assets. Its sign-and-publish-assets job failed because cosign 3 requires --bundle for sign-blob; the job now writes a Sigstore bundle per asset. npm and GitHub Packages publishing succeeded for that release.
  • The changelog does not yet mark every unpublished number (REL-04). Every number written to package.json from 7.4.0 through 9.9.0 now has an entry: 8.0.0, 9.4.0 and 9.6.0 were backfilled from their tag ranges, and 9.1.1 to 9.2.0, 9.5.0 to 9.5.2, 9.6.1, 9.6.2, 9.8.0, 9.8.1 and 9.9.0 are headed (not published) with the release their changes shipped in (#275). Outside that range, 7.0.0 to 7.3.0 and earlier numbers missing from npm, and 10.1.0, 10.1.1, 10.2.2 and 10.2.3 (tagged, not on npm, checked 2026-09-27), still have ordinary headings.

Released under the GPL-3.0-or-later license.