Skip to content

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 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" below

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

crates/server/giant.yaml
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.

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: false

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

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: false

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

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: false

inputs: ["//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.

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: false

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

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:

Terminal window
giant build --show-toolchains

The same flag works on giant test and on --watch (build --watch / test --watch).

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:

Terminal window
$ giant verify
✗ FAIL //:rust 0ms exit code 1 # readlink of an undeclared path, denied

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

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.