Skip to content

Merge pull request #13 from jski/feature/fuzzy-promotion-logic #8

Merge pull request #13 from jski/feature/fuzzy-promotion-logic

Merge pull request #13 from jski/feature/fuzzy-promotion-logic #8

Workflow file for this run

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 or can be re-tagged
id: check
run: |
CURRENT_IMAGE="ghcr.io/${{ github.repository_owner }}/python-container-builder:${{ matrix.python_version }}-${{ github.sha }}"
echo "=== Image Existence Check ==="
echo "Checking if ${CURRENT_IMAGE} already exists..."
# Check if image with current commit SHA exists
if docker manifest inspect "${CURRENT_IMAGE}" >/dev/null 2>&1; then
echo "✅ Image already exists for current commit, skipping build"
echo "exists=true" >> $GITHUB_OUTPUT
exit 0
fi
echo "❌ Image does not exist for current commit: ${{ github.sha }}"
# If this is a push to main (likely a PR merge), try to find and re-tag the PR image
if [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo ""
echo "=== Attempting PR Image Re-tag (Squash Merge Handling) ==="
# Method 1: Extract PR number from commit message
# Squash merges typically include "#123" in the commit message
PR_NUM=$(git log -1 --pretty=%B | grep -oP '#\K\d+' | head -1 || echo "")
if [ -n "$PR_NUM" ]; then
echo "Found PR #${PR_NUM} in commit message"
PR_IMAGE="ghcr.io/${{ github.repository_owner }}/python-container-builder:pr-${PR_NUM}-${{ matrix.python_version }}"
echo "Checking if PR image exists: ${PR_IMAGE}"
if docker manifest inspect "${PR_IMAGE}" >/dev/null 2>&1; then
echo "✅ Found PR image, re-tagging to new commit SHA..."
# Re-tag the PR image with the new commit SHA (no rebuild!)
docker buildx imagetools create \
"${PR_IMAGE}" \
--tag "${CURRENT_IMAGE}"
if [ $? -eq 0 ]; then
echo "✅ Successfully re-tagged PR image to commit SHA"
echo " Source: ${PR_IMAGE}"
echo " Target: ${CURRENT_IMAGE}"
echo "exists=true" >> $GITHUB_OUTPUT
exit 0
else
echo "❌ Re-tagging failed, will build from scratch"
fi
else
echo "⚠️ PR image not found: ${PR_IMAGE}"
fi
else
echo "⚠️ Could not extract PR number from commit message"
fi
# Method 2: Use GitHub API to find PR by merge commit SHA
echo ""
echo "Trying GitHub API to find PR by commit SHA..."
PR_NUM=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/commits/${{ github.sha }}/pulls" \
| jq -r '.[0].number // empty')
if [ -n "$PR_NUM" ]; then
echo "Found PR #${PR_NUM} via GitHub API"
PR_IMAGE="ghcr.io/${{ github.repository_owner }}/python-container-builder:pr-${PR_NUM}-${{ matrix.python_version }}"
echo "Checking if PR image exists: ${PR_IMAGE}"
if docker manifest inspect "${PR_IMAGE}" >/dev/null 2>&1; then
echo "✅ Found PR image, re-tagging to new commit SHA..."
docker buildx imagetools create \
"${PR_IMAGE}" \
--tag "${CURRENT_IMAGE}"
if [ $? -eq 0 ]; then
echo "✅ Successfully re-tagged PR image to commit SHA"
echo " Source: ${PR_IMAGE}"
echo " Target: ${CURRENT_IMAGE}"
echo "exists=true" >> $GITHUB_OUTPUT
exit 0
else
echo "❌ Re-tagging failed, will build from scratch"
fi
else
echo "⚠️ PR image not found: ${PR_IMAGE}"
fi
else
echo "⚠️ Could not find PR via GitHub API"
fi
fi
echo ""
echo "=== Final Decision ==="
echo "❌ No existing image found, will build from scratch"
echo "exists=false" >> $GITHUB_OUTPUT
- 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\")"'