Skip to content

PHP images (grpc + protobuf) #40

PHP images (grpc + protobuf)

PHP images (grpc + protobuf) #40

# Builds the PHP images carrying `grpc` and `protobuf`, and publishes them to GHCR.
#
# **Deliberately independent of `ci.yml`.** It tests nothing of the component: it builds tooling,
# on its own rhythm and with its own permissions. Mixing it into CI would charge a multi-minute C++
# compilation to every pull request that touches a line of PHP.
#
# **Why these images exist, when CI does not need them.** Measured on this repository:
# `shivammathur/setup-php` installs `grpc` in **five seconds**, on the strength of a prebuilt
# binary — the "Temporal integration tests" job starts PHP as fast as the Sylius one, which does
# not ask for the extension. For GitHub Actions the problem is settled, and a build chain would be
# a restatement.
#
# What is not settled is **Docker**. A `php:8.3-alpine` image has no grpc, and
# `install-php-extensions` compiles it from source on every build. Third-party images exist,
# maintained by one person and outside our control. Hence these: the cost is paid once, here, and
# what we consume belongs to us.
name: PHP images (grpc + protobuf)
on:
workflow_dispatch:
inputs:
platforms:
description: "Target architectures. arm64 goes through QEMU and lengthens a C++ compilation considerably."
default: linux/amd64
type: choice
options:
- linux/amd64
- linux/amd64,linux/arm64
dry_run:
description: "Prune: show what would be removed, without removing it."
default: true
type: boolean
# External trigger, for whoever wants to refresh without going through the interface:
# gh api repos/:owner/:repo/dispatches -f event_type=php-grpc-images
repository_dispatch:
types: [php-grpc-images]
push:
# `main` only. Without this filter, a working branch publishes its images under the
# rolling tags — `8.3-cli` ends up designating an unreviewed experiment.
branches: [main]
paths:
- 'docker/php-grpc/**'
- '.github/workflows/php-grpc-images.yml'
pull_request:
paths:
- 'docker/php-grpc/**'
- '.github/workflows/php-grpc-images.yml'
schedule:
# Every week. Two things age here, and not at the same rate: the base's system packages,
# and PHP itself — whose patch releases come out every month. A weekly cadence keeps the
# gap between `php:8.4-cli` and our image counted in days. The Buildx cache makes the
# rebuild cheap when nothing has moved upstream.
- cron: '17 4 * * 1'
concurrency:
group: php-grpc-images-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: PHP ${{ matrix.php }} · ${{ matrix.flavour }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4', '8.5']
# `zts` is the variant compiled with thread support. It is not a compilation
# detail: a runtime that runs several workers in a single process demands it, and
# an extension loaded into a PHP whose thread-safety does not match simply refuses
# to load. Both are therefore necessary, neither one replaces the other.
flavour: [cli, cli-alpine, zts, zts-alpine]
steps:
- uses: actions/checkout@v7
- uses: docker/setup-qemu-action@v3
if: contains(inputs.platforms, 'arm64')
- uses: docker/setup-buildx-action@v3
- name: Authenticate against GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# `github.repository_owner` rather than the repository name: these images are not
# Durable's, they are its author's — other projects can consume them.
- name: Tags
id: meta
run: |
base="ghcr.io/${{ github.repository_owner }}/php-grpc"
echo "tags=${base}:${{ matrix.php }}-${{ matrix.flavour }},${base}:${{ matrix.php }}-${{ matrix.flavour }}-$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
- name: Build and publish
uses: docker/build-push-action@v6
with:
context: docker/php-grpc
platforms: ${{ inputs.platforms || 'linux/amd64' }}
build-args: |
PHP_VERSION=${{ matrix.php }}
FLAVOUR=${{ matrix.flavour }}
# On a pull request we build without publishing: the compilation is what we
# want to check, the publication does not have to be.
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha,scope=php-grpc-${{ matrix.php }}-${{ matrix.flavour }}
cache-to: type=gha,mode=max,scope=php-grpc-${{ matrix.php }}-${{ matrix.flavour }}
# The Dockerfile's `RUN php -m | grep` proves the extension **in** the built layer.
# This one proves it in the **published** image, which is not the same assertion.
- name: The published image does carry both extensions
if: github.event_name != 'pull_request' && !contains(inputs.platforms, 'arm64')
run: |
image="ghcr.io/${{ github.repository_owner }}/php-grpc:${{ matrix.php }}-${{ matrix.flavour }}"
docker run --rm "$image" php -m | grep -qx grpc
docker run --rm "$image" php -m | grep -qx protobuf
attendu="$(case '${{ matrix.flavour }}' in zts*) echo enabled;; *) echo disabled;; esac)"
docker run --rm "$image" php -i | grep -q "Thread Safety => $attendu"
docker run --rm "$image" php -v | head -n 1
# Sixteen images rebuilt every Monday, each placing a dated tag: about eight hundred tags a
# year, and nothing prunes them. The version list becomes unreadable well before storage
# becomes a problem.
retention:
name: Prune the dated tags
needs: build
# Never on a pull request: nothing is published there, so there is nothing to prune, and
# a job that deletes published images has no place in validating a proposal.
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# What decides fits in one function, and it is checked before being applied.
# The case that matters: on publication Monday, `8.4-zts` and `8.4-zts-20260828`
# designate the same manifest — removing "the dated tag" would remove the image the
# rolling tag designates.
- name: The rule checks itself before it applies
run: python3 docker/php-grpc/retention.py --self-test
- name: Prune
env:
# The package deletion API does not accept the `GITHUB_TOKEN`: it asks for a
# classic personal token carrying `read:packages` and `delete:packages`. Without
# the secret, the job warns and stops — a weekly failure would end up ignored,
# and the real ones with it.
GH_TOKEN: ${{ secrets.GHCR_RETENTION_TOKEN }}
OWNER: ${{ github.repository_owner }}
DRY_RUN: ${{ inputs.dry_run || 'false' }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::warning title=Pruning not configured::The GHCR_RETENTION_TOKEN secret is absent. Dated tags keep accumulating. Create a classic personal token with read:packages and delete:packages."
exit 0
fi
# Deux familles d'URL cohabitent pour les paquets : celle de l'utilisateur
# authenticated user, and that of a named user. A token belonging to the
# package's owner goes through the first; we try it, and fall back on the second.
# Resolving here rather than at deletion time matters: the read happens every
# week, the first deletion is only due in two months.
base="/user/packages/container/php-grpc"
if ! gh api "$base/versions?per_page=1" >/dev/null 2>&1; then
base="/users/$OWNER/packages/container/php-grpc"
gh api "$base/versions?per_page=1" >/dev/null
fi
echo "API : $base"
gh api --paginate "$base/versions?per_page=100" \
-q '.[] | {id: .id, tags: (.metadata.container.tags // [])}' \
| jq -s '.' > versions.json
python3 docker/php-grpc/retention.py --keep 8 < versions.json > a-retirer.txt
if [ "$DRY_RUN" = "true" ]; then
echo "::notice::Dry run: $(wc -l < a-retirer.txt) version(s) would have been removed."
exit 0
fi
while read -r id; do
gh api -X DELETE "$base/versions/$id"
echo "removed: $id"
done < a-retirer.txt