📝 Add development/ docs for decisions and known issues

Category files under development/ replace the decision prose that was
scattered through README.md and CONTRIBUTING.md. Each decision is a block with
Decision (YYYY-MM) / Why / Rejected / Known issue; the new library.md records
the public API contract and its limitations. AGENTS.md and backlog.tasks now
point at the new home.
This commit is contained in:
tmu committed 2026-09-15 13:26:59 +00:00
1 parent 97dfe9e7b4
commit dc7ce22fc5
9 files changed
+1049 -5

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
# 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 <tag>` 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.