SpuntoSpunto/@spunto/build

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 mode

The 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 version

Merge 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
fi

A 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 public

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

ConsumerRange 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.

Edit on GitHub

On this page