How to publish the brand package to npm, and how to get out of it if the publish was wrong.
Read the rollback section before the first publish, not after. npm's unpublish window is 72 hours and a version number, once used, can never be reissued. Everything else in this runbook is reversible; that is not.
- A token value changed in
docs/and consumers need it — a minor bump. - The package layout changed (export paths, module shape) — a major bump, now that 1.0 is out and the layout is a promise.
- A first publish of some future package. Read First publish only
as well; it has preconditions the routine path does not. This one's first
publish is long done — the registry has been serving
@codecavepro/brandsince 1.0.0.
Do not use it for changes that do not reach the tarball. The package ships
dist/ plus README.md and LICENSE — 57 files as of 2.2.0. Storybook
specimens, docs pages, DESIGN.md and WEBSITE-REVIEW.md are none of them.
Those deploy with GitHub Pages on push and need no release.
A change under docs/authored/ is a change that reaches the tarball.
That directory is where the nineteen shipped components live, so editing one
moves published bytes and needs a release.
There is no longer a check comparing it to codecave.pro, and there should not be: the site installs this package and pins it, so a release is precisely the moment the two are furthest apart. See CONTRIBUTING.md.
GitHub Actions does, and pushing a version tag is what asks it to.
.github/workflows/release.yml runs the whole
verification below and then publishes; nothing in the routine release happens on
your machine except deciding the version and pushing the tag.
It authenticates as a trusted publisher — OIDC. There is no npm token in
this repository, no NPM_TOKEN secret and no OTP to type: npm mints a
short-lived credential after matching the workflow's identity against what is
registered on the package, and attaches a provenance attestation on the way out.
See The trusted publisher for the one-time setup, and
for what silently revokes it.
This reverses what this file used to say. The old reasoning was that a manual publish leaves no automation-shaped path to publishing something unreviewed — true of the credential, and false of everything else. Doing it by hand was also the only thing standing between the registry and a skipped step, and step 3's tarball rehearsal was five shell one-liners that were easy to skip and had already drifted from the build once (CCWEB2-370). Trusted publishing removes the stored credential that was the objection, and CI runs the rehearsal every time instead of when someone remembers. The review did not go away — it is the tag. Nothing publishes that a person did not name.
| Needed | Check it | |
|---|---|---|
| Node | ≥ 22 (engines); both workflows run 24 |
node --version |
| Working tree | clean, on development, pushed |
git status |
| Trusted publisher | registered on the package, workflow release.yml |
the package's access settings |
| Push access | enough to create and push a tag | git push --dry-run |
npm whoami, an npm login and org membership are no longer prerequisites
for a routine release. They matter for the by-hand fallback under
If CI cannot publish, and for the rollback commands,
which stay manual because npm offers no automated path for them.
The org exists and salaros owns it. npm org ls codecavepro returns
{"salaros": "owner"}, checked 2026-08-21 — and checked to be a real answer
rather than a swallowed error, by running the same command against
codecavepro-definitely-not-real and watching it return E404 Scope not found.
A command that reports success by printing nothing is worth provoking once.
Every step is a command whose failure stops you. Nothing here is a judgement call except step 1.
The package is 1.0 and under ordinary semver. The layout is a promise now: an export cannot move or disappear outside a major bump.
| Change | Bump |
|---|---|
| README wording, a fixed build, a docs-only change | patch — 1.0.0 → 1.0.1 |
| a token value changing, a token added, an export added | minor — 1.0.0 → 1.1.0 |
| an export path moving, a module shape changing, a token removed | major — 1.0.0 → 2.0.0 |
A token value moving is a minor, not a patch. It is not a bug fix — the
palette tracks a living design system, and a consumer who pinned ~1.0.0
deserves to opt into a colour change rather than receive it. Removing a token is
a major for the same reason it is in any library: somebody's var() goes
undefined and nothing tells them.
npm version --workspace @codecavepro/brand minor --no-git-tag-version(minor is the common case — a token value moved. Substitute patch or
major per the table.)
--no-git-tag-version is deliberate: npm tags as v1.0.1, and this repo tags
as 1.0.1. Letting npm do it also commits on your behalf. Tag by hand in
step 4 — and note that the tag is now the thing that publishes, so it is not a
step to run early.
Use the command rather than editing package.json, because it also writes the
new version into package-lock.json. Editing by hand leaves the two
disagreeing, and npm ci — which is what CI runs — refuses to install a
workspace in that state. If you did edit by hand, npm install --package-lock-only fixes it.
CI runs this too, before it publishes — every step from here to the end of
step 3 is in release.yml. Running it locally first is not ceremony: once the
tag is pushed there is no rehearsal left, so this is where a mistake is still
free.
npm run build && npm run checkcheck asserts what the package promises: three files byte-identical to their
origin (docs/colors_and_type.css, docs/fonts/fonts.css, the root LICENSE),
dist/tokens.css still re-extracting from docs/colors_and_type.css and
dist/theme.css from docs/theme.css to exactly what shipped, every component
matching the source it was copied from, the ports typechecking, and the
storybook matching both component roots. If the
byte-identity assertion fails, do not fix it in packages/ — the fix
belongs in docs/, which is the origin. See CLAUDE.md.
npm run check needs dist/ to exist, which is why release.yml runs
npm run build immediately before it — a fresh checkout has no dist/ at all,
and a check run against one would be asserting things about nothing.
npm pack --workspace @codecavepro/brandThis runs prepack, so the full build (including the README value assertions)
runs again and cannot be skipped. It writes
codecavepro-brand-<version>.tgz — the exact bytes npm would publish.
Install it somewhere real, then ask it the questions a consumer asks first:
mkdir -p /tmp/brand-smoke && (cd /tmp/brand-smoke && npm init -y && npm i "$OLDPWD"/codecavepro-brand-*.tgz --legacy-peer-deps)node docs/tools/smoke-tarball.mjs /tmp/brand-smoke/node_modules/@codecavepro/brandThat prints seventeen assertions and exits 0, or names what is broken and exits
--legacy-peer-depsskips auto-installing the five peers; nothing the script checks imports them, andvueis a large download to prove nothing with.
Why the tarball and not the tree. files: ["dist"] decides what ships,
exports decides what resolves, and npm picks LICENSE and README.md up from
the package root by name — files neither includes nor excludes them. None
of those three are visible to a check that walks dist/ in place, so a package
can be completely correct on disk and broken for everyone who installs it. The
licence is a build-time copy of the repo root's; if it is missing, the build did
not run — stop.
This was five shell one-liners until the release moved to CI, one of them
900 characters of inlined JavaScript. It is a script because release.yml runs
the same command, and because that last one-liner had already drifted: it read
only from "...", reported that everything resolved over a 1.6.0 tarball whose
Checkbox.vue reached a checked-icon.svg that was not in it, and the bug
shipped (CCWEB2-370). A background-image is a reference like any other.
The reference walk is the assertion worth understanding. The captures flatten
the site's src/components/ level away while their imports still climb through
it — common/Checkbox.vue reaches ../../assets/icons/asterisk-icon.vue — so
the package ships at the site's depth to keep those resolving. The storybook
cannot catch a regression there, because build-storybook.mjs re-roots escaping
imports with a resolver plugin and a consumer's import has no such plugin. A
dangling reference means the package is broken for every consumer while looking
fine in this repo.
smoke-tarball.mjs mirrors referencesOf() in build.mjs, deliberately and by
copy. Keep the two in step — if the build learns a new spelling of "reaches",
this has to learn it too, because this one runs against the tarball rather than
against the tree that produced it. Each of its eleven failure modes was proven
to fire by breaking an installed copy one way at a time.
This replaces the git-dependency validation named in CCWEB2-318 phase 5. That plan proposed proving the loop with a
github:CodeCavePro/brand#tagdependency first, on the grounds that it is free and reversible. It does not work here: an npm git dependency installs the repository root, and the root manifest —/package.json, notpackages/brand/package.json— isprivate: trueand publishes nothing. A consumer would install the workspace, not the package.The tarball is strictly better anyway — it is free, reversible and it is literally what npm publishes, which the git dependency never was.
Delete the smoke directory and the .tgz when done. The release itself packs
its own tarball on the runner and never sees yours, but npm publish <file.tgz>
is a real command and a stale one in the repo root is a thing someone will
eventually reach for.
git commit -am "Release @codecavepro/brand 1.0.1" && git pushgit tag -a 1.0.1 -m "Release 1.0.1 — <what changed>" && git push origin 1.0.1Pushing that tag publishes. Everything above this line is reversible; pushing the tag starts a run that ends at the registry, and a version number, once used, can never be reissued. Push the commit first and the tag separately, so the two are distinguishable acts rather than one keystroke.
Tags are the bare version — 1.0.1. No v, no package prefix. Set by
1.0.0 on 2026-08-21, the repo's first tag, and now load-bearing: release.yml
triggers on [0-9]+.[0-9]+.[0-9]+ and refuses to run from a branch.
Tags are annotated (git tag -a), not lightweight, so each carries its tagger,
date and a message saying what was in it.
The tag must equal packages/brand/package.json's version. The workflow
asserts it and stops if they disagree, because that is the one mistake here that
would otherwise succeed quietly: tagging 1.0.2 while the manifest still says
1.0.1 republishes 1.0.1 under a tag claiming otherwise, and npm never sees
the tag, so nothing downstream would ever notice. If you get it wrong, delete
the tag (git tag -d and git push origin :1.0.2), fix one of the two, and tag
again.
Actions → Publish @codecavepro/brand to npm.
It runs the toolchain floor, the tag/manifest guard, an already-published guard,
npm ci, the build, npm run check, the captures check when it can reach
codecave.pro, and the tarball smoke test — and only then publishes. A red run
before the publish step has cost nothing.
Two things in the summary are worth reading rather than skimming:
- Which root each component came from, in the storybook rebuild summary. A specimen quietly falling back to the captures is the drift this arrangement exists to prevent, and the log is the only place it shows.
- Provenance. A trusted-publisher release attaches an attestation; the npm page shows the commit and the workflow run that built it. If the badge is absent, the publish did not go out as a trusted publisher and something in The trusted publisher has drifted.
Comment the version and tag on whichever issue the release was for. If this publish changed a token value, say which — the site consumes these, and a value change is the only kind of release with downstream work attached.
codecave.pro will not move on its own: it installs with pnpm install --frozen-lockfile, so it runs whatever its lockfile pins until someone raises
the range and regenerates it.
One-time setup, on the package rather than the org: npmjs.com/package/@codecavepro/brand/access → Trusted publisher → GitHub Actions.
| Field | Value |
|---|---|
| Organization or user | CodeCavePro |
| Repository | brand |
| Workflow filename | release.yml — the bare filename, not the path |
| Environment name | empty — the job declares no environment |
| Allowed actions | npm publish |
Every field is case-sensitive and matched exactly. The workflow filename is
the one people get wrong: .github/workflows/release.yml will never match.
The filename is part of the credential. Renaming
.github/workflows/release.yml revokes the ability to publish, and npm reports
that as
npm error code E404
npm error 404 Not Found - PUT https://registry.npmjs.org/@codecavepro%2fbrand
which reads as "no such package" and is not that — the package exists. Every OIDC failure arrives wearing that costume, so work down the table above before suspecting anything else.
One thing is genuinely unproven and is written down in the workflow too: whether
npm's OIDC exchange resolves the package correctly for npm publish --workspace, given that this package sits in a subdirectory and declares
repository.directory. Trusted publishing has known rough edges with nested
packages. If every field above is right and the PUT is still refused, the next
thing to try is publishing from the package directory — working-directory: packages/brand with a bare npm publish, which reaches the same manifest by a
plainer route.
The fallback is the old manual path, and it needs an npm login and org membership:
npm run release -- --otp=123456The --workspace flag inside that script is not optional, and it is the
entire reason this is a script rather than a command you type. Bare npm publish at the repository root targets the root manifest —
/package.json — and not packages/brand/package.json, which is the one that
describes this package. The two share the name @codecavepro/brand, so the
distinction is by path and only by path. The root manifest's private: true
stops it, with
npm error This package has been marked as private
which reads like a problem with the package you meant to publish and is not one.
Do not remove that private field to make the message go away. It is the
only thing between a mistyped command and 554 files of monorepo on the public
registry, and the package it is refusing to publish is the wrong package
anyway.
No rehearsal catches this. Verified on npm 11.7.0: npm publish --dry-run on a
private package prints + name@version and exits 0. Only the real publish
enforces it — which is a reason to prefer the pipeline, where the command is
written down rather than typed.
publishConfig.access is already public in the manifest, so the script's
--access public is redundant — it is there so the intent shows up in shell
history. A scoped package defaults to restricted, and a restricted publish under
a free org fails rather than silently going private, but do not rely on that as
the safety net.
A hand publish gets no provenance attestation. That is not a reason to avoid it in an emergency; it is a reason to say so in the CCWEB2-318 comment, because the npm page will differ from every other release.
Then tag as in step 4 — the workflow will refuse the already-published version, which is correct, and the tag still needs to exist.
Order matters. Read all four before doing any.
npm unpublish @codecavepro/brand@1.0.1npm allows this only inside the 72-hour window and only while no other package depends on the version. The version number is burned regardless — it can never be republished, even with different content. Bump and republish; never try to reuse the number.
Unpublishing is not available. Deprecate, then ship a fix:
npm deprecate @codecavepro/brand@1.0.1 "Broken build — use 1.0.2 or later."The version stays installable — deprecation is a warning on install, not a
removal — so the fix has to be a real one. Publish 1.0.2 immediately.
Do nothing urgent. A byte-identity failure that nobody's browser will notice is
a patch release, not an incident. Fix in docs/, re-run the routine release.
Stop. Do not publish again to "correct" it. A package published to the wrong scope is someone else's namespace and unpublishing is the only remedy — which puts you back in the 72-hour window above, with an escalation attached.
| Situation | Who |
|---|---|
| npm org membership, scope ownership, billing | Yaroslav Zhmayev (repo owner) |
| A published version must be pulled and the window has closed | Yaroslav Zhmayev, then npm support |
| Token values are disputed — the package shipped a colour someone disagrees with | Maria Shaban; the package is not the place to settle it, docs/ is |
| The site's consumption of the package breaks | CCWEB2, no brand-kit label — it is the site's to fix |
The routine release assumes the package already exists on the registry. The first one has two preconditions the rest do not, in this order:
-
Every name is settled. A token name is only cheap to change while nobody has installed it — before the first publish a rename is free, after it a rename is a
2.0.0. Two were caught this way and both were free:spacing.controlHeightshipping 44px against production's 48px, and--text-lg/--text-sm/--text-basesilently redefining Tailwind defaults in every consumer's app.npm run check:collisionsguards the second class. Expect a third. The last chance to rename anything is the run of this checklist that ends innpm publish.Note what the first one implies: documenting a divergence honestly is right for a token already published, and the wrong thing to do for one you are about to publish for the first time and could simply get right.
-
Version
1.0.0. The manifest already says so, and1.0.0is tagged. Publishing straight at 1.0 is a deliberate call, not an oversight: the token values are the thing consumers depend on and they already mirror production, so a0.xwould be understating the stability of the only part that matters to them. The cost is that the layout is now a promise — an export path cannot move again without a2.0.0. Settle any layout doubts before this publish, because after it they are expensive.
Then run the routine release from step 2 — step 1 is skipped, the version is already correct.
Worth knowing before someone files a release request for one of these:
- Font binaries. A licensing question, not an oversight. The stylesheets declare six Satoshi faces and ship no files; consumers supply them. See the package README.
- Nothing CMS-shaped, any more.
ArticlePreview,Review,pain-points-itemandtechnologiesused to reach a CMS host and token throughhelpers/image-url.ts, soNOT_SHIPPEDheld all four. CCWEB2-332 inverted that: each declares the fields it reads and takes an optionalresolveImage(), the caller supplies the resolver, and all four ship.helpers/image-url.tswas deleted rather than excluded — a package that knows where the content lives has the dependency the wrong way round, and an exclusion only hides it. - Anything authored in
packages/.README.mdis the single exception, because npm renders it as the package page and there is nowhere else for that page to come from — and even its example values are asserted against the compiled module at build time. Everything else is copied or compiled fromdocs/.