Skip to content

Changelog & versioning

The changelog engine is native — no second tool to install in CI, no config file to keep in sync with the workspace. Changes are recorded as small TOML fragments, one per change; version bumps and changelog sections are derived from them.

trellis changelog new [--package <pkg>] --kind <kind> --body <text>
trellis changelog check --base <ref> [--head <ref>] [--format text|json|github] [--strictness error|warn|off]
trellis version plan [--json]
trellis version apply [--json]

A fragment is one file in .changes/unreleased/ with three keys — the package it belongs to, a configured change kind, and the entry text:

.changes/unreleased/lat_core-add-graph-parallel-task.toml
package = "lat_core"
kind = "Added"
body = "Add graph-parallel task scheduling"

package was spelled project through v0.7.0. Both spellings parse, so fragments already sitting in .changes/unreleased/ keep working, but changelog new writes package and the alias goes away at 1.0.

changelog new writes one non-interactively — --kind and --body are explicit flags, which suits CI and agents as well as shells (--package can be omitted when the workspace has exactly one releasable package):

Terminal window
$ trellis changelog new --package lat_core --kind Fixed \
--body "Reject path deps that escape the workspace"
created .changes/unreleased/lat_core-reject-path-deps-that.toml

The filename is the package plus the first few words of the body, so a directory of fragments reads as a list of pending changes and two branches rarely pick the same name. A clash takes the next free -2, -3 suffix.

Invalid fragments — unknown package, kind, or category, empty body, unparseable TOML — are hard errors for check, plan, and apply, and doctor flags them on every PR.

A fragment takes one more optional key, category, when the workspace configures categories — a second grouping axis that carries no version bump and sections the changelog by which part of a package changed:

Terminal window
$ trellis changelog new --package lat_cli --kind Added --category build \
--body "A --watch flag"

changelog check maps a base...head diff to packages and fails (non-zero exit) if a changed releasable package has no unreleased fragment.

Terminal window
$ trellis changelog check --base origin/main
lat_core: 2 fragment(s)
lat_cli: needs a changelog entry

A package counts as changed when the diff touches any file under its directory — a README edit trips the same gate as a behavior change. That makes the gate stricter than some repositories want; strictness turns it down.

check counts only the fragments the branch itself wrote: those whose contents differ from the merge base of base...head, whether the branch added them outright or edited what the base branch already had. Fragments the branch left untouched document the PRs that added them.

This is what makes the gate per-PR. A package with an unreleased fragment from an earlier PR does not satisfy the check for a later PR that touches the same package — otherwise one entry excuses every change until the next release. It also keeps the PR comment honest: version is the bump this PR causes rather than what the accumulated backlog adds up to, and a package whose only unreleased fragment came from the base branch shows .

Comparing contents rather than reading the diff means an uncommitted fragment counts too, so running check locally before committing gives the same answer CI gives afterwards.

Invalid fragments are not scoped. A fragment that does not parse fails check wherever it came from, because it blocks the next release for everyone — scoping it would leave every PR green while the release stayed stuck.

Upgrading from v0.9.0 or earlier. The counts were previously unscoped, so a PR touching a package that already had an unreleased fragment passed without adding its own. Those PRs now report a missing entry, and the payload advanced to trellis.changelog_check/2. Set strictness to warn to land the reporting without failing builds while the open PRs catch up.

changelog.strictness sets what a missing entry costs:

Value Effect
error Default. A missing entry fails the run.
warn Reported, exit 0 — advisory, so the comment still appears but the check stays green.
off Not checked. Packages and fragment counts are still reported.
gleam.toml
[tools.trellis.changelog]
strictness = "warn"

--strictness error|warn|off overrides it for one run, so a workflow can gate harder on ready-for-review PRs than the workspace default without editing the manifest.

Strictness governs missing entries, not broken ones. A fragment that does not parse, or that names an unknown package or kind, fails check at every setting including off — that is malformed input, not a policy call.

--format json emits a payload (including a markdown preview) ready for a PR sticky comment:

