Skip to content

fix: an imported @currentEnv must win over the --env fallback - #1036

Closed
chriscors wants to merge 2 commits into
dmno-dev:mainfrom
chriscors:fix/imported-currentenv-vs-env-fallback
Closed

fix: an imported @currentEnv must win over the --env fallback#1036
chriscors wants to merge 2 commits into
dmno-dev:mainfrom
chriscors:fix/imported-currentenv-vs-env-fallback

Conversation

@chriscors

Copy link
Copy Markdown
Contributor

The bug

A directory whose schema gets @currentEnv through @import() never loads its own .env.[env] files when a fallback env is supplied.

Minimal repro — same directory, same files, the only difference is the flag:

$ varlock load
BETTER_AUTH_URL = "https://app-level-stg.example.com"     # app's .env.stg wins

$ varlock load --env production
BETTER_AUTH_URL = "https://root-level-stg.example.com"    # app's .env.stg silently skipped

with:

apps/dashboard/.env.schema   # @import(../../)
apps/dashboard/.env.stg      BETTER_AUTH_URL=https://app-level-stg.example.com
.env.schema                  # @currentEnv=$APP_ENV  (+ APP_ENV declared here)
.env.stg                     BETTER_AUTH_URL=https://root-level-stg.example.com

APP_ENV=stg throughout. Note the root's .env.stg does load — so @currentEnv resolves fine — while the app's own is skipped in favour of a .env.production that doesn't exist. No error, and nothing in the loaded-file list hints the app file was considered:

- Environments: .env.schema, ../../.env.stg, ../../.env.schema

Why

DirectoryDataSource._finishInit() resolves currentEnv before imports are processed, which is too early for an imported @currentEnv, so it falls through to graph.envFlagFallback. That value is truthy, so the post-import re-check — guarded on !currentEnv — never runs, and the directory stays pinned to the fallback for the rest of the load.

