Files
ansiblings/README.PUBLISH.md
Benjamin Diedrichsen 4c0fe528dc
Publish snapshot / snapshot (push) Successful in 1m2s
fixing documentation
2026-07-29 13:21:04 +02:00

34 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

Directory Package Binary Kind
packages/nopy @bitsquare/nopy nopy CLI
packages/keyman @bitsquare/keyman keyman CLI
packages/nopy-cubes @bitsquare/nopy-cubes library (cube authoring)
packages/nopy-cubes-core @bitsquare/nopy-cubes-core cube bundle (no build)

All are ESM and declare engines.node >= 22. The two CLIs expose a single executable through bin, so npm install -g puts nopy / keyman on the PATH; the other two are libraries you add to a project.

The tarball contents are pinned by files — for the three TypeScript packages that is ["dist", "README.md", "LICENSE"], so sources and tests are not shipped. nopy-cubes-core ships ["cubes", "!cubes/**/*.log", "README.md", "LICENSE"]: the negation matters, because a cube that has been run leaves a pyinfra-debug.log next to its deploy.py, and .gitignore does not filter an npm tarball. publishConfig.access: "public" is what makes a scoped package publishable to npmjs without an extra flag; the workflows pass --access public anyway.

Dependencies between them

keyman stands alone. nopy and nopy-cubes-core both depend on nopy-cubes through workspace:*, which drives three rules the rest of this document keeps coming back to:

  1. Publish with pnpm, not npm. link-workspace-packages is unset and pnpm 10+ defaults it to false, so workspace:* is mandatory in the manifests. npm has no idea what that protocol is: npm pack copies the string through verbatim and the install fails with EUNSUPPORTEDPROTOCOL. pnpm pack and pnpm publish substitute the concrete version at pack time. Both workflows use pnpm publish --ignore-scripts --no-git-checks.
  2. nopy-cubes publishes before anything that depends on it. node scripts/publish-order.mjs prints the publishable directories in dependency order — note that plain alphabetical packages/*/ gets this backwards, putting nopy first.
  3. Every packed manifest is checked. node scripts/verify-pack.mjs packs each non-private package and fails if any workspace: range survived into the tarball. It runs in both publish workflows, after the build.

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 every package → 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) → verify-pack
        → 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. verify-pack then packs for real and checks no workspace: range survived; both publish workflows run it too, but running it here is what puts the failure on the pull request rather than on the release.

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
        → verify-pack → write .npmrc → publish every package → 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.

The publish step is two passes over node scripts/publish-order.mjs: the first stamps the snapshot version into every manifest with npm pkg set, the second publishes. They cannot be one loop — pnpm publish reads a linked package's version out of its manifest at pack time, so stamping and publishing one package at a time would bake the old nopy-cubes version into nopy's tarball.

release.yml

checkout → resolve tag → check secrets
        → pnpm → node → cache → install → check linked deps are released
        → lint:ci → typecheck → test:coverage → build → verify-pack
        → 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.

Check linked deps are released asks npmjs whether every workspace: dependency of the package being released already exists at the version pnpm is about to bake in (scripts/linked-deps.mjsnpm view). Tagging nopy-v1.3.0 while @bitsquare/nopy-cubes@1.1.0 is still unpublished would otherwise ship a tarball nobody can install, and npmjs only lets you unpublish for 72 hours. The check is npmjs-only: it runs before any credentials are written, and npmjs is the registry where the mistake is permanent.

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 0.5.0-main.42.g736c012 Gitea main
tag nopy-v0.6.0 0.6.0 Gitea, npmjs latest
tag nopy-v0.6.0-rc.1 0.6.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.

Why 0.x and not 1.0.0-alphaN

The packages used to be numbered 1.0.0-alpha5, 1.0.0-alpha0 and so on. Every one of those is a prerelease, so the rule above sent every release to next and latest never moved. That is a quiet failure rather than a loud one: on npmjs latest happened to point at 1.0.0-alpha5 only because npmjs sets latest on a package's first publish whatever --tag says, and it would have stayed pinned there through every subsequent alpha. On Gitea, which has no such fallback, latest did not exist at all — and npm view @bitsquare/nopy against a registry with no latest prints nothing and exits 0, so it looks like a successful lookup of a package with no data.

0.x.y says the same thing about stability that 1.0.0-alphaN was trying to say, while leaving the prerelease slot free for actual release candidates. So latest rolls on every release, next means what it says, and no dist-tag has to be moved by hand.

One-off consequence of the switch. 1.0.0-alpha5 is semver-greater than any 0.x, and it is already on npmjs. Publishing 0.5.0 moves the latest tag to it correctly, but the alpha remains the numerically highest version on the registry. Install with an explicit tag (npm i -g @bitsquare/nopy@latest, which follows the tag and will downgrade), not with npm update -g. Consider npm deprecate '@bitsquare/nopy@1.0.0-alpha5' 'Superseded by the 0.x line' so nobody lands on it by pinning.

Snapshots

Every commit that lands on main publishes every package 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 @bitsquare/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

  1. Bump version in packages/<pkg>/package.json.

  2. Add a changelog entry (see below).

  3. Commit, merge to main, and let the snapshot workflow go green.

  4. 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 @bitsquare/nopy-v1.2.0. All four prefixes work the same way:

git tag nopy-v1.2.0
git tag keyman-v1.2.0
git tag nopy-cubes-v1.2.0
git tag nopy-cubes-core-v1.2.0

Ordering when more than one package changed

Tags are independent, but the dependency graph is not. If a release touches nopy-cubes and something that depends on it, release them in this order, waiting for each run to go green:

nopy-cubes  →  nopy, nopy-cubes-core   (these two are independent of each other)

Release nopy first and the run stops at the check linked deps step, telling you the nopy-cubes version it wanted is not on npmjs. That is the guard working; release nopy-cubes, then re-tag. node scripts/publish-order.mjs prints the order if you would rather not reason about it.

Bumping nopy-cubes means bumping the packages that depend on it in the same change — the workspace:* range resolves to whatever version is in the workspace at pack time, so their next release picks it up whether or not you meant it to.

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:

  • @bitsquare/<pkg>@<version> on the Gitea registry
  • the same tarball on npmjs, public, under latest or next
  • 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 granular token, read-and-write on @bitsquare/*
MYGITEA_NPM_TOKEN yes Gitea PAT with write:package

GITEA_TOKEN is injected into every run by Gitea itself, and the workflows fall back to it via ${{ secrets.MYGITEA_NPM_TOKEN || secrets.GITEA_TOKEN }}. On gitea.bitsquare.dev that fallback is not sufficient: the automatic token is a short-lived task token scoped to git and repo API calls, and the package registry rejects it with E401 Incorrect or missing password at the publish step, with the gate already green. MYGITEA_NPM_TOKEN is therefore required in practice. Create it under Settings → Applications → Access Tokens with the package scope set to read-and-write; its owner needs package-write on the BitSquare organisation, since the registry path is org-owned.

For npmjs, create a granular access token scoped to @bitsquare/* with read-and-write permission, and set 2FA to not-required so it works unattended. npm warns against that combination and points at Trusted Publishing instead — but Trusted Publishing federates only GitHub Actions and GitLab CI/CD over OIDC, and Gitea is not a provider it accepts. A token is the only route from this runner. Scoping the token to @bitsquare/* is what keeps the exposure small: a leak lets someone publish to that scope, not touch the account.

npm caps granular token lifetime at 90 days, so NPM_TOKEN needs rotating roughly quarterly. The failure mode is gentle — an expired token trips the secret check in release.yml within seconds, before anything is installed or built. If it expires between the two registries, re-running after rotation is safe; the npm view guard skips whatever already published.

release.yml refuses to start if either token is missing, and says which one.

npm --provenance is 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 @bitsquare 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_userconfig points 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. The CLIs go on the PATH:

npm install -g @bitsquare/nopy @bitsquare/keyman

The other two go into a project. A cube bundle is a dev dependency of whatever repo describes your infrastructure; nopy-cubes is only needed if you are writing cubes of your own:

pnpm add -D @bitsquare/nopy-cubes-core     # then name it in .nopyrc.json cubePackages
pnpm add -D @bitsquare/nopy-cubes zod  # authoring your own manifests

From the Gitea registry, which holds every snapshot plus a mirror of every release. Per-project, in the repo's .npmrc:

@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/

Globally with credentials, in ~/.npmrc:

@bitsquare: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 @bitsquare/nopy@main

Always map the scope, never set a bare registry=. The Gitea registry serves @bitsquare packages and does not proxy npmjs, so a global --registry sends commander, execa, zod and everything else to a registry that has never heard of them. The CLI's own self-update builds --@bitsquare:registry=<url> for the same reason.

Resolving from Gitea in this repo

This repository ships a root .npmrc that maps the scope:

@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/

So any npm or pnpm command run from inside the repo resolves @bitsquare/* from Gitea, with no flags — including a global install, since npm reads the project .npmrc for those too:

npm install -g @bitsquare/nopy@main       # the newest snapshot, no flags needed

This is not a trade against npmjs. Gitea is a strict superset of it for this scope: release.yml publishes to both, publish-snapshot.yml pushes a main snapshot to Gitea on every push, and today three of the four packages exist only there. Pointing the scope at Gitea gains the snapshots and loses nothing.

.npmrc is otherwise gitignored — the publish workflows write credentials into .npmrc-gitea / .npmrc-release — so .gitignore carries a !/.npmrc negation for the root file specifically. It contains the scope mapping and nothing else. Reads are anonymous; no token belongs in a committed file.

It cannot affect pnpm install: every @bitsquare dependency in the workspace is a workspace:* range that resolves to a link:, so nothing in the tree is ever fetched from that scope. Verified with pnpm install --frozen-lockfile.

Two consequences worth knowing:

  • An untagged install resolves to nothing. Gitea currently publishes no latest dist-tag, so npm i -g @bitsquare/nopy finds no version — and npm reports that by printing nothing and exiting 0. Always name a tag (@main, @next) until the first 0.x release lands. See Why 0.x and not 1.0.0-alphaN.
  • Bare lookups now answer for Gitea. npm view @bitsquare/nopy … run from the repo queries Gitea. Pass --registry https://registry.npmjs.org/ when you specifically mean npmjs.

To see both registries at once — which versions exist where, and which are on Gitea only and therefore still testable and still un-published:

pnpm run registry:status
pnpm run registry:status -- --json

Working outside the repo, set the same mapping globally once:

npm config set @bitsquare:registry https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
npm config delete @bitsquare:registry     # back to npmjs

nopy self-update reads that key too (npm config get @bitsquare:registry), so a CLI installed from Gitea keeps checking Gitea for its own updates with nothing else configured.

Why the publish jobs delete it

A scoped mapping is not just another way to say --registry. For a scoped package npm resolves @scope:registry before registry, so the scoped key wins no matter how the plain one was set — including on the command line. And a project .npmrc outranks the userconfig the workflows write.

Left in place, that combination silently redirects the npmjs release lane:

$ pnpm publish --tag latest --access public --registry https://registry.npmjs.org/ --dry-run
📦 @bitsquare/nopy@0.5.0 → https://gitea.bitsquare.dev/api/packages/BitSquare/npm/

Not hypothetical — that is the workflow's own command, measured. The npm view … --registry <npmjs> idempotency guard inverts the same way: it answers from Gitea, finds the version already there, and skips the npmjs publish entirely. A release that reports success and shipped nothing.

Both publish workflows therefore rm -f .npmrc right after checkout, and every publish and lookup names its registry as --@bitsquare:registry=<url>. Either fix alone is sufficient — both are verified independently — and the pair means a command added later cannot quietly inherit the wrong registry. Nothing else in the job is affected: every @bitsquare range in the workspace is workspace:*, so no install resolves through that scope.

Testing a snapshot before you release

Every push to main publishes a snapshot, so the rehearsal for a release is to install one the way a stranger would:

pnpm run try:snapshot                                  # @main from Gitea
pnpm run try:snapshot -- --tag latest                  # a release, from Gitea
pnpm run try:snapshot -- --registry https://registry.npmjs.org/
pnpm run try:snapshot -- --keep                        # keep the directory

scripts/try-snapshot.mjs builds a throwaway project in a temp directory, points the @bitsquare scope at the registry, installs @bitsquare/nopy and @bitsquare/nopy-cubes-core at that tag, and then:

  • asserts the installed nopy declares a concrete nopy-cubes version rather than a leaked workspace:* range;
  • prints the three resolved versions, so you can see which commit you are on;
  • runs nopy --version;
  • runs nopy install -P -D with stdin closed and asserts the cube-selection prompt listed cubes from the bundle — which only happens if the loader resolved the package out of node_modules and imported every manifest.

It uses npm, not pnpm, on purpose: npm is the client that rejects a leaked workspace: range, so a clean install here is the stronger proof. This is the check verify-pack.mjs cannot be — that one inspects a local tarball, this one goes to the real registry and runs the real binary.

The directory is deleted on success and left behind on failure, with its path printed.

Upgrading an installed CLI

Both CLIs can update themselves:

nopy self-update
keyman self-update

Each derives its channel from the version it is running — a -main. prerelease came from the snapshot workflow, any other prerelease from next, a clean version from latest — so an upgrade keeps you on the channel you installed from instead of quietly moving you to another one. The registry comes from npm config get @bitsquare:registry, so an install from Gitea checks Gitea without any further configuration. The package manager is detected from the install path (npm, pnpm, yarn or bun), so the update does not leave two copies on the PATH.

nopy self-update --dry-run          # print the command, change nothing
nopy self-update --force            # reinstall even when up to date
nopy self-update --channel next     # switch channel
nopy self-update --registry <url>   # check somewhere else

Once a day each CLI checks its channel at startup and prints a one-line hint to stderr when something newer exists — never stdout, so --json and --print-only stay machine-readable. Results are cached in ~/.nopy/update-check.json and ~/.keyman/update-check.json; an unreachable registry gets 1.5 seconds and is then ignored. The check is off whenever CI is set, and NOPY_NO_UPDATE_CHECK=1 / KEYMAN_NO_UPDATE_CHECK=1 turn it off explicitly. NOPY_REGISTRY, NOPY_REGISTRY_TOKEN and NOPY_PACKAGE_MANAGER (and the KEYMAN_ equivalents) override the three things it detects.

The logic lives in packages/nopy/src/nopy.update.ts and packages/keyman/src/keyman.update.ts — two near-identical copies. keyman shares no internal library with nopy by design, and a fifth workspace package for ~250 lines would add another edge to the publish order for nothing. If a third CLI appears, extract it then.

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 packing locally; in CI the build has already run as its own step, and repeating it inside pnpm publish would only cost time and add a way for a lifecycle script to change what ships after the gate looked at it.

verify-pack.mjs checks the artefact, not the source. Reading package.json in the repo would only tell you what you already know — every one of them says workspace:*. The question is what pnpm wrote into the tarball, so the script packs, extracts package/package.json, and reads the ranges back out. It cannot pass --ignore-scripts, because pnpm pack has no such flag (only pnpm publish does), so prepack rebuilds — which at least means the tarball under test is byte-for-byte the one publish would ship.

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 && pnpm pack --dry-run

Check that no workspace: range leaks into a published manifest — the same check CI runs:

node scripts/verify-pack.mjs
node scripts/publish-order.mjs             # the order to release in
node scripts/linked-deps.mjs packages/nopy # what must be on the registry first

See what is on each registry, and which versions Gitea has that npmjs does not:

pnpm run registry:status

Rehearse an install against a registry that has actually been published to — see Testing a snapshot:

pnpm run try:snapshot

Rehearse an install the way a stranger gets one, without publishing anything. Use npm, not pnpm: npm is the one that rejects a leaked workspace: range, so a clean install here is the real proof.

pnpm --filter @bitsquare/nopy-cubes pack --pack-destination /tmp/tgz
pnpm --filter @bitsquare/nopy      pack --pack-destination /tmp/tgz
pnpm --filter @bitsquare/nopy-cubes-core pack --pack-destination /tmp/tgz

mkdir /tmp/try && cd /tmp/try && npm init -y
npm install /tmp/tgz/*.tgz
echo '{"hosts":["h"],"cubePackages":["@bitsquare/nopy-cubes-core"]}' > .nopyrc.json
./node_modules/.bin/nopy install -l session.json -P -D

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 @bitsquare/nopy

Check that a version is not already taken before you tag. The repo's .npmrc points the scope at Gitea, so the bare lookup answers for Gitea and npmjs is the one that needs the explicit flag:

npm view @bitsquare/nopy@1.2.0 version                                          # Gitea
npm view @bitsquare/nopy@1.2.0 version --registry https://registry.npmjs.org/   # npmjs

Or both registries, every package, in one table:

pnpm run registry:status

npm view <name>@<version> exits 1 for a version that does not exist, so it is a sound check. npm view <name> — no version — is not: against a registry with no latest tag it prints nothing and exits 0.

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 MYGITEA_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.
EUNSUPPORTEDPROTOCOL / Unsupported URL Type "workspace:" on install A workspace: range reached a tarball — something published with npm publish instead of pnpm publish. verify-pack.mjs exists to catch this before it ships.
... is not published yet on npmjs before the gate runs Releasing a package before its nopy-cubes dependency. Tag and release nopy-cubes first, then re-tag.
verify-pack.mjs fails locally with a build error pnpm pack runs prepack, so a broken build fails the check. Fix the build; there is no skip flag.

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 @bitsquare/nopy@1.1.9 latest    # point users back
npm deprecate @bitsquare/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 → @bitsquare/… → 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.