Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .bowerrc

This file was deleted.

28 changes: 5 additions & 23 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ on:
jobs:
build_push_to_dockerhub:
strategy:
# TEMPORARY: report each PHP version's result separately while testing; remove before merging
fail-fast: false
matrix:
base_container: ["php:7.4-bullseye", "php:8.0-bullseye", "php:8.1-bullseye", "php:8.2-bookworm", "php:8.3-bookworm", "php:8.4-bookworm"]
base_container: ["php:8.2-bookworm", "php:8.3-bookworm", "php:8.4-bookworm"]
runs-on: ubuntu-latest

steps:
Expand All @@ -21,27 +23,6 @@ jobs:

## Set environment variables for the build container by matrix

# 7.4 is the current "latest" default version and gets this extra tag
# upon EOL, this should move to 8.0
# 7.4 has already reached EoL, we can consider 8.0 as default version now
- name: Set PHP 7.4 settings
if: ${{ matrix.base_container == 'php:7.4-bullseye' }}
run: |
echo "BUILD_TAGS=10up/wordpress-ci:latest,10up/wordpress-ci:php-7.4" >> $GITHUB_ENV
echo "COMPOSER_VERSION=1" >> $GITHUB_ENV

- name: Set PHP 8.0 settings
if: ${{ matrix.base_container == 'php:8.0-bullseye' }}
run: |
echo "BUILD_TAGS=10up/wordpress-ci:php-8.0" >> $GITHUB_ENV
echo "COMPOSER_VERSION=2" >> $GITHUB_ENV

- name: Set PHP 8.1 settings
if: ${{ matrix.base_container == 'php:8.1-bullseye' }}
run: |
echo "BUILD_TAGS=10up/wordpress-ci:php-8.1" >> $GITHUB_ENV
echo "COMPOSER_VERSION=2" >> $GITHUB_ENV

- name: Set PHP 8.2 settings
if: ${{ matrix.base_container == 'php:8.2-bookworm' }}
run: |
Expand All @@ -54,10 +35,11 @@ jobs:
echo "BUILD_TAGS=10up/wordpress-ci:php-8.3" >> $GITHUB_ENV
echo "COMPOSER_VERSION=2" >> $GITHUB_ENV

# The newest PHP version also gets the "latest" tag; move it when adding a newer version
- name: Set PHP 8.4 settings
if: ${{ matrix.base_container == 'php:8.4-bookworm' }}
run: |
echo "BUILD_TAGS=10up/wordpress-ci:php-8.4" >> $GITHUB_ENV
echo "BUILD_TAGS=10up/wordpress-ci:latest,10up/wordpress-ci:php-8.4" >> $GITHUB_ENV
echo "COMPOSER_VERSION=2" >> $GITHUB_ENV

## GitHub Action validation testing before starting workflow ##
Expand Down
56 changes: 56 additions & 0 deletions AGENTS.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As Claude now supports AGENTS.md, we should switch to it. We should also make this AI-agnostic, so no mention of Claude Code, but rather AI agents in general.

We should likely address this Known doc drift section at the bottom, no?

Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# AGENTS.md

This file provides guidance to AI coding agents working with code in this repository.

**Keep this file current.** Any change to the Dockerfile, build/CI scripts, workflow matrix, env-var contract, or tests must be reflected here in the same change.

## What this repo is

Source for the `10up/wordpress-ci` Docker images: one Debian-based PHP image per PHP version, bundling the tooling WordPress CI/CD pipelines need (composer, nvm/node, wp-cli, ClamAV, kubectl, docker-cli, terminus, gh, glab, awscli/ansible via pip, etc.). There is no application code. The deliverables are the image and the helper scripts it ships in `/custom-scripts`.

## Commands

