Skip to content
Latchkey

Build and Publish Service Docker Images workflow (SpecterOps/Nemesis)

The Build and Publish Service Docker Images workflow from SpecterOps/Nemesis, explained and optimized by Latchkey.

C

CI health: C - fair

Point runs-on at Latchkey and get run de-duplication, job timeouts, self-healing for flaky steps, and up to 58% lower cost, applied automatically.

Grade your own workflow free or run it on Latchkey →
Source: SpecterOps/Nemesis.github/workflows/docker-build.ymlLicense BSD-3-ClauseView source

What it does

This is the Build and Publish Service Docker Images workflow from the SpecterOps/Nemesis repository, a real project running GitHub Actions. It is shown here with attribution under its BSD-3-Clause license.

Below, Latchkey shows a faster, safer version produced by its optimization engine.

The workflow

workflow (.yml)
name: Build and Publish Service Docker Images

on:
  push:
    # To run on every push to main
    branches: [ "main" ]
    paths:
      - 'libs/**'
      - 'projects/**'

    # # To run when release tags are created:
    # tags: [ "v*.*.*" ]
  workflow_dispatch: # For manual triggering

env:
  REGISTRY: ghcr.io
  IMAGE_PREFIX: "specterops/nemesis" # ${{ github.repository }} causes issues as SpecterOps is not all lowercase

