Configuration
Overview
Setting up a dev environment usually means running a handful of tools in
the right order, each building on what the last one set up — get into the
right OS, install native toolchains, create a Python venv, fetch source
repos. denver makes that sequence declarative: maintain your environment in
a denver.toml, and denver will run it.
This page is the complete reference for that file and for the machinery behind it — every top-level key, every generic stage key, and how a config is resolved. If you worked through denver in 30 minutes, you have already met most of it in context; this is where the remaining keys and the exact rules live.
Terms used here (environment, stage, step, provider, resolved config, …) are defined once in the Glossary; each provider’s own config keys are documented on its own page under Providers.
denver.yml vs. denver.toml
denver.yml/denver.yaml is denver’s default format: PyYAML is a required
dependency, so it always works, down to denver’s >=3.9 floor. denver.toml
is supported too, but only where tomllib is importable (stdlib only from
Python 3.11) — on an older interpreter it isn’t there, and denver says so
with a clear error instead of silently misreading it. When a directory
holds more than one, denver picks in this order: denver.yml, then
denver.yaml, then denver.toml. Every example on this page is TOML, but
the schema is the same either way — only the syntax differs.
Core model
An environment is a denver.toml; it declares an ordered list of stages
under stages:; each stage names a provider: type (uv, conan,
zephyr, docker, download, git, nix, custom) plus some provider-specific keys in a
top-level section of the stage’s own name.
A provider is a generic, reusable engine — all project specifics come from
the denver.toml itself, never from the provider’s own code. Most
providers build a piece of the environment in place (create a venv,
install tools, update a workspace); one, docker, is different: instead
of building anything itself, it relocates the rest of the pipeline into a
container (see “Wrapper / relocation” below).
A stage id is only a label. The section must always declare its
provider: explicitly, even when the id happens to match a provider name —
no type is ever guessed from an id. That is what lets one environment run
two uv stages (say uv and uv-zephyr, targeting the same or
different venvs) at different points of the pipeline.
The denver.toml schema
Top-level keys
The following keys are recognised at the top level; everything else at that
level must be a stage id declared in stages: (anything else is an error —
see “Fail loud” in philosophy.md).
version— thedenver.tomlschema version this file is written against. The only value this denver understands is"1.0"; any other value is rejected with a clear message rather than silently misinterpreted. Optional, but worth setting: it exists so a future, incompatible schema change can’t quietly do the wrong thing to an old file. Compared as a string, so TOML’s own numeric parsing (a bare1.0becoming a float) doesn’t matter.denver-version— the minimum denver tool version this file needs, e.g.denver-version = ">=1.0.4". See “Requiring a denver version” below.import— a list of environments (or config files) whose configuration is inherited as a base, before this file’s own content is applied on top. See “Layering” below.stages— the ordered list of stage ids to run. This is the pipeline; order is significant, and each id must have a matching top-level section declaring itsprovider:.command— the default command to run once the environment is built, when none is given on the command line. If unset, denver falls back to thedocker:section’sdefault-cmd:, then$SHELL, thenbash. A command passed on the CLI after--always wins over this.runnable— set tofalseto mark this file as a base meant only to be imported, never started directly;denver run <that env>then fails with an explanatory message. Deliberately not inherited: an env importing arunnable: falsebase is itself runnable unless it says otherwise.env— a mapping of environment variables to set for the whole environment (values go through${...}interpolation). Applied once, right after theenvhook, before any stage runs. Entries are set one at a time, in the order written, so a later entry’s${...}can reference an earlier one from the same map:env: PROJECT_ROOT: "${DENVER_ENV_DIR}/.." PATH: "${PROJECT_ROOT}/tools:${PATH}"
hooks— scripts sourced at fixed points in the pipeline. See “Hooks” below.extensions— own, project-localProvidersubclasses to register alongside the built-ins (uv,conan,zephyr,docker,download,git,nix,custom), no denver fork required. See “Extension providers” below.denver-custom-args— command-line flags of this environment’s own, each one forwarded to argparse’sadd_argument. See “Environment-specific CLI arguments” below.download-auth— a list of{ host, username, password, headers }entries, the credentials thedownloadprovider sends per host. Top-level rather than per stage: a token belongs to a server, so every stage and every package fetching from that server is covered by the one entry. See “Authenticated downloads” in the download provider’s page.
Requiring a denver version
version: pins the schema; denver-version: pins the tool. Those are
two different questions, and only the second one has a good answer for the
common case: a purely additive change — a new provider key, a new flag —
never bumps the schema version, but a denver.toml relying on it still needs
a denver new enough to have it. Without denver-version:, running such a
file on an older denver fails somewhere deep inside a stage, or quietly does
something subtly different; with it, denver says so up front:
version = "1.0"
denver-version = ">=1.0.4" # directly below version:, always
The value is a version requirement, quoted (an unquoted
>=…is not a valid TOML value at all). A bare version means “at least this one”, sodenver-version = "1.0.4"and">=1.0.4"are the same requirement.>=,>,<=,<,==and!=are all understood, and several comma-separated specifiers are ANDed:">=1.0.4, <2".Requirements are checked against the merged config, like every other top-level key: an env inherits its base’s requirement through
import:. Two stacked layers stating a different requirement is the usual conflicting-strings error — prefix the overriding one with!to mean it (see “Layering” below).The version denver compares against is the one it is really running: from the checkout’s git tags when denver runs out of a checkout (the plain
src/denver.pyscript and an editable install, whose packaging metadata is frozen at install time and would go stale), otherwise from the installed distribution’s metadata.denver --versionprints the same value. A checkout whose tags haven’t caught up with what the tree contains reports againstDEV_VERSIONinstead (1.1.0-17-gabc1234), so running from source works at every commit rather than only after a release — see “Releasing” indevelopment.md. In the rare case where neither can answer (a source copy with no git history and no install at all), the requirement is reported as unverifiable — a warning, not a failure.A commit past a tag counts as newer than that tag (
1.0.3-2-gabc1234satisfies">=1.0.3"), and a pre-release counts as older than its release (1.1.0.dev3+g1234567does not satisfy">=1.1.0").
A denver too old to know the key at all (before it was introduced) rejects
the file with unknown top-level key(s) denver-version — different wording,
same conclusion: that denver is too old for this denver.toml.
Generic stage keys
These keys may appear in any stage’s section, whatever its provider:
provider(required) — which provider engine runs this stage:uv,conan,zephyr,docker,download,git,nixorcustom.description— free text (a list of strings) with whatever notes are useful to whoever reads the config: what this stage is for, why it’s configured this way. denver never reads it; it is only surfaced in--show-config.disabled—trueopts this stage out of the normal pipeline, as if it had been--skipped, without deleting its configuration. Must be a real boolean.depends-on— a list of other stage ids. If any of them is itself skipped this run — for any reason:disabled: true,--until/--skip, askip-on-success:/skip-on-failure:check, or its owndepends-on:cascade — this stage is skipped too, reported asskipped (depends-on '<id>'). This cascades transitively (A depends on B depends on C: C skipped skips both B and A). Every named id must be declared earlier than this stage in the top-levelstages:list — a forward reference, a self-reference, or an id not declared at all is a config error at startup, not a runtime surprise.skip-on-success/skip-on-failure— each a list of scripts; when every script in the list exits0(skip-on-success) or exits exactly1(skip-on-failure), this stage’s whole setup — itspre-<stage>hook included — is skipped for this run, as if it had been--skipped for this one invocation. The two lists are independent: give either, both (each checked on its own group; either group being fully satisfied skips the stage), or neither (no skip check at all).--forcebypasses both.scripts— the generic one-shot mechanism:scripts: <name>: [...]declares scripts run bydenver run <env> --scripts <name>instead of the normal pipeline. See “Hooks and scripts” below.env— a mapping of environment variables to set once this stage’s own setup is done, as{ VAR = "value" }(values go through${...}interpolation, so a value can read what this stage’s own setup() just exported, or an earlier entry of this same map, entries being set in the order written). The per-stage counterpart of the top-levelenv:— the same key, one level down, in scope for one stage rather than the whole environment.env-prepend/env-append— what a stage contributes to an existing variable, as{ VAR = "value" }: each value is resolved like any other denver path (against the env dir, then imported base envs — see “Variable interpolation” below) and glued directly ontoctx.env[VAR]– no separator inserted – in front of its current value forenv-prepend:(the common case: a stage exists to provide a specific version of a tool, and appending would let whatever the OS already has onPATHwin instead), behind it forenv-append:(a fallbackMANPATH, a low-priorityCMAKE_PREFIX_PATH). If the result needs a separator (it usually does, for a:-joined variable likePATH), write it into the value yourself: a trailing:forenv-prepend:("tools/bin:"), a leading:forenv-append:(":share/man") – denver never guesses one.
All three apply after this stage’s own setup() (so a value can reference what
that just exported), –fast/–dry-run included – this is activation, not a
build step – and are skipped outright for a stage disabled:/
skip-on-success:/skip-on-failure:/depends-on: skips this run, the same
as everything else about that stage. Every provider gets them for free; download’s own
per-package env-prepend:/env-append: (see download)
is the same glue-with-no-separator mechanism, just scoped to one package of
a stage that has several, each needing its own directory named — not
replaced by this stage-wide one.
Every other key in a stage’s section must be one the stage’s own provider
recognises — see that provider’s page under providers/. An
unrecognised key is an error, not silently ignored.
Variable interpolation
Any string value in a denver.toml may contain ${VAR} or
${VAR:-fallback}, expanded against the environment denver is building
(so a variable exported by an earlier hook or stage is visible to a later
one). An unset variable with no fallback expands to the empty string.
Alongside the real environment, denver seeds a few built-ins of its own, which always reflect the current run even if a stale variable of the same name is already exported:
DENVER_SRC_DIR— where denver’s own code lives.DENVER_ENV_DIR— this environment’s directory (the one holding itsdenver.toml). Also the variable denver reads as the<env>CLI argument’s fallback when it’s omitted.DENVER_ENV_NAME— that directory’s name.DENVER_ENV_WORKDIR— denver’s own working area for this environment (<env dir>/.denver/<config file stem>/by default): venv, install trees, fingerprints, logs,performance.jsonl. Per environment and never shared by default — overridable directly (DENVER_ENV_WORKDIR) — see “Where an environment’s state lives” in the top-levelREADME.md.DENVER_CACHE_DIR— the shared cache root (~/.cache/denverby default), offered for an env to point a tool’s own download cache at, e.g.env: {CONAN_HOME: "${DENVER_CACHE_DIR}/conan2"}. Safe to share across envs and checkouts because the tools owning such caches lock them themselves; denver writes nothing there itself.SHELL_PROMPT_PREFIX—(<env>): the text marking a shell as running inside this environment, so a prompt reads(raspberry-pico) dev@host:~/ws$. fish reads this natively from fish 4.8.0 onwards (see below).
These are exported into the environment too, so scripts, compose files and
the final command can read them as ordinary variables. DENVER_ENV_DIR —
the one variable here denver reads rather than computes — is documented
alongside them in the top-level
README.md.
The prompt marker
denver marks the shell it starts by writing the prompt variables the shells themselves define — it contributes to them, it does not own them:
Variable |
Shell |
denver writes |
|---|---|---|
|
bash |
its snippet, appended after whatever was there |
|
zsh |
|
|
fish ≥ 4.8.0 |
|
PS1 is deliberately not set. An interactive bash re-reads its rc files
after denver execs it and assigns PS1 outright, so anything denver put
there would be discarded before the user ever saw it. PROMPT_COMMAND is
bash’s answer to exactly that — it runs after those rc files, before every
prompt — so that is where the marker goes for bash instead.
The PROMPT_COMMAND snippet is idempotent: it re-applies the prefix only if
it isn’t already present (bash would otherwise grow PS1 by one copy per
prompt), and a wrapper provider re-invoking denver inside a container never
appends a second copy.
One ordering subtlety, handled in Context._prefix_prompt: in zsh PROMPT
and PS1 are the same parameter, so an inherited PS1 would win over
denver’s PROMPT if it happened to come later in environ. PROMPT is
therefore always written last, so zsh keeps the zsh-syntax value.
Config resolution
Loading a denver.toml goes through a fixed sequence, and understanding it
explains a lot of otherwise-surprising behavior:
import:chain. Eachimport:entry points at another env (or directly at a config file); that file is loaded the same way, recursively, then merged in as the base before the importing file’s own content is applied on top. A circularimport:chain is an error.Merge rules. Mappings merge key by key, recursively. A list is appended to, not replaced — a lower layer’s entries plus this layer’s own, in that order, so a derived env only needs to list what it adds. Prefix one entry with
!to drop everything from lower layers, or use a bare<overwrite>entry to do the same as a pure marker (it’s removed from the merged list, unlike!foowhich keepsfoo). A string works the same way: two layers disagreeing on the same string key (e.g.uv.python = "3.11"vs"3.12") is treated as a likely mistake and is a hard error, unless the overriding value is explicitly prefixed with!(e.g.python = "!3.12") to say “yes, replace it on purpose.” For both,!only means anything when there’s an actual lower-layer value to override — on a brand new key it’s an ordinary character.Section-level
import:(“stacking”) lets one stage’s section pull its content from another env’s section, instead of (or in addition to) inheriting the whole file — see “Layering” below.Every provider’s defaults are filled in centrally, before any stage actually runs — never guessed inside the stage itself at run time. The practical effect:
--show-configalways shows exactly the config a real run would use, values already defaulted and all. If a value looks wrong in--show-config, it will be exactly as wrong in the real run — there’s no separate “what setup() actually decides” to go check.Unknown keys are an error, not silently ignored — a typo’d key at the top level, or a key not recognised by a stage’s own provider, dies immediately rather than quietly doing nothing.
--show-config prints the result of this whole sequence, as TOML, and
exits. It is the single best way to understand what an environment really
does, imports included.
Layering
import: is what lets several environments share configuration without
copy-pasting it:
Whole-file
import:inherits another env’s entire stack as a base, e.g. a project-specific env importing a shared base that already declaresstages:,docker:,conan:,uv:. The importing file only needs to state what’s actually different for it.Section-level
import:stacks just one section from another env, e.g. adocker:section pulling in a shared base’sdocker:config without inheriting that base’s entire stack. An entry can point at a specific section by name (path:section) instead of always the same-named one.
A base env that only exists to be imported should set runnable: false, so
starting it directly fails with an explanation instead of half-building
something nobody meant to run.
Command-line overrides
-c KEY.PATH=VALUEoverrides one value in the resolved config, addressed by a dotted path (e.g.-c uv.python=3.13). Any missing parent section along the path is created automatically.KEY.PATH+=VALUEappends to an existing list/string/number instead of replacing it. The value is parsed as JSON when that succeeds, so-c uv.no-index=truesets a real boolean, not the string"true"; anything that isn’t valid JSON on its own (a bare word, an unquoted version string like3.12.3) is kept as a plain string. Repeatable; later-cs win over earlier ones targeting the same path.-cf FILEoverlays a whole TOML file on top of the env’s owndenver.toml, using the exact same merge rules asimport:. Repeatable, applied in the order given.Ordering: every
-cffile is applied first (in order), then every-coverride, last — so-calways has the final word overimport:and-cfalike.
Both compose with --show-config, so you can check what an override
actually does before running it for real.
Command-line environment variables
-e/--env NAME[=VALUE] sets an environment variable for this run — the
“as if you’d exported it in your shell first” counterpart to -c’s config
overrides. Applied to denver’s own process (os.environ), to every stage
and hook (ctx.env), and to the final command; repeatable, later entries
win over earlier ones of the same name, the same as -c. NAME alone (no
=) forwards NAME’s current value out of denver’s own environment,
mirroring docker run -e’s own shorthand.
It always wins over the same name set by the env’s own declarative env:
map (applied before any stage runs, right after the env hook — see
“Hooks and scripts”), the same way -c always wins over import:/-cf.
A wrapper stage that relocates into an actual container (docker) is a
fresh process with its own, separate environment, so -e values are handed
across that boundary explicitly two ways: as --env NAME=VALUE flags on the
re-invoked denver (see “Wrapper / relocation” below), and as docker compose run -e NAME=VALUE flags on the container itself — the same mechanism that
already carries DENVER_IN_CONTAINER/DENVER_RELOCATED across (see
docker.py’s _relocation_env). A custom stage’s launcher: needs
neither: its child simply inherits ctx.env like any other exec.
Environment-specific CLI arguments
denver-custom-args: lets an env declare its own command-line flags (e.g.
--board, --release), each becoming a real argparse flag and a
DENVER_ARG_<NAME> variable — see
“An env’s own flags: denver-custom-args:”
in CLI Arguments for the full reference (key syntax, how the value reaches
${...} interpolation, how it survives a wrapper relocation).
Hooks and scripts
A hook is a script sourced (not just executed) at a fixed point, so its exports become part of the environment everything after it runs in:
env— once, before any stage; its exports apply to the whole environment.pre-<stage>/post-<stage>— around each individual stage.pre-cmd— right before the final command launches.
Each hook name can be declared by any layer in the import: chain
(hooks: <name>:, a list or a single path); a derived env’s own hook never
silently replaces a base env’s hook of the same name — both run, base
first.
Separately, scripts: is a generic, open-ended mechanism for one-shot
actions that are not part of the normal pipeline: any stage’s section can
declare scripts: <name>: [...], and denver run <env> --scripts <name> runs
every stage’s own <name> entries, then exits without doing anything else.
Nothing about <name> is fixed — setup, login and clean are only
conventions, recognised by denver just far enough to get a shorthand flag
each (--setup/--login/--clean, the last of which also removes the
env’s state directory afterwards); any other name works exactly as well and
needs no change to denver. Which is why denver run <env> --scripts, with no
name at all, lists the names that env actually defines: they are unguessable
by design, and scripts: stacks across the whole import: chain, so reading
one file does not answer it either.
$ denver run examples/zephyr-devshell-4.3.1 --scripts
available --scripts names for env 'zephyr-devshell-4.3.1':
setup docker (1 script), zephyr (1 script)
``` This is where one-time host setup belongs: installing
Docker itself, `udev` rules for flashing a board, a registry login — things
that must not run on every start.
## Extension providers
The built-in providers (`uv`, `conan`, `zephyr`, `docker`, `download`, `git`,
`nix`, `custom`) cover
the common cases, but a project may need its own — driving an internal
build tool or deploy step with the same `resolve_defaults`/`setup`/`wrap`
lifecycle a built-in provider gets, rather than squeezing it into a single
`custom: cmd:` line. `extensions: providers: dirs:` registers one without
maintaining a fork of denver:
```toml
[extensions.providers]
dirs = ["my_providers"] # resolved like conan's base-classes: env dir, then imported base envs
Every *.py file directly inside each listed dir — except those whose name
starts with _ — is imported and must define PROVIDER, a
denver_providers.Provider subclass:
# my_providers/acme.py
from denver_providers import Provider
class AcmeProvider(Provider):
name = "acme" # the 'provider: acme' name a stage's section sets
KEYS = ("target",) # denver.toml keys this provider reads
def setup(self, ctx):
cfg = self.config_section(ctx)
ctx.run(["acme-build", "--target", cfg["target"]])
PROVIDER = AcmeProvider
Once registered, any stage can set provider: acme exactly like a built-in
one:
stages = ["build"]
[build]
provider = "acme"
target = "release"
A provider too big for one file puts its shared code in a _-prefixed file
(_helpers.py, or an __init__.py making the dir a package): those are
skipped rather than required to be providers of their own. The dir itself
goes on sys.path, appended — never prepended, so an extension dir cannot
shadow the stdlib or denver’s own modules — which is what makes
import _helpers work from a provider module next to it.
Registration happens once per resolved config, before any stage is
instantiated, so an extension provider is indistinguishable from a built-in
one everywhere else — --show-config, --fast, -c overrides, import:
layering (the dirs: list itself follows the normal list-merge rule, so a
derived env only needs to list the dirs it adds). A name colliding with an
existing provider (built-in or from another extension dir) is a hard error,
as is an unknown key under extensions: itself — the same “fail loud” rule
every other config mistake gets, never a silent override and never a typo
that quietly disables the whole mechanism.
Loading an extension provider runs its module’s code, so an env’s
denver.toml is only ever as trustworthy as the repository it lives in —
the same already-true statement as for hooks:, custom: cmd: and a
sourced source: script, not a new trust boundary.
Fast by default
Every stage that does real work computes a fingerprint (a checksum of its relevant inputs — requirement files, recipe content, workspace state, …) and compares it against the last successful run’s. Nothing changed → nothing expensive re-runs.
--fastskips every stage’s (re-)build step entirely and only activates what a previous full run already built — it never looks at fingerprints, it just assumes there’s nothing to do. Run once without--fastfirst; a stage dies with a clear message if it has nothing to activate yet.--forceis the opposite extreme: bypass every fingerprint and redo the expensive work unconditionally, even if nothing looks changed.--ciswaps in narrower/faster args a stage judges appropriate for a CI runner instead of an interactive host (e.g. a shallow clone).
Being opposites, --fast and --force are mutually exclusive and giving
both is an error. There is no sensible resolution to guess at: a provider
takes its --fast path before it ever looks at --force, so the pair would
otherwise mean “--fast, and the --force you typed did nothing”.
Neither --force nor --ci is ever read from a real environment variable
— both only ever come from the flag itself, so behavior can’t silently
change based on what happens to be exported in the calling shell.
Each provider’s page under providers/ documents exactly what
--fast and --force mean for that provider.
Previewing a run (--dry-run)
--dry-run runs the pipeline for its description instead of its effect:
every stage still runs in order and still resolves its own config, but each
command is printed rather than executed, each file write is reported rather
than performed, and the final command is printed rather than launched.
This works because providers never call subprocess/pathlib for effect
directly — every subprocess goes through Context.run/Context.exec, and
every write through Context.write_text/mkdir/rmtree/… . One flag on
Context is therefore enough to intercept all of it in one place, which is
also what makes the guarantee checkable: a provider reaching around those
helpers is a review-visible mistake, not a silent hole in --dry-run.
Two categories deliberately still execute, because the preview is derived from them:
read-only queries — a
Context.run(..., query=True)call exists so the provider can immediately branch on it (docker image inspect,conan config home,west list, askip-on-success:/skip-on-failure:script’s exit code). Skipping those would leave a dry run with nothing to decide with, and it would stop reflecting what a real run does. They are reported with a?marker, and a missing executable degrades to a failed query instead of aborting.querydefaults tocapture— a caller that also needs the real output back (not just the guarantee it ran) passescapture=Truetoo; a caller likeskip-on-success:/skip-on-failure:that only branches on the exit code passesquery=Truealone, so the script’s own stdout/stderr stay live on the terminal on a real run.sourced scripts —
Context.source()is how denver computes the environment. Without it, every rendered command would show empty${...}values and a PATH missing whatever an earlier stage put there.
Two limits follow from the design rather than from the implementation:
A dry run describes what would happen given the machine’s current state. An already-built env legitimately previews fewer commands than a clean one — that is exactly what a real run would do too (see “Fast by default” above).
A wrapper stage can’t be previewed past its own boundary. Setup stages run inside the container via a re-invocation (see Wrapper / relocation below), and that re-invocation is itself one of the commands not being run — passing
--dry-runinward would mean really starting the wrapper, which is what the run promised not to do. denver prints an explicit!note there naming the--skip <wrapper>that previews those stages on the host.
Stage filtering
--until <stage>truncates the pipeline: every stage up to and including<stage>runs, everything after is dropped. There’s no “run only this one stage” flag — a stage practically always needs the ones before it.--skip <stage>removes individual stages from whatever--untilleft; repeatable.disabled: truein a stage’s own section opts it out by default, without--skiphaving to name it on every invocation.Naming a stage id that isn’t in
stages:is an error. A filtered-out stage’s own section is left out of--show-config’s output too, along with its id instages:.
Wrapper / relocation
A wrapper stage (docker, or a custom stage with launcher:) doesn’t
build the environment itself — it relocates the rest of the pipeline into
somewhere else (a container). Running an env that stacks a wrapper
builds/enters that container and re-invokes denver inside it with that
wrapper stage skipped, so the remaining stages build the environment there
instead of on the host. --skip <wrapper stage> (e.g. --skip docker) runs
the exact same stack directly on the host instead, skipping the relocation
entirely.
That symmetry is deliberate: the host and container paths run the same stages from the same config, so “does it work without Docker?” is one flag away rather than a separate code path.
One consequence for depends-on:: a setup stage’s dependency on a wrapper
stage is checked inside the re-invoked inner process, which always sees
that wrapper as --skipped (that’s how the relocation works) — so such a
dependency is always treated as skipped there, regardless of whether the
wrapper actually ran on the host in the outer process. Depending on a
wrapper stage is rarely useful in practice; depend on another setup stage
instead when you need the cascade to reflect what actually happened.
How the inner run knows where it is
Two separate questions, answered separately rather than by one guess:
“Did a wrapper relocate me?” is denver’s own bookkeeping, so it is stated, not detected: the relocating run sets
DENVER_RELOCATEDto the wrapper stage ids that put the inner process there. Being denver’s own variable, it works for a wrapper that relocates into something which is not a container at all — acustomstage with alauncher:— which no amount of probing the filesystem could reveal. It is what stops a denver-forced--skipfrom being reported as if the user had typed it.“Am I inside a container?” is a fact about the machine — it decides whether an interpreter can be installed, whether an offline install makes sense, and which venv directory is used. A wrapper that relocates into a container sets
DENVER_IN_CONTAINERso the inner run never has to infer it; failing that, denver probes/.dockerenv,/run/.containerenv, thecontainervariable and/run/systemd/container, which covers a container someone started by hand. (Not/proc/self/cgroup: under cgroup v2 it commonly reads0::/either way, so it answers nothing.)
Being inside a container at all — however denver learned it — is enough to stop a wrapper stage relocating again, deliberately without regard to which env put you there: starting an env from inside a devshell builds right there rather than starting a second container.
One run per environment at a time
Every part of an environment’s state is shared between concurrent runs, and
several steps rebuild rather than update: the conan provider wipes its whole
install tree before installing, and the uv provider removes and recreates a
venv whose requirements changed — potentially while another run is using
exactly that. There is no useful way to merge two such runs, so denver
serialises them with an exclusive lock on <DENVER_ENV_WORKDIR>/.lock.
A second run waits, saying whose run it is waiting for; --no-wait makes it
fail instead. The lock is never released explicitly: the descriptor is closed
by execvpe, so it lasts exactly as long as denver is mutating state and
drops the moment it hands over to your command. A long-lived devshell
therefore never holds it, and a wrapper relocation cannot deadlock against
itself — the outer process ceases to exist at exec, before the inner one
asks.
--dry-run takes no lock, since it mutates nothing. Where the filesystem
does not implement flock at all (some NFS and overlay mounts), denver warns
and continues rather than pretending the run is serialised.
Quiet levels and –verbose
By default denver prints only the coarse -- [i/n] stage 'id' (provider)
progress trail plus whatever each stage’s own build tool prints — its finer
detail (sub-step banners, performance timings, echoed commands) is off
unless asked for with -v/--verbose.
-qsilences denver’s own output entirely (the progress trail included), but a stage’s own build tool output still shows, so a long-running invocation stays legible.-qqadditionally silences that too, so only the final launched command’s own output reaches the terminal.-q/-qqalways win over-v– there is nothing left for it to add once either is given.Errors are always reported, at any level.
Performance tracing
Every stage’s runtime is appended to
<DENVER_ENV_WORKDIR>/performance.jsonl as JSON Lines of Chrome Trace Event
Format events. Concatenate them into a {"traceEvents": [...]} document to
load in chrome://tracing or https://ui.perfetto.dev and see where a slow
first run actually spent its time.
A --dry-run records nothing here: no stage did its work, so its durations
would measure printing commands rather than running them, and mixing those
into the file would poison the very timings it exists to answer.