Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changes/unreleased/Added-20260819-adapter-doctor-checks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
component: doctor
kind: Added
body: |-
**`doctor` checks an adapter workspace's own invariants instead of Gleam's.** Every member manifest must declare a version that parses as semver (an error), and a name matching its directory (a warning — nothing downstream requires them to agree, and repositories in the wild do drift here).

The checks with nothing to read are dropped: `manifest.toml` lock drift, shared external dependency agreement, and the `gleam`-on-PATH toolchain pin. The `checked:` preamble now names the checks that actually ran rather than a fixed list.
time: 2026-08-19T09:02:00.000000-07:00
9 changes: 9 additions & 0 deletions .changes/unreleased/Added-20260819-adapter-manifest-seam.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
component: trellis
kind: Added
body: |-
**`[tools.trellis.adapter]` lets a workspace's members be something other than Gleam packages.** One key is required — `manifest`, the member manifest's path relative to the member directory, whose presence marks a member the way a `gleam.toml` does otherwise, and whose extension picks the format (`.toml`, `.json`, `.yaml`, `.yml`). `name` and `version` are dotted field paths into that manifest, defaulting to `name` and `version`. Built for repositories distributed by git ref rather than a registry: Claude Code plugin marketplaces, APM monorepos, AI skill collections.

Trellis also accepts a **`trellis.toml`** at the workspace root carrying the same `[tools.trellis]` table, since an adapter workspace usually has no `gleam.toml` for it to live in. Root discovery walks up to the first directory whose `trellis.toml` or `gleam.toml` carries the table, preferring `trellis.toml`; carrying it in both at once is an error.

The dependency graph is flat under an adapter — no dependency edges are declared, so topological order is member order and a workspace dependency never ripple-bumps. `publish.lifecycle` defaults to `git_only` and `hex` anywhere under an adapter is a configuration error, because there is no registry to publish to.
time: 2026-08-19T09:00:00.000000-07:00
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
component: version
kind: Added
body: |-
**`version apply` writes the bump into the field `[tools.trellis.adapter].version` names.** The edit is surgical for JSON and YAML as it already was for TOML: only that scalar changes, so a hand-maintained `plugin.json` or `apm.yml` keeps its comments, key order, indentation, and quote style byte for byte. A field path that resolves to nothing, or to a mapping, sequence, or non-string, is an error — the bump never inserts a key.

The `manifest.toml` lockfile patch is skipped in an adapter workspace, which has no Gleam lock to keep in step.
time: 2026-08-19T09:01:00.000000-07:00
7 changes: 7 additions & 0 deletions .changes/unreleased/Changed-20260819-adapter-refusals.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
component: run
kind: Changed
body: |-
**The built-in verbs are refused in an adapter workspace, and name the tasks you have declared.** `build`, `test`, `check`, `docs`, `deps`, `format`, and `clean` all shell out to `gleam`, which an adapter workspace's members are not. Declare what you need under `[tools.trellis.tasks]` — a custom task with a built-in's name still overrides it, and `trellis exec` is unaffected.

`publish`, `lockfile refresh`, and `new` are refused for the same reason.
time: 2026-08-19T09:03:00.000000-07:00
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
component: init
kind: Fixed
body: |-
**`init` refuses a repository already configured by a `trellis.toml`.** It only looked for a `[tools.trellis]` table in `gleam.toml` files, so in a workspace configured the new way it wrote a second config home — a state every later command then rejects as ambiguous. Member manifests are checked for both spellings too.
time: 2026-08-19T09:04:00.000000-07:00
37 changes: 37 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ toml = "1.1.2"
toml_edit = "0.25.12"
ureq = { version = "3.3.0", features = ["json"] }
serde_ignored = "0.1.14"
saphyr-parser = "0.0.12"

[dev-dependencies]
assert_cmd = "2.2.2"
Expand Down
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ version, and path dependencies. The dependency graph — topological order,
publish order, change impact, path-dep rewrite maps — is computed, never
declared.

