Skip to content
Français
CI/CD & Automatisation medium

Lab: promoting an approved deployment

55 min de lecture

Read this page in French

GitHub logo

The security lab builds a repository publishing an attested image to GHCR. It stops there: nothing says who authorises the production release, nor how to go back. This lab adds the missing piece, the deploy.yml workflow, and brings out the property that makes environments valuable: a job waiting for approval has never seen the production secrets.

What you will build

  • Two environments, staging and production, with distinct secrets
  • A mandatory approval and a branch restriction on production
  • A deploy.yml workflow promoting a verified digest, with no rebuild
  • A rollback replaying the previous version through the same path

Prerequisites

This lab assumes the verifiable build is behind you: you have a repository publishing an image to GHCR with a provenance attestation. The reference repository github.com/stephrobert/secure-python-pipeline provides the base; fork it into your account, since this lab writes into the repository settings and its workflows.

On the tooling side, the gh CLI is authenticated and Docker is available locally. On the GitHub side, one point is not negotiable: environment protection rules require a public repository on a Free plan, or at least GitHub Pro on a private one. Keep your fork public, which is also what will make your attestations verifiable by a third party.

Step 1: create the two environments

  1. Open the fork settings, the Settings > Environments section.

  2. Create staging with New environment. No rule for now.

  3. Create production the same way.

  4. Add a secret to each: Environment secrets > Add secret, named DEPLOY_TOKEN in both cases, with different and recognisable values, for example staging-token-abc and prod-token-xyz.

    Those dummy values are deliberate: the lab is going to prove which secret each job receives, and when.

An immediate check: both environments appear in the list, and each shows 1 secret.

Step 2: protect production

This is where the environment stops being a label.

  1. Open production, then tick Required reviewers.

  2. Add yourself as a reviewer. On a personal repository, you are the only account available.

  3. Leave enabled the option allowing self-approval. Without it, the person triggering the deployment cannot approve it, and you would block your own lab. In a team, do the opposite: disabling self-approval is exactly what creates the four-eyes control.

  4. Enable Deployment branches and tags, then choose Selected branches and tags and add the main pattern.

  5. Save. The staging environment stays without a rule.

Step 3: write the deployment workflow

Create .github/workflows/deploy.yml. The workflow takes a digest as an input, verifies it, deploys it to staging, then waits for the approval before production.

.github/workflows/deploy.yml
name: Deploy
on:
workflow_dispatch:
inputs:
digest:
description: "Digest of the image to deploy (sha256:...)"
required: true
type: string
# No permission by default.
permissions: {}
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy-staging:
name: Deploy to staging
runs-on: ubuntu-24.04
timeout-minutes: 10
environment:
name: staging
url: https://staging.example.invalid
permissions:
contents: read
packages: read # pulling the image from GHCR
attestations: read # reading the provenance attestation
env:
IMAGE: ghcr.io/${{ github.repository }}
DIGEST: ${{ inputs.digest }}
steps:
- name: Harden runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Login GHCR
uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Verify the provenance
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh attestation verify "oci://${IMAGE}@${DIGEST}" --owner "${GITHUB_REPOSITORY_OWNER}"
- name: Deploy (simulation)
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
if [ -z "${DEPLOY_TOKEN}" ]; then
echo "No environment token received" >&2
exit 1
fi
echo "Target : ${IMAGE}@${DIGEST}"
echo "Fingerprint : $(printf %s "${DEPLOY_TOKEN}" | sha256sum | cut -c1-12)"
docker run --rm --detach --name app-staging "${IMAGE}@${DIGEST}"
docker ps --filter name=app-staging
deploy-production:
name: Deploy to production
needs: deploy-staging
runs-on: ubuntu-24.04
timeout-minutes: 10
environment:
name: production
url: https://example.invalid
permissions:
contents: read
packages: read
attestations: read
env:
IMAGE: ghcr.io/${{ github.repository }}
DIGEST: ${{ inputs.digest }}
steps:
- name: Harden runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Login GHCR
uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Verify the provenance
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh attestation verify "oci://${IMAGE}@${DIGEST}" --owner "${GITHUB_REPOSITORY_OWNER}"
- name: Deploy (simulation)
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
if [ -z "${DEPLOY_TOKEN}" ]; then
echo "No environment token received" >&2
exit 1
fi
echo "Target : ${IMAGE}@${DIGEST}"
echo "Fingerprint : $(printf %s "${DEPLOY_TOKEN}" | sha256sum | cut -c1-12)"
docker run --rm --detach --name app-prod "${IMAGE}@${DIGEST}"
docker ps --filter name=app-prod

