---
name: release
description: Version and publish @dunx/* packages to npm. Use when cutting a release, when a publish failed or a package is missing from npm, when a package needs its first npm version, or when touching scripts/version.ts, the pinned npm version, or the publish job in ci.yml.
---

# /release

Releases are **lockstep**: every `@dunx/*` package shares one version and ships
together, even the ones a release did not touch. Change detection decides _whether_
to release, never _what_. The reason is a correctness one - a published range names a
concrete version of `@dunx/core`, so independent versions would let an app end up
with two copies of it, and in this container a token _is_ a class object. Full
reasoning: architecture/packaging.md, "Versioning is lockstep".

**Do not make versions independent again** without also solving the duplicate-core
problem - see that section for the two alternatives and why each was rejected.

CI runs `bun run version` on every push to `main`, and it **publishes nothing unless
the head commit is a release commit**. Ordinary merges run the checks and deploy the
docs. This skill is for cutting a release deliberately, and for the failure modes.

## Cutting a release

1. `/ci-check` - build, lint, typecheck, test. A failed build publishes nothing,
   but a passing build with broken `dist/` publishes broken.
2. Run the `publish-guard` agent over the changed packages.
3. `bun run version:dry-run` - on a non-release commit this reports that it would
   skip. It prints the computed bump, the commits it read, and the changed packages.
4. Commit the release trigger and push to `main`:

   | Subject                                   | Bump                                             |
   | ----------------------------------------- | ------------------------------------------------ |
   | `release: <summary>`                      | derived from every commit since the last release |
   | `release(major\|minor\|patch): <summary>` | stated outright                                  |
   | `release!: <summary>`                     | major                                            |

   The trigger is matched on the **subject only**, so a body quoting the word does
   not publish.

The bump and the changed-package detection both span every commit back to the
previous `chore(release): bump version to ...` marker. That marker is
`RELEASE_COMMIT_PREFIX` in `scripts/bump.ts`, written by `pushVersionCommit` in
`scripts/version.ts` - if you change one, change both, or every range becomes "all
of history". CI's `fetch-depth: 0` is load-bearing for the same reason: a shallow
checkout cannot see the marker and silently under-reports the bump to a patch.

Force every package to publish regardless of computed bumps by putting
`[force-publish]` in the commit message. That path bypasses the release gate, and
writes no changelog entry: it has no range to describe.

## The changelog

Each release prepends a section to the root `CHANGELOG.md`, from the same commit
range the bump was derived from. `scripts/changelog.ts` owns the format in both
directions - `renderRelease` writes a section, `parseChangelog` reads them back -
and `internal/docs` renders them at `#/releases`. Nothing is hand-written.

- The `release:` commit's own prose becomes the section's summary, so that subject
  is the release note. A release whose whole range is that one commit still gets a
  section.
- Commits group by conventional type; an unrecognised one lands under "Other
  changes" rather than being dropped.
- The generator escapes `<` and replaces em and en dashes, so a subject written
  before those rules existed cannot fail `no-em-dash.test.ts` or lose a type
  parameter to raw HTML.
- The docs site is built **after** the release step in `ci.yml`, so the deployed
  artifact carries the section this release just wrote. The release commit is
  `[skip ci]`, so building it earlier would leave the page a release behind.
- Re-running a failed release does not duplicate a section: a version already
  present is left alone.

## The tag and the GitHub release

After the version commit is pushed, `scripts/github-release.ts` tags `v<version>`
and creates the GitHub release. Before this existed, `git tag -l` was empty across
every release and the repo's Releases page held nothing.

- The notes are **read back** from `CHANGELOG.md` by `parseChangelog`, not
  re-rendered from the commit range. A second renderer is how the tag, the file and
  the site would come to disagree about what shipped.
- The body ends with a link to `#/releases/<version>`, which `internal/docs` serves
  as a page per release. The URL is derived from `owner/repo`, so a fork points at
  its own Pages site.
- The API call is `fetch` against the REST API, not the `gh` CLI: nothing else in
  `scripts/` needs `gh`, and `fetch` is native. It needs `contents: write`, which
  `ci.yml`'s publishing job already had.
- **Neither step throws and neither fails the job.** By the time they run the
  packages are on npm, so a failure here would make a finished publish look broken.
  Both are idempotent: an existing tag or release is left alone, so a rerun after a
  partial failure is safe.
- A missing `GITHUB_TOKEN` skips the release and says so, which is what a local
  `bun run version` does.
- `[force-publish]` gets no tag and no release. It bypasses the release gate and
  writes no changelog section, so there is no range to describe.

## Constraints that are load-bearing

- **Trusted publishing (OIDC), no `NPM_TOKEN`.** Each package's trusted publisher
  on npmjs.com is pinned to the workflow **filename** `ci.yml`. Renaming that file
  silently breaks publishing for every package. `ci.yml` is the only workflow
  permitted to publish.
- **npm is the one sanctioned non-bun tool**, only inside `scripts/publish.ts`:
  `bun publish` cannot authenticate via OIDC (oven-sh/bun#15601). It runs as
  `bunx npm@<pinned>` - the `NPM` constant, currently `bunx npm@11.10.1`. Bun
  executes npm on its own runtime, so CI needs no `setup-node`. The pin must stay
  **>= 11.5.1**; `ubuntu-latest` still ships npm 10.x, so the pin is doing real
  work. Bump the constant to upgrade.
- **`workspace:` ranges.** `npm publish` does not expand them, so the publish path
  rewrites them to concrete ranges around the publish and restores `package.json`
  afterwards. The policy is one function, `resolveWorkspaceRange` in
  `scripts/workspace-ranges.ts`, shared by `publish.ts` and `first-publish.ts`
  because a second copy of it is how the two would drift: **`workspace:*` publishes
  as `^<version>`**, not as an exact pin. Every internal range is a
  `peerDependency`, and an exact peer accepts one version and nothing else, so a
  consumer whose core resolved one patch ahead gets an `ERESOLVE` from npm or a
  nested second copy of core. The caret's pre-1.0 limit (`^0.2.0` excludes `0.3.0`)
  is why versioning stays lockstep, not a reason to go back to exact - exact
  excludes `0.2.1` as well. If a publish dies mid-run, check `git diff` for a package
  manifest left with concrete ranges where `workspace:*` belongs.
- **`--provenance` only under `GITHUB_ACTIONS`.** It errors anywhere else, which
  would break a manual publish.

## Failure modes

| Symptom                                         | Cause                                                                                                                                                                                                                                                |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| First publish of a new package fails in CI      | A package with no versions on npm has no trusted-publisher settings page yet. Run `bunx npm@11.10.1 login` then `bun scripts/first-publish.ts` - **not** a bare `npm publish`, which ships `workspace:*` verbatim and breaks every consumer install. |
| `dist-tag`, `deprecate`, `access` fail in CI    | Only `npm publish` can use the OIDC credential. Run these locally against a personal npm login.                                                                                                                                                      |
| Published tarball contains `src/` or test files | Missing or wrong `files` in the manifest. `publish-guard` catches this.                                                                                                                                                                              |
| Consumer on `node16`/`nodenext` cannot resolve  | An extensionless relative specifier reached the emitted `.d.ts`. Every relative import in source needs a `.js` extension.                                                                                                                            |
| CI publishes nothing and reports success        | No version changed. Expected - check the dry-run output.                                                                                                                                                                                             |

## Never

Do not add `NPM_TOKEN`, a second publishing workflow, or `npm` calls outside
`scripts/publish.ts`. Do not hand-edit `CHANGELOG.md`: the next release rewrites
the file around whatever is there, and the site renders what the script wrote.
