Pinning toolchains
A target’s cache key covers its command, inputs, environment, and the
output hashes of its dependencies. It does not automatically cover
the compiler that runs the command. So if one machine has Go 1.22 and
another has Go 1.23, they compute the same key for the same target and
can share a cache entry that isn’t really interchangeable. A shared
remote cache turns that into silent poisoning: upgrade rustc on one
machine and every other machine pulls a stale artifact.
Giant has no built-in notion of a toolchain. Instead, you make the thing that pins your toolchain part of the cache key. There are two levels:
- Simplest - add your toolchain lockfile to a target’s
inputs(e.g.inputs: ["//devenv.lock"]). Updating the toolchain changes the file, which re-keys the target. Coarse (a bump rebuilds everything that lists it) but trivial, and what the config generators do by default. - Finer - model the toolchain as its own target that build targets depend on, so a bump re-keys only the affected ecosystem. The rest of this guide.
Either way, giant is agnostic to how you pin. The lockfile or binary
can come from anywhere your toolchain changes with:
devenv / Nix (used in the examples here), a checked-in
or git-lfs binary, an asdf .tool-versions, or a target that downloads
and caches a pinned binary. None of these are special to giant - they are
just files and targets. A system-installed toolchain works too, but then
reproducibility and cache correctness are on you (see
System-installed tools), so it is discouraged.
A toolchain is a target
Section titled “A toolchain is a target”A toolchain target is a normal, statically-declared target: it lives in
a giant.yaml like everything else (hand-written, or generated offline
and checked in). It declares an input that changes when the toolchain
changes, runs a command that writes a content-derived identity, and
carries the toolchain tag. The dogfood keeps one at the workspace
root, //:devenv, but the package is up to you -
//toolchain:rust works just as well. Its label is what build targets
deps: on:
# giant.yaml (workspace root)targets: - name: "rust" # → //:rust inputs: ["//devenv.lock", "//devenv.nix"] cwd: "//" command: "command -v rustc | xargs readlink -f > .giant/toolchains/rust.id" outputs: ["//.giant/toolchains/rust.id"] tags: ["toolchain"] sandbox: false # see "Toolchain targets and giant verify" belowA toolchain target reads the realized environment - the rustc on PATH, the
resolved store path, a .tool-versions line - none of which is a declared
source input. That’s deliberate (its job is to capture the current tool’s
identity), so the target can’t be hermetic - set sandbox: false on it.
Otherwise giant verify (which forces the sandbox on) blocks the undeclared
read and the target fails. What keeps it sound is the declared lock /
.tool-versions inputs; the sandbox would only get in the way - see
Toolchain targets and giant verify. The
examples below all carry it.
targets: - name: "server" # → //crates/server:server inputs: ["src/**/*.rs"] cwd: "//" command: "cargo build -p server --release && install -m755 target/release/server bin/server" outputs: ["//bin/server"] deps: ["//:rust"]//crates/server:server’s key folds in //:rust’s output hash. Change
the Rust toolchain and the id file’s content changes, which re-keys the
build target. The id file lives under .giant/ because it’s generated
state you never commit - only its content hash matters. The target runs
with cwd: "//" so the bare .giant/... it writes and the //.giant/...
it declares as an output both resolve to the same place at the workspace
root, regardless of which package the target lives in. (// is rewritten
in inputs/outputs/cwd, but not inside command - so the command
writes a path relative to its cwd, or uses $GIANT_WORKSPACE_ROOT /
$GIANT_PACKAGE_DIR, never a //-anchored path.)
This is the same shape Bazel and Buck2 use: the toolchain is a node in
the dependency graph, so a toolchain change re-keys exactly the targets
that depend on it. A Node bump moves //:node’s id and leaves //:rust
untouched, so your Rust targets stay cached. You get per-ecosystem
scoping automatically.
With devenv
Section titled “With devenv”If you pin your tools with devenv (or plain Nix), the cleanest identity is the resolved store path of the executable:
# giant.yaml (workspace root)- name: "go" # → //:go inputs: ["//devenv.lock", "//devenv.nix"] cwd: "//" command: "command -v go | xargs readlink -f > .giant/toolchains/go.id" outputs: ["//.giant/toolchains/go.id"] tags: ["toolchain"] sandbox: falsecommand -v go finds go on PATH; readlink -f resolves it to its
store path, something like /nix/store/9x…-go-1.22.1/bin/go. That path
is derived from the toolchain’s whole build recipe, so it moves whenever
the toolchain definition changes. The engine just hashes the string - it
has no idea Nix is involved.
The soundness rests on devenv’s own guarantee: if devenv.lock hasn’t
changed, the realized go hasn’t changed, so the toolchain target stays
cached and doesn’t even re-run. The trust boundary is devenv’s, the same
way it trusts your declared inputs.
One caveat: a Nix store path is derived from a derivation’s build
inputs, so the path can change even when the binary is byte-identical
(rebuilt from a different but irrelevant input). That only
ever over-invalidates - it never reuses a stale artifact - so it’s the
safe direction. If you want an exact-content identity, use the
sha256sum form below.
One toolchain target per tool
Section titled “One toolchain target per tool”Write one toolchain target per tool you pin, each carrying the
toolchain tag, then stamp deps: ["//:<tool>"] on the build targets in
that ecosystem:
# giant.yaml (workspace root)targets: - name: "go" # → //:go inputs: ["//devenv.lock", "//devenv.nix"] cwd: "//" command: "command -v go | xargs readlink -f > .giant/toolchains/go.id" outputs: ["//.giant/toolchains/go.id"] tags: ["toolchain"] sandbox: false
- name: "node" # → //:node inputs: ["//devenv.lock", "//devenv.nix"] cwd: "//" command: "command -v node | xargs readlink -f > .giant/toolchains/node.id" outputs: ["//.giant/toolchains/node.id"] tags: ["toolchain"] sandbox: falseEvery Go target depends on //:go; every Node target on //:node. A
Node bump moves //:node’s id and leaves the Go toolchain untouched, so
your Go targets stay cached. (Prefer a dedicated package? Put them in
toolchain/giant.yaml and the labels become //toolchain:go /
//toolchain:node - same mechanism, different home.)
With checked-in or git-lfs binaries
Section titled “With checked-in or git-lfs binaries”If a tool lives in the repo at a fixed path - say bin/go tracked by
git-lfs - the resolved-path trick does not work. The path
(bin/go) is stable while the bytes change, so a path-based identity
never moves and you’d reuse a stale artifact. Hash the content instead:
# giant.yaml (workspace root)- name: "go" # → //:go inputs: ["//bin/go"] cwd: "//" command: "sha256sum bin/go | cut -d' ' -f1 > .giant/toolchains/go.id" outputs: ["//.giant/toolchains/go.id"] tags: ["toolchain"] sandbox: falseinputs: ["//bin/go"] makes the target re-run only when the binary
changes; the id file holds the content digest. This works whether the
working tree has the real binary (its bytes are hashed) or just the
git-lfs pointer (the pointer file contains the content’s oid, which
moves with the binary).
The rule across both cases: the identity must change when the toolchain
changes. A resolved store path satisfies that; a content digest
satisfies that; a bare path does not. The engine hashes whatever the
command writes, so getting this right is on you - giant explain shows
each toolchain dependency’s resolved hash so you can confirm it moves
when you expect.
With asdf
Section titled “With asdf”asdf pins versions in a .tool-versions file. Unlike
Nix, asdf’s shims are stable paths (~/.asdf/shims/go doesn’t move between
versions), so the readlink -f trick won’t work - key on the version line
instead. This is simplest and needs no asdf binary in the build:
# giant.yaml (workspace root)- name: "go" # → //:go inputs: ["//.tool-versions"] cwd: "//" command: "grep '^golang ' .tool-versions > .giant/toolchains/go.id" outputs: ["//.giant/toolchains/go.id"] tags: ["toolchain"] sandbox: falseThe input is the whole .tool-versions, so the target re-runs on any edit -
but its output is only the golang line, so early
cutoff keeps your Go targets cached when you bump
Node and re-keys them only when Go’s line actually moves. For an identity
that also catches a re-install of the same version, write asdf which go
instead (it resolves to the version-specific install path, e.g.
~/.asdf/installs/golang/1.22.1/bin/go).
Showing toolchain targets
Section titled “Showing toolchain targets”Toolchain targets are folded out of the default output so the view stays focused on your build. They still build, and a failing toolchain target always surfaces. To see them:
giant build --show-toolchainsThe same flag works on giant test and on --watch (build --watch / test --watch).
Toolchain targets and giant verify
Section titled “Toolchain targets and giant verify”giant verify runs every target sandboxed with the cache bypassed, to catch
undeclared inputs. A toolchain target trips that on purpose: its
command reads the realized environment - the tool on PATH, .devenv/profile,
a resolved store path - which is exactly the undeclared access the sandbox
denies. So a toolchain target without sandbox: false fails verify:
$ giant verify✗ FAIL //:rust 0ms exit code 1 # readlink of an undeclared path, deniedSet sandbox: false on every toolchain target (as the examples above do). It
exempts that target from the sandbox - it runs normally even under verify -
while every real build target stays audited. It doesn’t open a hole in the
check: a toolchain target produces no build artifact to verify, and its
correctness rests on its declared lock / .tool-versions inputs - hermeticity
was never what made it sound.
System-installed tools
Section titled “System-installed tools”A toolchain target needs an input that records the tool’s version. If you rely on a system-installed compiler with nothing pinning it - no lockfile, no checked-in binary - there’s no honest input to declare, and the toolchain target can’t tell when it changed. This is unsupported rather than blocked: you can point a target at a system tool, but it won’t invalidate correctly. Pin your toolchain with devenv, a lockfile, or a checked-in binary, and the patterns above keep your cache honest.