
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,
stagingandproduction, with distinct secrets - A mandatory approval and a branch restriction on production
- A
deploy.ymlworkflow 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
-
Open the fork settings, the Settings > Environments section.
-
Create
stagingwith New environment. No rule for now. -
Create
productionthe same way. -
Add a secret to each: Environment secrets > Add secret, named
DEPLOY_TOKENin both cases, with different and recognisable values, for examplestaging-token-abcandprod-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.
-
Open
production, then tick Required reviewers. -
Add yourself as a reviewer. On a personal repository, you are the only account available.
-
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.
-
Enable Deployment branches and tags, then choose Selected branches and tags and add the
mainpattern. -
Save. The
stagingenvironment 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.
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-prodFour 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:
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
gh workflow run deploy.yml -f digest=sha256:YOUR_DIGESTgh run watchThe expected behaviour, in order:
-
deploy-stagingstarts immediately. Thestagingenvironment has no rule. The logs show the fingerprint of the staging token. Note it down. -
deploy-productiongoes pending. GitHub shows Review pending deployments, and the job does not start. -
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. -
Approve from the run interface, the Review deployments button, tick
production, then Approve and deploy. -
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
# The deployments recorded for productiongh 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.
-
Create a branch and push the workflow as it stands:
Fenêtre de terminal git switch -c test-protectiongit push -u origin test-protection -
Run the workflow from that branch:
Fenêtre de terminal gh workflow run deploy.yml --ref test-protection -f digest=sha256:YOUR_DIGEST -
Observe:
deploy-stagingruns,deploy-productionfails 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.
# Change one line of the application, then publish a new versiongh release create v1.0.1 --generate-notesThe release.yml workflow then builds and attests a second image. It is the
first one you will go back to.
-
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}' -
Rerun the deployment on that value:
Fenêtre de terminal gh workflow run deploy.yml -f digest=sha256:PREVIOUS_DIGEST -
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:
| Check | Expected result |
|---|---|
| Environments | staging and production exist, each with its DEPLOY_TOKEN |
| Approval | Production goes through Review pending deployments |
| Secret tightness | No production log before the approval |
| Distinct tokens | The two jobs show different token fingerprints |
| Branch restriction | A production deployment from another branch fails |
| Rollback | The previous digest redeploys with no rebuild |
Next steps
- Concurrency: the run grouping used in the lab workflow, detailed and generalised.
- Runners: an introduction: choosing and hardening the machines that will run these deployments for real.
- Rulesets and branch protection: the governance that keeps anyone from pushing straight to the branch allowed to deploy.