Chapter 26Lesson 02~220 minutes

Continuous Delivery to AWS, Azure, Google Cloud, and Kubernetes: Guided Hands-On Workflow

Promote one verified artifact into a disposable local Kubernetes target while comparing the same contract with current AWS, Azure, and Google Cloud OIDC adapters.

kind labKubernetesHealth checkRollbackCloud simulation

Learning objectives

  • Create a tiny release subject once, transfer it between jobs, and verify its SHA-256 before deployment.
  • Use a GitHub environment boundary and target-specific concurrency without granting cloud or repository write permissions.
  • Create a disposable kind cluster with pinned kind and Kubernetes node identities and prove the active context before mutation.
  • Roll out exact content identity, verify readiness and external HTTP health, and capture a Kubernetes deployment revision.
  • Compare the local deployment contract with optional AWS, Azure and Google Cloud OIDC adapters without requiring cloud accounts.

1. Scenario: same delivery contract, local target first

Create a throwaway repository named gha-delivery-lab. The mandatory path builds a static page as the release subject, uploads it as a GitHub Actions artifact, then a second job downloads and verifies the exact digest before touching a disposable kind cluster. No cloud account, package registry, paid runner or long-lived credential is required.

The deployment job references an environment named lab-k8s. On a public repository, configure protection if your plan supports the rule you want to practice. If reviewer protection is unavailable, keep the environment reference, use the typed deploy confirmation as a faithful procedural gate, and explicitly record that this is not equivalent to GitHub-enforced reviewer approval.

2. Preflight: prove repository, runner and tool assumptions

# Read-only local preflight before committing the workflow.
git status --short
git rev-parse HEAD
docker version
uname -a
# In the Actions run, also record $GITHUB_RUN_ID, $GITHUB_RUN_ATTEMPT,
# $GITHUB_SHA and runner image metadata before deployment.

The current lab assumptions are timestamped September 10, 2026: ubuntu-24.04; kind v0.33.0; Kubernetes/kubectl v1.37.0; and the kind node image kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5. The workflow verifies the kind binary checksum and the downloaded kubectl checksum.

3. Progressive workflow: build once, verify, target, rollout, health, cleanup

The workflow deliberately keeps the build job read-only and the local deployment job at permissions: {}. The cluster credential is created locally by kind and lives only for the disposable job. The GitHub environment creates deployment/governance state but does not inject any cloud secret.

name: Disposable provider-neutral delivery lab
on:
  workflow_dispatch:
    inputs:
      deploy:
        description: "Deploy the verified subject to the disposable target"
        type: boolean
        required: true
        default: false

permissions: {}

