♻️ Tighten the development/ prose

Same decisions, rationale, rejected alternatives and known issues, said with
less padding: ~5,530 -> ~4,730 words (-15%). Every fact from the first draft is
kept; only the wording, duplicated lead-ins and restated context are cut.
This commit is contained in:
tmu committed 2026-09-15 13:56:49 +00:00
1 parent 7ea84b66fa
commit 74c39e1346
7 files changed
+341 -409

No files matched your search

+53 -58
View File
@@ -1,31 +1,28 @@
# Publishing
Publishing is CI-only by policy. Local `npm publish` is not supported. The
maintainer triggers releases from `main`; the mechanics live in
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), and the job graph lives
in [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml).
[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, 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.
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
@@ -39,8 +36,8 @@ Releases are cut by CI from `main`; there is no local `npm publish` and no
- 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`.
needs a fresh build; they are not source-correctness checks and do not belong
in `check`.
## The release commit is assembled from two tools
@@ -53,32 +50,31 @@ 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.
`## [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
we want hand-written notes (see
the notes are hand-written (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.
- `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
@@ -86,38 +82,37 @@ release commit.
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`.
`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` is the derived copy — one direction, no drift.
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 without removing the copy.
the lookup, not remove the copy.
#### Rejected
- A `## Version` line in the README: it would make `release.sh` responsible for
a third file.
- 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
that the link does not keep in sync.
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.
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 with an empty
body. A leading `v` is tolerated so both `v1.2.3` and `1.2.3` match the
`## [1.2.3]` heading.
- 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
@@ -129,11 +124,11 @@ The Gitea release page uses the run's automatic token (`github.token`).
#### 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.
- 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.