$ @dataclass(frozen=True)
class EnvEntry: key: str value: str line_no: int # 1-indexed, points at the source line def parse_env(text: str) -> List[EnvEntry]: entries: List[EnvEntry] = [] for idx, raw_line in enumerate(text.splitlines(), -weight: 500;">start=1): line = raw_line.strip() if not line or line.startswith("#"): continue if line.startswith("export "): line = line[len("export ") :].lstrip() if "=" not in line: raise EnvParseError(f"expected KEY=VALUE, got {raw_line!r}", idx) key, _, raw_value = line.partition("=") key = key.strip() if not _is_valid_key(key): raise EnvParseError(f"invalid key {key!r}", idx) entries.append(EnvEntry(key=key, value=_unquote(raw_value), line_no=idx)) return entries
@dataclass(frozen=True)
class EnvEntry: key: str value: str line_no: int # 1-indexed, points at the source line def parse_env(text: str) -> List[EnvEntry]: entries: List[EnvEntry] = [] for idx, raw_line in enumerate(text.splitlines(), -weight: 500;">start=1): line = raw_line.strip() if not line or line.startswith("#"): continue if line.startswith("export "): line = line[len("export ") :].lstrip() if "=" not in line: raise EnvParseError(f"expected KEY=VALUE, got {raw_line!r}", idx) key, _, raw_value = line.partition("=") key = key.strip() if not _is_valid_key(key): raise EnvParseError(f"invalid key {key!r}", idx) entries.append(EnvEntry(key=key, value=_unquote(raw_value), line_no=idx)) return entries
@dataclass(frozen=True)
class EnvEntry: key: str value: str line_no: int # 1-indexed, points at the source line def parse_env(text: str) -> List[EnvEntry]: entries: List[EnvEntry] = [] for idx, raw_line in enumerate(text.splitlines(), -weight: 500;">start=1): line = raw_line.strip() if not line or line.startswith("#"): continue if line.startswith("export "): line = line[len("export ") :].lstrip() if "=" not in line: raise EnvParseError(f"expected KEY=VALUE, got {raw_line!r}", idx) key, _, raw_value = line.partition("=") key = key.strip() if not _is_valid_key(key): raise EnvParseError(f"invalid key {key!r}", idx) entries.append(EnvEntry(key=key, value=_unquote(raw_value), line_no=idx)) return entries
def test_line_numbers_survive_trailing_content(): text = "\n".join([ "# header", # 1 "DATABASE_URL=x", # 2 "# note", # 3 "PORT=8080", # 4 ]) entries = parse_env(text) assert entries[0].line_no == 2 assert entries[1].line_no == 4
def test_line_numbers_survive_trailing_content(): text = "\n".join([ "# header", # 1 "DATABASE_URL=x", # 2 "# note", # 3 "PORT=8080", # 4 ]) entries = parse_env(text) assert entries[0].line_no == 2 assert entries[1].line_no == 4
def test_line_numbers_survive_trailing_content(): text = "\n".join([ "# header", # 1 "DATABASE_URL=x", # 2 "# note", # 3 "PORT=8080", # 4 ]) entries = parse_env(text) assert entries[0].line_no == 2 assert entries[1].line_no == 4
_RED = "\x1b[31m"
_YELLOW = "\x1b[33m"
_GREEN = "\x1b[32m"
_DIM = "\x1b[2m"
_RESET = "\x1b[0m" def _color_enabled(ci_flag: bool, stream: TextIO) -> bool: if ci_flag: return False if os.environ.get("NO_COLOR"): return False return hasattr(stream, "isatty") and stream.isatty() def format_error(err: ValidationError, env_path: str, *, color: bool) -> str: location = f"{env_path}:{err.line_no}" if err.line_no else env_path location_str = _paint(location, _DIM, color) message_str = _paint(err.message, _RED, color) return f"{location_str}: {message_str}"
_RED = "\x1b[31m"
_YELLOW = "\x1b[33m"
_GREEN = "\x1b[32m"
_DIM = "\x1b[2m"
_RESET = "\x1b[0m" def _color_enabled(ci_flag: bool, stream: TextIO) -> bool: if ci_flag: return False if os.environ.get("NO_COLOR"): return False return hasattr(stream, "isatty") and stream.isatty() def format_error(err: ValidationError, env_path: str, *, color: bool) -> str: location = f"{env_path}:{err.line_no}" if err.line_no else env_path location_str = _paint(location, _DIM, color) message_str = _paint(err.message, _RED, color) return f"{location_str}: {message_str}"
_RED = "\x1b[31m"
_YELLOW = "\x1b[33m"
_GREEN = "\x1b[32m"
_DIM = "\x1b[2m"
_RESET = "\x1b[0m" def _color_enabled(ci_flag: bool, stream: TextIO) -> bool: if ci_flag: return False if os.environ.get("NO_COLOR"): return False return hasattr(stream, "isatty") and stream.isatty() def format_error(err: ValidationError, env_path: str, *, color: bool) -> str: location = f"{env_path}:{err.line_no}" if err.line_no else env_path location_str = _paint(location, _DIM, color) message_str = _paint(err.message, _RED, color) return f"{location_str}: {message_str}"
VALIDATORS: Dict[str, Callable[[str], Optional[str]]] = { "string": validate_string, "int": validate_int, "bool": validate_bool, "url": validate_url, "email": validate_email, "port": validate_port, "path": validate_path,
}
VALIDATORS: Dict[str, Callable[[str], Optional[str]]] = { "string": validate_string, "int": validate_int, "bool": validate_bool, "url": validate_url, "email": validate_email, "port": validate_port, "path": validate_path,
}
VALIDATORS: Dict[str, Callable[[str], Optional[str]]] = { "string": validate_string, "int": validate_int, "bool": validate_bool, "url": validate_url, "email": validate_email, "port": validate_port, "path": validate_path,
}
def validate_port(value: str) -> Optional[str]: try: n = int(value) except ValueError: return f'expected port (1-65535), got "{value}"' if 1 <= n <= 65535: return None return f'expected port (1-65535), got "{value}"'
def validate_port(value: str) -> Optional[str]: try: n = int(value) except ValueError: return f'expected port (1-65535), got "{value}"' if 1 <= n <= 65535: return None return f'expected port (1-65535), got "{value}"'
def validate_port(value: str) -> Optional[str]: try: n = int(value) except ValueError: return f'expected port (1-65535), got "{value}"' if 1 <= n <= 65535: return None return f'expected port (1-65535), got "{value}"'
$ envcheck --schema envcheck.yml --env .env
.env: missing required variable: JWT_SECRET
.env:3: DATABASE_URL: expected url, got "localhost:5432"
.env:7: PORT: expected port (1-65535), got "99999"
.env:12: LOG_LEVEL: expected one of [debug, info, warn, error], got "trace"
.env:15: ADMIN_EMAIL: expected email, got "ops-team"
.env:18: JWT_SECRET: length 12 < min_length 32
6 errors found in .env
$ envcheck --schema envcheck.yml --env .env
.env: missing required variable: JWT_SECRET
.env:3: DATABASE_URL: expected url, got "localhost:5432"
.env:7: PORT: expected port (1-65535), got "99999"
.env:12: LOG_LEVEL: expected one of [debug, info, warn, error], got "trace"
.env:15: ADMIN_EMAIL: expected email, got "ops-team"
.env:18: JWT_SECRET: length 12 < min_length 32
6 errors found in .env
$ envcheck --schema envcheck.yml --env .env
.env: missing required variable: JWT_SECRET
.env:3: DATABASE_URL: expected url, got "localhost:5432"
.env:7: PORT: expected port (1-65535), got "99999"
.env:12: LOG_LEVEL: expected one of [debug, info, warn, error], got "trace"
.env:15: ADMIN_EMAIL: expected email, got "ops-team"
.env:18: JWT_SECRET: length 12 < min_length 32
6 errors found in .env
-weight: 500;">git clone https://github.com/sen-ltd/envcheck
cd envcheck
-weight: 500;">docker build -t envcheck . # 1. Generate a .env.example from the included schema:
-weight: 500;">docker run --rm -v "$PWD:/work" envcheck example \ --schema envcheck.example.yml # 2. Validate a good .env:
-weight: 500;">docker run --rm -v "$PWD/tests/fixtures:/work" envcheck \ --schema schema.yml --env valid.env
# → OK valid.env: 9 variables, all valid # 3. Validate a broken one:
-weight: 500;">docker run --rm -v "$PWD/tests/fixtures:/work" envcheck \ --schema schema.yml --env invalid.env
# → exits 1, grep-friendly colored errors # 4. Run the test suite inside the image:
-weight: 500;">docker run --rm --entrypoint pytest envcheck -q
# → 51 passed
-weight: 500;">git clone https://github.com/sen-ltd/envcheck
cd envcheck
-weight: 500;">docker build -t envcheck . # 1. Generate a .env.example from the included schema:
-weight: 500;">docker run --rm -v "$PWD:/work" envcheck example \ --schema envcheck.example.yml # 2. Validate a good .env:
-weight: 500;">docker run --rm -v "$PWD/tests/fixtures:/work" envcheck \ --schema schema.yml --env valid.env
# → OK valid.env: 9 variables, all valid # 3. Validate a broken one:
-weight: 500;">docker run --rm -v "$PWD/tests/fixtures:/work" envcheck \ --schema schema.yml --env invalid.env
# → exits 1, grep-friendly colored errors # 4. Run the test suite inside the image:
-weight: 500;">docker run --rm --entrypoint pytest envcheck -q
# → 51 passed
-weight: 500;">git clone https://github.com/sen-ltd/envcheck
cd envcheck
-weight: 500;">docker build -t envcheck . # 1. Generate a .env.example from the included schema:
-weight: 500;">docker run --rm -v "$PWD:/work" envcheck example \ --schema envcheck.example.yml # 2. Validate a good .env:
-weight: 500;">docker run --rm -v "$PWD/tests/fixtures:/work" envcheck \ --schema schema.yml --env valid.env
# → OK valid.env: 9 variables, all valid # 3. Validate a broken one:
-weight: 500;">docker run --rm -v "$PWD/tests/fixtures:/work" envcheck \ --schema schema.yml --env invalid.env
# → exits 1, grep-friendly colored errors # 4. Run the test suite inside the image:
-weight: 500;">docker run --rm --entrypoint pytest envcheck -q
# → 51 passed
DATABASE_URL: type: url required: true description: Primary Postgres connection string PORT: type: port required: true NODE_ENV: type: string required: true enum: [development, staging, production] JWT_SECRET: type: string required: true min_length: 32 LOG_LEVEL: type: string required: false enum: [debug, info, warn, error]
DATABASE_URL: type: url required: true description: Primary Postgres connection string PORT: type: port required: true NODE_ENV: type: string required: true enum: [development, staging, production] JWT_SECRET: type: string required: true min_length: 32 LOG_LEVEL: type: string required: false enum: [debug, info, warn, error]
DATABASE_URL: type: url required: true description: Primary Postgres connection string PORT: type: port required: true NODE_ENV: type: string required: true enum: [development, staging, production] JWT_SECRET: type: string required: true min_length: 32 LOG_LEVEL: type: string required: false enum: [debug, info, warn, error]
- name: Validate .env run: | -weight: 500;">docker run --rm -v "$PWD:/work" ghcr.io/sen-ltd/envcheck \ --schema envcheck.yml --env .env.production --ci
- name: Validate .env run: | -weight: 500;">docker run --rm -v "$PWD:/work" ghcr.io/sen-ltd/envcheck \ --schema envcheck.yml --env .env.production --ci
- name: Validate .env run: | -weight: 500;">docker run --rm -v "$PWD:/work" ghcr.io/sen-ltd/envcheck \ --schema envcheck.yml --env .env.production --ci - DATABASE_URL must be a valid URL (not localhost:5432, which isn't one).
- NODE_ENV must be one of development|staging|production (not dev, which is a reasonable-looking typo).
- JWT_SECRET must be at least 32 characters long (not the empty string that sneaks in when someone runs unset JWT_SECRET without realizing it).
- PORT must be a valid TCP port.
- ADMIN_EMAIL must be an email-ish string. - dotenv-linter (Rust) — fast and well-maintained, but it's a style linter. It catches duplicated keys and spaces around =. It doesn't do types or required-ness.
- envalid (JavaScript) — type-safe, very nice, but the schema is code. You can't run it in CI without running your Node.js app; you can't share the schema with your Go -weight: 500;">service.
- Framework-specific tools (Rails's dotenv, Python's environs, Spring Boot's @ConfigurationProperties) — each does its own thing, each is code-based, none are declarative, none are language-neutral. - Reviewability: a diff should be obvious in a GitHub PR. "required": true → "required": false needs to stand out.
- Language-neutral: the same schema file should be editable by the Python team, the Go team, the TypeScript team, and the SRE reading it to understand what the -weight: 500;">service needs.
- In-ecosystem: nobody installs a new parser just for this. - 0 — .env is valid.
- 1 — validation errors (missing required, wrong type, etc).
- 2 — config error (schema file missing, YAML is malformed, .env file doesn't exist). This is distinct from 1 because "my schema is broken" is a different incident class from "my env vars are wrong" — you want to alert different people. - ${OTHER_VAR} interpolation. .env files in the wild sometimes reference other variables: DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@host/db. Supporting this correctly means implementing a small expression language and worrying about cycles. envcheck treats the literal string ${DB_USER} as the value and will validate that against your schema. This is a deliberate choice: interpolation is usually done by the shell or by a runtime library, not by the file itself, and the moment you add interpolation you're writing a programming language interpreter. I'm not writing a programming language interpreter for this.
- Multi-line values. The parser is single-line-only. If you have CERT="-----BEGIN CERTIFICATE-----\n...", you need to base64-encode it or put it in a file. This is already best practice.
- Filesystem checks on path values. validate_path checks that the value is a non-empty string without NUL bytes. It does not check that the path exists — doing so would make your CI lint pass or fail depending on where it was run, and that's a nightmare. If you want existence checks, you want a different tool.
- Cross-field constraints. You can't say "if FEATURE_X is true, then FEATURE_X_API_KEY must be set." That's a conditional, and conditionals are how schemas turn into code. If you need cross-field logic, reach for a code-based validator like envalid or pydantic.