Skip to content

CI recipes

Trellis runs identically locally and in CI, and emits the exact structures GitHub Actions consumes — so workspace workflows shrink to thin triggers around a few commands. The recipes below are the shapes a Gleam workspace actually needs.

The shell installer puts a prebuilt binary on the runner in about a second. Pin the version so CI doesn’t move under you (see installation for other options, including a .tool-versions pin through mise/asdf):

- name: Install trellis
run: |
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/tylerbutler/trellis/releases/download/v0.11.2/trellis-gleam-installer.sh | sh
# the installer adds its install dir to $GITHUB_PATH automatically

trellis ci matrix emits a strategy matrix ({"include":[{"name","path","version"},…]}). With --since it covers only the packages a PR touched, dependents included:

.github/workflows/ci.yml
jobs:
plan:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # --since needs history
- id: plan
run: echo "matrix=$(trellis ci matrix --since origin/main)" >> "$GITHUB_OUTPUT"
test:
needs: plan
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- run: trellis run test ${{ matrix.name }} --target all

Full fan-out is the default. --since is opt-in because coupling the path-dep graph can’t see would otherwise go silently untested, so a workspace that wants affected-only CI writes --since origin/main into its workflow explicitly.

trellis ci outputs emits workspace facts as key=value lines, ready for $GITHUB_OUTPUT:

Terminal window
$ trellis ci outputs
packages=["lat_core","lat_mid","lat_cli","lat_example"]
projects=["lat_core","lat_mid","lat_cli","lat_example"]
releasable=["lat_core","lat_mid","lat_cli"]
version_files=["packages/lat_core/gleam.toml","packages/lat_mid/gleam.toml","packages/lat_cli/gleam.toml"]
tags=["lat_core-v1.2.0","lat_mid-v0.5.1","lat_cli-v0.4.3"]
series_tags=["lat_cli-v0.4"]

projects is a deprecated alias of packages carrying an identical value. Read packages; the alias goes away at 1.0.

tags lists the immutable per-version tags and series_tags the moving ones, each covering only the packages whose package_tags includes that level — so a workspace with no exact entry reports an empty tags. Repository tags are deliberately absent from both outputs because they do not identify packages.

Both ci matrix and ci outputs are shaped by GitHub rather than by trellis, which is why neither carries the schema field every other payload does — see JSON output for what each one promises.

Run doctor on every PR — it checks every workspace invariant at once and exits non-zero on any error. Pair it with changelog check, which fails a PR that changes a package without logging it:

# on: pull_request
- run: trellis doctor
- run: trellis changelog check --base "origin/${{ github.base_ref }}"

When the tripwire fires on a mechanical finding, such as a missing CHANGELOG.md or a stale manifest.toml locked version, clear it locally with trellis doctor --fix, which applies exactly those fixes and re-reports the rest. --fix never touches the report-only findings (path-dep escapes, tag collisions, versions behind their changelog), so it stays safe to run anywhere; keep the bare trellis doctor in CI as the gate.

A failing gate in a log is easy to miss. changelog check --format github emits key=value lines for $GITHUB_OUTPUT, so the same run that gates the PR also drives a sticky comment — no jq, no second tool:

# on: pull_request
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the diff needs the base commit
- id: changelog
# The gate exits 1 on a missing entry. Let it, but keep going, so the
# comment lands before the job fails.
continue-on-error: true
run: |
trellis changelog check \
--base "${{ github.event.pull_request.base.sha }}" \
--head "${{ github.event.pull_request.head.sha }}" \
--format github >> "$GITHUB_OUTPUT"
- name: Post the changelog comment
if: >-
steps.changelog.outputs.has_entries == 'true'
|| steps.changelog.outputs.needs_entry == 'true'
|| steps.changelog.outputs.invalid_fragments != '[]'
uses: marocchino/sticky-pull-request-comment@v3
with:
header: changelog
message: ${{ steps.changelog.outputs.preview }}
- name: Remove it when there is nothing to say
if: >-
steps.changelog.outputs.has_entries == 'false'
&& steps.changelog.outputs.needs_entry == 'false'
&& steps.changelog.outputs.invalid_fragments == '[]'
uses: marocchino/sticky-pull-request-comment@v3
with:
header: changelog
delete: true
- name: Fail if the check did
if: steps.changelog.outputs.ok != 'true'
run: exit 1

The comment appears when there is something to say — entries to preview, an entry missing, or a fragment that doesn’t parse — and is deleted when there isn’t, so a fixed PR doesn’t keep a stale complaint. It shows each changed package’s fragment count and planned next version, plus a collapsible release preview of the changelog sections this PR’s own fragments would produce, ripple bumps included. Everything it counts is scoped to the branch — fragments already unreleased on the base branch answer for the PRs that added them, not this one. ok carries the verdict continue-on-error swallowed, and is empty if trellis could not run at all, which fails the job either way.

preview is already the comment body, rendered from the same data as the --format json field of that name. needs_entry_packages and invalid_fragments are JSON arrays if you would rather compose your own:

- run: echo "Missing: ${{ join(fromJSON(steps.changelog.outputs.needs_entry_packages), ', ') }}"

A workspace that wants the comment without the gate sets changelog.strictness to warn, which makes ok true even when needs_entry is — then drop both the continue-on-error line and the final step.

A doctor failure in a log tells a contributor a file is wrong; an annotation puts the message on the file itself in the PR’s Files tab. --format github emits one workflow command per finding and nothing else, so it drops straight into the gate:

# on: pull_request
- run: trellis doctor --format github

Errors become ::error, advisory warnings become ::warning, and the exit code is unchanged — the job still fails on any error. A healthy workspace prints nothing at all.

For anything richer than annotations — grouping findings by package, rendering them into a sticky comment, filtering to the ones --fix would clear — use --format json and read check, severity, file, and fixable off each finding:

- id: doctor
run: trellis doctor --format json > doctor.json || true
- run: jq -r '.findings[] | select(.fixable) | .message' doctor.json

The payload is stable; the message prose is not.

On every push to main, regenerate the release PR from pending fragments:

.github/workflows/release.yml
# on push to main: batch fragments into a release PR
- run: trellis release pr

When the release PR merges, pick one of the two release shapes. Tags as trigger — a tag-push workflow publishes each package:

.github/workflows/publish.yml
# on tag push '*-v*': publish the tagged package
- run: trellis publish --tag "$GITHUB_REF_NAME"
- run: trellis lockfile refresh --package "$(trellis ci tag-package "$GITHUB_REF_NAME")"

This shape needs per-version tags. A moving series tag matches *-v* too, but names no particular version, so publish --tag rejects it by design — ci tag-package still resolves it, so the workflow can route on it. Packages tagged series-only belong in the publish-then-tag shape below.

Or publish-then-tag — one idempotent run publishes everything not yet on Hex, in topological order, then records what shipped:

# on release-PR merge: publish everything, then record tags
- run: trellis publish --all-untagged
- run: trellis tag create --github-release

Because publish checks Hex before doing anything, re-running either workflow after a partial failure is safe: already-published versions are skipped.