# 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.