Skip to main content
GitHub Actions · lesson 5 of 7
GitHub Actions advanced Lesson 5 of 7

Deployment Gates: needs Really Blocks, environment Doesn't (Under act)

A failed job genuinely stops needs: from running the next stage — but act runs straight through an environment: declaration with no approval gate at all, a real, documented substrate limitation worth knowing before trusting a local test of a production deploy.

By SkillByExample · Updated

github-actions deployment environments act

needs: and environment: both sound like they gate whether a job runs — only one of them is actually enforced by a local test.

needs: really blocks the next stage

# .github/workflows/deploy_progression.yml
name: DeployProgression
on: [push]
jobs:
  staging:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - run: echo "deployed to staging"
  production:
    needs: staging
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: echo "deployed to production"
act push -W .github/workflows/deploy_progression.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest
[DeployProgression/staging] ⭐ Run Main echo "deployed to staging"
[DeployProgression/staging]   | deployed to staging
[DeployProgression/staging] 🏁  Job succeeded
[DeployProgression/production] ⭐ Run Main echo "deployed to production"
[DeployProgression/production]   | deployed to production
[DeployProgression/production] 🏁  Job succeeded

staging completes fully before production starts — no interleaving, unlike the matrix lesson’s genuinely parallel jobs. needs: staging is a real dependency edge act reads straight from the YAML and enforces exactly as written.

The bug: a failed dependency skips the next stage entirely

# .github/workflows/deploy_fail.yml
name: DeployFail
on: [push]
jobs:
  staging:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - run: exit 1
  production:
    needs: staging
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: echo "deployed to production"
act push -W .github/workflows/deploy_fail.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest
[DeployFail/staging] ⭐ Run Main exit 1
[DeployFail/staging]   ❌  Failure - Main exit 1 [105.820234ms]
[DeployFail/staging] 🏁  Job failed
Error: Job 'staging' failed

No [DeployFail/production] line appears anywhere — production never started at all, not “ran and failed.” needs: defaults to running a job only if everything it depends on succeeded, and act enforces that exactly like GitHub’s real service does.

The limitation: environment: has no approval gate under act

# .github/workflows/deploy.yml
name: Deploy
on: [push]
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: echo "deploying to production environment"
act push -W .github/workflows/deploy.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest
[Deploy/deploy] ⭐ Run Set up job
[Deploy/deploy]   ✅  Success - Set up job
[Deploy/deploy] ⭐ Run Main echo "deploying to production environment"
[Deploy/deploy]   | deploying to production environment
[Deploy/deploy]   ✅  Success - Main echo "deploying to production environment" [149.044277ms]
[Deploy/deploy] 🏁  Job succeeded

Straight through, no pause, no approval prompt — even though environment: production is exactly the field that, on real GitHub Actions, ties a job to required-reviewer approval rules if the repository has any configured for that environment name. This is a genuine, documented substrate limitation: those protection rules live in GitHub’s own repository settings, not in the workflow YAML, so act — which only ever reads the YAML file — has no way to know they exist, let alone enforce them. A local act run passing is not evidence that a real deployment’s approval gate works; that can only be verified against the real GitHub service.

Practice

1. Chain three environments (dev → staging → production) with needs and confirm strict ordering.
jobs:
  dev:
    runs-on: ubuntu-latest
    steps: [{ run: echo "dev" }]
  staging:
    needs: dev
    runs-on: ubuntu-latest
    steps: [{ run: echo "staging" }]
  production:
    needs: staging
    runs-on: ubuntu-latest
    steps: [{ run: echo "production" }]
[Chain/dev] 🏁  Job succeeded
[Chain/staging] 🏁  Job succeeded
[Chain/production] 🏁  Job succeeded

Three stages, three real completions in strict order — each stage’s needs: only allows it to start after the previous one actually finished successfully.

2. Confirm two jobs with no needs relationship run concurrently, unlike the chained ones above.
jobs:
  notify-slack:
    runs-on: ubuntu-latest
    steps: [{ run: echo "notifying" }]
  update-status-page:
    runs-on: ubuntu-latest
    steps: [{ run: echo "updating status page" }]
[Independent/notify-slack      ] ⭐ Run Set up job
[Independent/update-status-page] ⭐ Run Set up job

Both ⭐ Run Set up job lines appear back to back with no dependency between them (act pads the shorter job name so both log prefixes line up in a column) — jobs with no needs: relationship run in parallel by default, the same as this lesson’s staging/ production would have if needs had been omitted.

3. Use needs with multiple jobs (fan-in) and confirm the downstream job waits for all of them.
jobs:
  build-frontend:
    runs-on: ubuntu-latest
    steps: [{ run: echo "frontend built" }]
  build-backend:
    runs-on: ubuntu-latest
    steps: [{ run: echo "backend built" }]
  deploy:
    needs: [build-frontend, build-backend]
    runs-on: ubuntu-latest
    steps: [{ run: echo "deploying both" }]
[FanIn/build-frontend]   ✅  Success - Complete job
[FanIn/build-frontend] 🏁  Job succeeded
[FanIn/build-backend ]   ✅  Success - Complete job
[FanIn/build-backend ] 🏁  Job succeeded
[FanIn/deploy        ] ⭐ Run Main echo deploying both
[FanIn/deploy        ]   | deploying both
[FanIn/deploy        ] 🏁  Job succeeded

needs: [build-frontend, build-backend] only starts deploy once both listed jobs have succeeded — a list in needs means “wait for all of these,” not “wait for any one.”

That closes the track. The thread through all five lessons: a workflow file describes exactly what will happen and nothing more — act proves that description is internally consistent (steps run in order, matrices expand, needs gates correctly), but anything that depends on GitHub’s own infrastructure outside the YAML — masking’s substring matching, workflow_call’s real input validation, an environment’s approval rules — needs the real service to confirm, not just a passing local run.

Frequently Asked Questions

What does declaring environment: production on a job actually do on real GitHub Actions?
It ties the job to a named environment that can have its own protection rules configured in repository settings — required reviewers who must approve before the job runs, a wait timer, and environment-scoped secrets/variables distinct from repository-level ones. None of that configuration lives in the workflow YAML itself; it's set up separately in the repo's Settings > Environments.
Why did the job run immediately instead of waiting for approval?
act has no concept of repository settings or required reviewers — it only reads the workflow YAML, and environment: production in YAML alone doesn't carry any protection rules with it (those live in GitHub's own settings, which act never sees). This is a genuine, documented substrate limitation, not a bug in the example: local testing with act cannot validate that a deployment gate actually blocks anything.
How does needs: actually enforce job ordering, differently from environment?
needs: is pure workflow YAML — the dependency and its behavior (wait for the named job, and only proceed if it succeeded) are fully expressed in the file act reads, so act enforces it exactly like GitHub does. environment's protection rules live outside the YAML in repository configuration, which is the real reason one is enforced locally and the other isn't.
If a downstream job depends on a failed job, does it get skipped or does it fail?
It's skipped, not failed and not run — needs: production's default behavior is to only start a job if every job it depends on succeeded. A job that never starts doesn't show up as failed in the run's job list; it simply isn't there, distinguishable from a job that ran and failed.
How should I actually verify an approval gate works, if act can't test it?
By configuring the environment's required reviewers in the real repository's Settings > Environments and triggering an actual run on GitHub — there is no way to validate protection-rule enforcement without the real service, since the rules live entirely in GitHub's infrastructure rather than in anything a workflow file declares.