jobs:

  build-service-images:
    runs-on: ${{ matrix.runner }}
    permissions:
      contents: read
      packages: write
    strategy:
      matrix:
        runner: [ubuntu-22.04, ubuntu-22.04-arm]
        service:
          - name: agents
            context: .
            dockerfile: ./projects/agents/Dockerfile
          - name: alerting
            context: .
            dockerfile: ./projects/alerting/Dockerfile
          - name: cli
            context: .
            dockerfile: ./projects/cli/Dockerfile
          - name: document-conversion
            context: .
            dockerfile: ./projects/document_conversion/Dockerfile
          - name: dotnet-service
            context: .
            dockerfile: ./projects/dotnet_service/Dockerfile
          - name: file-enrichment
            context: .
            dockerfile: ./projects/file_enrichment/Dockerfile
          - name: frontend
            context: ./projects/frontend
            dockerfile: ./projects/frontend/Dockerfile
          - name: housekeeping
            context: .
            dockerfile: ./projects/housekeeping/Dockerfile
          - name: jupyter
            context: ./projects/jupyter
            dockerfile: ./projects/jupyter/Dockerfile
          - name: web-api
            context: .
            dockerfile: ./projects/web_api/Dockerfile

    steps:
      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
        with:
          fetch-depth: 0  # Fetch all history for git info

      - name: Generate version.json
        run: |
          cat > version.json << EOF
          {
            "git": {
              "sha": "${{ github.sha }}",
              "shaShort": "$(echo ${{ github.sha }} | cut -c1-7)",
              "branch": "${{ github.ref_name }}",
              "tag": "${{ github.ref_type == 'tag' && github.ref_name || '' }}",
              "dirty": false,
              "commitTimestamp": "$(git log -1 --format=%cI)",
              "commitMessage": "$(git log -1 --pretty=%B | head -n 1)",
              "commitAuthor": "$(git log -1 --pretty=format:'%an')"
            },
            "build": {
              "timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
              "source": "GitHub Actions"
            }
          }
          EOF
          echo "Generated version.json:"
          cat version.json
          # Copy to frontend public directory for frontend service
          if [ "${{ matrix.service.name }}" = "frontend" ]; then
            cp version.json ./projects/frontend/public/version.json
            echo "Copied version.json to frontend public directory"

            # Create template env.js file (will be populated at runtime by env.sh)
            cat > ./projects/frontend/public/env.js << 'EOF'
          window.ENV = { HASURA_ADMIN_SECRET: '%HASURA_ADMIN_SECRET%' };
          EOF
            echo "Created template env.js in frontend public directory"
          fi

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4

      - name: Log in to the Container registry
        uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Set platform variables
        run: |
          if [ "${{ matrix.runner }}" = "ubuntu-22.04" ]; then
            echo "PLATFORM=linux/amd64" >> $GITHUB_ENV
            echo "ARCH=amd64" >> $GITHUB_ENV
          else
            echo "PLATFORM=linux/arm64" >> $GITHUB_ENV
            echo "ARCH=arm64" >> $GITHUB_ENV
          fi

      - name: Extract metadata for ${{ matrix.service.name }} image
        id: meta
        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/${{ matrix.service.name }}
          tags: |
            type=sha,format=short,suffix=-${{ env.ARCH }}
            type=ref,event=branch,suffix=-${{ env.ARCH }}
            type=ref,event=pr,suffix=-${{ env.ARCH }}
            type=raw,value=latest-${{ env.ARCH }},enable={{is_default_branch}}
            type=raw,value=${{ github.ref_name }}-${{ env.ARCH }}

      - name: Build and push ${{ matrix.service.name }} image
        uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
        with:
          context: ${{ matrix.service.context }}
          file: ${{ matrix.service.dockerfile }}
          push: true
          platforms: ${{ env.PLATFORM }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            PYTHON_BASE_DEV_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/python-base-dev:latest
            PYTHON_BASE_PROD_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/python-base-prod:latest
            INSPECT_ASSEMBLY_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/inspect-assembly:latest
          cache-from: |
            type=gha,scope=${{ matrix.service.name }}-${{ env.ARCH }}-${{ github.ref_name }}
            type=gha,scope=${{ matrix.service.name }}-${{ env.ARCH }}-main
          cache-to: |
            type=gha,mode=min,scope=${{ matrix.service.name }}-${{ env.ARCH }}-${{ github.ref_name }}

  # Create multi-arch manifest
  create-manifest:
    needs: build-service-images
    runs-on: ubuntu-22.04
    permissions:
      contents: read
      packages: write
    strategy:
      matrix:
        service:
          - web-api
          - dotnet-service
          - file-enrichment
          - frontend
          - jupyter
          - alerting
          - agents
          - cli
          - housekeeping
          - document-conversion

    steps:
      - name: Log in to the Container registry
        uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata for manifest
        id: meta
        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/${{ matrix.service }}
          tags: |
            type=sha,format=short
            type=ref,event=branch
            type=ref,event=pr
            type=raw,value=latest,enable={{is_default_branch}}
            type=raw,value=${{ github.ref_name }}

      - name: Create and push multi-arch manifest
        run: |
          # Create multi-arch manifests for each tag
          echo '${{ steps.meta.outputs.tags }}' | while IFS= read -r tag; do
            if [ -n "$tag" ]; then
              echo "Creating manifest for: $tag"

              # Check which images actually exist
              IMAGES=""
              if docker buildx imagetools inspect "${tag}-amd64" > /dev/null 2>&1; then
                IMAGES="$IMAGES ${tag}-amd64"
                echo "Found AMD64 image: ${tag}-amd64"
              else
                echo "WARNING: AMD64 image not found: ${tag}-amd64"
              fi

              if docker buildx imagetools inspect "${tag}-arm64" > /dev/null 2>&1; then
                IMAGES="$IMAGES ${tag}-arm64"
                echo "Found ARM64 image: ${tag}-arm64"
              else
                echo "WARNING: ARM64 image not found: ${tag}-arm64"
              fi

              if [ -n "$IMAGES" ]; then
                echo "Creating manifest with images:$IMAGES"
                docker buildx imagetools create --tag "$tag" $IMAGES
              else
                echo "ERROR: No images found for $tag"
                exit 1
              fi
            fi
          done

The same workflow, on Latchkey

Removes redundant runs and caps runaway jobs. Added and changed lines are highlighted.

name: Build and Publish Service Docker Images
 
on:
  push:
    # To run on every push to main
    branches: [ "main" ]
    paths:
      - 'libs/**'
      - 'projects/**'
 
    # # To run when release tags are created:
    # tags: [ "v*.*.*" ]
  workflow_dispatch: # For manual triggering
 
env:
  REGISTRY: ghcr.io
  IMAGE_PREFIX: "specterops/nemesis" # ${{ github.repository }} causes issues as SpecterOps is not all lowercase
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
 
  build-service-images:
    timeout-minutes: 30
    runs-on: ${{ matrix.runner }}
    permissions:
      contents: read
      packages: write
    strategy:
      matrix:
        runner: [ubuntu-22.04, ubuntu-22.04-arm]
        service:
          - name: agents
            context: .
            dockerfile: ./projects/agents/Dockerfile
          - name: alerting
            context: .
            dockerfile: ./projects/alerting/Dockerfile
          - name: cli
            context: .
            dockerfile: ./projects/cli/Dockerfile
          - name: document-conversion
            context: .
            dockerfile: ./projects/document_conversion/Dockerfile
          - name: dotnet-service
            context: .
            dockerfile: ./projects/dotnet_service/Dockerfile
          - name: file-enrichment
            context: .
            dockerfile: ./projects/file_enrichment/Dockerfile
          - name: frontend
            context: ./projects/frontend
            dockerfile: ./projects/frontend/Dockerfile
          - name: housekeeping
            context: .
            dockerfile: ./projects/housekeeping/Dockerfile
          - name: jupyter
            context: ./projects/jupyter
            dockerfile: ./projects/jupyter/Dockerfile
          - name: web-api
            context: .
            dockerfile: ./projects/web_api/Dockerfile
 
    steps:
      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
        with:
          fetch-depth: 0  # Fetch all history for git info
 
      - name: Generate version.json
        run: |
          cat > version.json << EOF
          {
            "git": {
              "sha": "${{ github.sha }}",
              "shaShort": "$(echo ${{ github.sha }} | cut -c1-7)",
              "branch": "${{ github.ref_name }}",
              "tag": "${{ github.ref_type == 'tag' && github.ref_name || '' }}",
              "dirty": false,
              "commitTimestamp": "$(git log -1 --format=%cI)",
              "commitMessage": "$(git log -1 --pretty=%B | head -n 1)",
              "commitAuthor": "$(git log -1 --pretty=format:'%an')"
            },
            "build": {
              "timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
              "source": "GitHub Actions"
            }
          }
          EOF
          echo "Generated version.json:"
          cat version.json
          # Copy to frontend public directory for frontend service
          if [ "${{ matrix.service.name }}" = "frontend" ]; then
            cp version.json ./projects/frontend/public/version.json
            echo "Copied version.json to frontend public directory"
 
            # Create template env.js file (will be populated at runtime by env.sh)
            cat > ./projects/frontend/public/env.js << 'EOF'
          window.ENV = { HASURA_ADMIN_SECRET: '%HASURA_ADMIN_SECRET%' };
          EOF
            echo "Created template env.js in frontend public directory"
          fi
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
 
      - name: Log in to the Container registry
        uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Set platform variables
        run: |
          if [ "${{ matrix.runner }}" = "ubuntu-22.04" ]; then
            echo "PLATFORM=linux/amd64" >> $GITHUB_ENV
            echo "ARCH=amd64" >> $GITHUB_ENV
          else
            echo "PLATFORM=linux/arm64" >> $GITHUB_ENV
            echo "ARCH=arm64" >> $GITHUB_ENV
          fi
 
      - name: Extract metadata for ${{ matrix.service.name }} image
        id: meta
        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/${{ matrix.service.name }}
          tags: |
            type=sha,format=short,suffix=-${{ env.ARCH }}
            type=ref,event=branch,suffix=-${{ env.ARCH }}
            type=ref,event=pr,suffix=-${{ env.ARCH }}
            type=raw,value=latest-${{ env.ARCH }},enable={{is_default_branch}}
            type=raw,value=${{ github.ref_name }}-${{ env.ARCH }}
 
      - name: Build and push ${{ matrix.service.name }} image
        uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
        with:
          context: ${{ matrix.service.context }}
          file: ${{ matrix.service.dockerfile }}
          push: true
          platforms: ${{ env.PLATFORM }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            PYTHON_BASE_DEV_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/python-base-dev:latest
            PYTHON_BASE_PROD_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/python-base-prod:latest
            INSPECT_ASSEMBLY_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/inspect-assembly:latest
          cache-from: |
            type=gha,scope=${{ matrix.service.name }}-${{ env.ARCH }}-${{ github.ref_name }}
            type=gha,scope=${{ matrix.service.name }}-${{ env.ARCH }}-main
          cache-to: |
            type=gha,mode=min,scope=${{ matrix.service.name }}-${{ env.ARCH }}-${{ github.ref_name }}
 
  # Create multi-arch manifest
  create-manifest:
    timeout-minutes: 30
    needs: build-service-images
    runs-on: latchkey-small
    permissions:
      contents: read
      packages: write
    strategy:
      matrix:
        service:
          - web-api
          - dotnet-service
          - file-enrichment
          - frontend
          - jupyter
          - alerting
          - agents
          - cli
          - housekeeping
          - document-conversion
 
    steps:
      - name: Log in to the Container registry
        uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata for manifest
        id: meta
        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/${{ matrix.service }}
          tags: |
            type=sha,format=short
            type=ref,event=branch
            type=ref,event=pr
            type=raw,value=latest,enable={{is_default_branch}}
            type=raw,value=${{ github.ref_name }}
 
      - name: Create and push multi-arch manifest
        run: |
          # Create multi-arch manifests for each tag
          echo '${{ steps.meta.outputs.tags }}' | while IFS= read -r tag; do
            if [ -n "$tag" ]; then
              echo "Creating manifest for: $tag"
 
              # Check which images actually exist
              IMAGES=""
              if docker buildx imagetools inspect "${tag}-amd64" > /dev/null 2>&1; then
                IMAGES="$IMAGES ${tag}-amd64"
                echo "Found AMD64 image: ${tag}-amd64"
              else
                echo "WARNING: AMD64 image not found: ${tag}-amd64"
              fi
 
              if docker buildx imagetools inspect "${tag}-arm64" > /dev/null 2>&1; then
                IMAGES="$IMAGES ${tag}-arm64"
                echo "Found ARM64 image: ${tag}-arm64"
              else
                echo "WARNING: ARM64 image not found: ${tag}-arm64"
              fi
 
              if [ -n "$IMAGES" ]; then
                echo "Creating manifest with images:$IMAGES"
                docker buildx imagetools create --tag "$tag" $IMAGES
              else
                echo "ERROR: No images found for $tag"
                exit 1
              fi
            fi
          done

What changed

What Latchkey heals here

This workflow has steps that commonly fail on transient issues (network, registries, flaky browsers). On Latchkey managed runners they are detected, retried, and self-healed instead of failing your build:

This workflow runs 2 jobs (30 with the matrix expanded) per trigger. On Latchkey the same minutes cost up to 58% less than GitHub-hosted, with zero queue time.

Actions used in this workflow