GitHub Actions Deployments: Environments, Active Status, and Protection Rules

2 min readCloud Infrastructure

GitHub Deployments track what's live in each environment. Only one deployment can be 'active' per environment — the last successful deployment. When multiple PRs deploy to the same environment, only the most recent one holds 'active' status, which blocks other PRs from meeting deployment-based branch protection rules.

ci-cdgithub actions

How GitHub deployment tracking works

GitHub Deployments are first-class objects: when a workflow deploys to an environment, it creates a deployment record associated with that commit and environment. GitHub tracks the status (pending, in_progress, success, failure) and marks the latest successful deployment as active.

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # creates/updates a deployment for this environment
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: ./scripts/deploy.sh
      # GitHub automatically creates a deployment record and marks it active on success

The environment: key in a job does three things:

  1. Creates a deployment record when the job starts
  2. Sets the deployment status to success or failure when the job completes
  3. Makes the deployment the active one for that environment on success

Only one deployment is 'active' per environment — the last successful one

GotchaGitHub Actions

When a branch protection rule requires 'deployments must succeed' for a specific environment before merging, GitHub checks whether the PR's HEAD commit has an active deployment in that environment. If another PR deployed after yours, it holds the active status — your PR's deployment is superseded and can't merge until you re-deploy.

Prerequisites

  • GitHub branch protection rules
  • GitHub Environments
  • deployment workflows

Key Points

  • Each environment has one 'active' deployment — the most recent successful one.
  • If team A deploys to staging, then team B deploys to staging, team A's PR is no longer 'active'.
  • To restore 'active' status: re-run the deployment workflow on your branch.
  • Solution for teams: use per-PR dynamic environments (e.g., staging-pr-123) so deployments don't conflict.

Branch protection with required deployments

In GitHub settings, environment deployment protection rules can gate merges:

# Branch protection rule (configured in GitHub UI):
# Require deployments to succeed before merging → staging

# Your PR's workflow must deploy to 'staging' successfully
# AND that deployment must be the 'active' one

When multiple PRs are open and all deploy to staging:

  • PR-1 deploys at 10:00 → staging active deployment = PR-1
  • PR-2 deploys at 10:05 → staging active deployment = PR-2 (PR-1 is superseded)
  • PR-1 cannot merge — its deployment is no longer active

To make PR-1 mergeable again: re-trigger its deployment workflow (push a commit, re-run the job, or trigger via API).

Dynamic environments: per-PR isolation

The clean solution is naming environments after the PR or branch:

name: Deploy PR

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: staging-pr-${{ github.event.pull_request.number }}
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to PR environment
        run: |
          ./scripts/deploy.sh \
            --env "pr-${{ github.event.pull_request.number }}" \
            --url "https://pr-${{ github.event.pull_request.number }}.staging.example.com"
      - name: Set deployment URL
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: context.payload.deployment.id,
              state: 'success',
              environment_url: 'https://pr-${{ github.event.pull_request.number }}.staging.example.com'
            })

GitHub creates the staging-pr-42 environment automatically when the workflow runs. Each PR gets its own deployment record and active status — no conflicts.

Caveat: automatically created environments don't inherit deployment protection rules or secrets configured on the base staging environment. You must either:

  1. Configure secrets and rules on each dynamic environment (not scalable)
  2. Store environment-specific secrets elsewhere (Vault, AWS Secrets Manager) and inject them at deploy time
  3. Accept that dynamic environments have no protection rules (appropriate for preview/review deployments, not for gating production)
📝Manually creating deployment records via GitHub API

For deployment workflows that don't use the environment: key (e.g., you deploy from a script that calls an external system), create deployment records manually:

steps:
  - name: Create GitHub deployment
    id: create-deployment
    uses: actions/github-script@v7
    with:
      script: |
        const deployment = await github.rest.repos.createDeployment({
          owner: context.repo.owner,
          repo: context.repo.repo,
          ref: context.sha,
          environment: 'production',
          auto_merge: false,
          required_contexts: []   # skip status check requirements
        });
        core.setOutput('deployment-id', deployment.data.id);

  - name: Deploy
    run: ./scripts/deploy.sh

  - name: Update deployment status
    if: always()
    uses: actions/github-script@v7
    with:
      script: |
        await github.rest.repos.createDeploymentStatus({
          owner: context.repo.owner,
          repo: context.repo.repo,
          deployment_id: ${{ steps.create-deployment.outputs.deployment-id }},
          state: '${{ job.status == "success" && "success" || "failure" }}',
          environment_url: 'https://production.example.com'
        });

This gives you full control over when deployments are created and what status they report — useful when deployment happens outside of GitHub Actions (e.g., triggered by a Slack command or external orchestrator).

Two developers have open PRs (PR-1 and PR-2) targeting main. Both PRs trigger a deploy to the shared 'staging' environment. PR-1 deployed at 9 AM. PR-2 deployed at 11 AM. A branch protection rule requires 'staging deployment must be active'. At noon, PR-1 tries to merge but is blocked. PR-2 merges successfully. What is different about their deployment status?

easy

Both PRs deployed successfully. PR-2 deployed more recently. The branch protection rule checks for 'active' deployment in the staging environment.

  • APR-1 is blocked because it has an older commit SHA and GitHub prioritizes newer commits
    Incorrect.Branch protection checks deployment status against the PR's HEAD commit, not commit age. The issue is which deployment holds 'active' status.
  • BPR-2's deployment at 11 AM is the 'active' deployment for staging — PR-1's 9 AM deployment was superseded and is no longer active. The protection rule checks for active status, which only PR-2 has
    Correct!Only one deployment per environment holds 'active' status — the most recent successful one. PR-2's 11 AM deployment became the active deployment for staging, superseding PR-1's. PR-1's deployment is in a non-active 'success' state. The branch protection rule requires that the PR's deployment be active, which PR-1's isn't. Fix: re-run PR-1's deployment workflow to make it the active deployment before merging.
  • CPR-1 needs approval from a required reviewer before it can be marked as the active deployment
    Incorrect.Deployment status (active/inactive) is determined by deployment order, not approvals. Required reviewers are a separate protection mechanism.
  • DGitHub only allows one PR to have a successful staging deployment at a time
    Incorrect.Multiple PRs can have 'success' deployment status. The constraint is that only one can be 'active' — the most recent successful deployment.

Hint:Both deployments succeeded. What distinguishes the 'active' deployment from a superseded 'success' deployment?