Skip to content

Build and Publish Multi-Arch GeoServer Images #1

Build and Publish Multi-Arch GeoServer Images

Build and Publish Multi-Arch GeoServer Images #1

Workflow file for this run

name: Build and Publish Multi-Arch GeoServer Images
on:
workflow_dispatch:
inputs:
version:
description: 'GeoServer version (e.g., 2.28.1, 2.27-SNAPSHOT, 3.0-RC)'
required: true
type: string
build_number:
description: 'Build number (optional, defaults to "github")'
required: false
type: string
default: 'github'
push:
branches:
- ci/publish-test
concurrency:
group: publish-${{ github.event.inputs.version || 'push-test' }}
cancel-in-progress: true
permissions:
contents: read
packages: write
id-token: write
attestations: write
env:
# ------------------------------------------------------------------
# GeoServer Version Series Configuration
#
# These variables define the currently supported GeoServer release series.
# They MUST be updated when a new major or minor series is released (every 6 months).
#
# MAIN_VERSION: Current development series (main branch, experimental)
# STABLE_VERSION: Current stable release series (recommended for production)
# MAINTENANCE_VERSION: Previous stable series (receives critical fixes only)
#
# These values determine:
# - Docker image tags (e.g., "nightly", "stable-latest", "maintenance-latest")
# - Base Tomcat/JDK images for each series
# - Download URLs for WAR files and plugins
# - Git branch mappings for nightly builds
#
# When a new series is released (e.g., 3.1 becomes main):
# 1. Update MAIN_VERSION to the new series (e.g., "3.1")
# 2. Update STABLE_VERSION to the previous main (e.g., "3.0")
# 3. Update MAINTENANCE_VERSION to the previous stable (e.g., "2.28")
# 4. Update base image selection logic if JDK requirements change (line 89)
#
# WARNING: Incorrect values will cause images to be tagged incorrectly,
# use wrong base images, and download from incorrect plugin URLs.
# ------------------------------------------------------------------
MAIN_VERSION: "3.0"
STABLE_VERSION: "2.28"
MAINTENANCE_VERSION: "2.27"
# Primary registry: pushed to during build, receives personal account credentials
PRIMARY_REGISTRY: "petersmythe/geoserver-test"
# Secondary registry: mirrored after build, receives OSGeo credentials
SECONDARY_REGISTRY: "geoserver-docker.osgeo.org/geoserver"
jobs:
# ============================================================
# JOB 1: Prepare build parameters
# ============================================================
prepare:
name: Prepare Build Parameters
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
version: ${{ steps.parse.outputs.version }}
build_number: ${{ steps.parse.outputs.build_number }}
major: ${{ steps.parse.outputs.major }}
minor: ${{ steps.parse.outputs.minor }}
base_image: ${{ steps.parse.outputs.base_image }}
branch: ${{ steps.parse.outputs.branch }}
is_nightly: ${{ steps.parse.outputs.is_nightly }}
war_url: ${{ steps.parse.outputs.war_url }}
stable_plugin_url: ${{ steps.parse.outputs.stable_plugin_url }}
community_plugin_url: ${{ steps.parse.outputs.community_plugin_url }}
primary_tag: ${{ steps.parse.outputs.primary_tag }}
additional_tags: ${{ steps.parse.outputs.additional_tags }}
steps:
- name: Parse Version and Determine Build Parameters
id: parse
run: |
set -euo pipefail
VERSION="${{ inputs.version }}"
BUILD="${{ inputs.build_number }}"
# If triggered by a push (test), allow a sensible default so this can be run without workflow_dispatch inputs
if [ -z "$VERSION" ] && [ "${GITHUB_EVENT_NAME}" = "push" ]; then
VERSION="3.0-SNAPSHOT"
BUILD="${GITHUB_RUN_NUMBER:-github}"
echo "No version input (push trigger). Falling back to VERSION=$VERSION BUILD=$BUILD"
fi
# If BUILD is empty, use a default
if [ -z "$BUILD" ]; then
BUILD="${GITHUB_RUN_NUMBER:-github}"
echo "Build number not provided. Using BUILD=$BUILD"
fi
# Output version and build number for use in other jobs
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "build_number=$BUILD" >> $GITHUB_OUTPUT
echo "Parsing version: $VERSION"
# Extract major.minor version
if [[ $VERSION =~ ^([0-9]+)\.([0-9]+) ]]; then
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
echo "major=$MAJOR" >> $GITHUB_OUTPUT
echo "minor=$MINOR" >> $GITHUB_OUTPUT
else
echo "ERROR: Unable to parse version $VERSION"
exit 1
fi
# Determine base image based on version
if [[ "$VERSION" == "3"* ]]; then
BASE_IMAGE="tomcat:11.0-jdk21-temurin-noble"
elif [[ "$VERSION" == "2.28"* ]]; then
BASE_IMAGE="tomcat:9.0-jdk21-temurin-noble"
elif [[ "$VERSION" == "2.27"* ]] || [[ "$VERSION" == "2.26"* ]]; then
BASE_IMAGE="tomcat:9.0-jdk17-temurin-noble"
else
BASE_IMAGE="tomcat:9.0-jdk11-temurin-noble"
fi
echo "base_image=$BASE_IMAGE" >> $GITHUB_OUTPUT
# Determine branch and tag based on version pattern
if [[ "$VERSION" == *"-M"* ]]; then
# Milestone release (e.g., 2.28-M0)
BRANCH="$VERSION"
PRIMARY_TAG="$VERSION"
IS_NIGHTLY="true"
elif [[ "$VERSION" == *"-RC"* ]]; then
# Release candidate (e.g., 2.28-RC)
BRANCH="$VERSION"
PRIMARY_TAG="$VERSION"
IS_NIGHTLY="true"
elif [[ "$VERSION" == "${{ env.MAIN_VERSION }}"* ]]; then
# Main branch (e.g., 3.0-SNAPSHOT or 3.0.0)
if [[ "$VERSION" == *"-SNAPSHOT"* ]]; then
BRANCH="main"
PRIMARY_TAG="${{ env.MAIN_VERSION }}.x"
IS_NIGHTLY="true"
else
BRANCH="main"
PRIMARY_TAG="$VERSION"
IS_NIGHTLY="false"
fi
else
# Stable or maintenance branch
if [[ "$VERSION" == *"-SNAPSHOT"* ]]; then
BRANCH="${MAJOR}.${MINOR}.x"
PRIMARY_TAG="${MAJOR}.${MINOR}.x"
IS_NIGHTLY="true"
else
BRANCH="${MAJOR}.${MINOR}.x"
PRIMARY_TAG="$VERSION"
IS_NIGHTLY="false"
fi
fi
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
echo "is_nightly=$IS_NIGHTLY" >> $GITHUB_OUTPUT
echo "primary_tag=$PRIMARY_TAG" >> $GITHUB_OUTPUT
# Determine download URLs
if [[ "$IS_NIGHTLY" == "true" ]]; then
WAR_URL="https://build.geoserver.org/geoserver/$BRANCH/geoserver-$BRANCH-latest-war.zip"
STABLE_PLUGIN_URL="https://build.geoserver.org/geoserver/$BRANCH/ext-latest"
COMMUNITY_PLUGIN_URL="https://build.geoserver.org/geoserver/$BRANCH/community-latest"
else
WAR_URL="https://downloads.sourceforge.net/project/geoserver/GeoServer/$VERSION/geoserver-$VERSION-war.zip"
STABLE_PLUGIN_URL="https://downloads.sourceforge.net/project/geoserver/GeoServer/${VERSION}/extensions"
COMMUNITY_PLUGIN_URL="https://build.geoserver.org/geoserver/${BRANCH}/community-latest"
fi
echo "war_url=$WAR_URL" >> $GITHUB_OUTPUT
echo "stable_plugin_url=$STABLE_PLUGIN_URL" >> $GITHUB_OUTPUT
echo "community_plugin_url=$COMMUNITY_PLUGIN_URL" >> $GITHUB_OUTPUT
# Determine additional tags based on version pattern
ADDITIONAL_TAGS=""
# Add series-latest tag (e.g., 2.28-latest)
if [[ "$IS_NIGHTLY" == "false" ]]; then
ADDITIONAL_TAGS="${MAJOR}.${MINOR}-latest"
fi
# Add semantic tags for stable/maintenance releases only
# (main releases get x.y-latest but no semantic tag since they're experimental)
if [[ "$VERSION" == "${STABLE_VERSION}."* ]] && [[ "$IS_NIGHTLY" == "false" ]]; then
ADDITIONAL_TAGS="$ADDITIONAL_TAGS,stable-latest"
elif [[ "$VERSION" == "${MAINTENANCE_VERSION}."* ]] && [[ "$IS_NIGHTLY" == "false" ]]; then
ADDITIONAL_TAGS="$ADDITIONAL_TAGS,maintenance-latest"
fi
# Add nightly tags for snapshot builds
if [[ "$VERSION" == "${MAIN_VERSION}"*"-SNAPSHOT" ]]; then
ADDITIONAL_TAGS="$ADDITIONAL_TAGS,nightly"
elif [[ "$VERSION" == "${STABLE_VERSION}"*"-SNAPSHOT" ]]; then
ADDITIONAL_TAGS="$ADDITIONAL_TAGS,stable-nightly"
elif [[ "$VERSION" == "${MAINTENANCE_VERSION}"*"-SNAPSHOT" ]]; then
ADDITIONAL_TAGS="$ADDITIONAL_TAGS,maintenance-nightly"
fi
# Clean up leading comma
ADDITIONAL_TAGS=$(echo "$ADDITIONAL_TAGS" | sed 's/^,//')
echo "additional_tags=$ADDITIONAL_TAGS" >> $GITHUB_OUTPUT
echo "============================================"
echo "Build Configuration:"
echo " Version: $VERSION"
echo " Branch: $BRANCH"
echo " Base Image: $BASE_IMAGE"
echo " Is Nightly: $IS_NIGHTLY"
echo " Primary Tag: $PRIMARY_TAG"
echo " Additional Tags: $ADDITIONAL_TAGS"
echo " WAR URL: $WAR_URL"
echo "============================================"
- name: Validate GeoServer WAR URL
run: |
set -euo pipefail
echo "Validating GeoServer WAR URL: ${{ steps.parse.outputs.war_url }}"
if ! wget --spider "${{ steps.parse.outputs.war_url }}"; then
echo "ERROR: GeoServer WAR URL is not accessible: ${{ steps.parse.outputs.war_url }}" >&2
exit 1
fi
echo "GeoServer WAR URL is accessible"
# ============================================================
# JOB 2: Build images on native runners (matrix)
# ============================================================
build:
name: Build ${{ matrix.platform }} ${{ matrix.gdal && 'with GDAL' || 'without GDAL' }}
runs-on: ${{ matrix.runner }}
needs: prepare
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
# AMD64 builds
- platform: linux/amd64
runner: ubuntu-latest
arch: amd64
gdal: false
- platform: linux/amd64
runner: ubuntu-latest
arch: amd64
gdal: true
# ARM64 builds
- platform: linux/arm64
runner: ubuntu-24.04-arm
arch: arm64
gdal: false
- platform: linux/arm64
runner: ubuntu-24.04-arm
arch: arm64
gdal: true
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Primary Registry (Docker Hub)
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Download GeoServer WAR
run: |
mkdir -p geoserver
cd geoserver
wget -c "${{ needs.prepare.outputs.war_url }}"
- name: Build and export digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
build-args: |
GS_VERSION=${{ needs.prepare.outputs.version }}
GS_BUILD=${{ needs.prepare.outputs.build_number }}
BUILD_GDAL=${{ matrix.gdal }}
GEOSERVER_BASE_IMAGE=${{ needs.prepare.outputs.base_image }}
WAR_ZIP_URL=${{ needs.prepare.outputs.war_url }}
STABLE_PLUGIN_URL=${{ needs.prepare.outputs.stable_plugin_url }}
COMMUNITY_PLUGIN_URL=${{ needs.prepare.outputs.community_plugin_url }}
cache-from: type=gha,scope=build-${{ matrix.arch }}-${{ matrix.gdal && 'gdal' || 'nogdal' }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}-${{ matrix.gdal && 'gdal' || 'nogdal' }}
provenance: true
sbom: true
outputs: type=image,name=${{ env.PRIMARY_REGISTRY }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.arch }}-${{ matrix.gdal && 'gdal' || 'nogdal' }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# ============================================================
# JOB 3: Merge digests into multi-arch manifests
# ============================================================
merge:
name: Create Multi-Arch Manifests
runs-on: ubuntu-latest
needs:
- prepare
- build
timeout-minutes: 15
steps:
- name: Download all digests (no GDAL)
uses: actions/download-artifact@v4
with:
pattern: digests-*-nogdal
merge-multiple: true
path: /tmp/digests-nogdal
- name: Download all digests (with GDAL)
uses: actions/download-artifact@v4
with:
pattern: digests-*-gdal
merge-multiple: true
path: /tmp/digests-gdal
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Primary Registry (Docker Hub)
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create manifest list and push to Primary Registry (no GDAL)
working-directory: /tmp/digests-nogdal
run: |
set -euo pipefail
# Validate digest files exist
DIGEST_COUNT=$(ls -1 2>/dev/null | wc -l)
if [ "$DIGEST_COUNT" -eq 0 ]; then
echo "ERROR: No digest files found in /tmp/digests-nogdal" >&2
echo "Expected digests from build jobs (amd64 and arm64)" >&2
exit 1
fi
echo "Found $DIGEST_COUNT digest file(s)"
PRIMARY_TAG="${{ needs.prepare.outputs.primary_tag }}"
ADDITIONAL_TAGS="${{ needs.prepare.outputs.additional_tags }}"
# Build tag list for primary registry
TAGS="${{ env.PRIMARY_REGISTRY }}:$PRIMARY_TAG"
if [ -n "$ADDITIONAL_TAGS" ]; then
IFS=',' read -ra TAG_ARRAY <<< "$ADDITIONAL_TAGS"
for tag in "${TAG_ARRAY[@]}"; do
TAGS="$TAGS ${{ env.PRIMARY_REGISTRY }}:$tag"
done
fi
# Create manifest and push to primary registry
docker buildx imagetools create $(printf -- '-t %s ' $TAGS) \
$(printf '${{ env.PRIMARY_REGISTRY }}@sha256:%s ' *)
echo "Created manifest for tags: $TAGS"
- name: Create manifest list and push (with GDAL)
working-directory: /tmp/digests-gdal
run: |
set -euo pipefail
# Validate digest files exist
DIGEST_COUNT=$(ls -1 2>/dev/null | wc -l)
if [ "$DIGEST_COUNT" -eq 0 ]; then
echo "ERROR: No digest files found in /tmp/digests-gdal" >&2
echo "Expected digests from build jobs (amd64 and arm64)" >&2
exit 1
fi
echo "Found $DIGEST_COUNT digest file(s)"
PRIMARY_TAG="${{ needs.prepare.outputs.primary_tag }}-gdal"
ADDITIONAL_TAGS="${{ needs.prepare.outputs.additional_tags }}"
# Build tag list with -gdal suffix
TAGS="${{ env.PRIMARY_REGISTRY }}:$PRIMARY_TAG"
if [ -n "$ADDITIONAL_TAGS" ]; then
IFS=',' read -ra TAG_ARRAY <<< "$ADDITIONAL_TAGS"
for tag in "${TAG_ARRAY[@]}"; do
TAGS="$TAGS ${{ env.PRIMARY_REGISTRY }}:$tag-gdal"
done
fi
# Create manifest
docker buildx imagetools create $(printf -- '-t %s ' $TAGS) \
$(printf '${{ env.PRIMARY_REGISTRY }}@sha256:%s ' *)
echo "Created manifest for tags: $TAGS"
- name: Inspect manifests
run: |
docker buildx imagetools inspect ${{ env.PRIMARY_REGISTRY }}:${{ needs.prepare.outputs.primary_tag }}
docker buildx imagetools inspect ${{ env.PRIMARY_REGISTRY }}:${{ needs.prepare.outputs.primary_tag }}-gdal
- name: Generate Job Summary
run: |
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
## GeoServer Multi-Arch Build Summary
### Build Information
- **Version**: ${{ needs.prepare.outputs.version }}
- **Build Number**: ${{ needs.prepare.outputs.build_number }}
- **Base Image**: ${{ needs.prepare.outputs.base_image }}
- **Is Nightly**: ${{ needs.prepare.outputs.is_nightly }}
### Images Created
#### Without GDAL
- Primary: [${{ env.PRIMARY_REGISTRY }}:${{ needs.prepare.outputs.primary_tag }}](https://hub.docker.com/r/${{ env.PRIMARY_REGISTRY }}/tags)
EOF
# Add additional tags if present
ADDITIONAL_TAGS="${{ needs.prepare.outputs.additional_tags }}"
if [ -n "$ADDITIONAL_TAGS" ]; then
IFS=',' read -ra TAG_ARRAY <<< "$ADDITIONAL_TAGS"
for tag in "${TAG_ARRAY[@]}"; do
echo "- Additional: [${{ env.PRIMARY_REGISTRY }}:$tag](https://hub.docker.com/r/${{ env.PRIMARY_REGISTRY }}/tags)" >> $GITHUB_STEP_SUMMARY
done
fi
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
#### With GDAL
- Primary: [${{ env.PRIMARY_REGISTRY }}:${{ needs.prepare.outputs.primary_tag }}-gdal](https://hub.docker.com/r/${{ env.PRIMARY_REGISTRY }}/tags)
EOF
if [ -n "$ADDITIONAL_TAGS" ]; then
IFS=',' read -ra TAG_ARRAY <<< "$ADDITIONAL_TAGS"
for tag in "${TAG_ARRAY[@]}"; do
echo "- Additional: [${{ env.PRIMARY_REGISTRY }}:$tag-gdal](https://hub.docker.com/r/${{ env.PRIMARY_REGISTRY }}/tags)" >> $GITHUB_STEP_SUMMARY
done
fi
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
### Architectures
- linux/amd64
- linux/arm64
### Pull Commands
```bash
# Without GDAL
docker pull ${{ env.PRIMARY_REGISTRY }}:${{ needs.prepare.outputs.primary_tag }}
# With GDAL
docker pull ${{ env.PRIMARY_REGISTRY }}:${{ needs.prepare.outputs.primary_tag }}-gdal
```
EOF
# ============================================================
# JOB 4: Publish to Secondary Registry
# ============================================================
publish-secondary:
name: Mirror Images to Secondary Registry
runs-on: ubuntu-latest
needs:
- prepare
- merge
timeout-minutes: 15
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Secondary Registry (OSGeo)
uses: docker/login-action@v3
with:
registry: geoserver-docker.osgeo.org
username: ${{ secrets.OSGEO_REPO_USERNAME }}
password: ${{ secrets.OSGEO_REPO_PASSWORD }}
- name: Copy multi-arch manifests to secondary registry
run: |
# Copy multi-arch manifest from primary to secondary registry
docker buildx imagetools create \
-t ${{ env.SECONDARY_REGISTRY }}:${{ needs.prepare.outputs.primary_tag }} \
${{ env.PRIMARY_REGISTRY }}:${{ needs.prepare.outputs.primary_tag }}
# Apply additional tags if present
ADDITIONAL_TAGS="${{ needs.prepare.outputs.additional_tags }}"
if [ -n "$ADDITIONAL_TAGS" ]; then
IFS=',' read -ra TAG_ARRAY <<< "$ADDITIONAL_TAGS"
for tag in "${TAG_ARRAY[@]}"; do
docker buildx imagetools create \
-t ${{ env.SECONDARY_REGISTRY }}:${tag} \
${{ env.PRIMARY_REGISTRY }}:${tag}
done
fi
# Copy multi-arch manifest for the GDAL variant
docker buildx imagetools create \
-t ${{ env.SECONDARY_REGISTRY }}:${{ needs.prepare.outputs.primary_tag }}-gdal \
${{ env.PRIMARY_REGISTRY }}:${{ needs.prepare.outputs.primary_tag }}-gdal
if [ -n "$ADDITIONAL_TAGS" ]; then
IFS=',' read -ra TAG_ARRAY <<< "$ADDITIONAL_TAGS"
for tag in "${TAG_ARRAY[@]}"; do
docker buildx imagetools create \
-t ${{ env.SECONDARY_REGISTRY }}:${tag}-gdal \
${{ env.PRIMARY_REGISTRY }}:${tag}-gdal
done
fi
echo "Successfully copied multi-arch manifests to ${{ env.SECONDARY_REGISTRY }}."
echo "Verify at: https://hub.docker.com/r/${{ env.SECONDARY_REGISTRY }}/tags"
# Inspect to confirm multi-arch
echo ""
echo "Inspecting primary manifest:"
docker buildx imagetools inspect ${{ env.SECONDARY_REGISTRY }}:${{ needs.prepare.outputs.primary_tag }}
echo ""
echo "Inspecting primary manifest (with GDAL):"
docker buildx imagetools inspect ${{ env.SECONDARY_REGISTRY }}:${{ needs.prepare.outputs.primary_tag }}-gdal