commit 2016cfbe20ba350d0d57781184e3d9c19187f16d Author: James Griffin Date: Mon Aug 24 22:37:25 2026 -0300 Shared PHP CI images for Gitea Actions One image per PHP version, published to git.unsupervised.ca/unsupervised/ci-php:, so PHP projects can run their jobs with `container:` instead of installing PHP per job. Built on php:-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 Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2 diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..8e9f5f1 --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -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:. +# +# 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d0b1062 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +# Shared PHP CI image, one tag per PHP version. +# +# Published to git.unsupervised.ca/unsupervised/ci-php: 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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..577f342 --- /dev/null +++ b/README.md @@ -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..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:-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.