Your callee's environment shouldn't haunt your caller
A task loads staging secrets, calls a helper, and three steps later your prod deploy is running with a leaked DATABASE_URL. Scoped environments fix the class of bug: a callee's dotenv/env are its batteries, the caller can override them, and they unwind on return.
Here's a bug that costs an afternoon. A pipeline has a task that loads staging
credentials from a dotenv file to run a smoke test. Two steps later, a different
task deploys to production. It works on your laptop. In CI it occasionally deploys to
production using the staging DATABASE_URL, because the env the smoke-test task
loaded never went away — it leaked downstream and won a precedence fight nobody knew
was happening.
Environment variables that outlive the step that set them are a whole category of heisenbug: order-dependent, environment-dependent, and invisible in a diff.
The mental model: env should scope like variables
Application variables in OrchStep already scope. A task's vars: are local; a called
task gets its own; they don't bleed back. Environment variables now work the same
way, a model we call scoped environments:
- Batteries included. A called task's own
env:/dotenv:load when it runs, so a self-contained task carries what it needs. - Inversion of control. If the caller passes an env var down, it overrides the callee's own — the caller stays in charge.
- Unwind on return. When the task returns, its env additions are removed. The caller's later steps and any sibling tasks never see them.
Read the diagram left to right. main sets AVAR from its own dotenv. It calls
child_b, which pushes a scope: its b.env brings BVAR, its env: brings TVAR,
and an INJECTED value the caller passed wins over the callee's own. When child_b
returns, the scope pops — BVAR and TVAR are gone. The sibling child_d that runs
next never saw them. AVAR, which belonged to main, is untouched the whole time.
Before and after
The leak, concretely:
tasks:
main:
steps:
- { task: smoke_test } # loads staging dotenv -> DATABASE_URL
- { task: deploy_prod } # OOPS: used to inherit staging DATABASE_URLOld behavior: smoke_test's DATABASE_URL survived into deploy_prod. New behavior:
it unwinds when smoke_test returns, and deploy_prod runs with exactly its own
environment. The fix isn't a flag you remember to set — it's the default.
And the batteries-included half means a reusable task just works:
tasks:
smoke_test:
dotenv: [staging.env] # its own batteries
env:
TIMEOUT: "30s"
steps:
- { func: shell, do: 'run-smoke --db "$DATABASE_URL"' }smoke_test brings its own staging.env and TIMEOUT. A caller that wants to point
it at a different database overrides on the call; a caller that doesn't gets the
sensible default. Either way, nothing escapes the call.
Why this matters in CI specifically
Local runs hide the bug because your shell already has the "right" values exported, so a leak lands on top of something plausible. CI starts clean, so a leaked var is the only value present — which is exactly when it does damage. Scoped environments make local and CI behave the same: each task's environment is determined by that task plus what its caller explicitly hands down, not by whatever ran earlier in the process.
That determinism is the same reason OrchStep no longer auto-loads a stray .env from
the working directory — loading is always from a file a workflow or its project config
declares, which is a trust boundary as much as a correctness one.
Precedence, in one line
When the same key is set in more than one place, highest wins:
caller's call-step env: > task env: > task dotenv: > workflow env:/dotenv: > OS envCaller override beats the callee's own value; the callee's own value beats what it inherited; the OS env is always the floor. Predictable, and it unwinds back down the stack as calls return.
Takeaways
- Env vars that outlive their step are a real bug class, worst in clean CI.
- OrchStep scopes environment like it scopes variables: batteries included, caller overrides, unwind on return.
- It's the default — no flag to remember, no leak to debug.
- Pair it with module configured instances, where a module's whole bundled context scopes the same way.
curl -fsSL https://orchstep.dev/install.sh | sh