.env files supply
values, per-environment overlay files specialize them, and a committed .env.schema
declares which variables exist, their types, and which are secret. The environment is
resolved when a package loads and validated before anything builds — a missing
DATABASE_URL fails at analysis time with the file that should have set it, not as a
runtime crash inside a running service.
The files
KEY=value dotenv files, so humans and other tooling can
edit them. .env.schema is written in the build language — that is what makes
conditionals, load(), and schema inheritance work.
Both a workspace-root set and a per-package set are read: a package’s env files live
next to its BUILD.rbs.
Declaring variables
Theenv module is predeclared in every .rbs file — like glob — so no load() is
needed. A schema declares each variable with a type and constraints:
env.var() accepts:
Composable schemas
env.schema() returns a value, so a shared standard can live in one module and be
inherited everywhere — one place defines what every Go service in the repo looks like:
extends takes a list, so a package can compose several standards. Declaring the same
variable in two schemas is an error unless the extending schema restates it explicitly
— overriding is allowed, shadowing by accident is not.
Declaring environments
rbs hardcodes no environment names —dev, staging, prod are not built in. The
workspace-root .env.schema declares whatever environments you want:
-e on any command, or the RBS_ENV variable:
-e name is a hard error that lists the valid names — never a silent
fallback to an empty environment.
Precedence
For a file-sourced variable, lowest to highest:env.var(default = ...)in the schema- workspace-root
.env - workspace-root
.env.<name>(anextendschain applies the base environment’s file first) - package
.env - package
.env.<name> .env.local— workspace root, then package- a literal
override = {...}on the target
rbs env explain shows you exactly which layer won for any variable.
Using the environment in targets
env = ... can be set on any rule. The recommended shape is an allow-list of
exactly the variables the target reads:
Auto-injection
A package that declares its own.env.schema gives its targets the package
environment automatically — no env attribute needed:
env attribute, and only to actions that actually execute something. An
explicit allow-list always wins — and narrows the target’s cache key, so prefer it as a
package matures.
Value sources
A variable’s value normally comes from the files. The schema can point it somewhere else instead:env.infra(ref)resolves from applied infrastructure state, so your service reads the same connection string your infra rules created.env.host()is the only way a host machine’s variable reaches a build — it is explicit, declared, and hashed into the cache key, so differing host values produce different keys rather than false cache hits.env.command([...])runs a command and uses its output.env.managed()declares a value the platform delivers to a branch node, so a developer can clone a branch and build without any setup.env.define_source(name, impl)registers a custom resolver (a secret manager, for example), used assource = env.source("vault", key = "prod/db").
source may not also be set in an env file — two
sources of truth for one value is the bug this prevents, so it is an error. The one
exception is env.managed(): it is a platform-supplied default, and any file layer
that sets the variable wins.
Secrets
Marking a variablesecret = True changes how it is handled everywhere:
- The real value still reaches the process that needs it — redaction applies where values are displayed or persisted, never where they execute.
- The value is hashed into the cache key, so rotating a secret correctly invalidates the actions that consume it — but what the cache persists is a digest, never the plaintext.
rbs env print,diff, andexplainshow a digest.--revealshows the value and is refused when stdout is not a terminal, because revealing into a pipe or a log file is how secrets end up committed.- A secret may not carry a
default— a committed default defeats the point, and it is an analysis-time error. - Remote execution refuses to run an action that consumes secret variables, naming them, because the command environment would cross the wire. The build stops rather than leaking; run secret-consuming targets locally.
The rbs env CLI
1
Validate: rbs env check
Validates every package that declares a schema — missing required variables, type and
enum violations — and exits non-zero on failure. This is the gate to run in CI before
building.
2
Inspect: rbs env print / environments
print shows the fully resolved environment, secrets redacted:environments lists every declared environment, marks the selected one, and reports
how many declared variables have no value in each — the per-environment difference,
and what breaks a deploy:3
Debug: rbs env explain / diff
Layered dotenv precedence is guesswork without tooling.
explain shows which
file, line, or source won for a variable — and what it shadowed:diff shows what actually differs between two environments:4
Bootstrap: rbs env template
Generates a
.env.example from the schema — every declared variable with its type,
docs, and defaults — on stdout:How resolution fits the build
The environment for a package is resolved when the package loads, before its build file runs — soenv.vars() and env.all() see final values, and a missing required
variable fails immediately rather than inside a running service. Validation happens at
analysis time, before any action executes.
Every env file that contributes is a declared, content-hashed input: changing
.env.prod invalidates exactly the actions that read a variable it defines. A
.env.local override changes the cache key rather than breaking reproducibility — it
can never produce a false cache hit against a teammate’s or CI’s result.