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.
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.
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
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
doneThe 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
- Run on Latchkey managed runners with one line (
runs-on), which apply the fixes below automatically and self-heal transient failures. This example useslatchkey-small; pick the runner size that fits the job. - Cancel superseded runs when a branch or PR gets a newer push.
- Add a job timeout so a hung step cannot burn hours of runner time.
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:
- Container pulls and builds
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.