Validate config in GitHub Actions before deploy

The cheapest place to catch a missing DATABASE_URL is a GitHub Actions job; the most expensive is a crash-looping pod. This page adds a validation gate that runs before deploy, extending CI/CD Config Validation.

Two Actions features do all the work, and both are easy to leave out by accident. GitHub Environments decide which secrets a job can see, which is what makes validation meaningful rather than symbolic. Job dependencies decide whether a failure blocks anything, which is what makes it a gate rather than a notification. A workflow with both is a real control; a workflow missing either one looks identical in the UI and enforces nothing.

The two features that make the gate real The environment key scopes which secrets the validation job reads, and the needs key makes the deploy job depend on validation passing. both required — either alone enforces nothing environment: decides which secrets are visible makes the check meaningful without it: validates the wrong environment and passes anyway needs: decides whether failure blocks makes the check enforcing without it: the job goes red while the deploy runs beside it
One key decides what is checked, the other decides what a failure does — both are needed.

Problem 1: deploying first, discovering config errors second

# ANTI-PATTERN: no validation step — the bad config is found in production
jobs:
  deploy:
    steps:
      - run: ./deploy.sh        # boots the app for the first time in prod

The first time the settings model runs against production variables is in production. What happens next depends on the orchestrator: a rolling update may hold the old pods while the new ones crash-loop, which is the good case, or a recreate strategy may take the service down entirely while the new pods fail on a missing key. Either way the discovery happens with traffic involved and a rollback to consider, when the same information was available for free minutes earlier.

The rollback is the expensive part. A crash-looping deploy is usually recoverable, but the fix — set the missing variable, then redeploy — takes a full pipeline run while the incident is open, and it happens under the kind of time pressure that produces second mistakes. A validation job that fails before the deploy starts turns the same error into a red check on a pull request that nobody has to page anyone about.

Problem 2: validating against the wrong environment

# ANTI-PATTERN: validates with dev secrets, deploys to prod
- run: python -m ci.validate_config
  env: { DATABASE_URL: ${{ secrets.DEV_DATABASE_URL }} }   # not the deploy target

Passing dev secrets proves nothing about the environment you are about to deploy to. This is the subtler failure of the two, because the workflow looks complete: there is a validation step, it runs the real script, and it goes green. What it actually proves is that the model can be constructed from some set of values — a fact you already knew from local development.

The reason this happens is that repository-level secrets are the path of least resistance. They are visible to every workflow with no extra configuration, so a quick “add validation” pull request naturally reaches for them, and the difference only shows up when production has a key that development does not. Naming secrets identically across environments and letting the environment: key select the right set removes the temptation entirely: the workflow refers to secrets.DATABASE_URL and GitHub resolves it per environment.

Repository secrets versus environment secrets Repository secrets are the same for every job regardless of target, while environment secrets resolve to the values belonging to the deployment target named by the environment key. repository secrets one set for every job DEV_DATABASE_URL, PROD_… the workflow picks by name easy to validate one target and deploy to another environment secrets one set per environment DATABASE_URL in each environment: selects the set the same reference resolves differently per target
Identical secret names plus the environment key make the workflow target-agnostic — and impossible to point at the wrong set.

Secure implementation

# .github/workflows/deploy.yml
jobs:
  validate:
    runs-on: ubuntu-latest
    environment: production            # pull this environment's protected secrets
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r requirements.txt
      - run: gitleaks detect --no-banner
      - run: python -m ci.validate_config
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}
  deploy:
    needs: validate                    # deploy only if validation passed
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

The validate job uses GitHub Environments so it reads the production secrets, and deploy declares needs: validate so a failed model construction blocks the rollout. Note that secrets are passed through env: on the individual step rather than at the job level. That scoping means the pip install and gitleaks steps run without the credentials in their environment at all, so a compromised or over-curious dependency in the install step has nothing to read.

The environment: production key has a side effect worth knowing about: if that environment has protection rules — required reviewers, a wait timer, a branch filter — the job waits for them. For a validation job that is usually not what you want, since the point is fast feedback before anyone is asked to approve anything. The common arrangement is to keep approvals on the deploy job and either accept the wait on validation or define a separate environment carrying the same secrets with no protection rules.

The matrix form has one property worth calling out: each entry is an independent job with its own environment, so a staging failure and a production failure are reported separately and neither blocks the other’s validation. That is usually what you want during a promotion, because knowing that production is missing a key and staging is fine is more useful than a single red job that stops at the first problem. Pair it with fail-fast: false if you rely on that, since the default cancels sibling matrix jobs as soon as one fails.

Scaling to several targets is a matrix over environment names, with each entry validating against its own secrets in parallel:

# validate every target with its own secrets, in parallel
jobs:
  validate:
    strategy:
      matrix:
        target: [staging, production]
    environment: ${{ matrix.target }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: python -m ci.validate_config
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}   # resolves per environment
Step-scoped secrets keep them out of the install step Checkout and dependency installation run without credentials in the environment, and only the validation step receives the secrets it needs. secrets enter at the last possible step checkout no secrets pip install no secrets gitleaks no secrets validate_config — env: DATABASE_URL, API_KEY the only step that can read them
Job-level env would hand the credentials to every step, including third-party actions and the dependency install.

Reporting the failure where people will see it

A validation job that fails with a stack trace buried in step logs is technically a gate and practically an annoyance. Actions gives two mechanisms for surfacing the result where it will be read, and both take a couple of lines.

The job summary is a Markdown file the runner renders at the top of the run page. Writing the missing keys there means the person who opened the pull request sees the actual problem without expanding a single step:

