Solutions — Push-to-Deploy
Lesson 06 · Cloud Infrastructure & DevOps · Worked solutions
Solution to Task 1 — Staging environment
The key structural requirement is that deploy-production declares needs: deploy-staging so it only starts if staging passes. The staging job has no required reviewer; the production job inherits the reviewer from the production environment definition in GitHub Settings.
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.shortener.example.com
steps:
- uses: actions/checkout@v4
- uses: sigstore/cosign-installer@v3
- name: Verify api signature
env: { COSIGN_EXPERIMENTAL: "true" }
run: |
cosign verify \
--certificate-identity-regexp "https://github.com/${{ github.repository }}/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/${{ github.repository }}/api:${{ github.event.workflow_run.head_sha }}
- name: Verify worker signature
env: { COSIGN_EXPERIMENTAL: "true" }
run: |
cosign verify \
--certificate-identity-regexp "https://github.com/${{ github.repository }}/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/${{ github.repository }}/worker:${{ github.event.workflow_run.head_sha }}
- name: Deploy to staging VM
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.STAGING_USER }}
key: ${{ secrets.STAGING_KEY }}
script: |
set -euo pipefail
export IMAGE_TAG=${{ github.event.workflow_run.head_sha }}
cd /opt/shortener
docker compose pull
docker compose up -d
- name: Smoke-test staging
run: |
for i in $(seq 1 30); do
curl -fsS https://staging.shortener.example.com/healthz && exit 0
sleep 5
done
exit 1
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://shortener.example.com
steps:
- uses: actions/checkout@v4
- uses: sigstore/cosign-installer@v3
- name: Verify api signature
env: { COSIGN_EXPERIMENTAL: "true" }
run: |
cosign verify \
--certificate-identity-regexp "https://github.com/${{ github.repository }}/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/${{ github.repository }}/api:${{ github.event.workflow_run.head_sha }}
- name: Verify worker signature
env: { COSIGN_EXPERIMENTAL: "true" }
run: |
cosign verify \
--certificate-identity-regexp "https://github.com/${{ github.repository }}/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/${{ github.repository }}/worker:${{ github.event.workflow_run.head_sha }}
- name: Deploy to production VM
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
set -euo pipefail
export IMAGE_TAG=${{ github.event.workflow_run.head_sha }}
cd /opt/shortener
docker compose pull
docker compose up -d
docker compose ps
Common mistake: writing the smoke test as a step that runs on the GitHub Actions runner (curl https://staging.shortener.example.com/healthz) before the deploy step completes on the target. The smoke test must run after the SSH step and must target the staging URL — not localhost on the runner, which is not the target machine.
Solution to Task 2 — Digest-pinned deploys
The challenge with workflow_run is that upstream job outputs are not directly accessible to downstream workflows. The reliable workaround is artifact passing: Security & Sign uploads a small file with the digests; Deploy downloads it.
In security.yml, add to the end of the sign job:
- name: Save digests to artifact
run: |
echo "api=${{ steps.api.outputs.digest }}" > digests.env
echo "worker=${{ steps.worker.outputs.digest }}" >> digests.env
- uses: actions/upload-artifact@v4
with:
name: image-digests
path: digests.env
retention-days: 7
In deploy.yml, before the SSH step:
- uses: actions/download-artifact@v4
with:
name: image-digests
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Load digests into environment
run: cat digests.env >> $GITHUB_ENV
In the SSH script, use the digest variables:
- name: Deploy to VM
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
set -euo pipefail
export IMAGE_DIGEST_API=${{ env.api }}
export IMAGE_DIGEST_WORKER=${{ env.worker }}
cd /opt/shortener
docker compose pull
docker compose up -d
docker compose ps
The target’s docker-compose.yml:
services:
api:
image: ghcr.io/YOUR_ORG/YOUR_REPO/api@${IMAGE_DIGEST_API}
worker:
image: ghcr.io/YOUR_ORG/YOUR_REPO/worker@${IMAGE_DIGEST_WORKER}
Common mistake: assuming that github.event.workflow_run.outputs.api-digest propagates the upstream job’s output. The workflow_run event payload does not include upstream job outputs — only metadata about the triggering run (its SHA, conclusion, name, etc.). The artifact upload/download pattern is the correct workaround.
Solution to Task 3 — Concurrency trade-off writeup
A sample writeup that covers the required points:
With
cancel-in-progress: false, a second push tomainwhile a deploy is already running causes the new deploy job to wait in a queue. This is the safer default for production deployments: a mid-flight deploy that is suddenly cancelled can leave the target machine in a partially updated state (for example, the api container was restarted but the worker container was not). Queuing avoids this inconsistency at the cost of additional wall-clock time.With
cancel-in-progress: true, the second push cancels the first deploy job immediately. This is faster but risks the half-updated state described above. It is acceptable for non-production lanes (e.g., a preview environment that is re-created fresh each time) or for pure build jobs where there is no persistent state to corrupt. For production deployments the safety default isfalse.
Summary of the trade-off:
| Setting | Behavior on second push | Risk | Best for |
|---|---|---|---|
cancel-in-progress: false |
Second deploy queues; waits for first to finish | Slower; every commit eventually deploys | Production environments |
cancel-in-progress: true |
First deploy cancelled; second starts immediately | Possible half-updated state; intermediate commits skip prod | Preview envs, pure build jobs |
Module 3 replaces the SSH placeholder with a Terraform-provisioned target — and Module 4 replaces SSH entirely with kubectl apply against a Kubernetes cluster.