From e12adb9b31dce737445e68c4a773d683aa715c69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 15:46:58 +0000 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20document=20deferred=20values=20(${r?= =?UTF-8?q?ead://=E2=80=A6}=20and=20${src://=E2=80=A6})?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hephbuild/heph#458 added ${src://pkg:name} — the sandbox path of another target's output — alongside the existing ${read://pkg:name} contents form. Neither was documented on the site; add a Deferred values concept page and cross-link it from the exec/bash driver docs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JVc3xVtpWa7kPVKKweR5Dc --- website/docs/concepts/deferred-values.md | 111 +++++++++++++++++++++++ website/docs/plugins/exec.md | 9 +- website/sidebars.ts | 1 + 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 website/docs/concepts/deferred-values.md diff --git a/website/docs/concepts/deferred-values.md b/website/docs/concepts/deferred-values.md new file mode 100644 index 0000000..245f79a --- /dev/null +++ b/website/docs/concepts/deferred-values.md @@ -0,0 +1,111 @@ +--- +title: "Deferred values" +sidebar_position: 10 +description: Reference another target's output inside a driver option, resolved once that target has run. +--- + +# 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.txt"], + 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. The `run` option on the +[`exec` and `bash`](/docs/plugins/exec) drivers does. + +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 | + +## 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. diff --git a/website/docs/plugins/exec.md b/website/docs/plugins/exec.md index 54afa8b..ae1ecde 100644 --- a/website/docs/plugins/exec.md +++ b/website/docs/plugins/exec.md @@ -79,7 +79,7 @@ The following target config keys are available: | Key | Meaning | |--------------------|------------------------------------------------------| -| `run` | List of commands to execute. | +| `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. | @@ -260,6 +260,13 @@ 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` 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). + ## Interactive debugging with `--shell` When a target fails, drop into its sandbox with the exact inputs, tools, and diff --git a/website/sidebars.ts b/website/sidebars.ts index 0ab6361..5fd43ad 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -20,6 +20,7 @@ const sidebars: SidebarsConfig = { items: [ 'concepts/targets', 'concepts/dependencies', + 'concepts/deferred-values', 'concepts/reproducibility', 'concepts/caching', 'concepts/scratch', From 01be72675697a1aae914006e1241ecd8b7220b8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 15:50:26 +0000 Subject: [PATCH 2/3] docs(deferred-values): fix copy-version example output path cp's destination argument didn't match the declared out path. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JVc3xVtpWa7kPVKKweR5Dc --- website/docs/concepts/deferred-values.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/concepts/deferred-values.md b/website/docs/concepts/deferred-values.md index 245f79a..051835d 100644 --- a/website/docs/concepts/deferred-values.md +++ b/website/docs/concepts/deferred-values.md @@ -57,7 +57,7 @@ an argument under the `exec` driver, which runs with no shell at all: target( name = "copy-version", driver = "exec", - run = ["cp", "${src://:version}", "current-version.txt"], + run = ["cp", "${src://:version}", "current-version.copy"], out = "current-version.copy", ) ``` From cebb810c14e2228732c7c21dc7f5c0098fdfbf01 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 15:54:27 +0000 Subject: [PATCH 3/3] docs: document credentials as targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hephbuild/heph#455 introduced the credential driver, hephbuild/heph#456 the underlying deferred-values mechanism it and #458's ${src://…} both build on. Only the deferred-values half was covered so far; add a Credentials concept page covering the driver, source chain, when vocabulary, presentation shapes and presets, the CLI, and redaction — sourced from heph's docs/CREDENTIALS.md and the plugincredential builtins on master. Cross-link it from the deferred-values page, which already noted a credential's present block as a consumer. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JVc3xVtpWa7kPVKKweR5Dc --- website/docs/concepts/credentials.md | 257 +++++++++++++++++++++++ website/docs/concepts/deferred-values.md | 6 +- website/sidebars.ts | 1 + 3 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 website/docs/concepts/credentials.md diff --git a/website/docs/concepts/credentials.md b/website/docs/concepts/credentials.md new file mode 100644 index 0000000..c4e9b29 --- /dev/null +++ b/website/docs/concepts/credentials.md @@ -0,0 +1,257 @@ +--- +title: "Credentials" +sidebar_position: 11 +description: Declare an identity a build needs as a target, and let heph obtain it from whatever environment the build is running in. +--- + +# Credentials + +A **credential** is a target — `driver = "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 = "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 = "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`. + +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]` | Runs whichever login commands their probes found stale. | +| `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. diff --git a/website/docs/concepts/deferred-values.md b/website/docs/concepts/deferred-values.md index 051835d..0721577 100644 --- a/website/docs/concepts/deferred-values.md +++ b/website/docs/concepts/deferred-values.md @@ -70,8 +70,9 @@ 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. The `run` option on the -[`exec` and `bash`](/docs/plugins/exec) drivers does. +value if its documentation says so. Two do today: the `run` option on the +[`exec` and `bash`](/docs/plugins/exec) drivers, and the `env`/`files` values +in a [credential](/docs/concepts/credentials)'s `present` block. A reference is always rejected in an option that decides *which targets exist* or *what the build graph looks like* — `deps`, `tools`, `runner`, `out`, @@ -94,6 +95,7 @@ and `./name` forms an address elsewhere accepts stay bash here too. | 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 diff --git a/website/sidebars.ts b/website/sidebars.ts index 5fd43ad..a7d71dc 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -24,6 +24,7 @@ const sidebars: SidebarsConfig = { 'concepts/reproducibility', 'concepts/caching', 'concepts/scratch', + 'concepts/credentials', 'concepts/sandbox', 'concepts/codegen', 'concepts/approval',