← Back to .env.example Drift Check

Why .env.example files drift from the code that reads them

.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.

Why it happens: one file, edited on a different schedule than the code

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.

Why this bug is unusually visible, and unusually annoying

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.

What this tool checks, and what it deliberately doesn't

LanguagePatterns matched
JavaScript / TypeScriptprocess.env.FOO, process.env["FOO"]
Pythonos.getenv("FOO"), os.environ["FOO"], os.environ.get("FOO")
RubyENV["FOO"], ENV.fetch("FOO")
JavaSystem.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.

Wiring it into CI as a gate

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.

Common questions

Isn't this just a linter rule?

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.

Does this replace dotenv-linter or similar tools?

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.

What about variables read from a config object instead of process.env directly?

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.