Playbook: cut a release
Habitus uses Changesets for intent and changelog, and a v* tag to trigger publishing.
Why versioning is done locally
The usual Changesets automation (changesets/action, which opens a "Version Packages" PR) is GitHub-API-specific and does not work against Gitea. So the version bump happens on your machine and CI only reacts to the tag. Do not try to wire the action up; it will not work.
During development
Every PR that changes anything a consumer can observe adds a changeset:
npx changesetPick the bump and write the note. Write it for someone deciding whether to upgrade, not as a summary of your diff:
- Good:
HabButton now renders a real <a> when given as="a", so links appear in browser history. - Bad:
Refactored button internals.
Bump levels:
| Level | When |
|---|---|
patch | Bug fix, no API change |
minor | New component, new optional prop, additive behaviour |
major | Renamed or removed prop/slot/event/type, changed default, changed markup others may select on, changed token meaning |
A PR that only touches tests, docs prose or CI needs no changeset. CI runs changeset status as a prompt, not a hard gate — but if it flags you and you believe no changeset is needed, say why in the PR.
Before 1.0, a breaking change is minor
This is a deliberate exemption from the table above, and it lasts until the first 1.0 release. While the version is 0.x, Changesets treats major as a jump to 1.0.0 — so levelling a breaking change honestly would cut 1.0 as a side effect of a bug fix, rather than as a decision anyone made. Habitus is still settling its API, and several breaking changes have already shipped this way: the HabButton variant values, the Core token rename, HabDataTable's boolean labels.
So until 1.0:
- Use
minorfor anything breaking, andpatchfor a plain fix. - The note carries the whole weight. It must say what a consumer has to change, in the imperative: "
primary/secondary/ghost/dangerare gone — useflat,outlined,text, andtone="error"." A reader scanning bump levels cannot see that aminorwill break them, so the prose has to. - Say so in bold at the top of the note when the change is breaking.
Once 1.0 ships, delete this section and follow the table.
Releasing
Start clean on an up-to-date
main, withnpm run verifypassing.Look at the workbench.
npm run docs:dev, in light and dark mode.This is a manual step because there is deliberately no browser test layer:
verifyruns on happy-dom, which cannot see the cascade, real layout, focus behaviour or pointer interaction. Those failures are invisible to CI by construction, and a release is the moment to catch them by eye. Worth a specific look at anything focus-driven — the roving focus inHabColorGrid,HabSelectandHabMenu, and the focus indicatorsbase.csspaints onHabInput's box and the toggle marks.Apply the changesets. This bumps
package.jsonand writesCHANGELOG.md:shnpx changeset versionRead the changelog. This is the one moment to catch a mis-levelled changeset. If something additive was marked
major, or a breaking change slipped in asminor, fix it now —git checkout .and correct the changeset files rather than editing the generated changelog.Commit and tag. The tag must match the new version exactly; CI verifies this and fails the release if it does not.
shgit add . git commit -m "chore: release v$(node -p "require('./package.json').version")" git tag "v$(node -p "require('./package.json').version")" git push --follow-tagsWatch the release workflow.
.gitea/workflows/release.ymlre-verifies the tag against the manifest, runs lint, typecheck, tests and the build, then publishes to the Gitea registry, creates a release, and attaches the release's SBOM to it (see below).
The SBOM
Every release carries habitus-vue-<version>.cdx.json, a CycloneDX 1.6 SBOM generated by sbom-action from sbom/config.yml. It lists the package as the tarball that was published (its SHA-512 and download URL), the npm packages compiled into dist/ — @mdi/js's glyphs, and Tailwind's preflight in habitus.css — and the production dependencies npm installs with it (reka-ui and what it needs, marked livereader:sbom:npm:installed-alongside). CI generates the same SBOM as a dry run on every PR; its BSI TR-03183-2 score and licence check are in the job summary.
The Vite build records what it bundles by itself (sbom/vite-bundled-packages.ts). Something that reaches dist/ another way, the way the Tailwind CLI puts Tailwind into the stylesheet, has to be named in also-bundled in sbom/config.yml.
Docs deployment
Separate from the npm release and not tied to it: every push to main runs .gitea/workflows/docs-deploy.yml, which builds the workbench into an nginx image, pushes :latest and :<commit-sha> to git.livereader.com/livereader/habitus-docs, and notifies Watchtower.
To roll the docs back, retag a known-good SHA as :latest and poke Watchtower:
docker pull git.livereader.com/livereader/habitus-docs:<sha>
docker tag git.livereader.com/livereader/habitus-docs:<sha> \
git.livereader.com/livereader/habitus-docs:latest
docker push git.livereader.com/livereader/habitus-docs:latest
curl -fsS -X POST -H "Authorization: Bearer $WATCHTOWER_HTTP_API_TOKEN" \
"$WATCHTOWER_URL/v1/update?image=git.livereader.com/livereader/habitus-docs"If publishing fails
403/401— theLR_PUBLISH_TOKENrepository secret is missing or lackspackage:write. Or the registry URL inpublishConfigand the token key in.npmrc_cino longer match: npm matches a token to a URL case-sensitively, so both must spell the orgLiveReader.EPUBLISHCONFLICT/ version already exists — that version was already published. Registries do not allow republishing; bump again.- Tag/manifest mismatch — the guard did its job. Delete the tag, fix the version, re-tag.
Prereleases
For something consumers should try but not depend on:
npx changeset pre enter next
npx changeset version # -> 1.2.0-next.0Tag and push as normal, then npx changeset pre exit when stabilising.