# ci/validate_config.py — add a summary for humans
import os, sys
from pydantic import ValidationError
from config.settings import Settings

def report(lines: list[str]) -> None:
    path = os.environ.get("GITHUB_STEP_SUMMARY")
    if path:                                  # no-op outside Actions
        with open(path, "a") as fh:
            fh.write("\n".join(lines) + "\n")

def main() -> int:
    try:
        Settings()
    except ValidationError as exc:
        rows = [f"| `{'.'.join(str(p) for p in e['loc'])}` | {e['msg']} |"
                for e in exc.errors()]
        report(["### Config validation failed", "", "| field | problem |",
                "| --- | --- |", *rows])
        print("config validation failed", file=sys.stderr)
        return 1
    report(["### Config validation passed", "", "All required keys resolved."])
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

The second mechanism is workflow commands: printing a line beginning with ::error file=… annotates the run and, when the file and line refer to something in the diff, attaches the message to the pull request’s changed-files view. That is worth doing when the failure is attributable to a specific file — a settings module that declared a new required field without a matching entry in the deployment manifest, say.

Both are strictly output formatting; neither changes what is checked. They matter because the difference between a gate people act on and a gate people re-run is almost entirely about how quickly the failure explains itself. A table naming three missing keys is acted on immediately; a ValidationError traceback in step seven of a collapsed log is re-run first and read second.

Where a validation failure gets reported Step logs require expanding a collapsed section, while a job summary shows the missing fields at the top of the run page and annotations attach to the pull request diff. same failure, three levels of visibility step log only collapsed by default traceback among output re-run, then read job summary top of the run page table of field + problem read at a glance annotation ::error file=… attached to the diff impossible to miss
The check is identical in all three — only the odds of someone reading it before re-running change.

Gotchas & version-specific behaviour

  • Use environment: to scope secrets to the deploy target, not repo-wide secrets.
  • needs: is what gates deploy on validation — without it the jobs run independently.
  • Mark required reviewers on the production environment for a manual gate if needed.
  • Keep extra="forbid" so a stray repo secret injected as an env var fails the job.
  • Secrets are not available to workflows triggered by pull_request from a fork, so a validation job on fork pull requests fails on missing keys rather than on real problems — gate it with an if: on the event.
  • GitHub masks secret values in logs by exact match; a value that is transformed before printing — base64-encoded, URL-escaped, embedded in JSON — is no longer masked.

The fork caveat trips up open-source repositories in particular. The workflow behaves perfectly for internal branches and fails confusingly on every external contribution, which trains maintainers to ignore a permanently red check. Either restrict the validation job to non-fork events, or split it in two: run the model’s unit tests, which need no secrets at all, on every pull request, and run the environment validation only where secrets are available.

The masking caveat has a practical consequence for the validation script itself. Because masking is a literal substring match on the log stream, any transformation defeats it — and a settings model is full of transformations. A PostgresDsn field re-serialises the URL, a validator may normalise a host, and model_dump_json() escapes characters. That is the concrete reason the script prints field names rather than values: not because masking is unreliable in principle, but because the values a settings model holds have usually been reshaped by the time anything could print them, and the reshaped form is not what the masker is looking for.

Production parity checklist

  • The validate job reads the same environment’s secrets the deploy will use.
  • deploy declares needs: validate.
  • gitleaks runs before validation.
  • Secrets are masked; validation prints key names only.
  • The job runs on the Python version used in production.
  • Secrets are scoped to the step that needs them, not to the whole job.

The last item is the cheapest hardening available in Actions and the least commonly applied. Every third-party action in a job with job-level secrets can read them, and a typical workflow uses several actions maintained by people the team has never evaluated. Moving the env: block down to the one step that needs the values costs two lines of indentation and removes that exposure for the rest of the job.

Pinning actions by commit SHA rather than by tag belongs in the same category of cheap hardening. A tag is mutable, so @v4 resolves to whatever the maintainer last pushed under that name, and a validation job holding production secrets is a high-value place for that to matter. Pinning costs a longer line and a periodic update, and it makes the workflow’s supply chain reproducible.

One more structural point about needs: is worth stating plainly, because it is where multi-job workflows quietly lose the gate. A dependency edge is transitive but not implicit: deploy needing build, and build needing nothing, means validation running in parallel is not upstream of anything. When a workflow grows past three or four jobs, the reliable way to check the property is to read the needs: keys and ask whether every deployment job has a path back to a validation job. If the answer requires tracing more than two edges, the workflow is probably worth flattening into something a reviewer can hold in their head.

if: always() deserves a similar caution. It is a legitimate tool for cleanup and notification jobs, and it is also the most common accidental way to defeat a gate — a deploy job carrying if: always() runs regardless of whether its dependency failed, which is precisely the behaviour the dependency was added to prevent. Reserve it for jobs that genuinely must run after failures, and never put it on a job that ships anything. A quick grep for always() in the workflow directory is a reasonable thing to run before trusting a pipeline you did not write yourself.

Key takeaways

Gate deploy behind a validate job that constructs the model with the target environment’s secrets, and a bad config never reaches a pod. The two things to get right are structural rather than clever: use environment: so the job reads the values the deployment will actually use, and use needs: so a failure blocks rather than merely reports. Everything else — the matrix over targets, step-scoped secrets, restricting the job on fork events, SHA-pinned actions — refines those two without substituting for either. A useful way to audit an existing workflow is to answer three questions in order: which secrets does the validation job actually see, can any deploy job start without it passing, and would the person who broke it understand the failure from the run page alone. If all three answers are good, the gate is doing its job; if any is not, the fix is a few lines of YAML rather than a redesign. For the GitLab equivalent, see GitLab CI Config Validation Stage.