jobs:
  build:
    name: Build immutable delivery subject
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    outputs:
      digest: ${{ steps.subject.outputs.digest }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - id: subject
        shell: bash
        run: |
          set -euo pipefail
          mkdir -p dist
          printf '<h1>delivery-lab</h1>
<p>source=%s</p>
' "$GITHUB_SHA" > dist/index.html
          digest=$(sha256sum dist/index.html | awk '{print $1}')
          printf '%s  index.html
' "$digest" > dist/SHA256SUMS
          echo "digest=$digest" >> "$GITHUB_OUTPUT"
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: delivery-subject-${{ github.run_id }}-${{ github.run_attempt }}
          path: dist/
          retention-days: 7

  deploy_local:
    name: Promote exact subject to disposable kind cluster
    if: ${{ inputs.deploy }}
    needs: build
    runs-on: ubuntu-24.04
    environment: lab-k8s
    concurrency:
      group: deploy-lab-k8s
      cancel-in-progress: false
    permissions: {}
    env:
      EXPECTED_DIGEST: ${{ needs.build.outputs.digest }}
      KIND_VERSION: v0.33.0
      KUBECTL_VERSION: v1.37.0
      KIND_NODE_IMAGE: kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5
      CLUSTER: gha-cd-${{ github.run_id }}
      NS: lab-delivery
    steps:
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: delivery-subject-${{ github.run_id }}-${{ github.run_attempt }}
          path: dist

      - name: Verify the exact bytes before any rollout
        shell: bash
        run: |
          set -euo pipefail
          actual=$(sha256sum dist/index.html | awk '{print $1}')
          test "$actual" = "$EXPECTED_DIGEST"
          (cd dist && sha256sum -c SHA256SUMS)
          echo "verified_artifact_sha256=$actual"

      - name: Install pinned kind and kubectl
        shell: bash
        run: |
          set -euo pipefail
          curl -fsSLo kind "https://kind.sigs.k8s.io/dl/$KIND_VERSION/kind-linux-amd64"
          echo "aee6151561422756b764a4ae28e7f44cda5af5a9eead3cc9985112b1de8d8e0d  kind" | sha256sum -c -
          install -m 0755 kind "$RUNNER_TEMP/kind"
          echo "$RUNNER_TEMP" >> "$GITHUB_PATH"

          curl -fsSLo kubectl "https://dl.k8s.io/release/$KUBECTL_VERSION/bin/linux/amd64/kubectl"
          curl -fsSLo kubectl.sha256 "https://dl.k8s.io/release/$KUBECTL_VERSION/bin/linux/amd64/kubectl.sha256"
          echo "$(cat kubectl.sha256)  kubectl" | sha256sum -c -
          install -m 0755 kubectl "$RUNNER_TEMP/kubectl"

      - name: Create one disposable target and prove context
        shell: bash
        run: |
          set -euo pipefail
          cat > kind.yaml <<'EOF'
          kind: Cluster
          apiVersion: kind.x-k8s.io/v1alpha4
          nodes:
          - role: control-plane
            extraPortMappings:
            - containerPort: 30080
              hostPort: 18080
              listenAddress: "127.0.0.1"
              protocol: TCP
          EOF
          kind create cluster --name "$CLUSTER" --image "$KIND_NODE_IMAGE" --config kind.yaml --wait 120s
          test "$(kubectl config current-context)" = "kind-$CLUSTER"
          kubectl cluster-info
          kubectl create namespace "$NS"

      - name: Resolve runtime image and deploy exact content revision
        id: deploy
        shell: bash
        run: |
          set -euo pipefail
          docker pull nginx:alpine
          runtime_ref=$(docker inspect --format='{{index .RepoDigests 0}}' nginx:alpine)
          test -n "$runtime_ref"
          short="${EXPECTED_DIGEST:0:12}"
          config="content-$short"
          kubectl -n "$NS" create configmap "$config" --from-file=index.html=dist/index.html
          kubectl -n "$NS" label configmap "$config" "academy.example/artifact-sha256=$EXPECTED_DIGEST"

          cat > deployment.yaml <<EOF
          apiVersion: apps/v1
          kind: Deployment
          metadata:
            name: delivery-demo
            namespace: $NS
          spec:
            replicas: 1
            selector:
              matchLabels: {app: delivery-demo}
            template:
              metadata:
                labels: {app: delivery-demo}
                annotations:
                  academy.example/artifact-sha256: "$EXPECTED_DIGEST"
              spec:
                containers:
                - name: web
                  image: "$runtime_ref"
                  ports:
                  - containerPort: 80
                  readinessProbe:
                    httpGet: {path: /, port: 80}
                    initialDelaySeconds: 2
                    periodSeconds: 2
                  volumeMounts:
                  - name: content
                    mountPath: /usr/share/nginx/html
                volumes:
                - name: content
                  configMap: {name: "$config"}
          ---
          apiVersion: v1
          kind: Service
          metadata:
            name: delivery-demo
            namespace: $NS
          spec:
            selector: {app: delivery-demo}
            type: NodePort
            ports:
            - port: 80
              targetPort: 80
              nodePort: 30080
          EOF
          kubectl apply -f deployment.yaml
          kubectl -n "$NS" rollout status deployment/delivery-demo --timeout=120s
          revision=$(kubectl -n "$NS" get deployment delivery-demo -o jsonpath='{{.metadata.annotations.deployment\.kubernetes\.io/revision}}')
          echo "runtime_ref=$runtime_ref" >> "$GITHUB_OUTPUT"
          echo "revision=$revision" >> "$GITHUB_OUTPUT"

      - name: Verify external health and promoted identity
        shell: bash
        run: |
          set -euo pipefail
          body=$(curl -fsS --retry 10 --retry-delay 2 http://127.0.0.1:18080/)
          printf '%s
' "$body"
          grep -F "source=$GITHUB_SHA" <<<"$body"
          test "$(kubectl -n "$NS" get deploy delivery-demo -o jsonpath='{{.spec.template.metadata.annotations.academy\.example/artifact-sha256}}')" = "$EXPECTED_DIGEST"
          echo "deployment_revision=${{ steps.deploy.outputs.revision }}"
          echo "runtime_image=${{ steps.deploy.outputs.runtime_ref }}"

      - name: Preserve target evidence
        if: ${{ always() }}
        shell: bash
        run: |
          set +e
          mkdir -p evidence
          printf 'run=%s
attempt=%s
source=%s
artifact_sha256=%s
context=%s
'             "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$GITHUB_SHA" "$EXPECTED_DIGEST" "$(kubectl config current-context 2>/dev/null)" > evidence/identity.txt
          kubectl -n "$NS" get all -o wide > evidence/resources.txt 2>&1
          kubectl -n "$NS" rollout history deployment/delivery-demo > evidence/rollout-history.txt 2>&1
          kubectl -n "$NS" describe deployment delivery-demo > evidence/deployment.txt 2>&1

      - name: Cleanup exact disposable target
        if: ${{ always() }}
        shell: bash
        run: |
          set +e
          kind delete cluster --name "$CLUSTER"

4. Why the build and deploy jobs are separate

The separation proves that deployment consumes a previously produced subject. The build job records the digest and uploads bytes. The deployment job has no checkout and no build command; it downloads the named Actions artifact, recomputes SHA-256 and refuses to continue if the bytes differ. That is the deployment contract inherited from Chapters 23–25.

An Actions artifact is transfer/storage state, not deployment state. The Kubernetes ConfigMap name and Pod-template annotation encode the verified digest, while the Deployment revision and endpoint response prove what the target actually rolled out.

5. Target proof before mutation

The cluster name contains the workflow run ID, making the target disposable and collision-resistant. After creation, the workflow asserts that kubectl config current-context is exactly kind-$CLUSTER before creating the namespace. This guard is intentionally boring: many destructive Kubernetes incidents begin with valid YAML applied to the wrong context.

Never “fix” a context mismatch by skipping the check. Stop, preserve the current context/cluster evidence, select the intended target explicitly, then re-run the smallest equivalent scope.

6. Artifact identity and runtime-image identity are separate

The application artifact in this lab is index.html; its SHA-256 is the subject being promoted. Nginx is merely the runtime serving that content. The workflow also resolves the pulled Nginx image to a content-addressed repository digest and places that exact runtime reference in the Deployment so the runtime does not drift behind a mutable nginx:alpine tag during rollout.

A container-centric production pipeline would normally make the OCI image itself the release subject and pass its registry digest directly into the deployment adapter. The same rule applies: do not verify one digest and deploy a different tag.

7. Readiness and external health close different loops

Kubernetes readiness proves the Pod is eligible to receive Service traffic. kubectl rollout status proves the Deployment controller reached its desired rollout state. The final curl through the mapped NodePort proves the service path returns content from the expected source SHA. Keeping all three signals prevents “rollout complete” from being misread as “application behavior is correct.”

8. Optional AWS adapter: OIDC → IAM role → deployment API

If you have an authorized disposable AWS sandbox, use GitHub OIDC instead of storing access keys. The provider trust policy should bind the actual repository/environment subject and audience. AWS does not support GitHub OIDC custom claims in the same way some other providers do, so design around the supported audience/subject conditions and the IAM role’s own resource permissions.

permissions:
  contents: read
  id-token: write
steps:
  - uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
    with:
      role-to-assume: arn:aws:iam::111122223333:role/gha-production-deployer
      aws-region: us-east-1
  - run: aws sts get-caller-identity

This example stops at identity verification. A real adapter would then call one selected service API—such as ECS or EKS—and return a target-side deployment/revision identifier plus health/rollback data.

9. Optional Azure adapter: OIDC → federated credential → scoped resource

Azure Login uses Microsoft Entra workload identity federation. The recommended audience is normally api://AzureADTokenExchange. Keep the client/tenant/subscription identifiers as non-secret configuration variables, bind the federated credential to the actual GitHub subject, then give the service principal only the resource-scope role needed by the chosen deployment target.

permissions:
  contents: read
  id-token: write
steps:
  - uses: Azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
    with:
      client-id: ${{ vars.AZURE_CLIENT_ID }}
      tenant-id: ${{ vars.AZURE_TENANT_ID }}
      subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
  - run: az account show --query '{id:id,tenantId:tenantId,user:user.name}'

10. Optional Google Cloud adapter: WIF → principal/service account → target

Google Cloud Workload Identity Federation maps GitHub claims into a workload identity pool/provider and can authorize a direct federated principal or service-account impersonation. The provider must have an attribute condition; do not wildcard an entire organization just because the workload later selects a project.

permissions:
  contents: read
  id-token: write
steps:
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
    with:
      project_id: fake-project-123
      workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/actions
  - run: gcloud config get-value project

11. Challenge: choose the layer, not the command

Your team verifies image digest sha256:AAA, but the deployment adapter receives web:stable. The registry currently resolves that tag to sha256:BBB. Which layer should reject the operation? The deployment contract should fail before provider mutation because artifact identity no longer matches. Changing health checks or runner capacity would not repair the causal problem.

12. Cleanup and retained evidence

The workflow deletes only the kind cluster whose name contains its own run ID. Before cleanup, capture context, namespace resources, Deployment description and rollout history. In a cloud sandbox, preserve the provider deployment ID and identity response before deleting the disposable resource. Cleanup removes target state; it should not erase the GitHub run/attempt or artifact/provenance evidence needed to explain what happened.

13. Guided-workflow summary

You promoted one verified subject through an environment boundary into a disposable target, proved context before mutation, observed rollout and health separately, and mapped the same contract onto three cloud federation adapters without requiring cloud infrastructure for core completion.

Next lesson

Continuous Delivery to AWS, Azure, Google Cloud, and Kubernetes: Configuration, Design Patterns, and Trade-Offs

Continue with the next lesson to build on the current concepts, evidence, security boundaries, and operational practices.

Knowledge check

Why does the deploy job omit checkout?

What does the ConfigMap digest label prove?

Why record kubectl config current-context before apply?

Does a successful OIDC login prove the application was deployed?

If required environment reviewers are unavailable on the chosen plan, what must the learner record?

Official references and version notes

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
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.