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]Fragments
Section titled “Fragments”A fragment is one file in .changes/unreleased/ with three keys — the package
it belongs to, a configured change kind, and the entry text:
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):
$ 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.tomlThe 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:
$ trellis changelog new --package lat_cli --kind Added --category build \ --body "A --watch flag"Enforcing entries on PRs
Section titled “Enforcing entries on PRs”changelog check maps a base...head diff to packages and fails (non-zero
exit) if a changed releasable package has no unreleased fragment.
$ trellis changelog check --base origin/mainlat_core: 2 fragment(s)lat_cli: needs a changelog entryA 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.
Strictness
Section titled “Strictness”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. |
[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.
The JSON payload
Section titled “The JSON payload”--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.
Driving a PR comment
Section titled “Driving a PR comment”--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=falsestrictness=errorhas_entries=trueneeds_entry=trueneeds_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_PREVIEWneeds_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.
Planning a release
Section titled “Planning a release”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].
$ trellis version planlat_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)Dependents bump too
Section titled “Dependents bump too”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_kindis 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_cliabove is0.4.3from its own fragment, not0.4.3from the ripple — whichever is larger wins. - Ripples follow
[dev-dependencies]path deps as well as[dependencies]. - A package excluded by
@releasenever 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.0The 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.
Applying it
Section titled “Applying it”version apply performs the release bookkeeping in one step. Per pending
package, it:
- Renders the new version section from the fragments (minijinja templates,
configurable) and stores it under
.changes/<package>/. - Reassembles the package’s
CHANGELOG.mdfrom its stored sections, newest first. - Bumps the version in
gleam.tomlwith a surgical TOML edit — no regex, formatting preserved. - Patches every package’s
manifest.tomlso 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.
Overriding the derived version
Section titled “Overriding the derived version”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.
$ 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 versionPrecedence 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.
Prereleases
Section titled “Prereleases”--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:
$ trellis version apply --set lat_cli=1.0.0 --pre rcbumped lat_cli: 0.4.3 -> 1.0.0-rc.1kept fragments unreleased for the final version
$ trellis version apply --pre rcbumped lat_cli: 1.0.0-rc.1 -> 1.0.0-rc.2
$ trellis version apply --pre nonebumped lat_cli: 1.0.0-rc.2 -> 1.0.0Fragments 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:
$ trellis version planerror: `lat_cli` is at prerelease 1.0.0-rc.2; pass --pre <label> for anotherprerelease or --pre none to promote itA prerelease belongs to no release series, so it moves no series tag; exact tags apply as usual, and Hex accepts prerelease versions.
Adopting an existing changelog
Section titled “Adopting an existing changelog”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).
$ trellis version applybumped lat_core: 1.2.0 -> 1.3.0adopted existing changelog history as .changes/lat_core/v1.2.0.mdThe 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:
$ trellis doctorwarning: package `lat_core` has changelog history that trellis has not batchedyet; it will be adopted on the next releaseFrom here, publishing covers turning pending fragments
into a release PR (trellis release pr), tags, and Hex publishes.