Skip to content

Publishing

Publishing a workspace means turning pending changelog fragments into version bumps, recording what shipped as git tags, and pushing packages to Hex in dependency order with workspace path dependencies rewritten to real Hex requirements. Trellis covers each step, and every step is idempotent — re-running a partially failed release is safe.

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>]

Tag format, path-dep rewriting, and Hex retry policy are configured under [tools.trellis.publish]:

gleam.toml
[tools.trellis.publish]
exact_tag_format = "{name}-v{version}" # lat_core-v1.2.0
series_tag_format = "{name}-v{series}" # lat_core-v1.2, lat_cli-v0.4
# Which tags a release maintains, one entry per tag. "exact" keeps the whole
# version and is immutable; "major" (v1) and "minor" (v1.2) truncate it and
# move. Default ["exact"].
package_tags = ["exact"]
# Per-package overrides, keyed by a member-path glob.
package_tags_overrides = { "packages/lat_cli" = ["exact", "major", "minor"] }
# How a path dep is rewritten to a Hex requirement at publish time, from the
# dependency's current version X.Y.Z:
# minor → ">= X.Y.Z and < (X+1).0.0" (default)
# patch → ">= X.Y.Z and < X.(Y+1).0"
# exact → "== X.Y.Z"
path_dep_requirement = "minor"
retry = { attempts = 5, initial_delay = "30s", multiplier = 2 }
repository_tag_package = "lat_cli" # anchor package
repository_tag_format = "repo-v{series}" # repo-v0.4
# All three repository keys are required together, or omitted together.
repository_tags = ["major", "minor"] # repo-v0 and repo-v0.4

trellis release pr turns pending 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 on each run), and creates or updates the PR via the GitHub API. It needs a token: GITHUB_TOKEN (ambient in GitHub Actions), GH_TOKEN, or a logged-in gh CLI as the fallback (gh auth token). The body carries the bump table and each package’s new CHANGELOG section. It requires a clean working tree and is 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; --push pushes them, and --github-release (which implies --push and needs the same GitHub token as release pr) also creates a GitHub Release per tag with the matching CHANGELOG section as the body.

Each entry in a package’s package_tags falls into one of two lifecycles:

  • Exact tags ({name}-v{version}) are immutable. They are created once, fetched when origin already has them, and never rewritten — local and remote disagreeing about what one names is an error, not something to reconcile.
  • Series tags ({name}-v{series}) move. Releasing a new version in the series force-moves its tag to the release commit and force-pushes it, so consumers can pin lat_cli-v0.4 and follow the series instead of chasing patch tags. The signal is the package’s manifest version stored at the tag, so a commit that does not release the package — another package’s release, a docs change — leaves its series tags alone. The series is derived from the version — see series tags.
  • Repository tags are independent of any package’s list. Trellis moves the configured tag only when the anchor package’s manifest version differs from the version at that tag. A series change creates a new tag and leaves the prior series tag intact.
Terminal window
$ trellis tag plan
lat_core: 1.2.0 needs tag lat_core-v1.2.0
lat_cli: 0.4.3 needs tag lat_cli-v0.4.3
lat_cli: 0.4.3 moves tag lat_cli-v0.4
$ trellis tag create --github-release
tagged lat_core-v1.2.0
pushed lat_core-v1.2.0
tagged lat_cli-v0.4.3
pushed lat_cli-v0.4.3
moved lat_cli-v0.4
force-pushed lat_cli-v0.4

--github-release skips every moving tag. A release attached to a moving tag would silently retarget on the next release, so only immutable per-version tags carry one.

With no exact entry a package has no per-version tags at all, so there is no tag to trigger a publish from: run trellis publish --all-untagged on release-PR merge instead. Passing a series tag to publish --tag is an error — it names a package, but no particular version.

Repository tags identify repository snapshots rather than package releases, so neither publish --tag nor ci tag-package resolves them.

publish selects members whose release lifecycle is hex--package <name> and --tag <tag> refuse a workspace or git_only package by name, and --all-untagged only ever considers hex members. It runs, per package and in dependency order:

  1. Idempotency check — one Hex API query; versions already published are skipped.
  2. Validationgleam format --check, gleam build --warnings-as-errors, gleam test, run against the original manifest with path deps intact.
  3. Path-dep rewrite — computed from the graph, never hand-listed: each workspace path dependency becomes the Hex requirement derived from that dep’s current version, per path_dep_requirement. The version map only ever holds hex members, so a path dependency on a git_only or workspace member fails the rewrite rather than publishing something Hex could never resolve.
  4. Publishgleam publish --yes.
  5. Restore — the original gleam.toml comes back even when publishing fails; the repo never shows rewritten files.

Every Hex-touching step runs under the configured retry backoff policy.

Terminal window
# Publish the package a pushed tag points at:
trellis publish --tag "$GITHUB_REF_NAME"
# Or publish everything whose version isn't on Hex yet, in one run:
trellis publish --all-untagged
# See what would be published (and rewritten) without doing it:
trellis publish --all-untagged --dry-run

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

Dev-only path deps to a non-hex package are left alone — Hex doesn’t ship dev dependencies. A regular [dependencies] path dep to a package whose lifecycle is less capable than its dependent’s refuses to publish, and doctor catches that boundary on every PR — see Release lifecycle.

lockfile refresh --package <pkg> scopes gleam deps download to one package, with retry. Refreshing the whole workspace at once is what gets a runner rate-limited by Hex.

Both of these flows are supported; the difference is policy, not tooling:

Shape Flow
Tags as trigger Release PR merges → tag create --push → each pushed tag triggers a workflow running publish --tag "$GITHUB_REF_NAME".
Tags as record Release PR merges → publish --all-untagged (one run, topologically ordered) → tag create --github-release records what shipped.

Ready-to-paste workflows for both are in the CI recipes.