Automating .env File Generation from CI Artifacts
Generate .env files from CI artifacts to kill local config drift. Securely download, parse, and validate CI-produced config into a local .env with an atomic write.
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
- 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 - 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 - Convert the structured payload to
.envlines 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 - 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-checkoutGit hook that re-validates.env.localso a stale file is caught the moment you switch branches. - Set explicit artifact retention (
retention_days: 30) and ship structured JSON, not raw masked.envdumps, so masking never corrupts the payload. The schema rules live in environment variable validation.
WSL2: A
CRLF-terminated artifact breaksjqline splitting and validator parsing. Setcore.autocrlf=inputand pipe throughsed 's/\r$//'before the atomicmv. macOS:stat -cis GNU syntax; usestat -f '%Lp' .env.localon 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"