Your local .env falls out of sync with the canonical config your CI pipeline produces, so the app boots against stale hosts and missing keys. This page shows how to download a CI-generated config artifact and turn it into a validated local .env deterministically, as part of the broader environment sync and CI parity baseline and its dotenv configuration management practices.

The failure is easy to spot once you name it: the pipeline resolves a full set of variables — database hosts, cache TTLs, feature flags, TLS toggles — into a single artifact, but every engineer's laptop carries a hand-edited copy that was last correct three sprints ago. Because nothing forces the two to reconcile, the divergence is silent until a service crashes on boot or, worse, connects to the wrong backing store. The rest of this guide replaces the hand-edited copy with a generated one whose provenance is the CI run itself.

The approach rests on one inversion of ownership: the local .env stops being a file you edit and becomes a file you derive. You never open it in a text editor again. Instead, a single command rebuilds it from the most recent trustworthy artifact, and every other control on the page exists to make that command safe to run repeatedly — authenticated, atomic, permission-locked, and validated. Once the file is a build output rather than a source, the question "is my config correct?" reduces to "did I regenerate from the current artifact?", which is answerable with a timestamp instead of a code review.

Diagnostic

Compare your local file against the template's key set. Drift shows up as keys present on one side only.

#!/usr/bin/env bash
set -euo pipefail

diff <(grep -oE '^[A-Z_]+' .env.example | sort) \
     <(grep -oE '^[A-Z_]+' .env.local | sort) \
  && echo "PARITY OK" || echo "DRIFT DETECTED"

Expected BAD output — keys diverge and the file is stale:

2a3
> CACHE_TTL
5d5
< REDIS_TLS_VERIFY
DRIFT DETECTED

The diff above only compares key names, which catches added and removed variables but not value drift. A key that exists on both sides with a different value — POSTGRES_HOST=localhost locally versus postgres.internal in the artifact — passes this check and still breaks the app. Widen the diagnostic to compare the full KEY=VALUE line set once you have the artifact in hand, then treat any non-empty diff as a signal to regenerate rather than to hand-patch.

Confirm whether the artifact you need still exists and is reachable:

#!/usr/bin/env bash
set -euo pipefail

gh api repos/{owner}/{repo}/actions/artifacts \
  --jq '.artifacts[] | select(.name=="env-config") | {name, created_at, expires_at}'

Expected BAD output — the artifact has aged out:

{"name":"env-config","created_at":"2026-02-14T14:02:11Z","expires_at":"2026-05-15T14:02:11Z"}

An expires_at in the past means the download will 404 no matter how you authenticate: GitHub garbage-collects the blob storage on expiry, and the API row lingers only briefly after. Before you spend time debugging tokens, decide from the timestamps whether you are fetching a live artifact or need to trigger a fresh run. The decision tree below captures that branch.

Artifact freshness decision A decision from checking the expiry timestamp toward either downloading the artifact or triggering a fresh CI run. Is the Artifact Usable? expires_at in the future? and status success? Yes download and regenerate No re-run pipeline first
Check expiry before authentication so you debug the right failure.

Root cause

CI runners are ephemeral and isolated: artifacts expire under a retention policy, and secret masking can strip required payloads if you dump raw .env text through the log surface. When the local file was hand-edited or the artifact was never downloaded, the two states drift apart. The fix is to treat the CI artifact as the source of truth, fetch it over an authenticated channel, parse it from a structured format (JSON/YAML rather than a masked raw dump), and write it atomically so a partial download never leaves a half-written .env.

Two properties of CI environments make this drift structural rather than accidental. First, runners are provisioned fresh per job and discarded, so the only durable output is whatever you explicitly upload as an artifact — there is no persistent filesystem to read back later. Second, the platform's log redaction rewrites any string that matches a registered secret, which means a raw .env echoed into logs comes back with values replaced by ***. Emitting structured JSON as the artifact sidesteps both problems: the values travel inside an uploaded blob rather than through the log surface, and a parser can validate types and required keys instead of trusting a flat text blob. The pipeline stage that assembles this JSON becomes the single authority, and every laptop derives its .env from it.

