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
| Question | Mechanism |
|---|---|
| Whether a module bumps | the payload digest, exact, from the bytes |
| At what level | the edge graph — declared fragments, then propagation |
| Whether a changelog line is requested | the 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 istelo 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:
| Kind | Level |
|---|---|
Added, Deprecated | minor |
Fixed, Security | patch |
Changed, Removed | major — 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 recordedbuild.inputsno 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 runtelo 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 installandtelo upgradewrite 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'spath=, 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.