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
94 changes: 94 additions & 0 deletions .github/actions/build-image/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Build image
description: >
Builds one image for the runner's architecture and tests it. The image is pushed either to the
job's local registry, or by digest to ghcr, recording the digest for the publish workflow.

inputs:
image:
description: Image name without registry, e.g. php-dev.
required: true
tag:
description: Image tag, e.g. 7.4-apache.
required: true
arch:
description: Runner architecture (amd64 or arm64).
required: true
context:
description: Build context.
required: true
file:
description: Dockerfile path, relative to the repository root.
default: ''
build-args:
description: Build arguments.
default: ''
build-contexts:
description: Additional named build contexts.
default: ''
push:
description: Where to push the image, "local" or "ghcr".
default: local
cache-write:
description: Export the build cache to ghcr.
default: 'false'
no-cache:
description: Ignore the build cache.
default: 'false'
test:
description: Make directory whose "test" target checks the image; empty to skip testing.
default: ''

outputs:
ref:
description: Reference to the built image, usable as BASE_IMAGE.
value: ${{ steps.ref.outputs.ref }}

runs:
using: composite
steps:
- name: Build ${{ inputs.image }}
id: build
uses: docker/build-push-action@v5
with:
context: ${{ inputs.context }}
file: ${{ inputs.file }}
build-args: ${{ inputs.build-args }}
build-contexts: ${{ inputs.build-contexts }}
no-cache: ${{ inputs.no-cache == 'true' }}
cache-from: type=registry,ref=ghcr.io/chialab/${{ inputs.image }}:cache-${{ inputs.tag }}-${{ inputs.arch }}
cache-to: ${{ inputs.cache-write == 'true' && format('type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true,ref=ghcr.io/chialab/{0}:cache-{1}-{2}', inputs.image, inputs.tag, inputs.arch) || '' }}
tags: ${{ inputs.push == 'local' && format('localhost:5000/chialab/{0}:{1}', inputs.image, inputs.tag) || '' }}
push: ${{ inputs.push == 'local' }}
outputs: ${{ inputs.push == 'ghcr' && format('type=image,name=ghcr.io/chialab/{0},push-by-digest=true,name-canonical=true,push=true', inputs.image) || '' }}

- name: Resolve ${{ inputs.image }} reference
id: ref
shell: bash
env:
IMAGE: ${{ inputs.image }}
TAG: ${{ inputs.tag }}
DIGEST: ${{ steps.build.outputs.digest }}
PUSH: ${{ inputs.push }}
run: |
if [[ "$PUSH" == 'ghcr' ]]; then
echo "ref=ghcr.io/chialab/$IMAGE@$DIGEST" >> "$GITHUB_OUTPUT"
mkdir -p "$RUNNER_TEMP/digests/$IMAGE"
touch "$RUNNER_TEMP/digests/$IMAGE/${DIGEST#sha256:}"
else
echo "ref=localhost:5000/chialab/$IMAGE:$TAG" >> "$GITHUB_OUTPUT"
fi

- name: Test ${{ inputs.image }}
if: ${{ inputs.test != '' }}
shell: bash
env:
REGISTRY: localhost:5000/
VERSION: ${{ inputs.tag }}
IMAGE: ${{ inputs.image }}
REF: ${{ steps.ref.outputs.ref }}
MAKE_DIR: ${{ inputs.test }}
run: |
docker pull "$REF"
# the Makefile looks the image up by tag
docker tag "$REF" "localhost:5000/chialab/$IMAGE:$VERSION"
make -C "$MAKE_DIR" test
4 changes: 3 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ updates:

# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
directories:
- "/"
- "/.github/actions/build-image"
schedule:
interval: "daily"
195 changes: 195 additions & 0 deletions .github/workflows/build-images.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
name: Build and test images

# Builds and tests each version × flavor natively on amd64 and arm64. Pull requests push to a local
# registry; runs on main push by digest to ghcr and upload the digests for publish-images.yml.

