How a Pithy release is cut, what makes one security-relevant, and how a release becomes data the dashboard can answer questions from.
The shape of it
A release is started by hand. There is no push trigger — the release workflow runs on workflow_dispatch only, the same deliberateness the CI workflow applies to main.
snapshot → changeset version → build → restamp kit contract → build records → commit → publish & tag → push → move release → reportThe order matters and is not rearrangeable. changeset version consumes and deletes the changeset files, and the release notes live nowhere else until it has written the CHANGELOGs; the new versions do not exist until it has run. So the release records are collected around it, never reconstructed afterwards.
The version commit comes before the publish, and is pushed after it. changeset publish tags HEAD, so committing first is what puts every <package>@<version> tag on the commit that carries that version. Through 0.14.0 the commit came after, and every tag named the commit before the bump. The commit stays on the runner until the publish has succeeded, so main never claims a release that did not happen.
The build is after the version bump, and that order is load-bearing too. Both halves of the bump are build inputs — Changesets rewrites every package.json, and the root version script rewrites each capability’s generated version module. Built before them, the published bundle reports the version this release replaces to every customer reading GET /control-plane/manifest. The pack gate refuses a tarball whose stamp disagrees with the version being published, which is how the order is held rather than remembered.
The same stamp sets every Claude Code plugin’s version to the CLI’s, and the kit’s contract documents, docs/openapi.generated.json and docs/asyncapi.generated.json, are regenerated straight after the build with bun run kit-contract. Their info.version is the CLI’s, and they are composed from every package’s build output, so the command runs after the build and lands in the version commit. Every pull request already keeps the routes and frames in them current; at release it only ever moves the version.
Cutting one
gh workflow run release.yml -f dry_run=true # see what would ship
gh workflow run release.yml # ship itThat is the whole loop. The job runs the full gates — Biome, license headers, the docs catalog, the CLI pack, typecheck, tests, build — then versions, publishes, tags, pushes the version commit to main, moves release to it, and reports the release.
A release with no changesets fails rather than passing quietly. A release that released nothing should say so.
The release branch, and the plugin
The Claude Code plugins install from pithy-sh/pithy#release, never from main — the pithy plugin, and the pithy-games add-on that installs it with itself. main runs ahead of npm between releases, so a plugin read from it would name commands the installed pithy does not have yet. So release moves in one step of the workflow, on a real release only, after the publish and the push to main: a plugin anyone can install is always one whose CLI is already on npm.
It is a plain push, never forced. release only ever follows main forward, so a push that is not a fast-forward fails rather than overwriting. The plugin’s version is the CLI’s, and Claude Code keeps a user’s cached copy until that string moves, so the plugin updates once per release and never between.
Three consequences:
- The branch does not exist until the first workflow release creates it, and until then
/plugin marketplace add pithy-sh/pithy#releasehas nothing to read. bun run release:localnever moves it. The workflow is the one thing that does.- A failure at that step comes after the publish and the push. The packages are on npm and
mainis current, and only the plugin is a release behind.
Moving it also updates Anthropic’s plugin directory. A push webhook on this repository tells the directory when release moves, and it picks up the new version within minutes rather than at its own check, about every six hours. The webhook receives every push, main’s included. Only a move of release changes what the directory lists.
Versions, and what a 0.x minor carries
Until 1.0.0 everything stays on 0.x, where the minor slot carries breaking changes. That is why a changeset describing a genuine break declares minor rather than major: left at major, Changesets reads it as 1.0.0 on a 0.x package and would ship one capability at 1.0.0 alone, claiming API stability ahead of core.
A changeset names packages, so only those move. Taking the whole scope to one version means naming the whole scope — or accepting that only some advance, which is what a real release looks like anyway.
Marking a release security-relevant
A changeset carries a semver bump and a summary, and neither says whether a release matters. A patch closing a token-reuse hole and a patch fixing a log typo are both patch.
So a security-relevant change says so, in its changeset body, at the moment it is written:
---
"@pithy-sh/auth": patch
---
Refresh-token reuse now revokes the whole family.
Security: a revoked refresh token stayed valid until its natural expiry.The first paragraph is the release note — what changed. The Security: line is the exposure — what was wrong before. They are different sentences, and the second is the one a customer decides on.
In the body, never the frontmatter. The changeset parser treats every frontmatter key as a package name, so a security: true key there declares a package called security and breaks changeset version outright.
The line flows into CHANGELOG.md on purpose. That is what makes git the durable record once the changeset files are consumed, and what lets a replay rebuild a record months later.
Marking it while writing it takes a word. Reconstructing the judgment across two years of releases, under pressure, when a customer asks whether they are exposed, is miserable and unreliable.
Release records, and the dashboard
Every release emits one record per package — the version already split into major, minor and patch, the bump, the note, and the security flag with its exposure sentence. They are always written to .release/records.json and uploaded as an artifact on every run, published or not.
Staging and production both receive records, each turned on by its own environment variable and each attempted independently, so a staging outage cannot cost the production record. With neither set the step says reporting is off and succeeds: off is the absence of configuration, not a second switch that can disagree with it.
There is no secret to set, on either side. The release job already holds id-token: write for npm trusted publishing, and the same permission mints the credential the dashboard takes: a short-lived GitHub-signed OIDC token. The dashboard fetches GitHub’s published keys, verifies the signature, then checks issuer, audience and sub. The allowlisted sub is configuration on the receiver, so revoking is a config edit rather than a rotation held on two sides. The audience is per destination — one token minted for each, so a token minted for staging is not replayable against production.
Never a Cloudflare API token. Cloudflare tokens are not table-scoped: one able to insert release rows could read and write everything in that database, including the dashboard’s users, subscriptions, and the control-plane private keys it holds for every customer. The token above grants one operation instead of a database.
A dry run posts a zero-record delivery to staging: a real token against the real verifier, and no rows written. Reporting used to be skipped entirely on a dry run, which meant the first exercise of the path would have been a real release.
A failed write never fails a release, and is never silent either
An unreachable dashboard cannot block publishing an open-source package. Publishing is long over by the time anything is reported, so nothing here rolls a release back. It is still visible, and how loudly depends on what kind of failure it was:
| What happened | Exit | How it shows |
|---|---|---|
| Nothing answered — no DNS, refused connection, no reply inside the timeout | 0 | A warning, green step |
| Something answered and refused — a status, a rejected body | 1 | An error, failed step under a green job |
| No token could be minted, so the request was never made | 1 | An error, failed step under a green job |
| The records do not satisfy the contract, so nothing was sent | 1 | An error, failed step under a green job |
The split exists because a host nobody has deployed to yet is not a fact about the release. Both dashboard origins were unresolvable for the whole of this pipeline’s first life, so treating every failure as red spent each release printing a red step the reader was meant to ignore — which is how somebody learns to ignore the next one. A receiver that is up and disagreeing with us is different: that is a token, an audience or a payload, and each of those is somebody’s to fix.
The classification is by where the delivery stopped, never by reading an error message — a transport error’s text is a runtime’s wording, and it would need rewriting the first time two runtimes disagreed about how to spell connection refused.
Partial outcomes stay legible either way: Posted 12 records to prod. Failed to staging: ECONNREFUSED. is a different sentence from failed. One blocking failure among several decides the verdict for all of them, so a real refusal cannot hide behind an origin that is merely not up.
Replaying a record
The remaining cost is a dashboard missing a release, and that is recoverable, because the Security: marker is visible prose committed to the CHANGELOG.
bun scripts/releaseRecords.ts replay # every package
bun scripts/releaseRecords.ts replay --package @pithy-sh/auth # one
bun scripts/releaseRecords.ts postreplay rebuilds the records from the CHANGELOGs in git and the <package>@<version> tags. It is idempotent and keyed on package and version, so re-running it costs nothing. A release whose tag cannot date it is skipped and named, never dated by guess — the store is keyed on package and version, so a wrong date written once could not be corrected by running this again.
Releases before the convention carry no flag, because nobody made that judgment at the time. That is unknown, not safe, and the dashboard says so rather than implying the release was clean. Nothing is backfilled: retroactively deciding whether a two-year-old patch was security-relevant produces a dataset where a wrong no reads as an assurance.
Publishing, and why it is pinned to one workflow
Publishing is over npm trusted publishing rather than a stored token. The job mints a short-lived OIDC token that npm exchanges for publish rights, and every package’s publishing access is set to require two-factor authentication and disallow tokens — so the only thing that can publish @pithy-sh/* is this repository’s release workflow, from main.
A trusted publisher matches on repository and workflow filename, and has no branch field. workflow_dispatch runs the workflow definition from whichever ref the dispatcher names, so without something pinning the ref, anyone able to push a branch could run a rewritten workflow from it and publish the whole scope — with genuine provenance attestations that make the result look more trustworthy. A GitHub environment with a deployment-branch rule is what pins it, because GitHub evaluates that rule against the ref actually executing, and the trust declares the environment so npm verifies the claim too.
The filename is load-bearing for the same reason: renaming the workflow breaks publishing for every package until every trust is re-pointed.
A package added later needs its own trust, and until it has one, CI cannot cut a release at all — changeset publish publishes the set or fails. @pithy-sh/organization was the first to arrive after setup, which is why its first version was cut from a laptop: there was no package for a publisher to attach to.
Provenance needs a public repository. npm refuses to verify a sigstore bundle from a private source repository, and the workflow asks for provenance explicitly, so the publish fails outright rather than quietly skipping the attestation. It fails cleanly: nothing publishes, nothing is pushed, and the version bump is never committed. The claim is captured when the run is created, not when the publish step executes, so flipping the repository to public mid-run does not rescue that run. Flip first, then trigger.
What a package publishes
Every published package declares files, and two gates hold it:
- The declaration. A test asserts each package names
src, negates its test files, ships itspithy.manifest.jsonand any companion manifest beside it (@pithy-sh/authshipspithy.mcp.manifest.jsonformcp) where it has them, ships itsdocs/, and names nothing that is not there. - The artifact.
bun run verify-publishedpacks every package and fails on a test file, a build leftover, or a capability manifest at the package root that the tarball left out. It runs in CI, and again in the release workflow on the exact thing about to be published.
Both exist because files does not fail on a missing path. A field naming pithy.manifest.json passes every static check whether the file is there or not, and only the tarball knows the difference — which matters, because the CLI resolves every capability manifest from the adopter’s node_modules/@pithy-sh/*. A package that ships without one is a capability pithy add cannot see, and it exits 0.
The one deliberate exception is the CLI’s vendored templates: it ships a starter an adopter scaffolds, tests included, and its own pack check pins those files to the git index.
Troubleshooting
changeset versionexits 1 with a token error. The changelog generator calls the GitHub API to attribute each changeset. In CI the token is provided; locally,GITHUB_TOKEN=$(gh auth token).changeset versionexits 1 with no changesets. There is nothing to release. That is the expected behavior, and what a completed release leaves behind.- The push to
mainis refused (GH013: Repository rule violations). The job pushed as something the ruleset does not admit. Check that the release deploy key is still on the repository, that its secret is still set innpm-publish, and that the live ruleset still lists theDeployKeybypass. The packages are already on npm by then. Fix the key, then re-run the release: it versions to the same numbers and skips every package already published. It tags none of them, though, so write the missing tags from a checkout of the new version commit withbunx changeset tagand push them. Then replay and post the release records the failed run never sent. npm error 404 Not Found - PUTon an OIDC publish. The trusted publisher is missing or points at the wrong workflow. Checknpm trust list @pithy-sh/<name>— the repository and the workflow filename must both match.- No provenance on a published package. Provenance needs the
repositoryfield inpackage.json, a public repository, and a CI build. A local publish never produces one.