Releases and changelogs — tcm-ts

24. Releases and changelogs

A release names a point in a package's history as a version, for good, as on Unison Share. Its notes are markdown kept in the codebase, and the part that lists what changed is written by tcm from the codebase, so it can't leave out a change to the API, or list one that didn't happen (behaviour isn't checked: a change that keeps the API is a patch; see limitations). The version is decided the same way: semver checks, in the manner of cargo-semver-checks, find what changed in the package's API since its last release and what that calls for. A codebase can hold any number of packages, each with its own releases.

$ tcm release check geo
geo's next release: 0.2.0 (after 0.1.0): breaking changes

  major  geo.Point has a new required property: label
         type_required_property_added: values written for the old type lack it

  minor  geo.distance takes a new optional parameter: digits
         function_optional_parameter_added: old calls still work

$ tcm release geo
released geo@0.2.0 as #nbbd6qqu, after 0.1.0: breaking changes

## 0.2.0 (2026-10-29)

### Breaking changes

- `geo.Point` has a new required property: `label`

### Extended

- `geo.distance` takes a new optional parameter: `digits`

24.1 Making a release

  • tcm release [package] releases a package at the version its changes call for. The package's name can be left out when the codebase has one. Its first release is 0.1.0.
  • tcm release <package>@<version> releases at a version further than that, 1.0.0 say, to declare an API stable. A version smaller than the changes call for is refused (--force releases anyway).
  • tcm release check [package] shows what the next release would be and why: each finding, the lint it trips, and what it means for the code using the package; and, if releasing it would be refused as it stands, why (exiting 1).
  • A release is refused while the package has work left in tcm todo: conflicts, or definitions that stopped typechecking; or, for a tcm library, while it uses definitions outside its namespace (--force releases anyway).
  • Releases don't change. A version names one release; making it again is refused, and so is syncing with a codebase that has a different one under the same version.
  • Releases sync with tcm push and tcm pull, with the history they name.
  • A release follows the one in its branch's history. One made on a branch is that branch's until it lands: landing it keeps the branch's history, even with --squash (history), so the next release after it follows it there too.
  • tcm diff and tcm reset take a release (tcm diff geo@0.1.0) wherever they take a history entry, and tcm pack geo@0.2.0 packs one (publishing); tcm pack geo on a branch exactly at a release packs it as that release.

24.2 Announcing a release

tcm release --announce announces a release on the AT Protocol (the network Bluesky runs on): a codes.tcm.release record in your own repository there, saying which namespace hash the version is and where it can be fetched from. Your repository signs its records, so a release is known to be yours by your DID, and anyone can find it without a registry.

$ tcm release geo --announce --as alice.dev --source https://tcm.alice.dev
signing in as alice.dev, in the browser (if it doesn't open: https://bsky.social/oauth/authorize?…)
released geo@1.2.0 as #nbbd6qqu, after 1.1.0: additions
…
announced geo@1.2.0 as @alice.dev: at://did:plc:…/codes.tcm.release/geo:1.2.0
  fetched from https://tcm.alice.dev
  https://tcm.alice.dev doesn't have it yet: `tcm push` sends it
{
  "$type": "codes.tcm.release",
  "package": "geo",
  "version": "1.2.0",
  "namespace": "…",
  "entry": "…",
  "hashFormat": 2,
  "notes": "## 1.2.0 (2026-11-03)\n…",
  "sources": ["https://tcm.alice.dev"],
  "createdAt": "2026-11-03T12:00:00.000Z"
}
  • The record says the package and version; the namespace's whole hash, as released (what tcm lib add mounts for that release, libraries); the history entry it names, and the hash format the hashes are in; the notes (at most 100,000 bytes: a release with more is refused); the sources; and when the release was made. Its key is <package>:<version>, so a version is one record. Its lexicon, and the attestations' and stars', are in the records library (libraries), tcm.codes being their authority.
  • Sources are servers people fetch it from (tcm serve, sync), in order: --source <url> (up to 16, each at most 2048 characters) is kept, for that package's next announces; without it, the ones kept, else origin's URL if it's a server. With none, the announce is refused. A source that doesn't have the release yet, or doesn't answer, is said (it's announced anyway): people can fetch it from there once it's pushed. What sources send is re-hashed as it arrives, so they need no trust.
  • Signing in is AT Protocol OAuth, in your browser. tcm is a loopback client: it listens on 127.0.0.1 for the browser to come back, and asks only to write tcm's records (releases, and the reviews, advisories and yanks tcm lib attest writes: what people say). It takes only its own sign-in's answer (its state, from the server it asked, which must say so: RFC 9207), and the server's endpoints must be https. The first time, --as <handle> (or a DID) says which account; the sign-in is then kept for you in ~/.config/tcm/atproto.json ($XDG_CONFIG_HOME/tcm), readable only by you (a file that's a link, or someone else's, isn't read; one others could read is made private), and its tokens are refreshed as they expire, so the next announce doesn't ask. Without --as, it's the account used last. Deleting the file signs out. $BROWSER, if set, opens the page.
  • Sources and the sign-in are settled before the release is made. If writing the record fails after that, the release stays made, and tcm release geo@1.2.0 --announce announces it.
  • An existing version (tcm release geo@1.1.0 --announce) is announced as it is: one made before, or one whose announce failed. A record that already says the same is left as it is; one whose sources differ gets the new sources; one naming another namespace for the version is refused, since a version names one release.
  • People add it by your handle: tcm lib add @alice.dev/geo@1.2.0, and upgrade to what you announce next (announced releases).
  • $TCM_ATPROTO_DIRECTORY looks identities up in a PLC directory that also resolves handles, instead of DNS, HTTPS and plc.directory: for a private network, or a test's. Such a network's servers are usually local and plain http, which tcm refuses unless $TCM_ATPROTO_LOCAL=1 too: otherwise a PDS must be an https server's address alone, and the servers a DID or a handle names are asked only at public addresses, not following redirects.

