A workspace CLI for Gleam monorepos. A trellis is the frame a lattice grows on.
Gleam has no native workspace concept: gleam build, gleam test, and
gleam publish each operate on a single package directory. Multi-package
repos end up hand-building workspace features out of bash loops, YAML glue, and
duplicated config. Trellis replaces that glue with one binary that runs
identically locally and in CI.
The design principle:
Configure nothing that can be derived. Verify anything that must be duplicated.
Everything trellis knows comes from one file format the ecosystem already
uses: gleam.toml. The workspace root's manifest carries a [tools.trellis]
table (member globs and options); each member's manifest supplies its name,
version, and path dependencies. The dependency graph (topological order,
publish order, change impact, path-dep rewrite maps) is computed, never
declared.
Throughout, a package is one Gleam package, a directory with its own
gleam.toml, and a member is a package belonging to the workspace. The
second word appears where membership itself is the point, as in the members
key and the @members exclusion.
See docs/DESIGN.md for the full design.
The full rollout plan is implemented:
the workspace model plus list, graph, info, run, exec, init,
doctor, ci, changelog, version, release, tag, publish,
lockfile, pin, and completions, with prebuilt release binaries for
distribution.
Trellis ships as a single prebuilt binary, the same distribution model as
just, changie, and ratchet, so it installs in CI in about a second with
zero runtime dependencies. Releases are built and published by
cargo-dist, with SLSA build
provenance attestations.
Shell installer (Linux and macOS):
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/tylerbutler/trellis/releases/latest/download/trellis-gleam-installer.sh | shPowerShell installer (Windows):
powershell -ExecutionPolicy Bypass -c "irm https://github.com/tylerbutler/trellis/releases/latest/download/trellis-gleam-installer.ps1 | iex"Homebrew:
brew install tylerbutler/tap/trellismise / asdf (via the
github backend), which
is how a consuming workspace pins trellis in .tool-versions alongside its
other tools:
mise use "github:tylerbutler/trellis@0.13.0"From source: on crates.io the crate is trellis-gleam (trellis was
taken), but the installed binary is named trellis either way:
cargo install trellis-gleam
# or straight from the repository:
cargo install --git https://github.com/tylerbutler/trellisPrebuilt archives for every target are on the
releases page. Pin a
specific version in CI by replacing latest/download with
download/v0.13.0 in the installer URL.
Add one line to your shell's startup file. bash, zsh, fish,
powershell, and elvish are supported:
eval "$(trellis completions zsh)"Completions are computed by the binary as you type, so they offer real package
names, task names, and changelog kinds from the workspace you're in. Evaluate
the snippet on startup rather than saving it to a file: it talks to trellis
over an interface that can change between releases. See
installation for the other
shells and for man pages, which ship in the release archives under man/.
Interactive commands print a one-line notice to stderr when a newer trellis
has been published to crates.io. Successful checks are cached for a day, and
the notice appears only after a fresh check. The check is best-effort, capped
at a short timeout and silent on any error, so it never slows a command or
changes its exit status. It runs only when stderr is a terminal, so scripts
and structured output are never touched. It is additionally skipped in CI and
when DO_NOT_TRACK or TRELLIS_NO_UPDATE_CHECK is set in the environment,
and --no-update-check suppresses it for a single invocation.
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, a [tools.trellis] table in a
gleam.toml marks the workspace root. There is no 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:
# 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
# (e.g. committed test fixtures that auto-discovery would sweep in); 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]
# Which tags a release maintains for each package, one entry per tag:
# exact → the whole version (v1.2.3), immutable (the default)
# major → v1, minor → v1.2, both moving, for consumers who pin a series
# instead of chasing patch tags
package_tags = ["exact"]
# Per-package overrides, keyed by a member-path glob.
package_tags_overrides = { "packages/lat_cli" = ["exact", "major", "minor"] }
# `exact` substitutes into the first, every series level into the second.
# {version} and {series} are derived, never written by hand.
exact_tag_format = "{name}-v{version}"
series_tag_format = "{name}-v{series}"
# Optional repository-wide moving tag, independent of any package's list.
# All three keys are required together, or leave all three out.
repository_tag_package = "lat_cli"
repository_tag_format = "v{series}"
repository_tags = ["major", "minor"]Path dependencies between members define the graph; cycles and path deps escaping the workspace are
rejected, and a [tools.trellis] table in a member manifest is a doctor
error (it would hijack root discovery).
Member entries containing *, ?, or [ are wildcard patterns. In a Git
repository, wildcard discovery honors repository .gitignore files at every
level and .git/info/exclude. It does not read global core.excludesFile
rules, generic .ignore files, or automatically exclude hidden paths, so
results do not depend on machine-local Git configuration. Outside a Git
repository, Git ignore rules do not apply. Traversal follows symlinks but never
enters .git, and only matching directories with a gleam.toml become members.
Entries without wildcard metacharacters are resolved directly, so an explicit
literal path remains included even when Git ignores it. [tools.trellis.exclude]
is a separate post-discovery filter: task and @release exclusions do not
control traversal.
Every command works from anywhere inside the workspace (the root is found by
walking up to the first gleam.toml with a [tools.trellis] table, like
git or cargo — member manifests along the way are skipped). Without a
[tools.trellis] table anywhere, the git repository root is the workspace
root and members are auto-discovered.
-C, --directory <DIR> Run as if started in this directory
--color <WHEN> auto (default), always, or never
-q, --quiet Drop the per-package stream and the summary table
-v, --verbose Trace every command trellis shells out to, on stderr
--no-update-check Skip the release check for this invocation
These accept placement anywhere, before or after the subcommand.
--color auto follows the terminal and respects NO_COLOR (at any value) and
CLICOLOR=0; always and never override that detection in either direction,
so --color always survives a pipe. Live progress rows stay tied to terminal
detection either way — forcing color into a pipe does not start drawing
spinners into it.
-q and -v are mutually exclusive. -q changes nothing about exit codes:
errors still go to stderr and a failing task still fails the command. -v
writes a + trace to stderr for each gleam and git invocation, naming the
directory it ran in, and for each GitHub API request as + METHOD url.
Every command uses the same four codes. Branch on 1 versus 3 to tell a
workspace that has problems from a broken environment.
| Code | Meaning | Examples |
|---|---|---|
0 |
Success, no findings | doctor found nothing; every task passed |
1 |
Ran correctly, found problems | doctor findings, missing changelog fragments, a failed task |
2 |
Usage error | Unknown flag, missing argument, bad subcommand |
3 |
Trellis itself could not run | Unparseable config, not a git repository, missing gleam, no GitHub token, Hex unreachable after retries |
A failed task exits 1; trellis does not propagate the child's exit code. See
Compatibility for what
else the version number promises.
trellis list [--json] [--since <ref>] [--with-dependents] [--releasable]
trellis graph [--format text|dot|mermaid|json]
trellis info <package> [--json]
list prints members in topological order, dependencies first. --since origin/main filters to packages owning changed files (committed, uncommitted,
and untracked); --with-dependents adds the reverse-dependency closure. This
is the primitive behind "only test what a PR touched."
Every --json payload carries a schema field naming the payload and its
major version ("schema": "trellis.list/1"), so a workflow can assert on the
shape it was built against and detect a breaking change instead of silently
producing wrong results. Fields may be added without a bump; renaming,
removing, or retyping one bumps the major. Human-readable text output carries
no such guarantee. trellis ci matrix and ci outputs are the two
exceptions — their shapes are dictated by GitHub Actions.
Full details: docs/json-output.
trellis run <task> [pkgs...] [--since <ref>] [--with-dependents]
[--target erlang|javascript|all] [--strict] [--check]
[--serial] [--keep-going] [--jobs N] [--json]
trellis exec [pkgs...] [--since <ref>] [--serial] [--keep-going] [--json]
-- <command...>
Scheduling is graph-parallel by default: a package runs as soon as its
workspace dependencies have finished, up to --jobs N at once. Output is
streamed with a pkg ▏ prefix and a summary table names any failures. In an
interactive terminal, active packages remain visible as live progress rows and
package names use stable, hash-derived colors. Pipes and CI receive plain text
without terminal control sequences.
--target all runs the task once per compile target. --serial runs one
package at a time in dependency order.
--json reports per-package results — status, exit code, duration, and the
command that failed — so a workflow can record what passed without parsing
output written for a person. Package output moves to stderr so stdout carries
nothing but the payload, and the summary table is replaced by it. This is what
makes "only test what a PR touched" reportable as well as doable:
trellis run test --since origin/main --json | jq -r '.results[] | select(.status != "success") | .package'
Built-in tasks map 1:1 onto gleam verbs: build, test, check, format
(--check variant), docs, deps, clean. A [tools.trellis.tasks] entry with the same
name overrides a built-in. Any built-in or custom task can exclude member paths
through its key in exclude; exclusions still apply when packages are named
explicitly. For larger maps, use a table:
[tools.trellis.exclude]
docs = ["examples/*", "packages/internal-*"]
"@release" = ["examples/*"]The reserved @release key defines the package set used by changelog,
version, tag, and publish commands — read as the workspace state of the
finer-grained release lifecycle described next — and @members removes
directories from workspace membership entirely (in both auto-discovered and
explicit-members modes). Special keys use a @ prefix so they can never
collide with a task name — trellis rejects any task named with that prefix.
[tools.trellis.publish.lifecycle]
default = "hex"
packages = { "packages/experimental/**" = "workspace", "packages/providers/**" = "git_only" }Each member resolves to one of three lifecycles: workspace (build and test
only, with no changelog, version, tag, or publish), git_only (changelog,
version, and git tags/releases, but never published to Hex), or hex (the
full pipeline, and the default). default sets the workspace-wide fallback;
packages overrides it per member, keyed by member-path glob. A member
matched by globs that resolve to different lifecycles is a doctor error, but
globs agreeing on the same lifecycle are fine. exclude.@release keeps
resolving matching packages to workspace, but an explicit packages rule
for the same member takes precedence, so a package can graduate from
workspace to git_only to hex without moving directories.
--releasable (on list, ci matrix, and elsewhere) still means git_only
or hex; publish narrows further, since only hex packages ever reach
Hex. doctor additionally enforces that a runtime path dependency is at least
as capable as its dependent: hex may depend only on hex, git_only may
depend on git_only or hex, and workspace may depend on anything.
Dev-only path deps are exempt, since they never ship in any distribution.
trellis changelog new [--package <pkg>] --kind <kind> --body <text>
[--category <category>]
trellis changelog check --base <ref> [--head <ref>] [--format text|json|github]
[--strictness error|warn|off]
trellis version plan [--bump <level>|<pkg>=<level>] [--set <pkg>=<version>]
[--pre <label>|none] [--json]
trellis version apply [--bump <level>|<pkg>=<level>] [--set <pkg>=<version>]
[--pre <label>|none] [--json]
The changelog engine is native, so there is no second tool to install and no
config file to keep in sync. Changes are recorded as TOML fragments in
.changes/unreleased/ — package, kind, body, and an optional
category; changelog new writes one, non-interactively, which suits CI and
agents as well as shells. changelog check maps a base...head diff to
packages and fails if a changed releasable package has no unreleased fragment.
How much that costs is set by changelog.strictness: error by default,
warn to report without failing, off not to check (an invalid fragment
fails either way). --format json emits the payload, including a markdown
preview for a PR sticky comment; --format github emits the same facts as
key=value lines for $GITHUB_OUTPUT, so a workflow can post that comment
without a jq pipeline.
version plan computes each pending package's next version from its
fragments' kinds (the largest bump wins; kinds and their bumps are
configurable under [tools.trellis.changelog]). version apply renders each
package's version section (minijinja templates, see below), stores it under
.changes/<package>/, reassembles the package's CHANGELOG.md newest-first,
bumps gleam.toml with a surgical TOML edit rather than a regex, and finally
patches each member's manifest.toml so locked workspace-internal deps match.
Zero Hex network calls throughout. Invalid fragments (unknown package or kind,
empty body, unparseable TOML) are hard errors for plan/apply: silently
dropping a change is the drift trellis exists to prevent.
A bump ripples to the bumping package's workspace dependents. When lat_core
goes 1.2.0 → 1.3.0, every package that path-depends on it is released too —
by a patch, and with a generated changelog entry saying why:
lat_core: 1.2.0 -> 1.3.0 (1 fragment(s))
lat_mid: 0.5.0 -> 0.5.1 (dependencies: lat_core)
lat_cli: 0.3.1 -> 0.3.2 (dependencies: lat_core, lat_mid)
A dependent needs no fragment of its own to ripple, and a package that has one
keeps its own, larger bump. Ripples follow [dev-dependencies] path deps as
well as [dependencies]. Packages excluded by @release never bump, and a
ripple stops at one rather than skipping past it to its dependents. This is a
correctness requirement rather than a convenience;
why.
The fragment kinds do not always determine the version you want. Three flags
override them, accepted identically by plan and apply so an override is
previewable before it is applied:
trellis version apply --bump major # whole plan
trellis version apply --bump lat_core=major # one package
trellis version apply --set lat_core=1.0.0 # an exact version--set beats a per-package --bump, which beats a workspace-wide one, which
beats the derived level. Naming a package in both --bump and --set, naming
one that isn't a releasable member, or pinning a version that isn't ahead of
the current one are all errors before anything is written.
--pre <label> cuts a release candidate, and repeating it advances the
counter within the same base version:
trellis version apply --set lat_core=1.0.0 --pre rc # 1.0.0-rc.1
trellis version apply --pre rc # 1.0.0-rc.2
trellis version apply --pre none # 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. --pre none promotes the
candidate to its final version and consumes them. This means an entry appears
twice in CHANGELOG.md: once under the RC that shipped it, once under the
final. version --json reports fragments_retained so a workflow can tell the
two cases apart.
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 is at a prerelease, a plain version apply is an error rather
than a silent bump to the next release; resolving the cycle has to be
explicit. A prerelease belongs to no series, so it moves no
series tag; exact tags apply as usual, and Hex accepts
prerelease versions.
Rendering is controlled by minijinja templates in [tools.trellis.changelog],
each with a small context drawn from name, version, date, tag,
series, category, kind, and body:
[tools.trellis.changelog]
header_format = "# {{ name }} changelog" # default
version_format = "## v{{ version }} - {{ date }}" # default
kind_format = "### {{ kind }}" # default
change_format = "- {{ body }}" # default
# Generated ripple entries are ordinary entries of one configured kind, so
# they sort and render like any other. `dependency_kind` must name one of
# `kinds`; its bump is what a package bumps by when a dependency bump is the
# only reason it is being released.
dependency_kind = "Dependencies" # default
dependency_body = "Updated {{ dependency }} to {{ dependency_version }}" # defaultkinds sets the change kinds and the bump each implies; setting it replaces
the whole default list rather than adding to it, so copy the list before
trimming it. A kind you drop turns every fragment naming it invalid. An
optional categories list adds a second grouping axis above the kind
headings, carrying no bump. Both are on
configuration.
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. A package that already had a changelog
when it adopted trellis keeps it: on its first release, whatever sits below
the header is captured verbatim as one section under .changes/<package>/, so
regenerating preserves the history rather than replacing it. doctor reports
this before release day, and doctor --fix does the capture up front.
trellis release pr [--base <branch>] [--branch <branch>]
trellis tag plan [--json]
trellis tag create [--push] [--github-release]
trellis publish <pkg | --tag <tag> | --all-untagged> [--dry-run]
trellis lockfile refresh [--package <pkg>]
release pr turns pending changelog fragments into a release pull request: it
runs version apply on a release branch (default release/pending), commits
the bumps, force-pushes (so the branch is regenerated each run), and creates
the PR through the GitHub REST API, or updates it when one is already open.
The body carries the bump table and each package's new CHANGELOG section.
Requires a clean working tree and a token from GITHUB_TOKEN (ambient in
GitHub Actions), GH_TOKEN, or a logged-in gh CLI; a no-op when there are
no fragments.
tag plan lists the tags the current versions call for and don't have yet;
tag create reconciles them in topological order, optionally pushing them and
creating GitHub Releases — through the same API and token as release pr —
with the matching CHANGELOG section as the body. release bootstrap is the
same reconciliation without a version bump, for adopting trellis on a
repository that already has the versions it wants but no tags.
package_tags lists the tags a release maintains per package, one entry per
tag, each naming how much of the version it keeps: exact keeps the whole
thing ({name}-v1.2.3), major truncates to one part ({name}-v1), minor
to two ({name}-v1.2). It defaults to ["exact"], and
package_tags_overrides sets it per package by member-path glob. Entries name
levels rather than templates so that ci tag-package can resolve a pushed tag
back to its package.
The level also picks the lifecycle. exact tags are immutable, created once
and never rewritten, and substitute into exact_tag_format. Series levels
move: releasing a new version in the series force-moves the tag to the release
commit and force-pushes it, substituting into series_tag_format. A commit
that does not change the package's version leaves its series tags where they
are, so one package's release never drags another's forward. Because a moving
tag names no particular version, it never carries a GitHub Release and
publish --tag refuses it; ci tag-package still resolves it, and a
workspace with no exact entry publishes with --all-untagged.
An optional repository tag is separate from every package's list.
repository_tag_package is the anchor: trellis creates repository_tag_format
for the anchor's stable series and moves it only when the anchor's manifest
version differs from the version stored at that tag. Its repository_tags is
stated rather than inherited from package_tags, and all three keys are
required together — a partly-written repository tag is an error, not a default.
A new series creates a new tag and leaves the old series intact.
Repository tags are mutable, never get GitHub Releases, and cannot be passed to
publish --tag or ci tag-package.
It replaces the older way of reaching a repository-wide tag: dropping {name}
from series_tag_format. That form is deprecated and removed at 1.0, because
without the discriminator every package's series tag matches every member and
ci tag-package can no longer resolve one. doctor warns on it. To migrate,
restore {name} in series_tag_format and declare the three repository tag
keys above.
publish runs, per package and in dependency order: an idempotency check
against the Hex API (already-published versions are skipped, so re-running a
partially failed release is safe), validation (gleam format --check,
build --warnings-as-errors, test), then a path-dep rewrite computed from
the graph — each workspace path dep becomes the Hex requirement derived from
that dep's current version (minor, patch, or exact, per path_dep_requirement) —
followed by gleam publish --yes, and finally restoration of the original
gleam.toml (the repo never shows rewritten files, even on failure). Every
Hex-touching step runs under the configured [tools.trellis.publish] retry backoff policy.
--tag lat_core-v1.2.0 resolves a pushed tag to its package and refuses to
publish if the tag version doesn't match gleam.toml; --all-untagged
publishes everything not yet on Hex, enabling a single publish run per release
instead of one per tag.
lockfile refresh scopes gleam deps download to one package, with retry.
Refreshing the whole workspace at once is what gets a runner rate-limited by
Hex. trellis ci tag-package <tag> resolves $GITHUB_REF_NAME to a package
name for shell substitution.
trellis pin [pkgs...] # resolve symbolic refs to SHAs, record the intent
trellis pin --update [pkgs...] # re-resolve each recorded ref to its latest SHA
trellis pin --check [pkgs...] # fail if a pinned SHA drifted from its tracked ref
trellis pin --unpin [pkgs...] # restore the symbolic refs
A Gleam git dependency names a ref, and the two values you can give it are
wrong in opposite ways. ref = "v0.4" is readable but re-resolves silently
when the tag moves, and a bare SHA is reproducible but loses what to bump to.
pin keeps both: the ref becomes the commit SHA and the ref it tracks moves
into a # trellis:pin comment on the same line, the style
ratchet uses for GitHub Actions:
vestibule = { git = "https://github.com/example/monorepo.git", ref = "93deb4c38d4b3f5848681ff7e9d59883db751c67", path = "packages/vestibule" } # trellis:pin v0.4Refs resolve through git ls-remote, so there is no clone and any host works,
and manifest.toml is patched surgically. --update re-resolves each
recorded ref, so following a moving tag becomes a reviewable diff instead of a
silent re-resolution. --check fails when a pinned SHA is no longer reachable
from its tracked ref, which means the ref was force-moved past it; a pin
merely behind its ref is not drift. doctor runs the same check as an
advisory warning, since re-pinning past a force-move is a supply-chain call
--fix must not make.
trellis doctor [--fix] [--dry-run] [--format text|json|github]
Checks every workspace invariant and reports all problems at once: member
globs resolve and parse, path deps stay inside the workspace, the graph is
acyclic, task exclusion globs match real members, every package's runtime path
deps are available at its release lifecycle, tag formats don't collide,
manifest.toml locked versions match workspace-internal gleam.toml
versions, no package's version is behind its CHANGELOG, and every unreleased
changelog fragment parses and references a valid package, kind, and category.
Two checks are advisory warnings rather than errors, because both need the
network or the machine's own toolchain: pinned git dependency SHAs still
reachable from their tracked refs, and a gleam on PATH matching the
.tool-versions pin (enforcing toolchains stays mise/asdf's job). Non-zero
exit on any error. Run it on every PR.
Two further checks cover things that must agree because they are duplicated:
Deprecated and unrecognized [tools.trellis] keys. Every trellis config
key is snake_case. Through v0.7.0 they were kebab-case; the old spellings are
still accepted, and each one is reported so a workspace can migrate at its own
pace. A key that is not recognized in any spelling is also a warning, since it
may belong to a newer trellis and erroring would make the workspace unloadable
under a pinned older one.
Shared external dependencies. Members are checked for agreeing on the
non-path dependencies they share — lat_core requiring gleam_stdlib >= 0.44.0 while lat_cli requires >= 0.60.0. Requirements are compared as
written, never parsed as ranges, so whitespace counts. Divergence is sometimes
intended, so it warns by default:
[tools.trellis.doctor]
shared_dependencies = "warn" # or "error" to fail CI, "off" to skipThere is no --fix for it: picking a winner is a judgment call.
--fix applies the mechanical remedies (seed a missing CHANGELOG, patch stale
locked versions) and re-checks; --dry-run lists them without writing.
--format json emits each finding as {check, severity, message, file, package, fixable}, and --format github emits the same findings as workflow
commands, so they annotate the changed lines in a PR instead of scrolling past
in a log:
- run: trellis doctor --format githubtrellis init
init bootstraps the workspace itself: it writes a [tools.trellis] table
into the repository root's gleam.toml, creating a config-only manifest if the
root isn't a package, and finishes by running doctor.
The table it writes is nearly empty by design. Its presence is what marks the
workspace root; members stay auto-discovered, so members is derivable and
therefore not written. What it leaves instead are comments pointing at what can
be configured — the part a reference page answers badly. init reports the
members it discovered so you can see whether they need narrowing, and refuses
if the repository is already a trellis workspace.
$ trellis init
created /repo/gleam.toml
members are auto-discovered; found 2:
packages/a
packages/btrellis ci matrix [--since <ref>] [--releasable]
trellis ci outputs
matrix emits a GitHub Actions strategy matrix
({"include":[{"name","path","version"},…]}); with --since it covers only
affected packages, dependents included. outputs emits workspace facts as
key=value lines for $GITHUB_OUTPUT:
- id: plan
run: echo "matrix=$(trellis ci matrix --since origin/main)" >> "$GITHUB_OUTPUT"Standard Rust project: cargo test runs unit tests plus an end-to-end suite
against the fixture workspace in tests/fixtures/. cargo fmt and
cargo clippy --all-targets are enforced in CI.
assets/man/ and website/src/content/docs/docs/reference.md are generated
from the clap definitions — regenerate both with just docs after changing any
command, flag, or help string. cargo test fails if they're stale.
Contributor conventions — naming, changelog fragments, and how trellis itself is released — are in AGENTS.md.
MIT — see LICENSE.