Skip to content

Docker CI (v1 + v2) #135

Docker CI (v1 + v2)

Docker CI (v1 + v2) #135

Workflow file for this run

name: Docker CI (v1 + v2)
on:
push:
# Only run full CI on pushes to main (publishes/metrics). Running on
# every branch push plus pull_request creates duplicate runs when a PR
# receives new commits because GitHub emits both push and pull_request
# events for the same push. Restrict push to main to avoid that.
#
# paths: include-list of build-relevant files only — keeps unrelated
# changes (other workflows, docs, repo housekeeping) from triggering the
# full image matrix. Update this list when adding new files that affect
# the built image or the build pipeline itself.
branches:
- 'main'
paths:
- 'Dockerfile.v1'
- 'Dockerfile.v2'
- 's6-overlay/**'
- 'extras/retry.sh'
- '.github/workflows/docker-ci.yml'
pull_request:
branches:
- '**'
paths:
- 'Dockerfile.v1'
- 'Dockerfile.v2'
- 's6-overlay/**'
- 'extras/retry.sh'
- '.github/workflows/docker-ci.yml'
schedule:
- cron: '0 3 * * 2' # Weekly on Tuesday at 3:00 AM UTC
workflow_dispatch:
concurrency:
group: docker-ci-${{ github.ref }}
cancel-in-progress: true
jobs:
setup:
runs-on: ubuntu-latest
outputs:
build_matrix: ${{ steps.matrix.outputs.build_matrix }}
publish_matrix: ${{ steps.matrix.outputs.publish_matrix }}
arch_count: ${{ steps.matrix.outputs.arch_count }}
s6_version: ${{ steps.s6.outputs.version }}
steps:
- name: Compute build matrices
id: matrix
env:
EVENT_NAME: ${{ github.event_name }}
run: |
# Two flat matrices are emitted from this step:
# build_matrix — per-arch build jobs (run on PR + main)
# publish_matrix — per-tuple merge jobs (main / schedule only)
# On PRs only amd64 is built so feedback stays fast and we don't
# spin up arm runners for unmergeable code.
FULL_VERSIONS='["8.5","8.4","8.3","8.2"]'
if [ "$EVENT_NAME" = "pull_request" ]; then
TEST_VERSIONS='["8.5","8.2"]'
ARCHES='[
{"arch":"amd64","runner":"ubuntu-latest","platform":"linux/amd64","qemu":false}
]'
echo "::notice::PR detected — building amd64 only for PHP 8.5 + 8.2"
else
TEST_VERSIONS="$FULL_VERSIONS"
ARCHES='[
{"arch":"amd64","runner":"ubuntu-latest","platform":"linux/amd64","qemu":false},
{"arch":"arm64","runner":"ubuntu-24.04-arm","platform":"linux/arm64","qemu":false},
{"arch":"armv7","runner":"ubuntu-latest","platform":"linux/arm/v7","qemu":true}
]'
fi
# gen_tuples VERSIONS_JSON → flat array of {variant, php-version,
# php-type, php-base} entries. Mirrors the prior matrix logic:
# cartesian product minus apache-on-alpine and v2-on-bookworm,
# plus an explicit v2/trixie row per (version, type).
gen_tuples() {
jq -n -c --argjson versions "$1" '
[
["v1","v2"][] as $variant
| $versions[] as $ver
| ["fpm","cli","apache"][] as $type
| ["alpine","bookworm"][] as $base
| select(
($type != "apache" or $base != "alpine") and
($variant != "v2" or $base != "bookworm")
)
| {variant: $variant, "php-version": $ver, "php-type": $type, "php-base": $base}
]
+
[
$versions[] as $ver
| ["fpm","cli","apache"][] as $type
| {variant: "v2", "php-version": $ver, "php-type": $type, "php-base": "trixie"}
]
'
}
TEST_TUPLES=$(gen_tuples "$TEST_VERSIONS")
PUBLISH_TUPLES=$(gen_tuples "$FULL_VERSIONS")
BUILD_INCLUDES=$(jq -n -c \
--argjson tuples "$TEST_TUPLES" \
--argjson arches "$ARCHES" \
'[$tuples[] as $t | $arches[] as $a | $t + $a]')
BUILD_MATRIX=$(jq -n -c --argjson includes "$BUILD_INCLUDES" '{include: $includes}')
PUBLISH_MATRIX=$(jq -n -c --argjson includes "$PUBLISH_TUPLES" '{include: $includes}')
echo "build_matrix=$BUILD_MATRIX" >> $GITHUB_OUTPUT
echo "publish_matrix=$PUBLISH_MATRIX" >> $GITHUB_OUTPUT
# Expected number of per-arch builds per tuple. The publish job uses
# this to refuse a partial-arch manifest when a build leg flaked.
echo "arch_count=$(echo "$ARCHES" | jq 'length')" >> $GITHUB_OUTPUT
- name: Get latest s6-overlay version
id: s6
run: |
set -euo pipefail
RESPONSE="$(curl -fSLs \
-H "Authorization: Bearer ${{ github.token }}" \
https://api.github.com/repos/just-containers/s6-overlay/releases/latest)"
S6_OVERLAY_VERSION="$(echo "$RESPONSE" | jq -r .tag_name)"
if [ -z "$S6_OVERLAY_VERSION" ] || [ "$S6_OVERLAY_VERSION" = "null" ]; then
echo "::error::Failed to determine s6-overlay version"
exit 1
fi
echo "version=${S6_OVERLAY_VERSION}" >> $GITHUB_OUTPUT
echo "✅ Latest s6-overlay version: ${S6_OVERLAY_VERSION}"
build:
needs: setup
runs-on: ${{ matrix.runner }}
permissions:
contents: read
security-events: write
packages: write
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.setup.outputs.build_matrix) }}
name: ${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}-${{ matrix.arch }}
env:
PHP_VERSION: ${{ matrix.php-version }}
PHP_TYPE: ${{ matrix.php-type }}
PHP_BASE: ${{ matrix.php-base }}
VARIANT: ${{ matrix.variant }}
ARCH: ${{ matrix.arch }}
PLATFORM: ${{ matrix.platform }}
# Staging registry: per-arch images are pushed by digest here and the
# merge job copies them out to Docker Hub and Quay.
STAGING_NAME: ghcr.io/kingpin/php-docker
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup QEMU
if: matrix.qemu
uses: docker/setup-qemu-action@v4
with:
platforms: ${{ matrix.platform }}
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Set build variables
id: vars
run: |
VERSION="${PHP_VERSION}-${PHP_TYPE}-${PHP_BASE}"
if [ "$VARIANT" = "v2" ]; then
TAG="${VERSION}-v2"
DOCKERFILE="Dockerfile.v2"
else
TAG="${VERSION}"
DOCKERFILE="Dockerfile.v1"
fi
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Per-arch cache scope so concurrent matrix jobs don't fight.
CACHE_SCOPE="${VARIANT}-${VERSION}-${ARCH}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "TAG=${TAG}" >> $GITHUB_OUTPUT
echo "DOCKERFILE=${DOCKERFILE}" >> $GITHUB_OUTPUT
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_OUTPUT
echo "CACHE_SCOPE=${CACHE_SCOPE}" >> $GITHUB_OUTPUT
- name: Login to GHCR
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule')
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# amd64 jobs build with `load: true` so smoke tests + Trivy can run
# against the local docker daemon. On main this is a separate build
# from the digest-push below — both share the same GHA cache scope,
# so the second build is essentially a cache hit.
- name: Build amd64 test image
if: matrix.arch == 'amd64'
uses: docker/build-push-action@v7
with:
context: .
file: ${{ steps.vars.outputs.DOCKERFILE }}
load: true
platforms: ${{ matrix.platform }}
cache-from: type=gha,scope=${{ steps.vars.outputs.CACHE_SCOPE }}
cache-to: type=gha,mode=max,scope=${{ steps.vars.outputs.CACHE_SCOPE }}
build-args: |
VERSION=${{ steps.vars.outputs.VERSION }}
PHPVERSION=${{ matrix.php-version }}
BASEOS=${{ matrix.php-base }}
S6_OVERLAY_VERSION=${{ needs.setup.outputs.s6_version }}
BUILD_DATE=${{ steps.vars.outputs.BUILD_DATE }}
VCS_REF=${{ github.sha }}
tags: test-${{ steps.vars.outputs.TAG }}
- name: Smoke tests - PHP version
if: matrix.arch == 'amd64'
env:
TEST_TAG: test-${{ steps.vars.outputs.TAG }}
EXPECTED_VERSION: ${{ matrix.php-version }}
run: |
echo "::group::Testing PHP version"
if ! docker run --rm "$TEST_TAG" php -v | tee php-version.txt; then
echo "::error::Failed to run php -v"
docker logs "$TEST_TAG" 2>&1 || true
exit 1
fi
if ! grep -q "$EXPECTED_VERSION" php-version.txt; then
echo "::error::PHP version mismatch - expected $EXPECTED_VERSION"
cat php-version.txt
exit 1
fi
echo "✅ PHP version correct"
echo "::endgroup::"
- name: Smoke tests - Basic PHP CLI run
if: matrix.arch == 'amd64'
env:
TEST_TAG: test-${{ steps.vars.outputs.TAG }}
run: |
echo "::group::Testing basic PHP CLI execution"
SAPI=$(docker run --rm "$TEST_TAG" php -r "echo PHP_SAPI;" 2>&1)
if [ $? -ne 0 ]; then
echo "::error::Failed to execute PHP CLI test"
echo "$SAPI"
exit 1
fi
echo "✅ PHP CLI runs successfully (SAPI: $SAPI)"
echo "::endgroup::"
- name: Smoke tests - Extensions
if: matrix.arch == 'amd64'
env:
TEST_TAG: test-${{ steps.vars.outputs.TAG }}
run: |
echo "::group::Testing PHP extensions"
if ! docker run --rm "$TEST_TAG" php -m | tee extensions.txt; then
echo "::error::Failed to list PHP extensions"
docker logs "$TEST_TAG" 2>&1 || true
exit 1
fi
REQUIRED_EXTS="gd json mysqli zip"
MISSING_EXTS=""
for ext in $REQUIRED_EXTS; do
if ! grep -qi "$ext" extensions.txt; then
MISSING_EXTS="$MISSING_EXTS $ext"
echo "::error::Missing extension: $ext"
else
echo "✅ Extension $ext found"
fi
done
if [ -n "$MISSING_EXTS" ]; then
echo "::error::Missing required extensions:$MISSING_EXTS"
echo "Available extensions:"
cat extensions.txt
exit 1
fi
echo "::endgroup::"
- name: Smoke tests - Entrypoint quick-run
if: matrix.arch == 'amd64'
env:
TEST_TAG: test-${{ steps.vars.outputs.TAG }}
run: |
echo "::group::Testing entrypoint/init quick-run"
OUTPUT=$(docker run --rm "$TEST_TAG" php -r "echo 'entrypoint-ok';" 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "::error::Entrypoint test failed with exit code $EXIT_CODE"
echo "$OUTPUT"
exit 1
fi
if ! echo "$OUTPUT" | grep -q "entrypoint-ok"; then
echo "::error::Entrypoint did not produce expected output"
echo "Output: $OUTPUT"
exit 1
fi
echo "✅ Entrypoint executes successfully"
echo "::endgroup::"
- name: Smoke tests - Directory permissions
if: matrix.arch == 'amd64'
env:
TEST_TAG: test-${{ steps.vars.outputs.TAG }}
run: |
echo "::group::Testing directory permissions"
for dir in /tmp /var/www; do
if ! docker run --rm "$TEST_TAG" sh -c "test -d $dir && [ -w $dir ]" 2>&1; then
echo "::warning::Directory $dir either doesn't exist or is not writable"
else
echo "✅ Directory $dir exists and is writable"
fi
done
echo "::endgroup::"
- name: Smoke tests - v2 specific (s6-overlay)
if: matrix.arch == 'amd64' && matrix.variant == 'v2'
env:
TEST_TAG: test-${{ steps.vars.outputs.TAG }}
run: |
echo "::group::Testing s6-overlay presence and PID1 behavior"
if ! docker run --rm "$TEST_TAG" sh -c "test -d /etc/s6-overlay" 2>&1; then
echo "::error::s6-overlay directory not found at /etc/s6-overlay"
docker run --rm "$TEST_TAG" ls -la /etc/ 2>&1 || true
exit 1
fi
echo "✅ s6-overlay directory exists"
if ! docker run --rm "$TEST_TAG" sh -c "test -f /init" 2>&1; then
echo "::error::s6 init binary not found at /init"
docker run --rm "$TEST_TAG" ls -la / 2>&1 || true
exit 1
fi
echo "✅ s6 init binary exists"
if ! docker run --rm "$TEST_TAG" sh -c "test -d /etc/s6-overlay/s6-rc.d || test -d /etc/services.d" 2>&1; then
echo "::warning::s6 services directory not found"
else
echo "✅ s6 services directory found"
fi
echo "::endgroup::"
- name: Smoke tests - FPM specific
if: matrix.arch == 'amd64' && matrix.php-type == 'fpm'
env:
TEST_TAG: test-${{ steps.vars.outputs.TAG }}
run: |
echo "::group::Testing PHP-FPM"
if ! docker run --rm "$TEST_TAG" php-fpm --version 2>&1 | tee fpm-version.txt; then
echo "::error::Failed to run php-fpm --version"
cat fpm-version.txt || true
exit 1
fi
echo "✅ PHP-FPM version check passed"
echo "::endgroup::"
- name: Smoke tests - Apache specific
if: matrix.arch == 'amd64' && matrix.php-type == 'apache'
env:
TEST_TAG: test-${{ steps.vars.outputs.TAG }}
run: |
echo "::group::Testing Apache"
if ! docker run --rm "$TEST_TAG" apache2 -v 2>&1 | tee apache-version.txt; then
echo "::error::Failed to run apache2 -v"
cat apache-version.txt || true
exit 1
fi
echo "✅ Apache version check passed"
echo "::endgroup::"
# Report-only scan: findings are uploaded to the Security tab but never
# block the build or publish. The OS layer comes from upstream base
# images, so CRITICAL/HIGH CVEs there are tracked for visibility rather
# than gated on (we can't patch faster than upstream rebuilds).
# ignore-unfixed: true filters out advisories with no upstream fix.
- name: Trivy vulnerability scan
if: matrix.arch == 'amd64'
uses: aquasecurity/trivy-action@master
with:
scan-type: image
image-ref: test-${{ steps.vars.outputs.TAG }}
format: 'sarif'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
exit-code: '0'
output: 'trivy-results-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}.sarif'
- name: Upload Trivy results
if: matrix.arch == 'amd64' && always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-results-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}.sarif'
category: trivy-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}
# Push the per-arch image to the staging registry (GHCR) by digest.
# The merge job will pull these digests and assemble final manifests
# on Docker Hub, GHCR, and Quay with the proper tags.
- name: Build and push by digest
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule')
id: push
uses: docker/build-push-action@v7
with:
context: .
file: ${{ steps.vars.outputs.DOCKERFILE }}
platforms: ${{ matrix.platform }}
provenance: mode=max
sbom: true
cache-from: type=gha,scope=${{ steps.vars.outputs.CACHE_SCOPE }}
cache-to: type=gha,mode=max,scope=${{ steps.vars.outputs.CACHE_SCOPE }}
outputs: type=image,name=${{ env.STAGING_NAME }},push-by-digest=true,name-canonical=true,push=true
build-args: |
VERSION=${{ steps.vars.outputs.VERSION }}
PHPVERSION=${{ matrix.php-version }}
BASEOS=${{ matrix.php-base }}
S6_OVERLAY_VERSION=${{ needs.setup.outputs.s6_version }}
BUILD_DATE=${{ steps.vars.outputs.BUILD_DATE }}
VCS_REF=${{ github.sha }}
labels: |
com.sumguy.php-docker.php.variant=${{ matrix.php-type }}
com.sumguy.php-docker.image.variant=${{ matrix.variant }}
com.sumguy.php-docker.build_id=${{ github.run_id }}
com.sumguy.php-docker.build_url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
com.sumguy.php-docker.built_by=github-actions/docker-ci
- name: Export digest
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule')
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
mkdir -p /tmp/digests
touch "/tmp/digests/${DIGEST#sha256:}"
- name: Upload digest
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule')
uses: actions/upload-artifact@v7
with:
name: digests-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
- name: Summary
run: |
echo "::notice::✅ Build passed for ${{ matrix.variant }} ${{ steps.vars.outputs.TAG }} on ${{ matrix.arch }}"
publish-merge:
needs: [setup, build]
# Run per-tuple even when SOME build legs failed. `build` is one matrix
# job, so a single failed leg would otherwise mark it failed and skip
# every publish. `!cancelled() && needs.setup.result == 'success'` lets
# publish proceed regardless of build's aggregate result; the digest
# completeness guard below fails only the tuples that are actually
# missing an arch, so one flake no longer blocks all ~30 publishes.
if: >-
!cancelled()
&& needs.setup.result == 'success'
&& github.ref == 'refs/heads/main'
&& (github.event_name == 'push' || github.event_name == 'schedule')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.setup.outputs.publish_matrix) }}
name: publish-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}
env:
PHP_VERSION: ${{ matrix.php-version }}
PHP_TYPE: ${{ matrix.php-type }}
PHP_BASE: ${{ matrix.php-base }}
VARIANT: ${{ matrix.variant }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
STAGING_NAME: ghcr.io/kingpin/php-docker
EXPECTED_ARCHES: ${{ needs.setup.outputs.arch_count }}
steps:
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Quay.io
uses: docker/login-action@v4
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
- name: Download per-arch digests
uses: actions/download-artifact@v8
with:
pattern: digests-${{ matrix.variant }}-${{ matrix.php-version }}-${{ matrix.php-type }}-${{ matrix.php-base }}-*
merge-multiple: true
path: /tmp/digests
- name: Create manifest lists
run: |
set -euo pipefail
VERSION="${PHP_VERSION}-${PHP_TYPE}-${PHP_BASE}"
if [ "$VARIANT" = "v2" ]; then
SUFFIX="-v2"
else
SUFFIX=""
fi
PRIMARY_TAG="${VERSION}${SUFFIX}"
# v2/trixie also gets a bookworm-aliased tag so existing consumers
# of `<ver>-<type>-bookworm-v2` keep resolving to the new image.
EXTRA_TAGS=""
if [ "$VARIANT" = "v2" ] && [ "$PHP_BASE" = "trixie" ]; then
EXTRA_TAGS="${PHP_VERSION}-${PHP_TYPE}-bookworm${SUFFIX}"
fi
mkdir -p /tmp/digests
cd /tmp/digests
DIGEST_FILES=$(ls -1)
DIGEST_COUNT=$(ls -1 | wc -l | tr -d ' ')
# Completeness guard: refuse to publish unless every expected arch
# built. A partial set means a build leg flaked — publishing a
# 2-of-3-arch manifest would silently break consumers on the missing
# arch. Fail only THIS tuple (re-runnable via `gh run rerun
# --failed`); other tuples publish normally.
if [ "$DIGEST_COUNT" -eq 0 ]; then
echo "::error::No digests found for $VERSION$SUFFIX — all arch builds for this tuple failed. Re-run failed build jobs, then re-run publish."
exit 1
fi
if [ "$DIGEST_COUNT" -lt "${EXPECTED_ARCHES:-0}" ]; then
echo "::error::Incomplete arch set for $VERSION$SUFFIX: found $DIGEST_COUNT of $EXPECTED_ARCHES arch digests. Refusing to publish a partial-arch manifest. Re-run the failed build job, then re-run this publish."
exit 1
fi
# imagetools create copies blobs across registries as needed; the
# per-arch images live on GHCR (staging) and the final manifests
# land on all three registries with all the desired tags.
for reg in \
"docker.io/${DOCKERHUB_USERNAME}/php-docker" \
"ghcr.io/kingpin/php-docker" \
"quay.io/kingpinx1/php-docker"; do
T_ARGS="-t ${reg}:${PRIMARY_TAG}"
for et in $EXTRA_TAGS; do
T_ARGS="${T_ARGS} -t ${reg}:${et}"
done
S_ARGS=""
for d in $DIGEST_FILES; do
S_ARGS="${S_ARGS} ${STAGING_NAME}@sha256:${d}"
done
echo "::group::imagetools create ${reg}:${PRIMARY_TAG}"
# shellcheck disable=SC2086
docker buildx imagetools create $T_ARGS $S_ARGS
echo "::endgroup::"
done
- name: Inspect final manifests
run: |
set -euo pipefail
VERSION="${PHP_VERSION}-${PHP_TYPE}-${PHP_BASE}"
if [ "$VARIANT" = "v2" ]; then SUFFIX="-v2"; else SUFFIX=""; fi
PRIMARY_TAG="${VERSION}${SUFFIX}"
EXTRA_TAGS=""
if [ "$VARIANT" = "v2" ] && [ "$PHP_BASE" = "trixie" ]; then
EXTRA_TAGS="${PHP_VERSION}-${PHP_TYPE}-bookworm${SUFFIX}"
fi
# Inspect every (registry, tag) combination produced by the create
# step so a malformed push or missing alias tag fails the job.
for reg in \
"docker.io/${DOCKERHUB_USERNAME}/php-docker" \
"ghcr.io/kingpin/php-docker" \
"quay.io/kingpinx1/php-docker"; do
for tag in "$PRIMARY_TAG" $EXTRA_TAGS; do
echo "::group::imagetools inspect ${reg}:${tag}"
docker buildx imagetools inspect "${reg}:${tag}"
echo "::endgroup::"
done
done