Skip to content

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
# 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.

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.

Terminal window
$ trellis init
created /repo/gleam.toml
members are auto-discovered; found 2:
packages/a
packages/b

What 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.

members accepts paths relative to the workspace root. An entry containing *, ?, or [ is a wildcard member pattern; any other entry is literal.

gleam.toml
[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.

Add a key under exclude for any built-in or custom trellis run task whose package set differs from the full workspace:

gleam.toml
[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:

gleam.toml
[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:

Terminal window
$ trellis list --releasable
lat_core hex
lat_mid hex
lat_cli hex

trellis doctor catches every exclusion glob that matches no member. It also validates dependency availability across the release lifecycle: see Release lifecycle below.

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
gleam.toml
[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:

  1. Start from publish.lifecycle.default.
  2. Apply the legacy exclude.@release mapping to workspace, when matched.
  3. Apply an explicit publish.lifecycle.packages rule, when matched — this overrides both of the above, which is what lets a package graduate from workspace to git_only to hex over time without moving directories or rewriting exclude.@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:

gleam.toml
[tools.trellis.publish]
package_tags = ["exact"] # workspace default
package_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:

Terminal window
$ trellis tag create --push # releasing 0.4.2
tagged lat_cli-v0.4.2
pushed lat_cli-v0.4.2
tagged lat_cli-v0
tagged lat_cli-v0.4
$ trellis tag create --push # releasing 0.4.3
tagged lat_cli-v0.4.3
pushed lat_cli-v0.4.3
moved lat_cli-v0
moved lat_cli-v0.4
force-pushed lat_cli-v0.4

A 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.

For a repository consumed through Gleam 1.18 git path dependencies, configure one first-class repository tag rather than overloading the package format:

gleam.toml
[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.

gleam.toml
[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:

gleam.toml
[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.

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" # default
version_format = "## v{{ version }} - {{ date }}" # default
kind_format = "### {{ kind }}" # default
change_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" # default
dependency_body = "Updated {{ dependency }} to {{ dependency_version }}" # default

dependency_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.

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 }}" # default
uncategorized_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.0

A fragment opts in by naming one:

.changes/unreleased/lat_cli-a-watch-flag.toml
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 of kinds. An unknown one is an invalid fragment: doctor reports it and version refuses.
  • 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 ###. Setting kind_format yourself 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.

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:

Terminal window
$ trellis doctor
warning: packages disagree on `gleam_stdlib`: `>= 0.44.0` (lat_core) vs
`>= 0.60.0` (lat_cli). Requirements are compared as written, so whitespace
counts

Requirements 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 off

There is no doctor --fix for it — which requirement to unify on is a judgment call.

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:

Terminal window
$ trellis doctor
warning: [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:

Terminal window
$ trellis doctor
warning: [tools.trellis] key `publish.tag_frmat` is not recognized and is being
ignored; it may belong to a newer trellis

The free-form tables — exclude, tasks, publish.package_tags_overrides, and publish.lifecycle.packages — take keys you choose, hyphens and all, and are never reported.

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.