Chapter 19Lesson 02~220 minutes

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.

Disposable labRequired reviewArtifact digestqueue: maxRollback

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:

  1. Only release managers may authorize production.
  2. Two production writers must never overlap.
  3. The bytes approved in staging must equal production bytes.
  4. 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?

Why is the source commit SHA not enough to prove which bytes were promoted?

Why does the rollback run download an earlier artifact instead of rebuilding the old commit?

Why are staging and production concurrency policies different?

What permission does configuring an environment through the REST API require for a fine-grained token?

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.

Next lesson

Environments, Deployment Protection Rules, Approvals, Concurrency, and Rollbacks: Configuration, Design Choices, and Tradeoffs

Further reading

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.