{
"schema": "trellis.changelog_check/2",
"ok": false,
"strictness": "error",
"has_entries": true,
"needs_entry": true,
"invalid_fragments": [],
"packages": [
{ "name": "lat_core", "changed": true, "has_entry": true, "fragments": 2 },
{ "name": "lat_cli", "changed": true, "has_entry": false, "fragments": 0 }
],
"preview": "### Changelog check\n\n| package | fragments | version |\n| --- | --- | --- |\n…"
}

needs_entry states the fact; ok states the verdict after strictness is applied, and matches the exit code. fragments, has_entry, and has_entries count only the fragments this branch wrote (see Scope). The schema field is there so a workflow can assert on the shape it expects; the JSON output page covers what it promises. Note that preview is guaranteed to be present and a string, but its Markdown is prose — render it rather than parsing it.

--json is a deprecated alias for --format json. It still works; passing it alongside --format is a usage error.

--format github emits the same facts as key=value lines for $GITHUB_OUTPUT, so a workflow can post the comment without a jq pipeline:

ok=false
strictness=error
has_entries=true
needs_entry=true
needs_entry_packages=["lat_cli"]
invalid_fragments=[]
preview<<TRELLIS_PREVIEW
### Changelog check
| package | fragments | version |
| --- | --- | --- |
| lat_core | ✅ 2 | 1.2.0 → 1.3.0 |
| lat_cli | ❌ needs an entry | 0.3.1 → 0.3.2 |
Add one with `trellis changelog new --package <name> --kind <kind> --body <text>`.
### Release preview
<details>
<summary><code>lat_core</code> 1.2.0 → 1.3.0</summary>
## v1.3.0 - 2026-07-11
### Added
- add a --dry-run flag
### Fixed
- stop truncating long names
</details>
<details>
<summary><code>lat_cli</code> 0.3.1 → 0.3.2</summary>
## v0.3.2 - 2026-07-11
### Dependencies
- Updated lat_core to 1.3.0
</details>
TRELLIS_PREVIEW

needs_entry_packages and invalid_fragments are JSON arrays, read with fromJSON(); preview uses GitHub’s heredoc form because it is multi-line. See CI recipes for the workflow.

The table covers the packages the PR’s diff touched; the release preview covers every package this PR’s fragments would release, computed the same way version plan computes it — so a dependent that merely ripples (like lat_cli above) appears with its generated Dependencies entry. Each collapsed section is the version section version apply would write for these fragments. When a fragment does not parse, no plan can be computed, so the versions and the release preview are omitted and the problem is reported instead.

version plan is the dry run: each pending package’s next version, computed from its fragments’ kinds. The largest bump among a package’s fragments wins; kinds and the bump each implies are configurable under [tools.trellis.changelog].

Terminal window
$ trellis version plan
lat_core: 1.1.0 -> 1.2.0 (2 fragment(s))
lat_mid: 0.5.0 -> 0.5.1 (dependencies: lat_core)
lat_cli: 0.4.2 -> 0.4.3 (1 fragment(s), dependencies: lat_core, lat_mid)

lat_mid owns no fragment, yet it is in that plan. When a package bumps, so does everything that path-depends on it, transitively.

This is a correctness requirement, not a convenience. A path dependency carries no version in the repository; it becomes a Hex requirement at publish time, derived from whatever version the dependency is on then. Holding lat_mid at 0.5.0 while lat_core moved would leave one published lat_mid 0.5.0 meaning two different things — >= 1.1.0 to whoever fetched it before the release, >= 1.2.0 to whoever fetched it after.

The rules:

  • A rippled package bumps by whatever dependency_kind is configured to bump, patch by default. It needs no fragment of its own.
  • A package with its own fragments keeps its own bump; a ripple never lowers it. lat_cli above is 0.4.3 from its own fragment, not 0.4.3 from the ripple — whichever is larger wins.
  • Ripples follow [dev-dependencies] path deps as well as [dependencies].
  • A package excluded by @release never bumps, and a ripple stops there rather than skipping past it to its dependents.

Each rippled package gets a generated changelog entry, rendered like any other:

## v0.5.1 - 2026-07-11
### Dependencies
- Updated lat_core to 1.2.0

The entries are generated at plan time and never written to .changes/unreleased/ — the body embeds the dependency’s new version, which is only settled once the whole plan is computed. Hand-written entries of the same kind share the heading rather than producing a second section. A ripple entry names no category, so in a workspace using them it files under uncategorized_label.

