Skip to content

Build and publish Docker images #27

Build and publish Docker images

Build and publish Docker images #27

Workflow file for this run

name: Build and publish Docker images
on:
workflow_dispatch:
inputs:
releaseTag:
description: 'Required fcli release tag for which to build and release Docker images (e.g., v3.14.0)'
required: true
type: string
doPublish:
description: 'Publish images to Docker Hub'
required: true
type: boolean
default: false
isLatest:
description: 'Tag as latest (set by fcli CI when releasing latest version for current major version)'
required: false
type: boolean
default: false
buildProduction:
description: 'Build production images (scratch, ubi9)'
required: false
type: boolean
default: true
ubiBase:
description: 'Red Hat UBI base image (default: redhat/ubi9:9.7)'
required: false
type: string
default: 'redhat/ubi9:9.7'
buildTest:
description: 'Build test images (alpine, windows)'
required: false
type: boolean
default: false
alpineBase:
description: 'Alpine base image (default: alpine:3.23.0)'
required: false
type: string
default: 'alpine:3.23.0'
servercoreVersions:
description: 'Windows Server Core ltsc versions to build (comma-separated, default: ltsc2022)'
required: false
type: string
default: 'ltsc2022,ltsc2025'
permissions:
contents: read
packages: write
jobs:
check-base-images:
name: Check Base Images
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: Alpine
registry: dockerhub
repo: library/alpine
current: ${{ inputs.alpineBase }}
pattern: '^3\.[0-9]+\.[0-9]+$'
- name: Red Hat UBI9
registry: dockerhub
repo: redhat/ubi9
current: ${{ inputs.ubiBase }}
pattern: '^9\.[0-9]+(\.[0-9]+)?$'
- name: Windows Server Core
registry: mcr
repo: windows/servercore
current_versions: ${{ inputs.servercoreVersions }}
pattern: '^ltsc[0-9]+$'
fail-fast: false
steps:
- name: Check ${{ matrix.name }}
continue-on-error: true
run: |
echo "Checking ${{ matrix.name }}..."
CURRENT="${{ matrix.current }}"
PATTERN="${{ matrix.pattern }}"
if [[ "${{ matrix.registry }}" == "dockerhub" ]]; then
REPO="${{ matrix.repo }}"
# Get tags from Docker Hub API
LATEST=$(curl -fsSL "https://registry.hub.docker.com/v2/repositories/${REPO}/tags?page_size=100" 2>/dev/null | \
jq -r '.results[].name' 2>/dev/null | grep -E "$PATTERN" | sort -V | tail -1)
if [[ -z "$LATEST" ]]; then
echo "::notice::Could not determine latest ${{ matrix.name }} version"
echo "Current: $CURRENT"
else
CURRENT_TAG="${CURRENT##*:}"
if [[ "$LATEST" != "$CURRENT_TAG" ]]; then
echo "::warning::Newer ${{ matrix.name }} version available: $LATEST (current: $CURRENT_TAG)"
else
echo "✓ ${{ matrix.name }} is up to date: $CURRENT"
fi
fi
elif [[ "${{ matrix.registry }}" == "mcr" ]]; then
REPO="${{ matrix.repo }}"
CURRENT_VERSIONS="${{ matrix.current_versions }}"
# MCR API allows anonymous access to tags list
LATEST=$(curl -fsSL "https://mcr.microsoft.com/v2/${REPO}/tags/list" 2>/dev/null | \
jq -r '.tags[]?' 2>/dev/null | grep -E "$PATTERN" | sort -V | tail -1)
if [[ -z "$LATEST" ]]; then
echo "::notice::Could not determine latest ${{ matrix.name }} version"
echo "Current versions in matrix: $CURRENT_VERSIONS"
else
# Find the latest version in the matrix
IFS=',' read -ra VERSIONS <<< "$CURRENT_VERSIONS"
MATRIX_LATEST=$(printf '%s\n' "${VERSIONS[@]}" | sort -V | tail -1)
if [[ "$LATEST" != "$MATRIX_LATEST" ]]; then
echo "::warning::Newer ${{ matrix.name }} version available: $LATEST (latest in matrix: $MATRIX_LATEST)"
else
echo "✓ ${{ matrix.name }} is up to date: $MATRIX_LATEST"
fi
echo "Matrix includes: $CURRENT_VERSIONS"
fi
fi
generate-metadata:
name: Generate Tag Metadata
runs-on: ubuntu-latest
outputs:
version: ${{ steps.meta.outputs.version }}
major: ${{ steps.meta.outputs.major }}
minor: ${{ steps.meta.outputs.minor }}
patch: ${{ steps.meta.outputs.patch }}
timestamp: ${{ steps.meta.outputs.timestamp }}
is_semantic: ${{ steps.meta.outputs.is_semantic }}
release_updated_at: ${{ steps.meta.outputs.release_updated_at }}
steps:
- name: Extract metadata
id: meta
run: |
# Extract version from tag (remove 'v' prefix if present)
VERSION="${{ inputs.releaseTag }}"
VERSION="${VERSION#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
# Get release updated_at timestamp to bust Docker cache for rolling tags
RELEASE_DATA=$(curl -fsSL -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/fortify/fcli/releases/tags/${{ inputs.releaseTag }}")
RELEASE_UPDATED_AT=$(echo "$RELEASE_DATA" | jq -r '.published_at // .created_at // ""')
echo "release_updated_at=${RELEASE_UPDATED_AT}" >> $GITHUB_OUTPUT
echo "Release updated at: ${RELEASE_UPDATED_AT}"
# Try to parse semantic version components (x.y.z)
if [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
echo "major=${MAJOR}" >> $GITHUB_OUTPUT
echo "minor=${MINOR}" >> $GITHUB_OUTPUT
echo "patch=${PATCH}" >> $GITHUB_OUTPUT
echo "is_semantic=true" >> $GITHUB_OUTPUT
# Generate timestamp for semantic versions
TIMESTAMP=$(date +%Y%m%d%H%M%S)
echo "timestamp=${TIMESTAMP}" >> $GITHUB_OUTPUT
echo "Semantic version detected: ${MAJOR}.${MINOR}.${PATCH} (timestamp: ${TIMESTAMP})"
else
echo "is_semantic=false" >> $GITHUB_OUTPUT
echo "Non-semantic version: ${VERSION} (no timestamp or semantic tags)"
fi
generate-linux-matrix:
name: Generate Linux Matrix
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
has_variants: ${{ steps.matrix.outputs.has_variants }}
steps:
- name: Generate matrix based on build inputs
id: matrix
run: |
VARIANTS='[]'
# Add production variants if enabled (use -c for compact output)
if [[ "${{ inputs.buildProduction }}" == "true" ]]; then
VARIANTS=$(echo "$VARIANTS" | jq -c '. + [{"name":"scratch","target":"fcli-scratch","suffix":"","latest_suffix":"","base_image":"scratch (statically linked binary)","publish":true}]')
VARIANTS=$(echo "$VARIANTS" | jq -c --arg base "${{ inputs.ubiBase }}" '. + [{"name":"ubi9","target":"fcli-ubi9","suffix":"-ubi9","latest_suffix":"-ubi9","base_image":$base,"publish":true}]')
fi
# Add test variants if enabled
if [[ "${{ inputs.buildTest }}" == "true" ]]; then
VARIANTS=$(echo "$VARIANTS" | jq -c --arg base "${{ inputs.alpineBase }}" '. + [{"name":"alpine","target":"fcli-alpine","suffix":"-alpine","latest_suffix":"-alpine","base_image":$base,"publish":false}]')
fi
# Check if we have any variants to build
VARIANT_COUNT=$(echo "$VARIANTS" | jq 'length')
if [[ "$VARIANT_COUNT" -gt 0 ]]; then
HAS_VARIANTS="true"
else
HAS_VARIANTS="false"
fi
echo "matrix={\"variant\":$VARIANTS}" >> $GITHUB_OUTPUT
echo "has_variants=$HAS_VARIANTS" >> $GITHUB_OUTPUT
echo "Generated Linux matrix with $VARIANT_COUNT variants"
docker-linux:
name: Build & Test Linux Images
needs: [generate-metadata, generate-linux-matrix]
if: ${{ needs.generate-linux-matrix.outputs.has_variants == 'true' }}
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJson(needs.generate-linux-matrix.outputs.matrix) }}
env:
DOCKER_SRC: linux
REGISTRY: docker.io
IMAGE_NAME: fortifydocker/fcli
steps:
- name: Check-out source code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
image=moby/buildkit:latest
- name: Docker Login
if: ${{ inputs.doPublish }}
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Generate tags for ${{ matrix.variant.name }}
id: tags
run: |
VERSION="${{ needs.generate-metadata.outputs.version }}"
SUFFIX="${{ matrix.variant.suffix }}"
LATEST_SUFFIX="${{ matrix.variant.latest_suffix }}"
if [[ "${{ needs.generate-metadata.outputs.is_semantic }}" == "true" ]]; then
# Semantic version: generate timestamp and semantic tags
TIMESTAMP="${{ needs.generate-metadata.outputs.timestamp }}"
MAJOR="${{ needs.generate-metadata.outputs.major }}"
MINOR="${{ needs.generate-metadata.outputs.minor }}"
# Tags: x.y.z-suffix-timestamp, x.y.z-suffix, x.y-suffix, x-suffix
TAGS="${{ env.IMAGE_NAME }}:${VERSION}${SUFFIX}-${TIMESTAMP}"
TAGS="${TAGS},${{ env.IMAGE_NAME }}:${VERSION}${SUFFIX}"
TAGS="${TAGS},${{ env.IMAGE_NAME }}:${MAJOR}.${MINOR}${SUFFIX}"
TAGS="${TAGS},${{ env.IMAGE_NAME }}:${MAJOR}${SUFFIX}"
# Add 'latest' tag if explicitly requested
if [[ "${{ inputs.isLatest }}" == "true" ]]; then
TAGS="${TAGS},${{ env.IMAGE_NAME }}:latest${LATEST_SUFFIX}"
fi
else
# Non-semantic version (including dev_*): only version tag, no timestamp
TAGS="${{ env.IMAGE_NAME }}:${VERSION}${SUFFIX}"
fi
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Generated tags for ${{ matrix.variant.name }}: ${TAGS}"
- name: Build and push ${{ matrix.variant.name }}
uses: docker/build-push-action@v6
with:
context: ${{ env.DOCKER_SRC }}
file: ${{ env.DOCKER_SRC }}/Dockerfile
target: ${{ matrix.variant.target }}
platforms: linux/amd64
push: ${{ matrix.variant.publish && inputs.doPublish }}
tags: ${{ steps.tags.outputs.tags }}
labels: |
org.opencontainers.image.source=${{ github.repositoryUrl }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ github.event.repository.updated_at }}
build-args: |
FCLI_VERSION=${{ inputs.releaseTag }}
ALPINE_BASE=${{ inputs.alpineBase }}
UBI_BASE=${{ inputs.ubiBase }}
CACHE_BUST=${{ needs.generate-metadata.outputs.release_updated_at }}
provenance: ${{ matrix.variant.publish }}
sbom: ${{ matrix.variant.publish }}
load: ${{ !matrix.variant.publish }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Test ${{ matrix.variant.name }} image
run: |
VERSION="${{ needs.generate-metadata.outputs.version }}"
SUFFIX="${{ matrix.variant.suffix }}"
TAG="${{ env.IMAGE_NAME }}:${VERSION}${SUFFIX}"
TEST_DIR="${PWD}/test-${{ matrix.variant.name }}"
# Try to pull image if published, otherwise use local
docker pull ${TAG} 2>/dev/null || \
docker tag $(docker images -q ${TAG} | head -1) ${TAG} 2>/dev/null || true
mkdir -p ${TEST_DIR}
# Determine command prefix based on variant
if [[ "${{ matrix.variant.name }}" == "scratch" ]]; then
CMD_PREFIX=""
else
CMD_PREFIX="fcli"
fi
docker run --rm -u $(id -u):$(id -g) -v ${TEST_DIR}:/data \
${TAG} ${CMD_PREFIX} tool sc-client install
test -f ${TEST_DIR}/fortify/tools/bin/scancentral
echo "✓ ${{ matrix.variant.name }} image test passed"
- name: Generate summary for ${{ matrix.variant.name }}
if: always()
run: |
# Determine published status
PUBLISHED="${{ matrix.variant.publish && inputs.doPublish }}"
if [[ "${PUBLISHED}" == "true" ]]; then
PUBLISH_STATUS="✓ Published"
else
PUBLISH_STATUS="✗ Not Published (test only)"
fi
echo "### ${{ matrix.variant.name }} image" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Base Image:** \`${{ matrix.variant.base_image }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Target:** \`${{ matrix.variant.target }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Status:** ${PUBLISH_STATUS}" >> $GITHUB_STEP_SUMMARY
# Show generated tags if variant is publishable
if [[ "${{ matrix.variant.publish }}" == "true" ]]; then
echo "- **Tags:**" >> $GITHUB_STEP_SUMMARY
# Parse and display the generated tags (from steps.tags.outputs.tags)
TAGS="${{ steps.tags.outputs.tags }}"
IFS=',' read -ra TAG_ARRAY <<< "$TAGS"
for tag in "${TAG_ARRAY[@]}"; do
# Mark timestamp tags as immutable
if [[ "$tag" =~ -[0-9]{14}$ ]]; then
echo " - \`${tag}\` (immutable)" >> $GITHUB_STEP_SUMMARY
else
echo " - \`${tag}\`" >> $GITHUB_STEP_SUMMARY
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
generate-windows-matrix:
name: Generate Windows Matrix
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
- name: Generate matrix from servercoreVersions input
id: matrix
run: |
VERSIONS="${{ inputs.servercoreVersions }}"
# Convert comma-separated string to JSON array (compact output)
# e.g., "ltsc2022,ltsc2025" -> ["ltsc2022","ltsc2025"]
JSON_ARRAY=$(echo "$VERSIONS" | jq -Rc 'split(",") | map(gsub("^\\s+|\\s+$";""))')
echo "matrix={\"ltsc\":$JSON_ARRAY}" >> $GITHUB_OUTPUT
echo "Generated Windows matrix: $JSON_ARRAY"
# Windows images: build only for testing, do not publish
docker-windows:
name: Build Windows Images (Test Only)
needs: [generate-metadata, generate-windows-matrix]
if: ${{ inputs.buildTest }}
runs-on: windows-${{ matrix.ltsc }}
strategy:
matrix: ${{ fromJson(needs.generate-windows-matrix.outputs.matrix) }}
fail-fast: false
env:
DOCKER_SRC: windows
steps:
- name: Check-out source code
uses: actions/checkout@v4
- name: Build Windows image for ${{ matrix.ltsc }}
shell: pwsh
run: |
cd $env:DOCKER_SRC
$ltscVersion = "${{ matrix.ltsc }}"
$baseImage = "mcr.microsoft.com/windows/servercore:$ltscVersion"
$targetName = "fcli-$ltscVersion"
docker build . `
--target $targetName `
-t fcli-windows:$ltscVersion `
--build-arg FCLI_VERSION=${{ inputs.releaseTag }} `
--build-arg SERVERCORE_BASE=$baseImage `
--build-arg CACHE_BUST=${{ needs.generate-metadata.outputs.release_updated_at }}
Write-Host "✓ Windows $ltscVersion image build completed"
- name: Test Windows ${{ matrix.ltsc }} image
shell: pwsh
run: |
$ltscVersion = "${{ matrix.ltsc }}"
# Basic test: check fcli version
docker run --rm fcli-windows:$ltscVersion fcli --version
Write-Host "✓ Windows $ltscVersion image test passed"
Write-Host "Note: Windows images are built for testing only and are not published"
- name: Summary for ${{ matrix.ltsc }}
if: always()
shell: pwsh
run: |
$ltscVersion = "${{ matrix.ltsc }}"
$baseImage = "mcr.microsoft.com/windows/servercore:$ltscVersion"
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### windows-$ltscVersion image"
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ""
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "- **Base Image:** ``$baseImage``"
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "- **Target:** ``fcli-$ltscVersion``"
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "- **Runner:** ``windows-$ltscVersion``"
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "- **Status:** ✗ Not Published (test only)"
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ""
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "Windows images are built for testing only and are not published to Docker Hub."
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ""