DEV Community

Oleksandr Kuryzhev
Oleksandr Kuryzhev

Posted on • Originally published at kuryzhev.cloud

GitHub Actions Reusable Workflow Environment Protection Checklist

Originally published on kuryzhev.cloud


Why this checklist

Last quarter one of our platform teams shipped a shiny .github/workflows/reusable-deploy.yml from a central repo. Every service team switched their pipelines over to call it via workflow_call within a week. It looked great — one workflow, consistent deploy logic, less duplicated YAML across forty repos. Then someone noticed a "protected" production deploy had gone out with no approvals at all, no wait timer, nothing. The environment protection rules reusable workflows are supposed to enforce simply weren't there.

The root cause is something GitHub doesn't explain clearly anywhere obvious: protection rules apply to the job that references an environment, evaluated against the caller repo's settings — not the repo that hosts the reusable workflow. If the consumer repo never created an environment named exactly production, GitHub just auto-creates one on first run, with zero reviewers, zero branch policy, zero wait timer. It doesn't error. It doesn't warn. It deploys.

This causes two failure classes we've seen repeatedly. First, secrets over-exposure — teams reach for secrets: inherit because it's convenient, and suddenly the reusable workflow has access to every secret the caller repo owns, including ones it never needed. Second, silent unprotected deploys — an environment name typo (Production vs production) creates a brand new, completely open environment instead of failing the run. We built this checklist after auditing 40+ repos and finding both problems live in prod.

The checklist (numbered)

Run through this before you trust any reusable workflow + environment combo touching production.

  1. Environment exists in the calling repo, not just the source repo. Check Settings → Environments in the consumer repo itself. If it's missing, GitHub will silently create an unprotected one on the first run.
  2. Environment name is case-exact. environment: Production in the workflow input vs production in repo settings creates two different environments. Diff them character by character if you're paranoid — we now are.
  3. Permissions declared explicitly per job. The top-level permissions: block from the caller does not flow into the reusable workflow's jobs. Declare contents: read, id-token: write, etc. inside the reusable workflow itself, especially for OIDC-based cloud auth.
  4. No secrets: inherit. Replace it with an explicit mapping like secrets: { DEPLOY_TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }} }. Inherit passes everything, used or not — a bigger blast radius than most teams realize until an incident review forces them to enumerate it.
  5. Required reviewers list is populated and sane. Max is 6 users/teams per environment — if you're at the cap, that's usually a sign the team is too big for a single gate.
  6. Wait timer value is intentional. Valid range is 0–43200 minutes (30 days). We've found wait timers left at defaults nobody remembers setting.
  7. Deployment branch policy isn't "all branches." For prod, it should be "protected branches only" or a tight glob like release/*.
  8. Outputs are declared under workflow_call.outputs. Referencing an undeclared output returns an empty string with no error — a silent failure that's brutal to debug at 2am.
  9. Concurrency group is set explicitly inside the reusable workflow. It is not inherited from the caller. Skip this and you can get two parallel deploys hitting the same environment.
  10. Nesting depth ≤4, total reusable calls ≤20 per run. Exceed either and you get "The workflow is not valid... too many levels of nested workflows" — usually discovered mid-incident, not in review.
  11. Self-hosted runner labels passed as inputs. runs-on can't be overridden directly by the caller; interpolate it via ${{ inputs.runner_label }}.

Here's a working example that gets most of this right — pin the version, don't call @main:


# .github/workflows/reusable-deploy.yml (lives in platform repo, called by consumers)
on:
  workflow_call:
    inputs:
      env_name:
        required: true
        type: string
      runner_label:
        required: false
        type: string
        default: ubuntu-latest
    secrets:
      DEPLOY_TOKEN:          # must be explicitly declared to receive it via non-inherit calls
        required: true
    outputs:
      deployment_url:
        description: "URL of the deployed environment"
        value: ${{ jobs.deploy.outputs.url }}

permissions:
  contents: read             # explicit — do NOT rely on caller's permissions being inherited

