GitHub Actions — Level by Level›04 · Build, test and publish container images

Lesson 04 of 9 · Level 1 — Foundations

Build, test and publish container images

A production-grade image pipeline in GitHub Actions: Buildx with layer caching, tags generated from Git (commit SHA, semantic versions), push to GHCR or ECR, vulnerability scanning, capturing the digest, and promoting the new image through a pull request to the GitOps repository.

Practitioner
Key wordsdocker/build-push-actiondocker/setup-buildx-actiondocker/metadata-actionGHCRAmazon ECRcache-fromtype=ghaimage digestTrivymulti-platformGitOps promotionpeter-evans/create-pull-request

The pipeline

test → build (Buildx, cached) → push (tags from Git) → scan → record digest → PR to GitOps repo

A packaging line that stamps every box with the order number it came from, checks it for faults, and then puts a form in the manager's tray saying "ready to ship to dev". Shipping itself is the delivery team's job.

Build and push

# .github/workflows/image.yml
name: image
on:
  push:
    branches: [main]
    tags: ["v*.*.*"]
  pull_request:

permissions:
  contents: read
  packages: write          # push to GHCR

jobs:
  image:
    runs-on: ubuntu-latest
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository_owner }}/orders-api
          tags: |
            type=sha,format=long
            type=semver,pattern={{version}}
            type=ref,event=pr

      - id: build
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}   # PRs build but don't push
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - uses: aquasecurity/trivy-action@0.28.0    # pin a version you tested
        if: github.event_name != 'pull_request'
        with:
          image-ref: ghcr.io/${{ github.repository_owner }}/orders-api@${{ steps.build.outputs.digest }}
          severity: CRITICAL,HIGH
          exit-code: "1"
          ignore-unfixed: true

For Amazon ECR, replace the login with aws-actions/configure-aws-credentials (OIDC, lesson 06) and aws-actions/amazon-ecr-login. For multi-architecture images, add docker/setup-qemu-action and platforms: linux/amd64,linux/arm64.

Pin third-party actions by version tag at minimum, or by full commit SHA for the strongest guarantee (lesson 08).

Promote through the GitOps repository

After the image is pushed and scanned, open a pull request that bumps the image in the dev environment of the config repository:

  promote-dev:
    needs: image
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          repository: acme/gitops-config
          token: ${{ secrets.GITOPS_REPO_TOKEN }}   # or a GitHub App token; scoped to that repo only
      - run: |
          yq -i '.image.digest = "${{ needs.image.outputs.digest }}"' apps/orders-api/envs/dev/values.yaml
      - uses: peter-evans/create-pull-request@v7
        with:
          branch: promote/orders-api-dev-${{ github.sha }}
          title: "orders-api: promote ${{ github.sha }} to dev"
          body: "Built by ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"

The config repository's own rules decide what happens next: auto-merge for dev, required approval for production. Argo CD or Flux then deploys what's merged; the workflow never needs cluster credentials. "GitOps Principles & Practice", lesson 03, covers promotion in depth.

Try it: image to GitOps

  1. Add the image workflow to a small app repository; open a PR (build only) and merge it (build + push to GHCR).
  2. Push a tag v0.1.0 and confirm the image gets 0.1.0 alongside the sha-... tag.
  3. Run twice in a row and compare build times with the gha cache.
  4. Add promote-dev against a test config repository and review the pull request it opens.

Recap

  • Buildx + layer cache (type=gha), tags from metadata-action, push only from main/tags.
  • Scan the pushed digest; fail on serious, fixable findings.
  • Capture the digest as a job output; promote via a PR to the GitOps repo, never with cluster credentials.
  • Pin actions (tags or SHAs); use OIDC for cloud registries.

This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.