# Publishing Publishing is CI-only: local `npm publish` is not supported, and the maintainer triggers releases from `main`. The mechanics are in [scripts/release.sh](../scripts/release.sh) and [scripts/release-notes.sh](../scripts/release-notes.sh); the job graph is [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml). ## Release steps 1. All intended changes are merged to `main` and passing CI. 2. The maintainer runs `npm run create:release`. VS Code opens [CHANGELOG.md](../CHANGELOG.md) to finalize the `[Unreleased]` notes; because pubv refuses a dirty tree, the edit is committed first (then folded into the release commit), and pubv suggests a version from those notes to confirm or edit. 3. `scripts/release.sh` creates one release commit (graduated changelog + `package.json` bump, amended together), tags it, and pushes. 4. CI fires on both pushes. `publish` runs on the tag (build + publish checks + release page + `npm publish`), while `release-gate` recognizes the release commit and skips `build`/`maintain`: the tag verifies the identical SHA, so no work is duplicated. The publish checks pass before the artifact is published, and the release page is created from the matching Keep-a-Changelog section _before_ `npm publish`, so a broken page fails CI without consuming a version and `npm publish` stays the last step. ## CI-only publishing #### Decision (2026-09) Releases are cut by CI from `main`; there is no local `npm publish` and no `publish:*` script in the `check` chain. #### Why - The tag is the artifact marker: CI verifies the exact commit it points at, so a local publish could ship something the tag does not describe. - `publish:publint` / `publish:attw` validate the _publishable artifact_, which needs a fresh build; they are not source-correctness checks and do not belong in `check`. ## The release commit is assembled from two tools #### Decision (2026-09) `create:release` uses `pubv` for the changelog graduation and bump heuristic, then `npm version` for the `package.json` + lockfile bump, amended into a single release commit. #### Why - We want hand-written Keep-a-Changelog notes, an `[Unreleased]` -> `## [x.y.z] - DATE` graduation, and a tag on the exact commit that gets published โ€” and no single tool did both the graduation and the `package.json` bump. - Split by strength: `pubv` (tiny, changelog-driven) owns preflight, the interactive major/minor/patch heuristic, and graduating and committing `CHANGELOG.md` (no tag, no push); `npm version` syncs `package.json` + the lockfile; `--amend` folds them into one commit; the tag is created _after_ the amend so it is never orphaned. - The notes are finalized _before_ pubv because its bump heuristic reads the `[Unreleased]` body โ€” editing afterwards would inform the changelog only, not the version. The staging commit that satisfies pubv's clean-tree check is folded back into the single release commit. #### Rejected - The conventional-commits family: the history is gitmoji, not Conventional, and the notes are hand-written (see [workflow.md ยง Commit messages](./workflow.md#commit-messages)). - `changesets` / `rtk`: config plus a heavier flow that fights the CI-only publish. - `knope` / `kacl` / `bestikk`: changelog-only (no `package.json` bump) and 5-year / 2-year / brand-new maintenance. - `pubv` alone: it never writes `package.json`. - `versions` (silverwind): good Gitea support, but pairing it with a hand-rolled promote became a ~180-line script, which this ~30-line version replaces. ## The version has one source of truth #### Decision (2026-09) The version is derived from the graduated `## [x.y.z]` heading in [CHANGELOG.md](../CHANGELOG.md) and written to `package.json` + `package-lock.json` by `npm version`. The README has no version line and does not link `package.json`. #### Why - `release.sh` reads the version from the changelog, so the changelog is the input and `package.json` the derived copy โ€” one direction, no drift. - npm and Gitea render the version from package metadata, so a README copy would be a third place to update for no reader benefit, and a link would only move the lookup, not remove the copy. #### Rejected - A `## Version` line in the README: it makes `release.sh` responsible for a third file. - Linking `package.json` from the README: it invites a hand-maintained duplicate the link does not keep in sync. ## Release notes are extracted from the changelog #### Decision (2026-09) `scripts/release-notes.sh ` prints the Keep-a-Changelog section for the tag and exits non-zero when it is missing. #### Why - CI reuses the release body from the same file that drove the version, so the page and the changelog cannot disagree. - Failing on a missing section means a release can never publish an empty body. A leading `v` is tolerated so both `v1.2.3` and `1.2.3` match `## [1.2.3]`. ## Token gates #### Decision (2026-09) The Gitea release page uses the run's automatic token (`github.token`). `npm publish` is gated on `NPM_TOKEN`, lifted into job-level `env`. A final `always()` step fails the job unless both halves reported `success`. #### Why - The automatic token needs only `contents: write`, so the release page needs no secret gate. - `secrets` is not allowed in a step `if`, so `NPM_TOKEN` must be lifted into job-level `env`; an unset secret then skips the publish instead of attempting an unauthenticated one. - A tag is all-or-nothing: without the `always()` guard a skipped or failed npm half would leave the job silently green. The guard turns it red. Set `NPM_TOKEN` (npm publish rights) under Settings -> Actions -> Secrets.