From 572aaf5b4918dd1f22ee6afc77e92f5c772f224a Mon Sep 17 00:00:00 2001 From: James Griffin Date: Mon, 24 Aug 2026 19:49:17 -0300 Subject: [PATCH] Publish prebuilt CI images to the Gitea container registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup-php installs PHP 8.3+ from apt on these arm64 runners: a ~145s floor against ~35s for 8.1/8.2, with a tail that twice ran past the step timeout and failed the run (#178). Caching the .debs softened it without removing the apt step, and 8.5 has the same problem. Add a per-version CI image built on php:-cli-alpine and a workflow that publishes it to git.unsupervised.ca/unsupervised/ci-php:. The org is public, so the packages pull anonymously. The image carries bash and nodejs because act_runner runs JavaScript actions inside the job container, GNU coreutils/grep/sed because the workflow scripts use `tac` and `grep --include`, and curl/jq/git/zip for release.yml and bin/build-zip.sh. Composer 2 and the intl and zip extensions round it out. Nothing consumes the images yet — ci.yml and release.yml switch over in a follow-up, because a job cannot run in an image that has not been published. Part of #187 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2 --- .gitea/ci/Dockerfile | 60 ++++++++++++++++++++++ .gitea/workflows/ci-images.yml | 94 ++++++++++++++++++++++++++++++++++ docs/ci.md | 70 +++++++++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 .gitea/ci/Dockerfile create mode 100644 .gitea/workflows/ci-images.yml create mode 100644 docs/ci.md diff --git a/.gitea/ci/Dockerfile b/.gitea/ci/Dockerfile new file mode 100644 index 0000000..708a831 --- /dev/null +++ b/.gitea/ci/Dockerfile @@ -0,0 +1,60 @@ +# CI image for unsupervised-scheduler, one tag per PHP version. +# +# Built and published by .gitea/workflows/ci-images.yml to +# git.unsupervised.ca/unsupervised/ci-php:. CI and release jobs +# run inside it via `container:`, so nothing installs PHP at job time. +# +# Why: setup-php installs 8.3+ through apt/the ondrej PPA on these arm64 +# runners — a ~145s floor against ~35s for 8.1/8.2, with a tail that has +# twice crossed into hard failure (#178). Pulling a ~120MB image from the +# registry in our own cluster replaces that entirely (#187). + +ARG PHP_VERSION=8.3 +FROM php:${PHP_VERSION}-cli-alpine + +# bash and nodejs are not optional: 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 and sed replace the busybox applets with the GNU ones +# the workflow scripts are written against (`tac`, `grep --include`). +# +# jq, curl, git and zip/unzip are used by release.yml and bin/build-zip.sh. +RUN apk add --no-cache \ + bash \ + coreutils \ + curl \ + gawk \ + git \ + grep \ + jq \ + nodejs \ + sed \ + unzip \ + zip \ + 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. That covers what phpunit, phpstan, phpcs and Composer need. + +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 + +RUN mkdir -p "$COMPOSER_HOME" \ + && php -v \ + && php -m | grep -qx intl \ + && php -m | grep -qx mbstring \ + && composer --version + +CMD ["/bin/bash"] diff --git a/.gitea/workflows/ci-images.yml b/.gitea/workflows/ci-images.yml new file mode 100644 index 0000000..7dbeb2b --- /dev/null +++ b/.gitea/workflows/ci-images.yml @@ -0,0 +1,94 @@ +name: CI Images + +# Publishes the per-PHP-version images that ci.yml and release.yml run inside +# (#187). Nothing else consumes them, so this workflow is the only place the +# registry path is written down. +# +# Triggers: +# - the Dockerfile or this workflow changing on main, so an edit ships; +# - the same paths on a pull request, which builds but does not push, so a +# broken Dockerfile is caught before it reaches main; +# - workflow_dispatch, to rebuild on demand; +# - weekly, so PHP patch releases and Alpine security updates land without +# anyone remembering to ask. + +on: + push: + branches: + - main + paths: + - '.gitea/ci/Dockerfile' + - '.gitea/workflows/ci-images.yml' + pull_request: + paths: + - '.gitea/ci/Dockerfile' + - '.gitea/workflows/ci-images.yml' + schedule: + - cron: '17 4 * * 1' + workflow_dispatch: + +env: + # The instance ROOT_URL host — the container registry lives on the same host. + REGISTRY: git.unsupervised.ca + IMAGE: unsupervised/ci-php + +jobs: + build: + name: Build CI image (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + # One version failing should not hide whether the others built. + fail-fast: false + matrix: + # Keep in step with the test matrix in ci.yml. + php: + - '8.1' + - '8.2' + - '8.3' + - '8.5' + steps: + - uses: actions/checkout@v4 + + # The images are built natively, so they carry the runner's + # architecture only. Fine while every runner is arm64; if a runner of a + # different architecture ever joins the pool it will overwrite these + # tags with its own arch and the others will fail to pull. + - 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}}' + + # secrets.GITHUB_TOKEN is the Actions task token and can write packages + # for the repository owner. REGISTRY_TOKEN is an escape hatch: set it to + # a PAT with package:write if the task token is ever refused. + - name: Log in to the container registry + if: github.event_name != 'pull_request' + run: | + echo "${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_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 .gitea/ci/Dockerfile \ + .gitea/ci + + # Pull requests build only — the tags on the registry are what the other + # workflows run inside, so only main and a manual dispatch move them. + - 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/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..1822194 --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,70 @@ +# CI images + +CI and release jobs do not install PHP. They run inside prebuilt images +published to the Gitea container registry: + +``` +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.5 +``` + +The `Unsupervised` org is public, so the packages pull anonymously — jobs need +no registry credentials to use them. + +## Why + +`shivammathur/setup-php` installs PHP 8.3+ from apt/the ondrej PPA on these +arm64 runners. That was a ~145s floor against ~35s for 8.1 and 8.2, with a +tail that twice ran past the step timeout and failed the run outright +(#178). Caching the `.deb`s helped, but the apt step itself remained, and PHP +8.5 has the same shape of problem. Pulling a ~120MB image from a registry +inside the cluster replaces the whole thing (#187). + +## What is in the image + +`.gitea/ci/Dockerfile` builds on `php:-cli-alpine` and adds: + +- **`bash` and `nodejs`** — act_runner runs JavaScript actions + (`actions/checkout`, `actions/cache`, `actions/upload-artifact`) *inside* + the job container and shells `run:` steps through bash. Without these, the + first step of every job fails. +- **`coreutils`, `gawk`, `grep`, `sed`** — GNU versions, because the workflow + scripts use `tac` and `grep --include`, which busybox does not provide. +- **`curl`, `jq`, `git`, `zip`, `unzip`** — used by `release.yml` and + `bin/build-zip.sh`. +- **`intl` and `zip` PHP extensions**, plus Composer 2. `mbstring` is already + compiled into the official images. + +## Publishing + +`.gitea/workflows/ci-images.yml` builds and pushes them. It runs when the +Dockerfile changes on `main`, weekly (so PHP patch releases and Alpine +security updates land on their own), and on `workflow_dispatch`. On a pull +request it builds without pushing, so a broken Dockerfile is caught before it +reaches `main`. + +## Adding or dropping a PHP version + +1. Add the version to the `php` matrix in `.gitea/workflows/ci-images.yml`. +2. Merge to `main`, or dispatch the workflow, and wait for the tag to appear. +3. Add the version to the `test` matrix in `.gitea/workflows/ci.yml`. + +Steps 2 and 3 cannot be one commit: a job cannot run in an image that has not +been published yet. + +## Architecture + +The images are built natively on whichever runner picks the job, so they carry +that runner's architecture only. Every runner in the pool is arm64 today. If +one of a different architecture ever joins, it will overwrite these tags with +its own arch and the rest will fail to pull — at which point the build needs +`docker buildx` and a multi-arch manifest. + +## If a push is refused + +The build authenticates with `secrets.GITHUB_TOKEN`, the Actions task token. +If the registry ever refuses it, create a personal access token with +`package:write`, store it as the `REGISTRY_TOKEN` secret, and optionally set +the `REGISTRY_USER` variable — the workflow prefers both when present.