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.

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

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"}

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.

Resolution

  1. Authenticate the CI CLI with a short-lived, scoped token:
    #!/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:
    #!/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:
    #!/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:
    #!/usr/bin/env bash
    set -euo pipefail
    chmod 600 .env.local
    stat -c '%a' .env.local   # expect: 600

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

Prevention

  • 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.
  • 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.

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.

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"