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 src/components/ 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.
Deciding it is the whole of step 1. There is nothing to run.
packages/brand/package.json says 0.0.0 in the repository and is meant to:
the version lives in the tag, and tools/stamp-version.mjs writes it into the
manifest during the release. So there is no bump to commit, no
package-lock.json to keep in step, and no way to tag one number while the
manifest says another.
That is the inverse of what this step used to ask for — a hand-run
npm version --workspace @codecavepro/brand minor --no-git-tag-version before
tagging. It failed the first time someone skipped it: the tag said 2.3.0, the
manifest still said 2.2.0, and the release refused itself. Safe, but a guard
comparing the tag against a number that nothing moves can only ever be a
tripwire on a step people forget.
If you want to see what will be stamped before you tag anything:
node tools/stamp-version.mjs --version 2.3.0 --check--check writes nothing. Without --version it reads the tag on HEAD, or
GITHUB_REF_NAME in CI.
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:package && npm run checkcheck asserts what the package promises: three files byte-identical to their
origin (src/styles/colors_and_type.css, src/styles/fonts/fonts.css, the root LICENSE),
dist/tokens.css still re-extracting from src/styles/colors_and_type.css and
dist/theme.css from src/styles/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 src/, which is the origin. See CLAUDE.md.
npm run check needs dist/ to exist, which is why release.yml runs
npm run build:package 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-0.0.0.tgz — the exact bytes npm would publish, under
the placeholder name, because nothing has stamped a version into an untagged
working tree. That is the rehearsal working as intended: the contents are what
ships and only the number is pending. 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 tools/smoke-tarball.mjs /tmp/brand-smoke/node_modules/@codecavepro/brandThat prints eighteen assertions and exits 0, or names what is broken and exits
- The last of them is the version, and locally it reports
0.0.0 — unstamped, as an untagged rehearsal should be; in CI, whereVERSIONcarries the tag, it asserts the stamp reached the tarball.--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. dist/src/ mirrors
the source tree one file to one file, so a relative import means the same thing
in both — common/Checkbox.vue reaches ../assets/icons/asterisk-icon.vue
here and in the tarball. That is what lets this walk be a real test: it asks the
built files, and the answer is not distorted by a layout the build invented. 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 IS the version — there is nothing for it to agree with. The
workflow reads it off the ref, tools/stamp-version.mjs writes it into
packages/brand/package.json after npm ci, and the tarball smoke test asserts
it survived that far. So the mistake this step used to warn about — tagging
1.0.2 while the manifest still said 1.0.1, republishing 1.0.1 under a tag
claiming otherwise — is not a mistake you can make any more. What is left is
tagging the wrong number, which the already-published guard catches when it
has been used before and nothing catches when it has not. Read the tag before
you push it.
If you push a tag you did not mean, delete it (git tag -d 1.0.2 and
git push origin :1.0.2) and tag again — but only if the run has not reached
the publish step. After that the number is burned; see "If something went
wrong".
Actions → Publish @codecavepro/brand to npm.
It runs the toolchain floor, reads the version off the tag, an already-published
guard, npm ci, the stamp, the build, npm run check, 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:package -- --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/.