version apply performs the release bookkeeping in one step. Per pending package, it:

  1. Renders the new version section from the fragments (minijinja templates, configurable) and stores it under .changes/<package>/.
  2. Reassembles the package’s CHANGELOG.md from its stored sections, newest first.
  3. Bumps the version in gleam.toml with a surgical TOML edit — no regex, formatting preserved.
  4. Patches every package’s manifest.toml so locked workspace-internal dependencies match the new versions.

The whole operation makes zero Hex network calls, so a release never trips Hex rate limits on a shared CI runner the way gleam update per package does.

Each package’s CHANGELOG.md is a generated file: the source of truth is the version sections under .changes/<package>/, and apply reassembles the changelog from them.

The fragment kinds do not always determine the version you want: a breaking change filed as Fixed and already merged, a package that should jump straight to 1.0.0 from 0.4.3 (a minor under the pre-1.0 rule), or a one-off number matching something upstream.

Three flags override the derivation. plan and apply accept them identically, so an override is previewable before it is applied.

Terminal window
$ trellis version plan --bump major # the whole plan
$ trellis version plan --bump lat_cli=major # one package
$ trellis version plan --set lat_cli=1.0.0 # an exact version

Precedence runs --set → per-package --bump → workspace-wide --bump → the derived level. Naming a package in both --bump and --set, naming one that is not a releasable package, or pinning a version that is not ahead of the current one are all errors raised before anything is written.

--pre <label> cuts a release candidate. Repeating it advances the counter within the same base version rather than deriving a fresh bump from the same fragments:

Terminal window
$ trellis version apply --set lat_cli=1.0.0 --pre rc
bumped lat_cli: 0.4.3 -> 1.0.0-rc.1
kept fragments unreleased for the final version
$ trellis version apply --pre rc
bumped lat_cli: 1.0.0-rc.1 -> 1.0.0-rc.2
$ trellis version apply --pre none
bumped lat_cli: 1.0.0-rc.2 -> 1.0.0

Fragments survive a prerelease. The candidate renders its changelog section, but the fragments behind it stay in .changes/unreleased/: they are still unreleased as far as 1.0.0 is concerned, and retiring them at rc.1 would leave the final release with nothing to say.

The consequence is that an entry appears twice in CHANGELOG.md — once under the RC that shipped it, once under the final version. version --json reports fragments_retained so a workflow can tell a cut RC from a completed release.

A prerelease labels the whole plan, rippled dependents included, so the workspace moves as one coherent candidate. A package that only gained fragments after the RC was cut still bumps normally under --pre none, so a late arrival never blocks the promotion.

Once a package sits at a prerelease, a plain version apply is an error rather than a silent bump to the next release — leaving a cycle has to be explicit:

Terminal window
$ trellis version plan
error: `lat_cli` is at prerelease 1.0.0-rc.2; pass --pre <label> for another
prerelease or --pre none to promote it

A prerelease belongs to no release series, so it moves no series tag; exact tags apply as usual, and Hex accepts prerelease versions.

Because CHANGELOG.md is regenerated from .changes/<package>/, a package that already had a changelog before adopting trellis would lose it the first time it was released — there is nothing under .changes/<package>/ to reassemble from.

Trellis handles this on a package’s first release: everything below the header line is captured verbatim as a single section, filed under the newest version its headings mention (or the version being released from, if none parse).

Terminal window
$ trellis version apply
bumped lat_core: 1.2.0 -> 1.3.0
adopted existing changelog history as .changes/lat_core/v1.2.0.md

The captured block is byte-for-byte — no heading parsing, no reformatting — and sorts below every section trellis goes on to write. It happens once; after that the changelog is fully generated.

doctor reports pending adoptions so this is never a surprise mid-release, and doctor --fix performs the capture up front, so the restructuring lands in its own reviewable diff:

Terminal window
$ trellis doctor
warning: package `lat_core` has changelog history that trellis has not batched
yet; it will be adopted on the next release

From here, publishing covers turning pending fragments into a release PR (trellis release pr), tags, and Hex publishes.