There is a third, quieter cause worth calling out: format ambiguity. A flat .env dump has no schema, so a value like DEBUG=false is indistinguishable from the string "false", and a quoted PORT="5432" reads back differently than a bare 5432 depending on the loader. When the artifact is JSON, types survive the round trip — booleans stay booleans, numbers stay numbers — and the conversion to KEY=VALUE becomes the one place where you decide serialization rules explicitly instead of inheriting whatever the shell happened to do. That determinism is what lets two engineers on different operating systems regenerate byte-identical files from the same run.

CI artifact to local env pipeline A left-to-right flow from the CI build producing a JSON artifact, through authenticated download and parsing, to an atomic write of the local env file. Artifact to .env Pipeline CI build emits config.json Download scoped token Parse jq to KEY=VALUE Atomic mv .env.local Structured JSON travels in the blob, never through the log surface.
The artifact is the source of truth; each laptop derives its .env from it.

Resolution

Run these four steps in order. Each is idempotent — re-running regenerates the file from the current artifact rather than mutating your local edits in place, which is exactly the property you want.

  1. Authenticate the CI CLI with a short-lived, scoped token. Prefer a token restricted to actions:read for a single repository over a broad personal token, and let it expire rather than storing it long-term:
    #!/usr/bin/env bash
    set -euo pipefail
    gh auth login --with-token <<< "${GITHUB_TOKEN}"
    gh auth status
  2. Download and unpack the latest successful artifact into a staging directory. Staging under /tmp keeps a corrupt or partial download away from your working tree until you have verified it:
    #!/usr/bin/env bash
    set -euo pipefail
    gh run download "${CI_RUN_ID}" -n env-config -D /tmp/ci-artifacts
    unzip -o /tmp/ci-artifacts/env.zip -d /tmp/ci-artifacts
  3. Convert the structured payload to .env lines and write atomically via a temp file. The mv is the commit point: because a rename on the same filesystem is atomic, any reader either sees the old file or the new one, never a truncated mix:
    #!/usr/bin/env bash
    set -euo pipefail
    [ -f .env.local ] && cp .env.local .env.local.bak
    jq -r 'to_entries[] | "\(.key)=\(.value)"' /tmp/ci-artifacts/config.json > .env.local.tmp
    mv .env.local.tmp .env.local
  4. Lock down permissions so the file is not world-readable. A generated .env inherits the umask of whatever process wrote it, which is often 644 — readable by every user on a shared machine:
    #!/usr/bin/env bash
    set -euo pipefail
    chmod 600 .env.local
    stat -c '%a' .env.local   # expect: 600

The temp-file-then-rename pattern in step 3 is the load-bearing detail. If you redirect jq straight into .env.local, a network hiccup or a jq parse error mid-stream leaves the file half-written and the app reads garbage on its next boot. Writing to .env.local.tmp first means the destination is only ever replaced by a fully materialized file, and the earlier cp to .env.local.bak gives you a one-command rollback if the new values turn out wrong.

Expected output

Logged in to github.com account ci-bot
Downloading artifact env-config...
Archive:  /tmp/ci-artifacts/env.zip
  inflating: /tmp/ci-artifacts/config.json
Injected 24 variables
600

A clean run ends with the file mode 600 echoed back, confirming the permission tightening in step 4 took effect. If you see 644 here, the chmod was skipped or overridden by an editor that rewrote the file — re-run the final step before trusting the result on a multi-user host.

The line Injected 24 variables is worth asserting on in CI-adjacent scripts: a sudden drop to a handful of keys usually means the artifact was truncated or jq matched an empty object, and catching that count regression is cheaper than debugging why a downstream service cannot find its database URL. Wrap the whole flow in a wrapper that fails non-zero when the emitted count falls below a known floor, and you turn a silent partial write into a loud, actionable error at generation time rather than at application boot.

Prevention

