Secrets
Builds need secrets — a deploy token, a registry password, an API key. Zuke
treats a secret as a parameter with
two extra guarantees: it can be
sourced from a secret manager at run time with
.from(source), so the value never has to be pasted into a shell,
a .env file, or CI YAML; and its resolved value is
redacted from all of Zuke's output, so a secret can't leak
into a log, a summary, or an error message, on any platform.
import { Build, execSecret, parameter, run, target } from "jsr:@zuke/core";
class Deploy extends Build {
token = parameter("Deploy token")
.secret()
.from(
execSecret((s) => s.command("op").arg("read", "op://vault/deploy/token")),
);
deploy = target().executes(async () => {
await fetch("https://api.example.com/deploy", {
headers: { authorization: `Bearer ${this.token.value}` },
});
});
}
await run(Deploy);
A secret is still an ordinary parameter: it has a flag and an environment
variable, and it can be .required(), .number(), and
so on. .secret() adds redaction; .from(...) adds a
run-time provider.
Marking a value secret
.secret() marks a parameter sensitive. From then on Zuke masks
its resolved value wherever it prints:
- Every line the executor writes through its reporter — banners, per-target status, the build summary, and error messages (including a target that throws with the secret in its message, or a parse error on a malformed value).
- Under GitHub Actions, Zuke additionally emits
::add-mask::<value>so the runner masks the value in its own log stream.
token = parameter("Deploy token").secret().required();
// --token …, or the TOKEN env var; redacted everywhere Zuke prints it.
The mask is the literal text [redacted]. Matching is a plain
substring replace, never a regular expression, so a secret containing
regex-significant characters is masked literally and there's no injection
surface.
What redaction covers — and does not
Redaction is guaranteed for everything Zuke itself prints. It is applied by wrapping the executor's reporter, so it doesn't depend on running under a CI host that happens to mask logs.
It does not reach inside a subprocess a target spawns: if a command a target runs echoes the secret to its own stdout/stderr, that output streams straight to the terminal without passing through Zuke's reporter. Two mitigations apply:
- Under GitHub Actions, the
::add-mask::directive Zuke emits makes the runner mask the value in subprocess output too. - As a rule, don't pass secrets as command-line arguments (they show up in process listings) or echo them; pass them through the environment or stdin.
Redaction does cover the places a rendered command line escapes the reporter.
The run's redactor is installed as an ambient binding for
the plan's async subtree — the same way the cancellation signal is — and the
$ shell masks secrets where it
renders a line. So a secret passed as an argv token is masked in a
--dry-run echo, a failure's error message, and a spawned
service's recorded line, whoever prints them. The argv handed to the
operating system is untouched.
Multi-line secrets — a PEM private key is the common one
— are masked line by line as well as whole. Both sinks
read a line at a time: Zuke's redactor rewrites one reporter line, and the
Actions runner reads an ::add-mask:: directive to the end of
its line. A key registered only as one string would therefore
match no line of itself, and everything after the header would print in
the clear. Each line is registered as its own pattern, and one directive
is emitted per line. Lines under eight characters are skipped — a
two-character fragment identifies nothing and would mask ordinary text
wherever it appeared — while the value as a whole stays registered
regardless.
Secret sources
A source resolves a secret's value on demand. Attach one with
.from(source). Zuke ships two dependency-free source builders;
both shell out to a tool you already trust rather than bundling a provider SDK.
execSecret — run a command, take its stdout
For any secret manager with a CLI: 1Password (op), HashiCorp Vault
(vault), Google Secret Manager (gcloud), Doppler, AWS
(aws), and so on.
import { execSecret } from "jsr:@zuke/core";
// 1Password
.from(execSecret((s) => s.command("op").arg("read", "op://vault/deploy/token")))
// HashiCorp Vault
.from(execSecret((s) =>
s.command("vault").arg("kv", "get", "-field=token", "secret/ci/deploy")
))
// Google Secret Manager
.from(execSecret((s) =>
s.command("gcloud")
.arg("secrets", "versions", "access", "latest", "--secret=deploy-token")
))
The command runs quietly (its output is captured, never streamed to the
terminal), and its standard output becomes the value. A non-zero exit fails the
build with a SecretError naming the command and its exit code.
ExecSecretSettings | Effect |
|---|---|
.command(binary) | the executable to run (required) |
.arg(...values) | append one or more arguments (repeatable) |
.env(record) | extra environment variables for the process |
.cwd(path) | working directory for the process |
.trim(on = true) | trim surrounding whitespace from stdout (default on) |
fileSecret — read a file
For a secret mounted into the environment as a file — a Kubernetes/Docker secret, or a CI-provided credential file.
import { fileSecret } from "jsr:@zuke/core";
.from(fileSecret((s) => s.path("/run/secrets/registry_password"))) FileSecretSettings | Effect |
|---|---|
.path(path) | the file to read (required) |
.trim(on = true) | trim surrounding whitespace (default on) |
A missing or unreadable file fails the build with a SecretError naming the path.
Resolution precedence
A source is a fallback provider, not an override. Zuke resolves a parameter from, in order:
- a command-line flag (
--token …) - the environment variable (
TOKEN) - the
.from(...)source - the declared default
So the source is consulted only when neither a flag nor an environment variable supplied a value. This is what makes the same build portable: in CI, a token is usually injected as an environment variable (and the source is never invoked); on a developer's machine, the source pulls it from their secret manager. Neither path requires a code change.
Errors
A source that fails is reported as a parameter error, before any target runs — the same as a missing required value:
Invalid or missing parameters:
--token: execSecret command "op" exited with code 1: [ERROR] not signed in SecretError is exported for handling in programmatic use. The value
returned by a source is registered for redaction before it is
parsed, so even a parse error on a malformed secret (e.g. a
.secret().number() whose source returns a non-number) is masked
rather than echoed.
The failure message deliberately includes the source's own output — a
command's exit code and trimmed stderr, or the file-read error — so a
misconfigured source is debuggable. A source whose value never resolved has
nothing registered for redaction, so this one message is not
masked: choose a source command that reports failures on stderr
without echoing the secret itself (secret managers such as op,
vault, and gcloud do). This is the same boundary as
any subprocess a target spawns.
A complete example
import {
Build,
execSecret,
fileSecret,
parameter,
run,
target,
} from "jsr:@zuke/core";
class Release extends Build {
// From 1Password locally; from the REGISTRY_TOKEN env var in CI.
registryToken = parameter("Container registry token")
.secret()
.from(
execSecret((s) => s.command("op").arg("read", "op://ci/registry/token")),
);
// A cluster token mounted into the deploy job as a file.
clusterToken = parameter("Cluster token")
.secret()
.from(fileSecret((s) => s.path("/run/secrets/cluster_token")));
publish = target().executes(async () => {
// Prefer the environment/headers over argv, so the secret never appears in
// a process list. Anything printed here that contains the value is masked.
await fetch("https://registry.example.com/publish", {
method: "POST",
headers: { authorization: `Bearer ${this.registryToken.value}` },
});
});
}
await run(Release); See Parameters for the typed-input model secrets build on, and Installing tools for provisioning the CLIs a source shells out to.