# Publishing Publishing is CI-only by policy. Local `npm publish` is not supported. The maintainer triggers releases from `main`; the mechanics live in [scripts/release.sh](../scripts/release.sh) and [scripts/release-notes.sh](../scripts/release-notes.sh), and the job graph lives in [.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, any edit is committed first (then folded into the release commit), and pubv's interactive prompt suggests a version from those notes — the maintainer confirms or edits it. 3. `scripts/release.sh` creates a single release commit (graduated changelog + `package.json` bump, amended into one commit), tags it, and pushes everything to Gitea. 4. CI fires on both pushes: the `publish` job runs on the tag (`build` + publish-tier checks + release page + `npm publish`), while the branch run's `release-gate` job recognizes the release commit and skips `build`/`maintain` — the tag verifies the identical SHA, so no work is duplicated. The publish-tier checks must pass before the artifact is published. The `publish` job also creates the Gitea release page from the matching Keep-a-Changelog section; it runs _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, so they 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 that marks the exact commit on `main` that gets published. - No single tool did both the `[Unreleased]` graduation _and_ the `package.json` bump. Splitting 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 pubv's single commit; the tag is created _after_ the amend so it is never orphaned. - The notes are finalized in VS Code _before_ pubv: the `[Unreleased]` body is what pubv's bump heuristic reads, so editing afterwards would inform the changelog only, not the version choice. The staging commit that makes pubv accept the tree is folded back into the single release commit. #### Rejected - The conventional-commits family: the history is gitmoji, not Conventional, and we want hand-written notes (see [workflow.md § Commit messages](./workflow.md#commit-messages)). - `changesets` / `rtk`: config plus a heavier version/publish flow that fights the CI-only publish. - `knope` / `kacl` / `bestikk`: changelog-only — they don't bump `package.json` — and they carry 5-year / 2-year / brand-new maintenance. - `pubv` alone: verified it never writes `package.json`. - `versions` (silverwind): great Gitea support, but pairing it with a hand-rolled promote became a ~180-line script to maintain, which is exactly what 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 carries 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` is 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 without removing the copy. #### Rejected - A `## Version` line in the README: it would make `release.sh` responsible for a third file. - Linking `package.json` from the README: it invites a hand-maintained duplicate that 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 the section 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 with an empty body. A leading `v` is tolerated so both `v1.2.3` and `1.2.3` match the `## [1.2.3]` heading. ## 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 only needs `contents: write`, so no secret gate is needed for the release page. - `secrets` is not an allowed context 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.