Shared PHP CI images for Gitea Actions
One image per PHP version, published to git.unsupervised.ca/unsupervised/ci-php:<php-version>, so PHP projects can run their jobs with `container:` instead of installing PHP per job. Built on php:<version>-cli-alpine with Composer 2, the intl and zip extensions, and the GNU CLI tools workflow scripts expect. bash and nodejs are present because act_runner runs JavaScript actions inside the job container; GNU tar because actions/cache shells out to `tar --posix -P`, which busybox rejects. Covers 8.1 through 8.5. Pushing needs the REGISTRY_TOKEN organisation secret — Gitea's Actions task token cannot write packages (go-gitea/gitea#23642). Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
This commit is contained in:
@@ -0,0 +1,94 @@
|
|||||||
|
name: Publish
|
||||||
|
|
||||||
|
# Builds one image per PHP version and pushes it to the Gitea container
|
||||||
|
# registry as git.unsupervised.ca/unsupervised/ci-php:<php-version>.
|
||||||
|
#
|
||||||
|
# Runs when the image definition changes on main, weekly so PHP patch
|
||||||
|
# releases and Alpine security updates land on their own, and on demand.
|
||||||
|
# Pull requests build every version without pushing, so a broken Dockerfile
|
||||||
|
# is caught before it can move a published tag.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile'
|
||||||
|
- '.gitea/workflows/publish.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile'
|
||||||
|
- '.gitea/workflows/publish.yml'
|
||||||
|
schedule:
|
||||||
|
- cron: '17 4 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.unsupervised.ca
|
||||||
|
IMAGE: unsupervised/ci-php
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: PHP ${{ matrix.php }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
# One version failing should not hide whether the others built.
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
php:
|
||||||
|
- '8.1'
|
||||||
|
- '8.2'
|
||||||
|
- '8.3'
|
||||||
|
- '8.4'
|
||||||
|
- '8.5'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Images are built natively, so each carries the architecture of the
|
||||||
|
# runner that built it. Every runner in the pool is arm64. A runner of
|
||||||
|
# a different architecture joining would overwrite these tags with its
|
||||||
|
# own arch and break the rest, at which point this needs buildx and a
|
||||||
|
# multi-arch manifest.
|
||||||
|
- name: Check Docker is available
|
||||||
|
run: |
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "No usable Docker daemon in the job container." >&2
|
||||||
|
echo "act_runner needs container.docker_host set, or left empty to autodetect." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker version --format 'client {{.Client.Version}} / server {{.Server.Version}} / arch {{.Server.Arch}}'
|
||||||
|
|
||||||
|
# REGISTRY_TOKEN is an organisation secret: a personal access token
|
||||||
|
# with the package scope, Read and Write. Gitea's Actions task token is
|
||||||
|
# rejected by the container registry (go-gitea/gitea#23642), so this
|
||||||
|
# cannot fall back to secrets.GITHUB_TOKEN. The login user must own the
|
||||||
|
# token; set the REGISTRY_USER variable if it is not github.actor.
|
||||||
|
- name: Log in to the container registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
||||||
|
echo "REGISTRY_TOKEN is not set. See README.md — the Actions task token cannot push packages." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||||
|
| docker login "${REGISTRY}" -u "${{ vars.REGISTRY_USER || github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--pull \
|
||||||
|
--build-arg "PHP_VERSION=${{ matrix.php }}" \
|
||||||
|
--tag "${REGISTRY}/${IMAGE}:${{ matrix.php }}" \
|
||||||
|
--file Dockerfile \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: |
|
||||||
|
image="${REGISTRY}/${IMAGE}:${{ matrix.php }}"
|
||||||
|
docker push "${image}"
|
||||||
|
echo "Published ${image}"
|
||||||
|
|
||||||
|
- name: Log out
|
||||||
|
if: always() && github.event_name != 'pull_request'
|
||||||
|
run: docker logout "${REGISTRY}" || true
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
# Shared PHP CI image, one tag per PHP version.
|
||||||
|
#
|
||||||
|
# Published to git.unsupervised.ca/unsupervised/ci-php:<php-version> by
|
||||||
|
# .gitea/workflows/publish.yml. Projects run their jobs inside it with
|
||||||
|
# `container:`, so PHP, Composer and the usual CLI tools are already present
|
||||||
|
# when a job starts.
|
||||||
|
|
||||||
|
ARG PHP_VERSION=8.3
|
||||||
|
FROM php:${PHP_VERSION}-cli-alpine
|
||||||
|
|
||||||
|
# bash and nodejs are required by the runner itself: act_runner executes
|
||||||
|
# JavaScript actions (actions/checkout, actions/cache, actions/upload-artifact)
|
||||||
|
# inside the job container, and shells `run:` steps through bash.
|
||||||
|
#
|
||||||
|
# coreutils, gawk, grep, sed and tar replace the busybox applets with the GNU
|
||||||
|
# ones workflow scripts are usually written against. tar is load-bearing:
|
||||||
|
# actions/cache shells out to `tar --posix -P`, which busybox rejects, so
|
||||||
|
# every cache step fails without it. zstd is what actions/cache reaches for
|
||||||
|
# in preference to gzip when it is installed.
|
||||||
|
#
|
||||||
|
# curl, git, jq and zip/unzip cover what release and packaging scripts
|
||||||
|
# generally call out to.
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
bash \
|
||||||
|
coreutils \
|
||||||
|
curl \
|
||||||
|
gawk \
|
||||||
|
git \
|
||||||
|
grep \
|
||||||
|
jq \
|
||||||
|
nodejs \
|
||||||
|
sed \
|
||||||
|
tar \
|
||||||
|
unzip \
|
||||||
|
zip \
|
||||||
|
zstd \
|
||||||
|
icu-libs \
|
||||||
|
libzip \
|
||||||
|
&& apk add --no-cache --virtual .build-deps \
|
||||||
|
$PHPIZE_DEPS \
|
||||||
|
icu-dev \
|
||||||
|
libzip-dev \
|
||||||
|
&& docker-php-ext-install -j"$(nproc)" intl zip \
|
||||||
|
&& apk del --no-network .build-deps
|
||||||
|
|
||||||
|
# mbstring is compiled into the official php images. intl and zip are added
|
||||||
|
# above. Together that covers phpunit, phpstan, phpcs and Composer.
|
||||||
|
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
# Jobs run as root inside the container and never answer prompts.
|
||||||
|
ENV COMPOSER_ALLOW_SUPERUSER=1 \
|
||||||
|
COMPOSER_NO_INTERACTION=1 \
|
||||||
|
COMPOSER_HOME=/composer
|
||||||
|
|
||||||
|
# Smoke test: fail the build rather than publish an image missing something
|
||||||
|
# a consuming job depends on.
|
||||||
|
RUN mkdir -p "$COMPOSER_HOME" \
|
||||||
|
&& tar --version | head -1 | grep -q 'GNU tar' \
|
||||||
|
&& node --version \
|
||||||
|
&& php -v \
|
||||||
|
&& php -m | grep -qx intl \
|
||||||
|
&& php -m | grep -qx mbstring \
|
||||||
|
&& php -m | grep -qx zip \
|
||||||
|
&& composer --version
|
||||||
|
|
||||||
|
CMD ["/bin/bash"]
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# ci-php
|
||||||
|
|
||||||
|
Shared PHP CI images for Gitea Actions, one tag per PHP version.
|
||||||
|
|
||||||
|
```
|
||||||
|
git.unsupervised.ca/unsupervised/ci-php:8.1
|
||||||
|
git.unsupervised.ca/unsupervised/ci-php:8.2
|
||||||
|
git.unsupervised.ca/unsupervised/ci-php:8.3
|
||||||
|
git.unsupervised.ca/unsupervised/ci-php:8.4
|
||||||
|
git.unsupervised.ca/unsupervised/ci-php:8.5
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Unsupervised` org is public, so these pull anonymously — consuming jobs
|
||||||
|
need no registry credentials.
|
||||||
|
|
||||||
|
## Using them
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: git.unsupervised.ca/unsupervised/ci-php:${{ matrix.php }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
php: ['8.1', '8.2', '8.3', '8.4', '8.5']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /composer/cache
|
||||||
|
key: ${{ matrix.php }}-composer-${{ hashFiles('composer.lock') }}
|
||||||
|
- run: composer install --prefer-dist --no-progress --no-interaction
|
||||||
|
- run: composer test
|
||||||
|
```
|
||||||
|
|
||||||
|
`COMPOSER_HOME` is `/composer`, so `/composer/cache` is the path to cache.
|
||||||
|
|
||||||
|
Note that `jobs.<id>.container.image` cannot read the `env` context, so the
|
||||||
|
registry path has to be written out at each use or come from a repo variable.
|
||||||
|
|
||||||
|
## What is in them
|
||||||
|
|
||||||
|
Built on `php:<version>-cli-alpine`:
|
||||||
|
|
||||||
|
- **`bash`, `nodejs`** — required by the runner, not by your build. act_runner
|
||||||
|
executes JavaScript actions (`actions/checkout`, `actions/cache`,
|
||||||
|
`actions/upload-artifact`) inside the job container and shells `run:` steps
|
||||||
|
through bash. A job in an image without these fails on its first step.
|
||||||
|
- **`coreutils`, `gawk`, `grep`, `sed`, `tar`** — GNU rather than busybox.
|
||||||
|
`tar` is the one that matters: `actions/cache` shells out to
|
||||||
|
`tar --posix -P`, which busybox rejects outright, so every cache step fails
|
||||||
|
without it. `zstd` is what `actions/cache` prefers over gzip when present.
|
||||||
|
- **`curl`, `git`, `jq`, `zip`, `unzip`** — what release and packaging scripts
|
||||||
|
usually reach for.
|
||||||
|
- **PHP extensions**: `intl` and `zip` on top of the official image, which
|
||||||
|
already compiles in `mbstring`. Enough for phpunit, phpstan, phpcs and
|
||||||
|
Composer.
|
||||||
|
- **Composer 2.**
|
||||||
|
|
||||||
|
Roughly 67MB compressed.
|
||||||
|
|
||||||
|
If a project needs an extension that is not here, add it to the `Dockerfile`
|
||||||
|
rather than installing it at job time — that is the whole point of the image.
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
`.gitea/workflows/publish.yml` builds and pushes. It runs when `Dockerfile`
|
||||||
|
changes on `main`, weekly so PHP patch releases and Alpine security updates
|
||||||
|
land unattended, and on `workflow_dispatch`. Pull requests build every
|
||||||
|
version without pushing.
|
||||||
|
|
||||||
|
### Registry authentication
|
||||||
|
|
||||||
|
Pushing requires the **`REGISTRY_TOKEN`** secret, set at the organisation
|
||||||
|
level. This is not optional: Gitea's Actions task token
|
||||||
|
(`secrets.GITHUB_TOKEN`) is rejected by the container registry, failing with
|
||||||
|
`Get "https://git.unsupervised.ca/v2/": unauthorized`. See
|
||||||
|
[go-gitea/gitea#23642](https://github.com/go-gitea/gitea/issues/23642).
|
||||||
|
|
||||||
|
`REGISTRY_TOKEN` is a personal access token with the `package` scope, Read
|
||||||
|
and Write. The workflow logs in as `github.actor`, which must be the account
|
||||||
|
that owns the token; set a `REGISTRY_USER` variable if it needs to differ.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Images are built natively, so each carries the architecture of the runner
|
||||||
|
that built it. Every runner in the pool is arm64. If a runner of another
|
||||||
|
architecture joins, it will overwrite these tags with its own arch and the
|
||||||
|
rest will fail to pull — that is the point to switch to `docker buildx` and a
|
||||||
|
multi-arch manifest.
|
||||||
|
|
||||||
|
## Adding a PHP version
|
||||||
|
|
||||||
|
Add it to the `php` matrix in `.gitea/workflows/publish.yml` and merge.
|
||||||
|
Consuming projects can only reference a tag once it has been published.
|
||||||
Reference in New Issue
Block a user