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.
Installing trellis
Section titled “Installing trellis”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 trellisrun: | 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 automaticallyAn affected-only test matrix
Section titled “An affected-only test matrix”trellis ci matrix emits a strategy matrix
({"include":[{"name","path","version"},…]}). With --since it covers only
the packages a PR touched, dependents included:
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 allFull 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.
Workspace facts as outputs
Section titled “Workspace facts as outputs”trellis ci outputs emits workspace facts as key=value lines, ready for
$GITHUB_OUTPUT:
$ trellis ci outputspackages=["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.
PR gates
Section titled “PR gates”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.
The changelog comment
Section titled “The changelog comment”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_requestpermissions: 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 1The 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.
Annotating the diff
Section titled “Annotating the diff”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 githubErrors 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.jsonThe payload is stable; the message prose is not.
The release pipeline
Section titled “The release pipeline”On every push to main, regenerate the release PR from pending fragments:
# on push to main: batch fragments into a release PR- run: trellis release prWhen the release PR merges, pick one of the two release shapes. Tags as trigger — a tag-push workflow publishes each package:
# 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-releaseBecause publish checks Hex before doing anything, re-running either
workflow after a partial failure is safe: already-published versions are
skipped.