on:
workflow_call:
inputs:
versions:
description: JSON list of PHP versions.
type: string
required: true
flavors:
description: JSON list of flavors.
type: string
default: '[""]'
exclude:
description: JSON list of version/flavor combinations to skip.
type: string
default: '[]'
secrets:
pull-username:
required: true
pull-password:
required: true

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
name: ${{ matrix.version }}${{ matrix.flavor }} (${{ matrix.arch }})
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(inputs.versions) }}
flavor: ${{ fromJSON(inputs.flavors) }}
arch: [ 'amd64', 'arm64' ]
exclude: ${{ fromJSON(inputs.exclude) }}
services:
registry:
image: ${{ github.event_name == 'pull_request' && 'registry:2' || '' }}
ports:
- 5000:5000
env:
PUSH: ${{ github.event_name == 'pull_request' && 'local' || 'ghcr' }}
CACHE_WRITE: ${{ github.event_name != 'pull_request' }}
# Scheduled runs rebuild the extension layers, so they pick up new extension and OS package
# versions; the patched PHP base is only rebuilt when its inputs change.
NO_CACHE: ${{ github.event_name == 'schedule' }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Plan
id: plan
env:
VERSION: ${{ matrix.version }}
FLAVOR: ${{ matrix.flavor }}
ARCH: ${{ matrix.arch }}
run: |
tag="$VERSION$FLAVOR"
flavorDir="${FLAVOR#-}"
eolDir="eol/$VERSION/${flavorDir:-cli}"
[[ -d "$eolDir" ]] && eol=true || eol=false
[[ "$tag" == *alpine* ]] && dockerfile=Dockerfile.alpine || dockerfile=Dockerfile
[[ "$VERSION" == '5.6' && "$ARCH" == 'arm64' ]] && dev=false || dev=true
[[ "$VERSION" == '5.6' || "$VERSION" == '7.0' ]] && pcov=false || pcov=true
[[ "$ARCH" == 'amd64' ]] && xhprof=true || xhprof=false
[[ "$VERSION" == 8.* || "$VERSION" == 'latest' || "$VERSION" == 'alpine' ]] && otel=true || otel=false
{
echo "tag=$tag"
echo "eol=$eol"
echo "eol-dir=$eolDir"
echo "dockerfile=$dockerfile"
echo "dev=$dev"
echo "pcov=$pcov"
echo "xhprof=$xhprof"
echo "otel=$otel"
} >> "$GITHUB_OUTPUT"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host

- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.pull-username }}
password: ${{ secrets.pull-password }}

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

- name: Build patched PHP base image
id: base
if: ${{ steps.plan.outputs.eol == 'true' }}
uses: ./.github/actions/build-image
with:
image: php-base
tag: ${{ steps.plan.outputs.tag }}
arch: ${{ matrix.arch }}
context: ${{ steps.plan.outputs.eol-dir }}
build-contexts: |
patches=eol/patches
scripts=eol/scripts
push: ${{ env.PUSH }}
cache-write: ${{ env.CACHE_WRITE }}

- name: Build PHP image
id: php
uses: ./.github/actions/build-image
with:
image: php
tag: ${{ steps.plan.outputs.tag }}
arch: ${{ matrix.arch }}
context: .
file: ${{ steps.plan.outputs.dockerfile }}
build-args: BASE_IMAGE=${{ steps.plan.outputs.eol == 'true' && steps.base.outputs.ref || format('php:{0}', steps.plan.outputs.tag) }}
push: ${{ env.PUSH }}
cache-write: ${{ env.CACHE_WRITE }}
no-cache: ${{ env.NO_CACHE }}
test: .

- name: Build dev image
if: ${{ steps.plan.outputs.dev == 'true' }}
uses: ./.github/actions/build-image
with:
image: php-dev
tag: ${{ steps.plan.outputs.tag }}
arch: ${{ matrix.arch }}
context: ./dev
build-args: BASE_IMAGE=${{ steps.php.outputs.ref }}
push: ${{ env.PUSH }}
cache-write: ${{ env.CACHE_WRITE }}
no-cache: ${{ env.NO_CACHE }}
test: dev

- name: Build PCOV image
if: ${{ steps.plan.outputs.pcov == 'true' }}
uses: ./.github/actions/build-image
with:
image: php-pcov
tag: ${{ steps.plan.outputs.tag }}
arch: ${{ matrix.arch }}
context: ./pcov
build-args: BASE_IMAGE=${{ steps.php.outputs.ref }}
push: ${{ env.PUSH }}
cache-write: ${{ env.CACHE_WRITE }}
no-cache: ${{ env.NO_CACHE }}
test: pcov