Four choices deserve a word. The digest arrives through env:, never interpolated into the command, because it is an input supplied by a human. The provenance verification is repeated in both jobs, because an approval can take hours and only the latest check says anything about the moment of the deployment. cancel-in-progress is false: on a deployment, interrupting is worse than delaying.

Finally, the token is never printed. GitHub's automatic masking only covers the exact value: publishing its first characters works around it. So we print a truncated SHA-256 fingerprint, which is enough to prove the two environments deliver different secrets without revealing either.

Step 4: get the digest to deploy

The digest of the latest published image is read straight from the registry, without going through the API:

Fenêtre de terminal
docker buildx imagetools inspect ghcr.io/YOUR-ACCOUNT/secure-python-pipeline:latest \
--format '{{.Manifest.Digest}}'

Keep the returned value, in the form sha256:.... Write it down somewhere: at rollback time, that will be the previous digest you need to find.

Step 5: run the promotion and watch the wait

Fenêtre de terminal
gh workflow run deploy.yml -f digest=sha256:YOUR_DIGEST
gh run watch

The expected behaviour, in order:

  1. deploy-staging starts immediately. The staging environment has no rule. The logs show the fingerprint of the staging token. Note it down.

  2. deploy-production goes pending. GitHub shows Review pending deployments, and the job does not start.

  3. The production logs are empty. That is the point to observe: the runner has not started, so it has not received DEPLOY_TOKEN. The production secret never left GitHub.

  4. Approve from the run interface, the Review deployments button, tick production, then Approve and deploy.

  5. The job starts and shows a different fingerprint from staging's: proof that each environment delivered its own secret.

Step 6: check the trace left behind

Fenêtre de terminal
# The deployments recorded for production
gh api "repos/YOUR-ACCOUNT/secure-python-pipeline/deployments?environment=production" \
--jq '.[] | {id, sha, created_at}'

In the interface, the repository's Deployments tab shows both environments with their latest deployment and the URL filled in by the workflow. That page is what you will consult during an incident to know which version runs where, and since when.

Step 7: test the branch restriction

The rule set in step 2 limits production to main. Let us check that it bites.

  1. Create a branch and push the workflow as it stands:

    Fenêtre de terminal
    git switch -c test-protection
    git push -u origin test-protection
  2. Run the workflow from that branch:

    Fenêtre de terminal
    gh workflow run deploy.yml --ref test-protection -f digest=sha256:YOUR_DIGEST
  3. Observe: deploy-staging runs, deploy-production fails immediately, with a message saying the branch is not allowed to deploy to that environment.

The job fails before starting, therefore before any secret is delivered. The restriction is not an application-level control, it is a door closed upstream.

Step 8: go back

The rollback has no dedicated workflow: it is the same one, with the other digest. Which requires that another digest exists. A fork with a single release has nothing to go back to: publish a second one before continuing.

Fenêtre de terminal
# Change one line of the application, then publish a new version
gh release create v1.0.1 --generate-notes

The release.yml workflow then builds and attests a second image. It is the first one you will go back to.

  1. Find the previous digest, in your notes from step 4 or in the published versions of the package:

    Fenêtre de terminal
    gh api "user/packages/container/secure-python-pipeline/versions" \
    --jq '.[] | {digest: .name, tags: .metadata.container.tags, created_at}'
  2. Rerun the deployment on that value:

    Fenêtre de terminal
    gh workflow run deploy.yml -f digest=sha256:PREVIOUS_DIGEST
  3. Approve production again.

Going back takes exactly the same controlled path as the production release: provenance verified, approval traced, deployment recorded. There is no rebuild, so no new risk introduced at the worst possible moment.

Validation

The lab is a success when these six statements hold on your fork:

CheckExpected result
Environmentsstaging and production exist, each with its DEPLOY_TOKEN
ApprovalProduction goes through Review pending deployments
Secret tightnessNo production log before the approval
Distinct tokensThe two jobs show different token fingerprints
Branch restrictionA production deployment from another branch fails
RollbackThe previous digest redeploys with no rebuild

Next steps

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn