--- 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: ` | derived from every commit since the last release | | `release(major\|minor\|patch): ` | stated outright | | `release!: ` | 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` 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/`, 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@` - 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 `^`**, 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.