```bash
# Build locally (PHP_IMG is required, there is no default)
docker build --build-arg PHP_IMG=php:8.3-bookworm --build-arg COMPOSER_VERSION=2 -t wordpress-ci:local .

# Try a script inside the image against the current dir
docker run --rm -v "$PWD":/work -w /work wordpress-ci:local virus-scan

# Tests (host-side, no Docker; fake binaries/functions on PATH or NVM_DIR)
bash tests/test-virus-scan.sh
bash tests/test-install-node.sh

# Lint shell (all current findings are info-level)
shellcheck scripts/* build/*.sh entrypoint.sh tests/*.sh
```

`tests/test-virus-scan.sh` hardcodes `mktemp -d /private/tmp/...`, so it only runs on macOS as written. `tests/test-install-node.sh` uses `${TMPDIR:-/tmp}` and is portable. CI does not run the tests or shellcheck. The only CI job is the image build.

## Architecture

**Image build (`Dockerfile`)**, in this order: apt packages (incl. `clamav-daemon`), then `freshclam` early so the virus DB is baked in (running it later in the file hit bugs), locale, PHP ini (`memory_limit=-1`) and extensions (including `opentelemetry` from pecl), nvm in `/tmp/.nvm` + the latest `NODE_LTS_COUNT` (default 3) Node LTS lines via `build/install-node.sh`, pip `requirements.txt` (uses `PIP_BREAK_SYSTEM_PACKAGES=1` for bookworm), composer, wp-cli, then docker-cli/kubectl/terminus/gh/glab, which are always the latest release and unpinned. Finally `scripts/*` go to `/custom-scripts` (on `PATH`), plus `BASH_ENV=/root/.bashrc` so non-interactive CI shells load nvm and the `wp` alias.

- `build/install-composer.sh` installs `composer1-bin`, `composer2-bin`, and a default `composer` chosen by the `COMPOSER_VERSION` build arg (default 2, and every image in CI uses 2).
- `build/install-wpcli.sh` installs `/usr/local/bin/wp-cli.phar`. `wp` exists only as a `.bashrc` alias (`php wp-cli.phar --allow-root`), not as a binary. Bash doesn't expand aliases in non-interactive shells, so `bash -c 'wp ...'` fails with "command not found" even though `BASH_ENV` loads `.bashrc`.
- `build/install-node.sh [count]` installs nvm's relative aliases oldest first: `lts/-2`, `lts/-1`, then `lts/*`. It resolves them at build time, so the weekly no-cache rebuild rolls the set forward automatically when a new line goes LTS (versions get dropped the same way). It rejects a count that isn't a positive integer. Keeping 3 is a deliberate choice, even when the oldest line is past EOL (e.g. Node 20 until 26 goes LTS). The Dockerfile then runs `nvm alias default 'lts/*'`. Pipelines switch with `nvm use <major>` or run `nvm install <v>` for anything else.

**Runtime (`entrypoint.sh`, POSIX sh)**: sources nvm, then applies optional env vars: `PRIVATE_KEY`/`PUBLIC_KEY` go to `/root/.ssh/id_rsa{,.pub}`, `TERMINUS_TOKEN` runs a terminus login, `GIT_USER_NAME`/`GIT_USER_EMAIL` set git global config, `COMPOSER_CONFIG` runs `composer config -g`, and `BUILD_CACHE_DIR` points the composer and npm caches at `$BUILD_CACHE_DIR/.composer-cache` and `$BUILD_CACHE_DIR/node_modules_cache` (for GitLab: `${CI_PROJECT_DIR}`). It then prints the versions of php, composer, node, npm and wp-cli, and runs `exec "$@"`. The script has no `set -e`, so a missing tool in that printout only logs "not found". When removing a tool from the image, also remove its version line here.

