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.
- The very first publish, CCWEB2-318 phase 5. Read First publish only as well; it has preconditions the routine path does not.
Do not use it for changes that do not reach the tarball. The package ships
dist/ plus README.md and LICENSE — 59 files as of 1.4.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 capture is a change that reaches the tarball, now that components ship.
Refreshing docs/source_examples/ moves published bytes, so it is a release,
and npm run check:captures is a precondition of every publish rather than only
the first — with the checkout on development, because it reads whatever branch
it finds. See CONTRIBUTING.md.
| Needed | Check it | |
|---|---|---|
| Node | ≥ 20 (engines); CI builds on 22 |
node --version |
| Working tree | clean, on development, pushed |
git status |
| npm login | a member of the codecavepro npm org |
npm whoami |
| Registry | https://registry.npmjs.org/ |
npm config get registry |
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.
The scope holds nothing public yet: npm view @codecavepro/brand is still a
404, which is what leaves 1.0.0 available to claim.
Publishing is a manual, local step by design. No CI workflow holds an npm token, so there is no automation-shaped path to publishing something unreviewed.
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 5.
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.
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 to exactly
what shipped, the ports typechecking, and the storybook matching
docs/source_examples/. 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, so it is a local and prepack guard,
not a CI one — CI checks out a tree with no dist/ at all. That is why step 3
matters more here than it would in a repo where CI could do it for you.
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 and use it:
mkdir -p /tmp/brand-smoke && cd /tmp/brand-smoke && npm init -y && npm i "$OLDPWD/codecavepro-brand-1.0.1.tgz"Then check the three things a consumer does first. Run these from the smoke directory:
node -e "import('@codecavepro/brand').then(m => console.log(m.color.action))"prints a hex value — the typed module resolves, and has content rather than an empty barrel.
node -e "const fs=require('fs'),p='node_modules/@codecavepro/brand/dist/colors_and_type.css';console.log(fs.readFileSync(p).equals(fs.readFileSync(process.argv[1]))?'identical':'DIFFERS')" /path/to/brand/docs/colors_and_type.cssprints identical — the buildless URL and the installed file are the same
bytes, which is the package's central promise.
node -e "console.log(require.resolve('@codecavepro/brand/components/common/Button.vue'))"
node -e "const fs=require('fs'),path=require('path'),root=path.dirname(require.resolve('@codecavepro/brand/package.json'))+'/dist/src';const walk=d=>fs.readdirSync(d,{withFileTypes:true}).flatMap(e=>e.isDirectory()?walk(path.join(d,e.name)):[path.join(d,e.name)]);const bad=[];for(const f of walk(root))for(const m of fs.readFileSync(f,'utf8').matchAll(/from\s*['\"](\.[^'\"]*)['\"]/g)){const j=path.resolve(path.dirname(f),m[1]);if(![j,j+'.ts',j+'.vue'].some(c=>fs.existsSync(c)))bad.push(path.relative(root,f)+' -> '+m[1]);}console.log(bad.length?'DANGLING: '+bad.join(', '):'every relative import resolves')"The first prints a path — the ./components/* subpath export resolves. The
second prints every relative import resolves, walking the installed
dist/src/ and following every relative import in it.
That one is not ceremony. The captures flatten the site's src/components/
level away while their imports still climb through it — header/desktop-menu.vue
reaches ../../assets/images/logo.svg — so the package ships at the site's
depth to keep those resolving. The storybook cannot catch a regression here,
because build-storybook.mjs re-roots escaping imports with a resolver plugin
and a consumer's import has no such plugin. DANGLING means the package is
broken for every consumer while looking fine in this repo.
ls node_modules/@codecavepro/brand/lists LICENSE README.md dist package.json. The first two are there because
npm picks them up by name from the package root; files: ["dist"] does not
mention either, and the licence is a build-time copy of the repo's root
LICENSE. If LICENSE is missing, the build did not run — stop.
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. Neither is tracked, but a
stale tarball in the repo root is a thing someone will eventually publish by
accident.
npm run releaseIf your account requires two-factor auth, pass the code through — everything
after -- reaches npm publish:
npm run release -- --otp=123456The --workspace flag is not optional, and that 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 currently share the name @codecavepro/brand, so the distinction is by
path and only by path. It is stopped by the root manifest's private: true,
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.
Neither rehearsal above catches this. Verified on npm 11.7.0: npm publish --dry-run on a private package prints + name@version and exits 0. Step 3
cannot save you here; only the real publish enforces it.
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.
Confirm the registry agrees:
npm view @codecavepro/brand versiongit commit -am "Release @codecavepro/brand 1.0.1" && git tag 1.0.1 && git push && git push --tagsTags 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.
Tags are annotated (git tag -a), not lightweight, so each carries its tagger,
date and a message saying what was in it.
Comment the version and tag on the CCWEB2-318 phase-5 issue. 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.
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 three preconditions the rest do not, in this order:
-
docs/source_examples/has been re-measured. Runnpm run check:captures. A clean result is not durable — the captures measured nine drifted files on 2026-08-19 and thirteen the next day. Nothing in the token package is built from a capture today, but check before assuming that is still true. -
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.
- The four CMS-shaped components.
ArticlePreview,Review,pain-points-itemandtechnologiesreach the site's Strapi host and token throughhelpers/image-url.ts.build.mjsexcludes them by name inNOT_SHIPPED, each with its reason; they ship when CCWEB2-332 inverts that dependency site-side. The rest of the components ship as of 1.2.0 — but nothing is published fromdocs/source_examples/until the captures have been re-measured against the live site, ever. - 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/.