Environments, Deployment Protection Rules, Approvals, Concurrency, and Rollbacks: Guided Hands-On Workflow and Core Operations
The previous lesson separated environment policy, deployment evidence, concurrency, and artifact identity. This guided lab now creates those resources in a public disposable repository, runs a harmless deployment simulation, observes a required-review waiting state, deliberately fails one production transition, and rolls back by redeploying retained bytes from a known-good run.
Learning objectives
- Create and inspect disposable staging/production environments through versioned REST calls.
- Build one deployment workflow that carries a SHA-256-identified artifact across staging and production without rebuilding it.
- Observe a required-review waiting state, deployment records, job logs, and concurrency behavior.
- Inject a deployment failure only after artifact verification and perform rollback from a retained prior run artifact.
- Clean up protection resources and preserve enough evidence to explain every state transition.
Availability: Mandatory path: GitHub.com + GitHub Free + one public disposable personal repository. The repository owner can configure environments. The lab may name the learner as the required production reviewer with self-review allowed solely to demonstrate the waiting/approval state; real production should use a separate reviewer and prevent self-review. No cloud account or real secret is used.
1. Preflight: prove repository, host, role, and empty environment state
Do not use a repository that contains real deployments. The commands below create a public lab repository so current Free-plan environment protection is available.
gh auth status
OWNER="$(gh api -H "X-GitHub-Api-Version: 2026-03-10" user --jq .login)"
REPO="$OWNER/c19-deployment-governance-lab"
gh repo create "$REPO" --public --clone --add-readme
cd c19-deployment-governance-lab
git status --short --branch
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/environments" --jq '{total:.total_count,names:[.environments[].name]}'
Prediction 1: before configuration, the environment list is empty. Prediction 2: after a workflow job references a real environment, GitHub will create deployment records by default. Write both predictions down before proceeding.
2. Create staging and a protected production-like environment
Creating/configuring an environment is an administrative repository mutation. The REST endpoint requires Administration write permission for fine-grained tokens; repository owners have the required role in this personal lab. The production reviewer ID is resolved from the authenticated account—no email or secret is embedded.
REPO="OWNER/c19-deployment-governance-lab"
export ACTOR_ID="$(gh api -H "X-GitHub-Api-Version: 2026-03-10" user --jq .id)"
gh api --method PUT -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/environments/staging" --input - <<'JSON'
{}
JSON
python - <<'PYJSON' > /tmp/production-env.json
import json, os
print(json.dumps({
"wait_timer": 0,
"prevent_self_review": False,
"reviewers": [{"type": "User", "id": int(os.environ["ACTOR_ID"])}],
"deployment_branch_policy": None
}))
PYJSON
gh api --method PUT -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/environments/production" --input /tmp/production-env.json
rm -f /tmp/production-env.json
Now inspect rather than assume:
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/environments?per_page=100" --jq '.environments[] | {name,protection_rules,deployment_branch_policy}'
The production response should contain a
required_reviewers protection rule. This
self-reviewable configuration exists only so a single learner can
observe the gate. In a team, choose a different reviewer/team and
set prevent_self_review: true.
3. Create a deployment workflow whose payload identity survives promotion
The workflow has three jobs. acquire either builds a
tiny deterministic payload from the current commit or downloads the
exact payload from an earlier run during rollback. It computes
SHA-256 once and re-uploads the payload into the current run.
staging verifies the digest.
production waits on the protected environment, verifies
the same digest again, and only then performs a harmless activation
message.
The staging concurrency policy cancels stale staging work.
Production instead uses queue: max, which serializes
production writers without discarding pending deployments. This is
an intentional policy difference.
name: Governed deployment lab
on:
workflow_dispatch:
inputs:
source_run_id:
description: "Prior successful run ID for rollback; leave empty for a new build"
required: false
type: string
expected_sha256:
description: "Required when rolling back: SHA-256 of payload.txt from the source run"
required: false
type: string
inject_failure:
description: "Fail the production activation after identity verification"
required: true
default: false
type: boolean
permissions:
contents: read
actions: read
jobs:
acquire:
runs-on: ubuntu-24.04
outputs:
payload_sha: ${{ steps.identity.outputs.payload_sha }}
source_sha: ${{ steps.identity.outputs.source_sha }}
steps:
- name: Checkout current source for a new deployment
if: ${{ inputs.source_run_id == '' }}
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Build deterministic lab payload
if: ${{ inputs.source_run_id == '' }}
shell: bash
run: |
set -euo pipefail
mkdir -p deploy
printf 'source_sha=%s\n' "$GITHUB_SHA" > deploy/payload.txt
printf 'payload_schema=c19-v1\n' >> deploy/payload.txt
- name: Download retained known-good payload for rollback
if: ${{ inputs.source_run_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
SOURCE_RUN_ID: ${{ inputs.source_run_id }}
shell: bash
run: |
set -euo pipefail
mkdir -p deploy
gh run download "$SOURCE_RUN_ID" -R "$GITHUB_REPOSITORY" --name c19-deployable --dir deploy
- name: Verify and expose immutable payload identity
id: identity
env:
EXPECTED_SHA: ${{ inputs.expected_sha256 }}
IS_ROLLBACK: ${{ inputs.source_run_id != '' }}
shell: bash
run: |
set -euo pipefail
test -f deploy/payload.txt
actual="$(sha256sum deploy/payload.txt | awk '{print $1}')"
source_sha="$(sed -n 's/^source_sha=//p' deploy/payload.txt)"
test -n "$source_sha"
if [[ "$IS_ROLLBACK" == "true" ]]; then
test -n "$EXPECTED_SHA"
test "$actual" = "$EXPECTED_SHA"
fi
echo "payload_sha=$actual" >> "$GITHUB_OUTPUT"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
echo "Payload SHA-256: $actual"
echo "Source commit: $source_sha"
- name: Retain deployable payload in this run
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: c19-deployable
path: deploy/payload.txt
if-no-files-found: error
retention-days: 7
staging:
needs: acquire
runs-on: ubuntu-24.04
environment: staging
concurrency:
group: c19-staging-deploy
cancel-in-progress: true
steps:
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3
with:
name: c19-deployable
path: deploy
- name: Verify then simulate staging activation
env:
EXPECTED: ${{ needs.acquire.outputs.payload_sha }}
shell: bash
run: |
set -euo pipefail
actual="$(sha256sum deploy/payload.txt | awk '{print $1}')"
test "$actual" = "$EXPECTED"
echo "staging activated payload=$actual"
production:
needs: [acquire, staging]
runs-on: ubuntu-24.04
environment: production
concurrency:
group: c19-production-deploy
queue: max
steps:
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3
with:
name: c19-deployable
path: deploy
- name: Verify approved payload identity
env:
EXPECTED: ${{ needs.acquire.outputs.payload_sha }}
SOURCE_SHA: ${{ needs.acquire.outputs.source_sha }}
shell: bash
run: |
set -euo pipefail
actual="$(sha256sum deploy/payload.txt | awk '{print $1}')"
test "$actual" = "$EXPECTED"
grep -Fx "source_sha=$SOURCE_SHA" deploy/payload.txt
echo "approved payload=$actual source=$SOURCE_SHA"
- name: Simulate production activation
env:
INJECT_FAILURE: ${{ inputs.inject_failure }}
shell: bash
run: |
set -euo pipefail
if [[ "$INJECT_FAILURE" == "true" ]]; then
echo "Intentional deployment failure after identity verification" >&2
exit 42
fi
echo "production activation succeeded"
Save it as .github/workflows/governed-deploy.yml,
commit, and push:
mkdir -p .github/workflows
# Save the workflow above as .github/workflows/governed-deploy.yml
git add .github/workflows/governed-deploy.yml
git commit -m "Add governed deployment lab"
git push origin HEAD
gh workflow list -R "$REPO"
4. Establish a known-good production deployment
Dispatch a new build with no rollback source:
gh workflow run governed-deploy.yml -R "$REPO" -f source_run_id='' -f expected_sha256='' -f inject_failure=false
gh run list -R "$REPO" --workflow governed-deploy.yml --limit 3 --json databaseId,status,conclusion,headSha,createdAt --jq '.[] | {run_id:.databaseId,status,conclusion,sha:.headSha,createdAt}'
The production job should reach Waiting /
review-required state before runner steps execute. In the Actions UI
open the run, review deployments, confirm the environment is exactly
production, then approve. Do not approve from a
notification alone; inspect run ID, source SHA, and the
acquire digest first.
After success, capture the evidence:
GOOD_RUN="$(gh run list -R "$REPO" --workflow governed-deploy.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
gh run view "$GOOD_RUN" -R "$REPO" --log > good-run.log
GOOD_SHA="$(grep -m1 'Payload SHA-256:' good-run.log | awk '{print $NF}')"
printf 'known_good_run=%s
known_good_payload_sha=%s
' "$GOOD_RUN" "$GOOD_SHA"
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/deployments?environment=production&per_page=10" --jq '[.[] | {id,environment,sha,ref,created_at}]'
GOOD_SHA identifies the deployable file bytes. The
deployment record's sha identifies the workflow's
source revision. Keep both; they answer different questions.
5. Inject a production failure without changing identity checks
Create a second source commit so the attempted artifact is distinguishable:
printf 'candidate-2
' > release-note.txt
git add release-note.txt
git commit -m "Create candidate 2"
git push origin HEAD
gh workflow run governed-deploy.yml -R "$REPO" -f source_run_id='' -f expected_sha256='' -f inject_failure=true
Approve the production job only after verifying its run/source/digest. The job should fail with exit code 42 after its identity check. Preserve that red run. Failure evidence is part of the exercise, not clutter to erase.
6. Roll back by promoting the retained known-good payload
Do not checkout the old commit and rebuild. Start a new governed
deployment that downloads c19-deployable from
GOOD_RUN and requires its SHA-256 to equal
GOOD_SHA:
gh workflow run governed-deploy.yml -R "$REPO" -f source_run_id="$GOOD_RUN" -f expected_sha256="$GOOD_SHA" -f inject_failure=false
Again inspect and approve production. The rollback run's own
workflow headSha may be newer than the source commit
embedded in payload.txt; that is expected. The workflow
revision orchestrates rollback, while the payload carries the
known-good source identity and digest. Verify the log says the same
GOOD_SHA.
7. Challenge: select the correct control
For each requirement, choose environment protection, concurrency, artifact identity verification, or external target control:
- Only release managers may authorize production.
- Two production writers must never overlap.
- The bytes approved in staging must equal production bytes.
- A Kubernetes cluster must reject an identity that lacks namespace permission.
Expected reasoning: 1 → environment reviewer/custom protection; 2 → shared production concurrency group; 3 → digest verification and promotion; 4 → target-system authorization. GitHub controls do not replace runtime IAM.
8. Cleanup and rollback of the lab policy
Deleting environments removes their protection rules and environment secrets. It is destructive to those environment resources, so inspect first and do it only in this disposable repository.
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/environments" --jq '.environments[].name'
gh api --method DELETE -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/environments/staging"
gh api --method DELETE -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/environments/production"
gh repo archive "$REPO" --yes
Archiving is preferred over permanent deletion because the failed and rollback runs remain available as evidence while the lab becomes read-only.
Knowledge checks
Why did production wait before its steps ran?
The job referenced the protected
production environment, so its required-reviewer
rule had to pass before the job could proceed.
Why is the source commit SHA not enough to prove which bytes were promoted?
A build can transform one commit into many possible outputs depending on tool/dependency state. The payload SHA-256 identifies the exact deployable bytes.
Why does the rollback run download an earlier artifact instead of rebuilding the old commit?
The exercise preserves known-good byte identity. Rebuilding could create different bytes and would weaken the rollback evidence.
Why are staging and production concurrency policies different?
Stale staging work may be safely canceled to favor fresh feedback; production changes should normally be serialized/queued rather than canceled mid-transition.
What permission does configuring an environment through the REST API require for a fine-grained token?
Current GitHub REST documentation requires repository Administration write permission; the lab relies on the personal repository owner role.
Summary
You created explicit environment resources, attached a review gate, promoted one digest through two environments, preserved a failed deployment, and rolled back by redeploying retained known-good bytes. Every transition can now be explained with repository, run, source SHA, artifact digest, environment, reviewer state, concurrency policy, and result.
Next: Lesson 3 turns these mechanics into repeatable environment, secret, approval, concurrency, promotion, and rollback policy choices.
Further reading
- GitHub Docs — Deployments and environments
- GitHub Docs — Managing environments for deployment
- GitHub Docs — Reviewing deployments
- GitHub Docs — Deploying with GitHub Actions
- GitHub Docs — Control workflow/job concurrency
- GitHub REST — Deployment environments (2026-03-10)
- GitHub REST — Deployments
- GitHub REST — Deployment statuses
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.