There's no fallback in the CLI's default path, so this only surfaces when one is set. The Next.js integration always sets one: next-env-compat.ts passes --env development/production from next dev/next build to match @next/env. Its comment says the user should be able to "ignore it by setting their own @currentEnv" — which holds when the decorator is in the same schema (there's already a passing test for that), and doesn't when the schema imports it.

That makes the monorepo layout in Monorepos → one schema per project unusable for per-environment values under Next.js: each app's .env.schema is just @import(../../), so no app can have its own .env.[env]. Worse, both staging and production deploys run next build--env production, so they resolve identically.

The fix

_resolveCurrentEnv() now reports whether the value came from the fallback. A fallback is treated as provisional:

  • it is not acted on before imports, and
  • the post-import re-check runs for it, not only when there was no value at all.

Deliberately not acted on early rather than loaded and later overridden: loading .env.<fallback> leaves its values in the graph even once the real env is known, so a key present only there would leak into the wrong environment.

A resolved @currentEnv is unchanged — still final, still loaded before imports, so import conditions can read those values.

Tests

One test added next to the existing fallback env value is ignored if currentEnv is present, covering the same intent when @currentEnv arrives via an import. It fails on main:

AssertionError: ITEM1 value did not match: expected 'val-from-.env.staging' to deeply equal 'val-from-.env.dev'

Full suite green with the fix: 1797 passed, 1 skipped. typecheck and eslint clean.

Also verified end to end against the real monorepo this came from — building the patched CLI and running the exact command the Next integration shells out to (load --env production) now resolves the app-level value.

Related

#428 is a different symptom of @currentEnv + @import (env flag must be declared in the same schema when using a pick list); this path is separate and doesn't fix that one.

A directory whose schema gets `@currentEnv` through `@import()` never loaded its
own `.env.[env]` files when a fallback env was supplied.

`DirectoryDataSource._finishInit()` resolves `currentEnv` before imports are
processed, which is too early for an imported `@currentEnv` — so it falls
through to `graph.envFlagFallback`. That value is truthy, so the post-import
re-check (guarded on `!currentEnv`) never runs, and the directory stays pinned
to the fallback env for the rest of the load.

There is no fallback in the CLI's default path, so this only shows up when one
is set — which the Next.js integration always does, passing `--env
development`/`production` from `next dev`/`next build` to match `@next/env`.
Its own comment says the user should be able to "ignore it by setting their own
`@currentEnv`", and that holds when the decorator is in the same schema; it does
not when the schema imports it. In a monorepo where each app's `.env.schema` is
just `@import(../../)` of a root schema that owns `@currentEnv=$APP_ENV`, the
root's `.env.[APP_ENV]` loads correctly while the app's own is silently skipped
in favour of `.env.production` — with no error, and nothing in the loaded-file
list to suggest the app file was considered.

`_resolveCurrentEnv()` now reports whether the value came from the fallback.
A fallback is treated as provisional: it is not acted on before imports, and the
post-import re-check runs for it. Deliberately not acted on early, rather than
loaded and later overridden — loading `.env.<fallback>` leaves its values in the
graph even once the real env is known, so a key present only there would leak
into the wrong environment. A resolved `@currentEnv` is still final and still
loads before imports, so import conditions can read those values.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Deferring fallback environment files until after imports introduces a confirmed regression in conditional import resolution.

Reviewed changes Reviewed the environment-selection ordering, fallback provenance tracking, imported @currentEnv precedence, and regression coverage.

  • Fallback provenance: _resolveCurrentEnv() now distinguishes a graph-level fallback from a value resolved through @currentEnv.
  • Import precedence: fallback-specific files are deferred until imports have had a chance to introduce @currentEnv.
  • Regression coverage: the new test verifies that an imported @currentEnv selects the importing directory's matching environment file instead of the fallback file.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using azure/gpt-5.6-sol𝕏

// both be wrong and leave that file's values in the graph after the correct env is
// known. A resolved @currentEnv is final, so it loads immediately (before imports),
// which is what lets import conditions read those values.
if (currentEnv && !fromFallback) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring every fallback here breaks schemas whose @import(enabled=...) or imported @disable conditions depend on values overridden by .env.<fallback>. A minimal fallback-only case now resolves the schema default first and then errors when the environment file tries to override that early-resolved item, so the pre-import availability of fallback-specific values needs to be preserved when the fallback remains authoritative.

Technical details
# Fallback-specific values load too late for import conditions

## Affected sites
- `packages/varlock/src/env-graph/lib/data-source.ts:1010` - skips loading `.env.<fallback>` before `_processImports()`.
- `packages/varlock/src/env-graph/lib/data-source.ts:1028` - loads the fallback file only after schema imports have already resolved their condition dependencies.

## Required outcome
- When no imported `@currentEnv` supersedes the fallback, values from `.env.<fallback>` and `.env.<fallback>.local` must be registered before schema import and disable conditions are evaluated.
- When an imported `@currentEnv` does supersede the fallback, values from the fallback-specific files must not remain in the graph.

## Reproduction
A schema with `AUTH_MODE=none` and `@import(./.env.azure, enabled=eq($AUTH_MODE, "azure"))`, a fallback of `staging`, and `.env.staging` containing `AUTH_MODE=azure` now fails with `"AUTH_MODE" was already resolved during early initialization ... and cannot be redefined by .env.staging`. Before this change, the staging value enabled the import.

@theoephraim

Copy link
Copy Markdown
Member

Thanks for this! Will have to dig in a bit. I know it was made considerably more complex by attempting to match next’s default behavior of setting env based on running build vs dev (if no currentEnv is set). I’d rather not do that, but we were trying to make it work as a drop in replacement.

Deferring `.env.<fallback>` until after imports was a real regression, and the
review is right about the mechanism: an import condition or an imported
`@disable` may read a value that `.env.<fallback>` overrides. The reviewer's
case now has a test — a schema with `AUTH_MODE=none`, a `staging` fallback whose
`.env.staging` sets `AUTH_MODE=azure`, and `@import(..., enabled=eq($AUTH_MODE,
"azure"))`. Deferring made `AUTH_MODE` early-resolve to `none`, so the import was
skipped and the later file then failed with "already resolved during early
initialization ... cannot be redefined".

Verified as a regression rather than assumed: the new test passes on main and
failed on the previous commit here.

So the fallback loads before imports exactly as it did. What changes is only the
post-import re-check, which now runs when the value came from the fallback and
not just when there was no value at all. If a real `@currentEnv` then resolves to
a different env, that env's files are loaded too — after the fallback's, so they
take precedence.

Known limitation, called out in the code: files already loaded for a superseded
fallback stay in the graph. A key present ONLY in `.env.<fallback>` will still be
set. Dropping them would mean removing a data source and the config items it
created, and there is no mechanism for that — it looked worse to invent one here
than to document the edge. In the motivating case nothing is loaded early at all,
since `.env.production` does not exist.
@chriscors

Copy link
Copy Markdown
Contributor Author

Good catch — that's a real regression, and I've reverted the part of the change that caused it.

I verified it rather than taking it on faith: your case as a test passes on main and failed on b012969, with exactly the error you predicted:

"AUTH_MODE" was already resolved during early initialization
(used by @currentEnv, @import enabled, or @disable) and cannot be redefined by .env.staging

Deferring made AUTH_MODE early-resolve to none, so the conditional import was skipped and the later file then collided with that decision. It's now a permanent test (fallback env values are available to import conditions), so the deferral can't come back by accident.

What changed in 0f25087: .env.<fallback> loads before imports exactly as it did on main. The only difference from main is the post-import re-check, which now also runs when the value came from the fallback — not just when there was no value at all. If a real @currentEnv then resolves to a different env, that env's files load after the fallback's and take precedence.

On your second required outcome — "values from the fallback-specific files must not remain in the graph" — I have not done that, deliberately. Dropping them means removing a data source and the config items it already created, and I couldn't find a mechanism for that; inventing one felt like a much bigger change than this bug warrants, and easy to get subtly wrong. So a key present only in .env.<fallback> will still be set after the fallback is superseded. It's noted in a comment at the site.

Happy to take a swing at it if you'd point me at the right seam — or to leave it as a documented edge, since in the motivating case nothing loads early anyway (.env.production doesn't exist).

