Skip to content

chore(providers): onboarding prep — unblock Azure/GCP/OCI merges #1721

chore(providers): onboarding prep — unblock Azure/GCP/OCI merges

chore(providers): onboarding prep — unblock Azure/GCP/OCI merges #1721

Workflow file for this run

name: Container Build
env:
CONTAINER_TRIGGERS: "/container"
on:
push:
branches: [ main, develop ]
paths:
- 'Dockerfile*'
- 'src/**'
- 'pyproject.toml'
- 'deployment/docker/**'
- 'dev-tools/container/container_build.sh'
- '.github/workflows/dev-containers.yml'
workflow_run:
workflows: ["Unit Tests"]
types: [completed]
branches: [main]
pull_request:
branches: [ main, develop ]
paths:
- 'Dockerfile*'
- 'src/**'
- 'pyproject.toml'
- 'deployment/docker/**'
- 'dev-tools/container/container_build.sh'
- '.github/workflows/dev-containers.yml'
workflow_dispatch:
inputs:
push_images:
description: 'Push images to registry'
required: false
default: false
type: boolean
permissions:
contents: read
packages: read
concurrency:
# Scope per ref so runs on different branches/PRs do not cancel each other.
# Without the ref suffix any container run repo-wide would cancel this one,
# causing PR jobs to show CANCELLED (the red-X on PR #292).
group: container-registry-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
config:
name: Configuration
uses: ./.github/workflows/shared-config.yml
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
calculate-tags:
name: Calculate Container Tags
runs-on: ubuntu-latest
timeout-minutes: 10
needs: config
permissions:
contents: read
outputs:
primary-tags: ${{ steps.tags.outputs.primary-tags }}
python-tags: ${{ steps.tags.outputs.python-tags }}
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Calculate container tags
id: tags
env:
IS_RELEASE: ${{ needs.config.outputs.is-release }}
run: |
make get-container-tags >> "$GITHUB_OUTPUT"
setup-cache:
name: Setup Build Cache
needs: config
uses: ./.github/workflows/cache-management.yml
with:
# Use "dependencies" so the cache key hashes .project.yml/pyproject.toml/uv.lock
# and produces a warm hit for repeated container builds. The old "build" value
# fell through to the SHA-based fallback arm in cache-management.yml, producing
# a cold key on every run and forcing a full dep reinstall each time.
cache-type: dependencies
cache-key-base: container-build
python-version: ${{ needs.config.outputs.default-python-version }}
container-pipeline:
name: Build and Scan Container Images
runs-on: ubuntu-latest
timeout-minutes: 45
needs: [config, calculate-tags, setup-cache]
strategy:
matrix:
python-version: ${{ fromJSON(needs.config.outputs.python-versions) }}
env:
SCAN_LEVEL: ${{ matrix.python-version == needs.config.outputs.default-python-version && 'full' || 'basic' }}
PUSH_TO_REGISTRY: true
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
with:
platforms: ${{ needs.config.outputs.build-platforms }}
- name: Log in to Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
registry: ${{ needs.config.outputs.container-registry }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python and UV
uses: ./.github/actions/setup-uv-cached
with:
cache-key: ${{ needs.setup-cache.outputs.cache-key }}
fail-on-cache-miss: false
- name: Build package wheel
run: make build
- name: Build and scan container image
shell: bash
env:
REGISTRY: ${{ needs.config.outputs.container-registry }}
IMAGE_NAME: ${{ needs.config.outputs.container-image }}
VERSION: ${{ needs.config.outputs.package-version }}
PYTHON_VERSION: ${{ matrix.python-version }}
BUILD_PLATFORMS: ${{ needs.config.outputs.build-platforms }}
PACKAGE_NAME_SHORT: ${{ needs.config.outputs.short-name }}
run: |
# Trivy cannot scan a multi-arch manifest — it needs one concrete,
# locally-loaded image. We derive the scan arch from the first entry
# of build-platforms (single source of truth from .project.yml) so
# that if amd64 is ever dropped or reordered the scan follows suit.
SCAN_PLATFORM=$(echo "$BUILD_PLATFORMS" | cut -d, -f1)
# Determine whether to push. On workflow_dispatch the push_images input
# controls it explicitly; for all other triggers push unless it is a PR.
# The original expression `(dispatch && inputs.push_images) || 'true'`
# evaluated to 'true' even when push_images was false because the string
# literal 'true' was always the fallback. The corrected form only sets
# DISPATCH_PUSH=false when the caller explicitly opted out on a dispatch.
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.push_images }}" == "false" ]]; then
DISPATCH_PUSH="false"
else
DISPATCH_PUSH="true"
fi
if [[ "${{ env.PUSH_TO_REGISTRY }}" == "true" && "${{ github.event_name }}" != "pull_request" && "$DISPATCH_PUSH" == "true" ]]; then
# Build multi-platform image and push to registry
docker buildx build \
--platform "$BUILD_PLATFORMS" \
--build-arg "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
--build-arg "VERSION=$VERSION" \
--build-arg "VCS_REF=$(git rev-parse --short HEAD)" \
--build-arg "PYTHON_VERSION=$PYTHON_VERSION" \
--build-arg "PACKAGE_NAME_SHORT=$PACKAGE_NAME_SHORT" \
--cache-from type=gha \
--cache-to type=gha,mode=max \
--push \
-t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \
.
# Pull back a single-arch image for local Trivy scanning.
# Multi-arch manifests cannot be loaded/scanned; we use the first
# entry of build-platforms (SCAN_PLATFORM) rather than hardcoding amd64.
docker pull --platform "$SCAN_PLATFORM" "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION"
else
# Build single platform for scanning only (PR/test builds).
# Use the first entry of build-platforms (SCAN_PLATFORM) so the
# scan arch tracks .project.yml rather than a hardcoded value.
docker buildx build \
--platform "$SCAN_PLATFORM" \
--build-arg "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
--build-arg "VERSION=$VERSION" \
--build-arg "VCS_REF=$(git rev-parse --short HEAD)" \
--build-arg "PYTHON_VERSION=$PYTHON_VERSION" \
--build-arg "PACKAGE_NAME_SHORT=$PACKAGE_NAME_SHORT" \
--cache-from type=gha \
--cache-to type=gha,mode=max \
--load \
-t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \
.
fi
- name: Run Trivy security scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: "${{ needs.config.outputs.container-registry }}/${{ needs.config.outputs.container-image }}:${{ needs.config.outputs.package-version }}-python${{ matrix.python-version }}"
format: 'sarif'
output: 'trivy-results-py${{ matrix.python-version }}.sarif'
exit-code: '0'
severity: ${{ env.SCAN_LEVEL == 'full' && 'CRITICAL,HIGH,MEDIUM' || 'CRITICAL,HIGH' }}
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
if: always()
with:
sarif_file: 'trivy-results-py${{ matrix.python-version }}.sarif'
- name: Run Hadolint Dockerfile scan
if: matrix.python-version == needs.config.outputs.default-python-version
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: Dockerfile
format: sarif
output-file: hadolint-dockerfile.sarif
no-fail: true
- name: Upload Hadolint scan results
if: matrix.python-version == needs.config.outputs.default-python-version
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
sarif_file: hadolint-dockerfile.sarif
container-manifest:
name: Create Multi-Architecture Manifests
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [config, calculate-tags, setup-cache, container-pipeline]
# 'release' is not a trigger for this workflow so the disjunct was dead; keep only the main-branch guard.
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- name: Log in to Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
registry: ${{ needs.config.outputs.container-registry }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create primary tag manifests
env:
REGISTRY: ${{ needs.config.outputs.container-registry }}
IMAGE: ${{ needs.config.outputs.container-image }}
VERSION: ${{ needs.config.outputs.package-version }}
DEFAULT_PYTHON: ${{ needs.config.outputs.default-python-version }}
PRIMARY_TAGS: ${{ needs.calculate-tags.outputs.primary-tags }}
PYTHON_TAGS: ${{ needs.calculate-tags.outputs.python-tags }}
run: |
# Create primary tags (latest, X.Y.Z) -> point to default Python
if [[ -n "$PRIMARY_TAGS" ]]; then
IFS=',' read -ra TAGS <<< "$PRIMARY_TAGS"
for tag in "${TAGS[@]}"; do
echo "Creating primary tag: $tag -> $VERSION-python$DEFAULT_PYTHON"
docker buildx imagetools create \
-t "$REGISTRY/$IMAGE:$tag" \
"$REGISTRY/$IMAGE:$VERSION-python$DEFAULT_PYTHON"
done
fi
# Create Python variant tags (pythonX.Y) -> point to specific Python latest
if [[ -n "$PYTHON_TAGS" ]]; then
IFS=',' read -ra TAGS <<< "$PYTHON_TAGS"
for tag in "${TAGS[@]}"; do
py_ver=${tag#python}
echo "Creating Python variant tag: $tag -> $VERSION-python$py_ver"
docker buildx imagetools create \
-t "$REGISTRY/$IMAGE:$tag" \
"$REGISTRY/$IMAGE:$VERSION-python$py_ver"
done
fi
container-validation:
name: Validate Container Tags
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [config, calculate-tags, setup-cache, container-manifest]
# 'release' is not a trigger for this workflow so the disjunct was dead; keep only the main-branch guard.
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: read
steps:
- name: Log in to Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
registry: ${{ needs.config.outputs.container-registry }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Test primary tags
env:
REGISTRY: ${{ needs.config.outputs.container-registry }}
IMAGE: ${{ needs.config.outputs.container-image }}
PRIMARY_TAGS: ${{ needs.calculate-tags.outputs.primary-tags }}
PYTHON_TAGS: ${{ needs.calculate-tags.outputs.python-tags }}
run: |
# Test primary tags can be pulled and work
if [[ -n "$PRIMARY_TAGS" ]]; then
IFS=',' read -ra TAGS <<< "$PRIMARY_TAGS"
for tag in "${TAGS[@]}"; do
echo "Testing primary tag: $tag"
docker pull "$REGISTRY/$IMAGE:$tag"
docker run --rm "$REGISTRY/$IMAGE:$tag" version
echo "Primary tag $tag works"
done
fi
# Test Python variant tags
if [[ -n "$PYTHON_TAGS" ]]; then
IFS=',' read -ra TAGS <<< "$PYTHON_TAGS"
for tag in "${TAGS[@]}"; do
echo "Testing Python variant tag: $tag"
docker pull "$REGISTRY/$IMAGE:$tag"
docker run --rm "$REGISTRY/$IMAGE:$tag" version
echo "Python variant tag $tag works"
done
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Test multi-architecture support
env:
REGISTRY: ${{ needs.config.outputs.container-registry }}
IMAGE: ${{ needs.config.outputs.container-image }}
VERSION: ${{ needs.config.outputs.package-version }}
DEFAULT_PYTHON: ${{ needs.config.outputs.default-python-version }}
BUILD_PLATFORMS: ${{ needs.config.outputs.build-platforms }}
run: |
# Use buildx imagetools rather than `docker manifest inspect`:
# imagetools authenticates via the existing docker login session and
# handles both OCI and Docker manifest media types consistently for
# GHCR-hosted images.
REF="$REGISTRY/$IMAGE:$VERSION-python$DEFAULT_PYTHON"
echo "Testing multi-arch manifest for $REF"
INSPECT=$(docker buildx imagetools inspect --raw "$REF")
echo "$INSPECT"
# Derive expected arch list from BUILD_PLATFORMS (single source of truth).
# BUILD_PLATFORMS is a comma-separated list of "os/arch" pairs, e.g.
# "linux/amd64,linux/arm64". Extract the arch component of each pair.
MISSING=0
IFS=',' read -ra PLATFORMS <<< "$BUILD_PLATFORMS"
for platform in "${PLATFORMS[@]}"; do
arch="${platform##*/}"
if echo "$INSPECT" | jq -e --arg arch "$arch" \
'.manifests[]?.platform.architecture == $arch' | grep -q true; then
echo "Found architecture: $arch"
else
echo "ERROR: architecture missing from manifest: $arch"
MISSING=1
fi
done
if [[ "$MISSING" -eq 1 ]]; then
echo "Multi-arch manifest missing one or more expected architectures"
exit 1
fi
echo "Multi-arch manifest contains all expected architectures"