Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
name: Build & Deploy
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
inputs:
promote:
description: 'Promote to production tags after build'
required: false
type: boolean
default: false

jobs:
build:
name: Build Images
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- python_version: "3.9"
debian_version: "bullseye"
- python_version: "3.10"
debian_version: "bullseye"
- python_version: "3.11"
debian_version: "bookworm"
- python_version: "3.12"
debian_version: "bookworm"
- python_version: "3.13"
debian_version: "bookworm"
- python_version: "3.14"
debian_version: "bookworm"
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: all

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Check if image already exists
id: check
run: |
IMAGE="ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}-${{ github.sha }}"
echo "Checking if ${IMAGE} already exists..."

if docker manifest inspect "${IMAGE}" >/dev/null 2>&1; then
echo "✅ Image already exists, skipping build"
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "❌ Image does not exist, will build"
echo "exists=false" >> $GITHUB_OUTPUT
fi

- name: Extract metadata
if: steps.check.outputs.exists == 'false'
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/python-container-builder
tags: |
type=sha,prefix=${{ matrix.python_version }}-,format=long
type=ref,event=pr,prefix=pr-,suffix=-${{ matrix.python_version }}

- name: Build and push Docker images
if: steps.check.outputs.exists == 'false'
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
push: true
build-args: |
DEBIAN_VERSION=${{ matrix.debian_version }}
PYTHON_VERSION=${{ matrix.python_version }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=python-${{ matrix.python_version }}
cache-to: type=gha,mode=max,scope=python-${{ matrix.python_version }}
provenance: true
sbom: true
outputs: type=image,name=python-container-builder,annotation-index.org.opencontainers.image.description=Build your Python distroless containers with this

promote:
name: Promote to Production
runs-on: ubuntu-latest
needs: [build, test, security-scan]
# Promote on:
# 1. Normal merge to main (not force push)
# 2. Manual workflow dispatch with promote flag enabled
# CRITICAL: Only runs if build, test, AND security-scan all succeed
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/main' && !github.event.forced) ||
(github.event_name == 'workflow_dispatch' && inputs.promote == true)
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
python_version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Promote commit SHA to version tag
run: |
# Get the full commit SHA
COMMIT_SHA="${{ github.sha }}"

# Source image with commit SHA
SOURCE_IMAGE="ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}-${COMMIT_SHA}"

# Destination tags
VERSION_TAG="ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}"

echo "Promoting ${SOURCE_IMAGE} to ${VERSION_TAG}"

# Re-tag the existing image (no rebuild)
docker buildx imagetools create \
"${SOURCE_IMAGE}" \
--tag "${VERSION_TAG}"

echo "✅ Successfully promoted ${{ matrix.python_version }} to production"

- name: Promote latest tag
if: matrix.python_version == '3.14'
run: |
COMMIT_SHA="${{ github.sha }}"
SOURCE_IMAGE="ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}-${COMMIT_SHA}"
LATEST_TAG="ghcr.io/${{ github.repository_owner }}/python-container-builder:latest"

echo "Promoting ${SOURCE_IMAGE} to ${LATEST_TAG}"

docker buildx imagetools create \
"${SOURCE_IMAGE}" \
--tag "${LATEST_TAG}"

echo "✅ Successfully promoted latest tag"

security-scan:
name: Security Scan
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
security-events: write
steps:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/${{ github.repository_owner }}/python-container-builder:3.14-${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'

- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: 'trivy-results.sarif'

- name: Print Trivy results summary
uses: aquasecurity/trivy-action@master
if: always()
with:
image-ref: ghcr.io/${{ github.repository_owner }}/python-container-builder:3.14-${{ github.sha }}
format: 'table'
severity: 'CRITICAL,HIGH'

test:
name: Test Images
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
strategy:
fail-fast: false
matrix:
python_version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- name: Test Python version
run: |
docker run --rm ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}-${{ github.sha }} python --version

- name: Test uv is installed
run: |
docker run --rm ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}-${{ github.sha }} uv --version

- name: Test poetry is installed
run: |
docker run --rm ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}-${{ github.sha }} poetry --version

- name: Test pipenv is installed
run: |
docker run --rm ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}-${{ github.sha }} pipenv --version

- name: Test pdm is installed
run: |
docker run --rm ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}-${{ github.sha }} pdm --version

- name: Test pip is installed in venv
run: |
docker run --rm ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}-${{ github.sha }} /.venv/bin/pip --version

- name: Test venv is created
run: |
docker run --rm ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}-${{ github.sha }} sh -c 'test -d /.venv && echo "venv exists"'

- name: Test package installation with uv
run: |
docker run --rm ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}-${{ github.sha }} sh -c 'uv pip install requests && /.venv/bin/python -c "import requests; print(f\"requests {requests.__version__} imported successfully\")"'

- name: Test package installation with pip
run: |
docker run --rm ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}-${{ github.sha }} sh -c '/.venv/bin/pip install click && /.venv/bin/python -c "import click; print(f\"click {click.__version__} imported successfully\")"'
Loading