24.3 Releases kept in git

A codebase that mirrors a directory in a git repository can be made again from its files, so its releases are kept with them. Releasing records the release in tcm-releases.json in the mirrored directory (its version, notes, author and date, and the commit holding the files as released), and is refused while the mirror has changes git doesn't have yet. Commit the record with the files.

A codebase linked to the mirror afresh (tcm import <dir> --into ns --mirror) makes the recorded releases again before importing the files as they are: each one's commit is taken from git and imported, in order, and the release is put at the history entry that makes, with its notes as they were. Hashes don't depend on where or when code is imported, so the releases name the same definitions they did, and the next release's semver checks compare against them.

24.4 Release notes

A release's notes are a heading, a summary people write, and the list of changes tcm writes:

  • Breaking changes, Added, Extended, Changed: what the semver checks found, by name. An addition shows its signature and the first sentence of its documentation. A definition that only changed because something it uses did (re-hashed, code untouched) isn't listed again.
  • Contributions: the contributions merged since the last release that changed this package. They're read from this codebase and from origin (or --remote <name>), wherever they were merged.

To write the summary, draft the release first:

$ tcm release geo --draft
wrote release-geo.md (geo's next release, 0.2.0 as things stand): write what it's about above the marker, then `tcm release geo`; the changes below the marker are regenerated

release-geo.md is a markdown scratch file: write above the marker, and tcm release geo takes your words from it (and deletes it; they're in the release now). Everything below the marker is written again from the codebase when the release is made, so editing it changes nothing, and changes made since the draft are in. --notes <file> takes the summary from another file.

24.5 Changelogs

tcm changelog [package] prints a package's releases' notes, newest first, as markdown; --out CHANGELOG.md writes it to a file. tcm pack puts it in the package as CHANGELOG.md. The changelog is made from the releases, so it's never edited by hand and never out of date. tcm releases lists every package and its releases.

24.6 Semver checks

The checks compare each changed definition the package exports, as it was at the last release and as it is now, with the TypeScript checker, from the side of the code that uses it: every use that typechecked against the old version must still. A function that gains an optional parameter is extended (old calls still work); one that gains a required parameter, or accepts less for a parameter, or can return more, is breaking. Types are compared by their cases and properties, and as a whole.

The version they call for follows semver, as npm and Cargo read it: from 1.0.0, a major release for breaking changes, a minor one for additions and extensions, and a patch otherwise. Before 1.0.0, a minor release for breaking changes and a patch for anything else.

LintCalls forFindingWhat it means for code using it
definition_removedmajorremovedcode that uses it no longer compiles
definition_renamedmajorrenamedcode that imports it by its old name no longer compiles
export_movedmajoris exported from a different subpathimports of it from the old one no longer compile
bin_removedmajorcommand removedscripts that run it fail
bin_addedminorcommand addednew API
kind_changedmajoris now a type rather than a value, or the other way aroundcode that uses it the old way no longer compiles
function_became_valuemajorcan't be called any morecalls to it no longer compile
function_required_parameter_addedmajortakes a required parameter it didn'tcalls written for the old version are missing an argument
function_parameter_removedmajortakes fewer parameterscalls that pass the removed argument no longer compile
function_parameter_made_requiredmajorrequires a parameter that was optionalcalls that leave it out no longer compile
function_parameter_type_narrowedmajoraccepts less for a parameterarguments the old version accepted may not be accepted
function_return_type_widenedmajorcan return more kinds of valuecallers may get a value they don't handle
function_type_parameter_count_changedmajorhas a different number of type parametersexplicit type arguments no longer match
function_signature_incompatiblemajorhas a signature old calls don't fitsome calls written for the old version no longer compile
constructor_incompatiblemajoris constructed differentlynew written for the old version no longer compiles
value_type_widenedmajorcan hold more kinds of valuecode reading it may get a value it doesn't handle
type_property_removedmajorlost a propertycode reading the property no longer compiles
type_required_property_addedmajorhas a new required propertyvalues written for the old type lack it
type_property_made_optionalmajorhas a property that may now be missingcode reading it may get undefined
type_property_made_requiredmajorrequires a property that was optionalvalues written for the old type may lack it
type_property_made_readonlymajorhas a property that's now readonlycode assigning to it no longer compiles
type_property_type_changedmajorhas a property of a different typecode reading or writing the property may no longer compile
type_union_member_addedmajorhas a new casecode handling every case no longer does
type_union_member_removedmajorlost a casevalues of the removed case no longer fit
type_type_parameter_count_changedmajorhas a different number of type parametersuses with type arguments no longer match
type_changedmajorchangedcode using it may no longer compile
definition_addedminoraddednew API
function_optional_parameter_addedminortakes a new optional parameterold calls still work
function_parameter_made_optionalminorno longer requires a parameterold calls still work
function_parameter_type_widenedminoraccepts more for a parameterold calls still work
function_return_type_narrowedminorreturns a narrower typewhat old callers expect still fits
function_signature_extendedminoraccepts more (a new overload, say)old calls still work
value_type_narrowedminorholds a narrower typewhat old readers expect still fits
type_optional_property_addedminorhas a new optional propertyold values still fit
type_property_made_writableminorhas a property that's no longer readonlyold uses still work
implementation_changedpatchchanged, with the same APIuses still compile; behaviour may differ

A change to a type the API mentions shows where the type is, and isn't repeated at each definition that uses it. Behaviour isn't checked: a definition whose code changed with the same API is a patch, implementation_changed. Release at a higher version when a change in behaviour deserves one.

built from c482a14e