Skip to content

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:

sh
npx changeset

Pick 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:

LevelWhen
patchBug fix, no API change
minorNew component, new optional prop, additive behaviour
majorRenamed 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 minor for anything breaking, and patch for a plain fix.
  • The note carries the whole weight. It must say what a consumer has to change, in the imperative: "primary / secondary / ghost / danger are gone — use flat, outlined, text, and tone="error"." A reader scanning bump levels cannot see that a minor will 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 ​

  1. Start clean on an up-to-date main, with npm run verify passing.

  2. 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: verify runs 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 in HabColorGrid, HabSelect and HabMenu, and the focus indicators base.css paints on HabInput's box and the toggle marks.

  3. Apply the changesets. This bumps package.json and writes CHANGELOG.md:

    sh
    npx changeset version
  4. Read 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 as minor, fix it now — git checkout . and correct the changeset files rather than editing the generated changelog.

  5. Commit and tag. The tag must match the new version exactly; CI verifies this and fails the release if it does not.

    sh
    git 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-tags
  6. Watch the release workflow. .gitea/workflows/release.yml re-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:

sh
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 — the LR_PUBLISH_TOKEN repository secret is missing or lacks package:write. Or the registry URL in publishConfig and the token key in .npmrc_ci no longer match: npm matches a token to a URL case-sensitively, so both must spell the org LiveReader.
  • 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:

sh
npx changeset pre enter next
npx changeset version   # -> 1.2.0-next.0

Tag and push as normal, then npx changeset pre exit when stabilising.

Proprietary — internal use only.