Configuration
Configuration is optional. With no configuration at all, the git repository
root is the workspace root and every non-gitignored gleam.toml (outside
build/) marks a member — a fresh Gleam monorepo, or a single-package repo,
works with zero setup.
When you need to configure something, trellis uses a [tools.trellis] table
in a gleam.toml to identify the workspace root, so it does not need a
separate config file. The root manifest may be config-only, or a regular
Gleam package that also anchors the workspace. Every key is optional,
including members — omit it to keep auto-discovering members from git while
configuring everything else; trellis derives the rest of its workspace model
from the member manifests.
# gleam.toml at the repo root[tools.trellis]# Optional: pin membership to explicit globs instead of auto-discovery.members = ["packages/*", "examples/*"]# Exclusions are globbed against member paths and scoped by task. The# reserved `@release` key covers changelog, versioning, tagging, and# publishing; `@members` removes directories from membership entirely; the# `@` prefix keeps them from ever colliding with a task name.exclude = { docs = ["examples/*"], "@release" = ["examples/*"] }
# Custom tasks for `trellis run <name>`. Built-in verbs (build, test, check,# format, docs, deps, clean) need no declaration.[tools.trellis.tasks.lint]command = "gleam run -m glinter"needs_deps = true # run `gleam deps download` first if not cached
[tools.trellis.publish]exact_tag_format = "{name}-v{version}"series_tag_format = "{name}-v{series}"package_tags = ["exact"]A member is a directory with its own gleam.toml: one Gleam package, plus
its membership in the workspace. Path dependencies between packages define the
graph; cycles and path deps that escape the workspace are rejected with a clear
error.
Bootstrapping with trellis init
Section titled “Bootstrapping with trellis init”trellis init writes the table for you, at the repository root — creating a
config-only gleam.toml if the root is not itself a package, and adding to the
existing manifest if it is.
$ trellis initcreated /repo/gleam.tomlmembers are auto-discovered; found 2: packages/a packages/bWhat it writes is nearly empty. The table’s presence is what marks the
workspace root, and everything else trellis derives, so members is not
written — in its place are comments pointing at the keys you might want.
init reports the members it discovered so you can see whether they need
narrowing, refuses to run if the repository is already a trellis workspace (or
if a member manifest carries the table, which would hijack root discovery), and
finishes by running doctor.
You do not need init to start using trellis — configless mode already works.
Reach for it when you want to configure something, or to make the workspace
root explicit rather than inferred.
Member discovery
Section titled “Member discovery”members accepts paths relative to the workspace root. An entry containing
*, ?, or [ is a wildcard member pattern; any other entry is literal.
[tools.trellis]members = ["packages/core", "examples/**"]In a Git repository, wildcard discovery honors nested .gitignore files and
.git/info/exclude. It does not honor global core.excludesFile, generic
.ignore files, or automatically hide dot paths. Outside a Git repository, Git
ignore rules do not apply.
Wildcard traversal follows symlinks but does not enter .git. Only matching
directories that contain a gleam.toml become members. Literal entries bypass
ignore status and are resolved directly, even when Git ignores the path.
[tools.trellis.exclude] is a separate post-discovery filter. It can drop
packages from task or release sets, but it never prunes traversal.
A [tools.trellis] table in a member manifest is a doctor error because
it would change root discovery. Trellis walks up from the current directory to
the first manifest carrying the table, similar to how git and cargo find
their roots.
| Key | Required | What it does |
|---|---|---|
members |
no | Literal directories and wildcard patterns relative to the workspace root. Entries containing *, ?, or [ use repository-aware wildcard discovery; only matching directories with a gleam.toml become members. When omitted, every non-gitignored gleam.toml in the repository (outside build/) marks a member. An explicitly empty list is an error. |
exclude.<task> |
no | Member-path globs omitted from that built-in or custom task. The exclusion still applies when a package is named explicitly. |
exclude.@release |
no | The shared package set omitted from changelog, versioning, tagging, publishing, and release CI. The @ prefix is reserved for special keys like this one, so it can never collide with a task name — task names may not start with @. |
exclude.@members |
no | Directories removed from workspace membership entirely — never parsed, graphed, or touched by any command. Useful for committed test fixtures that auto-discovery would otherwise sweep in; also filters explicit members globs. |
tasks.<name> |
no | Custom tasks for trellis run. A task with a built-in’s name (build, test, …) overrides it. needs_deps = true downloads dependencies first. |
publish.package_tags |
no | Which tags a release maintains per package, one entry per tag: exact (the whole version — v1.2.3), major (v1), minor (v1.2). Default: ["exact"]. Must not be empty. |
publish.package_tags_overrides |
no | Per-package overrides: a map of member-path glob to tag list. A member matched by globs resolving to different lists is an error; matches agreeing on the same list are fine. |
publish.exact_tag_format |
no | Template the exact level substitutes into; {name} and {version}. Default: {name}-v{version}. |
publish.series_tag_format |
no | Template every series level substitutes into; {name} and {series}. Default: {name}-v{series}. Omitting {name} is deprecated and removed at 1.0 — use the repository tag keys below. |
publish.repository_tag_package |
with the other two | Anchor package whose manifest version drives one repository-wide moving tag. Must be releasable. |
publish.repository_tag_format |
with the other two | Repository tag template; must contain {series} and no {name}. |
publish.repository_tags |
with the other two | Which repository tags to maintain, from the same vocabulary minus exact. Required alongside repository_tag_package and repository_tag_format; declare all three or none. |
publish.path_dep_requirement |
no | How a workspace path dependency becomes a Hex requirement at publish time, from the dependency’s current version X.Y.Z: minor (default, >= X.Y.Z and < (X+1).0.0), patch (>= X.Y.Z and < X.(Y+1).0), or exact (== X.Y.Z). |
publish.retry |
no | Backoff for Hex rate limits: { attempts, initial_delay, multiplier }. Defaults: 5 attempts, 30s, multiplier 2. |
publish.lifecycle.default |
no | Release lifecycle for a member matched by no packages glob and no legacy exclude.@release glob: workspace, git_only, or hex (default). |
publish.lifecycle.packages |
no | Per-package lifecycle overrides: a map of member-path glob to lifecycle. Takes precedence over exclude.@release. A member matched by globs resolving to different lifecycles is an error; matches agreeing on the same lifecycle are fine. |
changelog.dir |
no | Where fragments and batched version sections live. Default: .changes. |
changelog.kinds |
no | Change kinds and the version bump each implies. The largest bump among a package’s unreleased fragments wins. Replacing the list replaces it entirely. |
changelog.categories |
no | A second grouping axis, rendered above the kind headings. Carries no version bump. Empty by default, which switches the axis off. |
changelog.uncategorized_label |
no | Heading for entries naming no category, rendered last. Default: Other. Read only when categories is set. |
changelog.*_format |
no | Minijinja templates for the rendered changelog: header_format, version_format, category_format, kind_format, change_format. |
changelog.dependency_kind |
no | Kind used for the entries generated when a workspace dependency bumps. Must name one of kinds. |
changelog.dependency_body |
no | Minijinja template for one such entry’s body. Context: dependency, dependency_version, package (also as project, deprecated). |
changelog.strictness |
no | How changelog check treats a changed releasable package with no unreleased fragment: error (default, fails the run), warn, or off. An invalid fragment fails at every setting. |
doctor.shared_dependencies |
no | How doctor treats packages disagreeing on a shared external dependency: warn (default), error, or off. |
Package exclusions
Section titled “Package exclusions”Add a key under exclude for any built-in or custom trellis run task whose
package set differs from the full workspace:
[tools.trellis]members = ["packages/*", "examples/*", "benchmarks/*"]exclude = { docs = ["examples/*", "benchmarks/*"], "@release" = ["examples/*", "benchmarks/*"] }Inline TOML is concise for a small map. The equivalent table form is easier to scan as more tasks gain exclusions:
[tools.trellis.exclude]docs = ["examples/*", "benchmarks/*"]"@release" = ["examples/*", "benchmarks/*"]Patterns are globs matched against member paths relative to the workspace
root, not package names. A task exclusion applies after normal package
selection, so it still wins when a package is named explicitly or selected
through --since.
| Key | What it excludes |
|---|---|
docs |
Matching packages from trellis run docs. Built-in tasks can be filtered without overriding their commands. |
| Any custom task name | Matching packages from that trellis run <name> invocation. |
@release |
Matching packages from changelog, version, tag, publish, and release CI operations — the legacy way to reach the workspace release lifecycle. Explicit changelog creation and publishing are rejected. |
@members |
Matching directories from workspace membership itself — they are invisible to every command, as if they held no gleam.toml at all. |
Release-excluded packages remain in the dependency graph and still participate
in list, graph, exec, and every task that does not exclude them. info
and JSON output expose releasable: false; list --releasable returns only
the git_only/hex set:
$ trellis list --releasablelat_core hexlat_mid hexlat_cli hextrellis doctor catches every exclusion glob that matches no member. It also
validates dependency availability across the release lifecycle: see
Release lifecycle below.
Release lifecycle
Section titled “Release lifecycle”exclude.@release is a single on/off switch: a package either participates in
the full release pipeline or is invisible to all of it. Real monorepos often
have packages in between — versioned and tagged in git, but never meant for
Hex, or not ready to release at all yet. publish.lifecycle resolves each
member to one of three states:
| Lifecycle | Changelog/version | Git tags/releases | Hex publish |
|---|---|---|---|
workspace |
no | no | no |
git_only |
yes | yes | no |
hex (default) |
yes | yes | yes |
[tools.trellis.publish.lifecycle]default = "hex"packages = { "packages/experimental/**" = "workspace", "packages/providers/**" = "git_only" }default is the lifecycle for a member matched by no packages glob and no
legacy exclude.@release glob. packages is a map of member-path glob to
lifecycle; a member matched by globs resolving to different lifecycles is a
doctor error, but matching several globs that agree is fine — that’s how a
directory-wide glob and a narrower one inside it can both claim a member.
Resolution order, so a workspace can adopt publish.lifecycle incrementally
alongside an existing exclude.@release:
- Start from
publish.lifecycle.default. - Apply the legacy
exclude.@releasemapping toworkspace, when matched. - Apply an explicit
publish.lifecycle.packagesrule, when matched — this overrides both of the above, which is what lets a package graduate fromworkspacetogit_onlytohexover time without moving directories or rewritingexclude.@release.
--releasable (on list, ci matrix, and elsewhere) still means git_only
or hex — the set that changelog, version, and tag commands operate on.
publish alone needs the finer distinction: it selects hex members only,
and the path-dependency rewrite it computes at publish time only ever
substitutes versions of hex members, so a hex package referencing a
git_only or workspace runtime path dependency fails safely instead of
publishing something unresolvable.
A dependency must be at least as capable as its dependent. hex may
depend only on hex; git_only may depend on git_only or hex; workspace
may depend on anything. doctor’s release_boundary check enforces this for
runtime ([dependencies]) path deps only — a dev-only path dep never ships in
any distribution, so it is exempt regardless of lifecycle.
package_tags lists the tags a release maintains for each package, one entry
per tag. An entry names how much of the version the tag keeps:
| Level | 0.10.3 |
1.2.3 |
Lifecycle |
|---|---|---|---|
exact (default) |
lat_cli-v0.10.3 |
lat_cli-v1.2.3 |
immutable — written once, never rewritten |
major |
lat_cli-v0 |
lat_cli-v1 |
moving — re-pointed at each release in the series |
minor |
lat_cli-v0.10 |
lat_cli-v1.2 |
moving |
The moving ones are what let a consumer pin lat_cli-v0.10 once instead of
chasing 0.10.1, 0.10.2, 0.10.3. exact substitutes into
exact_tag_format, the series levels into series_tag_format — the entry name
tells you which. A prerelease belongs to no series, so it gets an exact tag
and moves nothing.
package_tags sets the workspace default and package_tags_overrides sets it
per package, as globs matched against member paths:
[tools.trellis.publish]package_tags = ["exact"] # workspace defaultpackage_tags_overrides = { "packages/lat_cli" = ["exact", "major", "minor"] }Releasing lat_cli twice then leaves the exact tags where they were and moves
the series tags onto the newest release:
$ trellis tag create --push # releasing 0.4.2tagged lat_cli-v0.4.2pushed lat_cli-v0.4.2tagged lat_cli-v0tagged lat_cli-v0.4
$ trellis tag create --push # releasing 0.4.3tagged lat_cli-v0.4.3pushed lat_cli-v0.4.3moved lat_cli-v0moved lat_cli-v0.4force-pushed lat_cli-v0.4A series tag moves only when its own package releases. A commit that does not change the package’s version — another package’s release, a docs change — leaves its tags alone.
Entries name levels rather than templates deliberately. The closed vocabulary
is what keeps a series tag invertible — trellis ci tag-package splits
series_tag_format on {series} to recover the package a pushed tag names —
and what makes it impossible for two entries to collide at a version nobody
has released yet. Free-form templates could pass a uniqueness check at today’s
versions and still write one tag over another later, and series tags are
force-pushed.
Keep {name} in series_tag_format. Dropping it — series_tag_format = "v{series}" — gives the whole repository one shared series tag, which is
deprecated and removed at 1.0; see Repository tags for what
replaces it. doctor warns on it whether or not a second package has yet made
it ambiguous.
Series tags are the one ref trellis rewrites — they are force-moved and force-pushed on every release. That is also why they never carry a GitHub Release: the release would silently retarget on the next move. See Publishing for how the two lifecycles differ.
Repository tags
Section titled “Repository tags”For a repository consumed through Gleam 1.18 git path dependencies, configure one first-class repository tag rather than overloading the package format:
[tools.trellis.publish]repository_tag_package = "vestibule"repository_tag_format = "v{series}"# Required alongside the other two: the repository tag's levels are stated,# not inferred from package_tags.repository_tags = ["major", "minor"]The anchor package determines both the series and whether the tag moves.
Trellis compares the anchor’s manifest version at HEAD with its manifest
version at the existing repository tag. Changes to other packages do not move
the tag, and package tags do not affect that decision. A
series transition creates the new tag while preserving the old one;
prereleases create no repository tag.
[dependencies]vestibule = { git = "https://github.com/example/monorepo.git", ref = "v0.4", path = "packages/vestibule" }Repository tags are mutable repository metadata. They are force-pushed,
never receive GitHub Releases, and never resolve through publish --tag or
ci tag-package.
A {name}-less series_tag_format is deprecated and will be removed at
1.0. It was the way to get a repository-wide moving tag before the
repository tag keys existed, but it is a package template with the
discriminator removed: every package’s series tag then matches every member,
so trellis ci tag-package cannot resolve one to a package, and a second
series-mode package turns a working config ambiguous with no edit to the
format. doctor warns on the shape whether or not that has happened yet.
To migrate, restore {name} in series_tag_format and declare the repository
tag explicitly:
[tools.trellis.publish]series_tag_format = "v{series}"series_tag_format = "{name}-v{series}"repository_tag_package = "vestibule"repository_tag_format = "v{series}"One difference is deliberate: the repository tag is repository metadata, so
unlike the old shared package tag it does not resolve through ci tag-package
or publish --tag. A single-package repository that routed CI on its
{name}-less tag should keep a {name}-ful package series tag for that and
use the repository tag only for git path-dep consumers.
Changelog configuration
Section titled “Changelog configuration”The changelog engine is native — no second tool to install, no config file to
keep in sync. Changes live as TOML fragments in .changes/unreleased/;
rendering is controlled by small minijinja templates, each with a context of
name, version, date, tag, series, kind, and body as applicable:
[tools.trellis.changelog]header_format = "# {{ name }} changelog" # defaultversion_format = "## v{{ version }} - {{ date }}" # defaultkind_format = "### {{ kind }}" # defaultchange_format = "- {{ body }}" # default
# The default kinds, in full. Setting `kinds` replaces the whole list, so# copy this before trimming it — a kind you drop becomes an invalid fragment.kinds = [ { label = "Initial Release", bump = "major" }, { label = "Breaking", bump = "major" }, { label = "Removed", bump = "major" }, { label = "Added", bump = "minor" }, { label = "Changed", bump = "minor" }, { label = "Deprecated", bump = "minor" }, { label = "Fixed", bump = "patch" }, { label = "Performance", bump = "patch" }, { label = "Security", bump = "patch" }, { label = "Dependencies", bump = "patch" },]
# When a package bumps, its workspace dependents bump too and get a generated# entry saying why. Those entries are ordinary entries of one configured kind.dependency_kind = "Dependencies" # defaultdependency_body = "Updated {{ dependency }} to {{ dependency_version }}" # defaultdependency_kind must name one of kinds; that kind’s bump is what a package
bumps by when a dependency bump is the only reason it is being released. If
you replace the default kinds list, include a kind for it or point
dependency_kind at one of yours — trellis refuses to load otherwise rather
than drop the entries silently.
Categories
Section titled “Categories”A kind says how big a change is. For a package made of several distinct parts —
the subcommands of a CLI, say — readers also want to know which part changed,
and that question has nothing to do with the version bump. categories is a
second vocabulary for exactly that, grouping entries one level above the
kind headings:
[tools.trellis.changelog]categories = ["build", "publish", "doctor"]category_format = "### {{ category }}" # defaultuncategorized_label = "Other" # default## v1.3.0 - 2026-07-11
### build
#### Added
- A `--watch` flag
### publish
#### Fixed
- Retry on a 429 from Hex
### Other
#### Dependencies
- Updated lat_core to 1.3.0A fragment opts in by naming one:
package = "lat_cli"kind = "Added"category = "build"body = "A `--watch` flag"The details worth knowing:
- Categories are opt-in and off by default. With none configured, a version section is a flat list of kind headings.
- A category must be one of
categories, exactly as a kind must be one ofkinds. An unknown one is an invalid fragment:doctorreports it andversionrefuses. - The category stays optional on each fragment. Entries naming none — along
with the generated dependency entries, which belong to no single part of a
package — render last under
uncategorized_label. - Kind headings drop to
####while the axis is on, since categories now occupy###. Settingkind_formatyourself overrides that. Keep both below##, which the release notes extractor reads as the start of a version section. - Empty categories are skipped, so a release touching one area shows one heading.
Each package’s CHANGELOG.md is a generated file: the source of truth is the
version sections stored under .changes/<package>/, and version apply
reassembles the changelog from them, newest first. A package that already had
a changelog before adopting trellis keeps it — see
adopting an existing changelog.
Shared dependency agreement
Section titled “Shared dependency agreement”Nothing outside trellis notices when packages drift apart on an external
dependency they share — lat_core requiring gleam_stdlib >= 0.44.0 while
lat_cli requires >= 0.60.0. doctor catches it:
$ trellis doctorwarning: packages disagree on `gleam_stdlib`: `>= 0.44.0` (lat_core) vs`>= 0.60.0` (lat_cli). Requirements are compared as written, so whitespacecountsRequirements are compared as written, never parsed as ranges, so >= 1.0
and >=1.0 read as divergent. Path dependencies are out of scope — they carry
no requirement to agree on, and lockfile drift already covers them.
Divergence is sometimes intended, so this warns rather than failing by default:
[tools.trellis.doctor]shared_dependencies = "error" # warn (default), error, or offThere is no doctor --fix for it — which requirement to unify on is a judgment
call.
Deprecated and unrecognized keys
Section titled “Deprecated and unrecognized keys”Every key above is snake_case, matching gleam.toml’s own settings and Gleam
itself. Through v0.7.0 they were kebab-case. The old spellings still work, and
doctor reports each one so a workspace can migrate at its own pace:
$ trellis doctorwarning: [tools.trellis] key `publish.tag-format` is deprecated; rename it to`publish.tag_format` (trellis config keys are snake_case)A warning, not an error — the old spelling still configures what it always did. Plan on the aliases going away at 1.0.
A key that is not recognized in any spelling is also a warning, since it may belong to a newer trellis than the one CI has pinned:
$ trellis doctorwarning: [tools.trellis] key `publish.tag_frmat` is not recognized and is beingignored; it may belong to a newer trellisThe free-form tables — exclude, tasks, publish.package_tags_overrides,
and publish.lifecycle.packages — take keys you choose, hyphens and all, and
are never reported.
Verifying it
Section titled “Verifying it”Run trellis doctor after any config change. It validates that member globs
resolve, the graph is acyclic, task exclusion globs match members, release
boundaries are safe, the tag format produces unique tags, locked versions
match, packages agree on their shared dependencies, and [tools.trellis]
carries no unrecognized or deprecated keys — exiting non-zero on any error.