Publishing infrastructure - Three Gitea workflows: ci.yml (PRs, non-main pushes), publish-snapshot.yml (main -> Gitea under dist-tag @main) and release.yml (tags -> Gitea + npmjs) - Tag-driven releases as <package-dir>-v<version>; the manifest stays the source of truth and release.yml refuses to run if tag and manifest disagree - Every publish is idempotent: each step checks the registry first, so a run that fails on the second registry can simply be re-run - Hard coverage gate (85% branches) shared by CI, the pre-push hook and local runs, since the thresholds live in vitest.config.ts rather than a CI flag - README.PUBLISH.md documents the whole mechanism Toolchain - TypeScript 7 native compiler; drop tsgo and ts-node, use tsx for dev runs - Biome 1.9 -> 2.x, Vitest 1 -> 4, zod 3 -> 4, inquirer 8 -> 14, pnpm 11.17.0 - Replace inquirer-checkbox-plus-prompt, which is peer-capped at inquirer <9, with enquirer's AutoComplete; the CubeSelection contract is unchanged - Stand in for zod 4's removed z.AnyZodObject with a local AnyObjectSchema Repo hygiene - Stop tracking dist/; ignore coverage/, *.tsbuildinfo, .npmrc* and release.json - Drop package-lock.json in favour of pnpm-lock.yaml Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
17 KiB
Publishing
Everything about how the packages in this repository are verified, versioned and shipped. If you only want to cut a release, jump to Cutting a release.
- What ships
- The three workflows
- The verification gate
- Versions and dist-tags
- Snapshots
- Cutting a release
- Changelogs and release notes
- Secrets
- Registry authentication in the workflows
- Installing the packages
- Design decisions
- Checking things locally
- Troubleshooting
- Recovering from a bad publish
What ships
| Directory | Package | Binary |
|---|---|---|
packages/nopy |
@bitstack/nopy |
nopy |
packages/keyman |
@bitstack/keyman |
keyman |
Both are ESM, both declare engines.node >= 22, and both expose a single
executable through bin, so npm install -g puts nopy / keyman on the
PATH. cubes/ is not a package and is never published.
The tarball contents are pinned by files: ["dist", "README.md", "LICENSE"] —
sources and tests are not shipped. publishConfig.access: "public" is what makes
a scoped package publishable to npmjs without an extra flag; the workflows pass
--access public anyway.
Versions and changelogs are maintained by hand. Nothing in CI commits a version bump, opens a release PR, or pushes a tag. A release happens because you tagged a commit whose manifest already carries the version you want.
The three workflows
All three live in .gitea/workflows and run on the
ubuntu-latest runner label.
| Workflow | Trigger | Publishes |
|---|---|---|
ci.yml |
pull requests, non-main pushes |
nothing |
publish-snapshot.yml |
pushes to main |
both packages → Gitea, tag main |
release.yml |
tags matching *-v* |
one package → Gitea and npmjs |
ci.yml explicitly excludes main and all tags (branches-ignore +
tags-ignore). That is not an oversight: both publish workflows carry the full
gate themselves, so excluding them here means no commit is ever verified twice,
while nothing is ever published from an unverified tree.
Concurrency: ci.yml cancels superseded runs for the same ref
(cancel-in-progress: true); the two publish workflows never cancel, because a
half-finished publish is worse than a slow queue.
ci.yml
checkout → pnpm → node → pnpm store cache → install
→ lint:ci → typecheck → test:coverage → coverage summary
→ build → npm pack --dry-run (per package) → upload coverage
The npm pack --dry-run --ignore-scripts step prints the exact file list that
would be published. It is there to catch a files or bin entry pointing at
something the build no longer produces — a failure that would otherwise only
surface after the version is already on a registry and immutable.
Coverage HTML/JSON reports are uploaded as a coverage artifact with a 7-day
retention. Both the artifact upload and the store cache are
continue-on-error: true, so a runner without a cache or artifact server gets
slower CI rather than broken CI.
publish-snapshot.yml
checkout → pnpm → node → cache → install
→ lint:ci → typecheck → test:coverage → coverage summary → build
→ write .npmrc → publish both packages → delete .npmrc
One job, no needs: barrier, so install and build happen exactly once and
nothing has to be passed between jobs as an artifact.
release.yml
checkout → resolve tag → check secrets
→ pnpm → node → cache → install
→ lint:ci → typecheck → test:coverage → build
→ publish to Gitea → publish to npmjs → delete .npmrc
→ create the Gitea release → step summary
Tag resolution and the secret check run before anything is installed or built, so a malformed tag or a missing token fails in seconds instead of after the whole gate.
The verification gate
The same three commands guard every path into a registry:
| Step | Command |
|---|---|
| Lint | pnpm run lint:ci |
| Typecheck | pnpm run typecheck |
| Test + coverage | pnpm run test:coverage |
Coverage is a hard failure below 85 % branches (and 85 % functions, 80 %
lines, 80 % statements). The thresholds live in coverage.thresholds in each
package's vitest.config.ts, not in a CI-only flag, which means
pnpm run test:coverage fails identically on a laptop, in the pre-push hook,
and on the runner. Barrel files and CLI argv wiring are excluded from
measurement; everything with behaviour in it is not.
The pre-push hook installed by simple-git-hooks runs exactly this gate, so a
push that survives locally will not surprise you on the runner. Bypass with
SKIP_SIMPLE_GIT_HOOKS=1 when you must — CI will still catch it.
pnpm run coverage:summary renders the per-package json-summary reports as a
Markdown table. In CI it appends to $GITHUB_STEP_SUMMARY so the numbers show up
on the run page without opening the log. It is reporting only — if: always()
and continue-on-error: true — and can never be the reason a run goes red.
Versions and dist-tags
| Source | Version | Registry | dist-tag |
|---|---|---|---|
push to main |
1.0.0-main.42.g736c012 |
Gitea | main |
tag nopy-v1.2.0 |
1.2.0 |
Gitea, npmjs | latest |
tag nopy-v1.2.0-rc.1 |
1.2.0-rc.1 |
Gitea, npmjs | next |
The rule for the dist-tag is mechanical: a version containing a prerelease part
(anything with a - in it) goes out as next and is marked as a prerelease on
the Gitea release; anything else goes out as latest. There is no way to publish
a prerelease over latest by accident.
Snapshots
Every commit that lands on main publishes both packages to the Gitea registry,
versioned as:
<manifest version>-main.<run number>.g<short sha>
for example 1.0.0-main.42.g736c012. The g prefix keeps the identifier valid
semver even when the abbreviated sha happens to be all digits. The run number is
monotonic, so every push produces a version that has never existed before.
pnpm add @bitstack/nopy@main
Snapshots never reach npmjs and never move latest. The version is written into
the manifest on the runner with npm pkg set immediately before publishing; that
edit is discarded with the workspace and is never committed.
Cutting a release
-
Bump
versioninpackages/<pkg>/package.json. -
Add a changelog entry (see below).
-
Commit, merge to
main, and let the snapshot workflow go green. -
Tag that commit and push the tag:
git tag nopy-v1.2.0 git push origin nopy-v1.2.0
The tag name is <directory>-v<version> — the directory under packages/, not
the npm name. nopy-v1.2.0, not @bitstack/nopy-v1.2.0.
The tag decides which package ships; package.json decides the version.
The workflow re-reads the manifest and refuses to continue if the two disagree:
::error::Tag 'nopy-v1.2.0' asks for 1.2.0, but packages/nopy/package.json
declares 1.1.0. Bump the manifest and re-tag.
An unknown package name fails the same way. Both checks run before install, so a typo costs you seconds.
Tagging one package does not touch the other. Two packages at the same version is a coincidence, not a requirement.
What a successful run leaves behind:
@bitstack/<pkg>@<version>on the Gitea registry- the same tarball on npmjs, public, under
latestornext - a Gitea release on the tag, with notes and an install snippet
- a step summary with both install commands
Changelogs and release notes
Neither package has a CHANGELOG.md yet. Without one, the Gitea release body is
just the install snippet — nothing fails.
When you add one, release.yml extracts the section for the version being
released. The parser is deliberately dumb: it looks for the first ## heading
whose text contains the version string, and takes every line until the next ##
heading. Any of these work:
## 1.2.0
## [1.2.0] - 2026-07-27
## v1.2.0 — Faster inventory parsing
Version strings that are prefixes of each other are the one thing to watch: with
both ## 1.2.0 and ## 1.2.0-rc.1 in the file, releasing 1.2.0 matches
whichever heading comes first. Keep the newest at the top, as usual, and this
resolves itself.
Secrets
Configure under Settings → Actions → Secrets, on the repository or on the organisation to share across repos.
| Secret | Required | Purpose |
|---|---|---|
NPM_TOKEN |
yes | npmjs token with publish rights on the @bitstack scope |
GITEA_NPM_TOKEN |
maybe | Gitea PAT with write:package, if the built-in token is not enough |
GITEA_TOKEN is injected into every run by Gitea itself, and the workflows fall
back to it via ${{ secrets.GITEA_NPM_TOKEN || secrets.GITEA_TOKEN }}. Add
GITEA_NPM_TOKEN only if the automatic token turns out not to carry
package-write scope on your instance — the failure mode is a 401 or 403 from
the registry at the publish step, with the gate already green.
Create the npmjs token as an automation token, or as a granular token scoped
to @bitstack/* with read-and-write permission. Classic publish tokens tied to
2FA prompt interactively and cannot work on a runner.
release.yml refuses to start if either token is missing, and says which one.
npm
--provenanceis deliberately not used. It requires GitHub Actions OIDC, which Gitea has no equivalent for; passing the flag would only fail the publish. Releases are therefore unsigned in the provenance sense — the audit trail is the tag, the run log, and the Gitea release.
Registry authentication in the workflows
release.yml has to talk to two different registries about the same @bitstack
scope inside one job. It does that without ever mutating ~/.npmrc:
- each publish step writes its own credentials file, created with
install -m 600 /dev/null(which both sets the mode and truncates whatever was there before); npm_config_userconfigpoints npm at that file for the duration of the step;- the npmjs step therefore starts from a file that no longer contains the Gitea token;
- a final
if: always()step deletes it, so it does not survive into a later step or a cached workspace.
.npmrc, .npmrc-* and release.json are in .gitignore, so a credentials
file written into the workspace can never be committed by accident.
Installing the packages
From npmjs — public, no configuration:
npm install -g @bitstack/nopy @bitstack/keyman
From the Gitea registry, which holds every snapshot plus a mirror of every
release. Per-project, in the repo's .npmrc:
@bitstack:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
Globally with credentials, in ~/.npmrc:
@bitstack:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
//gitea.bitsquare.dev/api/packages/BitSquare/npm/:_authToken=<your gitea token>
Reads only need a token if the repository is private. The registry URL is derived
in the workflows as {server_url}/api/packages/{owner}/npm/, so it follows the
instance and organisation automatically.
To track snapshots in another project:
pnpm add @bitstack/nopy@main
Design decisions
Every publish is idempotent. Each step asks the registry whether that exact
version already exists (npm view <name>@<version>) and skips if it does. A
release that publishes to Gitea and then fails on npmjs can simply be re-run: the
Gitea publish is skipped, the npmjs publish proceeds. This matters because Gitea
refuses to overwrite an existing version — without the check, a re-run would fail
on the first registry and never reach the second.
The build is explicit, publishes are --ignore-scripts. prepack exists for
humans running npm pack locally; in CI the build has already run as its own
step, and repeating it inside npm publish would only cost time and add a way
for a lifecycle script to change what ships after the gate looked at it.
One job per workflow. No artifact hand-off, no second install, no risk of publishing a tree that a different job built.
Caching is best-effort. actions/cache@v4 is wrapped in
continue-on-error: true, and setup-node's built-in cache: is not used, so
a Gitea runner without a cache backend still works.
actions/upload-artifact@v3, not v4 — v4 depends on a backend API that many
Gitea runner setups do not implement.
pnpm and Node versions come from the repo. pnpm/action-setup@v4 reads
packageManager from the root manifest; setup-node reads .nvmrc. There is no
version pinned in the workflow files to drift out of sync.
Checking things locally
Reproduce the CI gate exactly:
pnpm install --frozen-lockfile
pnpm run lint:ci && pnpm run typecheck && pnpm run test:coverage
See what would actually be in the tarball:
pnpm run build
cd packages/nopy && npm pack --dry-run --ignore-scripts
Try the binary as an end user would get it, without publishing:
cd packages/nopy && pnpm run link:local # build + npm link
nopy --help
npm unlink -g @bitstack/nopy
Check that a version is not already taken before you tag:
npm view @bitstack/nopy@1.2.0 version # npmjs
npm view @bitstack/nopy@1.2.0 version \
--registry https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
Troubleshooting
| Symptom | Cause and fix |
|---|---|
Tag ... asks for X, but package.json declares Y |
The manifest was not bumped, or the tag is on the wrong commit. Fix the manifest, re-tag. |
Tag ... names package 'foo', but packages/foo/package.json does not exist |
Tag prefix must be the directory name under packages/. |
NPM_TOKEN secret is not set |
Add the secret; the run stops before installing anything. |
401/403 from the Gitea registry |
The automatic GITEA_TOKEN lacks write:package. Add a GITEA_NPM_TOKEN PAT — no edit needed. |
E409 Conflict / version already exists |
Only reachable if a version was published outside the workflow; the npm view guard covers re-runs. |
| Coverage step fails, thresholds look met | Thresholds are per package. Read which package failed — the summary table shows both. |
npm pack --dry-run step fails |
A files or bin path no longer exists after the build. Fix before it reaches a registry. |
| Snapshot workflow green, nothing installable | Snapshots are only on Gitea and only under @main. Point the scope at the Gitea registry. |
| The release workflow did not trigger | The tag must match *-v* and must be pushed (git push origin <tag>), not just created. |
Recovering from a bad publish
On npmjs, a version is permanent. Do not try to re-use it — publish a patch. Meanwhile:
npm dist-tag add @bitstack/nopy@1.1.9 latest # point users back
npm deprecate @bitstack/nopy@1.2.0 "Broken build, use 1.2.1"
npm unpublish is only possible within 72 hours and burns the version number
forever; a deprecation with a working latest is almost always the better move.
On Gitea, delete the version under Packages → @bitstack/… → Settings before that exact version can be published again.
A bad tag can be moved, but only before the release workflow has published anything:
git push --delete origin nopy-v1.2.0
git tag -d nopy-v1.2.0
Once a tarball is on npmjs, the tag is a historical record — leave it and roll forward.
A bad snapshot needs no action at all: the next push to main produces a new
run number and therefore a new version, and @main moves to it.