GitHub Actions Deployments: Environments, Active Status, and Protection Rules
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.
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:
- Creates a deployment record when the job starts
- Sets the deployment status to
successorfailurewhen the job completes - Makes the deployment the
activeone for that environment on success
Only one deployment is 'active' per environment — the last successful one
GotchaGitHub ActionsWhen 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:
- Configure secrets and rules on each dynamic environment (not scalable)
- Store environment-specific secrets elsewhere (Vault, AWS Secrets Manager) and inject them at deploy time
- 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?
easyBoth 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?