Lesson 06 Push-to-Deploy
Theme:

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 to main while 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 is false.

Summary of the trade-off:

SettingBehavior on second pushRiskBest 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.