Full suite green: 1798 passed, 1 skipped. Typecheck and lint clean.

@chriscors

Copy link
Copy Markdown
Contributor Author

Certainly! Noticed while trying to implement multi-env overrides across multiple next apps. Let me know if I can provide any add'l followup.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The fallback preload fixes the prior regression when the fallback remains authoritative, but it introduces an incorrect graph when an imported @currentEnv supersedes that fallback.

Reviewed changes Reviewed the changes since the prior Pullfrog review that restored fallback-specific loading before imports while retaining post-import @currentEnv resolution.

  • Restored fallback preload: Loaded fallback-specific files before schema imports so import and disable conditions can see their overrides.
  • Retained imported precedence: Re-resolved @currentEnv after imports and loaded a different selected environment afterward.
  • Added regression coverage: Added a focused test for fallback values used by import conditions.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using azure/gpt-5.6-sol𝕏

// real @currentEnv has to win over it, the same way it does when declared in this
// schema directly.
//
// NOTE: files already loaded for a superseded fallback env stay in the graph. The

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading the fallback before _processImports() lets it permanently enable, skip, or disable imports even when this later discovers that another environment is authoritative. Loading the resolved environment afterward cannot undo those decisions, so the final graph can still reflect the fallback rather than the imported @currentEnv.

Technical details
# Superseded fallback controls irreversible import decisions

## Affected sites
- `packages/varlock/src/env-graph/lib/data-source.ts:1008` - registers fallback-specific condition values.
- `packages/varlock/src/env-graph/lib/data-source.ts:1014` - processes imports and imported `@disable` decorators using those values.
- `packages/varlock/src/env-graph/lib/data-source.ts:1028` - discovers the actual environment only after those decisions have been committed.

## Required outcome
- If an imported `@currentEnv` supersedes the fallback, import and disable decisions must reflect the selected environment, with no fallback-specific source affecting the final graph.
- Add a regression case where fallback and selected environments produce opposite results for an import condition or imported `@disable`.

@chriscors

Copy link
Copy Markdown
Contributor Author

Confirmed as real — and it fails on main as well, so it isn't something this PR introduces.

I built your case as a test:

fallbackEnv: 'staging',
files: {
  '.env.schema': `# @import(./shared/)
                  # @import(./.env.azure, enabled=eq($AUTH_MODE, "azure"))
                  # ---
                  AUTH_MODE=none`,
  'shared/.env.schema': `# @currentEnv=$APP_ENV
                         # ---
                         APP_ENV=dev`,
  '.env.staging': 'AUTH_MODE=azure',
  '.env.azure':   'AZURE_ITEM=val-from-.env.azure',
},
expectNotInSchema: ['AZURE_ITEM'],

AZURE_ITEM is present both on main and on this branch. On main the directory never resolves the imported @currentEnv at all, so it stays on staging and the azure import is enabled by the fallback — the same end state, just self-consistently wrong rather than inconsistently.

The two findings can't both be satisfied in one pass. Your first one requires .env.<fallback> to load before imports, because import conditions read those values. This one requires import decisions to reflect an @currentEnv that only exists after imports are processed. That's circular: the env flag is defined by an import, and the imports are gated on values selected by the env flag.

Breaking it needs a design change, and I don't think it belongs in a bug-fix PR from a first-time contributor. The two shapes I can see:

  1. Env-flag pre-pass — process only the imports that can define the env flag, resolve @currentEnv, then process the remaining (conditional) imports with the right env. Cheapest, but needs a rule for which imports are "env-flag-defining", and is still circular if such an import is itself conditional.
  2. Two-phase load with teardown — load speculatively under the fallback and, if the resolved env differs, discard the directory's sources and reload. Correct by construction, but needs source/item removal, which is the same machinery your other comment's "must not remain in the graph" would need.

What this PR does is strictly narrower: the env flag now resolves correctly through an import, which is what makes per-app .env.[env] files work at all in a monorepo. It doesn't make the conditional-import case worse — that behaves identically to main.

So: happy to leave the PR scoped here, or to attempt (1) or (2) if you tell me which you'd prefer and whether the removal machinery is something you'd want to exist. I can also add the case above as a skipped/todo test documenting the limitation, if that's useful to you — say the word and I'll push it.

@theoephraim

Copy link
Copy Markdown
Member

Thanks again for digging into this. Your original commit (b012969) had the right semantics: --env should be a true last resort, and the review feedback that pushed you off it was from an automated reviewer asking for two mutually exclusive things. I've cherry-picked that commit as-is (your authorship) into #1050 so it lands cleanly on main. Closing this in favor of that one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants