Release Guide
August 30, 2026 · View on GitHub
This document describes the dsh-llm-fallbacks release process: npm authentication (pure OIDC Trusted Publishing), the release SOP (trigger → review → merge), changelog fragment format, the release checklist, and rollback / re-run instructions.
Release model (PR-driven)
Releases are two-step, not a one-click black box:
- Release prep (manual trigger) → generates a reviewable
release vX.Y.ZPR (version bump + English changelog + fragment archive). - Merging that PR is what publishes → the
Releaseworkflow automatically publishes + tags + creates the GitHub Release.
The repository does not declare NPM_TOKEN and no longer carries any token secret: publishing authenticates to npm purely via Trusted Publishing (OIDC id-token + npm publish --provenance, tokenless). The first-publish bootstrap token mode (a one-time NODE_AUTH_TOKEN) was retired on 2026-08-14 after the npm-side Trusted Publisher was configured — see the "npm authentication" section for history. On the GitHub side only the built-in GITHUB_TOKEN is used. There is no push:tags auto-publish path — a manual git tag && git push --tags does not publish; the only publishing entry point is merging a release vX.Y.Z PR.
Related workflows:
| Workflow | File | Trigger |
|---|---|---|
| CI | .github/workflows/ci.yml | PR / push to main / manual |
| Release prep | .github/workflows/release-prep.yml | manual (Actions → Release prep → Run workflow) |
| Release | .github/workflows/release.yml | merging a PR titled release v* |
npm authentication: Trusted Publishing (OIDC, pure tokenless)
npm Trusted Publishing (OIDC) can only be configured for an existing package — there is no pre-registration path. History: the first release (0.1.0-alpha.2) used a one-time bootstrap Granular Access Token (NODE_AUTH_TOKEN secret); after the first release, the Trusted Publisher was configured in the npm package settings and the token mode was retired (secret deleted, workflow env removed, 2026-08-14). Publishing is now zero-secrets: npm exchanges the GitHub OIDC id-token (from setup-node's registry-url + the workflow's id-token: write) for registry authentication against the npm-side Trusted Publisher entry, and --provenance signs the build source.
The workflow declares no token env at all (
NODE_AUTH_TOKEN/NPM_TOKEN); if the npm-side Trusted Publisher entry is ever removed, publishing fails loudly (ENEEDAUTH) instead of silently falling back to a token.
Configure the Trusted Publisher in the npm package settings (user action, tokenless)
The package only gets a Settings page on npm after the first release succeeds:
- Sign in to npmjs.com → Packages →
dsh-llm-fallbacks→ Settings → Trusted publishing. - Select your publisher → choose GitHub Actions.
- Fill in the fields:
- Organization or user (required):
omdsh-dev(GitHub org/user); - Repository (required):
dsh-llm-fallbacks; - Workflow filename (required):
release.yml— the filename only, no path, and it must include the.yml/.yamlextension; the workflow must exist under the repository's.github/workflows/; - Environment name (optional): fill in only if the publish job uses GitHub environment protection;
- Allowed actions (required): check
npm publish(this repository publishes directly withnpm publish --provenance, no staged publish).
- Organization or user (required):
- Save. This configuration creates no token — npm accepts OIDC publishing from that workflow (tokenless by design).
A package can only have one trusted publisher configuration at a time; it can be edited/deleted at any time (deleting returns to token authentication).
Notes
- npm provenance requires the package to be public (the publish command in the workflow already uses
--access public). - No extra configuration on the GitHub side: the OIDC token (
permissions: id-token: write) is issued by Actions automatically;contents: write/pull-requests: writeare already declared in the workflow. - No token secrets exist (the bootstrap
NODE_AUTH_TOKENwas deleted 2026-08-14); publishing fails loudly if the npm-side Trusted Publisher entry is missing. - The first release used explicit
0.1.0-alpha.2(see the SOP below);--patchauto is left for later releases.
Release SOP
1. Write a changelog fragment
For every user-visible change, add a fragment under .changes/unreleased/ (format in the next section; one file, one category, English bullets — non-bullet lines such as <!-- CN --> are rendered into the CHANGELOG verbatim).
At least one fragment is mandatory: release.yml fails outright when the changelog extraction is empty (an empty version section cannot be published). Before the first release in particular, verify that .changes/unreleased/ is non-empty (this repository's first-release fragments were committed together with the features).
2. Trigger Release prep
Repository → Actions → Release prep in the sidebar → Run workflow:
- Version input:
- First release: fill in
0.1.0-alpha.2explicitly (validate the pipeline first; the stable version is left for the next iteration). - Later: leave blank = auto bump (
--patch) — when the current version is a prerelease with a numeric tail (X.Y.Z-pre.N), only N is incremented (0.1.0-alpha.1→0.1.0-alpha.2, staying on the prerelease line); without a prerelease, patch+1 (0.1.0→0.1.1); a non-numeric prerelease tail errors out — use an explicit version instead.
- First release: fill in
The workflow then runs, in order:
- Rejects already-released versions: with an explicit version and an existing git tag
v<v>→ errors and exits (a released version cannot re-run prep). pnpm release:prepare: bumps thepackage.jsonversion, assembles the.changes/unreleased/fragments into a## [<version>] - <date>section inserted intoCHANGELOG.md(below## [Unreleased]), and archives the fragments to.changes/archive/<version>/.- The date is UTC: the script uses
new Date().toISOString().slice(0, 10), so the section date is fixed to the UTC day; a local prep late at night in a positive timezone may display "yesterday" — UTC is authoritative.
- The date is UTC: the script uses
pnpm release:validate -- v<v>: package.json version matches the tag + the tag does not already exist (belt and suspenders).pnpm buildsmoke test.- Commits
chore(release): prepare v<v>to therelease/v<v>branch and pushes (force-with-lease). - Opens the PR
release v<v>(basemain, labelrelease); updates it if an open PR already exists, otherwise creates a new PR (including when a closed PR exists for the same head branch — closed release PRs are never reopened).
3. Review the release PR
Before merging, verify:
-
package.jsonversionis the expected version; -
CHANGELOG.mdhas a## [<version>] - <date>section under## [Unreleased]with correct, English fragment bullets; - the
.changes/unreleased/fragments are archived to.changes/archive/<version>/; - the diff contains only version / changelog / archive changes (plus any direct commits on the branch; with none it should be those three blocks).
4. Merge → automatic publish
After the merge, release.yml triggers (pull_request: closed + merged == true + title with the release v prefix):
- Checks out the merge commit →
release:validate→pnpm build; npm publish --provenance --access public --tag <dist-tag>— the dist-tag is derived from the version: stableX.Y.Zpublishes underlatest; prereleaseX.Y.Z-<channel>.Npublishes under<channel>(e.g.0.4.0-alpha.1→alpha,1.0.0-rc.1→rc), so a prerelease never stealslatestfrom the newest stable (an explicit--tagis also required by npm ≥ 11, bundled with Node 24, when publishing a prerelease). Thenexttag is not used. npm authentication is pure OIDC (Trusted Publishing configured on the npm side; see the "npm authentication" section);- Tags
v<v>and pushes (skipped if it exists); - Creates the GitHub Release from the changelog section — always a regular release (no Pre-release marker, user decision 2026-08-14); the channel signal is the version-derived npm dist-tag (
latestfor stable, the prerelease channel such asalphafor prereleases), the GitHub Release is the visible record.
Changelog fragment format
Each file under .changes/unreleased/ is one fragment (.changes/unreleased/README.md is the explainer file and .gitkeep is a placeholder — both are ignored):
- Filename: any slug ending in
.md(e.g.add-foo.md). - Frontmatter (optional): the
category:key groups the fragment's bullets under a### <category>subheading in the changelog (defaultChanged). - Body: one or more English bullet lines (
-prefix), rendered verbatim.
---
category: Added
---
- Describe the change in one concise English bullet.
- A second bullet if needed.
Each fragment focuses on one user-visible change.
Release checklist
-
pnpm testall green (460 test baseline across 23 files, vitest run) -
pnpm buildall green (tsc + tsdown + build-client + verify-dist) -
actionlint .github/workflows/*.ymlclean (ci + release-prep + release) -
pnpm release:validate -- v<version>passes (local preview before releasing) - version matches the CHANGELOG section; fragments archived
- npm authentication ready: Trusted Publisher bound to
release.ymlin the npm package settings; no token secrets exist (see the "npm authentication" section)
Rollback / re-run
- PR stage (not merged): wrong version or content → simply close the PR, or re-run Release prep. Re-running is idempotent: re-running with the same version regenerates the
release/v<v>branch (force-with-lease push) and handles the PR — updates it if an open PR exists; creates a new PR if none is open (a previously closed release PR stays closed and is never reopened). - Failed mid-publish after merge: if
npm publishsucceeded but the tag / GitHub Release steps failed — do not re-run the Release workflow directly:npm publishwould fail because the version already exists on the registry. Fixes:- manually add the tag and Release:
git tag -a -m "release v<v>" v<v> && git push origin v<v>, then create the GitHub Release manually from the changelog section; or - fix-forward: go straight to the next version (see below).
- manually add the tag and Release:
- Published but wrong content: npm does not allow re-publishing the same version;
npm unpublishis only possible within 72 hours of publishing and without dependents (policy-limited). fix-forward is recommended: fix the content, bump to the next version (on the prerelease line, e.g.0.1.0-alpha.3), and re-run the SOP. The GitHub Release can be edited/deleted at any time; the tag can be deleted once you confirm no one depends on it (git push origin :refs/tags/v<v>). - Semantics: the two-step model (prep PR + merge) is itself the rollback gate — if something is wrong, just don't merge and nothing happens.
Related files
| File | Purpose |
|---|---|
.github/workflows/release-prep.yml | manual entry: bump + changelog + open/update the release PR |
.github/workflows/release.yml | automatic publish + tag + GitHub Release after merge |
scripts/prepare-release.ts | version resolution (explicit / --patch auto), fragment assembly, bump, archive |
scripts/validate-release-version.ts | version consistency + tag-not-exists validation |
CHANGELOG.md | English changelog (## [Unreleased] + version sections) |
.changes/unreleased/ | pending fragments |
.changes/archive/<version>/ | consumed fragment archive |