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.

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.

ExecSecretSettingsEffect
.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")))
FileSecretSettingsEffect
.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:

  1. a command-line flag (--token …)
  2. the environment variable (TOKEN)
  3. the .from(...) source
  4. 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.