← Back to .env.example Drift Check
.env.example (or .env.sample) exists to be a
contract: clone the repo, copy it to .env, fill in real values,
and the app has everything it needs. That contract only holds while the
file's declared names exactly match the environment variables the code
actually reads — and nothing about how most repos are built keeps that true
on its own.
A pull request adds a new integration and a new
process.env.STRIPE_WEBHOOK_SECRET read somewhere deep in a
route handler. The change works, the tests pass (test environments often
stub the value directly), and the PR merges. Updating
.env.example in the same diff is a manual, easy-to-forget step
that no build or type check enforces — a variable read and a variable
declared are two separate authored artifacts with no mechanical link
between them, the same shape as a changelog that quietly stops matching the
commits it describes.
Most documentation drift is invisible until someone happens to read the
stale section. This one is different: it fails the very first command a new
contributor, a new environment, or a CI runner executes after cloning —
cp .env.example .env followed by starting the app — with a
crash or a confusing default that has nothing to do with what the person
actually did wrong. It reads as "this project is broken" rather than as
what it is, a doc that fell one pull request behind, and it costs exactly
the person least equipped to debug it (someone new to the codebase) the
most time figuring out.
| Language | Patterns matched |
|---|---|
| JavaScript / TypeScript | process.env.FOO, process.env["FOO"] |
| Python | os.getenv("FOO"), os.environ["FOO"], os.environ.get("FOO") |
| Ruby | ENV["FOO"], ENV.fetch("FOO") |
| Java | System.getenv("FOO") |
Deliberately excluded from v0: shell scripts, YAML/Compose files, and any environment read built dynamically rather than as a literal name in one of the patterns above. Matched one line at a time by design, so it is trivially explainable and does not need a language parser — the tradeoff is that a read split unusually across multiple lines will not be seen, which in practice is rare for this specific call shape.
Run it on every pull request, pointed at the directory (or directories) that actually read environment variables:
- name: Check .env.example against actual env reads run: node ventures/env-example-drift-check/cli.mjs check --example .env.example src
The command exits non-zero if any variable is read in code but missing
from the example file. Add --strict to also fail when the
example file declares a variable nothing reads anymore — a softer signal
(stale documentation, not a crash) worth a separate, less urgent gate.
ESLint and similar tools can flag process.env usage inside one language's own files, but none of the common ones diff those reads against a separate .env.example file's declared names, and none work across JavaScript, Python, Ruby, and Java in one pass. This tool is narrower and more specific than a general linter rule: it answers exactly one question, does the example file's variable list match what the code actually reads, for a repo that may mix languages.
No, and it is not trying to. Tools like dotenv-linter check an .env file's own internal formatting (duplicate keys, inconsistent quoting, unordered keys). This tool checks a different pair entirely: the example file's declared names against the source code's actual reads. They compose -- run both.
Not scanned in v0. A codebase that centralizes all environment reads into one config module (a common and good pattern) means most of the actual process.env.FOO calls live in one or two files, which is exactly where pointing the CLI at that module still works well; a config object built dynamically from a list of names, rather than named process.env.FOO calls, is outside what a deterministic text scan can see.