**CI scripts (`scripts/`, extensionless, copied to `/custom-scripts`)**:
- `all-scripts` *sources* (does not execute) every top-level `/custom-scripts/*.sh` (user-added), then `php-syntax`, then `virus-scan`, all under `set -euo pipefail`. Because they are sourced, an `exit` in any of them ends the whole run, and `virus-scan` always exits. It must stay last.
- `php-syntax` runs `php -l` in parallel (`xargs -P10`) on every `*.php` outside `vendor/`. Output goes to stdout only when `IS_DEBUG_ENABLED=true`.
- `virus-scan` starts a private, temporary `clamd` (generated config in a `mktemp` dir, `LogClean yes`, excludes `.composer-cache` and `node_modules_cache`), waits up to 30s for `--ping`, runs `clamdscan --multiscan "$PWD"`, then kills the daemon and removes the temp dir via an EXIT trap. The scanned-file count comes from the **daemon log** (`: OK` / ` FOUND` lines), because clamdscan's report on a directory doesn't give a per-file count. `: OK` lines are filtered out of the printed report. **Exit contract:** 1 only when an infection is found. Every scanner or daemon failure exits 0 (fail-open, so a broken scanner never blocks a deploy). `CLAMAV_DB_DIR` and `TMPDIR` can be overridden; the tests rely on both.
- `slack-message` posts a Slack attachment via webhook. `-u` and `-m` are required (see the README for all flags).

**Tests** follow one pattern: fake the external tool, run the real script, then assert exit codes and the calls or output that were recorded.
- `test-virus-scan.sh` stubs `clamd`/`clamdscan` as bash scripts driven by `FAKE_SCAN_EXIT` (0 clean, 1 infected, 2 scan error, 3 no summary) and `FAKE_START_MODE`. It checks the generated `clamd.conf`, the output, and that the daemon was stopped. Changes to virus-scan output or config need matching test updates.
- `test-install-node.sh` points `NVM_DIR` at a fake `nvm.sh` that defines recording `nvm`/`npm` functions (`FAKE_FAIL_VERSION` makes one install fail). It checks install order, the count handling, and that the script stops at the first failed install.

## Release / CI (`.github/workflows/build.yaml`)

- Runs on every push to any branch, plus a weekly rebuild (Sun 04:00 UTC) to pick up security updates. Builds are always `no-cache: true`, wrapped in a 3-attempt retry.
- The matrix is `base_container`, which currently builds PHP 8.2, 8.3 and 8.4, all on `-bookworm` images. Each entry needs a matching "Set PHP X settings" step that sets `BUILD_TAGS` and `COMPOSER_VERSION` (always 2). The newest version also carries the `latest` tag, which is currently 8.4, so adding a newer PHP means moving `latest` to it.
- PHP 7.4, 8.0 and 8.1 are deprecated and removed from the matrix. Their Docker Hub tags still exist but are no longer rebuilt. Don't reintroduce bullseye-based images: Debian bullseye support ended on Aug 31, 2026, and its security package files return 404, so any bullseye build fails at the first `apt-get install`.
- The matrix has `fail-fast: false` set **temporarily** so each PHP version reports its own result while this branch is being tested. Remove it before merging.
- Images push to Docker Hub only from `trunk`. Branch builds only validate. Adding a PHP version means adding a matrix entry plus a settings step (the README has the template).
11 changes: 6 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# base image should be a PHP Debian container, such as php:7.4-buster
# base image should be a PHP Debian container, such as php:8.4-bookworm
ARG PHP_IMG
FROM $PHP_IMG

Check warning on line 3 in Dockerfile

View workflow job for this annotation

GitHub Actions / build_push_to_dockerhub (php:8.4-bookworm)

Default value for global ARG results in an empty or invalid base image name

InvalidDefaultArgInFrom: Default value for ARG $PHP_IMG results in empty or invalid base image name More info: https://docs.docker.com/go/dockerfile/rule/invalid-default-arg-in-from/

Check warning on line 3 in Dockerfile

View workflow job for this annotation

GitHub Actions / build_push_to_dockerhub (php:8.3-bookworm)

Default value for global ARG results in an empty or invalid base image name

