diff --git a/.bowerrc b/.bowerrc deleted file mode 100644 index 784ee65..0000000 --- a/.bowerrc +++ /dev/null @@ -1 +0,0 @@ -{ "allow_root": true } diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 118baed..ad028e5 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -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: @@ -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: | @@ -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 ## diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..df15da5 --- /dev/null +++ b/AGENTS.md @@ -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 ` or run `nvm install ` 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). diff --git a/Dockerfile b/Dockerfile index 2b84b68..9b1c673 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# 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 @@ -96,7 +96,7 @@ RUN echo "memory_limit=-1" > "$PHP_INI_DIR/conf.d/memory-limit.ini" && \ ## 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 #### @@ -110,10 +110,11 @@ RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.5/install.sh | b 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/*' @@ -125,7 +126,7 @@ RUN PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip -V && \ 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" diff --git a/README.md b/README.md index 4788e7d..5c7be27 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - curl - gh (Github CLI) - git -- glap (Gitlab CLI) +- glab (Gitlab CLI) - mysql-client - nodejs / npm, nvm for management - php @@ -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 @@ -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`: @@ -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: @@ -79,8 +84,9 @@ Update steps: echo "BUILD_TAGS=10up/wordpress-ci:php-" >> $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. @@ -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 `; 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 ` (e.g. `nvm use 22`). To install any other node version from CI just add a step to execute the command: `nvm install `; you can also execute the command from within a build script. [![Support Level](https://img.shields.io/badge/support-stable-blue.svg)](#support-level) diff --git a/build/install-node.sh b/build/install-node.sh index f3cc688..0b48ea3 100644 --- a/build/install-node.sh +++ b/build/install-node.sh @@ -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..." diff --git a/entrypoint.sh b/entrypoint.sh index 29b3c97..30c5c83 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 diff --git a/tests/test-install-node.sh b/tests/test-install-node.sh new file mode 100644 index 0000000..dccef98 --- /dev/null +++ b/tests/test-install-node.sh @@ -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'