diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 251cad7..637b6d0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,11 +28,16 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 + env: + update: "true" with: - php-version: "7.4" + php-version: "8.4.27" tools: composer:v2 coverage: none + - name: Verify host PHP security version + run: node scripts/check-php-version.js + - name: Install npm dependencies run: npm ci diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 51ee0f3..7c4572a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -30,13 +30,20 @@ jobs: grep -q "'BEEHIIV_REGISTRATION_TOKEN_PLACEHOLDER' !== \$token" includes/OAuth/Config.php lint: - name: Lint + name: Lint (${{ matrix.compatibility }}) runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: false matrix: - php: ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"] + include: + - { compatibility: "7.4", php: "8.4.27" } + - { compatibility: "8.0", php: "8.4.27" } + - { compatibility: "8.1", php: "8.4.27" } + - { compatibility: "8.2", php: "8.2.35" } + - { compatibility: "8.3", php: "8.3.36" } + - { compatibility: "8.4", php: "8.4.27" } + - { compatibility: "8.5", php: "8.5.12" } steps: - name: Checkout @@ -48,13 +55,18 @@ jobs: node-version-file: .nvmrc cache: npm - - name: Setup PHP + - name: Setup patched PHP ${{ matrix.php }} uses: shivammathur/setup-php@v2 + env: + update: "true" with: php-version: ${{ matrix.php }} tools: composer:v2 coverage: none + - name: Verify host PHP security version + run: node scripts/check-php-version.js + - name: Install Composer dependencies run: composer install --prefer-dist --no-progress @@ -67,5 +79,5 @@ jobs: - name: Lint CSS run: npm run lint:css - - name: Lint PHP - run: npm run lint:php + - name: Check PHP ${{ matrix.compatibility }}+ static compatibility + run: npm run lint:php -- --runtime-set testVersion "${{ matrix.compatibility }}-" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8fce90..0900814 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,15 +13,18 @@ concurrency: jobs: unit-tests: - name: "PHPUnit (PHP ${{ matrix.php-version }}, WP ${{ matrix.wp-version }})" + name: "PHPUnit (PHP ${{ matrix.php.patch }}, WP ${{ matrix.wp-version }})" runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: false matrix: - # Keep in sync with "Requires PHP" / "Requires at least" in readme.txt. - php-version: ["7.4", "8.0", "8.1", "8.2", "8.3"] + php: + - { series: "8.2", patch: "8.2.35" } + - { series: "8.3", patch: "8.3.36" } + - { series: "8.4", patch: "8.4.27" } + - { series: "8.5", patch: "8.5.12" } wp-version: ["6.8", "latest"] steps: @@ -36,11 +39,16 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 + env: + update: "true" with: - php-version: ${{ matrix.php-version }} + php-version: ${{ matrix.php.patch }} tools: composer:v2 coverage: none + - name: Verify host PHP security version + run: node scripts/check-php-version.js + - name: Install Composer dependencies run: composer install --prefer-dist --no-progress @@ -48,33 +56,29 @@ jobs: run: npm ci - name: Pin wp-env PHP/WP core version - run: npm run env:set-version -- --php=${{ matrix.php-version }} --wp=${{ matrix.wp-version }} - - - name: Patch archived Debian base image (PHP 7.4 / 8.0) - if: matrix.php-version == '7.4' || matrix.php-version == '8.0' - run: scripts/ci-patch-debian-base.sh ${{ matrix.php-version }} + run: npm run env:set-version -- --php=${{ matrix.php.series }} --wp=${{ matrix.wp-version }} - name: Start wp-env - run: npx wp-env start + run: npm run env:start - name: Run PHPUnit run: npx wp-env run tests-cli -- bash -c "cd wp-content/plugins/$(basename "$GITHUB_WORKSPACE") && composer test" - name: Generate PHPUnit dashboard - if: always() - run: node tests/phpunit/report/generate-dashboard.js --label="PHP ${{ matrix.php-version }} / WP ${{ matrix.wp-version }}" + if: always() && hashFiles('tests/phpunit/test-results/junit.xml') != '' + run: node tests/phpunit/report/generate-dashboard.js --label="PHP ${{ matrix.php.series }} / WP ${{ matrix.wp-version }}" - name: Upload PHPUnit dashboard - if: always() + if: always() && hashFiles('tests/phpunit/test-results/**') != '' uses: actions/upload-artifact@v4 with: - name: phpunit-report-php${{ matrix.php-version }}-wp${{ matrix.wp-version }} + name: phpunit-report-php${{ matrix.php.series }}-wp${{ matrix.wp-version }} path: tests/phpunit/test-results retention-days: 7 - name: Stop wp-env if: always() - run: npx wp-env stop + run: npm run env:stop e2e: name: E2E tests @@ -93,17 +97,25 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 + env: + update: "true" with: - php-version: "7.4" + php-version: "8.4.27" tools: composer:v2 coverage: none + - name: Verify host PHP security version + run: node scripts/check-php-version.js + - name: Install Composer dependencies run: composer install --prefer-dist --no-progress - name: Install npm dependencies run: npm ci + - name: Test PHP runtime safeguards + run: npm run test:php-runtime + - name: Build plugin assets run: npm run build @@ -116,17 +128,14 @@ jobs: - name: Install Playwright browsers run: npx playwright install --with-deps chromium - - name: Patch archived Debian base image (PHP 7.4) - run: scripts/ci-patch-debian-base.sh 7.4 - - name: Start wp-env - run: npx wp-env start + run: npm run env:start - name: Run Playwright E2E tests run: npx playwright test - name: Upload Playwright report on failure - if: failure() + if: failure() && hashFiles('tests/e2e/test-results/**') != '' uses: actions/upload-artifact@v4 with: name: playwright-report @@ -135,4 +144,4 @@ jobs: - name: Stop wp-env if: always() - run: npx wp-env stop + run: npm run env:stop diff --git a/.wp-env.json b/.wp-env.json index 0312c3b..99a81be 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -1,5 +1,5 @@ { - "phpVersion": "7.4", + "phpVersion": "8.4", "core": null, "plugins": [ "." ], "themes": [ "https://downloads.wordpress.org/theme/twentytwentyfive.zip" ], diff --git a/README.md b/README.md index 62dcc4d..ffbb062 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ Your beehiiv newsletters, launched straight from the WordPress editor. - [Node.js](https://nodejs.org/) (version from `.nvmrc`, currently 20) and npm - [Composer](https://getcomposer.org/) - [Docker](https://www.docker.com/) — only if you use wp-env -- **WordPress 6.5+** and **PHP 7.4+** (see `beehiiv.php`) +- **PHP 8.4.27** for the default development environment (`mise install` uses `mise.toml`) +- The plugin declares compatibility with **WordPress 6.5+** and **PHP 7.4+** (see `beehiiv.php`); use a supported, patched PHP runtime for development and hosting ## Development environments @@ -15,6 +16,25 @@ Use **wp-env** if you're running Docker — it spins up WordPress, creates the d Or, if you prefer **Local**, **MAMP**, **Valet**, or similar, clone or symlink this repo into `wp-content/plugins/beehiiv`, run the [setup commands](#setup) below, and activate the plugin manually under **Plugins** in wp-admin. +### PHP security requirements + +Development and CI execute PHP only on these supported branches, at or above the listed security release: + +| PHP branch | Minimum patch | +| ---------- | ------------- | +| 8.2 | 8.2.35 | +| 8.3 | 8.3.36 | +| 8.4 | 8.4.27 | +| 8.5 | 8.5.12 | + +These releases fix [CVE-2026-109618](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w) and [CVE-2026-109619](https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp). See the [PHP security advisory](https://www.php.net/archive/2026.php#2026-10-21-5). PHP 8.1 and older do not receive upstream fixes and are excluded from executable development/CI environments. + +CI requests the listed host patches with `setup-php` updates enabled and checks the actual interpreter before Composer runs. `setup-php` resolves the requested version to its minor branch, so the patch selector expresses the required minimum; only the runtime check proves that minimum was installed. wp-env uses minor Docker tags such as `wordpress:php8.4` and `wordpress:cli-php8.4`; those tags also do not prove the PHP patch version. `npm run env:start` stops this project's existing containers, pulls both base images, and checks their actual PHP versions with networking disabled. After those checks pass, it removes this project's derived images and recreates the environment, then checks each enabled web/CLI service. A failed check leaves the project stopped. The wrapper does not remove Docker volumes; back up local content before changing WordPress/PHP versions, since wp-env may change core files or require database updates. + +Direct `npx wp-env start` bypasses this gate and must not be used for development or CI. Keep phpMyAdmin disabled in wp-env configuration because its separate PHP image is not covered by these checks. + +The plugin's PHP 7.4 compatibility declaration, Composer platform, and PHPCompatibility static checks remain in place. Removing PHP 7.4–8.1 from the execution matrix means those versions no longer receive dynamic PHPUnit coverage; static checks do not replace that coverage. + ## Setup ```bash @@ -22,6 +42,7 @@ git clone beehiiv cd beehiiv nvm install && nvm use # optional; use Node version from .nvmrc npm install +node scripts/check-php-version.js composer install npm run build ``` @@ -62,7 +83,7 @@ That: 1. Mounts an ephemeral `.wp-env.override.json` from `.wp-env.override.tmpl` for `wp-env start` 2. If `docker-compose.extra-hosts.yml` exists, maps those hostnames to the Docker host (`host-gateway`) so WordPress inside the container can reach your machine -Re-run after changing Doppler secrets. If containers were recreated without the overlay, run `npm run env:hosts`. +Re-run after changing Doppler secrets. `npm run env:hosts` also performs the guarded PHP checks and project restart before applying the overlay; it is an alias for `npm run env:start`. Without Doppler, define the same constants in `wp-config.php`, or put them in a gitignored `.wp-env.override.json` (`config` map) and use `npm run env:start`. @@ -73,32 +94,33 @@ If your local app uses HTTPS with a private CA, set `BEEHIIV_SSLVERIFY` to `fals Point wp-env at a specific PHP / WordPress core version to check compatibility with older or newer combinations than your default local setup, and get an HTML dashboard for that combination: ```bash -npm run test:compat -- --php=8.1 --wp=6.8 +npm run test:compat -- --php=8.3 --wp=6.8 ``` -That pins wp-env to PHP 8.1 / WP 6.8 (leaving it set for subsequent `npm run env:start` runs — clear with `npm run env:set-version -- --clear`), **destroys and recreates the wp-env environment** so the combination starts from a genuinely clean install, runs PHPUnit, and writes `tests/phpunit/test-results/php8.1-wp6.8/dashboard.html` — pass/fail counts, a per-class breakdown, and failure details. Run it again with different `--php`/`--wp` values to get a separate dashboard per combination; nothing gets overwritten. `--wp` accepts a WordPress.org release number (e.g. `6.8`), `latest` (wp-env's own default), or `nightly`. +That selects PHP 8.3 / WP 6.8 (leaving it set for subsequent `npm run env:start` runs; clear with `npm run env:set-version -- --clear`), **destroys and recreates the wp-env environment**, checks the actual PHP versions against the security requirements above, runs PHPUnit, and writes `tests/phpunit/test-results/php8.3-wp6.8/dashboard.html`. The dashboard contains pass/fail counts, a per-class breakdown, and failure details. Different `--php`/`--wp` combinations have separate report directories. `--php` accepts supported branches 8.2–8.5; `--wp` accepts a WordPress.org release number (e.g. `6.8`), `latest` (wp-env's own default), or `nightly`. ⚠️ `test:compat` deletes your current wp-env database and content for this project (`wp-env destroy`) every time it runs. Only use it against a wp-env instance you're fine wiping — not one with dev content you want to keep. -To only change the pinned version without wiping anything, use `npm run env:set-version -- --php=8.1 --wp=6.8` directly — it writes to the gitignored `.wp-env.override.json`, so it never touches tracked config, and wp-env rebuilds the WordPress core files (not the database) the next time it starts. Because the database is left as-is, wp-admin will show the "database needs to be updated" screen after switching to a different WP version; run `npm run env:destroy` first if you want a clean database for manual browsing too. `npm run test:php:report` regenerates a dashboard from an existing JUnit report (e.g. after `composer test`) without re-running anything. +To update only the configuration, use `npm run env:set-version -- --php=8.3 --wp=6.8`. It writes to the gitignored `.wp-env.override.json`; apply the change with `npm run env:start`. Back up local content before switching versions. For an intentionally clean environment, `npm run env:destroy` deletes the project's database and content. `npm run test:php:report` regenerates a dashboard from an existing JUnit report (e.g. after `composer test`) without re-running anything. -CI runs the full PHP × WP matrix on every PR (see `.github/workflows/test.yml`) and uploads one `phpunit-report-php{X}-wp{Y}` dashboard artifact per combination. +CI runs PHP 8.2–8.5 against WP 6.8 and `latest` on every PR (see `.github/workflows/test.yml`) and uploads one `phpunit-report-php{X}-wp{Y}` dashboard artifact per combination. E2E tests and release builds require host PHP 8.4.27 or later in that branch; E2E containers use the guarded PHP 8.4 images. ## Development Commands -| Command | Purpose | -| --------------------------- | ---------------------------------------------------- | -| `npm run start` | Webpack watcher — rebuilds `build/` on save | -| `npm run build` | Production asset build | -| `npm run env:start` | Start wp-env (+ optional extra_hosts overlay) | -| `npm run env:start:doppler` | Start with Doppler-mounted OAuth/API overrides | -| `npm run env:hosts` | Re-apply `docker-compose.extra-hosts.yml` if present | -| `npm run env:set-version` | Pin wp-env's PHP/WP core version (`--php` / `--wp`) | -| `npm run env:stop` | Stop wp-env | -| `npm run env:destroy` | Tear down wp-env | -| `npm run test:compat` | Run PHPUnit for one PHP/WP combo + its own dashboard | -| `npm run test:php:report` | Regenerate a PHPUnit dashboard from an existing run | -| `npm run lint` | Lint JS, CSS, and PHP (or individually) | +| Command | Purpose | +| --------------------------- | ---------------------------------------------------------------------- | +| `npm run start` | Webpack watcher — rebuilds `build/` on save | +| `npm run build` | Production asset build | +| `npm run env:start` | Verify PHP runtimes, recreate wp-env, and apply optional hosts overlay | +| `npm run env:start:doppler` | Start with Doppler-mounted OAuth/API overrides | +| `npm run env:hosts` | Alias for guarded startup and optional hosts overlay | +| `npm run env:set-version` | Pin wp-env's PHP/WP core version (`--php` / `--wp`) | +| `npm run env:stop` | Stop wp-env | +| `npm run env:destroy` | Tear down wp-env | +| `npm run test:compat` | Run PHPUnit for one PHP/WP combo + its own dashboard | +| `npm run test:php:report` | Regenerate a PHPUnit dashboard from an existing run | +| `npm run test:php-runtime` | Test PHP version policy and guarded startup without Docker | +| `npm run lint` | Lint JS, CSS, and PHP (or individually) | ## Project layout @@ -183,7 +205,9 @@ npm run lint Or individually: `lint:js`, `lint:css`, `lint:php`. Autofix variants: `lint:js:fix`, `lint:css:fix`, `lint:php:fix`, plus `npm run format` for Prettier. -On every pull request (and pushes to `main` / `master`), GitHub Actions runs the same checks via [`.github/workflows/lint.yml`](.github/workflows/lint.yml) on PHP **7.4** through **8.5**. +On every pull request (and pushes to `main` / `master`), [`.github/workflows/lint.yml`](.github/workflows/lint.yml) runs JS, CSS, and PHP lint under the required check names `Lint (7.4)` through `Lint (8.5)`. These labels identify static compatibility targets. Targets **7.4–8.1** run on patched PHP **8.4.27**; targets **8.2–8.5** run on their patched branches listed above. Each job passes `--runtime-set testVersion -` to PHPCS, keeping end-of-life PHP versions out of CI execution. + +The pinned PHPCompatibility **9.3.5** rules provide partial static checks and predate PHP 8. They do not cover all PHP 8 changes or replace runtime and PHPUnit coverage on the target versions. ## Releasing (maintainers) diff --git a/mise.toml b/mise.toml index bcfdf8c..b042f80 100644 --- a/mise.toml +++ b/mise.toml @@ -3,7 +3,7 @@ php = "https://github.com/verzly/mise-php#latest" [tools] node = "20.20.2" -php = "8.4" +php = "8.4.27" [env] # Prebuilt static binaries (Composer is bundled per PHP version by verzly/mise-php). diff --git a/package.json b/package.json index 6801667..0a4b809 100644 --- a/package.json +++ b/package.json @@ -40,17 +40,18 @@ "lint:css:fix": "wp-scripts lint-style --fix", "lint:php": "vendor/bin/phpcs", "lint:php:fix": "vendor/bin/phpcbf", - "env:start": "wp-env start && npm run env:hosts", + "env:start": "node scripts/wp-env-start.js && sh scripts/wp-env-extra-hosts.sh", "env:start:doppler": "doppler run --mount .wp-env.override.json --mount-template .wp-env.override.tmpl -- npm run env:start", - "env:hosts": "sh scripts/wp-env-extra-hosts.sh", + "env:hosts": "npm run env:start", "env:set-version": "node scripts/wp-env-set-version.js", - "env:stop": "wp-env stop", + "env:stop": "node scripts/wp-env-start.js --stop", "env:destroy": "wp-env destroy", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "node tests/e2e/report/generate-dashboard.js", "test:php:report": "node tests/phpunit/report/generate-dashboard.js", - "test:compat": "node scripts/test-compat.js" + "test:compat": "node scripts/test-compat.js", + "test:php-runtime": "node --test tests/php-runtime/*.test.js" }, "devDependencies": { "@playwright/test": "^1.60.0", diff --git a/scripts/check-php-version.js b/scripts/check-php-version.js new file mode 100644 index 0000000..c254b32 --- /dev/null +++ b/scripts/check-php-version.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/* eslint-disable no-console -- CLI script, console is its output. */ +const { execFileSync } = require( 'child_process' ); + +// https://www.php.net/archive/2026.php#2026-10-21-5 +const minimumVersions = { + 8.2: 35, + 8.3: 36, + 8.4: 27, + 8.5: 12, +}; + +function assertPhpSeries( series ) { + if ( ! Object.hasOwn( minimumVersions, series ) ) { + throw new Error( + `Unsupported PHP series ${ series }; use ${ Object.keys( + minimumVersions + ).join( ', ' ) } with the October 2026 security fixes.` + ); + } +} + +function assertPhpVersion( version, label = 'PHP', expectedSeries ) { + const match = /^(\d+\.\d+)\.(\d+)$/.exec( version.trim() ); + if ( ! match ) { + throw new Error( + `${ label }: cannot verify PHP version ${ version }.` + ); + } + const [ , series, patch ] = match; + assertPhpSeries( series ); + if ( expectedSeries && series !== expectedSeries ) { + throw new Error( + `${ label }: expected PHP ${ expectedSeries }, found ${ version }.` + ); + } + if ( Number( patch ) < minimumVersions[ series ] ) { + throw new Error( + `${ label }: PHP ${ version } is affected by CVE-2026-109618/CVE-2026-109619; requires ${ series }.${ minimumVersions[ series ] } or newer in that series.` + ); + } + console.log( `${ label }: verified PHP ${ version.trim() }.` ); +} + +if ( require.main === module ) { + try { + assertPhpVersion( + execFileSync( 'php', [ '-r', 'echo PHP_VERSION;' ], { + encoding: 'utf8', + } ), + 'Host' + ); + } catch ( error ) { + console.error( error.message ); + process.exitCode = 1; + } +} + +module.exports = { assertPhpSeries, assertPhpVersion }; diff --git a/scripts/ci-patch-debian-base.sh b/scripts/ci-patch-debian-base.sh deleted file mode 100755 index ce244aa..0000000 --- a/scripts/ci-patch-debian-base.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# Re-tag wordpress:php with apt sources pointed at archive.debian.org. -# -# The PHP 7.4 / 8.0 official images are Debian bullseye, whose security mirror -# no longer serves packages. wp-env's generated Dockerfile runs -# `apt-get install` on top of those images and fails with 404s. Building -# over the same local tag lets wp-env pick up the patched base unchanged. -# -# Usage: scripts/ci-patch-debian-base.sh e.g. 7.4 - -set -euo pipefail - -php_version="${1:?Usage: $0 }" -image="wordpress:php${php_version}" - -docker pull "${image}" - -docker build -t "${image}" - < /etc/apt/apt.conf.d/99archive -DOCKERFILE diff --git a/scripts/test-compat.js b/scripts/test-compat.js index 91f3ad8..b562e2e 100644 --- a/scripts/test-compat.js +++ b/scripts/test-compat.js @@ -6,11 +6,11 @@ * backward-compatibility testing. * * Usage: - * npm run test:compat -- --php=8.1 --wp=6.8 + * npm run test:compat -- --php=8.4 --wp=6.8 * * Writes: - * tests/phpunit/test-results/php8.1-wp6.8/junit.xml - * tests/phpunit/test-results/php8.1-wp6.8/dashboard.html + * tests/phpunit/test-results/php8.4-wp6.8/junit.xml + * tests/phpunit/test-results/php8.4-wp6.8/dashboard.html * * Runs `wp-env destroy` before starting, so each combination gets a genuinely * clean WordPress install rather than the new core version's files sitting on @@ -71,7 +71,7 @@ run( 'node', [ `--wp=${ args.wp }`, '--destroy', ] ); -run( 'npx', [ 'wp-env', 'start' ] ); +run( 'node', [ 'scripts/wp-env-start.js' ] ); let exitCode = 0; try { diff --git a/scripts/wp-env-set-version.js b/scripts/wp-env-set-version.js index 1c90da8..8dcdd62 100644 --- a/scripts/wp-env-set-version.js +++ b/scripts/wp-env-set-version.js @@ -9,7 +9,7 @@ * `wp-env start` runs, because it detects that the resolved config changed. * * Usage: - * node scripts/wp-env-set-version.js --php=8.1 --wp=6.8 + * node scripts/wp-env-set-version.js --php=8.4 --wp=6.8 * node scripts/wp-env-set-version.js --php=8.3 # PHP only, leave WP core as-is * node scripts/wp-env-set-version.js --wp=nightly # WordPress/WordPress#master * node scripts/wp-env-set-version.js --wp=latest # wp-env default (newest stable) @@ -18,7 +18,7 @@ * Then apply with: npm run env:start * * Add --destroy to also run `wp-env destroy` right away (e.g. - * `--php=8.1 --wp=6.8 --destroy`). wp-env only refreshes the WordPress core + * `--php=8.4 --wp=6.8 --destroy`). wp-env only refreshes the WordPress core * files when the version changes, not the database, so a stale database * can trigger the "database needs to be updated" screen or skew test * results after switching versions -- --destroy avoids that by starting @@ -29,6 +29,7 @@ const fs = require( 'fs' ); const path = require( 'path' ); const { execFileSync } = require( 'child_process' ); +const { assertPhpSeries } = require( './check-php-version' ); const PLUGIN_ROOT = path.join( __dirname, '..' ); const overridePath = path.join( PLUGIN_ROOT, '.wp-env.override.json' ); @@ -108,6 +109,7 @@ if ( ! args.php && ! args.wp ) { } if ( args.php ) { + assertPhpSeries( args.php ); override.phpVersion = args.php; } diff --git a/scripts/wp-env-start.js b/scripts/wp-env-start.js new file mode 100644 index 0000000..ec51e19 --- /dev/null +++ b/scripts/wp-env-start.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/* eslint-disable no-console -- CLI script, console is its output. */ +const { execFileSync } = require( 'child_process' ); +const { existsSync } = require( 'fs' ); +const path = require( 'path' ); +const { loadConfig } = require( '@wordpress/env/lib/config' ); +const { assertPhpSeries, assertPhpVersion } = require( './check-php-version' ); + +const pluginRoot = path.resolve( __dirname, '..' ); + +function run( command, args, capture = false ) { + return execFileSync( command, args, { + cwd: pluginRoot, + encoding: 'utf8', + stdio: [ 'ignore', capture ? 'pipe' : 'inherit', 'inherit' ], + } ); +} + +function stop( config, execute = run ) { + if ( existsSync( config.dockerComposeConfigPath ) ) { + execute( 'docker', [ + 'compose', + '-f', + config.dockerComposeConfigPath, + 'stop', + ] ); + } +} + +function start( config, execute = run ) { + const compose = [ 'compose', '-f', config.dockerComposeConfigPath ]; + const environments = config.testsEnvironment + ? [ 'development', 'tests' ] + : [ 'development' ]; + + try { + // An existing environment must not keep serving an older PHP while checks run. + stop( config, execute ); + for ( const environment of environments ) { + if ( config.env[ environment ].phpmyadmin ) { + throw new Error( + 'Disable phpmyadmin in wp-env config; its PHP runtime has not been verified.' + ); + } + assertPhpSeries( config.env[ environment ].phpVersion ); + } + const series = new Set( + environments.map( + ( environment ) => config.env[ environment ].phpVersion + ) + ); + for ( const phpSeries of series ) { + for ( const image of [ + `wordpress:php${ phpSeries }`, + `wordpress:cli-php${ phpSeries }`, + ] ) { + execute( 'docker', [ 'pull', image ] ); + const version = execute( + 'docker', + [ + 'run', + '--rm', + '--network', + 'none', + '--entrypoint', + 'php', + image, + '-r', + 'echo PHP_VERSION;', + ], + true + ); + assertPhpVersion( version, image, phpSeries ); + } + } + if ( existsSync( config.dockerComposeConfigPath ) ) { + // wp-env can reuse old derived images after a base pull. Keep volumes intact. + execute( 'docker', [ ...compose, 'down', '--rmi', 'local' ] ); + } + execute( 'npx', [ + '--no-install', + 'wp-env', + 'start', + '--runtime=docker', + ] ); + for ( const environment of environments ) { + const prefix = environment === 'tests' ? 'tests-' : ''; + for ( const service of [ + `${ prefix }wordpress`, + `${ prefix }cli`, + ] ) { + const version = execute( + 'docker', + [ + ...compose, + 'exec', + '-T', + service, + 'php', + '-r', + 'echo PHP_VERSION;', + ], + true + ); + assertPhpVersion( + version, + service, + config.env[ environment ].phpVersion + ); + } + } + } catch ( error ) { + try { + stop( config, execute ); + } catch ( stopError ) { + throw new Error( + `${ error.message }\nCould not stop wp-env: ${ stopError.message }` + ); + } + throw error; + } +} + +if ( require.main === module ) { + loadConfig( pluginRoot ) + .then( ( config ) => { + if ( process.argv[ 2 ] === '--stop' ) { + return stop( config ); + } + return start( config ); + } ) + .catch( ( error ) => { + console.error( `WordPress startup blocked: ${ error.message }` ); + process.exitCode = 1; + } ); +} + +module.exports = { start, stop }; diff --git a/tests/php-runtime/security.test.js b/tests/php-runtime/security.test.js new file mode 100644 index 0000000..5426c19 --- /dev/null +++ b/tests/php-runtime/security.test.js @@ -0,0 +1,229 @@ +const assert = require( 'node:assert/strict' ); +const { test } = require( 'node:test' ); +const { mkdtempSync, writeFileSync, rmSync } = require( 'node:fs' ); +const { tmpdir } = require( 'node:os' ); +const path = require( 'node:path' ); +const { assertPhpVersion } = require( '../../scripts/check-php-version' ); +const { start, stop } = require( '../../scripts/wp-env-start' ); + +for ( const [ series, patch ] of [ + [ '8.2', 35 ], + [ '8.3', 36 ], + [ '8.4', 27 ], + [ '8.5', 12 ], +] ) { + test( `${ series } rejects the affected patch`, () => { + assert.throws( + () => assertPhpVersion( `${ series }.${ patch - 1 }` ), + /is affected/ + ); + } ); + test( `${ series } accepts the fixed patch and subsequent patches`, () => { + assert.doesNotThrow( () => + assertPhpVersion( `${ series }.${ patch }` ) + ); + assert.doesNotThrow( () => + assertPhpVersion( `${ series }.${ patch + 1 }` ) + ); + } ); +} + +test( 'unsupported and unverified PHP releases are rejected', () => { + for ( const version of [ + '7.4.33', + '8.0.30', + '8.1.34', + '8.6.0', + '8.4.27RC1', + '8.4', + '', + ] ) { + assert.throws( () => assertPhpVersion( version ) ); + } +} ); + +function fixture( context, response = () => '8.4.27' ) { + const directory = mkdtempSync( path.join( tmpdir(), 'php-runtime-test-' ) ); + context.after( () => + rmSync( directory, { recursive: true, force: true } ) + ); + const config = { + dockerComposeConfigPath: path.join( directory, 'docker-compose.yml' ), + testsEnvironment: true, + env: { + development: { phpVersion: '8.4' }, + tests: { phpVersion: '8.4' }, + }, + }; + writeFileSync( config.dockerComposeConfigPath, '' ); + const calls = []; + const execute = ( command, args, capture ) => { + calls.push( [ command, ...args ] ); + return response( command, args, capture ); + }; + return { config, calls, execute }; +} + +test( 'an affected web image blocks startup and leaves an existing project stopped', ( context ) => { + const { config, calls, execute } = fixture( context, () => '8.4.26' ); + assert.throws( () => start( config, execute ), /is affected/ ); + assert.equal( calls[ 0 ].at( -1 ), 'stop' ); + assert.equal( calls.at( -1 ).at( -1 ), 'stop' ); + assert.equal( + calls.some( ( call ) => call[ 0 ] === 'npx' ), + false + ); + assert.deepEqual( + calls.find( ( call ) => call[ 1 ] === 'run' ).slice( 1, 7 ), + [ 'run', '--rm', '--network', 'none', '--entrypoint', 'php' ] + ); +} ); + +test( 'an affected CLI image also blocks web startup', ( context ) => { + const { config, calls, execute } = fixture( context, ( command, args ) => + args.includes( 'wordpress:cli-php8.4' ) ? '8.4.26' : '8.4.27' + ); + assert.throws( + () => start( config, execute ), + /wordpress:cli-php8.4.*is affected/ + ); + assert.equal( + calls.some( ( call ) => call[ 0 ] === 'npx' ), + false + ); +} ); + +test( 'a registry failure cannot fall back to a cached image', ( context ) => { + const { config, calls, execute } = fixture( context, ( command, args ) => { + if ( args[ 0 ] === 'pull' ) { + throw new Error( 'registry unavailable' ); + } + } ); + assert.throws( () => start( config, execute ), /registry unavailable/ ); + assert.equal( + calls.some( ( call ) => call[ 0 ] === 'npx' ), + false + ); +} ); + +test( 'unsupported test overrides are rejected before pulling or starting', ( context ) => { + const { config, calls, execute } = fixture( context ); + config.env.tests.phpVersion = '7.4'; + assert.throws( () => start( config, execute ), /Unsupported PHP series/ ); + assert.equal( + calls.some( + ( call ) => call.includes( 'pull' ) || call[ 0 ] === 'npx' + ), + false + ); +} ); + +test( 'phpMyAdmin cannot start an unchecked PHP web service', ( context ) => { + const { config, calls, execute } = fixture( context ); + config.env.tests.phpmyadmin = true; + assert.throws( () => start( config, execute ), /Disable phpmyadmin/ ); + assert.equal( + calls.some( ( call ) => call[ 0 ] === 'npx' ), + false + ); +} ); + +test( 'a disabled test environment only checks development services', ( context ) => { + const { config, calls, execute } = fixture( context ); + config.testsEnvironment = false; + config.env.tests.phpVersion = '7.4'; + start( config, execute ); + assert.deepEqual( + calls + .filter( ( call ) => call.includes( 'exec' ) ) + .map( ( call ) => call[ 6 ] ), + [ 'wordpress', 'cli' ] + ); +} ); + +test( 'different development and test PHP series are independently verified', ( context ) => { + const { config, calls, execute } = fixture( context, ( command, args ) => + args.some( + ( argument ) => + argument.includes( '8.3' ) || argument.startsWith( 'tests-' ) + ) + ? '8.3.36' + : '8.4.27' + ); + config.env.tests.phpVersion = '8.3'; + start( config, execute ); + assert.deepEqual( + calls + .filter( ( call ) => call[ 1 ] === 'pull' ) + .map( ( call ) => call[ 2 ] ), + [ + 'wordpress:php8.4', + 'wordpress:cli-php8.4', + 'wordpress:php8.3', + 'wordpress:cli-php8.3', + ] + ); +} ); + +test( 'verified bases replace project images without deleting volumes and all services are checked', ( context ) => { + const { config, calls, execute } = fixture( context ); + start( config, execute ); + const refreshIndex = calls.findIndex( ( call ) => call.includes( 'down' ) ); + assert.deepEqual( calls[ refreshIndex ].slice( -3 ), [ + 'down', + '--rmi', + 'local', + ] ); + assert.equal( calls[ refreshIndex + 1 ][ 0 ], 'npx' ); + assert.equal( + calls.some( + ( call ) => + call.includes( '--volumes' ) || call.includes( '--update' ) + ), + false + ); + assert.deepEqual( + calls + .filter( ( call ) => call.includes( 'exec' ) ) + .map( ( call ) => call[ 6 ] ), + [ 'wordpress', 'cli', 'tests-wordpress', 'tests-cli' ] + ); +} ); + +test( 'an unexpected running PHP version stops the project', ( context ) => { + const { config, calls, execute } = fixture( context, ( command, args ) => + args.includes( 'exec' ) ? '8.4.26' : '8.4.27' + ); + assert.throws( () => start( config, execute ), /is affected/ ); + assert.equal( calls.at( -1 ).at( -1 ), 'stop' ); +} ); + +test( 'a failed wp-env startup stops partially started services', ( context ) => { + const { config, calls, execute } = fixture( context, ( command ) => { + if ( command === 'npx' ) { + throw new Error( 'afterStart failed' ); + } + return '8.4.27'; + } ); + assert.throws( () => start( config, execute ), /afterStart failed/ ); + assert.equal( calls.at( -1 ).at( -1 ), 'stop' ); +} ); + +test( 'unverified or mismatched image versions block startup', ( context ) => { + for ( const version of [ '8.4.27RC1', '8.3.36', '' ] ) { + const { config, calls, execute } = fixture( context, () => version ); + assert.throws( () => start( config, execute ) ); + assert.equal( + calls.some( ( call ) => call[ 0 ] === 'npx' ), + false + ); + assert.equal( calls.at( -1 ).at( -1 ), 'stop' ); + } +} ); + +test( 'cleanup succeeds before wp-env has initialized', ( context ) => { + const { config, calls, execute } = fixture( context ); + rmSync( config.dockerComposeConfigPath ); + stop( config, execute ); + assert.deepEqual( calls, [] ); +} );