- name: Build xhprof image
if: ${{ steps.plan.outputs.xhprof == 'true' }}
uses: ./.github/actions/build-image
with:
image: php-xhprof
tag: ${{ steps.plan.outputs.tag }}
arch: ${{ matrix.arch }}
context: ./xhprof
build-args: BASE_IMAGE=${{ steps.php.outputs.ref }}
push: ${{ env.PUSH }}
cache-write: ${{ env.CACHE_WRITE }}
no-cache: ${{ env.NO_CACHE }}
test: xhprof

- name: Build OTel image
if: ${{ steps.plan.outputs.otel == 'true' }}
uses: ./.github/actions/build-image
with:
image: php-otel
tag: ${{ steps.plan.outputs.tag }}
arch: ${{ matrix.arch }}
context: ./otel
build-args: BASE_IMAGE=${{ steps.php.outputs.ref }}
push: ${{ env.PUSH }}
cache-write: ${{ env.CACHE_WRITE }}
no-cache: ${{ env.NO_CACHE }}
test: otel

- name: Upload digests
if: ${{ env.PUSH == 'ghcr' }}
uses: actions/upload-artifact@v4
with:
name: digests-${{ steps.plan.outputs.tag }}-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/
if-no-files-found: error
retention-days: 7
92 changes: 92 additions & 0 deletions .github/workflows/publish-images.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: Publish images

# Publishes the images built and tested by the triggering build-images.yml run: merges each
# image's per-arch digests into a multi-arch tag on ghcr, then copies it to Docker Hub. Nothing is
# rebuilt, and nothing is read from Docker Hub, so publishing doesn't count against its pull limits.
# Callers must skip runs whose commit is no longer the head of main (github.sha), so an older
# commit whose tests finished late can't overwrite the tags of a newer one.

on:
workflow_call:
inputs:
versions:
description: JSON list of PHP versions.
type: string
required: true
flavors:
description: JSON list of flavors.
type: string
default: '[""]'
exclude:
description: JSON list of version/flavor combinations to skip.
type: string
default: '[]'
secrets:
push-username:
required: true
push-password:
required: true

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
publish:
name: ${{ matrix.version }}${{ matrix.flavor }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(inputs.versions) }}
flavor: ${{ fromJSON(inputs.flavors) }}
exclude: ${{ fromJSON(inputs.exclude) }}
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
pattern: digests-${{ matrix.version }}${{ matrix.flavor }}-{amd64,arm64}
path: ${{ runner.temp }}/digests
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

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

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

- name: Create multi-arch tags
env:
TAG: ${{ matrix.version }}${{ matrix.flavor }}
GHCR_USERNAME: ${{ github.repository_owner }}
GHCR_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
DOCKERHUB_USERNAME: ${{ secrets.push-username }}
DOCKERHUB_PASSWORD: ${{ secrets.push-password }}
run: |
shopt -s nullglob
cd "$RUNNER_TEMP/digests"
for arch in amd64 arm64; do
if [[ ! -d "digests-$TAG-$arch" ]]; then
echo "Missing the $arch build of $TAG" >&2
exit 1
fi
done
for image in $(find . -mindepth 2 -maxdepth 2 -type d -printf '%f\n' | sort -u); do
sources=()
for digest in digests-*/"$image"/*; do
sources+=("ghcr.io/chialab/$image@sha256:$(basename "$digest")")
done
docker buildx imagetools create -t "ghcr.io/chialab/$image:$TAG" "${sources[@]}"
# The patched base image is internal and stays on ghcr.
if [[ "$image" != 'php-base' ]]; then
skopeo copy --all --preserve-digests \
--src-creds "$GHCR_USERNAME:$GHCR_PASSWORD" \
--dest-creds "$DOCKERHUB_USERNAME:$DOCKERHUB_PASSWORD" \
"docker://ghcr.io/chialab/$image:$TAG" "docker://docker.io/chialab/$image:$TAG"
fi
done
Loading
Loading