concurrency:
  group: deploy-${{ inputs.env_name }}   # prevents parallel deploys to same environment
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ${{ inputs.runner_label }}
    environment: ${{ inputs.env_name }}   # protection rules evaluated against CALLER repo's env
    outputs:
      url: ${{ steps.set_url.outputs.url }}
    steps:
      - uses: actions/checkout@v4

      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: |
          echo "Deploying to ${{ inputs.env_name }} on ${{ inputs.runner_label }}"
          # actual deploy command here

      - id: set_url
        run: echo "url=https://${{ inputs.env_name }}.example.com" >> "$GITHUB_OUTPUT"

---
# .github/workflows/ci.yml (consumer repo — this is where the environment must exist)
on: [push]

jobs:
  call-deploy:
    uses: my-org/platform-workflows/.github/workflows/reusable-deploy.yml@v1.4.0  # pin, don't use @main
    with:
      env_name: production        # must exactly match an environment created in THIS repo's settings
      runner_label: ubuntu-latest
    secrets:
      DEPLOY_TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }}   # explicit, not `inherit`

Commonly missed items

These pass a five-minute review but bite you during an actual incident.

Watch out for: fork-originated pull requests. If your workflow triggers on pull_request from a fork, GitHub withholds environment secrets entirely — regardless of approvals — even if the job references a protected environment. We spent an hour once debugging a "missing secret" error that turned out to be expected fork behavior. If you need this to work, switch to pull_request_target or split the deploy step into a separate workflow_run trigger. See the GitHub Actions docs on reusable workflows for the exact secret-passing rules.

Watch out for: cross-org or cross-repo reusable workflow calls. Teams often check the environments configured in the repo hosting the reusable workflow (the platform repo) and assume that's what's enforced. It isn't. GitHub evaluates protection against the caller repo's environment of the same name. I've seen teams audit the wrong repo entirely and walk away confident nothing was wrong.

Approval fatigue is the third trap, and it's a human one, not a config one. When workflow A calls workflow B, and B references an environment, the approval UI at two levels of nesting doesn't clearly surface which environment is actually being targeted. Reviewers click "approve" without registering it's prod. Fix this by naming jobs explicitly — name: Deploy to ${{ inputs.env_name }} — so the environment shows up in the run summary, not just buried in YAML.

One thing worth knowing: jobs paused waiting on approval don't burn billable Actions minutes. So there's no cost excuse for skipping gates — it's effectively free insurance against exactly this class of incident.

Automation ideas

Manual review doesn't scale past a handful of repos, so we automated most of this checklist.

First, codify environments with Terraform instead of clicking through Settings → Environments. The github_repository_environment and github_repository_environment_deployment_policy resources make reviewers, wait timers, and branch policy diffable in a PR — no more "who changed the reviewer list and when."

Second, run a scheduled audit across the org. This catches drift between what Terraform says should exist and what's actually configured (someone always clicks around in the UI eventually):


# audit-environments.sh — checks all repos in an org for weak/missing protection rules
ORG="my-org"

for repo in $(gh repo list "$ORG" --limit 200 --json name -q '.[].name'); do
  envs=$(gh api "repos/$ORG/$repo/environments" --jq '.environments[].name' 2>/dev/null)

  for env in $envs; do
    reviewers=$(gh api "repos/$ORG/$repo/environments/$env" \
      --jq '.protection_rules[] | select(.type=="required_reviewers") | .reviewers | length')

    if [ -z "$reviewers" ] || [ "$reviewers" -eq 0 ]; then
      echo "⚠️  $repo/$env has NO required reviewers configured"
    fi
  done
done

# Example output:
# ⚠️  billing-service/production has NO required reviewers configured
# ⚠️  legacy-cron/prod has NO required reviewers configured

Third, gate this at PR time, not after merge. A custom actionlint plugin or a small Python script can fail CI if a reusable workflow call uses secrets: inherit or a called workflow omits an explicit permissions: block. We wired this into our pre-merge checks alongside the broader CI/CD checklist we use for release gates, and it's caught at least three risky PRs before they hit main.

None of this is exotic tooling. It's Terraform, a bash loop, and a linter rule — the same pattern I'd apply to any environment protection rules reusable workflows setup where "we'll just review it manually" is the thing that eventually fails at 2am.

Related

Top comments (0)