deployhealth-scan
v0.5.0
Published
Find env var drift in a repo (read in code but never defined, defined but never read, out of sync between env files) and report it to deployhealth.
Maintainers
Readme
deployhealth-scan
Find env var drift in a repository and report it to deployhealth:
- MISSING: read in code (
process.env.X,os.environ["X"],os.Getenv("X"),ENV["X"],env('X'),System.getenv("X"),@Value("${X}")) but defined in no env file; - UNUSED: defined in an env file but never read;
- MISMATCH: in
.envbut not.env.example, or the reverse.
It scans JavaScript/TypeScript (.js, .jsx, .mjs, .cjs, .ts, .tsx, .mts, .cts),
Python (including pydantic-settings classes), Go, Ruby, PHP (with Laravel and Symfony) and
Java/Kotlin (.java, .kt, .kts, with Spring), respects .gitignore, and treats each folder
with its own env files as a separate scope (monorepos). One file, no dependencies, Node 20+.
Reads JS/TS, Python, Go, Ruby, PHP and Java/Kotlin. Rust, C# and others aren't read yet: on a repo in those, the pull request check and the CLI say they can't check it instead of passing it.
On a directory with no such source file (after the rules below), it prints
No JS/TS, Python, Go, Ruby, PHP or Java/Kotlin source files found: deployhealth can't check this directory yet.,
reports no UNUSED variables (nothing it reads could use them), still reports MISMATCH, and exits
0. With --json the line goes to stderr, and the JSON says "can_check": false with
"source_files": 0.
Env files it reads: .env, .env.local, .env.development, .env.production, .env.test and
their .local variants, plus declaration files: .env.example, .env.sample,
.env.template, .env.dist, .env.defaults, example.env, sample.env, env.example and
.env.<name>.example / .sample / .template (e.g. .env.appStore.example). A commented-out
# KEY= line in a declaration file declares KEY too.
A first scan stays quiet:
- Names the platform or runtime provides are skipped:
NODE_ENV,CI,npm_*, GitHub Actions' default variables (GITHUB_SHA,GITHUB_REF_NAME, …, not your ownGITHUB_names),RUNNER_*,VERCEL_*,RAILWAY_*,RENDER_*,FLY_*and a few more.--no-default-ignorechecks them too;--dry-runlists what was skipped. - A read with a default on the same line isn't MISSING:
process.env.X ?? "a"or|| "a",os.getenv("X", "a"),os.environ.get("X", "a"),os.getenv("X") or "a",ENV.fetch("X", "a"),ENV.fetch("X") { … },ENV["X"] || "a",env('X', 'a'),env('X') ?? 'a'/?: 'a',getenv('X') ?: 'a',$_ENV['X'] ?? 'a',System.getenv().getOrDefault("X", "a"), Kotlin'sSystem.getenv("X") ?: "a",Optional.ofNullable(System.getenv("X")).orElse("a"),@Value("${X:a}"). Such variables are counted as optional (one OPTIONAL line;--show-optionallists them). (A default ofundefined,null,Noneornilis no default, nor is PHP's?: throw …or Kotlin's?: error(…).) - Tests, fixtures and test tooling are skipped:
test/,tests/,__tests__/,spec/,e2e/,fixtures/,__fixtures__/,testdata/,playwright/,cypress/,mocks/,__mocks__/andtesting/directories (their env files too, so a fixture's.envnever becomes a scope), and*.test.*,*.spec.*,*.e2e.*,*.e2e-spec.*,*.cy.*,*_test.go,test_*.py,*_test.py,conftest.py,*_spec.rb,*Test.php,phpunit.xml,*Test(s).java|kt,*IT.java|kt, Spring'sapplication-test.*, Gradle's*.gradle.ktsbuild scripts, andplaywright/vitest/jest/cypressconfig files,vitest.workspace.*andvitest/jestsetup files. They're only read to see which variables they use, so a variable only tests read isn't UNUSED.--include-testsscans them like any other file. - Vendored and generated code is never read:
node_modules,dist,.next,venv,.venv,.yarn,vendor,third_party,bower_components,out,coverage,.turbo,.vercel,.output,.svelte-kit,.nuxt,.cache,.pnpm-store,__pycache__,site-packages,.phpunit.cache,.gradleand.mvndirectories,bootstrap/cache/andpublic/build/, Laravel'sstorage/next toartisan(never opened: cached config can hold values), Maven'starget/next topom.xml, Gradle'sbuild/andtarget/next to a Gradle build file,.pnp.cjs,.pnp.loader.mjs,*.min.js(.mjs,.cjs), and any source file over 512 KB.--dry-runsays what it skipped. - Docker Compose interpolation counts as a use:
${VAR},${VAR:-x},${VAR:?x},$VAR, … indocker-compose*.yml/compose*.yamlkeep an env entry Compose consumes from being UNUSED. They never make MISSING rows (Compose often gets values from the shell or CI). - Laravel and Symfony: next to
artisan, the env vars Laravel's framework and skeleton read (BCRYPT_ROUNDS,BROADCAST_CONNECTION, …) are never UNUSED, and a read of one is never MISSING (exceptAPP_KEY).$_SERVER['X']marksXused, never MISSING. Symfony's%env(X)%inconfig/**/*.yamlis a read (%env(default:param:X)%is optional). - Spring:
${X}inapplication*/bootstrap*.propertiesand.ymlfiles is a read (${X:default}is optional); dotted names like${server.port}are properties, not env vars. Where the code uses Spring, a declared name Spring binds to a property isn't UNUSED: the env form of a key in that config (mail.host→MAIL_HOST), anything starting withSPRING_,SERVER_,MANAGEMENT_orLOGGING_, and a@ConfigurationProperties("app.mail")prefix (APP_MAIL_…). That only ever marks names used, never MISSING. - One row per variable per line, even when a line reads it twice, and a quoted
'process.env.X'(a bundlerdefinekey) isn't a read. Same-line destructuring counts:const { X, Y: y, Z = "a" } = process.envreads all three,Zas optional. - A folder with no env file at all gets one line, "No .env.example …: N variables referenced",
instead of a MISSING row per reference; deployhealth's handoff offers them as a starting
.env.example.
pydantic-settings
In a .py file, a class whose bases include BaseSettings (or a subclass of one in the same file)
reads one env var per annotated field: database_url: str is DATABASE_URL, after a literal
env_prefix (model_config = SettingsConfigDict(env_prefix="APP_") or class Config). A string
alias= / validation_alias= replaces the name; each AliasChoices(...) string counts and is
optional. A field with a default is optional; = None counts as a default only when the type
allows None (str | None, Optional[str]). ClassVar, _private fields, model_config and
fields typed as a model from the same file are skipped.
Known misses
Same-line, regex-based scanning misses some things on purpose:
- reads split across lines (including a destructuring pattern spread over several lines), and
dynamic keys (
process.env[name],os.environ[key]); - pydantic-settings bases defined in another file, prefixes or aliases held in variables,
AliasPath, andenv_nested_delimiter's nested names; - Compose's
env_file:(it passes a whole file into a container) andenvironment:keys; - env reads in other file types: Rails' ERB in
config/*.yml,.rakefiles, shell scripts, Dockerfiles and CI workflows; turbo.json'senvlists, which aren't declarations.
Try it locally
npx [email protected] --dry-run # grouped findings with file:line
npx [email protected] --dry-run --json # the same, as JSONWith --dry-run nothing leaves your machine.
Report every push from GitHub Actions
Create a project in deployhealth, add its ingest token as a repository secret named
DEPLOYHEALTH_TOKEN (Settings → Secrets and variables → Actions → Secrets tab → New repository
secret; not a Variable, not an environment secret), then add .github/workflows/deployhealth.yml:
name: deployhealth
# Must not run on pull_request events from forks: the job reads a repository secret.
on:
push:
branches: [main]
jobs:
env-scan:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false
- name: Scan env vars and report to deployhealth
env:
DEPLOYHEALTH_TOKEN: ${{ secrets.DEPLOYHEALTH_TOKEN }}
run: |
npx --yes [email protected] \
--url https://deployhealth.dev \
--token "$DEPLOYHEALTH_TOKEN" \
--sha "$GITHUB_SHA" \
--branch "$GITHUB_REF_NAME"Pin the version (@0.5.0) so an update never runs in your CI unreviewed. For a self-hosted
deployhealth, change --url to your instance.
What it sends
The commit sha and branch, and for each finding the variable name, file:line and env file name,
plus every referenced variable name with the env files that define it (and whether the code has a
default), and each scope's env file names. Never values: the scanner reads env files for the
names they define; values are neither sent nor printed.
Options
--url <url> deployhealth base URL (required unless --dry-run)
--token <token> the project's ingest token, dh_... (required unless --dry-run;
default: the DEPLOYHEALTH_TOKEN environment variable)
--sha <sha> commit being deployed (default: git rev-parse HEAD)
--branch <name> branch being deployed (default: current git branch)
--dir <path> directory to scan (default: current directory)
--ignore <glob> skip variables matching a glob, e.g. NEXT_PUBLIC_* (repeatable)
--no-default-ignore also check names the platform or runtime provides (NODE_ENV,
CI, GITHUB_SHA, npm_*, VERCEL_*, RAILWAY_*, RENDER_*, FLY_*, ...)
--exclude <pattern> skip paths matching a gitignore-style pattern (repeatable)
--include-tests also scan tests, fixtures and test tooling (test/, tests/, e2e/,
fixtures/, playwright/, testing/, *.test.*, *.spec.*, *.e2e.*,
*_test.go, vitest.config.*, ...)
--dry-run print the findings instead of sending them
--show-optional with --dry-run, list each variable read with a default (OPTIONAL)
--json with --dry-run, print JSON (always lists them)
-v, --version print the version
-h, --help show this helpThe token can come from the DEPLOYHEALTH_TOKEN environment variable instead of --token, which
keeps it out of process listings. A --url that is plain http: to anything but this machine
prints a warning: the token and the report would travel unencrypted.
Exit codes: 0 success, 1 the report failed (network or HTTP error), 2 usage error.
License
MIT. The deployhealth web app and worker are licensed separately (FSL-1.1-MIT); see the repository.
The name "deployhealth" and its logo are not licensed under MIT or FSL-1.1-MIT; please don't use them for your own product or service.