Generating the file once is not enough; the goal is to make a stale .env impossible to keep. Each control below closes a different gap — schema drift, branch switches, and payload corruption respectively.

  • Validate the generated file against a schema derived from the same artifact, and fail non-zero on any violation:
    #!/usr/bin/env bash
    set -euo pipefail
    dotenv-validator --schema .env.schema.json --file .env.local --strict --exit-code
  • Add a post-checkout Git hook that re-validates .env.local so a stale file is caught the moment you switch branches. A branch that introduced a new required variable will fail the hook immediately instead of at runtime:
    #!/usr/bin/env bash
    set -euo pipefail
    # .git/hooks/post-checkout
    dotenv-validator --schema .env.schema.json --file .env.local --strict --exit-code \
      || echo "WARNING: .env.local drifted from schema — re-run the artifact sync"
  • Set explicit artifact retention (retention_days: 30) and ship structured JSON, not raw masked .env dumps, so masking never corrupts the payload. The schema rules live in environment variable validation.

The measurable payoff is fewer regeneration events over a sprint: teams that only sync on demand tend to accumulate drift until something breaks, while a validating hook flattens the curve by catching each divergence at its source. The chart below contrasts the two regimes.

Drift incidents per sprint by workflow Bar chart comparing drift incidents per sprint across manual editing, on-demand sync, and hook-enforced sync. Drift Incidents per Sprint manual edit 12 on-demand sync 5 hook-enforced 1
Enforcing the sync at checkout time collapses drift toward zero.

Platform caveats

WSL2: A CRLF-terminated artifact breaks jq line splitting and validator parsing. Set core.autocrlf=input and pipe through sed 's/\r$//' before the atomic mv. macOS: stat -c is GNU syntax; use stat -f '%Lp' .env.local on BSD/macOS to read the mode. Apple Silicon (ARM64): If you invoke a containerized dotenv-validator image, pull the arm64 variant or set --platform linux/arm64 — an emulated amd64 binary adds seconds per validation and can mask architecture-specific parsing bugs.

Rollback

#!/usr/bin/env bash
set -euo pipefail
[ -f .env.local.bak ] && mv .env.local.bak .env.local && echo "Restored prior .env.local" || echo "No backup; re-run sync"

Because step 3 wrote .env.local.bak before regenerating, this restores the exact file you had before the sync. If no backup exists — a first-ever run, or a machine where you skipped the cp — the safe move is to re-run the full Resolution flow rather than hand-editing, so the file's provenance stays the CI artifact.

Frequently Asked Questions

Why write to a temp file and mv instead of redirecting jq straight to .env.local?

A rename within the same filesystem is atomic, so any process reading .env.local sees either the complete old file or the complete new one — never a half-written mix. Redirecting jq output directly truncates the destination the instant the command starts; if jq errors on malformed JSON or the download was partial, you are left with a corrupt .env that the app reads on its next boot. The temp-file pattern makes the swap all-or-nothing.

The gh api call shows the artifact but gh run download returns 404 — what happened?

Almost always the artifact expired. The API row for an artifact lingers briefly after its expires_at, but the underlying blob is garbage-collected on schedule, so the metadata query succeeds while the download fails. Check the expires_at timestamp first; if it is in the past, re-run the pipeline to produce a fresh artifact instead of debugging your token.

Should the CI artifact contain plaintext secrets, or only non-sensitive config?

Keep true secrets out of the generated .env. Ship non-sensitive config — hosts, ports, feature flags, TTLs — in the artifact, and resolve secrets locally from a vault so they never sit in a downloadable blob or in CI logs. See rotating secrets without restarting containers for keeping those values current without a full regeneration.

How do I catch value drift, not just missing or extra keys?

The name-only diff in the Diagnostic compares key sets, so a key present on both sides with different values slips through. After downloading the artifact, compare the full KEY=VALUE line set — for example diff <(sort .env.local) <(jq -r 'to_entries[]|"\(.key)=\(.value)"' config.json | sort) — and treat any non-empty result as a signal to regenerate rather than to hand-patch the local file.