InvalidDefaultArgInFrom: Default value for ARG $PHP_IMG results in empty or invalid base image name More info: https://docs.docker.com/go/dockerfile/rule/invalid-default-arg-in-from/

Check warning on line 3 in Dockerfile

View workflow job for this annotation

GitHub Actions / build_push_to_dockerhub (php:8.2-bookworm)

Default value for global ARG results in an empty or invalid base image name

InvalidDefaultArgInFrom: Default value for ARG $PHP_IMG results in empty or invalid base image name More info: https://docs.docker.com/go/dockerfile/rule/invalid-default-arg-in-from/

RUN apt-get update && \
apt-get install -y \
Expand Down Expand Up @@ -96,7 +96,7 @@

## PHP extensions ##
RUN docker-php-ext-install zip pdo pdo_mysql gd bcmath intl sockets mysqli exif soap
RUN if [ ! -z "$(php --version | grep ^PHP | awk '{print $2}' | grep -v ^7 | grep -v "8.0")" ]; then pecl install opentelemetry && docker-php-ext-enable opentelemetry; fi
RUN pecl install opentelemetry && docker-php-ext-enable opentelemetry

#### Specific to building / deploying ####

Expand All @@ -110,10 +110,11 @@
ENV CYPRESS_CACHE_FOLDER="/tmp/cypress/cache"
RUN mkdir -p ${CYPRESS_CACHE_FOLDER} && chmod 777 ${CYPRESS_CACHE_FOLDER}

ARG NODE_VERSION=24
# Install the latest N LTS lines of node; the newest is the default
ARG NODE_LTS_COUNT=3
COPY build/install-node.sh /tmp/install-node.sh
RUN chmod +x /tmp/install-node.sh && \
/tmp/install-node.sh "--lts" && \
/tmp/install-node.sh "${NODE_LTS_COUNT}" && \
. "$NVM_DIR/nvm.sh" && \
nvm alias default 'lts/*'

Expand All @@ -125,7 +126,7 @@
PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install -r /tmp/requirements.txt

## Composer ##
ARG COMPOSER_VERSION 1
ARG COMPOSER_VERSION=2

ENV COMPOSER_ALLOW_SUPERUSER="1"
ENV COMPOSER_HOME="/tmp"
Expand Down
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
- curl
- gh (Github CLI)
- git
- glap (Gitlab CLI)
- glab (Gitlab CLI)
- mysql-client
- nodejs / npm, nvm for management
- php
Expand All @@ -19,13 +19,12 @@
- clamdscan
- kubectl
- aws-cli
- azure-cli
- docker-cli
- wp-cli
- terminus
- yamllint

