Developing and releasing
How to work on @spunto/build, how a version is cut and published from main, and how each product adopts it by version.
Where it lives
The source is in packages/build of the Spunto Cloud repository (coderhammer/spunto). It is
published to the public npm registry as @spunto/build, under the MIT license.
Development
The package has its own package.json and its own lockfile. There are no npm workspaces in the
repository (see why below), so install and run it from its own
directory:
cd packages/build
npm install
npm test # vitest run
npm run typecheck # tsc --noEmit
npm run test:watch # vitest, watch modeThe tests are what make it safe for two control planes to share a protocol, so they gate the release. Two kinds deserve a mention:
- Pinned-bytes tests. Formats are tested against their exact strings (
mp-worker-abc123,::spunto:step:start:code-server…). Those tests are meant to be annoying to change, and a diff in them should be read as a migration. - Snapshot scripts. The generated setup scripts are compared against checked-in shell files
(
src/script/__snapshots__/setup-scripts/*.sh). A change to a generator shows up as a reviewable diff of the shell it produces. Snapshots are excluded from the published tarball.
The typecheck runs without @types/node on purpose. If you reach for Buffer, fs or
process, the typecheck fails. That failure is the package enforcing its
rule, not a missing dependency.
Cutting a release
The same cycle applies to @spunto/design-system. Follow it as written.
Bump the version in the PR that changes the code
Edit version in packages/build/package.json in the same pull request as the change. Check
the number against what npm has, not against main:
npm view @spunto/build versionMerge to main
.github/workflows/publish-build.yml runs on every push to main that touches
packages/build/**. It runs npm ci, npm run typecheck and npm test, then publishes, but only
if the version is new:
VERSION="$(node -p "require('./package.json').version")"
if npm view "@spunto/build@${VERSION}" version >/dev/null 2>&1; then
echo "@spunto/build@${VERSION} already published — skipping."
else
npm publish
fiA merge without a bump is therefore a safe no-op.
Adopt it in a separate PR
The apps depend on the package by version, from npm. Bump the dependency in whichever app
needs the new code (apps/api, apps/next, apps/agent, or Spunto Lite) in its own pull request,
once the version is on the registry.
How publishing authenticates
Publishing uses npm Trusted Publishing (OIDC), so there is no NPM_TOKEN secret. The job has
id-token: write, runs on Node 24, and upgrades npm to the latest version first, because Trusted
Publishing requires npm ≥ 11.5.1. On npmjs.com, the package trusts the repository coderhammer/spunto
and the workflow filename publish-build.yml.
Never from a branch
The job runs only when github.ref == 'refs/heads/main'. npm Trusted Publishing pins the workflow
filename, not the ref. Without the guard, a workflow_dispatch from any branch would publish
that branch's tree under whatever version its package.json declares. This has already happened to
the design system: on 2026-07-26 a feature branch burned 0.9.0 and 0.9.1 with unmerged code. The
push to main that should have released the real 0.9.0 then found the version taken and skipped,
so a green run shipped nothing. Dispatching on main is still allowed, for re-running a failed release.
Two PRs, one version number
The "skip if published" check protects against a merge without a bump. It does not protect against
two PRs that bump to the same number. The second one to merge finds the version already on npm, the
job skips, and its code sits on main without ever being published. Nothing turns red.
This happened on the design system on 2026-09-19. Two PRs both carried 0.24.0. The first published,
so npm's 0.24.0 contains only half of what main contains, and nobody noticed until Spunto Lite
installed it and a just-added state was missing.
The fix is a release PR that does nothing but bump the version again. Do not npm unpublish, and do
not republish the same number (npm refuses that anyway).
The first version of a new package is published by hand
A trusted publisher can only be declared on a package that already exists, and a package only exists after its first publish. So the workflow cannot create a package. It can only take over from the next version:
cd packages/build && npm login && npm publish # publishConfig.access is already publicThen declare the trusted publisher in the package settings on npmjs.com. @spunto/build@0.1.0 was
bootstrapped this way.
No provenance, deliberately
No @spunto/* version carries an npm provenance attestation, and --provenance is passed nowhere.
In July 2023, GitHub dropped provenance for packages built from private source repositories, and
this repository is private. With the flag, the publish would fail instead of producing an
attestation. Revisit this only if the repository goes public.
Versioning
The package is pre-1.0 (0.x). In practice, new modules and new exports arrive as minor bumps.
For example, organization skills (buildSkillsScript) arrived in 0.10.0 and the worker status
vocabulary in 0.7.0. Fixes ship as patch bumps. Consumers use caret ranges, which under 0.x
accept patches but not the next minor:
| Consumer | Range at the time of writing |
|---|---|
Spunto Cloud apps/api | ^0.10.0 |
Spunto Cloud apps/next | ^0.7.0 |
Spunto Cloud apps/agent | ^0.3.0 |
| Spunto Lite | ^0.10.0 |
Different apps can sit on different versions. The node agent, which only imports ./naming and
./steps, has no reason to move every time a generator changes.
A version number says nothing about the formats. A change to a marker, a name, a path or a
SetupStatus field affects things that already exist: containers created under the old name,
status files written by an old image, logs stored before the change. Whatever the semver bump,
plan it as a migration. See Formats.
Why by version, and not a workspace
A workspace link would make Spunto Cloud a privileged consumer. Cloud could change the package and validate it in a closed loop without ever going through what Lite goes through. Consumed by version, both products are equal clients of the same published artifact. That is the difference between a shared package and a monolith with a subfolder.
The cost is the window between the PR that publishes and the PR that adopts, during which the code exists twice. That cost is accepted on purpose.