Nested repos
Some subtrees in a monorepo are not a single project. They are a project of
projects, already wired together by a task runner of their own: a JS monorepo
with its own task graph, a Gradle multi-module build, a directory driven by a
Makefile. You do not need to flatten that subtree into individual Lattice
workspaces to bring it into the graph. Declare it as one workspace whose
scripts shell out to the runner it already has, and it becomes a single node
that Lattice schedules and caches like any other.
This page walks through examples/nested-repo, a small repo with exactly this
shape, and the commands below are real output from running it.
The shape
lattice.json two workspaces: frontend, api
frontend/ an inner monorepo with its own task runner and lockfile
packages/ui/ built first (inner dependency)
packages/site/ depends on ui, emits dist/bundle.js
services/api/ a plain workspace; serves site's bundle at run time
frontend wraps a repo with its own task runner and its own inner dependency
graph (ui before site). services/api is an ordinary workspace that reads
frontend’s output. The root lattice.json:
{
"$schema": ".lattice/schema.json",
"latticeVersion": "1.0.0-beta-2",
"workspaces": [
{
"name": "frontend",
"path": "frontend",
"auto": false,
"scripts": {
"build": "sh -c 'if [ -x node_modules/.bin/turbo ]; then node_modules/.bin/turbo run build; else echo \"[frontend] turbo is not installed — run: (cd frontend && npm install)\" >&2; exit 1; fi'",
"test": "sh -c 'if [ -x node_modules/.bin/turbo ]; then node_modules/.bin/turbo run test; else echo \"[frontend] turbo is not installed — run: (cd frontend && npm install)\" >&2; exit 1; fi'",
"clean": "sh -c 'rm -rf .turbo packages/*/dist && echo \"frontend clean complete\"'"
}
},
{
"name": "api",
"path": "services/api",
"auto": false,
"dependsOn": ["frontend"],
"scripts": {
"build": "mkdir -p dist && cp src/serve.sh dist/serve.sh && chmod +x dist/serve.sh && echo 'api built'",
"test": "sh -c 'test -x dist/serve.sh && echo \"api test ok\"'",
"serve": "sh dist/serve.sh",
"clean": "sh -c 'rm -rf dist && echo \"api clean complete\"'"
}
}
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["**/*"],
"ignore": ["**/node_modules/**", "**/.turbo/**", "**/dist/**"],
"outputs": ["dist/**", "packages/*/dist/**"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["**/*"],
"ignore": ["**/node_modules/**", "**/.turbo/**", "**/dist/**"]
},
"serve": {
"dependsOn": ["build"],
"cache": false
},
"clean": {
"cache": false
}
},
"settings": {
"maxCacheSize": "1GB"
}
}
Nothing here names the inner packages, and the root declares no engines: the
runner Lattice invokes for frontend:build comes from that repo’s own
node_modules, installed the ordinary way for that ecosystem
(cd frontend && npm install). See
Workspaces for path, dependsOn, and scripts in
general; this page covers the one pattern where a workspace’s script hands off
to a whole other build.
auto: false: full manual control
Both workspaces set "auto": false. On an auto workspace, Lattice runs the
evidence ladder to detect a driver and infers commands from it; on a manual
workspace it skips detection entirely and runs only the commands you wrote in
scripts. For a wrapped subtree this is not optional: the inner directory may
contain a lockfile or config file Lattice would otherwise use to infer a
command for the outer language, and that guess would be wrong — the real
entry point is “invoke the inner runner,” not “run this package’s own script.”
auto: false says exactly that: no inference, only what you declared.
A manual workspace must declare a script for any task you run directly against
it. frontend has no serve script, because there is nothing for the frontend
to serve. Running serve scoped to it fails to resolve; running it repo-wide
(or filtered to api) works, because tasks that are only pulled in as
dependencies are skipped where a workspace doesn’t apply.
inputs and outputs: still yours to declare
The inner runner already tracks its own file dependencies and has its own
cache. That does not remove the need to declare inputs and outputs in
lattice.json — it changes what they need to cover. Lattice hashes frontend
as one opaque unit; it has no visibility into ui or site individually, so
its inputs glob has to be broad enough to catch a change anywhere inside:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["**/*"],
"ignore": ["**/node_modules/**", "**/.turbo/**", "**/dist/**"],
"outputs": ["dist/**", "packages/*/dist/**"]
}
}
}
**/* is deliberately wide, because the wrapped repo owns its own internal
layout and Lattice is not going to enumerate it. The precision moves to
ignore, and three things have to stay out of the hash:
**/node_modules/**— the installed tree; the package manager’s lockfile is hashed instead (see Caching)**/.turbo/**— the inner runner’s own cache directory, which changes on every run regardless of source changes and would make every build a miss**/dist/**— build output; an output glob left insideinputswould feed yesterday’s build into today’s cache key
outputs still has to name every artifact directory the wrapped repo
produces, at every level (dist/** for api, packages/*/dist/** for each
inner package) — Lattice restores exactly those paths on a hit, and no others.
Two caches, not one
Wrapping a subtree does not disable its inner cache. It adds a second one above it, and each skips a different kind of repeated work:
- Lattice’s cache covers
frontendas a whole. A hit restores every inner package’sdist/in one step and skips invoking the inner runner at all. - The inner runner’s cache covers individual inner packages, and only runs
at all on a Lattice miss — when
frontend’s hash has changed and Lattice hands the whole workspace back to it. From there the inner runner still decides, package by package, what actually needs to rerun.
So a change inside frontend costs one inner rebuild the first time (Lattice
miss, inner cache narrows it to the package that changed) and zero the second
time (Lattice hit, the inner runner never runs). Neither cache makes the other
redundant: Lattice’s is coarse and language-agnostic, which is why the rest of
the repo can treat frontend as one node; the inner one is fine-grained and
specific to that ecosystem.
Running it
With the inner repo’s dependencies installed once (cd frontend && npm install), from the repo root:
$ lattice run build
frontend:build: running
frontend:build: done (0.66s)
api:build: running
api:build: done (0.01s)
lattice: 2 tasks, 0 cached, 0 failed, 0.68s
Run it again with nothing changed and both workspaces come back from cache without the inner runner executing:
$ lattice run build
frontend:build: cache hit [587c2274]
api:build: cache hit [11b8de60]
lattice: 2 tasks, 2 cached, 0 failed, 0.01s
Edit a file anywhere under frontend (say, packages/ui/src/index.js) and
only frontend misses; api still depends on frontend completing, but its
own inputs haven’t changed:
$ lattice run build
frontend:build: running
frontend:build: done (0.52s)
api:build: cache hit [11b8de60]
lattice: 2 tasks, 1 cached, 0 failed, 0.52s
--dry-run shows the resolved commands without running them — useful for
confirming the handoff script is right before it executes:
$ lattice run build --dry-run
❖ lattice dry run · build
→ frontend:build sh -c 'if [ -x node_modules/.bin/turbo ]; then node_modules/.bin/turbo run build; else echo "[frontend] turbo is not installed — run: (cd frontend && npm install)" >&2; exit 1; fi'
→ api:build mkdir -p dist && cp src/serve.sh dist/serve.sh && chmod +x dist/serve.sh && echo 'api built'
serve is filtered to api, since frontend has nothing to serve
(--filter is covered in full in
Selecting what runs):
$ lattice run serve --filter api -l
lattice: running `build+serve` across 1 workspace(s)
api:build: cache hit [11b8de60]
api:serve: running
api:serve: api serving:
api:serve: module.exports = {"site":"site@1","button":"ui/button@1"};
api:serve: done (0.01s)
lattice: 2 tasks, 1 cached, 0 failed, 0.01s
Don’t copy an upstream artifact at build time
A workspace’s cache key covers only its own declared inputs; it does not
fold in the keys of the workspaces it depends on, and an inputs glob cannot
reach outside its own workspace directory. So if api:build copied
frontend’s bundle into its own dist/, a frontend-only edit would rebuild
frontend and still hand api a cache hit — serving a stale copy.
That is why, in this example, api builds only from its own src/ and reads
frontend’s bundle at run time instead, in the uncached serve task
(services/api/src/serve.sh):
#!/bin/sh
# Stand-in for the API process: serves the frontend bundle as it is on disk.
# Read at run time, never copied at build time.
set -e
echo "api serving:"
cat ../../frontend/packages/site/dist/bundle.js
If a downstream workspace genuinely needs a copy of an upstream build
artifact at build time, that artifact belongs in the downstream workspace’s
own inputs, or the copy step has to happen somewhere Lattice’s cache can see
it — otherwise the two caches disagree about what’s current and the second one
wins silently.
When to wrap, when to flatten
Wrap the subtree as one workspace when:
- it already has its own task runner, lockfile, and internal dependency graph, and that graph is not something the rest of the repo needs to see or schedule around
- the packages inside it are only ever built or tested together, never targeted individually from outside the subtree
- adopting it incrementally matters more than exposing its internals — see Adopting Lattice
Flatten it into individual workspaces instead when another workspace in the repo needs to depend on one specific inner package rather than the whole subtree, when you want Lattice’s cache to narrow a rebuild down to the single inner package that changed instead of the whole wrapped unit, or when the inner runner would otherwise become the only thing standing between you and running a single package’s tests directly. Flattening costs you the one-line handoff; it buys you per-package caching and dependency edges at Lattice’s own level of resolution.