Node and npm are managed by the `build/install-node.sh` script. The only version installed by default will be the `lts` version.
Node and npm are managed by the `build/install-node.sh` script. The three latest LTS versions of Node are installed (resolved at build time via nvm's `lts/-2`, `lts/-1` and `lts/*` aliases), and the newest LTS is the default. Because images are rebuilt weekly, the set rolls forward automatically when a new Node line enters LTS. The number of LTS versions can be changed with the `NODE_LTS_COUNT` build argument.

## Customize Package and Tool Configurations

Expand Down Expand Up @@ -57,6 +56,12 @@ The variables `PRIVATE_KEY` and `PUBLIC_KEY` can be used to create a custom publ

The Terminus token can be set via the `TERMINUS_TOKEN` variable, for use with the Pantheon managed hosting platform.

## Supported PHP versions

Images are built for PHP 8.2, 8.3 and 8.4 (tags `php-8.2`, `php-8.3`, `php-8.4`). The `latest` tag points to the newest version, currently PHP 8.4.

PHP 7.4, 8.0 and 8.1 are **deprecated** and no longer built. Their existing tags (`php-7.4`, `php-8.0`, `php-8.1`) remain on Docker Hub, but they will not receive any further updates, including security updates. Projects using them should move to PHP 8.2 or newer.

## Updating PHP versions
The versions of PHP in use by the container are defined in `.github/workflows/build.yaml`:

Expand All @@ -65,7 +70,7 @@ jobs:
build_push_to_dockerhub:
strategy:
matrix:
base_container: ["php:7.4-bullseye", "php:8.0-bullseye", "php:8.1-bullseye", "php:8.2-bullseye", "php:8.3-bullseye", "php:8.4-bullseye"]
base_container: ["php:8.2-bookworm", "php:8.3-bookworm", "php:8.4-bookworm"]

```
Update steps:
Expand All @@ -79,8 +84,9 @@ Update steps:
echo "BUILD_TAGS=10up/wordpress-ci:php-<VERSION>" >> $GITHUB_ENV
echo "COMPOSER_VERSION=2" >> $GITHUB_ENV
```
3. Push changes to working branch to trigger the build of the new image. *NOTE:* This will not push it to the registry.
4. Create a PR and tag one of the maintainers. The image will become available once it is merged into `trunk` and the actions complete.
3. If the new version is the newest one, move `10up/wordpress-ci:latest` from the previous newest version's `BUILD_TAGS` to the new version's.
4. Push changes to working branch to trigger the build of the new image. *NOTE:* This will not push it to the registry.
5. Create a PR and tag one of the maintainers. The image will become available once it is merged into `trunk` and the actions complete.



Expand Down Expand Up @@ -111,7 +117,7 @@ This will enable detailed logs in php-syntax checking for debugging, trading off

## Node Version

For convenience, `nvm` is installed to easily manage the node version in the CI container. To install a different node version from CI just add a step to execute the command: `nvm install <version>`; you can also execute the command from within a build script.
For convenience, `nvm` is installed to easily manage the node version in the CI container. The three latest LTS versions are preinstalled, so switching between them is instant with `nvm use <version>` (e.g. `nvm use 22`). To install any other node version from CI just add a step to execute the command: `nvm install <version>`; you can also execute the command from within a build script.

[![Support Level](https://img.shields.io/badge/support-stable-blue.svg)](#support-level)

Expand Down
29 changes: 20 additions & 9 deletions build/install-node.sh
Original file line number Diff line number Diff line change
@@ -1,26 +1,37 @@
#!/bin/bash

# Install only the current LTS version and npm packages
# This avoids extra GB of build container image size and maintains security
# Install the latest N LTS lines of node (default 3), oldest first
# Uses nvm's relative LTS aliases (lts/-2, lts/-1, lts/*), so the weekly rebuild
# rolls the set forward automatically when a new line enters LTS
# nvm can be used in individual pipelines to install other versions

# LTS calendar: https://nodejs.org/en/about/releases/

# TODO: NODE_VERSION variable could be centralized in the Dockerfile for quicker management

# catch Errors
set -euo pipefail

#Get node version from Docker build argument
NODE_VERSION="$1"
# Get number of LTS lines from Docker build argument
LTS_COUNT="${1:-3}"

if [[ ! "${LTS_COUNT}" =~ ^[1-9][0-9]*$ ]]; then
>&2 echo "ERROR: LTS count must be a positive integer, got '${LTS_COUNT}'"
exit 1
fi

# set up nvm in this script
. "$NVM_DIR/nvm.sh"

echo "Building node environment for version ${NODE_VERSION}"
for (( offset = LTS_COUNT - 1; offset >= 0; offset-- )); do
if [[ "${offset}" -eq 0 ]]; then
NODE_VERSION="lts/*"
else
NODE_VERSION="lts/-${offset}"
fi

nvm install "${NODE_VERSION}"
echo "Building node environment for version ${NODE_VERSION}"
nvm install "${NODE_VERSION}"
done

npm cache clean --force

echo "node ${NODE_VERSION} build completed..."
echo "node build completed for the latest ${LTS_COUNT} LTS versions..."
4 changes: 0 additions & 4 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,6 @@ php --version
composer --version
node --version
npm --version
grunt --version
gulp --version
bower --version
yarn --version
php /usr/local/bin/wp-cli.phar --allow-root --version
set +x

Expand Down
113 changes: 113 additions & 0 deletions tests/test-install-node.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash

set -uo pipefail

TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/install-node-tests.XXXXXX")"
readonly TEST_DIR
REPOSITORY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly REPOSITORY_DIR
readonly SCRIPT_PATH="${REPOSITORY_DIR}/build/install-node.sh"

failures=0

cleanup() {
if [[ -d "${TEST_DIR}" ]]; then
rm -r -- "${TEST_DIR}"
fi
}
trap cleanup EXIT

fail() {
printf 'not ok - %s\n' "$1" >&2
failures=$((failures + 1))
}

assert_status() {
local expected="$1"
local actual="$2"
local description="$3"

if [[ "${actual}" -ne "${expected}" ]]; then
fail "${description}: expected exit ${expected}, got ${actual}"
fi
}

assert_file_equals() {
local file="$1"
local expected="$2"
local description="$3"

if [[ ! -f "${file}" ]] || [[ "$(cat "${file}")" != "${expected}" ]]; then
fail "${description}"
fi
}

# Fake nvm.sh: defines nvm and npm functions that record their calls
create_fake_nvm() {
local nvm_dir="$1"

mkdir -p "${nvm_dir}"

cat > "${nvm_dir}/nvm.sh" <<'EOF'
nvm() {
printf '%s\n' "$*" >> "${TEST_STATE}/nvm.calls"
if [[ "$1" == "install" && "$2" == "${FAKE_FAIL_VERSION:-}" ]]; then
return 3
fi
}

npm() {
printf '%s\n' "$*" >> "${TEST_STATE}/npm.calls"
}
EOF
}

run_install_case() {
local name="$1"
local expected_exit="$2"
shift 2
local state_dir="${TEST_DIR}/${name}"
local actual_exit

mkdir -p "${state_dir}"
create_fake_nvm "${state_dir}/nvm"

(
NVM_DIR="${state_dir}/nvm" \
TEST_STATE="${state_dir}" \
bash "${SCRIPT_PATH}" "$@"
) > "${state_dir}/output" 2>&1
actual_exit=$?

assert_status "${expected_exit}" "${actual_exit}" "${name}"
}

run_install_case default 0
assert_file_equals "${TEST_DIR}/default/nvm.calls" "$(printf '%s\n' 'install lts/-2' 'install lts/-1' 'install lts/*')" \
'default installs the three latest LTS lines, oldest first'
assert_file_equals "${TEST_DIR}/default/npm.calls" 'cache clean --force' 'npm cache is cleaned once'

run_install_case single 0 1
assert_file_equals "${TEST_DIR}/single/nvm.calls" 'install lts/*' 'a count of 1 installs only the latest LTS'

run_install_case five 0 5
assert_file_equals "${TEST_DIR}/five/nvm.calls" \
"$(printf '%s\n' 'install lts/-4' 'install lts/-3' 'install lts/-2' 'install lts/-1' 'install lts/*')" \
'the LTS count is configurable'

run_install_case zero 1 0
[[ -f "${TEST_DIR}/zero/nvm.calls" ]] && fail 'a count of 0 installs nothing'

run_install_case not_a_number 1 abc
[[ -f "${TEST_DIR}/not_a_number/nvm.calls" ]] && fail 'a non-numeric count installs nothing'

FAKE_FAIL_VERSION='lts/-1' run_install_case install_failure 3
assert_file_equals "${TEST_DIR}/install_failure/nvm.calls" "$(printf '%s\n' 'install lts/-2' 'install lts/-1')" \
'a failed install stops the build'

if [[ "${failures}" -gt 0 ]]; then
printf '%s test assertion(s) failed\n' "${failures}" >&2
exit 1
fi

printf 'ok - install-node behavior\n'
Loading