Gleam is the native, batteries-included ecosystem. For a repository whose
packages are not Gleam packages — a Claude Code plugin marketplace, an APM
monorepo, anything distributed by git ref rather than a registry — a
[`[tools.trellis.adapter]`](#manifest-adapter) table redefines which file marks
a member and where its name and version live. Everything downstream is the same
code.

See [docs/DESIGN.md](docs/DESIGN.md) for the full design.

## Status
Expand Down Expand Up @@ -171,11 +178,45 @@ 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.

### Manifest adapter

`[tools.trellis.adapter]` redefines the manifest seam for a workspace whose
members are not Gleam packages. One key is required — `manifest`, the member
manifest's path relative to the member directory, whose presence marks a member
and whose extension (`.toml`, `.json`, `.yaml`, `.yml`) picks the format:

```toml
# trellis.toml at the repo root — an adapter workspace usually has no
# gleam.toml for the table to live in, so trellis accepts this file too.
# Root discovery prefers it; carrying the table in both is an error.
[tools.trellis]
members = ["plugins/*"]

[tools.trellis.adapter]
manifest = ".claude-plugin/plugin.json"
# name = "name" # dotted field paths, defaults shown
# version = "version" # this is the field `version apply` rewrites

[tools.trellis.publish]
package_tags = ["exact", "major"]
```

The bump is surgical: only the field `version` names is rewritten, so comments,
key order, and formatting survive byte for byte. The changelog engine,
`version`, `tag`, GitHub Releases, `release pr`, and `ci` are unchanged.

The graph is flat — an adapter declares no dependency edges, so nothing ripple-
bumps. `publish.lifecycle` defaults to `git_only` and `hex` is a configuration
error; `publish`, `lockfile refresh`, `new`, and `run`'s built-in Gleam verbs
are refused. `doctor` drops the `manifest.toml`, shared-dependency, and
toolchain checks, and adds that every member manifest declares a semver version
and a name matching its directory.

## Commands

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
walking up to the first `trellis.toml` or `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.

Expand Down
52 changes: 52 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,58 @@ Notably absent, because derived: package lists, dependency order, per-package
changelog wiring, version-file maps, path-dep rewrite maps, tag→package
mappings.

### 4.2 The manifest seam

Trellis reads exactly four things out of a member's manifest:

1. **Membership** — the file's presence is what makes a directory a member.
2. **Identity** — its name.
3. **Version** — the field a bump rewrites.
4. **Dependency edges** — path deps, which become the graph.

Everything else trellis does is computed from those four, and is therefore
language-agnostic: the changelog engine, `version plan`/`apply`, exact and
series tags, GitHub Releases, `release pr`, the scheduler, `ci matrix` and
`ci outputs`. The `git_only` lifecycle already *is* the publishing model for a
registry-less package type — changelog → version → tag → GitHub Release.

`[tools.trellis.adapter]` redefines the first three for a different manifest
format, and deliberately leaves the fourth undefined:

```toml
[tools.trellis.adapter]
manifest = ".claude-plugin/plugin.json" # membership; extension picks format
name = "name" # identity, dotted field path
version = "version" # bump target, dotted field path
```

No `deps` key means a flat graph, and every consumer of the graph degrades
cleanly rather than specially: topological order is member order, ripple bumps
never fire, and the path-dep rewrite is unreachable. That is the whole reason
the adapter is four keys rather than a subsystem — declaring dependency edges
in an arbitrary manifest format is the expensive half, and no motivating
repository needs it yet.

Two consequences follow from the seam rather than from configuration. A
workspace whose members are not Gleam packages usually has no `gleam.toml` for
the `[tools.trellis]` table to live in, so a root `trellis.toml` is a second
config home, preferred over `gleam.toml` when both exist and an error when both
carry the table. And the lifecycle ladder tops out one rung lower: `hex` under
an adapter is a configuration error, not a runtime refusal, so it is caught
once at load rather than at each of `publish`'s four gates.

The write side is where the seam costs something real. A `gleam.toml` bump has
always been surgical — `toml_edit` with the original value's decor cloned onto
the replacement — because a manifest is a file its owner reads. That promise
has to hold for JSON and YAML too, and neither has a `toml_edit`. `src/manifest.rs`
gets it from the fact that YAML 1.2 is a strict superset of JSON: one
`saphyr-parser` event walk locates the target scalar's span in both formats,
and the edit is a byte splice. Two of that parser's guarantees do not survive
contact — `Marker::index` is a char index despite its doc comment, and a quoted
scalar's `span.end` in block context runs to end-of-line, comment included — so
only the start marker and the scalar style are trusted, and the extent is
re-lexed from there.

## 5. Command surface

### Introspection
Expand Down
11 changes: 0 additions & 11 deletions src/changelog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -711,17 +711,6 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) {
(if month <= 2 { year + 1 } else { year }, month, day)
}

pub fn render_manifest_version(text: &str, next: &semver::Version) -> Result<String> {
let mut doc: toml_edit::DocumentMut = text.parse().context("failed to parse gleam.toml")?;
let Some(value) = doc.get_mut("version").and_then(|item| item.as_value_mut()) else {
bail!("gleam.toml has no version field");
};
let mut replacement = toml_edit::Value::from(next.to_string());
*replacement.decor_mut() = value.decor().clone();
*value = replacement;
Ok(doc.to_string())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
2 changes: 1 addition & 1 deletion src/commands/ci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ pub fn outputs(workspace: &Workspace) -> Result<()> {
.members
.iter()
.filter(|m| m.releasable())
.map(|m| format!("{}/gleam.toml", m.rel_path))
.map(|m| format!("{}/{}", m.rel_path, workspace.manifest_rel()))
.collect();
let tags: Vec<String> = workspace
.members
Expand Down
Loading
Loading