Skip to main content

Releasing modules

telo release versions the modules of a workspace. It exists because a Telo module's artifact embeds its dependencies: esbuild inlines a sibling library's source into the controller bundle, and publishing pins each relative import to a hash of that sibling's manifest. npm has no such property — a dependent's tarball does not contain its dependency — so where npm treats "bump the dependents" as a courtesy, here it is a correctness requirement.

The workspace​

A workspace declares itself with a telo-workspace.yaml at its root. Its location is the anchor: every module key, ledger entry and fragment path is relative to that directory.

# telo-workspace.yaml
release:
modules:
- modules/*
- apps/*

release.modules names the subtrees that may hold modules. That is not derivable — a whole-tree scan would read every example, starter and cached .telo/manifests/** copy as a released module. It sits inside release: because that is what it is: a release inventory, not an inventory of manifests.

The file is optional, and so is every block in it. check, publish, install, upgrade, migrate and module behave identically without one, and the kernel only ever reads its location. telo run reads the location too, plus an env: block if one is declared — see Workspaces for every key the file carries, including per-subtree publish destinations.

Within those subtrees, a module is a directory holding a telo.yaml whose module doc carries a metadata.version. Nothing registers the set. A module's key is its workspace-relative path (modules/sql), never a bare name — two directories in different subtrees can share a name, and a path cannot.

A Dockerfile beside the manifest makes a module an image module; its absence, a registry artifact module. That decides only which file set is digested.

The three questions​

QuestionMechanism
Whether a module bumpsthe payload digest, exact, from the bytes
At what levelthe edge graph — declared fragments, then propagation
Whether a changelog line is requestedthe path-scoped changed-files rule

The digest is what sees a change no path rule can: an inlined sibling's edit, a fix in a shared TypeScript library, a transitive bump the lockfile alone moved. The graph is what says a Fixed on modules/sql makes modules/sqlite a Fixed too. And the changed-files rule — which used to decide the version, and whose guesswork therefore had to be sound — now only asks for prose, so a false positive costs one sentence rather than a spurious republish.

Edges come from two places:

  • Inlined files, read from the controller build's own metafile. A declared-dependency graph cannot see --external, so @telorun/sdk (declared by 54 modules, inlined by none) would otherwise bump the whole standard library on every SDK change.
  • In-repo relative imports:. A pinned remote ref is deliberately not an edge: pinning is the statement "I am not affected until I choose to be", and moving it is telo upgrade's job.

A payload that moved with nothing to attribute it to — a third-party dependency, a changesets-owned package, a toolchain bump — takes a patch and is reported as unattributed. It is never silent, and it never asks anyone to write prose.

The ledger​

.changes/ledger.yaml records, per module, its version and its per-layer integrity as published, plus the registry base those digests were taken against.

modules:
modules/sql:
version: 0.21.0
registry: oci://ghcr.io/telorun
layers:
manifest: sha256-4f1c…
controller/js: sha256-9ab7…

Every entry records its own base, because a workspace may publish its subtrees to different ones.

That is what lets the PR gate and the publish gate compute the same number. The gate reads the ledger, so it needs no merge base, no shallow-clone workaround and no credentials — a fork's PR runs the identical computation the release job runs.

The ledger is a cache; the registry is the authority. A committed digest can disagree with what is published (a hand edit, an apply whose publish then failed, a push made outside the pipeline), so telo publish still reads the registry and telo release verify reconciles the two on demand. A missing entry is not drift — it means nothing is published, which is the right reading for a new module.

verify compares the ledger against the registry, not your working copy against it. Those are different questions: the working copy differs from what is published on any commit after a release, which is normal and is what check plans a bump for.

One key is deliberately outside that reconciliation. The manifest digest has no entry in the published layers: index — that index lives inside telo.yaml, so it cannot carry that file's own digest — and the index is what verify reads. It stays in the ledger regardless: it is the only thing that sees a manifest-only change (a schema edit, a new kind, a dependency's version moving into a pin), none of which touch a controller byte. So verify compares and rewrites the layers the registry can answer for and preserves that one; apply is what writes it.

The base is recorded because canonicalization writes the destination into every relative import, so digests taken against another base are digests of different bytes.

Seeding it​

An empty ledger is a valid state: every module reads as never-published, so nothing drifts and only declared fragments and their propagation appear in a plan. That is correct but blind — the digest half of the system is switched off until the ledger knows what is out there. Seed it once, against the registry:

$ telo release verify --write --registry oci://ghcr.io/telorun

That is also the repair for a ledger that has drifted out of step with what is actually published. It records everything the registry can answer for; the manifest digest is filled in by the first apply, and until then check reads its absence as drift and plans a patch — the safe direction, since a bump that was not needed costs a version while a missed one ships to nobody.

Writing a fragment​

# .changes/pending/fixed-parameters-are-bound-positionally.yaml
modules:
modules/sql: Fixed
body: Parameters are bound positionally on every dialect.

One file, several modules, one body — so a cross-cutting change is one fragment rather than one per module repeating a sentence. The kind: drives both the level and the changelog section:

KindLevel
Added, Deprecatedminor
Fixed, Securitypatch
Changed, Removedmajor — rejected

Modules are intentionally pre-1.0, so a breaking change ships as a minor (Added, with the break described in the body). A major-inducing kind is a hard error.

Write one with:

$ telo release add --module modules/sql --kind Fixed \
--body "Parameters are bound positionally on every dialect."

The commands​

$ telo release status     # what would bump, and why
$ telo release check # the same computation, with an exit code
$ telo release order # modules in publish order (a dependency first)
$ telo release apply # write versions, changelogs and the ledger
$ telo release verify # reconcile the ledger against the registry
$ telo release stage # fetch and verify the files `sources:` declares

status prints the plan with its attribution:

modules/sql             0.21.0 → 0.21.1  patch — declared
modules/kv-store-sql 0.7.0 → 0.7.1 patch — inlines modules/sql/nodejs/src/query.ts
modules/sql-repository 0.4.0 → 0.4.1 patch — imports modules/sql
modules/test 0.9.3 → 0.9.4 patch — payload changed, unattributed

check does not fail because a payload moved. Under a toolchain bump every module's digest moves; demanding sixty hand-written fragments for that would be a tax on nobody's behalf. It fails when no consistent plan can be formed:

  • a fragment naming a module that does not exist,
  • a major-inducing kind,
  • a manifest version that disagrees with its ledger entry,
  • digests taken against a different registry base,
  • a source built from a crate (build: { cargo: <dir> }) whose recorded build.inputs no longer match the crate's tracked files, the files of the path crates it reaches, the lock packages they reach and the cargo configuration that applies to the build — or that records none. Rebuild the files and run telo release stage --pin. See Native Files.

It warns — CHANGELOG_ENTRY_REQUESTED — when a module's own files changed and no fragment names it.

apply writes the new version into every manifest the module owns (telo.yaml, nodejs/package.json, rust/Cargo.toml), appends to each changelog, re-records the ledger with the digests of what will actually be published, and deletes the fragments it consumed. Version rewriting is a byte-splice over the author's own text, so a bump lands as a one-line diff rather than a re-serialized file.

A module with a Rust half also has its entry moved in the Cargo.lock that records the crate — its own, else the nearest one above it. A lockfile left behind still names the version the crate had, so every cargo --locked invocation has to re-resolve, which means reaching the network, which --locked forbids: the build fails on whichever machine runs a locked cargo command before an unlocked one has silently repaired the file.

stage fetches the prebuilt files a module's sources: block declares from their pinned upstream archives and verifies each against its sha256 and executable bit; a file already on disk and matching its pin is not fetched. stage --pin fetches every file entry and writes those pins into telo.yaml first, as a byte splice, together with the build-input digest of each source built from a crate, and writes nothing unless the edited manifest reads back with exactly those pins. --module <path> narrows either to named modules. A kernel running from a source checkout stages the one file it needs on first use, so running a manifest needs no stage; publish, which reads every tuple, does. See Native Files.

Consumers in the same repository​

An example, a starter or an application that lives beside the modules it uses normally imports them by pin. While a change to a module is unreleased, the consumer that needs it imports the module by relative path instead — and should name the published artifact again once the change ships.

telo upgrade --pin-local <manifests..> does that. For each relative import whose target is a module of the workspace, it asks the release plan:

  • the plan bumps the module — the published artifact is not the working copy, so the import stays relative and the plan's reason is printed (kept local — modules/sql has an unreleased change (0.27.0 → 0.27.1: declared));
  • the plan does not, and that version exists at the registry — the import is rewritten to <destination>@<version> with its integrity pin;
  • the version is not at the registry yet — it stays relative until the next run.

A dependent held back by a sibling says so: imports modules/sql in the reason names the module that has to ship first. Only the manifests you name are rewritten; a released module's own relative imports are what its release is planned along, and stay.

Reading your own version​

A manifest can read the module doc's metadata instead of restating it:

serverInfo:
name: telo-hub
version: !cel "module.version"

module.<field> is typed per field and closed, so module.verison is a diagnostic rather than an empty string at runtime. An imported library reads its own metadata, not its importer's. The loader's derived stamps (source, sourceLine, and the analyzer's indices) are not exposed.

The field must be a CEL slot (x-telo-eval) like any other expression.

What publishing assumes​

Two of the release system's invariants are enforced at publish:

  • Every remote import carries an author-written pin. telo install and telo upgrade write it. Publishing an unpinned remote import is refused rather than resolved over the network — that is what makes the published bytes a pure function of the commit, and it retires a best-effort branch that shipped a silently unpinned artifact whenever a fetch failed. Existing pins are verified against the registry, and a mismatch is fatal.
  • The controller is built from source, by the kernel. The path= file in a working copy is a gitignored build artifact; reading it would digest and ship bytes other than the source the manifest names. On this path a host without esbuild is a hard failure, not a fallthrough.

Staged files​

A file a sources: entry stages is digested from its pin, not from disk, by every command that computes a payload digest — status, check, apply, order. A layer's integrity is one line per entry, and a pinned file contributes exactly the line its bytes would, so a tree where nothing is staged — a fork's PR checkout, a fresh clone — computes the same integrity publish derives from the staged bytes and the registry serves.

A layer's blob addresses framed bytes and cannot be computed from a pin, so a layer holding a staged file takes a stand-in: sha256: plus the hex form of its integrity digest, deterministic on every tree. Only the manifest ledger digest — the one entry verify never reconciles — sees it.

telo publish does not fetch. It reads every staged file off disk, verifies it against its pin, and frames the real bytes, so every blob and every sibling pin it ships names bytes the registry holds. The module publish job runs telo release stage over the modules it pushes and every in-repo module they import — a dependent's pin is derived from its sibling's published bytes — before pushing anything. Publish refuses:

  • a staged file that is missing or whose bytes or executable bit do not match its pin (run telo release stage), or that carries no pin (run --pin);
  • a native: path, or a platform-qualified controller candidate's path=, that no source stages and git does not track — a binary nobody committed and nothing fetches is one the next checkout cannot reproduce;
  • a sources: block that does not read, including a source naming no notices, and a source entry nothing in the manifest names.

The published telo.yaml carries no sources: block. It is removed, as include: is, before any dependent derives its pin from the text, so editing only a source's url — a moved mirror, the same bytes — moves no digest and bumps nothing. Each source's notice files ship in the common layer, and a staged file an assets: pattern selects in the assets layer, with no files: entry.

A LICENSE file at the module root ships in the common layer the same way, so the artifact carries the text metadata.license names. Keep it beside telo.yaml: a license file in a parent directory is outside the module and is not published. telo package carries it into a packaged application too.

npm packages​

@telorun/kernel, cli, sdk, analyzer and the other infrastructure packages own no module manifest and stay on changesets. Every package under modules/*/nodejs/ is on the module ledger instead — its version is its module's, written by telo release apply — and is listed in .changeset/config.json's ignore so the two systems never write the same field.

Most of those packages are private: the controller is bundled into the module artifact, so nothing is fetched at load and there is no tarball to push. A module that cannot be bundled — one whose driver resolves a native binary relative to its own package directory — names pkg:npm/<name>@<version> in controllers: instead, and that tarball has to exist for any consumer outside this checkout.

telo publish pushes it, before the manifest that names it, and skips it when npm already has that exact version. It is the same step --skip-controllers turns off. Being on the module ledger, the pin, the package and the module all carry one version: telo release apply rewrites the module's own pkg:npm pin along with metadata.version, and publish refuses when the three disagree rather than shipping a manifest that names a tarball nobody has. That is the failure this closed — an npm-delivered module went on pointing at a years-old version of itself after its package moved to the module ledger and no publisher took over.