Releases and changelogs
A release names a point in a package's history as a version, for good. tcm picks the version and writes the list of changes from the codebase: semver checks, in the manner of cargo-semver-checks, compare the package's API with its last release. So the notes can't miss an API change or list one that didn't happen. Behaviour isn't checked: a change that keeps the API is a patch (limitations). 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`
Making a release
tcm release [package]- releases at the version its changes call for. The package can be left out when there's one. The first release is
0.1.0 tcm release <package>@<version>- releases at a higher version, to declare an API stable (
1.0.0, say). A smaller version than the changes call for is refused tcm release check [package]- shows the next release and why: each finding, the lint it trips, what it means for code using the package, and why releasing would be refused, if it would (exits 1)
tcm release view <package>@<version>- prints a release's notes (
@<version>alone with one package) tcm releases- lists every package and its releases
- Refused while there's work to do: conflicts or broken definitions in
tcm todo, or, for a tcm library, definitions it uses from outside its namespace.--forcereleases anyway (also past a too-small version). - Releases don't change. Making a version again is refused, and so is syncing with a codebase that has a different release under the same version.
- Releases sync with
tcm pushandtcm pull, along 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 keeps the branch's history, even with
--squash(history), so the next release follows it there too. - Releases work as history entries:
tcm diff geo@0.1.0,tcm reset geo@0.1.0.tcm pack geo@0.2.0packs one (publishing);tcm pack geoon a branch exactly at a release packs that release.
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, naming the version's namespace hash and where to fetch it.
Your repository signs its records, so the release is provably 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 holds the package and version; the namespace's whole hash as released (what
tcm lib addmounts, libraries); the history entry it names and the hash format; the notes (at most 100,000 bytes, or the release is refused); the sources; and when it was made. Its key is<package>:<version>, so one record per version. The lexicons for it, the attestations and stars are in therecordslibrary (libraries), with tcm.codes as their authority. - Sources are
tcm serveservers (sync) people fetch it from, in order.--source <url>(repeatable, up to 16, each at most 2048 characters) is kept for the package's next announces. Without it: the kept ones, elseorigin'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 reported but the announce goes ahead:tcm pushsends it there. Sources need no trust: what they send is re-hashed. - Existing versions:
tcm release geo@1.1.0 --announceannounces one made earlier, or one whose announce failed. A record that already says the same is left alone; one with different sources gets the new ones; one naming another namespace for the version is refused. - If writing the record fails, the release still stands (sources and sign-in are settled first):
tcm release geo@1.2.0 --announceretries. - People add it by your handle:
tcm lib add @alice.dev/geo@1.2.0, and upgrade to what you announce next (announced releases).
Signing in
- In your browser, with AT Protocol OAuth. tcm 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 attestwrites: what people say). It accepts only its own sign-in's answer (RFC 9207), and the server's endpoints must be https.$BROWSER, if set, opens the page. --as <handle>(or a DID) picks the account the first time; later, the last one used is the default.- The sign-in is kept in
~/.config/tcm/atproto.json($XDG_CONFIG_HOME/tcm), readable only by you (a link or someone else's file isn't read; one others could read is made private). Tokens refresh as they expire. Delete the file to sign out. - Or with an app password, no browser (CI, a server over ssh). Make one in your account's settings (Bluesky:
Settings, Privacy and security, App passwords) and give it once, with
--as:TCM_ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx tcm announce --as alice.dev. tcm signs in with it (com.atproto.server.createSession), keeps it in the same file, and signs in again by itself when tokens run out, for every command that writes tcm's records. Revoking the app password signs it out everywhere. $TCM_ATPROTO_DIRECTORYresolves identities in a PLC directory that also resolves handles, instead of DNS, HTTPS and plc.directory: for a private or test network. Such networks' servers are usually local and plain http, which tcm refuses unless$TCM_ATPROTO_LOCAL=1too. Otherwise a PDS must be an https server's address alone, and servers a DID or handle names are asked only at public addresses, without following redirects.
Releases kept in git
A codebase that mirrors a directory in a git repository keeps its releases with the files.
Releasing writes tcm-releases.json in the mirrored directory (version, notes, author, date, and the commit
holding the files as released), and is refused while the mirror has changes git doesn't have. Commit the record
with the files.
Linking a codebase to the mirror afresh (tcm import <dir> --into ns --mirror) makes the recorded releases again
first: each one's commit is imported from git in order, and the release put at the history entry that makes, with
its notes. Hashes don't depend on where or when code is imported, so the releases name the same definitions, and the
next release's semver checks compare against them.
Release notes
A release's notes are a heading, a summary you 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 docs. A definition only re-hashed because something it uses changed isn't listed.
- Contributions: the contributions merged since the last release that changed this
package, read from this codebase and from
origin(or--remote <name>).
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
Write above the marker in release-geo.md; tcm release geo takes your words and deletes the file. Everything
below the marker is regenerated when the release is made, so editing it does nothing, and changes since the draft
are included. --notes <file> takes the summary from another file.
Changelogs
tcm changelog [package] prints a package's release notes, newest first, as markdown; --out CHANGELOG.md writes a
file. tcm pack puts it in the package as CHANGELOG.md. It's made from the releases, so it's never hand-edited
and never out of date.
Semver checks
Each changed definition the package exports is compared, as at the last release and as now, with the TypeScript checker, from the side of code using it: every use that typechecked against the old version must still. Gaining an optional parameter is an extension (old calls still work); gaining a required parameter, accepting less for a parameter, or returning more is breaking. Types are compared by their cases and properties, and as a whole.
The version follows semver as npm and Cargo read it. From 1.0.0: major for breaking changes, minor for additions
and extensions, patch otherwise. Before 1.0.0: minor for breaking changes, patch for anything else.
| Lint | Calls for | Finding | What it means for code using it |
|---|---|---|---|
definition_removed | major | removed | code that uses it no longer compiles |
definition_renamed | major | renamed | code that imports it by its old name no longer compiles |
export_moved | major | is exported from a different subpath | imports of it from the old one no longer compile |
bin_removed | major | command removed | scripts that run it fail |
bin_added | minor | command added | new API |
kind_changed | major | is now a type rather than a value, or the other way around | code that uses it the old way no longer compiles |
function_became_value | major | can't be called any more | calls to it no longer compile |
function_required_parameter_added | major | takes a required parameter it didn't | calls written for the old version are missing an argument |
function_parameter_removed | major | takes fewer parameters | calls that pass the removed argument no longer compile |
function_parameter_made_required | major | requires a parameter that was optional | calls that leave it out no longer compile |
function_parameter_type_narrowed | major | accepts less for a parameter | arguments the old version accepted may not be accepted |
function_return_type_widened | major | can return more kinds of value | callers may get a value they don't handle |
function_type_parameter_count_changed | major | has a different number of type parameters | explicit type arguments no longer match |
function_signature_incompatible | major | has a signature old calls don't fit | some calls written for the old version no longer compile |
constructor_incompatible | major | is constructed differently | new written for the old version no longer compiles |
value_type_widened | major | can hold more kinds of value | code reading it may get a value it doesn't handle |
type_property_removed | major | lost a property | code reading the property no longer compiles |
type_required_property_added | major | has a new required property | values written for the old type lack it |
type_property_made_optional | major | has a property that may now be missing | code reading it may get undefined |
type_property_made_required | major | requires a property that was optional | values written for the old type may lack it |
type_property_made_readonly | major | has a property that's now readonly | code assigning to it no longer compiles |
type_property_type_changed | major | has a property of a different type | code reading or writing the property may no longer compile |
type_union_member_added | major | has a new case | code handling every case no longer does |
type_union_member_removed | major | lost a case | values of the removed case no longer fit |
type_type_parameter_count_changed | major | has a different number of type parameters | uses with type arguments no longer match |
type_changed | major | changed | code using it may no longer compile |
definition_added | minor | added | new API |
function_optional_parameter_added | minor | takes a new optional parameter | old calls still work |
function_parameter_made_optional | minor | no longer requires a parameter | old calls still work |
function_parameter_type_widened | minor | accepts more for a parameter | old calls still work |
function_return_type_narrowed | minor | returns a narrower type | what old callers expect still fits |
function_signature_extended | minor | accepts more (a new overload, say) | old calls still work |
value_type_narrowed | minor | holds a narrower type | what old readers expect still fits |
type_optional_property_added | minor | has a new optional property | old values still fit |
type_property_made_writable | minor | has a property that's no longer readonly | old uses still work |
implementation_changed | patch | changed, with the same API | uses still compile; behaviour may differ |
A change to a type the API mentions is shown where the type is, not repeated at each definition using it. When a change in behaviour deserves more than a patch, release at a higher version.