Turborepo · beginner
Turborepo environment input omission: explain incorrect cache reuse
Turborepo caches task outputs by hashing inputs declared in the task's pipeline definition, including environment variables explicitly listed via `env`. When a task's output actually depends on a variable that is not included in that list, the cached artifact is keyed without that variable's value, and a later run with a changed variable will reuse stale output as if nothing changed. This is the "environment input omission" failure mode: cache hit rate stays high while semantic correctness collapses.
The symptoms
- •A task prints fresh logs (process restarted) yet its artifact is byte-identical to a previous run; `turbo run <task> --dry-run` shows the task as `"cache": true, "hash": "..."` despite a changed environment variable.
- •Behavior diverges only when an unlisted env var flips: e.g., `NODE_ENV`, `API_BASE_URL`, `SENTRY_DSN`, or feature flags change between runs but the output tarball (`.turbo/cache/<hash>.tar.gz`) remains unchanged.
- •Two environments with identical inputs but different shell-exported variables produce identical cached output; downstream consumers fail in the second environment while the cache reports "HIT".
- •`turbo run <task> --summarize` shows a miss only on inputs the user expects to be irrelevant, never on the variable they know changed; cache logs do not mention the missing key.
Likely causes
- •The task's pipeline entry lists `env: ["CI", "NODE_ENV"]` but the implementation reads `process.env.SOMETHING_ELSE` (e.g., a new API URL or auth token) that was added without updating the key.
- •A `dotenv` / `.env` file was introduced, and the task reads it via `require()` rather than Turborepo's `dotenv` / `globalEnv`, so those keys are invisible to the hash.
- •The `env` glob uses a prefix that the new variable does not match, e.g., `env: ["PUBLIC_*"]` while the variable is `PRIVATE_API_URL` and was assumed to be covered.
- •A task depends on a sibling package's runtime export (a TS constant from a workspace) instead of a `deps` or `inputs` entry, so changing that file across runs does not invalidate the hash.
- •OS-level or CI-injected variables (`GITHUB_SHA`, `CI_COMMIT_REF_SLUG`, custom runner tags) are not enumerated, and the build silently consumes them via shell substitution inside the script.
First ten minutes
- 01Run `turbo run <task> --summarize` in the affected workspace and record the `hash` for the task; compare it to the hash from the previous run. Identical hashes with different env vars is the primary signal.
- 02Open `turbo.json` and locate the task definition; copy the `env` array verbatim and treat it as the authoritative list of hashed environment inputs.
- 03Execute `env | sort > before.txt` before the run and `env | sort > after.txt` after, then `diff before.txt after.txt` to enumerate exactly which variables changed between the two invocations.
- 04Inspect `.turbo/cache/<hash>.tar.gz` (or the remote cache blob) for any string the variable would embed (URLs, tokens, paths); presence of the new value confirms the cache, absence confirms omission.
- 05Force a re-run with `turbo run <task> --force` to confirm the cache miss yields correct output; this isolates staleness from a separate code bug.
Evidence to collect
- •The exact `env` array from `turbo.json` for the affected task, and the full task definition (including `inputs`, `outputs`, `dependsOn`, `cache`).
- •The diff of process environment variables between the two runs that produced diverging behavior, restricted to variables not present in the `env` array.
- •Grep results showing where each candidate variable is consumed inside the task script (e.g., `grep -n 'process.env.X' packages/*/scripts/*`).
- •The hash reported by `--dry-run` or `--summarize` across the two runs; identical hashes are the dispositive evidence of omission.
- •The contents of the cached archive at `.turbo/cache/<hash>.tar.gz` confirming whether the new variable's value is actually serialized into the artifact.
Where to look
- •Boundary: the Turborepo cache-key computation boundary, defined per-task in `turbo.json` under `pipeline.<task>.env` and `pipeline.<task>.inputs`. This is the only place environment values enter the hash for a given task.
- •Boundary: the task execution boundary inside `pipeline.<task>` script. Anything the script reads from `process.env`, shell variables, or `.env` files that is not declared upstream is invisible to the hash.
- •Boundary: the `globalEnv` array at the top level of `turbo.json`, which hashes every task in the repo against those variables regardless of per-task `env`.
- •Boundary: the dotenv loading boundary, governed by `pipeline.<task>.dotenv` (or `globalDotenv`); a file loaded inside the script via `require('dotenv').config()` is outside Turborepo's awareness.
- •Boundary: the CI runner injection boundary; variables exported by GitHub Actions, GitLab CI, Buildkite, or custom runner scripts are part of `process.env` and must be enumerated to participate in the hash.
Diagnostic steps
- 01Step 1 — Reproduce divergence: run the task twice with one variable changed between runs (e.g., toggle `API_BASE_URL`). If the second run reports `cache: hit` and produces the first run's output, omission is confirmed.
- 02Step 2 — Constrain the candidate set: take `diff before.txt after.txt` and subtract the contents of `turbo.json` `env` (and `globalEnv`). The remainder is the suspect list.
- 03Step 3 — Trace consumption: for each suspect variable, grep the task's script and any imported modules for `process.env.<NAME>` or shell interpolation. Variables with no consumer are noise; variables with a consumer are the omission candidates.
- 04Step 4 — Verify absence from hash: rebuild with `--summarize` and inspect the `hash` field. Add the candidate to `env`, rerun, and require that the hash change. No change means the variable was not actually influencing the output (revisit Step 3).
- 05Step 5 — Rule out inputs vs env: if the candidate is a file path or workspace constant, the fix belongs in `inputs` or `dependsOn`, not `env`. Reclassify before editing.
- 06Step 6 — Distinguish global vs per-task: if the variable affects every task in the repo (e.g., deploy target), add it to `globalEnv`; otherwise scope it to the specific task's `env`.
Common mistakes
- •Adding the variable to `inputs` instead of `env`: file inputs are hashed by content; environment variables are hashed by name and value. Misclassifying means the hash still does not capture the variable's change.
- •Using a wildcard like `env: ["*"]`: Turborepo does not support catch-all globbing in `env`; only literal names or, where supported, prefix globs are recognized. Assuming otherwise silently leaves the variable unkeyed.
- •Editing `turbo.json` without clearing the local cache: the existing tarball may still match the prior hash and mask the fix on the next run until `--force` is used.
- •Assuming `.env` files are auto-hashed: they are only if listed in `dotenv` (per-task) or `globalDotenv`. Loading them via `require('dotenv')` inside the script bypasses hashing entirely.
- •Trusting the cache log alone: a "HIT" does not mean correct; it means the hash matched. Correctness requires that the hash actually reflects all inputs, which is what omission breaks.
Safe fixes
- •Fix (conditional on Steps 1–3 confirming a real consumer): add the variable's literal name to `pipeline.<task>.env` in `turbo.json`, scoped to the smallest task set that consumes it. Re-run with `--summarize` and require the hash to change.
- •Fix (conditional on Step 5 confirming a file/workspace source): add the source path or workspace dependency to `pipeline.<task>.inputs` or `dependsOn` respectively; do not add it to `env`.
- •Fix (conditional on dotenv being the source): move loading out of the script and declare the file under `pipeline.<task>.dotenv` so Turborepo reads and hashes its contents.
- •Fix (conditional on repo-wide impact): add the variable to the top-level `globalEnv` if every task depends on it; this is heavier on cache invalidation and should be reserved for genuinely global inputs.
- •Validation gate for any fix: run the divergent pair again; the second run must now show `cache: miss` and produce output containing the new variable's value. If it still hits, the omission is elsewhere.
Prove the fix
- 01Replay the divergent pair (variable X set to value A, then to value B). The second invocation must report `cache: miss` and the resulting artifact must embed value B; a third invocation with X=A must hit and return to the prior hash.
- 02Inspect `.turbo/cache/<hash>.tar.gz` (or the remote cache entry) and confirm the new value is present in the serialized outputs; absence means the artifact was not actually parameterized by X.
- 03Run `turbo run <task> --summarize` on a clean checkout where the variable is toggled three times; expect three distinct hashes in chronological order and no false hits.
- 04Downstream regression check: any consumer package that imports the task's output must observe the new value on the first run after the variable change, without manual `--force`.
Prevention and next steps
- •Treat `env` as a maintenance contract: any new `process.env` read inside a cached task script must be paired with a `turbo.json` edit in the same change. Encode this in code review by requiring the diff to touch `turbo.json` whenever a task script gains a new environment read.
- •Periodically run a "cache honesty" sweep: pick a non-production variable, toggle it, and verify the hash changes for tasks that read it; automate this in CI as a nightly job that asserts divergence.
- •Prefer making inputs explicit over implicit: read configuration from files declared in `inputs`, and use `env` only for values that genuinely come from the shell or CI runner. This keeps the cache key narrow and auditable.
Safe commands and checks
turbo run <task> --summarize turbo run <task> --dry-run turbo run <task> --force env | sort > before.txt && env | sort > after.txt && diff before.txt after.txt grep -n 'process.env.' scripts/<task>.js