# heph > Build once. Trust the cache. This file contains all documentation content in a single document following the llmstxt.org standard. ## Approval gates An **approval gate** pauses a target until you explicitly approve or reject it. Use it for targets that have irreversible effects — deploys, infrastructure changes, database migrations — where an accidental run must be prevented. ## Declaring an approval gate Add `approval = True` to any target: ```python title="BUILD" target( name = "deploy", driver = "bash", approval = True, run = ["./deploy.sh"], ) ``` The map form lets you attach a **notice** — content shown to you before you decide: ```python title="BUILD" target( name = "deploy", driver = "bash", approval = {"required": True, "notice": ["plan"]}, deps = {"plan": ["//infra:plan"]}, run = ["./apply.sh"], ) ``` `notice` is a list of input group names. Before asking for your decision, heph resolves each named group and displays the contents of its files. In the example above the output of `//infra:plan` is shown so you can review what will change before approving. ### `approval` field shape | Value | Meaning | |-------|---------| | `True` | Gate is required; no notice. | | `False` (default) | No gate. | | `{"required": True, "notice": ["group", ...]}` | Gate is required; listed input groups are rendered as the notice. | | `{"required": False, "notice": [...]}` | Notice only — content is shown but no approval is required. | Unknown keys in the map form are an error, so typos fail loudly instead of silently disabling the gate. ## Interactive prompt When you run a gated target in a terminal, heph pauses and waits for your decision: - Press `y` to approve and continue execution. - Press `n` to reject — the target fails immediately with a clear error. In the full TUI the prompt appears inline on the progress view. In a plain terminal session the notice prints to stderr and heph reads your answer from the controlling terminal. ## Non-interactive runs With no terminal attached, a gated target fails unless you pass `--auto-approve`: ```bash title="terminal" heph run //infra:deploy --auto-approve ``` `--auto-approve` approves all gated targets in the run without prompting. The notice still prints to stderr — the decision is automatic but the notice is never suppressed. :::warning Running without a terminal and without `--auto-approve` fails with a clear error asking you to pass the flag. This is intentional — a gated target must never execute silently. ::: ## Cache interaction A cache hit skips execution entirely — and with it, the approval gate. For targets that must always prompt (a deploy, for example), disable caching: ```python title="BUILD" target( name = "deploy", driver = "bash", approval = True, cache = False, run = ["./deploy.sh"], ) ``` With `cache = False` the target always runs, so the gate always fires. --- ## Builtin tools heph ships a set of POSIX utilities (`cp`, `sed`, `grep`, `tar`, …) inside its own binary and puts them on the `PATH` of every `exec` and `bash` target. A recipe that runs `install -D` or `sed -i` behaves the same on Linux and macOS. Without them, a recipe runs whatever the host provides: GNU tools on Linux, BSD tools on macOS. `install -D` does not exist on BSD, `sed -i 's/a/b/' f` eats the next argument as a filename, and `sort` orders by the host's locale. ## What's included | Group | Utilities | |-------|-----------| | Files | `cat` `chmod` `cp` `install` `ln` `mkdir` `mktemp` `mv` `readlink` `realpath` `rm` `rmdir` `stat` `tee` `touch` | | Text | `comm` `cut` `grep` `head` `sed` `sort` `tail` `tr` `uniq` `wc` | | Search | `find` `xargs` | | Archives | `tar` `gzip` `gunzip` `zstd` | | Checksums & encoding | `base64` `md5sum` `sha1sum` `sha256sum` `sha512sum` | | Shell helpers | `basename` `date` `dirname` `echo` `env` `false` `nproc` `printf` `seq` `sleep` `timeout` `tmpl` `true` | List them with: ```bash heph tool coreutils list # add --json for machine-readable output heph tool coreutils which cp # does heph ship cp, and what would the host supply? heph tool coreutils run cp -r a b # run one directly to check what a flag does ``` ## Enabling and disabling On by default; there is nothing to configure. To use the host's tools instead, set `coreutils: false` on the driver: ```yaml title=".hephconfig" plugins: - builtin: exec options: coreutils: false - builtin: bash options: coreutils: false ``` :::warning Turning the toolbox on or off changes the cache key of every `exec` and `bash` target. A heph release that changes a builtin's behavior does too, so expect those targets to rebuild after such an upgrade. ::: ## What a target's `PATH` contains A target sees its own tools, what it declared, and the builtins. The host's directories are **not** on `PATH`: a recipe that calls an undeclared host binary fails with `command not found` instead of working only on machines that happen to have it. Declare the tool, or add directories back with the driver's `path` option. ## Where the builtins differ from GNU | Utility | Behavior | |---------|----------| | `sed` | `-i[SUFFIX]` takes GNU's attached-suffix form. BSD's `sed -i '' …` is refused with an explanation. Basic regular expressions work, including `\(…\)` groups and `\1` in the replacement. Backreferences in the *pattern* and lookaround are rejected with an error naming the construct. Command groups (`{ … }`) are not supported. | | `grep` | No `-P`. Never colorized, whatever the terminal. | | `tar` | Modes `-c`, `-x`, `-t` with `-z`, `-f`, `-C`, `-v`. Output is reproducible by default (see below). | | `find`, `xargs` | Arguments must be valid UTF-8; others are refused. | `sed` supports `s` (flags `g`, `p`, `i`, a number), `y`, `d`, `p`, `q`, `=`, `a`, `i`, `c`; line, `$`, `/re/` and range addresses with `!`; and `-n`, `-e`, `-f`, `-E`/`-r`. Both `a text` and `a\` followed by a newline are accepted. `grep` supports `-E -F -i -v -n -c -l -L -q -w -x -r -h -H -s`, `-m`, `-e` and `-f`. ## Reproducible archives `tar`, `gzip` and `zstd` produce the same bytes for the same input on every machine, with no flags to remember: - entries are sorted by path; - owner is uid/gid 0 with empty names; - modes are normalized to the executable bit; - mtime is `SOURCE_DATE_EPOCH` when set, otherwise 0; - `gzip` writes no file name or timestamp in its header. There is no option to turn this off. Extraction detects gzip and zstd from the file contents, not its name. ```python title="BUILD" target( name = "greeting", driver = "bash", deps = {"src": "//app:template.txt"}, run = "sed 's/NAME/world/' $SRC > $OUT", out = "greeting.txt", ) ``` --- ## Caching heph caches at the granularity of a target. Before running anything it hashes a target's complete inputs into a single digest; if that digest is already in the cache, heph returns the stored output instead of re-running the action. That is a **cache hit**. ```bash title="terminal" $ heph run //app:server 0.565s · 1 / 1 done · 1 cached · 0 failed ``` ## What goes into the hash Everything a target declares folds into its input hash: its sources, its dependencies' outputs, the tools it uses, its environment, and the target definition itself. Change any of them and the digest changes, so the target re-runs. Leave them all identical and you get the cached bytes — on your laptop or in CI. This is the foundation of [reproducibility](/docs/concepts/reproducibility). :::note Because outputs are content-addressed by their inputs, a cache hit is provably the exact artifact you *would* have built. The cache is trustworthy, not a guess. ::: ## Fixed-point caching A target whose inputs equal its outputs reaches a fixed point: re-running it produces the same digest, so it's a cache hit and never executes again. This is what makes idempotent [codegen](/docs/concepts/codegen) (an `in_place` formatter, say) free to re-run — once the tree is formatted, the formatter is a no-op hit. ## Controlling caching per target Whether a target caches, and how aggressively, is a property of the [driver](/docs/plugins) that runs it. The [exec](/docs/plugins/exec) driver exposes a `cache` field — a bool, or a map of `{enabled, remote, history}` — to opt a target out or tune retention. See the driver's page for the exact shape. ## In-memory cache Within a single run, heph also keeps results in memory. Size it with the [`memCache`](/docs/reference/configuration#memcache--in-memory-cache) block in `.hephconfig`; `capacityBytes: 0` disables it. ## Durable cache storage Between runs, artifacts are stored on disk. Small artifacts (≤ 8 MiB by default) are kept in the cache database; larger ones are stored as plain files under `/cache/blobs/`. GC reclaims both. The threshold is tunable with [`cache.spillThresholdBytes`](/docs/reference/configuration#cache--local-cache-storage) in `.hephconfig`. ## Remote shared cache A remote cache lets multiple machines share build artifacts. When one machine builds a target, its artifacts are pushed to the remote; any other machine with the same inputs pulls them instead of rebuilding. Configure one or more remote caches in `.hephconfig`: ```yaml title=".hephconfig" caches: shared: uri: s3://my-bucket/heph-cache # s3://, gs://, az://, https://, file:// ``` **Writes** happen on a background task after a local cache write — every writable remote receives the artifacts in parallel. The build's critical path does not wait on the network; heph stays open until all uploads drain. **Reads** happen on a local cache miss. heph tries remotes in ascending-latency order; the first cache holding a matching entry is a cache hit. Confirming the hit only costs a small metadata fetch — the output bytes themselves transfer later and lazily, and only for the outputs something actually reads. Latency is measured once per process (or loaded from a persisted file) and refreshed when the cache definitions change. A failing remote never fails the build — errors are logged once and, after three consecutive failures, that cache is paused with an exponential backoff. It resumes automatically the next time a request to it succeeds. For the full set of options and supported backends, see [`caches` in the configuration reference](/docs/reference/configuration#caches--remote-shared-caches). For a step-by-step CI walkthrough, see the [Remote cache guide](/docs/guides/remote-cache). ## Filesystem scan cache heph also remembers the results of workspace file scans — glob expansion, package discovery — across runs, validated against file and directory metadata. Any change to the tree re-scans the affected paths, so edits are always picked up. This is transparent and needs no configuration. :::tip When debugging, rule the scan cache out by setting `HEPH_DEBUG_CACHED_WALKER=0` — every scan then reads the filesystem directly, with no caching at all. ::: ## Reclaiming space A target's revisions beyond its configured [`history`](/docs/plugins/exec#cache-control) count are trimmed automatically at the end of the run that writes the new one — there's nothing to run for that. The on-disk cache still grows as inputs change. Garbage-collect everything else that's no longer reachable (removed targets, orphaned entries): ```bash title="terminal" heph tool gc ``` To deliberately remove cached entries for a specific target selection instead — every revision, regardless of `history`, whether or not the target still exists — use `heph tool clean`. Targets are selected the same way as for `run`: an address, a label followed by a package matcher, or `-e ''`. ```bash title="terminal" heph tool clean //cmd/server:bin # one target (that exact variant) heph tool clean all //cmd/... # every cached target under a subtree heph tool clean test //cmd/... # every target labelled "test" heph tool clean all //... # clear the entire local cache ``` The selection is required — a bare `heph tool clean` prints help instead of wiping the cache. `all` selects a package without naming a label, the same way it does for [`query`](/docs/plugins/query). :::tip Reaching for `clean` regularly on a target that hasn't changed is usually a symptom, not a fix — it means the cache is returning something stale, which points at an under-declared input on that target rather than a cache problem. Track down the missing input instead of making `clean` part of your workflow. ::: --- ## Codegen Most targets keep their outputs inside heph's cache and sandbox — the source tree never changes. **Codegen targets are the exception.** They materialize their outputs *back into the workspace*, next to your hand-written code, so that tools which read the tree directly — your editor, the compiler, `go list`, `grep` — see the generated files as if you had written them yourself. The `codegen` attribute on an exec target turns this on and picks **how** the outputs land in the tree: ```python title="BUILD" target( name = "generated", driver = "bash", codegen = "copy", # or "in_place" out = "generated.txt", run = "echo 'generated by //fmt:generated' > $OUT", ) ``` ## The two modes | Mode | Produces | Tracked in git? | Needs gitignore? | |-------------|-----------------------------------|-----------------|------------------| | `copy` | **New** files the target creates | No | Yes | | `in_place` | Rewrites of **existing** sources | Yes | No | The distinction is about *ownership of the file on disk*: - **`copy`** emits a brand-new file that did not exist in the tree before. It is a build artifact that happens to live among your sources — not a tracked source. heph stamps these outputs so a later `glob()` **excludes** them (they won't be picked up as inputs to other targets), and they belong in `.gitignore` rather than in a commit. - **`in_place`** transforms files that are *already* tracked and committed. The target reads a source file and writes the result back over the same path. The file stays a normal, version-controlled source — there is nothing new to ignore. ## `copy` — generate a new file Use `copy` when the target *creates* output that you do **not** want to commit: protobuf stubs, generated API clients, embedded asset manifests — anything derived purely from other inputs. ```python title="fmt/BUILD" target( name = "generated", driver = "bash", codegen = "copy", out = "generated.txt", run = "echo 'generated by //fmt:generated' > $OUT", ) ``` `//fmt:generated` writes `fmt/generated.txt` into the tree. Because it is a `copy` output, `glob("*.txt")` in the same package will skip it, and `heph tool gen-gitignore` will add it to the root `.gitignore`. ## `in_place` — rewrite existing sources Use `in_place` for tools that *modify* files you keep under version control: formatters, codemods, an auto-fixing linter. The generated result replaces the checked-in file, so the change shows up as a normal diff you review and commit. ```python title="fmt/BUILD" target( name = "fmt", driver = "bash", codegen = "in_place", deps = {"src": glob("*.txt")}, out = glob("*.txt"), # Idempotent: uppercase every tracked .txt file, writing it back in place. run = 'for f in $SRC_SRC; do tr a-z A-Z < "$f" > "$f.up" && mv "$f.up" "$f"; done', ) ``` :::tip Keep `in_place` transforms **idempotent** — running them twice should produce the same bytes as running them once. That keeps the output reproducible and the diff stable. ::: :::note An `in_place` target never writes into a file a `copy` target owns. If an `in_place` output path collides with a `copy` target's output, heph leaves that file untouched — the `copy` target's content stays intact, and only that file is skipped (any other files the `in_place` target also writes are still updated). `--frozen` applies the same exemption, so a `copy`-owned file never fails an `in_place` frozen check. Point `copy` and `in_place` targets at disjoint files rather than relying on this. ::: ## Keeping `.gitignore` in sync: `heph tool gen-gitignore` `copy` outputs should never be committed, so heph can maintain the `.gitignore` for you. Running: ```bash heph tool gen-gitignore ``` scans the workspace for **every** `codegen = "copy"` output and writes them into a managed block in the root `.gitignore`, pairing each pattern with a comment naming the target that emits it: ```text title=".gitignore" # BEGIN heph-generated (managed by `heph tool gen-gitignore` — do not edit) # //fmt:generated /fmt/generated.txt # END heph-generated ``` The attribution comment always sits on its own line directly above the pattern it names — never trailing on the same line — since git only treats a `#` as a comment when it starts the line. The command is: - **Scoped.** Only `copy` outputs are listed — `in_place` rewrites are tracked sources and never appear. - **Anchored.** Paths are workspace-root-relative, so a file becomes `/foo/bar.go`, a directory `/foo/gen`, and a glob `/foo/gen/**/*.go`. Directory patterns carry no trailing slash, since a `copy` directory output lands in the tree as a symlink — a plain `/foo/gen/` pattern would not match it. - **Idempotent & non-destructive.** Only the marked block is rewritten; anything you put outside the markers is preserved verbatim. Output is sorted and deduplicated, so re-running it produces a stable, diffable result. Run it after adding or removing a `copy` target. Because the output is deterministic, it also works well as a CI check — fail the build if `heph tool gen-gitignore` would change the committed `.gitignore`. ## Detecting output conflicts Two `copy` targets must never claim overlapping output paths — if they do, one target's output silently overwrites the other's. Run `heph validate` to catch these conflicts across the whole workspace: ```bash heph validate ``` Overlap means more than identical paths. heph also flags **containment**: if one target claims the directory `/gen` and another claims the file `/gen/a.go`, they overlap because the directory output encompasses the file. A file and a same-named directory also conflict. Only conflicts between *different* targets are reported — a single target that declares both a directory output and a file inside it is valid. Run `heph validate` in CI alongside `heph tool gen-gitignore` to ensure no two targets compete for the same part of the source tree. ## Reached through a dependency A codegen target updates the tree whenever it is part of the run — not only when you name it. If `//app:bin` depends on `//fmt:generated`, running `//app:bin` writes `fmt/generated.txt` too, and `--frozen` on a run that reaches a codegen target verifies it the same way. The target is written once per run, however many targets depend on it. ## Verifying the tree in CI: `--frozen` Running a codegen target normally **writes** its outputs into the tree — `copy` drops in the generated file, `in_place` rewrites the source. In CI you usually want the opposite: assert that the committed tree *already* matches what codegen would produce, without touching anything. That is what `--frozen` does: ```bash heph run //fmt:fmt --frozen ``` In frozen mode heph computes the generated output but **writes nothing**. It compares the result against what is on disk and, if they differ, exits non-zero with a unified diff of every offending file: ```text $ heph run //fmt:fmt --frozen # clean tree → passes $ echo 'hello world' > fmt/greeting.txt $ heph run //fmt:fmt --frozen # dirty tree → fails × target failed: //fmt:fmt ╰─▶ generated output differs from tree ╭─[diff] │ --- a/fmt/greeting.txt │ +++ b/fmt/greeting.txt │ @@ -1 +1 @@ │ -hello world │ +HELLO WORLD ╰──── ``` It works for both modes: a `copy` target fails if its generated file is missing or stale, and an `in_place` target fails if a source file isn't already in its formatted/transformed form. Wire `heph run --frozen` into CI to guarantee that whoever forgot to run codegen locally gets a red build with an exact diff, instead of a drifting tree. ## When to use which | You want to… | Mode | |----------------------------------------------------------------|-------------| | Generate code from a schema (proto, OpenAPI, SQL) | `copy` | | Produce a derived file you don't commit | `copy` | | Format source files (`gofmt`-style) | `in_place` | | Run a codemod or auto-fixing linter over checked-in code | `in_place` | The quick rule: if the file is **born from the build**, use `copy` and let `heph tool gen-gitignore` keep it out of git. If the file is **yours and the build edits it**, use `in_place` and commit the result. --- ## Credentials A **credential** is a target — `driver = "auth.credential"` — that declares an identity a build needs: an ordered list of ways to obtain it, and the shape it's handed to consumers in. A consuming target names it with `credentials = [...]` and never learns which of the ways supplied it. ## The contract > A credential grants **access**; it is not an **input**. A target's outputs > must be identical whichever identity satisfied its credential requirement. > A target whose output depends on *who* ran it is not cacheable, and says so > with `cache = False`. Nothing about a credential — the material, which source supplied it, the declaration, even the names of the variables it presents — enters the [input hash](/docs/concepts/caching). A cache hit acquires nothing: build the same target twice with two different identities and the second run is still a cache hit, never a re-acquire. ## Declaring one ```python title="BUILD" aws = target( name = "aws", driver = "auth.credential", sources = [ # CI: exchange the runner's own OIDC token for the role, no secret # stored anywhere. heph.auth.oidc( "github_actions", audience = "sts.amazonaws.com", present = heph.auth.aws_web_identity( role = "arn:aws:iam::123456789012:role/deployer", ), ), # Laptop: whatever's already logged in via the AWS CLI. heph.auth.exec( ["aws", "configure", "export-credentials", "--profile", "acme", "--format", "process"], fields = { "access_key_id": "AccessKeyId", "secret_access_key": "SecretAccessKey", "session_token": "SessionToken", }, expires = "Expiration", login = [["aws", "sso", "login", "--profile", "acme"]], present = heph.auth.aws_process(), ), ], ) target( name = "deploy", driver = "bash", credentials = [aws], tools = ["//tools:terraform"], env = {"AWS_REGION": "eu-west-1"}, # selects bytes — stays an ordinary hashed input run = "terraform apply -auto-approve", cache = False, # this target's output depends on which account it ran against ) ``` `//svc:deploy` says one word — `credentials = [aws]` — with no branching on CI and no environment names. The declaration is environment-independent; the chain inside it isn't. `AWS_REGION` stays on the consumer because it *selects* which bytes come back, not because it's a secret. ## The chain `sources` is ordered, and the **environment** picks the winner, not the author. A source's **probe** answers *"is this applicable here?"*, not *"will it succeed?"* — the first applicable source is the one used; if its acquire step fails, that's a hard failure, not a fallthrough to the next source. `heph auth explain ` prints the walk: every source, why each was skipped or chosen. | Source | Constructor | Probe | |---|---|---| | Environment variables | `heph.auth.env(names)` | Every named variable is set | | A file | `heph.auth.file(path, fields=None, expires=None)` | The path exists | | A command | `heph.auth.exec(run, fields=None, expires=None, login=None, runner=None)` | `run`'s program resolves | | Host paths, in place | `heph.auth.passthrough(paths, env=None, login=None)` | Every named path exists | | A CI provider's own token | `heph.auth.oidc(provider="github_actions", audience=None)` | That CI provider is detected | | Another target | a bare target address (no wrapper) | none — selected by `when` instead | `heph.auth.exec`'s command must print JSON to stdout; `fields` maps a material field name to a key in that JSON, and `expires` names the key holding an absolute timestamp or a duration in seconds. `heph.auth.file` uses the same `fields`/`expires` shape against a file's contents instead of a command's stdout, or presents the whole file as `${value}` when `fields` is omitted. `heph.auth.passthrough` exposes existing host paths without copying them, each reachable in a presentation as `${file:}`. Every source also accepts: | Option | Meaning | |---|---| | `when` | Restrict this source to a condition instead of probing for one — see below. | | `credentials` | Credentials this source's own acquire step needs (a command that must itself authenticate to a secret manager, say). | | `present` | Override the credential's presentation for material from this source only. | | `hint` | Text shown by `heph auth explain` for this source. | ### `when` A closed vocabulary, not an expression language: | Value | Matches | |---|---| | `"ci"` | Any detected CI provider | | `"ci:"` | One named provider — `"ci:github_actions"`, `"ci:gitlab_ci"`, `"ci:buildkite"`, `"ci:circleci"`, `"ci:jenkins"` | | `"interactive"` | A terminal is attached | | `"env:NAME"` | A named host environment variable is set and non-empty | | `"os:linux"` / `"os:darwin"` | The build host's OS | A source that is a target address has no cheap probe — you can't ask "is this applicable?" of a target without running it — so it's always selected by `when` rather than probed. ## A source that's a target When acquiring needs tools, dependencies, or more than one command, make it an ordinary target instead of an inline `heph.auth.exec`: ```python title="BUILD" mint = target( name = "mint", driver = "bash", cache = False, # required of every credential source out = {"credential": "cred.json"}, # the reserved group, read as JSON run = 'vault kv get -format=json -field=data secret/cloudflare > $OUT_CREDENTIAL', ) cf = target( name = "cf", driver = "auth.credential", sources = [mint], present = {"env": {"CLOUDFLARE_API_TOKEN": "${token}"}}, ) ``` An output group named `credential` is read as JSON and its top-level keys become material fields; every other output group becomes a named file, reachable in a presentation as `${file:}`. Expiry comes from an `expires_at` or `expires_in` key in that same JSON, or from the credential's own `ttl`. The target **must** be `cache = False` — its output is material, and heph refuses to let it become a cached, shareable artifact. ## Presentation `present` decides how material reaches a consumer's sandbox — set on the credential target itself, or per source to override it for that source's material: ```python present = { "env": {"NAME": "${field}"}, # environment variables "files": {"name": "${field}"}, # files, written mode 0600 "helper": "dialect", # a callback the consuming tool invokes } ``` At least one of the three is required. Only material and the handles needed to use it belong here — a region, an account id, a profile name all *select* which bytes a tool talks to, so they stay ordinary hashed config on the consumer, not the credential. | Shape | Survives the material expiring mid-run? | |---|---| | `helper` | Yes — the consuming tool calls back for a fresh credential whenever it needs one. | | `files` | Sometimes — depends on whether the tool re-reads the file on refresh. | | `env` | No — handed over once; the target fails if it expires while still running. | ### Template vocabulary Values inside `present` are templates: | Written | Resolves to | |---|---| | `${}` | A material field, by name. | | `${file:}` | The absolute path of a presented file. | | `${helper:command}` / `${helper:args}` | How to invoke the callback (for a hand-written `helper` config). | | `$$` | A literal `$`. | A `present` value is also a [deferred-value](/docs/concepts/deferred-values) field, so `role = "${read://infra/aws:role-arn}"` works the same way it does anywhere else that accepts one — read the owner of the value from another target instead of copying it into the BUILD file. ### Presentation presets Hand-rolling a third-party wire format is how you get a subtly wrong one, so common ones ship as presets. Each returns a `present` dict — use one directly in a source's or credential's `present =`: | Preset | Parameters | Produces | |---|---|---| | `heph.auth.aws_process()` | — | An AWS credential-process callback. | | `heph.auth.aws_web_identity(role, session_name="heph")` | `role`, `session_name` | A web-identity token file plus `AWS_WEB_IDENTITY_TOKEN_FILE`/`AWS_ROLE_ARN`/`AWS_ROLE_SESSION_NAME`. | | `heph.auth.gcp(audience, impersonate=None)` | `audience`, `impersonate` | A generated `external_account` file plus `GOOGLE_APPLICATION_CREDENTIALS`. | | `heph.auth.azure_workload(client_id, tenant_id)` | `client_id`, `tenant_id` | A federated token file plus `AZURE_FEDERATED_TOKEN_FILE`/`AZURE_CLIENT_ID`/`AZURE_TENANT_ID`. | | `heph.auth.github(hosts="github.com")` | `hosts` | `GH_TOKEN` plus a git credential callback for the named host(s). | | `heph.auth.docker(registries)` | `registries` | A Docker credential-helper callback for the named registries. | | `heph.auth.git(hosts)` | `hosts` | A git credential callback for the named hosts. | | `heph.auth.netrc(machines, login="${username}", password="${token}")` | `machines`, `login`, `password` | A generated `netrc` file plus `NETRC`. | ## Using a credential Reference it by address on a consuming target: ```python title="BUILD" target( name = "fetch", driver = "bash", credentials = ["//auth:github"], run = 'curl -sf -H "Authorization: token $GH_TOKEN" https://api.github.com/user > $OUT', out = "response.json", ) ``` Whether the target should still cache depends on the contract above, not on the fact that it uses a credential: if the output would be identical regardless of which identity ran it, leave caching on; if the output embeds the identity — a plan naming an account, a presigned URL — set `cache = False`. [`oci.pull` and `oci.push`](/docs/plugins/oci#registry-authentication) take `credentials` too, presented with `heph.auth.docker`; they are anonymous unless one is named. A target cannot reference the same credential twice, and two credentials can't both present the same variable name to one target — both are rejected at parse time rather than silently picked between. ## The CLI | Command | Does | |---|---| | `heph auth status` | One row per declared credential, its state, source, and expiry. `--json` for machine-readable output. | | `heph auth explain ` | Walks the whole chain: every source, why each was skipped, which won. | | `heph auth login [addr] [--force]` | Runs the login commands of sources that are stale: skipped because their tool is missing, or the chosen source failing to produce the credential (an expired session). `--force` signs in to the chosen source anyway and discards the credential's cached material — to switch accounts, or when a session is broken in a way heph can't see. | | `heph auth logout` | Clears cached material from disk. | A build never signs you in on its own — it fails, naming the exact `heph auth login` command to run. ## Redaction Declared credential material is scrubbed from a target's captured output before it's written anywhere, so a build step that echoes its own token prints `[redacted]` instead. It's best-effort: material shorter than 8 bytes isn't scrubbed, a secret the target transformed (base64, URL-encoded) isn't recognized, and only material *fields* are scrubbed — the contents of a presented file are not, so `cat`-ing a credential file still leaks it. --- ## Deferred values A driver option's value is normally a literal you write in the BUILD file. A **deferred value** is a reference to another target's output instead — heph resolves it once that target has run, and the reference itself becomes the dependency edge, so you don't also add it to `deps`. ```python title="BUILD" target( name = "version", driver = "bash", deps = [file("VERSION")], out = "version.txt", run = "tr -d '\n' < $SRC > $OUT", ) target( name = "image", driver = "bash", run = 'printf "%s" "myapp:${read://:version}" > $OUT', out = "image.txt", ) ``` `//:image` never lists `//:version` in `deps` — the reference is the edge. Editing `VERSION` reruns `//:version`; if its output text comes out unchanged, `//:image` stays a cache hit. If the text changed, `//:image` reruns with the new value. ## Two forms | Form | Resolves to | |------|-------------| | `${read://pkg:name}` | The contents of the target's output, with surrounding whitespace trimmed. | | `${src://pkg:name}` | The sandbox path of the target's output file. | Either accepts an [output-group selector](/docs/reference/addresses#output-group-selector) for a target that publishes more than one group: `${src://tools:cli|bin}`. A template can mix literal text with more than one reference: `"${read://infra:registry}/app:${read://infra:version}"`. ## `${src://…}` vs. `$SRC_` [`$SRC_`](/docs/plugins/exec#dependencies) already exposes a dependency's path, but only inside a shell — expanding an environment variable needs one. `${src://pkg:name}` fills the path in directly, so it also works as an argument under the `exec` driver, which runs with no shell at all: ```python title="BUILD" target( name = "copy-version", driver = "exec", run = ["cp", "${src://:version}", "current-version.copy"], out = "current-version.copy", ) ``` `${src://…}` places the producer's file into the sandbox and hashes the edge the same way a `deps` entry does, with one difference: it does not pull in the producer's transitive environment and tools the way `deps` does. Depend on the target normally with `deps` instead if you need those too. ## Where you can use one Support is per option, not per driver — an option only accepts a deferred value if its documentation says so. Three do today: the `run`, `env` and `runtime_env` options on the [`exec` and `bash`](/docs/plugins/exec) drivers, the `env`/`files` values in a [credential](/docs/concepts/credentials)'s `present` block, and the `ref` option of [`oci.push`](/docs/plugins/oci#ocipush) (`${read://…}` only). In `env` and `runtime_env`, only a variable's **value** may hold a reference, not its name. A `runtime_env` value stays out of the cache key as usual, but the target it references does not: the consumer reruns when that target's output changes. A reference in an `env` inherited from a dependency's `transitive` is rejected. A reference is always rejected in an option that decides *which targets exist* or *what the build graph looks like* — `deps`, `tools`, `runner`, `out`, `name`, a `glob()` pattern, an address filter, and labels. heph reports this at parse time, before anything runs. :::note An unrecognized `${…}` is left exactly as written, so shell syntax that merely looks similar — `${FOO:-default}`, `${SRC:0:3}` — still works as shell syntax. heph only claims the form when what follows `read:` or `src:` is an [address](/docs/reference/addresses) starting with `//` — the relative `:name` and `./name` forms an address elsewhere accepts stay bash here too. ::: ## Failures | When | Behavior | |------|----------| | the producer's build fails | the consuming target fails, naming the producer | | the producer's output is empty | fails | | `${read://…}` output has more than one line | fails | | the producer publishes more than one output and no group is given | fails, listing them | | `${src://…}` used in a [credential](/docs/concepts/credentials)'s `present` | fails at parse — a credential has no sandbox for a path to point into | ## Substitution, not quoting heph splices the resolved value into the option's text as-is — it does not quote it. Under the `exec` driver each reference fills exactly one argument, so this is never a concern. Under `bash`, the value lands inside a shell command, so a producer that emits `1.0; rm -rf /` runs exactly that: ```python title="BUILD" run = 'printf "%s" "${read://:version}" > $OUT' # substituted, then handed to bash ``` Only reference targets whose output you trust — including one whose cached result was pulled from a [remote cache](/docs/concepts/caching#remote-shared-cache) built on another machine. --- ## Dependencies A dependency is an edge in the build graph: one target consuming another's outputs. Edges define both **order** and **data flow** — heph runs a target only once every dependency has produced its outputs, and it makes those outputs available to the dependent. ## Declaring an edge A target references another by [address](/docs/reference/addresses), or by the value returned from `target()` (which *is* its address): ```python title="BUILD" lib = target(name = "lib", driver = "bash", run = "go build -o $OUT .", out = "lib") target( name = "image", driver = "bash", deps = ["//assets:bundle", lib], run = "...", out = "image", ) ``` Independent targets run concurrently; only a real edge forces ordering. The finer you split targets, the more heph can cache and parallelize. ## Outputs and groups A target's outputs are its product. A target may split them into named **output groups**, and a dependent can select a single group with the `|group` [selector](/docs/reference/addresses#output-group-selector): ```text //app:compile|bin # depend on just the "bin" group ``` This keeps edges narrow: a consumer that needs only the binary doesn't rebuild when an unrelated output group changes. ## How outputs reach the dependent The build graph defines *that* an edge exists; **how** a dependency's files are surfaced to a target is up to the [driver](/docs/plugins) that runs it. The [exec](/docs/plugins/exec#dependencies) driver, for instance, exposes each dependency group through `$SRC_` environment variables inside the sandbox. See the driver's page for its specific contract. :::note Every input must be declared. If a target reads a file it didn't list as a dependency, the [sandbox](/docs/concepts/sandbox) won't contain it — the build fails fast rather than silently depending on something untracked. ::: --- ## Reproducibility heph's central promise is **byte-identical outputs**: the same inputs produce the same artifact, on every machine, every time. This is what makes the cache trustworthy — a cache hit is provably the artifact you would have built. ## How it's enforced 1. **Inputs are hashed before anything runs.** Sources, tool versions, environment and rule definition all fold into one digest. 2. **Actions run hermetically.** A sandbox exposes only the declared inputs. 3. **Outputs are content-addressed.** The digest of the inputs is the key; the bytes of the output are the value. :::warning Non-hermetic targets opt out of caching guarantees. Use them sparingly. ::: ## Path names must be UTF-8 Every path heph reads — a package directory, a source file matched by a `glob()`, an entry discovered while walking the tree — must be valid UTF-8. A name that isn't fails the build instead of being silently skipped: ```text directory entry name is not valid UTF-8: ... heph paths must be UTF-8 — rename it, or `fs.skip` the containing directory ``` Rename the entry, or add its directory to [`fs.skip`](/docs/reference/configuration#fs--workspace-ignore-patterns) to exclude it from every walk. heph does not apply Unicode normalization to path names either: two differently-encoded spellings of the same characters (e.g. composed vs. decomposed accents) are treated as distinct names, matching what the filesystem itself stores. --- ## Runners An **exec runner** decides *where* a target's command actually runs: on the host as usual, inside a devenv shell, or inside a container. An [Exec](/docs/plugins/exec) (`exec`/`bash`) target can name one, and so can a [Go](/docs/plugins/go) target through the provider's own `runner` option. ## Using a runner Point a target at a runner target's address: ```python title="BUILD" target( name = "build", driver = "bash", run = "make", out = "out/", runner = "//tools/devenv:runner", ) ``` Or set it once for the whole workspace, on the driver: ```yaml title=".hephconfig" plugins: - builtin: exec options: runner: "//tools/devenv:runner" - builtin: bash options: runner: "//tools/devenv:runner" ``` A target's own `runner` field wins over the driver-wide default. Either way, `runner = "local"` is the explicit opt-out — for the one target in a workspace-wide default that must still run on the bare host. A runner is a hashed dependency: naming one, or a change to the environment it resolves to, changes the target's cache key. An unchanged environment stays a cache hit. :::warning A runner target must not run under the workspace-wide default it configures. The natural way to write one is a `bash` target, which would otherwise become its own runner and cycle on the first build — the `exec`/`bash` driver excludes a target from a default it is itself. If the runner's own `deps` are themselves `exec`/`bash` targets, give them `runner = "local"` explicitly, or a workspace-wide default turns them into a cycle too. ::: ## What runs where | Plugin | Driver | Describes | |---|---|---| | [Devenv](/docs/plugins/devenv) | `devenv.runner` | A [devenv](https://devenv.sh) shell. | | [OCI](/docs/plugins/oci#running-targets-inside-a-container) | `oci.runner` | A running container. | Both are targets like any other: build one, point other targets at its address. Nothing else about the consuming target changes — its own `deps`, `tools`, `env`, and sandbox behave exactly as they do without a runner; only where the command executes differs. ## The environment a target sees ```text env_clear + the runner's environment + the target's own ``` The target's own `env`, `pass_env`, deps, and tools always win over anything the runner's environment provides — a target that declares something gets what it declared, even inside an environment that has an opinion of its own. `PATH` is assembled rather than overridden, because it is a list and both sides legitimately contribute: ```text PATH = the target's tools ++ the target's own declared PATH entries ++ the runner's PATH ++ heph's builtin utilities ``` Tools lead, so a target that declares a tool gets that one even when the runner's environment ships a program by the same name. The builtin utilities come last: they fill gaps and never shadow a binary the environment ships. They are not added under an [OCI](/docs/plugins/oci) runner. :::note The driver's [`path`](/docs/plugins/exec#the-path-a-target-sees) option does not apply under a runner — the runner's environment supplies the `PATH`. ::: ## Writing your own Most workspaces reach for a plugin-provided runner ([Devenv](/docs/plugins/devenv), [OCI](/docs/plugins/oci#running-targets-inside-a-container)). A runner is nothing more than a target whose single output is a `runner.json` file, so any driver able to write one qualifies — a hand-written [Textfile](/docs/plugins/textfile) target is a legitimate runner. ```json title="runner.json" { "version": 1, "fingerprint": "devenv:9f2c4e1b7a0d3856", "runner": "wrap", "config": { "env": {"PATH": "/nix/store/.../bin:..."} } } ``` | Key | Meaning | |-----|---------| | `version` | Format version. Currently `1`. | | `fingerprint` | A string that changes if and only if the described environment changes — see below. | | `runner` | The runner implementation to use: `wrap`, `session`, or a name a plugin registered. | | `config` | Configuration specific to that implementation. | ### The built-in runner implementations | `runner` | Behavior | `config` | |---|---|---| | `wrap` | Static rewrite of the command: an argv prefix and an environment, applied at spawn. | `prefix` — argv prepended to the command; its head becomes the program. `env` — hashed base environment; a variable the target also sets keeps the target's value (`PATH` is composed instead). `runtime_pass_env` — host variables pulled in by name at spawn time, under the same rule; unhashed. | | `session` | Holds one process open inside the environment for the whole build, and runs each target inside it. | `launch` — argv that enters the environment; the target's command is appended to it. `cwd` — where `launch` runs. | `session` has no `env` key, deliberately: the held process's own environment *is* the environment targets run in, so there is nothing separate to declare. ### The fingerprint A consumer's cache key comes from the runner target's own hashout — the bytes of `runner.json`. If those bytes don't move when the environment does, every consumer keeps serving artifacts built against the *old* environment, silently, forever — including from a shared remote cache. Two rules follow: 1. **Derive it from the resolved environment, never from the files you read to build it.** A config file can import other files a declared input never named, so hashing the source files misses exactly the change that matters. 2. **It must be stable across runs of an unchanged environment.** One leftover per-invocation value in the capture makes the fingerprint move on every build, and every consumer in the workspace goes permanently cold — with nothing erroring, and nothing pointing back here. See [Devenv](/docs/plugins/devenv#fingerprint) for a worked example: it derives its fingerprint from the resolved shell rather than from `devenv.nix`, for exactly this reason. --- ## Sandbox Every target runs in a **sandbox**: an isolated directory containing only its declared inputs. No ambient filesystem access, no implicit dependencies — if a target needs a file, it must declare it. ## Why isolate Isolation is what makes the [input hash](/docs/concepts/caching) honest. If a target could read a file it never declared, that file would change its output without changing its hash — and the cache would hand back a stale, wrong artifact. The sandbox makes undeclared reads impossible: a missing dependency fails the build immediately instead of corrupting the cache. This is also what guarantees [reproducibility](/docs/concepts/reproducibility) — two machines with the same declared inputs assemble byte-identical sandboxes. ## Assembly mode By default heph assembles each sandbox by materializing the declared inputs into it. For large input sets that copying can dominate, so heph can instead present inputs through a FUSE overlay. It is opt-in via the [`fuse`](/docs/reference/configuration#fuse--sandbox-overlay) block in `.hephconfig`: ```yaml title=".hephconfig" fuse: enabled: auto # true | false | auto ``` :::warning The FUSE overlay is in development and not yet fully functional. Leave it off for production builds. ::: `auto` lets the engine choose per target. The sandbox's contents are identical either way — only how the files get there differs. ## Output collisions Every file in a sandbox has exactly one owner. If two different dependencies each try to place a file at the same path, the build fails instead of silently picking one: ```text output collision: pkg/go.mod is produced by two different targets (//pkg:_a and //pkg:_b); a sandbox file may be provided by only one target ``` The check runs before the sandbox is assembled, so a conflict is caught immediately rather than producing a sandbox whose contents depend on materialization order. A dependency reached more than once — through a diamond in the build graph, or by listing the same target twice — is not a collision: the same target claiming the same path repeatedly still resolves to one file. Two targets are also free to populate different files under a shared parent directory; only the file paths themselves are compared. To resolve a real collision, narrow one dependency so it no longer overlaps the other — for example, exclude the conflicting path from a [glob](/docs/plugins/fs), or depend on a single named [output group](/docs/concepts/dependencies#outputs-and-groups) instead of a target's full output: ```python title="BUILD" deps = [ glob("**/*", exclude = ["go.mod", "go.sum"]), ":go_mod", # already provides go.mod and go.sum ], ``` ## Inspecting a sandbox When a target fails, step inside its sandbox with the exact inputs, tools, and environment it runs with. The [exec](/docs/plugins/exec#interactive-debugging-with---shell) driver opens an interactive shell: ```bash title="terminal" heph run //app:server --shell ``` From there you can list `$SRC_*`, re-run the command by hand, and see precisely what the target sees. --- ## Scratch caches A **scratch** is a directory a target declares, keeps between runs, and shares with every other target that references it. It is the one thing inside a [sandbox](/docs/concepts/sandbox) that is neither an input nor an output — mutable, and never hashed. ## The contract > A target's outputs must be identical whether its scratch directories are > warm, cold, or absent. Losing one is always a slowdown, never a wrong > answer. heph gives every other part of a target's inputs and outputs the hermetic treatment: declared in, declared out, sandbox thrown away. A scratch is the deliberate exception, for a tool that already maintains its own content-addressed cache — a compiler cache, a package download cache, a registry blob store. Because it never enters the [input hash](/docs/concepts/caching), it can never invalidate anything: changing a scratch's settings rebuilds nothing, and deleting one costs time and nothing else. Check the contract on your own target: ```bash title="terminal" heph inspect hashout //build:compile heph --no-scratch inspect hashout //build:compile ``` Same hash both times, or the target depends on carried-over state and is broken. ## Declaring one A scratch is declared like any other target, with `driver = "scratch"`. It builds nothing — it only describes a cache. ```python title="BUILD" target( name = "gocache", driver = "scratch", path = ".cache/go-build", # optional; omit for env-var-only env = "GOCACHE", # defaults to SCRATCH_ access = "shared", # "exclusive" (default) | "shared" version = "", # what the contents depend on, beyond the addr remote = False, # may travel through the remote cache ) target( name = "build", driver = "bash", scratch = ["//build:gocache"], run = "go build -o $OUT ./...", out = "bin", ) ``` | Field | Type | Default | Meaning | |---|---|---|---| | `path` | `string` | unset | Where the directory mounts inside a consuming target's sandbox, relative to its cwd. Optional — most tools find their cache through an environment variable, and omitting `path` is the safer shape: with nothing mounted, no output can collect the directory and no dependency can be shadowed by it. | | `env` | `string` | `SCRATCH_` | Environment variable a consumer reads the directory's absolute path from. Point it at the tool's own variable (`GOCACHE`, `CCACHE_DIR`) so nothing else needs wiring. | | `access` | `string` | `"exclusive"` | `"exclusive"` — one consumer at a time, enforced across separate `heph` processes too; the safe default for a tool with no stated concurrency story. `"shared"` — concurrent consumers allowed, only for a cache that's safe under concurrent access *by construction* (content-addressed and self-verifying, the way Go's build cache is). | | `version` | `string` | `""` | Everything the contents depend on, beyond the address — the whole of it. Two declarations at the same address share a directory if and only if they also agree on `version`. heph never guesses: state what the cache depends on, or leave it empty for a cache that's portable everywhere. See [Portability with `version`](#portability-with-version). | | `remote` | `bool` | `false` | Whether this cache may be pulled from a remote cache automatically, and published to it with `heph tool scratch push`. See [Sharing through the remote cache](#sharing-through-the-remote-cache). | | `max_size` | `string` | unset | A size cap, e.g. `"10GiB"`. Past it, the whole cache is dropped and starts again — heph can't tell which of a foreign tool's entries are hot, so it doesn't try to trim. | Reference a declaration from a consuming target with `scratch = [...]` — a plain list of addresses, the same shape as `deps`. A target may reference several; every target referencing the same declaration gets the same directory. ## Choosing `access` `access` is an assertion about the tool, not a wish — get it wrong and `"shared"` corrupts the cache, or `"exclusive"` serializes work that didn't need to be. Go's build cache and module cache are safe under concurrent writers because they're content-addressed: an entry either matches its key or is not used, the same property `go build -p N` already relies on. Most tools don't document that, so `"exclusive"` — one consumer at a time, enforced with a lock held for the whole run — is the default. ## Portability with `version` `version` is opaque to heph — it isn't parsed, and heph contributes nothing of its own to it (no host OS, no architecture) unless the BUILD file puts it there: ```python title="BUILD" version = heph.core.os() + "/" + heph.core.arch() # host-specific version = goos + "/" + goarch + "/" + go_version # target-specific version = "" # portable (the default) ``` The default is empty, which is the *less* safe direction, deliberately — a narrow default that guesses wrong (keying on the host for contents that actually depend on the target) is worse than an obviously-too-broad one. Changing `version` yields a fresh, empty cache without touching anything else. ## Auditing with `--no-scratch` `--no-scratch` runs against a throwaway, empty cache instead of the stored one — proving the contract rather than assuming it. It implies `--force`: since a scratch never enters the cache key, a cache hit would otherwise just replay the answer the audit exists to check. The stored cache itself is never touched. ```bash title="terminal" heph run --no-scratch //build:compile ``` A target that reads an unset scratch variable under `set -u` fails outright rather than running cold — write `${GOCACHE:-}` if the audit should report on the target instead of on the shell. ## Sharing through the remote cache With a [remote cache](/docs/reference/configuration#caches--remote-shared-caches) configured, `remote = True` lets a scratch's contents travel between machines. A build pulls automatically on a cold cache; nothing is ever published as a side effect of building — that's always an explicit step, so CI runs it last: ```bash title="terminal" heph tool scratch push --all --producer "$CI_RUN_ID" # publish every remote = True cache heph tool scratch pull --all # warm a machine ahead of time ``` Which lineage a run reads and writes is controlled by `scratch.scope` in `.hephconfig` — see [`scratch` — branch-lineage policy](/docs/reference/configuration#scratch--branch-lineage-policy). By default every run shares one lineage; scope it per branch so work on one branch doesn't overwrite another's cache, and a fresh branch starts from its base instead of from nothing. ## Inspecting and reclaiming ```bash title="terminal" heph tool scratch ls # every cache: address, access, size, lineages present heph tool scratch head //build:gocache # why a build would be warm or cold heph tool scratch path //build:gocache # the on-disk directory heph tool scratch rm //build:gocache # drop one; always safe heph tool scratch rm --all # drop every scratch cache ``` `heph tool scratch head` is the one worth remembering: it prints every candidate lineage a build would consult, local and remote, in order, and which one wins — the answer to "why did my branch start cold?", a question the directory itself can't answer since the interesting part is what *wasn't* found. Scratch caches also come under `heph tool gc`: ```bash title="terminal" heph tool gc --scratch-max-size 50GiB --scratch-max-age-days 30 ``` ## A broad output beside a mounted scratch A target's own outputs must never come from inside its scratch — that would make the artifact's bytes depend on unhashed, mutable state. Give a mounted scratch (one with `path` set) a narrow `out` pattern in the same directory: a glob broad enough to reach the mount (`out = "**/*"`, say) fails when heph packs the result, naming the mount path in the error. An unmounted scratch (no `path`, the env-var-only form) sidesteps this entirely — there's nothing in the sandbox tree for an output to reach, which is why it's the shape to reach for first when a tool is happy to be told its cache directory through an environment variable alone. ## Who uses one already The [Go plugin](/docs/plugins/go#build-and-module-caches) shares one `GOCACHE` per module and per build variant, and one portable `GOMODCACHE` for downloaded modules, both without any configuration. The [OCI plugin](/docs/plugins/oci#ocipull)'s `oci.pull` shares one registry blob store across every pull in the workspace — two images sharing a base layer download it once, not once per pull. --- ## Targets & rules # Targets & rules A **target** is the unit of work in heph: a name, a set of inputs, and the action that turns those inputs into outputs. Targets are addressed by path, using the `//` prefix that gives the project its mark: ```text //app:server //lib/auth:lib //proto:api ``` ## The dependency graph Edges between targets form a DAG. heph walks it bottom-up: a target can only run once every dependency has produced its outputs. Independent targets run concurrently across every core. | Term | Meaning | |-------------|-------------------------------------------| | `target` | A named, cacheable unit of work. | | `hashin` | The hash of a target's complete inputs. | | `cache hit` | A hashin already present in the cache. | | `sandbox` | The isolated directory an action runs in. | :::tip Keep targets small and explicit. The finer the graph, the more heph can cache and parallelize. ::: --- ## Getting started Install heph: ```bash title="terminal" curl -fsSL https://hephbuild.github.io/install.sh | sh ``` Then drop a `.hephconfig` at the root of your repository. Pin the version so every machine and CI run resolves the same toolchain — byte for byte: ```yaml title=".hephconfig" version: ``` From here, enable the [plugins](/docs/plugins) that you require and get building! A good plugin to get started is [buildfile](/docs/plugins/buildfile). --- ## Using heph in CI heph behaves the same in CI as on your laptop — same inputs, same artifacts. A few flags make CI runs cleaner and turn heph's guarantees into gates. ## Plain log output The interactive TUI assumes a terminal. In CI, force log-only output with `--no-tui`. ## Fail on stale codegen [Codegen](/docs/concepts/codegen) targets normally write into the tree. In CI you want the opposite: assert the committed tree already matches what codegen would produce, without writing anything. That is `--frozen`: ```bash title="terminal" heph run //... --frozen ``` If a generated file is missing or out of date, the run exits non-zero with a diff — so a contributor who forgot to regenerate gets a red build, not a silent drift. ## Selectors that match nothing fail the run `heph run` exits non-zero if its selector matches no targets. A typo'd label, an unbuilt variant, or a package matcher outside the workspace used to build nothing and exit 0 — indistinguishable from a job that legitimately had nothing to do. Now it fails loud instead. Debug a selector that isn't matching what you expect with: ```bash title="terminal" heph query -e '' ``` This only applies to `heph run`. A `heph query` that matches nothing is a legitimate answer. ## Validate the workspace [`heph validate`](/docs/guides/validate) checks that every target resolves, that no two `codegen = copy` targets write to the same path, and that `.gitignore` is current — all in one read-only command: ```bash title="terminal" heph validate ``` It reports every problem it finds, not just the first one. ## Reuse the cache The [cache](/docs/concepts/caching) is keyed by input hash, so persisting it between CI runs turns unchanged targets into instant hits. Cache the heph home directory (set with [`homeDir`](/docs/reference/configuration#keys)) across jobs using your CI's caching mechanism, keyed on your lockfiles. ## Live build status (GitHub Actions) The [GitHub Actions hook](../plugins/gha.md) writes a live status comment on the pull request under review and a step summary when the command finishes. Load it from a `ci.hephconfig` overlay so it only activates in CI: ```yaml title="ci.hephconfig" plugins: - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-gha-plugin.json ``` ```yaml title=".github/workflows/build.yml" jobs: build: permissions: pull-requests: write # required for the live PR comment steps: - uses: actions/checkout@v4 - name: Build env: HEPH_PROFILES: ci run: heph run //... --no-tui ``` See the [GitHub Actions plugin page](../plugins/gha.md) for the full options reference. ## A representative job ```bash title="terminal" heph run //... --frozen # build everything; fail on stale codegen heph run //... # run tests / checks heph validate # check targets resolve and .gitignore is current ``` --- ## Claude Code plugin `heph-expert` is a [Claude Code](https://claude.com/claude-code) plugin that teaches Claude how heph works — so it writes correct BUILD files, debugs cache misses and sandbox failures, and explains the target graph instead of guessing. It ships from this docs site, which doubles as a plugin **marketplace**. ## Install Run both commands inside Claude Code: ```text title="Claude Code" /plugin marketplace add hephbuild/hephbuild.github.io /plugin install heph-expert@heph-marketplace ``` The first command registers the marketplace from this repository; the second installs the plugin. Restart Claude Code if prompted. ## What you get | Piece | What it does | |---|---| | **Skill `heph`** | Auto-activates whenever Claude sees a heph workspace — `.hephconfig`, BUILD files, `heph` commands, or `//pkg:name` addresses. Carries the full model and per-topic references (concepts, authoring, CLI, configuration, plugins). | | **Agent `heph-expert`** | A specialist subagent for larger jobs: designing or refactoring a target graph, diagnosing rebuilds, debugging sandboxes, tuning config. | | **`/heph-target`** | Scaffold a new target from a plain-language description. | | **`/heph-debug`** | Debug a failing target inside its sandbox. | | **`/heph-explain`** | Explain why a target did or didn't rebuild. | | **`/heph-ci`** | Set up or review heph in CI. | The skill activates on its own — you don't invoke it. Type a command with its leading slash; address the agent by name ("ask the heph-expert to…"). :::tip The plugin's bundled knowledge mirrors these docs. When an exact flag or option matters, the skill fetches the matching `.md` doc page live (indexed at [llms.txt](https://hephbuild.github.io/llms.txt)), so it stays current even between plugin updates. ::: ## Update Pull the latest marketplace catalog, then upgrade: ```text title="Claude Code" /plugin marketplace update heph-marketplace /plugin install heph-expert@heph-marketplace ``` ## Uninstall ```text title="Claude Code" /plugin uninstall heph-expert@heph-marketplace ``` --- ## Consuming build outputs After `heph run` resolves a target, its artifacts live in the cache. A few flags let you surface those artifacts into your local workflow. ## List output paths Print each output file path to stdout, one per line: ```bash title="terminal" heph run //app:bundle --list-out ``` ## Print output contents Stream the contents of every output artifact to stdout: ```bash title="terminal" heph run //app:bundle --cat-out ``` `--cat-out` and `--list-out` are mutually exclusive. ## Copy outputs to a directory Copy all output artifacts into a local directory, creating it if it does not exist: ```bash title="terminal" heph run //app:bundle --copy-out dist/ ``` Relative paths resolve against the current directory; absolute paths are used as-is. The copied tree mirrors the workspace-relative artifact paths — the same layout `--list-out` prints. ## Selecting specific output groups When a target declares multiple named [output groups](/docs/plugins/exec#output-groups), use `--output` to restrict any of the above flags to one or more groups: ```python title="BUILD" target( name = "compile", driver = "bash", run = "go build -o $OUT_BIN .; go doc . > $OUT_DOC", out = {"bin": "app", "doc": "app.txt"}, ) ``` ```bash title="terminal" heph run //app:compile --output bin --copy-out dist/ heph run //app:compile --output bin --output doc --list-out ``` `--output` is repeatable. When omitted, all groups are included. Requesting a name the target does not declare is an error. --- ## Editor setup heph ships a language server that connects to any editor supporting the Language Server Protocol. It provides diagnostics, completion, hover documentation, go-to-definition, and document formatting. ## What it provides | Feature | What you get | |---------|-------------| | Diagnostics | Syntax errors as you type (the first error on each line), and a `target()` or `provider_state()` call that's missing a required field — a base field like `name`, or, once `driver`/`provider` is set, a field required by that driver or provider. | | Completion | See [Completion](#completion). | | Hover | Signature and documentation for any builtin or provider function. Hover a `target()` call to see the addresses it produced and, once its `driver` is set, that driver's config fields with their types and descriptions. Same for `provider_state()`: once its `provider` is set, hover shows that provider's state fields instead of a generic signature. Hover a field name for its type and description, and a `heph..` name for its signature. | | Go-to-definition | Jump to the BUILD file that defines an address — both `//pkg:name` and relative `:name` forms. Also jumps from a `load()` path to the loaded file, from a loaded symbol to its `def`, and from a name to its `def` elsewhere in the package. | | Formatting | Format-on-save rewrites the open BUILD file to canonical style. Uses the same rules as [`heph tool build-fmt`](/docs/guides/formatting-build-files), respecting the workspace `indent` setting and the `# heph:fmt skip-file` directive. | ## Completion | Where the cursor is | What completes | |---------------------|----------------| | `driver = "` in `target()` | Driver names | | `provider = "` in `provider_state()` | Provider names | | A keyword name in `target()` | The base fields plus the selected driver's fields, minus the ones already written | | A keyword name in `provider_state()` | `provider` plus the selected provider's fields, minus the ones already written | | A keyword name in a call to another function (`my_rule(`, `glob(`) | The function's remaining parameters, inserted as `name = `, ahead of the usual suggestions | | After `heph.` | Plugin names and `core` | | After `heph..` | That plugin's functions | | A string starting with `//` or `:` | Packages and targets | | The first argument of `load(` | Package paths | | A later string argument of `load(` | The `def`s exported by the loaded file | Completion keeps working while the file doesn't parse — typically when you are midway through typing a call or string. An error later in the file doesn't affect the suggestions at the cursor. ## VS Code Install the [heph extension](https://marketplace.visualstudio.com/items?itemName=hephbuild.heph-nightly) from the marketplace. It configures the language server automatically. ## Connecting an editor The language server runs over stdio. Configure your editor's LSP client to launch `heph tool build-lsp`. :::warning The `heph tool build-lsp` command is not part of the stable public API and may change without notice. ::: ### Neovim ```lua title="~/.config/nvim/init.lua" vim.api.nvim_create_autocmd("BufRead", { pattern = "BUILD", callback = function() vim.lsp.start({ name = "heph", cmd = { "heph", "tool", "build-lsp" }, root_dir = vim.fs.root(0, ".hephconfig"), }) end, }) ``` ### Workspaces The server handles a single workspace: the first workspace folder your editor sends, which must be inside the heph repository it was started in. A BUILD file outside that repository only gets generic Starlark completion and hover. ### Other editors Any editor with LSP support works. The relevant settings are: - **Command**: `heph tool build-lsp` - **Transport**: stdio - **Root marker**: `.hephconfig` - **File patterns**: `BUILD` (or your workspace's configured names) --- ## Your first build This walkthrough takes you from nothing to a working, cached build with two targets — one that produces an output, and one that consumes it. ## 1. Mark the workspace Drop a [`.hephconfig`](/docs/reference/configuration) at the repo root. It pins the toolchain and registers the plugins this build uses: ```yaml title=".hephconfig" version: providers: - name: buildfile drivers: - name: bash ``` ## 2. Write a BUILD file Create a `BUILD` file. The first target writes a greeting to its output; the second depends on it and wraps it. ```python title="hello/BUILD" greeting = target( name = "greeting", driver = "bash", run = "echo 'hello world' > $OUT", out = "greeting.txt", ) target( name = "shout", driver = "bash", deps = {"msg": greeting}, run = "tr a-z A-Z < $SRC_MSG > $OUT", out = "shout.txt", ) ``` `greeting` returns its [address](/docs/reference/addresses), which `shout` lists as a dependency. heph surfaces that dependency to the command as `$SRC_MSG` — the group name `msg` becomes the variable suffix. ## 3. Run it ```bash title="terminal" $ heph run //hello:shout 0.31s · 2 / 2 done · 0 cached · 0 failed ``` heph builds `greeting` first (the edge forces the order), then `shout`. Inspect the output: ```bash title="terminal" $ heph run //hello:shout --cat-out HELLO WORLD ``` ## 4. See the cache work Run it again without changing anything: ```bash title="terminal" $ heph run //hello:shout 0.02s · 2 / 2 done · 2 cached · 0 failed ``` Both targets are [cache hits](/docs/concepts/caching) — identical inputs, so heph returns the stored outputs instead of re-running. Change the `run` script of `greeting` and only the targets whose inputs changed rebuild. ## Next - Add a [tool](/docs/plugins/exec#dependencies) the build runs with. - Split outputs into [groups](/docs/plugins/exec#output-groups). - Wire codegen checks and caching into [CI](./ci.md). --- ## Formatting BUILD files `heph tool build-fmt` rewrites BUILD files in place to a canonical style — consistent indentation, argument spacing, and quote normalization. Run it with `--check` in CI to enforce formatting without writing. ## Usage ```bash title="terminal" heph tool build-fmt # format every BUILD file in the workspace heph tool build-fmt //pkg/... # scope to a package matcher heph tool build-fmt - # read from stdin, write formatted result to stdout ``` With no argument the formatter walks the whole workspace and rewrites every BUILD file it finds. With a [package matcher](/docs/reference/addresses), only the matched packages are touched. The stdin mode (`-`) reads source from stdin and writes the formatted result to stdout. It is useful for editor integrations and shell pipelines. ## In CI Pass `--check` to report unformatted files and exit non-zero without writing: ```bash title="terminal" heph tool build-fmt --check ``` A CI job that requires `--check` to pass keeps the workspace consistently formatted. See [Using heph in CI](/docs/guides/ci) for a representative setup. ## Opting a file out Add `# heph:fmt skip-file` as the first comment in a BUILD file to leave it untouched by the formatter: ```python title="BUILD" # heph:fmt skip-file target(name = "generated", driver = "exec") # preserved exactly as written ``` The directive must appear before the first statement. A file with this directive passes `--check` even when it is not formatted. ## Configuration The formatter reads the `buildfile` plugin options from `.hephconfig`. Both settings are shared with the buildfile provider, so the formatter and the provider always agree on which files are BUILD files and how they are indented. ```yaml title=".hephconfig" plugins: - builtin: buildfile options: indent: 4 ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `indent` | number | `4` | Spaces per indentation level. | | `patterns` | `string[]` | `["BUILD", "*.BUILD"]` | File names (and globs) the formatter treats as BUILD files, matching what the buildfile provider uses. | --- ## Inspecting builds When a build does something surprising — a target rebuilds when you expected a [cache hit](/docs/concepts/caching), or a dependency isn't where you think — the `heph inspect` subcommands show you the engine's view. See the [CLI reference](/docs/reference/cli) for full flag detail. The `hashin`, `hashout`, `spec`, `def`, and `deps` subcommands accept both absolute addresses (`//pkg:name`) and [relative](/docs/reference/addresses#relative-forms) forms resolved against the current working directory's package. ## "Why did this rebuild?" A target rebuilds when its input hash changes. Compare the hash across runs to find what moved: ```bash title="terminal" heph inspect hashin //app:server heph inspect hashin :server # same, from inside app/ ``` The input hash (`hashin`) folds in every declared input. If it changed, an input changed. Pair it with the output hash to confirm what the target produced: ```bash title="terminal" heph inspect hashout //app:server ``` ## "What does this depend on?" Print a target's dependency edges, or explore them interactively: ```bash title="terminal" heph inspect deps //app:server heph inspect deps //app:server -i # interactive explorer ``` ## "Where is this target used?" The reverse of `deps`: scan the workspace for every target that declares a given target as a direct input. ```bash title="terminal" heph inspect revdeps //lib:core ``` Output is one address per line. To narrow the search to a subset of packages, pass `--scope` with a package matcher: ```bash title="terminal" heph inspect revdeps //lib:core --scope //cmd/... ``` You can also pass a file path instead of a target address — heph resolves it to the corresponding file target automatically: ```bash title="terminal" heph inspect revdeps ./config/defaults.yaml ``` Only direct edges are checked; transitive users are not included. A target that uses `//lib:core` indirectly (through another library) does not appear unless it also declares `//lib:core` as an explicit input. ## "How are these two targets connected?" `deps` and `revdeps` show a target's immediate neighbors. When you need to know *how* two targets relate several hops apart, `path` prints the shortest chain linking them: ```bash title="terminal" heph inspect path //cmd/server:bin //lib:core ``` Argument order doesn't matter — heph searches both directions and always prints the chain from the dependent to the dependency, one address per line. Like `revdeps`, it also accepts a file path in place of either address. If the two targets aren't connected, stdout stays empty and the reason is logged instead — useful when scripting a check for "is A reachable from B?". ## "What provider_state applies to this package?" A BUILD file can call `provider_state(provider="X", ...)` to configure a provider for its package — and, depending on the provider, for descendant packages too. `heph inspect states` shows where those declarations live and what reaches a given package: ```bash title="terminal" heph inspect states # every declaration in the workspace heph inspect states //cmd/... # scoped to a matcher heph inspect states //cmd/server --inherited # the whole chain //cmd/server is handed heph inspect states -p go --json # narrow to one provider, machine-readable ``` Packages that declare nothing are omitted, so empty output means "no state here". Add `--inherited` to also see what a package picks up from its ancestors — each line is tagged with the package that declared it, root first. Which of two declarations wins when they share a key is the provider's own policy (the [Go plugin](/docs/plugins/go#provider_state--per-package-configuration), for example, applies its own `recursive` and closest-wins rules); this command only reports what applies, not who wins. ## "What did the provider produce?" A [provider](/docs/plugins) turns BUILD files (or generated sources) into target specs, which a [driver](/docs/plugins) then resolves into a definition. Inspect each stage: ```bash title="terminal" heph inspect spec //app:server # the raw spec the provider emitted heph inspect def //app:server # the resolved definition (inputs, outputs, sandbox) ``` `spec` is what the provider produced before any driver interpreted it; `def` is the fully resolved form the engine executes. ## Listing what exists ```bash title="terminal" heph inspect packages # all packages heph inspect packages cmd/... # packages under a matcher heph inspect functions # provider-exposed BUILD functions heph inspect labels # every label declared across the workspace heph inspect labels //cmd/... # labels scoped to a matcher ``` `functions` lists every provider function available in BUILD files, with its typed signature — for example `fs.glob(pattern: string) -> list[string]`. Useful when a provider adds its own callable beyond the core [builtins](/docs/plugins/buildfile#authoring-build-files), or to check argument names and types before writing a call. `labels` prints the sorted, deduplicated set of labels declared across every matching target — a quick way to see what labels exist in a package before writing a [query](/docs/plugins/query) predicate against them. ## Reproducing the environment To go past inspection and actually poke at a failing target, open its [sandbox](/docs/concepts/sandbox): ```bash title="terminal" heph run //app:server --shell ``` --- ## Remote cache A remote cache lets heph share build artifacts across machines. CI jobs and developers with the same inputs get cache hits from artifacts another machine already built — without re-executing. ## How it works 1. A machine builds a target and writes the artifacts to its local cache. 2. heph pushes those artifacts to the remote cache on a background task — the build's critical path does not wait on the network. 3. A second machine with the same inputs misses locally and checks the remote. A matching entry there is enough to call it a cache hit and skip re-executing — the actual output bytes download afterward, lazily, and only for the outputs something reads. Uploads are best-effort: a remote failure logs a warning but never fails the build. After three consecutive failures a cache is paused with an exponential backoff, and automatically resumes on its own the next time a request to it succeeds. An authentication failure is the exception. If the remote rejects your credentials (a 401/403, an expired login or session token, a revoked or unreadable key), heph logs one warning and disables that cache for the rest of the run instead of retrying. The build carries on using the local cache; log in again and re-run to use the remote. ## Bucket layout Objects are keyed by target address, so a bucket browses like the source tree — inspecting it in the S3/GCS console shows which target owns which artifact: | Target | Object key | |---|---| | `//some/pkg:tgt` | `some/pkg/tgt//out.tar` | | `//some/pkg:tgt@v=linux,vp=arm64` | `some/pkg/tgt@v=linux,vp=arm64//out.tar` | | `//:root_tgt` | `root_tgt//out.tar` | `` is the target's input hash — the same value that drives local cache hits. Treat the layout as read-only: don't script against it, since it's not a part of heph's stable API and can change between versions. If it does change, existing objects are simply never read again — the next build repopulates the cache under the new layout, no cleanup needed. ## Setting up S3 Add a `caches:` block to `.hephconfig`: ```yaml title=".hephconfig" caches: shared: uri: s3://my-bucket/heph-cache ``` heph reads credentials from the environment using the standard AWS credential chain — `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, `AWS_PROFILE`, or an instance/workload-identity role. ### Custom S3-compatible endpoints Point an `s3://` cache at an S3-compatible service that isn't AWS — Cloudflare R2, MinIO, Ceph — with `endpoint` and `region`. The URI still names the bucket and prefix; `endpoint` names the host to talk to: ```yaml title=".hephconfig" caches: shared: uri: s3://my-bucket/heph-cache endpoint: https://.r2.cloudflarestorage.com region: auto ``` `endpoint` and `region` override the standard `AWS_ENDPOINT_URL` / `AWS_REGION` environment variables when set. Both are `s3://`-only — setting either on a `gs://`, `az://`, `https://`, or `file://` cache fails at startup with the offending field and URI. A plain `http://` endpoint (a local MinIO, a test double) is what opts that cache into plaintext requests — heph otherwise refuses plaintext HTTP to a remote cache. Writing `https://` never lifts that block: ```yaml title=".hephconfig" caches: local: uri: s3://bucket/prefix endpoint: http://localhost:9000 ``` ## Setting up GCS ```yaml title=".hephconfig" caches: shared: uri: gs://my-bucket/heph-cache ``` Credentials come from `GOOGLE_APPLICATION_CREDENTIALS` or Application Default Credentials. In CI, a Workload Identity binding or a service account key file both work. ## Scoping credentials to a cache By default heph reads credentials from the ambient environment — the same `AWS_ACCESS_KEY_ID` any other AWS tool on the machine uses. That's a problem the moment the cache lives somewhere else: an `s3://` cache in Cloudflare R2 needs `AWS_ACCESS_KEY_ID` to hold an R2 key, which is then the wrong key for any target that talks to real AWS. Two `s3://` caches in two different accounts have the same conflict with each other. heph also reads two heph-owned namespaces, checked most specific first, before falling back to the ambient environment: 1. **This cache alone** — `HEPH_CACHE__*`, where `` is the cache's key under `caches:`, uppercased with every character a shell can't spell replaced by `_`. `build-cache` becomes `HEPH_CACHE_BUILD_CACHE_*`. 2. **Every cache of this kind** — `HEPH_S3_*`, `HEPH_GCS_*`, `HEPH_AZURE_*`, or `HEPH_HTTP_*`, matching the cache's scheme. The suffix is the same setting name the ambient variable uses, so the mapping is mechanical: `HEPH_S3_ACCESS_KEY_ID` for `AWS_ACCESS_KEY_ID`, `HEPH_S3_ENDPOINT_URL` for `AWS_ENDPOINT_URL`, `HEPH_GCS_SERVICE_ACCOUNT` for `GOOGLE_SERVICE_ACCOUNT`, `HEPH_AZURE_ACCOUNT_NAME` for `AZURE_STORAGE_ACCOUNT_NAME`. `HEPH_S3_AWS_ACCESS_KEY_ID` also works, if you'd rather keep the vendor name. Two caches, two accounts, one shell: ```yaml title=".hephconfig" caches: r2: uri: s3://heph-cache/repo endpoint: https://.r2.cloudflarestorage.com corp: uri: s3://corp-cache/repo ``` ```bash title="terminal" export HEPH_CACHE_R2_ACCESS_KEY_ID=... export HEPH_CACHE_R2_SECRET_ACCESS_KEY=... export HEPH_CACHE_CORP_ACCESS_KEY_ID=... export HEPH_CACHE_CORP_SECRET_ACCESS_KEY=... ``` The most specific namespace that sets anything wins, and it wins outright — heph never merges credentials from two sources for one cache. Setting `HEPH_S3_ACCESS_KEY_ID` is enough to stop heph from reading `AWS_SECRET_ACCESS_KEY` or `AWS_SESSION_TOKEN` from the ambient environment for that cache, so set the matching secret in the same namespace too. For GCS workload identity federation specifically, the scoped equivalent of `GOOGLE_APPLICATION_CREDENTIALS` is `HEPH_GCS_APPLICATION_CREDENTIALS` (or `HEPH_CACHE__APPLICATION_CREDENTIALS`). :::note An unrecognized variable name inside a `HEPH_*` namespace fails at startup, naming the variable — heph assumes it's a typo rather than an unrelated setting. Two caches whose names flatten to the same `HEPH_CACHE__` namespace (`build-cache` and `build.cache`, for example) also fail at startup, naming both. ::: ## Read-only and write-only caches Set `read` or `write` to `false` to restrict what a cache does. A common setup: CI writes, developers only read. ```yaml title=".hephconfig" caches: shared: uri: s3://my-bucket/heph-cache read: true write: false # developers read; CI writes ``` ## Multiple caches Configure more than one cache — for example a fast regional store and a slower central one: ```yaml title=".hephconfig" caches: regional: uri: s3://eu-bucket/heph-cache central: uri: s3://us-bucket/heph-cache ``` heph reads from the fastest cache first and writes to both. Latency is measured once per process and persisted so subsequent runs skip the probe. ## Measuring latency Force a fresh latency measurement and see per-cache round-trip times: ```bash title="terminal" heph tool cache measure-latency ``` Output lists each cache, fastest first: ``` Remote cache latency (fastest first): 1.23ms [rw] regional (s3://eu-bucket/heph-cache) 18.45ms [rw] central (s3://us-bucket/heph-cache) ``` The `[rw]` flags show the configured `read`/`write` permissions. The order is persisted and reused automatically until the cache definitions change. ## Excluding targets from the remote cache Some targets produce outputs that embed host-local paths — a wrapper script that references an absolute path in a local toolchain store, for example. Sharing those artifacts causes another machine to pull a wrapper that points at a path it doesn't have, which fails at run time. Set `cache = {"remote": False}` on those targets. Local caching stays on; heph will never upload the artifact to or download it from a remote cache: ```python title="BUILD" target( name = "local-wrapper", driver = "exec", run = ["./gen-wrapper.sh"], out = "wrapper.sh", cache = {"remote": False}, ) ``` Every machine builds its own copy and caches it locally. Targets that depend on it get a local hit on the same machine; other machines build their own copy on first use. ## In CI A typical CI setup: grant write access to CI runners and read access everywhere. The cache fills on every merged build and keeps subsequent runs fast for the whole team. ```bash title="terminal" heph run //... --no-tui ``` No extra flags needed — heph reads and writes the remote automatically when `caches:` is configured. See [Using heph in CI](/docs/guides/ci) for the broader CI setup, and [`caches` in the configuration reference](/docs/reference/configuration#caches--remote-shared-caches) for the full set of options. --- ## Validating your workspace `heph validate` checks your workspace without running any builds. It resolves every target's inputs, detects overlapping codegen outputs, and verifies that `.gitignore` is current. All three checks run in parallel and every failure is reported at once — not just the first one — so you can fix everything in a single pass. ## What it checks | Check | What it catches | |-------|-----------------| | **Target link** | Every in-scope target is parsed and its runtime inputs are resolved. A missing dependency or an unresolvable target surfaces here. No execution happens. | | **Codegen overlap** | Two `codegen = copy` targets that write to the same output path. | | **`.gitignore` freshness** | The `.gitignore` block maintained by heph is out of date. If stale, the error message tells you to run `heph tool gen-gitignore`. Skipped when a package matcher is provided. | ## Usage ```bash title="terminal" heph validate # whole workspace heph validate //pkg/... # scope to a package matcher ``` When a package matcher is provided, the `.gitignore` freshness check is skipped (it applies to the whole workspace), and a warning is printed to say so. The target link and overlap checks still run. If any check fails, `heph validate` exits non-zero and lists every problem it found. ## In CI `heph validate` is a natural CI gate: it is read-only, exits non-zero on any problem, and surfaces all failures in one run. ```bash title="terminal" heph validate ``` See [Using heph in CI](/docs/guides/ci) for a representative CI job. --- ## Introduction heph is an open-source build system and task orchestrator. You define targets; heph hashes their inputs, runs each action in parallel in a sandbox, and content-addresses the output. Same inputs, byte-identical artifacts — on your laptop and in CI. > **Build once. Trust the cache.** :::warning Documentation for deprecated v0 can be found [here](https://hephbuild.github.io/heph). ::: ## The model in one minute A build is a directed graph of **targets**. Each target declares its inputs and the action that produces its outputs. Before running anything, heph hashes the inputs. A matching digest is a **cache hit** — heph returns the cached output instead of rebuilding. ```python title="BUILD" target( name = "server", driver = "bash", run = "go build -o $OUT .", deps = [glob("**/*.go"), "//gen:assets"], out = "server", ) ``` :::note Every input you reference — sources, deps, the toolchain — is tracked. If it isn't declared, the sandbox won't see it. ::: Build it, and watch heph rebuild only what changed: ```bash title="terminal" $ heph run //app:server 0.565s · 1 / 1 done · 1 cached · 0 failed ``` ## Next steps - [Your first build](./guides/first-build.md) — from empty repo to a cached build in four steps. - [Targets & rules](./concepts/targets.md) — how the graph is defined. - [Reproducibility](./concepts/reproducibility.md) — what "byte-identical" actually guarantees. - [CLI reference](./reference/cli.md) — the commands you'll use daily. --- ## Buildfile The buildfile plugin is a package provider that scans the workspace for BUILD files containing Starlark code and parses target definitions from them. It evaluates each BUILD file as Starlark to extract targets along with their drivers, configuration, labels, and transitive dependencies, then hands those targets to the build engine. BUILD file naming is customizable through glob patterns, and `load()` statements let you share symbols across BUILD files so common definitions can be reused throughout the workspace. ## Provider A provider discovers targets and makes them available to the build engine without running any of the work itself; the actual execution is delegated to whatever driver each target names. ## Enabling it Built-in. Register in `.hephconfig` under `plugins` with `builtin: buildfile`. The plugin scans the workspace for BUILD files (or custom patterns) and automatically makes those targets available to the build engine. ## Configuration Register the provider and, optionally, tune which file names it treats as BUILD files: ```yaml title=".hephconfig" plugins: - builtin: buildfile options: patterns: - BUILD - "*.BUILD" ``` ### Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `patterns` | `string[]` | `["BUILD", "*.BUILD"]` | Glob patterns for BUILD file names to recognize. | | `skip` | `string[]` | `[]` | Workspace-relative glob patterns for directories to exclude from the BUILD file walk. | | `defaultDriver` | `string` | `""` | Driver name to use when a target omits `driver`. An explicit `driver` in `target()` always wins. | | `indent` | number | `4` | Indentation width in spaces per level, used by [`heph tool build-fmt`](/docs/guides/formatting-build-files). | ### Skipping directories Use `skip` to prevent the provider from descending into directories you don't want scanned — vendored code, generated output trees, large third-party subtrees, etc. Each pattern is matched against the workspace-relative path of the directory. ```yaml title=".hephconfig" plugins: - builtin: buildfile options: skip: - vendor - "third_party/**" - "generated/*" ``` `skip` is a hard boundary: it applies to discovery *and* to direct target resolution. Addressing a target inside a skipped subtree — for example `heph run //vendor/pkg:lib` — returns not found, the same as if the BUILD file were never there. ### Default driver If most targets in your workspace use the same driver, set `defaultDriver` so `target()` calls can omit the `driver` field: ```yaml title=".hephconfig" plugins: - builtin: buildfile options: defaultDriver: bash ``` ```python title="BUILD" # driver is omitted — falls back to "bash" target( name = "hello", run = ["echo hello"], out = "hello.txt", ) # explicit driver always wins target( name = "gen", driver = "exec", run = ["./gen.sh"], out = "gen.txt", ) ``` A target that omits `driver` when no `defaultDriver` is configured is an error. ## Usage A matching BUILD file defines targets by calling `target()` in Starlark: ```python title="BUILD" target( name = "hello", driver = "bash", run = ["echo hello"], ) ``` ## Authoring BUILD files BUILD files are written in Starlark — a small, deterministic dialect of Python. The plugin exposes a fixed set of builtins: | Builtin | Returns | Purpose | |---------|---------|----------| | `target(name, driver, **kwargs)` | the target's address | Declare a target. | | `file(path, abs=False)` | a file address | Reference one workspace file as an input. | | `glob(pattern, exclude=None, abs=False)` | a glob address | Reference many files by pattern. | | `query(expr)` | a query address | Reference all targets matching a [query expression](/docs/plugins/query). | | `struct(**kwargs)` | a struct | Bundle named values to pass into a field. | | `get_pkg()` | the current package path | Compute addresses relative to where the BUILD file lives. Prefer `heph.core.pkg()`. | | `provider_state(provider, **kwargs)` | — | Hand package-level state to a provider. | | `heph.core` | namespace | Host platform info and current package. See [below](#hephcore--host-platform). | `file()` and `glob()` resolve to [filesystem](./fs.md) addresses; `query()` resolves to a [query](./query.md) address. All three drop straight into a dependency field: ```python title="BUILD" target( name = "lint", driver = "group", deps = [query("//... && label(lint)")], ) target( name = "lib", driver = "bash", deps = [glob("src/**/*.go", exclude = "src/**/*_test.go")], run = "go build -o $OUT ./src", out = "lib", ) ``` By default paths are resolved relative to the BUILD file's package; pass `abs = True` to resolve from the workspace root instead. Paths are lexically normalized — `./` prefixes and empty segments are collapsed, `..` components resolve against the package path, and a path whose `..` segments escape the workspace root is an error. For `glob()`, `exclude` resolves exactly like `pattern` — package-relative by default, workspace-root-relative under `abs = True`. A package-relative `exclude = ["vendor/**"]` in `//pkg` excludes `pkg/vendor/**`, not a root-level `vendor/**`; write `exclude = ["vendor/**"], abs = True` to exclude from the workspace root instead. ### `target()` — what buildfile reads, and what it forwards `target()` interprets only the fields that describe the target to the engine — everything else is handed verbatim to the **driver**: | Field | Required | Meaning | |-------|----------|---------| | `name` | yes | The target's name within its package. | | `driver` | no* | The driver that executes it (`bash`, `go.golist`, `group`, …). Required when `defaultDriver` is not set. | | `labels` | no | A label or list of labels, used by [query](./query.md) and matchers. Each must match `[A-Za-z0-9_-]+` — a `target()` with a label outside that grammar fails to evaluate. | | `transitive` | no | Sandbox settings propagated to targets that depend on this one. | | `approval` | no | `True`, or `{"required": True, "notice": [...]}` to gate execution on explicit user confirmation. See [Approval gates](/docs/concepts/approval). | :::note Any other keyword (`run`, `deps`, `out`, `env`, `cache`, `codegen`, …) is **driver-defined**. buildfile does not interpret it — it forwards the value to the named driver, which decides what it means. For the fields a given driver accepts, see that driver's page, e.g. [Exec](./exec.md) for `bash`/`exec`. ::: `target()` returns the new target's address, so you can bind it to a variable and reference it from another target instead of retyping the address: ```python title="BUILD" lib = target(name = "lib", driver = "bash", run = "go build -o $OUT .", out = "lib") target( name = "image", driver = "bash", deps = {"bin": lib}, run = "cp $SRC_BIN $OUT", out = "image", ) ``` ### Relative addresses Inside a BUILD file, `deps` (and any other address field) accept relative address forms resolved against the BUILD file's own package. This avoids repeating the package path for targets that live nearby. ```python title="app/BUILD" util = target(name = "util", driver = "exec", run = "...", out = "util") target( name = "server", driver = "exec", deps = [ ":util", # same as //app:util "./proto:api", # same as //app/proto:api "../shared:lib", # same as //shared:lib ], run = "...", out = "server", ) ``` See [Addresses → Relative forms](/docs/reference/addresses#relative-forms) for the full reference table. ### `heph.core` — host platform `heph.core` is a namespace available in every BUILD file. Use it to read the host platform so targets can vary by operating system or CPU architecture. | Function | Returns | Example values | |----------|---------|----------------| | `heph.core.os()` | normalized OS name | `"darwin"`, `"linux"`, `"windows"` | | `heph.core.arch()` | normalized architecture | `"amd64"`, `"arm64"` | | `heph.core.os_raw()` | host OS identifier | `"macos"`, `"linux"` | | `heph.core.arch_raw()` | host architecture identifier | `"x86_64"`, `"aarch64"` | | `heph.core.pkg()` | current package path | `"tools/build"` | | `heph.core.num_cpu()` | CPUs available to the process | `8` | | `heph.core.packages(matcher)` | matching workspace package paths | `["tools", "tools/build"]` | `heph.core.os()` and `heph.core.arch()` return normalized names that match the conventions used by container registries and most package distribution tools. `heph.core.os_raw()` and `heph.core.arch_raw()` return the exact identifiers the host reports — use those when a tool or URL scheme expects non-normalized names. `heph.core.pkg()` returns the current package path — the same value as the top-level `get_pkg()`, which still works but `heph.core.pkg()` is preferred. ```python title="BUILD" # Download a platform-specific binary target( name = "tool", driver = "exec", run = [ "curl -fsSLo $OUT https://releases.example.com/tool/{}/{}/tool".format( heph.core.os(), heph.core.arch(), ), ], out = "tool", ) ``` ```python title="BUILD" # Build different targets per platform if heph.core.os() == "linux": target(name = "bundle", driver = "exec", run = ["./package-linux.sh"], out = "bundle") else: target(name = "bundle", driver = "exec", run = ["./package-mac.sh"], out = "bundle") ``` `heph.core.num_cpu()` returns the number of CPUs available to the process, falling back to `1` if the host count can't be determined. Use it to size a tool's own parallelism flag instead of hardcoding a number: ```python title="BUILD" target( name = "test", driver = "exec", run = ["go test -p {} ./...".format(heph.core.num_cpu())], ) ``` `heph.core.packages(matcher)` returns the sorted list of workspace package paths matching a [query](./query.md) matcher string — `//foo`, `//foo/...`, or a combination joined with `&&`, `||`, `!`. Relative forms (`./`, `..`, `.`) resolve against the current package. The matcher is evaluated per package, so only package-level matchers are supported; one that needs target-level information (`label(...)`, `//pkg:name`) errors instead of silently matching nothing. ```python title="BUILD" # One check target per package under services/. for pkg in heph.core.packages("//services/..."): target( name = "check-" + heph.fs.base(pkg), driver = "exec", run = ["./check.sh " + pkg], ) ``` ### Sharing symbols with `load()` `load()` imports symbols from another Starlark file so common definitions live in one place. A loaded helper registers its targets against the package that *calls* it, not the file it is defined in: ```python title="BUILD" load("//build/defs:go.star", "go_service") go_service(name = "api") ``` The path argument follows the same rules as `file()`/`glob()` above: `//pkg/...` is workspace-root-relative, `./`/`../` is resolved against the current package, and a path whose `..` segments would resolve outside the workspace root is an error rather than being followed. --- ## Devenv The Devenv plugin registers one driver, `devenv.runner`, that captures a [devenv](https://devenv.sh) shell environment into a **runner** — a target other targets point at with `runner = "//pkg:name"` to run inside that environment instead of on the bare host. See [Runners](/docs/concepts/runners) for what a runner is and how a target uses one. ## Driver A **driver** is the component that knows how to execute a target's action. This plugin registers a single driver, `devenv.runner`. ## Enabling it The Devenv plugin is an **external plugin** — it is not compiled into the heph binary. It ships as a shared library (cdylib) with a manifest file (`heph-devenv-plugin.json`). Requires `devenv` on the host `PATH` (or pinned via the `bin` option below). ```yaml title=".hephconfig" plugins: - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-devenv-plugin.json checksum: sha256: # optional; pin from heph-devenv-plugin.json.sha256 ``` The `checksum` field is optional but recommended — it pins the manifest to a known digest so a tampered or misdelivered manifest is rejected before loading. See [Pinning manifests with checksums](/docs/reference/configuration#pinning-manifests-with-checksums) for details. ### Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `bin` | `string` | `devenv` | Path (or name on `PATH`) of the `devenv` binary to resolve the environment with. | ## Usage ```python title="BUILD" target( name = "runner", driver = "devenv.runner", deps = glob("devenv.*"), ) target( name = "build", driver = "bash", run = "make", out = "out/", runner = ":runner", ) ``` `//pkg:build` now runs `make` inside the environment this package's `devenv.nix` describes, instead of on the host. ### Fields | Field | Type | Default | Description | |-------|------|---------|-------------| | `mode` | `string` | `"wrap"` | `"wrap"` captures the environment once, at build time, and runs targets locally with it. `"session"` holds one `devenv shell` open for the whole build and runs targets inside it. See [Choosing a mode](#choosing-a-mode). | | `root` | `string` | the target's own package | Directory containing `devenv.nix`, relative to the target's package. Must stay inside the package — an absolute path or a `..` component is rejected. | | `profile` | `string` | unset | devenv profile to enter (`devenv --profile shell`), if the environment declares one. | | `deps` | `string[]` | `[]` | The environment's own files (`devenv.nix`, `devenv.lock`, `devenv.yaml`, anything they import) as target addresses — typically a `glob`. Rebuilds the runner when the environment's definition changes; **not** what the fingerprint is derived from — see [Fingerprint](#fingerprint). | | `pass_env` | `string[]` | a fixed set `devenv` needs to reach the nix store and the network (`HOME`, `PATH`, `NIX_PATH`, TLS/proxy variables, …) | Host environment variables `devenv` may see while resolving the environment. Hashed at parse. | ## Choosing a mode `wrap` is what most workspaces want: it is faster (no `devenv` process per target, no shell evaluation on the build's hot path) and its fingerprint is the strongest available, because it *is* the resolved environment. `session` costs more — one held `devenv shell` for the whole build — and earns that cost only when the environment is not just a set of variables: shell activation with side effects, services devenv starts, state under `.devenv/`. Reach for it when what matters is process ancestry, not just the environment's variables. ## Fingerprint Every runner's cache key rests on the bytes it writes, so this plugin resolves the environment (`devenv shell -- env`) and folds a digest of the result into the runner, rather than hashing `devenv.nix` — a `devenv.nix` can `import` files nobody declared in `deps`, which a source-file hash would miss. It prefers `DEVENV_PROFILE`, the nix store path devenv resolves the environment to: identical across machines and directories for the same environment, and the strongest signal available. See [Runners → The fingerprint](/docs/concepts/runners#the-fingerprint) for why this matters. Cached locally, never remotely — the captured environment names this machine's own nix store paths, and publishing it would let one host's resolution key another's builds. ## Guarding against a self-referential default A runner target must not run under the workspace-wide default it configures. The natural way to write one is a `bash` target, which would otherwise become its own runner and cycle on the first build — the `exec`/`bash` driver excludes a target from a default it is itself. If the runner's own `deps` are themselves `exec`/`bash` targets, give them `runner = "local"` explicitly, or a workspace-wide default turns them into a cycle too. --- ## Exec The `pluginexec` plugin provides two execution drivers — `exec` and `bash` — for running shell commands inside sandboxed target builds. It wires up dependency injection, sets up environment variables, resolves the tools a target needs on its `PATH`, and supports interactive shell debugging with PTY allocation. Whenever a target runs a command to produce its outputs, one of these drivers is doing the work. ## Driver A **driver** is the component that knows how to execute a target's action and turn its inputs into outputs. This plugin registers the drivers named `exec` and `bash`. ## Enabling it Built-in. Register in `.hephconfig` under `plugins` with `builtin: exec` or `builtin: bash`. Options: | Option | Meaning | |-------------|---------| | `path` | Host directories to put on a target's `PATH`. Empty by default — see [The `PATH` a target sees](#the-path-a-target-sees). | | `coreutils` | [Builtin tools](/docs/concepts/builtin-tools) on every target's `PATH`. `true` by default; `false` opts out. | | `runner` | Workspace-wide default [runner](/docs/concepts/runners) for every target using this driver. | ## Configuration ```yaml title=".hephconfig" plugins: - builtin: exec options: path: # optional; host directories, none by default - /usr/local/bin - /usr/bin - /bin coreutils: true # optional; the default runner: "//tools/devenv:runner" # optional; see Runners - builtin: bash options: path: - /usr/local/bin - /usr/bin - /bin runner: "//tools/devenv:runner" # optional; see Runners ``` ## Usage ```python title="BUILD" target( name = "hello", driver = "bash", run = "echo hello world > $OUT", out = "greeting.txt", env = {"MY_VAR": "value"}, pass_env = ["HOME", "USER"], deps = {"src": "//src:files"}, tools = "//toolchain:gcc", cache = {"enabled": True, "remote": True, "history": 5}, ) ``` ## Notes There are two driver variants: | Driver | Behavior | |--------|---------------------------------------------------| | `exec` | Direct command execution. | | `bash` | Bash shell with job control, run with `-o pipefail` so a failing stage in a pipeline fails the target. | The `bash` driver supports an interactive `--shell` mode with PTY allocation, which is useful for debugging a target's sandbox. The following target config keys are available: | Key | Meaning | |--------------------|------------------------------------------------------| | `run` | List of commands to execute. Accepts [deferred values](/docs/concepts/deferred-values). | | `deps` | Hashed build-time dependencies. | | `hash_deps` | Hash-only dependencies. | | `runtime_deps` | Runtime-only dependencies. | | `tools` | Executables placed on `PATH`. | | `out` | Output groups and paths. | | `support_files` | Non-output artifacts. | | `env` | Literal environment variables. | | `pass_env` | Host vars passed at parse time and hashed into the cache key. Accepts `["*"]` to pass all. | | `runtime_pass_env` | Host vars passed at run time only, not hashed. Accepts `["*"]` to pass all. | | `runtime_env` | Runtime literal environment variables. | | `cache` | `True`/`False` toggles local + remote together; dict: `{enabled, remote, history}`. | | `codegen` | Write generated outputs into the source tree (`copy` or `in_place`). | | `runner` | Exec runner this command runs under — a target address producing a `runner.json`, or `"local"` to opt out of a workspace-wide default. See [Runners](/docs/concepts/runners). | These environment variables are available inside the sandbox: | Variable | Meaning | |-------------------------------|-------------------------| | `SRC` / `SRC_` | Dependency paths. | | `LIST_SRC` / `LIST_SRC_` | Dependency list files. | | `TOOL` / `TOOL_` | Tool paths. | | `OUT` / `OUT_` | Output paths. | | `WORKSPACE_ROOT` | Root of the workspace. | | `PATH` | Executables search path. | ## The `PATH` a target sees A target's `PATH` holds its [`tools`](#notes), any `PATH` it declares through `env`, and heph's builtin utilities. **The host's directories are not on it.** A command that relies on an undeclared host binary fails with `command not found` instead of passing on whichever machine happens to have it. ```python title="BUILD" target( name = "pack", driver = "bash", tools = "//toolchain:tar", # declared: on PATH run = "tar -cf $OUT $SRC", # declared tool, or a builtin deps = "//src:files", out = "pack.tar", ) ``` If a command needs something else, declare it as a `tools` entry, call it by absolute path, or list host directories in the driver's `path` option: ```yaml title=".hephconfig" plugins: - builtin: bash options: path: [/usr/local/bin, /usr/bin, /bin] ``` The `bash` driver itself finds `bash` on the `PATH` heph was launched with, so you don't need `path` for the shell. Under a [runner](/docs/concepts/runners), the runner's environment supplies the `PATH` and `path` is not applied. The interactive `--shell` session keeps the host's directories. ### Builtin utilities heph appends its [builtin tools](/docs/concepts/builtin-tools) (`cp`, `sed`, `install`, …) to every target's `PATH`. They fill gaps only: a tool the target declares, or a binary the runner's environment ships, wins over the builtin of the same name. Inside an [OCI](/docs/plugins/oci) container they are not available; the image's own utilities apply. Set `coreutils: false` on the driver to use the host's utilities instead. ## Dependencies `deps` accepts either a **list** or a **dict**. The dict form names each group, and that name becomes the suffix on the sandbox variables — `deps = {"src": …}` surfaces as `$SRC_SRC` (and `$LIST_SRC_SRC`, a file listing every path in the group, handy when a group expands to many files): ```python title="BUILD" target( name = "bundle", driver = "bash", deps = { "code": glob("*.go"), "assets": "//web:assets", }, run = "cat $LIST_SRC_CODE; cp -r $SRC_ASSETS $OUT", out = "bundle", ) ``` Group names are uppercased, and any character that isn't a letter, digit, or underscore becomes `_` — a group named `my-group` surfaces as `$SRC_MY_GROUP`, not `$SRC_MY-GROUP` (which isn't a valid environment variable name). The same rule applies to `out` and `tools` group names. A plain list lands in the default group, available as `$SRC` and `$LIST_SRC`. heph distinguishes three dependency kinds by how they affect the cache: | Field | Hashed into the key? | Present in the sandbox? | Use for | |----------------|----------------------|--------------------------|--------| | `deps` | yes | yes | Normal build inputs. | | `hash_deps` | yes | no | Inputs that must invalidate the cache but the command never reads. | | `runtime_deps` | no | yes (at run only) | Things needed to *run* an output, not to build it — changing them is a cache hit. | ## Output groups `out` mirrors `deps`: a string or list is the default group (`$OUT`), a dict names groups (`$OUT_`). Other targets select a specific group with the `|group` suffix on the address: ```python title="BUILD" target( name = "compile", driver = "bash", run = "go build -o $OUT_BIN .; go doc . > $OUT_DOC", out = {"bin": "app", "doc": "app.txt"}, ) target( name = "package", driver = "bash", deps = ["//app:compile|bin"], # just the bin group run = "cp $SRC $OUT", out = "app.tar", ) ``` `support_files` declares files a target produces that are **not** outputs — kept in the sandbox for the command's own use but not published to dependents. Output and support-file paths are lexically normalized: `./` prefixes and empty segments are stripped, `..` resolves against the package path, and a path that escapes the workspace root is an error. ## Environment passthrough `pass_env` and `runtime_pass_env` each accept a list of variable names to inherit from the host environment. Both also accept the special value `"*"` to pass through every host environment variable: | Field | Hashed into the cache key? | When applied | |-------|----------------------------|--------------| | `pass_env` | yes | At parse time. The full environment snapshot is folded into the input hash. | | `runtime_pass_env` | no | At run time only. Variables are not hashed and do not affect the cache. | Because `pass_env = ["*"]` hashes the entire host environment, any change to any env var forces a rebuild. Only use it on targets where `cache` is disabled: ```python title="BUILD" target( name = "local-tool", driver = "exec", run = ["./build.sh"], out = "tool", pass_env = ["*"], # full env snapshot — only for uncached targets cache = False, ) ``` Use `runtime_pass_env = ["*"]` when the command needs the full environment at run time but you still want caching — the environment will not affect the cache key: ```python title="BUILD" target( name = "run-tool", driver = "exec", run = ["./tool"], out = "result", runtime_pass_env = ["*"], # full env at run time, not hashed ) ``` ## Cache control `cache` accepts a bare bool or a dict with up to three keys: | Key | Type | Default | Meaning | |-----|------|---------|--------| | `enabled` | bool | `true` | Enable local caching for this target. | | `remote` | bool | `true` | Enable remote caching for this target. Set `false` when outputs embed host-local paths that cannot be safely shared across machines. | | `history` | int | `1` | Number of past revisions to retain in the local cache (minimum `1`). | A bare `True` sets both `enabled` and `remote` to `true`. A bare `False` disables both. Use `cache = {"remote": False}` to keep local caching on but opt a specific target out of remote push and pull — useful for targets whose outputs reference paths that only exist on the machine that built them: ```python title="BUILD" target( name = "wrapper", driver = "exec", run = ["./gen-wrapper.sh"], out = "wrapper.sh", cache = {"remote": False}, ) ``` The target still builds and caches locally; it is never uploaded to or pulled from a remote cache. ## Runners By default a command runs on this host. Point it at a [runner](/docs/concepts/runners) target instead to run inside a described environment — a devenv shell, a container: ```python title="BUILD" target( name = "build", driver = "bash", run = "make", out = "out/", runner = "//tools/devenv:runner", ) ``` Set `runner` on the driver's own options (see [Configuration](#configuration)) to apply it to every `exec`/`bash` target in the workspace by default; a target's own `runner` field overrides that default, and `runner = "local"` opts a single target back onto the host. See [Runners](/docs/concepts/runners) for the full picture, including what environment a target actually sees. ## Deferred values `run`, and the values of `env` and `runtime_env`, can reference another target's output instead of a literal — `${read://pkg:name}` for its contents, `${src://pkg:name}` for its sandbox path — and heph resolves the reference once that target has run. See [Deferred values](/docs/concepts/deferred-values). ```python title="BUILD" target( name = "deploy", driver = "bash", run = 'echo "deploying $VERSION"', env = {"VERSION": "${read://:version}"}, ) ``` ## Builtin coreutils heph ships 40 common POSIX utilities in its own binary. With the toolbox on, they are available on every target's `PATH`, so `cp`, `install` or `sha256sum` behave the same on Linux and macOS instead of resolving to GNU or BSD variants. It is off by default. Enable it per driver: ```yaml title=".hephconfig" plugins: - builtin: exec options: coreutils: true - builtin: bash options: coreutils: true ``` Included: `base64`, `basename`, `cat`, `chmod`, `comm`, `cp`, `cut`, `date`, `dirname`, `echo`, `env`, `false`, `head`, `install`, `ln`, `md5sum`, `mkdir`, `mktemp`, `mv`, `nproc`, `printf`, `readlink`, `realpath`, `rm`, `rmdir`, `seq`, `sha1sum`, `sha256sum`, `sha512sum`, `sleep`, `sort`, `stat`, `tail`, `tee`, `timeout`, `touch`, `tr`, `true`, `uniq`, `wc`. The builtins fill gaps, they don't override anything: they come last on `PATH`, after the target's `tools`, its declared dependencies and the [runner](/docs/concepts/runners)'s environment. A tool you provide yourself always wins. Targets under a container runner (such as `oci`) use the image's own utilities. :::warning Turning the toolbox on changes the cache key of every `exec` and `bash` target. A new heph release that changes the toolbox does so again. ::: Inspect the set from the CLI: ```bash heph tool coreutils list # the bundled utilities and the toolbox version heph tool coreutils which cp # is `cp` builtin, and what the host provides instead heph tool coreutils run cp -r a b # run one directly, outside a sandbox ``` ## Interactive debugging with `--shell` When a target fails, drop into its sandbox with the exact inputs, tools, and environment it would run with: ```bash title="terminal" heph run //app:server --shell ``` The `bash` driver allocates a PTY and starts an interactive shell inside the prepared sandbox, so you can inspect `$SRC_*`, re-run the command by hand, and see why it broke. --- ## Filesystem The fs plugin reads files and directories from the workspace filesystem. It provides two target types: `file` for referencing a single file, and `glob` for matching multiple files with patterns. Both types emit output artifacts that can be consumed by other build targets as inputs, which lets the rest of the graph depend on raw workspace content without copying or restating paths. ## Driver A **driver** is the component that knows how to execute a particular kind of target. The fs plugin registers a single driver named `fs`. ## Enabling it Built-in and auto-registered. The `fs` driver is always available in heph and does not require configuration in `.hephconfig`. ## Usage ```text //@heph/fs:file@f=src/main.rs //@heph/fs:glob@p=src/**/*.rs@e=vendor/**,generated/** ``` ## Provider functions The fs plugin exposes helper functions in every BUILD file under the `heph.fs` namespace. These compute path values at BUILD-eval time; they return strings (or lists of strings), not target addresses. | Function | Signature | Returns | |----------|-----------|---------| | `heph.fs.glob` | `glob(pattern: string) -> list[string]` | Paths of files matching `pattern`, relative to the current package. | | `heph.fs.join` | `join(*elems: string) -> string` | Join path elements with `/` and clean the result (Go `path.Join` semantics). | | `heph.fs.dir` | `dir(path: string) -> string` | Directory component of `path`. | | `heph.fs.base` | `base(path: string) -> string` | Base name of `path`. | | `heph.fs.parent` | `parent(filename: string) -> string \| None` | Workspace-root-relative path of the closest `filename`, searching the current package's directory and each ancestor up to the workspace root. `None` if no ancestor has it. | ```python title="BUILD" target( name = "release", driver = "bash", run = ["cp $SRC $OUT"], out = heph.fs.join("dist", heph.core.os(), heph.core.arch(), "server"), ) ``` :::note `heph.fs.glob(pattern)` returns a list of file path strings evaluated at BUILD-eval time. The top-level `glob(pattern)` builtin returns a filesystem _target address_ for use as a build input. Use `heph.fs.glob` when you need path strings, and `glob()` when you need a build dependency. ::: `heph.fs.parent` takes a bare file name only — a name containing `/` is an error, since a nested path would make "closest parent" ambiguous. It's useful for finding a module or config root from a nested package: ```python title="BUILD" # Locate the nearest go.mod, e.g. to compute a module-relative path. mod = heph.fs.parent("go.mod") # None if no ancestor package has one ``` All five functions enforce their argument types: wrong type, missing required argument, or unknown keyword produce a clear error naming the function and the offending argument. Run `heph inspect functions` to list every provider function available in the current workspace with its full signature. ## Notes Path normalization: all paths are normalized (`.` and `..` segments are collapsed), and leading/trailing `../` escapes are rejected to prevent escaping the workspace root. Glob patterns support the `*`, `?`, `[..]`, `{..}`, and `**` operators. Within a single build request, glob results are reused to avoid walking the tree twice. :::tip In a BUILD file you rarely write these addresses by hand — the `file()` and `glob()` [builtins](./buildfile.md#authoring-build-files) produce them for you. ::: --- ## GitHub Actions The `gha` hook observes the engine's build-event stream and surfaces build progress in GitHub Actions: - **Live PR comment** — a sticky comment on the current pull request is created at job start and patched on a timer. It shows targets matched, built, cached, and failed, plus any targets running longer than 10 seconds. One comment per CI job; a job's multiple heph steps each get their own collapsible section, so earlier steps' results are preserved as later steps update. A new failing target patches the comment immediately instead of waiting for the next timer tick, then holds off for 10 seconds before the next early update — so several targets failing together produce one refresh, not a burst of them. - **Step summary** — the full markdown is written once to `$GITHUB_STEP_SUMMARY` when the command finishes. - **Failure annotations** — a `::error::` workflow command for each failing target, emitted the moment the failure is seen. See [Failure annotations](#failure-annotations). - **Machine-readable output** — `$GITHUB_OUTPUT` keys, and optionally a JSON report file, for a later step or an agent to consume. See [Machine-readable output](#machine-readable-output). The live comment is only created when running inside a pull request (GitHub populates `GITHUB_EVENT_PATH` or `GITHUB_REF` with the PR number). On a push, schedule, or manual dispatch there is no PR to comment on — only the step summary is written. ## What it is `gha` is a **hook** — a third plugin kind alongside providers and drivers. A hook receives every `BuildEvent` the engine emits but never produces or runs targets. It runs in the same process as heph on a background thread. ## Enabling it The GHA plugin is an **external plugin** — it ships as a shared library (cdylib) with a manifest file (`heph-gha-plugin.json`). ### Loading from a URL ```yaml title=".hephconfig" plugins: - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-gha-plugin.json checksum: sha256: # optional; pin from heph-gha-plugin.json.sha256 ``` See [Pinning manifests with checksums](/docs/reference/configuration#pinning-manifests-with-checksums) for details. ### CI-only via a profile overlay The hook only makes sense in CI, so the recommended pattern is to load it from a profile overlay so local runs are unaffected: ```yaml title="ci.hephconfig" plugins: - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-gha-plugin.json checksum: sha256: ``` ```yaml title=".github/workflows/build.yml" jobs: build: permissions: pull-requests: write # required for the live PR comment steps: - uses: actions/checkout@v4 - name: Build env: HEPH_PROFILES: ci run: heph run //... --no-tui ``` See [Profiles](/docs/reference/configuration#profiles--layered-config-overlays) for how profile overlays work. ## GitHub token permissions The live PR comment is written via the GitHub REST API using `GITHUB_TOKEN` by default. The token must have `pull-requests: write` permission: ```yaml title=".github/workflows/build.yml" permissions: pull-requests: write ``` The hook needs three things to create or update the comment: 1. A non-empty `GITHUB_TOKEN` (or the env var named by `tokenEnv`) 2. `GITHUB_REPOSITORY` set by Actions 3. A PR number — read from the event payload (`GITHUB_EVENT_PATH`) or inferred from the ref (`refs/pull//merge` or `/head`) When any of the three is absent the comment is silently skipped and a log message is emitted. The step summary is always written regardless. ## Options ```yaml title="ci.hephconfig" plugins: - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-gha-plugin.json options: refreshSecs: 30 # optional summaryPath: "" # optional tokenEnv: GITHUB_TOKEN # optional jsonPath: "" # optional annotations: true # optional ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `refreshSecs` | `number` | `30` | How often (in seconds) the live PR comment is patched while the build runs. Minimum 1. | | `summaryPath` | `string` | `$GITHUB_STEP_SUMMARY` | Path where the final markdown is written at the end of the run. When neither the option nor the env var is set, the step summary is skipped. | | `tokenEnv` | `string` | `GITHUB_TOKEN` | Name of the environment variable holding the GitHub API token used for PR comment operations. | | `jsonPath` | `string` | unset | Path to write the full run report as JSON at the end of the run. Unset skips the file. See [Machine-readable output](#machine-readable-output). | | `annotations` | `boolean` | `true` | Emit a `::error::` workflow command for each failing target. See [Failure annotations](#failure-annotations). | ## What the comment looks like While the build runs the comment shows live progress: ```markdown ## ⏳ heph: run //... **Targets:** 12 / ~40  •  **built:** 3  •  **cached:** 9  •  **failed:** 0
🐢 Slow targets (1) | target | phase | running for | | --- | --- | --- | | `//heavy:compile` | execute | 23s |
``` The heading emoji reflects the current state: ⏳ while the command is running, ✅ once the command finishes without error, ❌ if any target failed. A total shown as `~40` means the matcher hasn't resolved all targets yet; the tilde drops once resolution is complete. When failures occur a **Failed** section is appended, listing each failed target address and the first line of its error message. ## One comment per job, one section per step The comment is scoped by `GITHUB_JOB` — one comment per CI job, never duplicated (found by a hidden HTML marker). Within the comment, each heph invocation owns a section keyed by its command line (the arguments passed to `heph`). A job with three separate `heph run` steps produces one comment with three independently-updated sections, so earlier steps' results are preserved as later steps write theirs. Each new workflow run (identified by `GITHUB_RUN_ID` and `GITHUB_RUN_ATTEMPT`) starts the comment fresh — the previous build's sections are cleared when the first step of a new run writes to the comment. Steps within the same run continue to preserve each other's sections as described above. ## Failure annotations A failing target gets a GitHub Actions `::error::` annotation the moment its failure is seen — not after the whole build finishes. Annotations appear inline in the job log at the point of failure and on the run's summary page. They cost no API call and are unaffected by GitHub's API rate limits, which is why they're the fastest way a failure reaches a human. ``` ::error title=heph //base:proto::exit status: 1: proto/api.proto:41:3: "UserId" is not defined. ``` Only a target that fails on its own gets an annotation. A target that fails only because one of its dependencies failed does not — one broken dependency blocking thousands of downstream targets still produces a single annotation, for the target that actually broke. Annotations are target-level: there's no `file=`/`line=` pointing at a specific source line, since heph has no way to know which line a target's failure came from. Set `annotations: false` to turn this off. ## Machine-readable output Two surfaces are meant for a later workflow step or an agent to read, not for a human to look at. ### `$GITHUB_OUTPUT` Written once, when the run finishes, so a later step can read it as `${{ steps..outputs.heph_failed }}`: | Key | Example | Notes | |-----|---------|-------| | `heph_status` | `failed` | `ok`, `failed`, or `running` | | `heph_failed` | `2` | number of failing targets | | `heph_blocked` | `4117` | targets blocked by a failure elsewhere in the graph | | `heph_executed` | `338` | targets actually run | | `heph_cached` | `19802` | targets served from cache | | `heph_elapsed_ms` | `468000` | build duration in milliseconds | | `heph_elapsed` | `7m48s` | build duration, formatted | | `heph_cache_hit_rate` | `0.983` | omitted entirely when the cache was never consulted | | `heph_json_path` | `/tmp/heph.json` | present only when `jsonPath` is set | ### `jsonPath` Set `jsonPath` to write the full run report to a file when the run finishes: ```yaml title="ci.hephconfig" options: jsonPath: ${{ runner.temp }}/heph.json ``` ```json title="heph.json" { "schema": "heph.gha/1", "status": "failed", "command": "run //...", "elapsed_ms": 468000, "fail_fast": false, "targets": { "matched": 20140, "done": 15923, "failed": 2, "blocked": 4117, "cached": 15802, "executed": 338 }, "cache": { "local_hits": 15650, "remote_hits": 152, "misses": 338, "hit_rate": 0.983 }, "failures": [ { "addr": "//base:proto", "driver": "exec", "duration_ms": 3000, "exit_status": "exit status: 1", "blocked_count": 4109, "message": "process exited with status 1", "log_tail": "proto/api.proto:41:3: \"UserId\" is not defined." } ], "slowest": [ { "addr": "//services/api:image", "driver": "exec", "duration_ms": 252000 } ] } ``` `failures` lists only targets that failed on their own; a target blocked by one of those failures is counted in that entry's `blocked_count` instead of getting its own entry. Unset by default — no file is written. ### The embedded copy in the step summary The step summary also carries a compact copy of the same report, wrapped in an HTML comment: ``` ``` This is for a reader that can only fetch the step summary and has no filesystem access. It's capped at 2 KiB, so it carries only counters, status, and the addresses of the targets that failed — no log tails, no slowest list. If even the addresses don't fit the cap, the oldest ones are dropped and `truncated: true` plus `failures_omitted` are set, so a reader knows to fall back to `jsonPath` (when set) for the complete report. --- ## Go Provides Go language support for heph. The `go` provider analyzes Go packages and generates targets for building libraries, binaries, and tests. It reads package metadata, resolves source files and module dependencies, and wires up the targets needed to compile and run Go code. Managed drivers do the underlying work: toolchain provisioning (`go.toolchain`), package metadata analysis (`go.golist`), Go package compilation (`go.compile`), test main generation (`go.testmain`), and per-package lint/format analysis (`go.lint`, `go.lint_gate`, `go.lint_fix`, `go.format`, `go.format_check`). ## Driver A driver is the execution backend that knows how to run a particular kind of target. This plugin registers nine drivers: `go.toolchain`, `go.golist`, `go.compile`, `go.testmain`, `go.lint`, `go.lint_gate`, `go.lint_fix`, `go.format`, and `go.format_check`. These are internal — you should not interact with them directly. ## Enabling it The Go plugin is an **external plugin** — it is not compiled into the heph binary. It ships as a shared library (cdylib) with a manifest file (`heph-go-plugin.json`). A single `plugins:` entry loads the provider and all four drivers at once. Use `url:` to have heph fetch and cache the plugin automatically: ```yaml title=".hephconfig" plugins: - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-go-plugin.json checksum: sha256: # optional; pin from heph-go-plugin.json.sha256 ``` The `checksum` field is optional but recommended — it pins the manifest to a known digest so a tampered or misdelivered manifest is rejected before loading. See [Pinning manifests with checksums](/docs/reference/configuration#pinning-manifests-with-checksums) for details. ## Configuration ```yaml title=".hephconfig" plugins: - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-go-plugin.json checksum: sha256: # optional options: gotool: "1.27.0" # required — pinned version, "host", or a target address skip: [] # optional checksums: # optional; recommended for supply-chain verification "1.27.0/linux/amd64": "" "1.27.0/darwin/arm64": "" ``` ### Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `gotool` | `string` | **required** | Go toolchain to use. Set to a pinned version like `"1.27.0"` to download the SDK hermetically from `go.dev/dl`, `"host"` to use the `go` binary already on the host's `PATH`, or a target address like `"//@heph/bin:go"` to use the toolchain a target produces. | | `govet` | `string` (target address) | the plugin's own published `heph-govet` build | The `heph-govet` binary that [lint and format targets](#linting-and-formatting) run. See [Pinning the analyzer binary](#pinning-the-analyzer-binary). | | `cctool` | `string` (target address) | the host's `cc`, via [hostbin](./hostbin.md) (`//@heph/bin:cc`) | The C compiler a [race-detector](#race-detector) build stages where it needs cgo. Only resolved when such a build actually runs. | | `runner` | `string` (target address) | unset | Exec [runner](/docs/concepts/runners) every Go tool (`go list`, `go tool compile`/`asm`/`pack`, `gofmt`, `heph-govet`) runs under, or `"local"`. Reaches the `go_*` drivers as their default and this provider's own `bash` targets (the standard library install, the third-party module download) as their `runner` field. See [Running Go tools under a runner](#running-go-tools-under-a-runner). | | `checksums` | `map[string, string]` | `{}` | Expected SHA-256 digests for hermetic SDK tarballs, keyed `"//"` (e.g. `"1.27.0/linux/amd64"`), and for `govet` release downloads, keyed `"govet///"`. Look up SDK values at [go.dev/dl/?mode=json](https://go.dev/dl/?mode=json). When a key is missing the download is unverified (a warning is logged). SDK checksums have no effect when `gotool = "host"`. | | `skip` | `string[]` | `[]` | Workspace-relative glob patterns for directories to exclude from Go package discovery. | | `walk_db` | path | `/heph-plugin-go-fswalk.db` | Path to the filesystem walk cache database. | ### Toolchain modes **Hermetic (recommended)** — set `gotool` to a Go version string: ```yaml title=".hephconfig" options: gotool: "1.27.0" checksums: "1.27.0/linux/amd64": "" "1.27.0/darwin/arm64": "" ``` The plugin downloads the Go SDK tarball for the host platform from `go.dev/dl`, verifies its SHA-256 (when a `checksums` entry is present), and caches the extracted SDK. Every build, test, and analysis target depends on that cached SDK and points `GOROOT` at it. No Go installation is required on the host. **Host** — set `gotool` to `"host"`: ```yaml title=".hephconfig" options: gotool: "host" ``` The plugin uses the `go` binary found on the host's `PATH`. Builds are not reproducible across machines with different Go versions. **Target** — set `gotool` to a target address: ```yaml title=".hephconfig" options: gotool: "//@heph/bin:go" ``` The plugin builds the given target and uses its output as the Go toolchain — for example a `go` binary exposed by the [hostbin](./hostbin.md) provider, or a `go` built by a [nix](./nix.md) target. The target must produce a single output: either a `go` binary file, or a directory containing `bin/go`. Reproducibility depends entirely on what the target produces. ### Skipping directories Use `skip` to prevent the provider from scanning directories you don't want included in Go package discovery — for example, directories containing non-module code, generated stubs, or vendored packages managed outside of heph. Each pattern is matched against the workspace-relative path of the directory. ```yaml title=".hephconfig" plugins: - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-go-plugin.json options: gotool: "1.27.0" skip: - vendor - "internal/generated/**" ``` `skip` is a hard boundary for first-party packages: it applies to both discovery and direct target resolution. Addressing a first-party package inside a skipped subtree — for example `heph run //vendor/pkg:build` — returns not found. Standard library (`@heph/go/std/…`) and third-party module (`@heph/go/thirdparty/…`) packages live outside the workspace tree and are never affected by `skip`. ## Running Go tools under a runner Set `runner` to run every Go tool inside a described environment instead of on the host — see [Runners](/docs/concepts/runners) for what a runner is: ```yaml title=".hephconfig" options: gotool: "1.27.0" runner: "//tools/devenv:runner" ``` It reaches every Go target — the `go_*`-driver ones and the plain `bash` targets this provider also generates (the standard library install, the third-party module download, and the binary/test-binary links) alike. A build where only half moved into the environment mixes toolchains and fails to link. `gotool: "host"` composes with it: under a runner, `"host"` means the `go` found on the *runner's* `PATH`, resolving `GOROOT` by running `go env GOROOT` inside that environment. Left unset (no runner), `gotool: "host"` behaves exactly as before — heph's own `PATH`. `gotool: ""` and a target-address `gotool` are unaffected either way — both already resolve to a staged path inside the sandbox and never probe a host. A hand-written target using a `go_*` driver can set its own `runner` field (including `runner = "local"` to opt out); there is no per-package override for *generated* targets — the option is workspace-wide. **Tests are deliberately not covered by it.** A build wants the compiler's environment; a test often wants the runtime's — a separate decision. Point a package's tests at a runner with `provider_state` instead: ```python title="BUILD" provider_state( provider = "go", test = {"runner": "//tools/devenv:runner"}, ) ``` With `test.runner` unset, a test falls back to the plugin's own `runner` option, so a workspace that wants everything in one environment sets it there and needs nothing per package. See [Test environment](#test-environment) for the rest of what `test` accepts. ## Usage The provider analyzes each Go package from its `go.mod` and source files and generates targets for it — you don't write `target()` calls for Go code. For a package it produces: | Target | Builds | Labels | |-----------|--------|---------| | `:build` | The library or, for `package main`, the binary. | | | `:test` | The package's tests. | `test`, `go-test` | Explore what was generated for the package in the current directory: ```bash title="terminal" heph query all . ``` Then build or test by address: ```bash title="terminal" heph run //cmd/server:build # compile the binary heph run //lib/auth:test # run the package's tests ``` ## Race detector Every package with tests also gets `:test_race` and `:xtest_race` — `test` and `xtest`, compiled and linked with Go's race detector. | Target | Builds | Labels | |---------------|----------------------------------------------|------------------------------| | `:test_race` | The package's tests, with `-race`. | `test-race`, `go-test-race` | | `:xtest_race` | The package's external tests, with `-race`. | `test-race`, `go-test-race` | ```bash title="terminal" heph run //lib/auth:test_race # run this package's tests under the race detector heph run 'label(test-race)' # every race-detector test in the workspace ``` Race instrumentation covers the whole program, standard library included, so race targets are several times slower to build and run than `test`/`xtest`. That's why they carry the `test-race`/`go-test-race` labels instead of `test`/`go-test`: `heph run 'label(test)'` keeps meaning the ordinary suite. Run both together with `label(test) || label(test-race)`. `:test_race`/`:xtest_race` accept the same `@v=NAME` variant selection and the same `provider_state(test = {...})` configuration — `env`, `pass_env`, `pre_run`, `runner`, and so on — as `:test`/`:xtest`. See [Test environment](#test-environment). On Linux, a race build always links with `buildmode = "exe"`, even if the selected variant declares `buildmode = "pie"` — Go's race detector doesn't support PIE there. On darwin the variant's declared buildmode is honored as-is. A race build needs a C compiler everywhere except darwin, where Go's race runtime has no cgo dependency. On Linux, the `cctool` option picks which C compiler to stage — it defaults to the host's `cc`, found through the [hostbin](./hostbin.md) provider: ```yaml title=".hephconfig" options: gotool: "1.27.0" cctool: "//@heph/bin:cc" # default; point elsewhere for a hermetic compiler ``` `cctool` is resolved only when a race build that needs cgo actually runs — an ordinary build, and a darwin race build, never touch it. ## Build and module caches Every Go list, compile, and third-party download step shares two caches automatically — nothing to configure: - **`GOCACHE`** — one per Go module (the directory holding `go.mod`) and per [build variant](#build-variants), shared by that module's `go list` and `go tool compile` steps alike. - **`GOMODCACHE`** — one shared, portable module-download cache for the whole workspace. heph owns it outright: a host-set `GOMODCACHE` is not passed through, so the shared cache is what every build sees. Both are [scratch caches](/docs/concepts/scratch) — visible in `heph tool scratch ls`, droppable with `heph tool scratch rm` if one goes bad, and auditable with `heph run --no-scratch`. Point a hand-written target at the same `GOCACHE` a module's own targets already share with `heph.go.gocache_addr()`: ```python title="BUILD" target( name = "custom_build", driver = "bash", scratch = [heph.go.gocache_addr()], run = "go build -o $OUT ./cmd/tool", out = "tool", ) ``` | Argument | Default | Meaning | |----------|---------|---------| | `goos` / `goarch` | this machine's | Target platform of the cache to share. | | `gotool` | `"host"` | Toolchain selector — `"host"`, a pinned version, or a target address. Match whatever the variant you're building against declares. | | `tags` / `goexperiment` / `gcflags` / `ldflags` | `[]` | The rest of the variant's factors — pass what the variant uses, or the target warms a different cache than the one it meant to share. | | `race` | `false` | Address the race-detector variant of the cache. | `gocache_addr()` resolves the module from the *calling* BUILD file's nearest `go.mod`, so a target gets the same cache the Go driver's own targets in that module already use. ## Linting and formatting A Go module gets four extra targets the moment it has a `.golangci.yml` (or `.golangci.yaml`) at its module root — no `provider_state` toggle needed. Lint analysis sees the module's whole first-party dependency graph, not just the package being linted, so interprocedural checks (a `printf`-style wrapper, a context that's never cancelled, …) stay accurate across package boundaries. | Target | Does | |--------|------| | `lint-check` | Fails the build if the package has lint findings. Reports only, writes nothing. | | `lint` | Applies the linter's suggested fixes back into the package's sources. | | `format-check` | Fails the build if any file in the package isn't formatted. Reports only, writes nothing. | | `format` | Reformats the package's sources in place. | ```bash title="terminal" heph run //lib/auth:lint-check # gate: fails on findings heph run //lib/auth:lint # fixer: applies suggested fixes heph run //lib/auth:format-check # gate: fails if unformatted heph run //lib/auth:format # fixer: reformats in place ``` `lint` and `format` rewrite files in the package directory — the same in-place rewrite behavior as any [codegen](../concepts/codegen.md) fixer target. A package whose module has no `.golangci.yml` gets none of these four targets; `:build` and `:test` are unaffected either way. Within a module that does opt in, [`provider_state(lint = False)`](#skipping-lint) drops `lint-check`/`lint` for a package while leaving `format`/`format-check` in place — the two families are gated independently. ### Selecting and configuring linters `linters.default`/`enable`/`disable` in `.golangci.yml` select among four available linters: | Linter | Covers | |--------|--------| | `govet` | The standard `go vet` analyzers, plus opt-in ones (`shadow`, `fieldalignment`, `nilness`, `sortslice`, `unusedwrite`). | | `staticcheck` | staticcheck (`SA*`) checks. | | `gosimple` | gosimple (`S*`) checks. | | `stylecheck` | stylecheck (`ST*`) checks. | ```yaml title=".golangci.yml" linters: default: standard # "standard" (govet only, the default), "all", or "none" enable: - staticcheck settings: govet: enable: [shadow] settings: printf: funcs: [Wrapf] staticcheck: checks: [all, -ST1000] ``` | Key | Effect | |-----|--------| | `linters.default` | `standard` (`govet` only — the default), `all` (all four linters), or `none`. | | `linters.enable` / `linters.disable` | Add or remove linters from the default set, by name. | | `linters.settings.govet.enable` / `.disable` / `.enable-all` / `.disable-all` | Turn individual vet analyzers on or off. | | `linters.settings.govet.settings..` | Per-analyzer flags, e.g. `printf.funcs`, `shadow.strict`. | | `linters.settings.{staticcheck,gosimple,stylecheck}.checks` | Check patterns to include, e.g. `all`, `SA1000`, `-ST1003`, `SA1*`. | | `linters.settings.stylecheck.initialisms` / `.dot-import-whitelist` / `.http-status-code-whitelist` | Config consumed by specific stylecheck checks. | :::note `unused` needs whole-program analysis and isn't available in this per-package model. ::: ### Suppressing findings ```go title="handler.go" func handle() { //nolint:staticcheck ... } ``` `//nolint[:linter1,linter2]` on its own line covers the next line of code; trailing at the end of a line of code covers that line; bare `//nolint` (or `//nolint:all`) covers every linter. Match against `staticcheck` for any `staticcheck`/`gosimple`/`stylecheck` finding — golangci-lint folds those three into one linter name. `linters.exclusions` suppresses by rule instead of by comment: ```yaml title=".golangci.yml" linters: exclusions: generated: lax # lax (default) | strict | disable presets: - common-false-positives rules: - linters: [staticcheck] path: "_test\\.go$" text: "S1002" paths: - "third_party/.*" ``` | Key | Effect | |-----|--------| | `generated` | Whether findings in generated files are excluded: `lax` (default), `strict`, or `disable`. | | `presets` | Built-in exclude patterns: `comments`, `common-false-positives`, `legacy`, `std-error-handling`. | | `rules` | Per-rule `linters` / `path` / `path-except` / `text` filters — all present conditions on a rule must match. | | `paths` / `paths-except` | Whole-file regexes to exclude (or to exclusively include). | ### Formatters `format` and `format-check` default to `gofmt` when `.golangci.yml` sets no `formatters`. Enable `gofumpt` and/or `goimports` (import sorting only — it does not add missing imports) and tune their settings: ```yaml title=".golangci.yml" formatters: enable: - gofumpt - goimports settings: gofmt: simplify: true gofumpt: extra-rules: true goimports: local-prefixes: [github.com/myorg/myrepo] exclusions: generated: lax paths: - "_gen\\.go$" ``` | Key | Effect | |-----|--------| | `formatters.enable` | `gofmt` (default), `gofumpt`, `goimports`. Applied imports → gofmt → gofumpt regardless of listing order. | | `formatters.settings.gofmt.simplify` / `.rewrite-rules` | `gofmt -s` simplification and rewrite rules. | | `formatters.settings.gofumpt.module-path` / `.extra-rules` | gofumpt options. | | `formatters.settings.goimports.local-prefixes` | Import groups treated as "local" when sorting. | | `formatters.exclusions.generated` / `.paths` | Same shape as the linter exclusions, applied to formatting instead. | ### Pinning the analyzer binary Lint and format targets run through `heph-govet`, a binary the plugin downloads and verifies automatically the first time it's needed — nothing to configure by default. The default download is itself an [HTTP Fetch](./http-fetch.md) target, checksum-verified against a digest baked into the plugin at release time. Point the `govet` option at a different target address to use another build instead — for example one that compiles `heph-govet` from a local source tree: ```yaml title=".hephconfig" options: gotool: "1.27.0" govet: "//tools/heph-govet:build" ``` Building `heph-govet` from source needs a Go toolchain of at least the version its own module targets — set `gotool` accordingly, or the build fails. The plugin's default published binary is unaffected either way. Add a `"govet///"` entry to `checksums` to verify a pinned `govet` release download other than the plugin's own build. ## Provider functions The go plugin exposes one helper function in every BUILD file under the `heph.go` namespace. | Function | Signature | Returns | |----------|-----------|-------| | `heph.go.build_addr` | `build_addr(pkg: string, variant: string = "") -> string` | The address of `pkg`'s `build` target, optionally pinned to a named [build variant](#build-variants). `pkg` accepts a package path or a path relative to the calling BUILD file's package. | The function enforces its argument types: wrong type, missing required argument, or unknown keyword produces a clear error. See [Build variants](#build-variants) for usage details. ## Build variants Go targets compile against a **build variant**: a named, reusable set of factors — target OS/architecture, build tags, `GOEXPERIMENT`, extra compiler and linker flags — declared once and selected by name from an address. There is no implicit variant, so declare at least one before building. Declare variants with `provider_state`, scoped to a Go **module** (the directory containing `go.mod`): ```python title="BUILD" provider_state(provider = "go", variants = { "base": {"goos": "linux", "goarch": "amd64"}, "release": { "inherit": "base", "goexperiment": ["arenas"], "gcflags": ["-l"], "ldflags": ["-s", "-w"], "tags": ["prod"], }, }) ``` | Field | Type | Required | Meaning | |----------------|----------------|---------------------------------|---------| | `goos` | `string` | yes (directly or via `inherit`) | Target operating system, e.g. `"linux"`. | | `goarch` | `string` | yes (directly or via `inherit`) | Target architecture, e.g. `"amd64"`. | | `tags` | `list[string]` | no | Build tags to compile with. | | `goexperiment` | `list[string]` | no | `GOEXPERIMENT` values to enable. | | `gcflags` | `list[string]` | no | Extra flags passed to `go tool compile`. | | `ldflags` | `list[string]` | no | Extra flags passed to `go tool link`. | | `buildmode` | `string` | no | Link mode: `"exe"` (default) or `"pie"`. See [Buildmode: static vs PIE](#buildmode-static-vs-pie). | | `inherit` | `string` | no | Name of another variant in the same map to start from. | `cgo` is not a variant field — every heph-built Go target compiles with `CGO_ENABLED=0`, unconditionally. `inherit` resolves the named variant first, then overlays the fields you set on top of it. List fields (`tags`, `goexperiment`, `gcflags`, `ldflags`) are **replaced**, not merged — in the example above, `release` ends with `tags = ["prod"]`, not `["prod", ...]` plus whatever `base` had. `goos` and `goarch` can be omitted when inheriting from a variant that already sets them. Inheritance cycles are rejected with an error. ### Buildmode: static vs PIE `buildmode` controls the binary's link mode for a `package main` target's `:build`: | Value | Behavior | |---------|----------| | `"exe"` | Default. Matches plain `go build`. On Linux, links a statically linked binary with no interpreter — it runs in a `FROM scratch` or distroless image. | | `"pie"` | Position-independent executable. On Linux this always needs `/lib/ld-linux-.so.1` present at run time, even with cgo disabled. | ```python title="BUILD" provider_state(provider = "go", variants = { "release": {"goos": "linux", "goarch": "arm64", "buildmode": "pie"}, }) ``` On darwin/arm64 the Go linker makes every executable PIE regardless of this setting, so both values produce the same kind of binary there — the knob only changes behavior on Linux. Library targets and archives are unaffected by `buildmode`; it only applies to the linked binary of a `package main`. ### Selecting a variant Add `@v=NAME` to a target address: ```bash title="terminal" heph run //cmd/server:build@v=release # compile with the "release" variant heph run //cmd/server:test@v=base # run tests under "base" ``` A variant is resolved by walking from the target's package up to its module root — the closest ancestor package that declares `NAME` wins. A `variants` map declared outside the target's module (above its `go.mod`) doesn't apply to it. An address with no matching `@v=` fails with the list of variant names available at that point in the tree. A `package main` target's `:build` also accepts the plain address, with no `@v=` at all, as a shortcut: it forwards to whichever declared ancestor variant's `goos`/`goarch` match the machine running the command. ```bash title="terminal" heph run //cmd/server:build # forwards to a variant matching this machine, if one exists ``` If no declared variant matches the host, the plain address doesn't resolve. Library targets have no such shortcut — always give them `@v=NAME`. In a BUILD file, don't assemble these address strings by hand — use `heph.go.build_addr()`: ```python title="BUILD" heph.go.build_addr(pkg, variant = "") ``` | Argument | Default | Meaning | |-----------|---------|---------| | `pkg` | required | The target's package, e.g. `"cmd/server"`. Starting with `./` or `../`, it resolves relative to the calling BUILD file's package instead — same rules as [relative addresses](../reference/addresses.md#relative-forms). A path that resolves outside the workspace is an error. | | `variant` | `""` | The variant name. Omit (or pass `""`) for the plain, unparameterized address. | ```python title="cmd/server/BUILD" target( name = "image", driver = "bash", deps = {"bin": heph.go.build_addr("cmd/server", "release")}, run = "./package-image.sh $SRC_BIN $OUT", out = "image.tar", ) ``` The image target now embeds the `release`-variant binary. Calling `build_addr` only formats the address — it does not resolve or build anything, and it does not check that the variant is actually declared. `pkg` can also be given relative to the calling package: ```python title="cmd/server/BUILD" heph.go.build_addr("./worker", "release") # -> //cmd/server/worker:build@v=release heph.go.build_addr("../shared") # -> //cmd/shared:build ``` :::note List every provider-exposed BUILD function with `heph inspect functions`. ::: ## Generated code and test data The provider analyzes Go packages, but a package often needs files that aren't hand-written `.go` sources — generated code (protobuf stubs, mocks, enums) or fixtures a test reads at runtime. You don't wire these into the `:build` and `:test` targets by hand. Instead you **label** the target that produces them and the provider picks it up automatically. Three labels are recognized: | Label | Attach to a target that produces… | Pulled into | |-----------------|------------------------------------------------------------------------------|-------------| | `go_src` | Generated `.go` sources (and small embedded files cheap to produce). | `:build`, `:build_test` | | `go_embed_src` | Embed-only assets for `//go:embed` that should not block `query`/`list`. | `:build`, `:build_test` | | `go_test_data` | Files a test reads at runtime (fixtures, goldens). | `:test`, `:xtest` | ### `go_src` — generated source Label a codegen target `go_src` and its output tree is unpacked into the package before analysis, so the generated `.go` (and embedded) files are compiled as part of the package — exactly as if you'd committed them. This is how `go list`, the build, and the tests all see code produced by `protoc`, `mockgen`, `stringer`, and friends. ```python title="api/BUILD" target( name = "proto", driver = "bash", codegen = "copy", labels = ["go_src"], deps = {"src": glob("*.proto")}, out = glob("*.pb.go"), run = "protoc --go_out=. $SRC_SRC", ) ``` The `api` package now builds and tests with `api.pb.go` included — no `deps` entry, no reference to `:proto` anywhere. Add the generator, label it, done: ```bash title="terminal" heph run //api:build # compiles your sources + the generated .pb.go heph run //api:test # tests see the generated code too ``` :::tip Pair `go_src` with `codegen = "copy"` (see [Codegen](../concepts/codegen.md)) so the generated files also land in the tree for your editor and `gofmt`, and `heph tool gen-gitignore` keeps them out of git. ::: ### `go_embed_src` — embed assets excluded from package analysis Label a target `go_embed_src` when it produces files referenced by `//go:embed` that are expensive to build and should not run during `query`, `list`, or IDE metadata operations. Unlike `go_src`, outputs from `go_embed_src` targets are **not** staged during package analysis. `heph query`, `heph list`, and editor integrations complete without triggering the asset build. The files are staged only when the package actually compiles. ```python title="web/BUILD" target( name = "bundle", driver = "bash", labels = ["go_embed_src"], deps = {"src": glob("frontend/**")}, out = "dist", run = "npm ci && npm run build -- --outdir=$OUT", ) ``` The package's `//go:embed dist/*` directive is satisfied at compile time, without a frontend build running during `query` or metadata operations. `go_embed_src` respects `go_codegen_root`: when an ancestor sets `go_codegen_root = True`, embed-src targets are searched by package prefix across the whole subtree, the same as `go_src`. :::tip If your embed asset is cheap to produce (a few static templates, a small binary), `go_src` works fine — its output tree is staged before both analysis and compile. Use `go_embed_src` specifically when the asset build is expensive enough that blocking `query` or metadata on it is costly. ::: ### `go_test_data` — test fixtures Tests frequently read files from disk — golden files, sample inputs, a fixture database. Label the target that produces them `go_test_data` and its outputs are staged into the sandbox next to the test binary when `:test` (or `:xtest`) runs, so the test finds them at the path it expects. ```python title="parser/BUILD" target( name = "fixtures", driver = "bash", labels = ["go_test_data"], deps = {"src": glob("schema/*.json")}, out = glob("testdata/*.golden"), run = "./gen-goldens.sh", ) ``` ```bash title="terminal" heph run //parser:test # the .golden files are present in the sandbox ``` Because the sandbox is isolated, a test only sees the files its package declares. Labeling a fixture target `go_test_data` is what makes those files visible to the test without loosening isolation — the test stays reproducible and cacheable. ### Generated dependency addresses The provider wires each package's imports automatically through two address families it generates — you reference these only when reading a dependency graph, never by hand: | Address | Resolves to | |---------|-------------| | `@heph/go/std/` | A package from the Go standard library. | | `@heph/go/thirdparty/@` | A pinned third-party module. | Because the module and version are part of the address, a dependency bump changes the address and invalidates only the targets that import it. ## provider_state — per-package configuration `provider_state(provider="go", ...)` placed in a BUILD file configures the Go provider for that package. `go_codegen_root` and `go_codegen_deps` always extend to descendant packages. `test`, `lint`, and `link` apply to the exact declaring package by default; add `recursive = True` to extend them to descendants. When the same key applies at multiple depths, the deepest (closest) declaration wins. :::tip `heph inspect states //pkg --inherited` prints every `provider_state` declaration a package inherits, root first — a quick way to check what a package actually sees before chasing it through the BUILD file tree. See [Inspecting builds](/docs/guides/inspecting-builds#what-provider_state-applies-to-this-package). ::: ### Recognized keys | Key | Type | Effect | |-------------------|----------------------|----------| | `go_codegen_root` | `bool` | When `True` on an ancestor, `go_src` and `go_embed_src` targets are searched across the whole subtree rooted here instead of only the leaf package. Use when one generator feeds many descendant packages. Always applies to descendants, independent of `recursive`. | | `go_codegen_deps` | `list[string]` | Explicit codegen target addresses injected into every descendant package's sandbox. For generators not labelled `go_src`. The closest ancestor carrying it wins. Always applies to descendants, independent of `recursive`. | | `go_embed_deps` | `list[string]` | Explicit embed-asset target addresses injected into every descendant package's compile step. The analog of `go_codegen_deps` for the `go_embed_src` lane — for targets that produce embed-only assets but aren't labelled `go_embed_src`. The closest ancestor carrying it wins. | | `variants` | `map[string, struct(...)]` | Declares named [build variants](#build-variants) that this package and its descendants select with `@v=NAME`, bounded to the enclosing Go module. | | `test` | `bool \| struct(...)` | Controls test-target generation and configuration. Applies to the exact package by default; add `recursive = True` to extend to descendants. See below. | | `lint` | `bool` | `lint = False` drops the package's `lint-check`/`lint` targets; `lint = True` (or unset) keeps them, within a module that opts into linting at all. `format`/`format-check` are unaffected. Applies to the exact package by default; add `recursive = True` to extend to descendants. See [Skipping lint](#skipping-lint). | | `link` | `struct(...)` | Link settings for a `main` package's `build` (binary) target. Applies to the exact package by default; add `recursive = True` to extend to descendants. See [Link configuration](#link-configuration). | | `recursive` | `bool` | When `True`, extends this state's `test`, `lint`, and `link` config to all descendant packages. `go_codegen_root` and `go_codegen_deps` are unaffected — they always apply to descendants. | ### Skipping tests `test = False` disables test-target generation for the exact declaring package. Add `recursive = True` to disable across the whole subtree. A deeper `test = True` (or the struct form) re-enables them — the closest applicable ancestor wins. ```python title="BUILD" # Disable tests for this package only: provider_state(provider = "go", test = False) # Disable tests for this package and all descendants: provider_state(provider = "go", recursive = True, test = False) # Re-enable in a subdirectory: provider_state(provider = "go", test = True) ``` ### Skipping lint `lint = False` drops the `lint-check`/`lint` targets for the exact declaring package, the same scoping rules as `test`: add `recursive = True` to disable across the whole subtree, and a deeper `lint = True` re-enables them — the closest applicable ancestor wins. This only applies within a module that already has a `.golangci.yml`/`.golangci.yaml` at its root; it can't turn linting on for a module that hasn't opted in. ```python title="BUILD" # Drop lint targets for this package only: provider_state(provider = "go", lint = False) # Drop lint targets for this package and all descendants: provider_state(provider = "go", recursive = True, lint = False) # Re-enable in a subdirectory: provider_state(provider = "go", lint = True) ``` `format`/`format-check` are a separate family and keep running regardless — they're a different tool with a different verdict. The internal per-variant lint-analysis unit also stays resolvable even when a package opts out, so a still-linted importer can still pull in this package's facts for interprocedural analysis; it produces no user-visible diagnostics on its own. ### Test environment The struct form configures the generated `test`/`xtest` run targets. Like the boolean form, it applies to the exact declaring package by default. Add `recursive = True` to extend it to descendants. A struct-form state also re-enables tests even when an ancestor set `test = False`. ```python title="BUILD" provider_state( provider = "go", test = { "env": {"FOO": "1"}, # hashed; affects the cache key "pass_env": ["HOME"], # hashed; affects the cache key "runtime_env": {"BAR": "2"}, # not hashed; runtime-only "runtime_pass_env": ["PATH"], # not hashed; runtime-only "pre_run": ["export FOO=bar", "mkdir -p ./scratch"], "runner": "//tools/devenv:runner", # hashed; affects the cache key }, ) ``` | Field | Type | Hashed | Description | |--------------------|----------------|--------|-------------| | `env` | `map[string]` | yes | Env vars set on the test run. | | `pass_env` | `list[string]` | yes | Names of host env vars forwarded to the test run. | | `runtime_env` | `map[string]` | no | Like `env` but excluded from the cache key. | | `runtime_pass_env` | `list[string]` | no | Like `pass_env` but excluded from the cache key. | | `pre_run` | `list[string]` | yes | Shell lines run before the test binary. When non-empty, the target switches from the `exec` driver to the `bash` driver so the lines execute as shell. | | `runner` | `string` (target address) | yes | Exec [runner](/docs/concepts/runners) the test runs under, overriding the plugin's own `runner` option for this package's tests. See [Running Go tools under a runner](#running-go-tools-under-a-runner). | ### Link configuration The `link` struct configures the `build` (binary) target generated for a `package main`. It applies to the exact declaring package by default; add `recursive = True` to extend it to descendant packages. When multiple applicable states carry `link`, their values accumulate shallow-to-deep: a recursive ancestor's flags, deps, and runtime deps are collected first, then the package's own. For `deps` and `runtime_deps`, accumulation happens per dep group — see [Naming dep groups](#naming-dep-groups). ```python title="BUILD" provider_state( provider = "go", link = { "flags": ["-X main.version=1.2.3", "-s"], "deps": ["//embed:assets"], "runtime_deps": ["//data:config"], }, ) ``` | Field | Type | Hashed | Description | |----------------|--------------------------------------------------------------------|--------|-------------| | `flags` | `list[string]` | yes | Extra flags passed verbatim to `go tool link`, inserted before `-o`. Use for `-X` linker vars, stripping flags (`-s`, `-w`), etc. | | `deps` | `string \| list[string] \| map[string, string \| list[string]]` | yes | Target addresses staged into the link sandbox as hashed inputs. `flags` can reference their outputs. | | `runtime_deps` | `string \| list[string] \| map[string, string \| list[string]]` | no | Target addresses staged with the binary at run time only. Not hashed — they do not affect the cache key. | Unknown keys in the `link` map are rejected with a clear error naming the unrecognized field. #### Naming dep groups A single address, or a list of addresses, lands in the default group and is staged in the sandbox under the `link_deps` / `link_runtime_deps` key: ```python title="BUILD" provider_state( provider = "go", link = {"deps": ["//embed:assets", "//embed:more"]}, ) ``` Pass a map instead to split `deps` or `runtime_deps` into named groups. Each key is used verbatim as the sandbox key its addresses are staged under, and a map value can be a single address string or a list: ```python title="BUILD" provider_state( provider = "go", link = { "deps": { "assets": ["//embed:assets"], "icons": "//embed:icons", # a bare string is a single-address group }, }, ) ``` The same group name declared on a recursive ancestor and on the package itself merges shallow-to-deep; different group names stay separate. :::note Named groups are staged verbatim, so avoid reusing the plugin's own internal group names (`link_deps`, `link_runtime_deps`, `lib_*`, `gosdk`) — a collision merges your addresses into that internal group instead of a distinct one. ::: ## Claude Code plugin `heph-go` is a [Claude Code](https://claude.com/claude-code) plugin focused on Go under heph: it teaches Claude to enable the provider and drivers, wire generated code (`go_src` / `go_codegen_root` / `go_codegen_deps`) and test fixtures (`go_test_data`), and keep `:build` / `:test` green. It ships from this docs site, which doubles as a plugin **marketplace**. ### Install Run both commands inside Claude Code: ```text title="Claude Code" /plugin marketplace add hephbuild/hephbuild.github.io /plugin install heph-go@heph-marketplace ``` The first command registers the marketplace from this repository; the second installs the plugin. Restart Claude Code if prompted. ### What you get | Piece | What it does | |---|---| | **Skill `heph-go`** | Auto-activates whenever Claude works on Go under heph — enabling the provider and the `go.golist` / `go.compile` / `go.testmain` drivers, building and testing packages, wiring `go_src` / `go_embed_src` / `go_codegen_root` / `go_codegen_deps` / `go_embed_deps` and `go_test_data`, pinning `gotool`, or debugging missing generated code, embeds, modules, or testdata. Carries this page's reference bundled. | | **Agent `heph-go-expert`** | A specialist subagent for non-trivial Go-in-heph jobs: standing up the provider, wiring cross-package codegen, staging fixtures, or diagnosing why a `:build` / `:test` fails to see what it needs. | | **`/heph-go-setup`** | Turn on and configure Go support — provider, the three drivers, `gotool`, and `skip`. | | **`/heph-go-codegen`** | Wire generated code and test fixtures into a package via `go_src`, `go_codegen_root`, `go_codegen_deps`, and `go_test_data`. | | **`/heph-go-check`** | Audit a workspace's Go setup — drivers enabled, `gotool` / `skip` sane, generated code and fixtures wired, `:build` / `:test` green — and fix what isn't. | The skill activates on its own — you don't invoke it. Type a command with its leading slash; address the agent by name ("ask the heph-go-expert to…"). :::tip The plugin's bundled knowledge mirrors this page. When an exact option or label matters, the skill fetches the matching `.md` doc live (indexed at [llms.txt](https://hephbuild.github.io/llms.txt)), so it stays current even between plugin updates. ::: For general heph support beyond Go, install `heph-expert` the same way — see the [Claude Code plugin guide](../guides/claude-code-plugin.md). ### Update Pull the latest marketplace catalog, then upgrade: ```text title="Claude Code" /plugin marketplace update heph-marketplace /plugin install heph-go@heph-marketplace ``` ### Uninstall ```text title="Claude Code" /plugin uninstall heph-go@heph-marketplace ``` --- ## Group Groups multiple targets under one address. In its plain form a group is **transparent**: it stays invisible to the build graph at run time and is just a convenient handle for a set of targets. Add `include`, `exclude`, `strip_prefix`, `prefix`, or `rename` and a group does more: it re-exports its deps' outputs **filtered and relocated to new paths**, without copying any bytes. ## Driver A driver is the engine that interprets a target and knows how to execute it. This plugin registers the `group` driver. ## Enabling it Built-in; always registered by the heph engine. No configuration or plugin registration needed — the `group` driver is automatically available to all builds. ## Usage ```python title="BUILD" target( name = "g", driver = "group", deps = ["t1", "t2"], ) ``` ## Filtering and relocating outputs Set any of `include`, `exclude`, `strip_prefix`, `prefix`, or `rename` to turn a group into a re-export with a new path layout: ```python title="BUILD" target( name = "dist", driver = "group", deps = [":lib", ":assets"], include = ["**/*.txt"], strip_prefix = "build/out", prefix = "release", rename = {"assets/notice.txt": "release/NOTICE.txt"}, ) ``` | Key | Type | Meaning | |-----|------|---------| | `include` | `string[]` | Glob patterns an output path must match to be re-exported. Empty re-exports everything. | | `exclude` | `string[]` | Glob patterns that drop an output path. Applied after `include`. | | `strip_prefix` | `string` | Leading directory removed from re-exported paths. | | `prefix` | `string` | Leading directory prepended to re-exported paths. | | `rename` | `string` or `map[string, string]` | Where selected outputs are placed — see below. | `include`, `exclude`, and `strip_prefix` accept the path the *producing* target's own `BUILD` file uses (e.g. `build/out`) as well as the path heph emits once it lands in a consumer's sandbox (e.g. `app/build/out`) — either spelling matches the same file, so you don't need to know which layout a dependency was built with. Rules apply in this order: `include`/`exclude` select which outputs survive, `rename` places the paths it names, then `strip_prefix`/`prefix` place whatever `rename` didn't claim. Two files landing on the same destination path is an error naming both; a pattern that matches nothing is an error naming what was available instead of silently doing nothing. ### `rename` `rename` has two forms: ```python title="BUILD" # String — the sole output surviving include/exclude, renamed with no source # path named at all. Keeps working if the dep changes where it writes. rename = "bin/server" # Map — exact emitted paths to destinations. Leaves everything else to # strip_prefix/prefix. rename = {"app/build/out/x": "lib/x"} ``` The string form only works when exactly one output survives `include`/ `exclude` — more than one is an error. It cannot be combined with `strip_prefix`/`prefix`, since it fixes the destination outright and there would be nothing left for a prefix to apply to. The map form can be combined with `strip_prefix`/`prefix`: it places the paths it names, and the prefixes place the rest. ### Cost and caching Re-exporting costs a few string operations — no sandbox, no subprocess, and no second copy of any byte in either the local or the remote cache. The dependencies stay cached exactly as they were; the group itself is never cached, since it owns none of the bytes it re-exports and is cheap to re-derive on every build. A group with no `include`/`exclude`/`strip_prefix`/`prefix`/`rename` stays transparent, exactly like the plain aggregate form above. :::note Relative symlinks whose target stays inside the re-exported tree survive `strip_prefix`/`prefix` (both shift every path equally), but can dangle if `include`/`exclude`/`rename` relocates only one end of the pair. ::: ### Diagnosing the result `heph inspect outputs ` prints the paths a target actually produces. Run it against a filtering group to see the effect of its transform, or compare it against a dependency's own outputs to see exactly what changed: ```bash title="terminal" heph inspect outputs //app:dist ``` --- ## Hostbin Hostbin wraps host system binaries as heph targets. The plugin discovers binaries on the system `PATH` and generates executable wrapper scripts that can be used as build dependencies — letting targets depend on tools already installed on the machine. ## Driver A **driver** is the component that knows how to execute a target's action; it turns a resolved target into runnable work. Hostbin registers the `hostbin` driver. ## Enabling it Built-in and always-on. The provider and driver are registered automatically. No configuration in `.hephconfig` is required. ## Usage Targets in the `@heph/bin` package are generated on-demand. For example, to reference the system `sh` binary: ```text //@heph/bin:sh ``` --- ## HTTP Fetch The HTTP Fetch plugin downloads a file from a URL into a cacheable output. The URL can be templated over the target's own address arguments, so one target definition serves every platform, version, or other axis a caller asks for — this is how heph itself fetches platform-specific tool binaries. ## Driver A **driver** is the component that knows how to execute a target's action, turning a resolved target into runnable work. This plugin registers the `http_fetch` driver. ## Enabling it Built-in and always-on. No configuration in `.hephconfig` is required — just use the `http_fetch` driver name in your targets. ## Usage ```python title="BUILD" target( name = "dl", driver = "http_fetch", url = "https://example.com/releases/v1.2.3/tool_{goos}_{goarch}", sha256 = "", executable = True, ) ``` ```bash title="terminal" heph run //tools:dl # host platform heph run //tools:dl@goos=linux,goarch=amd64 # a specific platform ``` Each `{arg}` placeholder in `url` is substituted from the target address's own args (the `@key=value,...` part of the address) — `{{` and `}}` are literal braces. Rendering happens before the target is hashed, so the *rendered* URL — not the template — determines the cache key: each argument combination is its own cache entry. A placeholder the address doesn't set is a hard error, not a silent fetch of the wrong file. ## Fields | Field | Type | Default | Description | |-------|------|---------|-------------| | `url` | `string` | required | URL template to fetch. | | `sha256` | `string` | none | Expected SHA-256 of the downloaded bytes, in hex. When set, a mismatch fails the build. When omitted, the file is fetched **unverified** — the target is only as reproducible as the remote server, and a warning naming the actual hash is logged so you can pin it. | | `out` | `string` | the URL's last path segment | Output filename, relative to the target's package. Required when the URL has no usable last segment (for example one ending in `/`). | | `executable` | `bool` | `false` | Mark the fetched file executable. | | `cache` | `bool` or dict | both tiers on | Caching for the fetched file. See [Cache control](#cache-control). | ## Cache control A fetch is content-addressed — pinned by `sha256` when set — so it's safe to share, and `http_fetch` targets cache in both the local and remote cache by default. `cache` accepts a bare bool or a dict with up to three keys: | Key | Type | Default | Meaning | |-----|------|---------|--------| | `enabled` | bool | `true` | Enable local caching for this target. | | `remote` | bool | `true` | Enable remote caching for this target. | | `history` | int | `1` | Number of past revisions to retain in the local cache (minimum `1`). | A bare `True` sets both `enabled` and `remote` to `true`. A bare `False` disables both. Use the dict form to toggle a tier independently, for example keeping a fetch local-only: ```python title="BUILD" target( name = "dl", driver = "http_fetch", url = "https://example.com/releases/v1.2.3/tool_{goos}_{goarch}", sha256 = "", executable = True, cache = {"remote": False}, # keep local-only ) ``` ## Notes Fetching has no inputs from the rest of the build graph — the bytes come from the network. There's nothing to declare as a dependency beyond the target itself; reference it from `deps`/`tools` like any other target once fetched. --- ## Plugins Almost everything heph can do comes from **plugins**. Plugins come in three kinds: a **driver** is a named executor a target references via its `driver` field; a **provider** discovers or generates targets; a **hook** observes the engine's build-event stream without producing or running anything. For example, when you write `driver = "bash"` in a BUILD file, you are reaching for a driver registered by the [Exec](./exec.md) plugin; when heph scans the workspace for BUILD files, that is a provider at work. ## Naming A plugin's drivers are named `.` in a BUILD file: `driver = "oci.pull"`, `driver = "go.lint"`, `driver = "devenv.runner"`, `driver = "auth.credential"`. A plugin's functions are `heph..`, such as `heph.fs.glob` and `heph.auth.env`. A driver that carries the plugin's own name, like `bash` or `group`, is written bare. List every loaded plugin with the full name of each driver, function and runner it provides: ```bash title="terminal" heph tool plugins heph tool plugins --json ``` ## Drivers Each driver plugin registers one or more named executors a target can run through. | Plugin | Driver | Purpose | |-------------------------|--------------------------------------------|--------------------------------------------------------------------------| | [Exec](./exec.md) | `exec`, `bash`, `sh` | Runs shell commands in sandboxed builds, with interactive debugging. | | [Filesystem](./fs.md) | `fs` | References workspace files and globs as build inputs. | | [Group](./group.md) | `group` | Bundles targets transparently with no extra work. | | [Hostbin](./hostbin.md) | `hostbin` | Wraps host `PATH` binaries as targets for the build system. | | [HTTP Fetch](./http-fetch.md) | `http_fetch` | Downloads a URL, templated over the target's address args, into a cacheable file output. | | [Nix](./nix.md) | `nix` | Builds reproducible tool environments via Nix flakes. | | [Template](./template.md) | `template` | Renders a template file with declared variables into a cacheable output. | | [Textfile](./textfile.md) | `textfile` | Generates text files with optional executable permissions. | ## Languages Language plugins add first-class support for a toolchain, registering the drivers that build its libraries, binaries, and tests. | Plugin | Driver | Purpose | |---------------|---------------------------------------------|--------------------------------------------------| | [Go](./go.md) | `go.golist`, `go_embed`, `go.testmain` | Go language support: libraries, binaries, tests. | ## Containers The OCI plugin builds and moves container images, with or without a Dockerfile and with or without a daemon. | Plugin | Driver | Purpose | |---------------|-----------------------------------------------------------------------------------------------------|------------------------------------------------| | [OCI](./oci.md) | `oci.docker_build`, `oci.image`, `oci.layer`, `oci.index`, `oci.pull`, `oci.push`, `oci.load`, `oci.runner` | Build an image (with a Dockerfile or from target outputs), group multi-platform images, move images between a registry, the cache, and a local docker daemon, and run targets inside a running container. | ## Runners A [runner](/docs/concepts/runners) target describes an environment other targets run inside — a devenv shell, a container — instead of on the bare host. Any [Exec](./exec.md) or [Go](./go.md) target can point at one. | Plugin | Driver | Describes | |----------------------|-----------------|-----------------------------------| | [Devenv](./devenv.md) | `devenv.runner` | A [devenv](https://devenv.sh) shell. | | [OCI](./oci.md#running-targets-inside-a-container) | `oci.runner` | A running container. | ## Providers Each provider plugin discovers or generates targets rather than executing them, so it registers no driver. | Plugin | Purpose | |-----------------------------|--------------------------------------------------------------| | [Buildfile](./buildfile.md) | Scans the workspace for BUILD files and parses target definitions. | | [Query](./query.md) | Selects targets dynamically by label, package, prefix, or output. | ## Hooks Hook plugins observe the engine's `BuildEvent` stream — targets matched, started, finished, cache hits, failures — without producing or running targets. They are purely build-event consumers and run in the same process as heph on a background thread. | Plugin | Purpose | |--------------------------------------|-----------------------------------------------------------------------| | [GitHub Actions](./gha.md) | Live PR comment and step summary for GitHub Actions builds. | --- ## Nix The Nix plugin builds reproducible shell environments using Nix, resolving packages from a nixpkgs flake URL and exposing selected programs as tool binaries. The driver locks flake URLs to ensure pure evaluation, builds an environment via `nix build`, and wraps the resulting programs for use in other build targets. This lets you pin an exact toolchain once and depend on it from anywhere in the graph without polluting the host environment. ## Driver A **driver** is the component that knows how to execute a target's action, turning a resolved target into runnable work. This plugin registers the `nix` driver. ## Enabling it Not enabled by default. Register the `nix` driver in `.hephconfig` under `plugins` with `builtin: nix`, then reference it from a target's `driver` field. Requires the `nix` command-line tool with flakes support on the host `PATH`. ## Configuration ```yaml title=".hephconfig" plugins: - builtin: nix ``` ## Usage Build a small toolchain and consume it from another target via `tools`: ```python title="BUILD" nix_tools = target( name = "nix_tools", driver = "nix", nixpkgs = "github:NixOS/nixpkgs/nixos-unstable", packages = ["pkgs.ripgrep"], programs = ["rg"], ) target( name = "check", driver = "bash", tools = nix_tools, run = ["rg --version"], ) ``` ## Notes Requires `nix` on `PATH` with the `nix-command` and `flakes` experimental features. The `nixpkgs` URL should pin a specific commit for reproducibility (moving branches are resolved once then cached until the URL changes). Programs must exist at `{store_path}/bin/{program}` after the nix build. Remote caching is disabled because wrappers reference host-local `/nix/store` paths. Use `system` to override the default host system detection (e.g. for cross-compilation specs). --- ## OCI The OCI plugin builds and moves container images. `oci.docker_build` builds one from a Dockerfile with `docker buildx`; `oci.image` assembles one directly from target outputs, with no Dockerfile, no daemon, and nothing executed. `oci.pull`, `oci.push`, and `oci.load` move an image between a registry, heph's cache, and a local docker daemon. `oci.index` groups images built separately, one per platform, into a single multi-platform image. `oci.runner` turns a running container into a [runner](/docs/concepts/runners) other targets can execute inside. ## Driver A **driver** is the component that knows how to execute a target's action. This plugin registers eight drivers: `oci.docker_build`, `oci.image`, `oci.layer`, `oci.index`, `oci.pull`, `oci.push`, `oci.load`, and `oci.runner`. ## Enabling it The OCI plugin is an **external plugin** — it is not compiled into the heph binary. It ships as a shared library (cdylib) with a manifest file (`heph-oci-plugin.json`) and takes no options. ```yaml title=".hephconfig" plugins: - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-oci-plugin.json checksum: sha256: # optional; pin from heph-oci-plugin.json.sha256 ``` The `checksum` field is optional but recommended — it pins the manifest to a known digest so a tampered or misdelivered manifest is rejected before loading. See [Pinning manifests with checksums](/docs/reference/configuration#pinning-manifests-with-checksums) for details. ## Building an image Two drivers build an image. Pick by whether the image needs to run a command. | Driver | Use when | Needs | |---|---|---| | [`oci.docker_build`](#ocidocker_build) | The image needs a `RUN` step — installing packages, compiling, a multi-stage build. | A running docker daemon and a `docker buildx` builder that can export to a file. | | [`oci.image`](#ociimage-and-ocilayer) | The image is a base plus files on top — a static binary, a few assets. Nothing needs to execute. | Nothing. No daemon, no `docker` binary, no execution of any kind. | Prefer `oci.image` when it fits. Its cache key covers everything that can change the image: there is no base resolved from the network at build time, no un-hashed toolchain version, no secret value that can't be hashed. It also cross-builds for free — assembling a `linux/arm64` image on an `amd64` machine needs no emulation, because nothing runs. `oci.docker_build` is the escape hatch for a real build step, at the cost of leaving some of those factors outside the cache key — see [What `oci.docker_build` does not hash](#what-ocidocker_build-does-not-hash). ### `oci.docker_build` ```python title="BUILD" target( name = "img", driver = "oci.docker_build", dockerfile = "Dockerfile", context = [":srcs"], build_args = {"VERSION": "1.2.3"}, platforms = ["linux/amd64", "linux/arm64"], ) ``` | Field | Type | Default | Description | |-------|------|---------|-------------| | `dockerfile` | `string` | `"Dockerfile"` | The Dockerfile, as either a **target address** or a path. An address (starting with `:` or `//`) makes the target that produces it a dependency, hashed on its own. A path is relative to the target's package and must be produced by a `context` dependency (a plain checked-in `Dockerfile` comes from the [Filesystem](./fs.md) provider). Absolute paths are rejected. | | `context` | list or dict of `string` → target addresses | `{}` | Build-context dependencies. A bare list is the default group, exported to the build as the `SRC` build arg; a dict names groups, each exported as `SRC_` (context-relative paths of that group's outputs). Group names are uppercased and non-alphanumerics become `_`. | | `context_by_platform` | dict of platform → (list or dict as `context`) | `{}` | Context dependencies that differ **per platform**, keyed `"os/arch"` — e.g. `{"linux/amd64": {"bin": [":bin_amd64"]}, "linux/arm64": {"bin": [":bin_arm64"]}}`. One buildx invocation builds every platform from one context, so a dependency that differs by architecture (a cross-compiled binary, for example) can't go in `context` — it stages under a per-platform prefix instead, and the Dockerfile selects with BuildKit's `TARGETPLATFORM` build arg. Group names must not collide with `context`; every platform in `platforms` needs an entry and vice versa; requires `platforms` to be set explicitly. | | `bases` | dict of `string` → target address | `{}` | Base images, by build-context name → a single `oci.pull(layout = True)` (or `oci.image`/`oci.docker_build`) target. Wired to `docker buildx --build-context =…`, so the Dockerfile does `FROM ` (and can `COPY --from=` for multi-stage). A multi-platform build needs the base pulled with `all_platforms = True` too. | | `format` | `string` | `"oci"` | Archive format: `oci` or `docker`. | | `build_args` | dict of `string` → `string` | `{}` | `--build-arg` values. Hashed — they change the image. A key may not contain `=`. | | `stage` | `string` | unset | Build a specific stage (`--target`) of a multi-stage Dockerfile — the name in `FROM … AS `. Named `stage` rather than buildx's `target`, since a heph *target* is a different thing. | | `out` | `string` | `.tar` | Output archive filename, relative to the target's package. Must be a bare filename. | | `platforms` | `string[]` | the builder's own default | Target platforms (`--platform`), e.g. `["linux/amd64", "linux/arm64"]`. Left empty, the builder's default platform is resolved at parse time (`docker buildx inspect --bootstrap`) and folded into the cache key, so an arm64 laptop and an amd64 CI runner don't compute the same key for different images. A multi-platform build needs a container-driver builder — see [The builder](#the-builder) — the default daemon builder only builds one platform, and `format = "docker"` can't hold more than one image at all. | | `builder` | `string` | buildx's current builder | The `docker buildx` builder to build on (`--builder`). A hashed input — and the reason `BUILDX_BUILDER` is stripped from the build's environment: what matters is the *name* written here, not whatever the host happens to have selected. | | `secrets` | `string[]` | `[]` | BuildKit build secrets, as raw `--secret` specs, e.g. `["id=token,env=TOKEN"]`, consumed in the Dockerfile via `RUN --mount=type=secret`. The spec **string** is hashed; the secret's *value* is not (and cannot be). `src=` sources are rejected — they'd resolve against a working directory outside the sandbox; use `env=`, or stage the file through `context`. | | `ssh` | `string[]` | `[]` | SSH forwarding, as raw `--ssh` specs, e.g. `["default"]`, consumed via `RUN --mount=type=ssh`. Same hashing caveat and `src=` restriction as `secrets`. | | `cache_from` | `string[]` | `[]` | BuildKit `--cache-from` refs, e.g. `["type=registry,ref=reg/img:cache"]`. A build optimization, excluded from the input hash — never busts the heph cache. | | `cache_to` | `string[]` | `[]` | BuildKit `--cache-to` refs, e.g. `["type=registry,ref=reg/img:cache,mode=max"]`. Also excluded from the input hash. | | `cache` | bool or dict | both tiers on | Caching for the built archive. See [Cache control](#cache-control). | Two output groups: the default group (`""`) is the archive; `digest` is a plain-text file holding the built image's digest, for a downstream target to read without unpacking the archive: ```python title="BUILD" target( name = "deploy", driver = "bash", deps = {"digest": "//app:img|digest"}, run = "envsubst < manifest.tmpl.yaml > $OUT", out = "manifest.yaml", ) ``` #### The builder `oci.docker_build` needs `docker buildx` with a builder that can **write an image archive to a file**. The plain `docker` driver — what a stock Docker Engine selects by default — cannot: `--output type=oci,dest=…` and `type=docker,dest=…` both fail on it, and with them every `oci.docker_build` target regardless of `format`. Either turn on the daemon's containerd image store, or create a container-driver builder and name it: ```console title="terminal" $ docker buildx create --name heph --driver docker-container ``` ```python title="BUILD" target(name = "img", driver = "oci.docker_build", builder = "heph", ...) ``` Multi-platform builds have the same requirement, plus a container-driver builder specifically — the default daemon builder only builds one platform at a time. #### What `oci.docker_build` does not hash `oci.docker_build`'s cache key covers the build context, Dockerfile, build args, stage, platforms, and base-image contexts — an unchanged context is a cache hit, and the archive is served from cache without rebuilding. It does **not** cover everything that can change the resulting image bytes: - **`FROM` base images** not passed through `bases`. BuildKit resolves a plain `FROM alpine:3.20` itself, from the network — whatever that tag pointed at when the machine that populated the cache last resolved it. Pin `FROM` by `@sha256:`, or pull the base with [`oci.pull`](#ocipull) and wire it through `bases`, which *is* a hashed input. - **Anything `RUN` fetches** — `apt-get`, `curl`, `npm ci`. Execution-time downloads are neither content-addressed nor verified. - **Secret and SSH *values***. The spec strings are hashed; what the agent or environment actually hands the build is not. - **The host `docker` / `buildx` version.** BuildKit changes output-visible defaults across releases (attestations, compression), so two machines on different versions can produce different archives from an identical cache key. - **`.dockerignore`.** Nothing stages one, so unless a `context` target produces it the build sees none — a heph-built image can differ from the same `docker build` run by hand in the repo. The build's environment is not on this list: every `oci.docker_build` runs with a cleared environment and an explicit passthrough allowlist, so a stray `BUILDX_BUILDER` or `SOURCE_DATE_EPOCH` on the host cannot change the build behind the cache key's back. ### `oci.image` and `oci.layer` `oci.layer` turns target outputs into one image layer — a tar of a file tree, nothing executes. `oci.image` stacks `oci.layer` targets on an optional base into a built image. Splitting the two means a big shared layer (static assets, a CA bundle) is built and cached once and reused by every image that lists it. ```python title="BUILD" target( name = "bin_layer", driver = "oci.layer", srcs = [":server"], prefix = "/usr/bin", ) target( name = "img", driver = "oci.image", base = ":base_alpine", # an oci.pull(layout = True) target layers = [":bin_layer"], entrypoint = ["/usr/bin/server"], platforms = ["linux/amd64", "linux/arm64"], ) ``` #### `oci.layer` fields | Field | Type | Default | Description | |-------|------|---------|-------------| | `srcs` | `string[]` | `[]` | Targets whose files go into this layer, at their workspace-relative path, rewritten by `strip` and `prefix`. A `srcs` that produces no files is an error, not an empty layer — an image missing its binary would otherwise build, push, and start, and only fail when something tries to run it. | | `prefix` | `string` | **required** | Where the files land inside the image, e.g. `"/usr/bin"`. Required deliberately — a default of `/` (files at their workspace-relative path, e.g. `/cmd/server/bin`) is never what anyone wants, and would fail silently at `docker run` time instead of at build time. | | `strip` | `string` | unset | A leading workspace-relative path to drop before applying `prefix` — e.g. `strip = "cmd/server"` puts `cmd/server/bin` at `/bin`. A `strip` that matches nothing is an error, not a silent no-op. | | `mode` | `string` (octal) | `"0755"` for executable files, `"0644"` otherwise | File mode as an octal string, e.g. `"0755"`. A string because Starlark has no octal literal — `mode = 755` would silently mean decimal 755. Setuid, setgid, and the sticky bit are rejected: heph's artifact hash records only the executable bit, so those permissions can't survive a rebuild from cache. | | `cache` | bool or dict | both tiers on | Caching for the layer tar. See [Cache control](#cache-control). | A layer's bytes are written to be reproducible regardless of the host: file timestamps, uid, and gid are zeroed; entries are sorted by path; symlinks are preserved (never followed); and the layer is written uncompressed. #### `oci.image` fields | Field | Type | Default | Description | |-------|------|---------|-------------| | `base` | `string` | unset | Base image — an `oci.pull(layout = True)` (or another `oci.image`) target. Omitted, the image starts from scratch. This target's layers go on top; the base's config (`Env`, `Entrypoint`, `Cmd`, `User`, `WorkingDir`, `Labels`, `ExposedPorts`) is inherited, and anything set below wins. A multi-platform build needs a base with an instance per platform: `oci.pull(layout = True, all_platforms = True)`. | | `layers` | `string[]` | `[]` | `oci.layer` targets, bottom to top. Order matters — a later layer's file shadows the same path in an earlier one. | | `layers_by_platform` | dict of platform → `string[]` | `{}` | Layers that differ per platform, appended after `layers`. Every platform in `platforms` needs an entry and vice versa — a missing one would silently ship an image without its binary for that platform. | | `platforms` | `string[]` | **required** | Target platforms, e.g. `["linux/amd64", "linux/arm64"]`. No default: `platforms` is a label written into the image config with nothing relating it to `layers`, so defaulting to the host's architecture would let an `amd64` laptop and an `arm64` CI runner build different images from one BUILD file, or let an `amd64` binary ship inside a config claiming `arm64` — an `exec format error` at run time, correctly cached, with no build-time error. | | `entrypoint` | `string[]` | `[]` | The image's entrypoint (`ENTRYPOINT`). Setting it clears any `cmd` inherited from the base, mirroring a Dockerfile's `ENTRYPOINT`. | | `cmd` | `string[]` | `[]` | The image's default arguments (`CMD`). | | `env` | dict of `string` → `string` | `{}` | Environment variables, merged over the base's by name. | | `user` | `string` | unset | The user to run as (`USER`), e.g. `"65532:65532"`. | | `workdir` | `string` | unset | The working directory (`WORKDIR`). | | `labels` | dict of `string` → `string` | `{}` | Image labels, merged over the base's. | | `exposed_ports` | `string[]` | `[]` | Ports the image declares (`EXPOSE`), e.g. `["8080/tcp"]`. | | `format` | `string` | `"oci"` | Archive format: `oci` or `docker`. | | `layout` | `bool` | `false` | Write an OCI **layout directory** instead of a single archive file — the shape another image's `base`, and `oci.docker_build`'s `bases`, consume. | | `out` | `string` | `.tar`, or `.oci` with `layout = True` | Output filename (or directory name), relative to the target's package. Must be a bare name. | | `cache` | bool or dict | both tiers on | Caching for the built image. See [Cache control](#cache-control). | Like `oci.docker_build`, `oci.image` has a default output group (the archive/layout) plus a `digest` group holding the image digest as text. ## Multi-platform images `oci.docker_build(platforms = [...])` and `oci.image(platforms = [...])` build every platform from one recipe — one Dockerfile (or one set of layers), with per-platform differences limited to `context_by_platform` / `layers_by_platform`. When the platforms genuinely need different recipes — a different base, a different package manager, a stage that only exists on one architecture — build them as separate targets and group the results with `oci.index`. ```python title="BUILD" target( name = "amd64", driver = "oci.docker_build", dockerfile = "Dockerfile.amd64", context = [":srcs"], platforms = ["linux/amd64"], ) target( name = "arm64", driver = "oci.docker_build", dockerfile = "Dockerfile.arm64", context = [":srcs"], platforms = ["linux/arm64"], ) target( name = "img", driver = "oci.index", images = [":amd64", ":arm64"], ) ``` `//pkg:img` is then one image everywhere downstream: `oci.push` sends the whole manifest list under one tag, `oci.load` picks the instance for the host, and `oci.docker_build`'s `bases` resolves the right platform out of it. Nothing is rebuilt or re-hashed by `oci.index` — it reads the layouts its inputs already produced and writes one index over them, copying blobs by reference. Each input still caches on its own inputs, so changing the arm64 Dockerfile doesn't rebuild amd64. ### `oci.index` fields | Field | Type | Default | Description | |-------|------|---------|-------------| | `images` | `string[]` | **required** | The images to group, one per platform — `oci.docker_build`, `oci.image`, `oci.pull`, or another `oci.index` target. Each contributes every instance it holds, so grouping two single-platform builds is the common case, but a multi-platform input is merged rather than rejected. Two inputs claiming the same platform is an error. | | `format` | `string` | `"oci"` | `docker` is rejected — a docker-format archive holds a single image, which is the one thing this rule doesn't produce. | | `layout` | `bool` | `false` | Write an OCI **layout directory** instead of a single archive file. | | `out` | `string` | `.tar`, or `.oci` with `layout = True` | Output filename (or directory name), relative to the target's package. Must be a bare name. | | `cache` | bool or dict | both tiers on | Caching for the grouped image. See [Cache control](#cache-control). | ## Moving images ### `oci.pull` Pulls an image from a registry into a cacheable archive output — the image-world analogue of [HTTP Fetch](./http-fetch.md): bytes come from the network, not from other targets, and the pulled archive is a cacheable target output, so a base image shared by many `oci.docker_build` or `oci.image` targets is pulled once and served from cache thereafter. ```python title="BUILD" target( name = "alpine", driver = "oci.pull", ref = "alpine:3.20", layout = True, ) ``` | Field | Type | Default | Description | |-------|------|---------|-------------| | `ref` | `string` | **required** | Source image reference, e.g. `docker.io/library/alpine:3.20` or, pinned, `alpine@sha256:…`. | | `layout` | `bool` | `false` | Write an OCI **layout directory** instead of a single archive file — the shape `oci.docker_build`'s `bases` and another image's `base` consume. Without it the pulled archive can only be pushed or loaded. | | `platforms` | `string[]` | Linux on the host's architecture | Which platforms to pull out of a multi-platform manifest list, as `"os/arch"`. One entry selects one instance; several keep an index holding exactly those. Always part of the cache key. Mutually exclusive with `all_platforms`. | | `all_platforms` | `bool` | `false` | Pull **every** instance of the manifest list instead of naming them, keeping the whole index. This is what a base for a multi-platform `oci.docker_build`/`oci.image` needs. | | `out` | `string` | `.tar`, or `.oci` with `layout = True` | Output filename (or directory name), relative to the target's package. Must be a bare name. | | `insecure` | `bool` | `false` | Pull from an insecure (HTTP / self-signed) registry: plain HTTP, certificate validation off. | | `credentials` | `string[]` | `[]` | [Credential](/docs/concepts/credentials) targets to pull with, presented with `heph.auth.docker([...])`. See [Registry authentication](#registry-authentication). | | `ambient_credentials` | `bool` | `false` | Pull with the host's own `docker login`. Not combinable with `credentials`. | | `cache` | bool or dict | both tiers on | Caching for the pulled archive. A pull is content-addressed only when `ref` is digest-pinned. See [Cache control](#cache-control). | The platform is always resolved to a concrete `os/arch` and always hashed, even when `platforms` is left at its default — leaving it implicit would make an arm64 and an amd64 machine share one cache entry for two different images, and would fail outright on macOS, where there's no `darwin` instance to pull from a Linux image. #### Pinning for reproducibility heph caches a pull on the **ref string**, not on registry content — a `docker.io/library/alpine:3.20` moving to point at new bytes still serves the stale, already-cached archive. Pin by digest for a reproducible pull: ```python title="BUILD" target( name = "alpine", driver = "oci.pull", ref = "alpine@sha256:9f2c4e1b7a0d38560c1a4c9b6e...", layout = True, ) ``` Leaving `ref` on a mutable tag logs a warning at parse time, on every run: ```text WARN oci.pull: pulling a mutable tag "alpine:3.20" — heph caches on the ref string, so a moved tag serves the stale archive; pin the ref by @sha256:digest to make the pull reproducible ``` On an actual pull (never on a cache hit, since nothing is resolved then), a second warning names the exact digest the tag currently resolves to — paste it straight back into `ref`: ```text WARN oci.pull: "alpine:3.20" currently resolves to "docker.io/library/alpine@sha256:9f2c4e1b7a0d38560c1a4c9b6e..." — pin `src` to that to make this pull reproducible ``` (The message refers to the field internally as `src` — this is the same `ref` attribute set above.) See [Reproducibility](/docs/concepts/reproducibility) for the same principle applied elsewhere in heph. #### Shared blob cache Every `oci.pull` target draws downloaded registry blobs from one shared [scratch cache](/docs/concepts/scratch), keyed by nothing but its own address — blobs are content-addressed by digest, so one store holds `amd64` and `arm64` layers side by side. Two images sharing a base layer download it once, not once per pull target. Nothing to configure; inspect or reclaim it like any other scratch: ```bash title="terminal" heph tool scratch ls # the blob store shows up alongside your own caches heph tool scratch rm --all # drop it if it ever needs a clean slate ``` ### `oci.push` Pushes an image archive to a registry. An **action**, not an artifact — it has an external side effect (the upload) and is therefore never cached; it runs every time it's requested. ```python title="BUILD" target( name = "publish", driver = "oci.push", image = ":img", ref = "registry.io/me/app:1.2", ) ``` | Field | Type | Default | Description | |-------|------|---------|-------------| | `image` | `string` | **required** | Target address of the image to push — a `oci.docker_build`, `oci.image`, or `oci.index` target. Only its archive output is consumed. | | `ref` | `string` | **required** | Destination registry reference, e.g. `registry.io/me/app:1.2`. May use `${read://pkg:name}` and `${image_hashout}`, see [Computed references](#computed-references). | | `insecure` | `bool` | `false` | Push to an insecure (HTTP / self-signed) registry: plain HTTP, certificate validation off. | | `credentials` | `string[]` | `[]` | [Credential](/docs/concepts/credentials) targets to push with, presented with `heph.auth.docker([...])`. See [Registry authentication](#registry-authentication). | | `ambient_credentials` | `bool` | `false` | Push with the host's own `docker login`. Not combinable with `credentials`. | A multi-platform archive pushes every instance plus the manifest list that ties them together. Blobs the registry already has are skipped. #### Computed references `ref` can take its repository from another target and its tag from the image itself: ```python title="BUILD" target(name = "registry", driver = "bash", run = "echo registry.io/me/app > $OUT", out = "registry.txt") target( name = "publish", driver = "oci.push", image = ":img", ref = "${read://pkg:registry}:${image_hashout}", ) ``` | Variable | Value | |----------|-------| | `${read://pkg:name}` | The contents of that target's single output. See [Deferred values](/docs/concepts/deferred-values). | | `${image_hashout}` | The hash of the `image` archive, so every distinct image gets its own tag. | `${image_hashout}` is heph's content hash of the image, **not** the registry digest, so it won't match what `docker images` or the registry reports. Any other `${…}` in `ref` is refused before anything runs, and a value produced by a `${read://…}` target must not contain `$`. On success, the run logs the reference it pushed along with the registry digest. ### `oci.load` Loads an image archive into the local docker daemon. Also an action, not cached — it mutates the daemon's image store and runs every time. Its output is the ref it tagged in the daemon — needed because the derived tag (see `tag` below) carries an input hash and can't be written down by hand, so a downstream target reads the ref instead of reconstructing it. ```python title="BUILD" target( name = "load", driver = "oci.load", image = ":img", ) ``` ```console title="terminal" $ heph run --cat-out //app:load app_img:9f2c4e1b7a0d3856 $ docker run --rm "$(heph run --cat-out //app:load)" ``` A downstream target reads the ref the same way it reads any other dependency's output: ```python title="BUILD" target( name = "smoke_test", driver = "bash", deps = [":load"], run = "docker run --rm \"$(cat $SRC)\"", ) ``` | Field | Type | Default | Description | |-------|------|---------|-------------| | `image` | `string` | **required** | Target address of the image to load — a `oci.docker_build`, `oci.image`, or `oci.index` target. Only its archive output is consumed. | | `tag` | `string` | derived | Local ref to give the loaded image. Takes either half of a ref, or both — whichever half is left out is derived. See below. | | `platform` | `string` | Linux on the host's architecture | Which instance to load out of a multi-platform archive, as `"os/arch"`. A daemon holds one image per tag, so a multi-arch archive must be narrowed to one instance on the way in. | `tag` splits into a **repository** and a **tag**. Naming only one leaves the other derived — the derived tag changes if and only if the image changes, so two people building the same commit get the same tag, and it never points at whichever image happened to load last; the derived repository is the image target's address turned into a docker-legal name (`//cmd/server:img` becomes `cmd_server_img`). Whichever half is written is carried through as-is: ```python title="BUILD" target(driver = "oci.load", image = ":img") # app_img:9f2c4e1b7a0d3856 target(driver = "oci.load", image = ":img", tag = "app") # app:9f2c4e1b7a0d3856 target(driver = "oci.load", image = ":img", tag = "ghcr.io/me/app") # ghcr.io/me/app:9f2c4e1b7a0d3856 target(driver = "oci.load", image = ":img", tag = "app:dev") # app:dev ``` A `tag` with no `:` after its last `/` names a repository only — this includes a repository on a registry port, e.g. `localhost:5000/app`, which keeps a derived tag rather than being read as tag `5000/app`. A `tag` that can't identify an image to load — empty, `:dev`, `app:`, or pinned to a digest (`app@sha256:…`) — is rejected at parse. ## Registry authentication `oci.pull` and `oci.push` are **anonymous by default**. A target that needs a registry login says which identity it uses, in the BUILD file: | The target sets | It authenticates as | |---|---| | nothing (the default) | anonymous | | `credentials = [...]` | those credentials only | | `ambient_credentials = True` | the host's own Docker login (`~/.docker/config.json` or `$DOCKER_CONFIG`, and any `docker-credential-*` helper it names, or podman's `auth.json`); anonymous for a registry it has nothing for | `credentials` and `ambient_credentials` are mutually exclusive; setting both is an error. ### Declared credentials Declare a [credential](/docs/concepts/credentials) presented with `heph.auth.docker`, listing the registries it covers, and reference it from the target: ```python title="BUILD" ghcr = target( name = "ghcr", driver = "auth.credential", sources = [heph.auth.env(["USERNAME", "PASSWORD"])], present = heph.auth.docker(["ghcr.io"]), ) target(driver = "oci.pull", name = "base", ref = "ghcr.io/acme/base@sha256:…", credentials = [ghcr]) target(driver = "oci.push", name = "push", image = ":img", ref = "ghcr.io/acme/app:1.2", credentials = [ghcr]) ``` - The credential must provide a `username` and a secret (`password`). A credential with no `username` is refused. The username depends on the registry: ghcr.io and GitLab accept any name, Google Artifact Registry wants `oauth2accesstoken`, ECR wants `AWS`, Docker Hub wants your account name. - The registry must be listed in `heph.auth.docker([...])`. Docker Hub aliases (`docker.io`, `index.docker.io`, `registry-1.docker.io`) are equivalent, so `["docker.io"]` covers `alpine`. - A registry none of the listed credentials covers is an error naming the registry, and so are two credentials covering the same registry. A target never falls back to another identity. ### Using your own docker login ```python title="BUILD" target(driver = "oci.pull", name = "base", ref = "registry.corp/base@sha256:…", ambient_credentials = True) ``` Handy for a developer's laptop. Because the result then depends on whoever runs the build, prefer `credentials` for anything CI runs. When a request fails, the error says whether it was anonymous and names both attributes. ### Caching a credential-gated pull Credentials never enter the cache key. A pull by tag (`ref = "registry.corp/base:1"`) resolves against the identity that ran it, and a registry that filters tags by permission can then hand one identity's image to another through a shared cache. heph warns when a tag-referenced pull uses credentials. Pin the ref by digest (`@sha256:…`), or set `cache = False`. A digest-pinned pull is cached like any other: anyone who can read the remote cache can read the image. :::note `oci.docker_build` doesn't take `credentials`. To build on a private base image, pull it with `oci.pull(layout = True, credentials = [...])` and pass it as `bases`. ::: ## Running targets inside a container `oci.runner` describes a running container as a [runner](/docs/concepts/runners) other targets execute inside: ```python title="BUILD" target( name = "load", driver = "oci.load", image = ":img", tag = "app:runner", ) target( name = "runner", driver = "oci.runner", image = "app:runner", deps = [":load"], ) target( name = "check", driver = "bash", runner = ":runner", run = ["python --version"], ) ``` | Field | Type | Default | Description | |-------|------|---------|-------------| | `image` | `string` | **required** | Image reference to run targets in, e.g. the tag `oci.load` gave it. Must already be in the local docker daemon. | | `deps` | `string[]` | `[]` | Targets that must build first — normally the `oci.load` target that puts `image` in the daemon. Hashed: rebuilding the image re-derives the runner. | | `run_args` | `string[]` | `[]` | Extra `docker run` arguments for the held container — `--network`, additional `-v` mounts, `--user`. The workspace root and heph's own home are mounted automatically; do not repeat them here. | One container is held open for the whole build and each target runs inside it with `docker exec`. The runner's fingerprint is the image's content digest, resolved from the daemon at build time — retagging the image moves the digest, which re-keys every target that runs under it. Cached locally, never remotely: the answer is a fact about this daemon's image store, and publishing it would let one machine's resolution key another's builds. ## Cache control `oci.docker_build`, `oci.image`, `oci.layer`, `oci.index`, and `oci.pull` all accept `cache` as a bare bool or a dict with up to three keys: | Key | Type | Default | Meaning | |-----|------|---------|--------| | `enabled` | bool | `true` | Enable local caching for this target. | | `remote` | bool | `true` | Enable remote caching for this target. | | `history` | int | `1` | Number of past revisions to retain in the local cache (minimum `1`). | A bare `True` sets both `enabled` and `remote` to `true`. A bare `False` disables both. `oci.push`, `oci.load`, and `oci.runner` have no `cache` field — as actions with an external side effect (or, for `oci.runner`, an answer scoped to this machine's own daemon), they're never remotely cached. --- ## Query The query plugin selects targets dynamically. Instead of listing targets by address, you write a predicate — a boolean expression over labels, package paths, and addresses — and heph resolves the matching set for you. New targets that satisfy the predicate are picked up automatically as the repo grows; targets that no longer match drop out without anyone editing a list. A query expands into a **group** of matching targets, so you can pass it anywhere a list of targets is accepted. See [group](/docs/plugins/group) for how groups execute. ## Provider A **provider** is a plugin that contributes targets to the graph without registering its own execution driver — the targets it emits run on an existing driver. The query plugin is a provider: it emits group targets that run on the `group` driver. It registers **no** driver of its own. ## Enabling it Built-in and always-on; no registration required in `.hephconfig`. ## Query language Queries are written in a small expression language. ### Patterns | Pattern | Matches | |---------|---------| | `//pkg` | exactly package `//pkg` | | `//pkg/...` | `//pkg` and every package beneath it | | `//pkg:name` | the single target `//pkg:name` | | `.` | the current package | | `./sub` | the sub-package relative to the current directory | | `../sibling` | a sibling package | A bare `//foo` (no `:`) is a **package** matcher. To match a specific target address, include the colon: `//foo:bar`. ### Functions | Function | Matches | |----------|---------| | `label(x)` | targets that carry label `x` | | `driver(x)` | targets run by driver `x` — whole name, case-sensitive (e.g. `driver(credential)`) | | `addr(//pkg:name)` | the target at that address | | `package(//pkg)` | targets in exactly that package | | `package_prefix(//pkg)` | targets in `//pkg` or any sub-package | | `tree_output(pkg)` | targets whose codegen tree writes into `pkg` | A label matches `[A-Za-z0-9_-]+` — ASCII letters, digits, `-`, and `_`. This excludes `//`, `:`, `@` (address syntax), the query operators (`&& || ! ( )`), and whitespace, so a label is never ambiguous with the syntax around it. `label(x)` rejects an argument outside that grammar as a parse error, rather than compiling into a predicate that quietly matches nothing. ### Operators | Operator | Meaning | |----------|---------| | `a && b` | both must match | | `a \|\| b` | either must match | | `!a` | negate | | `(…)` | grouping | Precedence: `!` binds tightest, then `&&`, then `||`. Use parentheses to override. ### Grammar ```text or := and ( "||" and )* and := not ( "&&" not )* not := "!" not | atom atom := "(" or ")" | func | pattern ``` ### Examples ```bash # All lint targets under //cmd, excluding the vendor subtree heph run -e '//cmd/... && label(lint) && !//cmd/vendor/...' # Every target in either //a or //b heph query -e '//a/... || //b/...' # Targets generating code for the gen package heph run -e 'tree_output(gen)' # Combine group membership with a codegen scope heph run -e '(//a/... || //b/...) && tree_output(gen)' # Every credential target heph query -e 'driver(credential)' ``` ## Faster queries `label()`, `driver()` and `tree_output()` are decided from what providers list, without resolving each target they rule out. `heph query` then resolves every match and prints only targets that exist. Pass `--candidates` to skip that last step. It is faster, but may print a listed target that does not exist. ```bash heph query --candidates -e '//... && label(lint)' ``` ## CLI: `-e` / `--expr` `heph run` and `heph query` accept `-e ` (long form `--expr`) as an alternative to a positional target address. The flag and the positional arguments are mutually exclusive — use one or the other. ```bash heph run //pkg:name # positional address (unchanged) heph run lint //pkg/... # label + package matcher (unchanged) heph run -e '//pkg/... && label(ci)' # query expression heph query -e '//... && !//vendor/...' ``` The positional `