From 09d4f3beac7a6e332cb9c0eaefe30c6ff01b45f5 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Wed, 9 Sep 2026 18:48:44 +0200 Subject: [PATCH 1/3] add(CI): MySQL and PgSQL --- .dockerignore | 8 + .github/docker/ci/Dockerfile | 45 +++++ .github/docker/ci/README.md | 44 +++++ .github/docker/ci/docker-compose.yml | 80 ++++++++ .github/docker/ci/install-and-smoke.sh | 194 ++++++++++++++++++ .github/docker/ci/smoke.php | 260 +++++++++++++++++++++++++ .github/workflows/ci.yml | 36 ++++ .gitignore | 1 + 8 files changed, 668 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/docker/ci/Dockerfile create mode 100644 .github/docker/ci/README.md create mode 100644 .github/docker/ci/docker-compose.yml create mode 100644 .github/docker/ci/install-and-smoke.sh create mode 100644 .github/docker/ci/smoke.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..851b47f311 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# Keeps the build context small for both images in .github/docker. Neither of +# them needs the history, the editor settings or the scratch directory. +.git +.github/docker/**/*.md +.idea +tmp +node_modules +core/database/*.sqlite diff --git a/.github/docker/ci/Dockerfile b/.github/docker/ci/Dockerfile new file mode 100644 index 0000000000..6c5d006d45 --- /dev/null +++ b/.github/docker/ci/Dockerfile @@ -0,0 +1,45 @@ +# Runs the CLI installer against a real database server, so the MySQL and +# PostgreSQL paths of install/cli-install.php get the same coverage the sqlite +# build (.github/workflows/build.yml) already gives the sqlite one. +# +# php:-cli, not one of the Salo runtimes: the checks here are the +# installer, the migrations and the seeded data, and the built in web server is +# enough to prove the installed site answers. Which web server serves it is what +# the Salo images in .github/docker/Dockerfile are for. +ARG PHP_VERSION=8.4 +FROM php:${PHP_VERSION}-cli + +# libpq/libzip/libicu are the build inputs of the extensions below; the -dev +# packages stay out of the final image only in multi stage builds, and a CI +# image that lives for one workflow run does not earn the extra stage. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libpq-dev libzip-dev libicu-dev libxml2-dev libonig-dev unzip \ + && docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql zip intl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Every diagnostic the installer or a migration emits has to reach the log the +# entrypoint greps. display_errors alone is not enough: the installer runs the +# migrations inside an output buffer it later discards, which would take the +# warnings printed during them with it. Logging to stderr as well puts each one +# outside the buffer, where nothing can swallow it. +RUN { \ + echo 'error_reporting = E_ALL'; \ + echo 'display_errors = On'; \ + echo 'display_startup_errors = On'; \ + echo 'log_errors = On'; \ + echo 'error_log = /dev/stderr'; \ + } > /usr/local/etc/php/conf.d/evo-ci.ini + +WORKDIR /var/www/html + +# The repository ships core/vendor, so there is no composer step here and the +# installer is called with --skipComposer=y. A copy rather than a bind mount: +# the installer rewrites core/config and deletes install/, which would leave a +# developer running this locally with a dirty working tree. +COPY . /var/www/html + +RUN chmod +x .github/docker/ci/install-and-smoke.sh + +ENTRYPOINT ["/var/www/html/.github/docker/ci/install-and-smoke.sh"] diff --git a/.github/docker/ci/README.md b/.github/docker/ci/README.md new file mode 100644 index 0000000000..9d71e54d0d --- /dev/null +++ b/.github/docker/ci/README.md @@ -0,0 +1,44 @@ +# Install checks on MySQL and PostgreSQL + +Installs Evolution CMS onto a real database server and asserts the result, so +the MySQL and PostgreSQL paths of `install/cli-install.php` get the coverage the +sqlite path already gets from the build workflow. + +Run either leg from the repository root: + +```sh +docker compose -f .github/docker/ci/docker-compose.yml --profile mysql up \ + --build --abort-on-container-exit --exit-code-from install-mysql + +docker compose -f .github/docker/ci/docker-compose.yml --profile pgsql up \ + --build --abort-on-container-exit --exit-code-from install-pgsql +``` + +Clean up with `docker compose -f .github/docker/ci/docker-compose.yml --profile + down -v`. The container works on a copy of the tree, not a bind +mount, so a run leaves the working tree alone. + +`PHP_VERSION`, `MYSQL_IMAGE_TAG` and `POSTGRES_IMAGE_TAG` override the defaults +(PHP 8.4, MySQL 9.7 LTS, PostgreSQL 18.6 LTS). CI runs both databases against +PHP 8.3 and 8.4 — see the `install` job in `.github/workflows/ci.yml`. + +## What a run asserts + +`install-and-smoke.sh` drives it: + +1. The installer creates the database itself — neither server image pre-creates + the one being installed into — and runs the whole migration and seed chain. +2. Nothing PHP would call a diagnostic was printed. The installer runs the + migrations inside an output buffer it later discards, so the image also + routes `error_log` to stderr; a warning raised during a migration cannot be + swallowed. +3. `smoke.php` checks what reached the database, reading the connection the + installer just wrote: every table created, the columns the core migrations + add, the seeded document, template, event names, settings, roles and + permissions, the admin account with a hashed password and the Administrator + role, and the bundled plugins and modules. +4. The updater (`--typeInstall=2`) replays the same chain over the installed + site, and `smoke.php` runs again — its row counts have to match the first + run, which is what catches a migration or seeder that is not idempotent. +5. The site answers over HTTP: the front page renders the seeded document, and + the manager renders its login form. diff --git a/.github/docker/ci/docker-compose.yml b/.github/docker/ci/docker-compose.yml new file mode 100644 index 0000000000..e0174c4493 --- /dev/null +++ b/.github/docker/ci/docker-compose.yml @@ -0,0 +1,80 @@ +# Installs Evolution CMS onto a real database server and asserts the result. +# +# docker compose -f .github/docker/ci/docker-compose.yml --profile mysql up \ +# --build --abort-on-container-exit --exit-code-from install-mysql +# docker compose -f .github/docker/ci/docker-compose.yml --profile pgsql up \ +# --build --abort-on-container-exit --exit-code-from install-pgsql +# +# One profile per database, so a failing leg names the database in its own +# service. Run from the repository root: the build context is the whole tree. +name: evo-ci + +services: + mysql: + profiles: [mysql] + # 9.7 is the LTS of the MySQL 9 series. + image: mysql:${MYSQL_IMAGE_TAG:-9.7} + environment: + MYSQL_ROOT_PASSWORD: secret + # No MYSQL_DATABASE on purpose: the installer is meant to create the + # database itself, and that branch of checkConnectToDatabaseWithBase() + # only runs when the database is missing. + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-psecret"] + interval: 3s + timeout: 5s + retries: 30 + start_period: 30s + + pgsql: + profiles: [pgsql] + image: postgres:${POSTGRES_IMAGE_TAG:-18.6} + environment: + POSTGRES_USER: evo + POSTGRES_PASSWORD: secret + # The database the installer connects to first. The one it installs into + # (EVO_DB_NAME below) is deliberately a different, missing database, so + # the CREATE DATABASE branch of the installer is exercised. + POSTGRES_DB: evo + healthcheck: + test: ["CMD-SHELL", "pg_isready -U evo -d evo"] + interval: 3s + timeout: 5s + retries: 30 + start_period: 30s + + install-mysql: + profiles: [mysql] + build: + context: ../../.. + dockerfile: .github/docker/ci/Dockerfile + args: + PHP_VERSION: ${PHP_VERSION:-8.4} + depends_on: + mysql: + condition: service_healthy + environment: + EVO_DB_TYPE: mysql + EVO_DB_HOST: mysql + EVO_DB_USER: root + EVO_DB_PASSWORD: secret + EVO_DB_NAME: evolution + EVO_DB_PREFIX: evo_ + + install-pgsql: + profiles: [pgsql] + build: + context: ../../.. + dockerfile: .github/docker/ci/Dockerfile + args: + PHP_VERSION: ${PHP_VERSION:-8.4} + depends_on: + pgsql: + condition: service_healthy + environment: + EVO_DB_TYPE: pgsql + EVO_DB_HOST: pgsql + EVO_DB_USER: evo + EVO_DB_PASSWORD: secret + EVO_DB_NAME: evolution + EVO_DB_PREFIX: evo_ diff --git a/.github/docker/ci/install-and-smoke.sh b/.github/docker/ci/install-and-smoke.sh new file mode 100644 index 0000000000..ba2ad27299 --- /dev/null +++ b/.github/docker/ci/install-and-smoke.sh @@ -0,0 +1,194 @@ +#!/bin/bash +# Installs Evolution CMS onto the database named by the EVO_DB_* environment, +# then proves the installation is sound: no diagnostics from the migrations, the +# seeded data present, the updater able to replay the chain over it without +# changing anything, and the site answering over HTTP. +# +# Runs as the container entrypoint (see Dockerfile); every knob has a default +# so `docker run` with only EVO_DB_HOST set does something sensible. +# pipefail: the installer output goes through tee, and a pipeline that hides +# the exit code of its first command would report a failed install as a pass. +set -euo pipefail + +APP_DIR=${EVO_APP_DIR:-/var/www/html} + +EVO_DB_TYPE=${EVO_DB_TYPE:-mysql} +EVO_DB_HOST=${EVO_DB_HOST:-127.0.0.1} +EVO_DB_USER=${EVO_DB_USER:-root} +EVO_DB_PASSWORD=${EVO_DB_PASSWORD:-secret} +EVO_DB_NAME=${EVO_DB_NAME:-evolution} +EVO_DB_PREFIX=${EVO_DB_PREFIX:-evo_} + +# Exported: smoke.php reads these to check what the installer stored. +export EVO_ADMIN=${EVO_ADMIN:-admin} +export EVO_ADMIN_EMAIL=${EVO_ADMIN_EMAIL:-admin@evo.local} +export EVO_ADMIN_PASSWORD=${EVO_ADMIN_PASSWORD:-Passw0rd123} +export EVO_LANGUAGE=${EVO_LANGUAGE:-en} + +LOG=/tmp/install.log +UPDATE_LOG=/tmp/update.log +PORT=${EVO_HTTP_PORT:-8899} + +say() { + printf '\n\033[1;36m== %s\033[0m\n' "$1" +} + +fail() { + printf '\033[1;31m!! %s\033[0m\n' "$1" >&2 + exit 1 +} + +# Rejects a run that printed anything PHP or PDO would call a problem. +# +# php.ini in the image sends diagnostics to stdout and to stderr (see +# Dockerfile), and both are captured here, so a warning raised inside the output +# buffer the installer later discards still reaches this log. +# +# The patterns are anchored on how PHP formats a diagnostic ("Warning: text in +# /file on line N") rather than on the bare words, which turn up in plenty of +# legitimate output - the settings seeder alone writes rows whose names contain +# "error". +check_log() { + if grep -nE '(^|PHP )(Warning|Notice|Deprecated|Fatal error|Parse error|Recoverable fatal error|Strict Standards):' "$1"; then + fail "the $2 emitted PHP diagnostics" + fi + # SQLSTATE is how both PDO drivers label a failed statement, and the + # installer swallows some of those into plain output instead of exiting. + if grep -nE 'SQLSTATE\[|Uncaught .*Exception|Migration not found|✖' "$1"; then + fail "the $2 reported a database or migration error" + fi + echo "no diagnostics in $(wc -l < "$1") lines of $2 output" +} + +say "Waiting for ${EVO_DB_TYPE} at ${EVO_DB_HOST}" +# Compose already gates on a healthcheck, but this script is also meant to be +# runnable on its own against any reachable server. +attempt=0 +until php -r ' + $dsn = $argv[1] === "pgsql" + ? "pgsql:host=" . $argv[2] . ";dbname=" . $argv[5] + : "mysql:host=" . $argv[2]; + new PDO($dsn, $argv[3], $argv[4]); +' "$EVO_DB_TYPE" "$EVO_DB_HOST" "$EVO_DB_USER" "$EVO_DB_PASSWORD" "${EVO_DB_BOOTSTRAP_NAME:-$EVO_DB_USER}" 2>/dev/null; do + attempt=$((attempt + 1)) + [ "$attempt" -lt 60 ] || fail "database never became reachable" + sleep 2 +done +echo "reachable after ${attempt} attempt(s)" + +say "Installing Evolution CMS (${EVO_DB_TYPE}, database '${EVO_DB_NAME}', prefix '${EVO_DB_PREFIX}')" +# --skipComposer=y: core/vendor is committed, so there is nothing to install and +# a composer update here would test the network rather than the CMS. +# --removeInstall=n: keeping install/ lets the run report what it used, and the +# sqlite leg of the build workflow already covers removal. +# Interactive prompts read from stdin; &1 | tee "$LOG" +status=$? +set -e +[ "$status" -eq 0 ] || fail "the installer exited with ${status}" +cd "$APP_DIR" + +say "Checking the installer output for diagnostics" +check_log "$LOG" "installer" +# The banner is the last thing install() prints, and the only progress message +# that survives: everything the installer says while migrating and seeding goes +# into the output buffer index.php opens, which checkRemoveInstall() discards. +# What actually reached the database is checked below instead. +grep -q 'Now you use' "$LOG" || fail "the installer did not run to completion" + +say "Checking the seeded data" +php "$APP_DIR/.github/docker/ci/smoke.php" "$APP_DIR" || fail "the seeded data is not what the installer promises" + +say "Running the updater over the site just installed" +# The update path replays the same migration chain against a populated database, +# which is the only way to find a migration that is not idempotent - and it is +# what every existing site runs when it moves to a new release. +cd "$APP_DIR/install" +set +e +php cli-install.php --typeInstall=2 --removeInstall=n < /dev/null 2>&1 | tee "$UPDATE_LOG" +status=$? +set -e +[ "$status" -eq 0 ] || fail "the updater exited with ${status}" +cd "$APP_DIR" + +# update() prints its own "Evolution CMS updated!" before checkRemoveInstall() +# discards the buffer, so a successful update says nothing at all; its exit code +# above and the checks below are the evidence. Warnings still arrive, because +# php.ini routes them to stderr as well. +check_log "$UPDATE_LOG" "updater" + +say "Checking the data survived the update unchanged" +# Same checks, plus the row counts recorded by the run above: a seeder that +# inserts instead of updating doubles its table here. +php "$APP_DIR/.github/docker/ci/smoke.php" "$APP_DIR" || fail "the update changed the installed data" + +say "Checking the installed site answers over HTTP" +php -S "127.0.0.1:${PORT}" -t "$APP_DIR" > /tmp/server.log 2>&1 & +server=$! +trap 'kill "$server" 2>/dev/null || true' EXIT + +php -r ' + // The front end and the manager are the two entry points an installed site + // has to serve. The manager answers 404 without an Accept-Language header + // by design, hence the header on the second request. + $port = $argv[1]; + $get = function (string $path, array $headers = []) use ($port): array { + $context = stream_context_create(["http" => [ + "ignore_errors" => true, + "timeout" => 20, + "header" => $headers, + ]]); + $body = @file_get_contents("http://127.0.0.1:{$port}{$path}", false, $context); + $status = isset($http_response_header[0]) ? (int) substr($http_response_header[0], 9, 3) : 0; + return [$status, (string) $body]; + }; + + for ($i = 0; $i < 30; $i++) { + [$status] = $get("/"); + if ($status !== 0) { + break; + } + sleep(1); + } + + $failures = []; + [$status, $body] = $get("/"); + echo "front page: {$status}\n"; + $status === 200 or $failures[] = "the front page answered {$status}"; + // The seeded document, rendered through the seeded template - proof the + // CMS read its own data rather than merely booting. + str_contains($body, "Install Successful!") or $failures[] = "the front page did not render the seeded document"; + + [$status, $body] = $get("/manager/index.php", ["Accept-Language: en-US,en;q=0.9"]); + echo "manager: {$status}\n"; + $status === 200 or $failures[] = "the manager answered {$status}"; + stripos($body, "password") !== false or $failures[] = "the manager did not render its login form"; + + if ($failures !== []) { + fwrite(STDERR, " - " . implode("\n - ", $failures) . "\n"); + exit(1); + } + echo "both entry points served the installed site\n"; +' "$PORT" || { + tail -40 /tmp/server.log >&2 + fail "the installed site did not answer correctly" +} + +say "OK: ${EVO_DB_TYPE} installation is clean, migrated and seeded" diff --git a/.github/docker/ci/smoke.php b/.github/docker/ci/smoke.php new file mode 100644 index 0000000000..9a2796212c --- /dev/null +++ b/.github/docker/ci/smoke.php @@ -0,0 +1,260 @@ + 'mysql:host=' . $config['host'] . ';port=' . $config['port'] . ';dbname=' . $config['database'] . ';charset=' . $config['charset'], + 'pgsql' => 'pgsql:host=' . $config['host'] . ';port=' . $config['port'] . ';dbname=' . $config['database'], + 'sqlite' => 'sqlite:' . $config['database'], + default => null, +}; +if ($dsn === null) { + fwrite(STDERR, 'smoke: unsupported driver ' . $driver . PHP_EOL); + exit(1); +} + +$pdo = new PDO($dsn, $config['username'], $config['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, +]); + +/** Quote a prefixed table name the way the server in front of us wants it. */ +function t(string $table): string +{ + global $prefix, $driver; + + $name = $prefix . $table; + + return $driver === 'mysql' ? '`' . $name . '`' : '"' . $name . '"'; +} + +/** Quote a column name; pgsql folds unquoted identifiers to lower case. */ +function c(string $column): string +{ + global $driver; + + return $driver === 'mysql' ? '`' . $column . '`' : '"' . $column . '"'; +} + +function scalar(string $sql, array $bindings = []) +{ + global $pdo; + + $statement = $pdo->prepare($sql); + $statement->execute($bindings); + $value = $statement->fetchColumn(); + + return $value === false ? null : $value; +} + +function count_rows(string $table): int +{ + return (int) scalar('SELECT COUNT(*) FROM ' . t($table)); +} + +function has_column(string $table, string $column): bool +{ + global $pdo; + + try { + $pdo->query('SELECT ' . c($column) . ' FROM ' . t($table) . ' WHERE 1 = 0'); + } catch (PDOException $e) { + return false; + } + + return true; +} + +function setting(string $name) +{ + return scalar('SELECT setting_value FROM ' . t('system_settings') . ' WHERE setting_name = ?', [$name]); +} + +$admin = getenv('EVO_ADMIN') ?: 'admin'; +$adminEmail = getenv('EVO_ADMIN_EMAIL') ?: 'admin@evo.local'; +$adminPassword = getenv('EVO_ADMIN_PASSWORD') ?: ''; +$language = getenv('EVO_LANGUAGE') ?: 'en'; + +echo 'Smoke test: ' . $driver . ' ' . $pdo->getAttribute(PDO::ATTR_SERVER_VERSION) + . ", prefix '" . $prefix . "'" . PHP_EOL; + +echo PHP_EOL . 'Schema' . PHP_EOL; +// Every table the migration chain creates has to be there: a migration that +// silently skipped its Schema::create leaves the site half installed. +$expected = [ + 'active_user_locks', 'active_user_sessions', 'active_users', 'categories', + 'document_groups', 'documentgroup_names', 'event_log', 'file_groups', + 'manager_log', 'member_groups', 'membergroup_access', 'membergroup_names', + 'migrations_install', 'permissions', 'permissions_groups', 'role_permissions', + 'site_content', 'site_content_closure', 'site_htmlsnippets', 'site_module_access', + 'site_module_depobj', 'site_modules', 'site_plugin_events', 'site_plugins', + 'site_snippets', 'site_templates', 'site_tmplvar_access', + 'site_tmplvar_contentvalues', 'site_tmplvar_templates', 'site_tmplvars', + 'system_cli_task_logs', 'system_cli_tasks', 'system_eventnames', + 'system_scheduler_health', 'system_settings', 'system_worker_health', + 'user_attributes', 'user_role_vars', 'user_roles', 'user_settings', + 'user_values', 'users', +]; +$missing = []; +foreach ($expected as $table) { + try { + $pdo->query('SELECT 1 FROM ' . t($table) . ' WHERE 1 = 0'); + } catch (PDOException $e) { + $missing[] = $prefix . $table; + } +} +check(count($expected) . ' tables created', $missing === [], 'missing: ' . implode(', ', $missing)); + +// Columns added by the core only migrations. Those run after the install stubs +// chain, so their absence means that second migrate call did nothing. +$lateColumns = [ + 'site_templates' => ['templatefileextension', 'templatesource'], + 'users' => ['cachepwd_valid_to'], +]; +foreach ($lateColumns as $table => $columns) { + foreach ($columns as $column) { + check($table . '.' . $column . ' exists (core migration applied)', has_column($table, $column)); + } +} + +// The installer records its whole chain - the install stubs and the core +// migrations it runs afterwards - in migrations_install; the `migrations` table +// of a plain Laravel app stays unused here (core/config/database/migrations.php). +$applied = (int) scalar('SELECT COUNT(*) FROM ' . t('migrations_install')); +check('migrations were recorded', $applied > 0, 'recorded: ' . $applied); + +echo PHP_EOL . 'Seeded content' . PHP_EOL; +check('one document seeded', count_rows('site_content') === 1, 'rows: ' . count_rows('site_content')); +$home = $pdo->query('SELECT * FROM ' . t('site_content') . ' ORDER BY id LIMIT 1')->fetch() ?: []; +check('the document is the install success page', ($home['alias'] ?? '') === 'minimal-base', 'alias: ' . ($home['alias'] ?? 'none')); +check('the document is published', (int) ($home['published'] ?? 0) === 1); +check('the document uses the seeded template', (int) ($home['template'] ?? 0) === 1); +check('a template was seeded', count_rows('site_templates') >= 1); +check('event names were seeded', count_rows('system_eventnames') > 50, 'rows: ' . count_rows('system_eventnames')); +check('settings were seeded', count_rows('system_settings') >= 40, 'rows: ' . count_rows('system_settings')); +check('permission groups were seeded', count_rows('permissions_groups') >= 14, 'rows: ' . count_rows('permissions_groups')); +check('permissions were seeded', count_rows('permissions') > 0); +check('role permissions were seeded', count_rows('role_permissions') > 0); + +echo PHP_EOL . 'Settings written by the installer' . PHP_EOL; +check("manager_language is '" . $language . "'", setting('manager_language') === $language, 'got: ' . var_export(setting('manager_language'), true)); +check("manager_theme is 'default'", setting('manager_theme') === 'default', 'got: ' . var_export(setting('manager_theme'), true)); +check('site_id was generated', is_string(setting('site_id')) && setting('site_id') !== ''); +check('emailsender is the admin email', setting('emailsender') === $adminEmail, 'got: ' . var_export(setting('emailsender'), true)); +check('auto_template_logic is on', (string) setting('auto_template_logic') === '1'); + +echo PHP_EOL . 'Admin account' . PHP_EOL; +$statement = $pdo->prepare('SELECT * FROM ' . t('users') . ' WHERE username = ?'); +$statement->execute([$admin]); +$user = $statement->fetch() ?: []; +check('the admin user exists', $user !== []); +$hash = (string) ($user['password'] ?? ''); +check('the password was hashed', $hash !== '' && $hash !== $adminPassword && strlen($hash) >= 32, 'length: ' . strlen($hash)); + +$statement = $pdo->prepare('SELECT * FROM ' . t('user_attributes') . ' WHERE ' . c('internalKey') . ' = ?'); +$statement->execute([$user['id'] ?? 0]); +$attributes = $statement->fetch() ?: []; +check('the admin has attributes', $attributes !== []); +check('the admin email was stored', ($attributes['email'] ?? '') === $adminEmail, 'got: ' . ($attributes['email'] ?? 'none')); +check('the admin is verified', (int) ($attributes['verified'] ?? 0) === 1); +$administrator = scalar('SELECT id FROM ' . t('user_roles') . ' WHERE name = ?', ['Administrator']); +check('the Administrator role exists', $administrator !== null); +check('the admin holds the Administrator role', (int) ($attributes['role'] ?? -1) === (int) $administrator, 'role: ' . ($attributes['role'] ?? 'none')); + +echo PHP_EOL . 'Bundled extras' . PHP_EOL; +// installModulesAndPlugins() parses assets/plugins and assets/modules; a parse +// that quietly found nothing would leave a site with no plugins at all. +check('plugins were installed', count_rows('site_plugins') > 0, 'rows: ' . count_rows('site_plugins')); +check('plugin events were bound', count_rows('site_plugin_events') > 0, 'rows: ' . count_rows('site_plugin_events')); +check('modules were installed', count_rows('site_modules') > 0, 'rows: ' . count_rows('site_modules')); + +echo PHP_EOL . 'Stability' . PHP_EOL; +// The entrypoint runs this script once after installing and again after the +// updater has been over the same site. The second run compares against the +// counts the first one recorded: a migration or seeder that is not idempotent +// shows up here as rows appearing twice. +$baselineFile = getenv('EVO_SMOKE_BASELINE') ?: sys_get_temp_dir() . '/evo-smoke-baseline.json'; +$counted = [ + 'migrations_install', 'site_content', 'site_templates', 'system_eventnames', + 'system_settings', 'permissions', 'permissions_groups', 'role_permissions', + 'user_roles', 'users', 'user_attributes', 'site_plugins', 'site_plugin_events', + 'site_modules', 'site_snippets', 'site_htmlsnippets', +]; +$counts = []; +foreach ($counted as $table) { + $counts[$table] = count_rows($table); +} + +if (!is_file($baselineFile)) { + file_put_contents($baselineFile, json_encode($counts, JSON_PRETTY_PRINT)); + echo ' -- baseline recorded in ' . $baselineFile . PHP_EOL; +} else { + $baseline = json_decode((string) file_get_contents($baselineFile), true) ?: []; + $drifted = []; + foreach ($counts as $table => $rows) { + if (($baseline[$table] ?? $rows) !== $rows) { + $drifted[] = $prefix . $table . ': ' . $baseline[$table] . ' -> ' . $rows; + } + } + check('row counts unchanged since the first run', $drifted === [], implode(', ', $drifted)); +} + +echo PHP_EOL; +if ($failures === []) { + echo 'All ' . $checks . ' checks passed.' . PHP_EOL; + exit(0); +} + +echo count($failures) . ' of ' . $checks . ' checks failed:' . PHP_EOL + . ' - ' . implode(PHP_EOL . ' - ', $failures) . PHP_EOL; +exit(1); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5677867ce6..b0651a3382 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,3 +91,39 @@ jobs: - name: Run Pest working-directory: core run: composer test -- --compact + + install: + name: Install on ${{ matrix.db }} (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The two database servers the CLI installer supports besides sqlite, + # each on its current long term release. The sqlite path is covered by + # the build workflow, which installs on it to produce the nightly zip. + db: [mysql, pgsql] + php: ['8.3', '8.4'] + env: + COMPOSE: .github/docker/ci/docker-compose.yml + PHP_VERSION: ${{ matrix.php }} + steps: + - uses: actions/checkout@v7 + + # Everything happens inside the containers: a PHP image with pdo_mysql and + # pdo_pgsql, next to the database server. See .github/docker/ci/ for what + # the run asserts - it fails on any PHP diagnostic, on missing or wrong + # seeded data, and on a site that does not answer. + - name: Install and smoke test + run: | + docker compose -f "$COMPOSE" --profile ${{ matrix.db }} up \ + --build \ + --abort-on-container-exit \ + --exit-code-from install-${{ matrix.db }} + + - name: Database server log + if: failure() + run: docker compose -f "$COMPOSE" --profile ${{ matrix.db }} logs ${{ matrix.db }} + + - name: Tear down + if: always() + run: docker compose -f "$COMPOSE" --profile ${{ matrix.db }} down -v diff --git a/.gitignore b/.gitignore index 5c330741a0..eba71044fc 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ Desktop.ini **/.* !.gitignore !/.github +!/.dockerignore From 4d8f9f3b8ae470ac43c0fca1ef85c710efae99b1 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Wed, 9 Sep 2026 23:12:43 +0200 Subject: [PATCH 2/3] add(CI): MySQL and PgSQL --- .github/docker/ci/Dockerfile | 2 +- .github/docker/ci/README.md | 24 +++++++++-- .github/docker/ci/docker-compose.yml | 12 +++++- .github/docker/ci/install-and-smoke.sh | 0 .github/workflows/ci.yml | 59 ++++++++++++++++++++------ 5 files changed, 78 insertions(+), 19 deletions(-) mode change 100644 => 100755 .github/docker/ci/install-and-smoke.sh diff --git a/.github/docker/ci/Dockerfile b/.github/docker/ci/Dockerfile index 6c5d006d45..03217e38d6 100644 --- a/.github/docker/ci/Dockerfile +++ b/.github/docker/ci/Dockerfile @@ -6,7 +6,7 @@ # installer, the migrations and the seeded data, and the built in web server is # enough to prove the installed site answers. Which web server serves it is what # the Salo images in .github/docker/Dockerfile are for. -ARG PHP_VERSION=8.4 +ARG PHP_VERSION=8.3 FROM php:${PHP_VERSION}-cli # libpq/libzip/libicu are the build inputs of the extensions below; the -dev diff --git a/.github/docker/ci/README.md b/.github/docker/ci/README.md index 9d71e54d0d..8c40f424f8 100644 --- a/.github/docker/ci/README.md +++ b/.github/docker/ci/README.md @@ -18,9 +18,27 @@ Clean up with `docker compose -f .github/docker/ci/docker-compose.yml --profile down -v`. The container works on a copy of the tree, not a bind mount, so a run leaves the working tree alone. -`PHP_VERSION`, `MYSQL_IMAGE_TAG` and `POSTGRES_IMAGE_TAG` override the defaults -(PHP 8.4, MySQL 9.7 LTS, PostgreSQL 18.6 LTS). CI runs both databases against -PHP 8.3 and 8.4 — see the `install` job in `.github/workflows/ci.yml`. +`PHP_VERSION`, `MYSQL_IMAGE_TAG`, `POSTGRES_IMAGE_TAG`, `MYSQL_PORT` and +`POSTGRES_PORT` override the defaults (PHP 8.3, MySQL 9.7 LTS, PostgreSQL 18.6 +LTS, the servers' own ports). + +## What CI runs + +The `install` job in `.github/workflows/ci.yml` does not use the image above. +It starts only the database service from this compose file — an official image, +pulled, never built — and runs `install-and-smoke.sh` directly on the runner, +whose PHP `shivammathur/setup-php` already provides with `pdo_mysql` and +`pdo_pgsql` prebuilt. Building the image there would spend two or three minutes +per leg compiling extensions the runner hands over ready-made, on every push. + +The script only needs `EVO_DB_*` and a reachable server, so both paths run the +same checks; the image is what makes a local run reproducible on a machine with +no PHP on it, and what pins the PHP version when a version question is the one +being investigated. + +One PHP version, both databases: what the job proves is the installer's MySQL +and PostgreSQL paths, which do not vary with the PHP minor. 8.3 and 8.4 are both +covered by the analysis and unit test jobs. ## What a run asserts diff --git a/.github/docker/ci/docker-compose.yml b/.github/docker/ci/docker-compose.yml index e0174c4493..4af612dca3 100644 --- a/.github/docker/ci/docker-compose.yml +++ b/.github/docker/ci/docker-compose.yml @@ -14,6 +14,12 @@ services: profiles: [mysql] # 9.7 is the LTS of the MySQL 9 series. image: mysql:${MYSQL_IMAGE_TAG:-9.7} + # Published so the installer can also be driven from the host - which is + # what CI does, to avoid building a PHP image for something the runner + # already has. The install-* services below reach it over the network + # compose creates and ignore this. + ports: + - "${MYSQL_PORT:-3306}:3306" environment: MYSQL_ROOT_PASSWORD: secret # No MYSQL_DATABASE on purpose: the installer is meant to create the @@ -29,6 +35,8 @@ services: pgsql: profiles: [pgsql] image: postgres:${POSTGRES_IMAGE_TAG:-18.6} + ports: + - "${POSTGRES_PORT:-5432}:5432" environment: POSTGRES_USER: evo POSTGRES_PASSWORD: secret @@ -49,7 +57,7 @@ services: context: ../../.. dockerfile: .github/docker/ci/Dockerfile args: - PHP_VERSION: ${PHP_VERSION:-8.4} + PHP_VERSION: ${PHP_VERSION:-8.3} depends_on: mysql: condition: service_healthy @@ -67,7 +75,7 @@ services: context: ../../.. dockerfile: .github/docker/ci/Dockerfile args: - PHP_VERSION: ${PHP_VERSION:-8.4} + PHP_VERSION: ${PHP_VERSION:-8.3} depends_on: pgsql: condition: service_healthy diff --git a/.github/docker/ci/install-and-smoke.sh b/.github/docker/ci/install-and-smoke.sh old mode 100644 new mode 100755 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0651a3382..0adc59f463 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,7 @@ jobs: run: composer test -- --compact install: - name: Install on ${{ matrix.db }} (PHP ${{ matrix.php }}) + name: Install on ${{ matrix.db }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -101,24 +101,57 @@ jobs: # The two database servers the CLI installer supports besides sqlite, # each on its current long term release. The sqlite path is covered by # the build workflow, which installs on it to produce the nightly zip. - db: [mysql, pgsql] - php: ['8.3', '8.4'] + # One PHP version only: what this job proves is the installer's MySQL + # and PostgreSQL paths, and those do not vary with the PHP minor. The + # matrix above already runs the analysis and the tests on both. + include: + - db: mysql + user: root + password: secret + - db: pgsql + user: evo + password: secret env: COMPOSE: .github/docker/ci/docker-compose.yml - PHP_VERSION: ${{ matrix.php }} steps: - uses: actions/checkout@v7 - # Everything happens inside the containers: a PHP image with pdo_mysql and - # pdo_pgsql, next to the database server. See .github/docker/ci/ for what - # the run asserts - it fails on any PHP diagnostic, on missing or wrong - # seeded data, and on a site that does not answer. + # Only the database server runs in a container, straight from its official + # image - nothing is built here. The installer itself runs on the runner's + # own PHP, which already carries the PDO drivers; building a PHP image for + # it would cost minutes per leg to reproduce what setup-php hands over + # prebuilt. .github/docker/ci/ still describes the full containerised run + # for local use. + - name: Start ${{ matrix.db }} + run: docker compose -f "$COMPOSE" --profile ${{ matrix.db }} up -d --wait ${{ matrix.db }} + + # error_log to stderr for the same reason the CI image sets it: the + # installer runs the migrations inside an output buffer it later discards, + # so a warning raised during one only reaches the log the script greps if + # PHP also writes it outside that buffer. + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: ${{ env.PHP_EXTENSIONS }}, pdo_mysql, pdo_pgsql + ini-values: error_reporting=E_ALL, display_errors=On, display_startup_errors=On, log_errors=On, error_log=/dev/stderr + coverage: none + tools: composer:v2 + + # core/vendor is committed, so there is no composer step: the script calls + # the installer with --skipComposer=y. See .github/docker/ci/ for what the + # run asserts - it fails on any PHP diagnostic, on missing or wrong seeded + # data, and on a site that does not answer. - name: Install and smoke test - run: | - docker compose -f "$COMPOSE" --profile ${{ matrix.db }} up \ - --build \ - --abort-on-container-exit \ - --exit-code-from install-${{ matrix.db }} + env: + EVO_APP_DIR: ${{ github.workspace }} + EVO_DB_TYPE: ${{ matrix.db }} + EVO_DB_HOST: 127.0.0.1 + EVO_DB_USER: ${{ matrix.user }} + EVO_DB_PASSWORD: ${{ matrix.password }} + EVO_DB_NAME: evolution + EVO_DB_PREFIX: evo_ + run: .github/docker/ci/install-and-smoke.sh - name: Database server log if: failure() From ad056876a12c9cc2a0a4eb44c81ab2fcef766fa8 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Wed, 9 Sep 2026 23:38:50 +0200 Subject: [PATCH 3/3] add(CI): upgrade between version smoke test --- .github/docker/ci/README.md | 84 +++++++++++-- .github/docker/ci/install-and-smoke.sh | 129 +++----------------- .github/docker/ci/lib.sh | 145 ++++++++++++++++++++++ .github/docker/ci/smoke.php | 160 +++++++++++++++++++++---- .github/docker/ci/upgrade-and-smoke.sh | 125 +++++++++++++++++++ .github/workflows/ci.yml | 21 ++-- .github/workflows/upgrade.yml | 142 ++++++++++++++++++++++ 7 files changed, 651 insertions(+), 155 deletions(-) create mode 100644 .github/docker/ci/lib.sh create mode 100755 .github/docker/ci/upgrade-and-smoke.sh create mode 100644 .github/workflows/upgrade.yml diff --git a/.github/docker/ci/README.md b/.github/docker/ci/README.md index 8c40f424f8..d789473b43 100644 --- a/.github/docker/ci/README.md +++ b/.github/docker/ci/README.md @@ -1,10 +1,19 @@ -# Install checks on MySQL and PostgreSQL +# Install and upgrade checks -Installs Evolution CMS onto a real database server and asserts the result, so -the MySQL and PostgreSQL paths of `install/cli-install.php` get the coverage the -sqlite path already gets from the build workflow. +Two scripts, one set of assertions: -Run either leg from the repository root: +- `install-and-smoke.sh` installs this tree onto a database and checks what it + put there, then runs the updater over it to catch anything not idempotent. +- `upgrade-and-smoke.sh` installs an **older release**, copies this tree over it + the way unpacking a release archive would, and runs the updater. That is the + only check that replays the migration chain over a schema this branch did not + create - what every existing site does when it moves to a new release. + +Both take a `sqlite`, `mysql` or `pgsql` database from `EVO_DB_TYPE`. + +## Installing + +Run either database leg from the repository root: ```sh docker compose -f .github/docker/ci/docker-compose.yml --profile mysql up \ @@ -18,10 +27,43 @@ Clean up with `docker compose -f .github/docker/ci/docker-compose.yml --profile down -v`. The container works on a copy of the tree, not a bind mount, so a run leaves the working tree alone. +sqlite needs no container at all: + +```sh +EVO_DB_TYPE=sqlite EVO_APP_DIR="$PWD" .github/docker/ci/install-and-smoke.sh +``` + `PHP_VERSION`, `MYSQL_IMAGE_TAG`, `POSTGRES_IMAGE_TAG`, `MYSQL_PORT` and `POSTGRES_PORT` override the defaults (PHP 8.3, MySQL 9.7 LTS, PostgreSQL 18.6 LTS, the servers' own ports). +## Upgrading + +`upgrade-and-smoke.sh` needs two trees: the older one it installs and upgrades +in place, and this one to copy over it. The older tree is left dirty, so give it +a checkout that can be thrown away. + +```sh +git clone --depth 1 --branch 3.5.7 https://github.com/evolution-cms/evolution /tmp/evo-3.5.7 +EVO_FROM_DIR=/tmp/evo-3.5.7 EVO_NEW_DIR="$PWD" EVO_DB_TYPE=mysql EVO_DB_HOST=127.0.0.1 EVO_DB_USER=root EVO_DB_PASSWORD=secret .github/docker/ci/upgrade-and-smoke.sh +``` + +Two things about the source version are worth knowing: + +- **No released version installs on sqlite.** `cli-install.php` offered only + `pgsql` and `mysql` through 3.5.7; the sqlite branch is new in this tree. So a + sqlite upgrade leg has to take its source from a ref that carries it - the + `nightly-3.5.x` tag, or any 3.5.x commit after it landed. A tag works here + like any other ref once a release ships sqlite support. +- **No `--skipComposer` before 3.5.8 either.** An older `composerUpdate()` runs + whatever it finds at `core/vendor/bin/composer` and only warns when that is + missing, so the script moves the shim aside: `core/vendor` is committed at + every tag, and a composer update would test the network rather than the CMS. + +The source install is checked for hard failures only. A release run on a newer +PHP may emit deprecations that are not this branch's to fix, and they are not +what is under test - the updater's own log is checked strictly. + ## What CI runs The `install` job in `.github/workflows/ci.yml` does not use the image above. @@ -36,9 +78,18 @@ same checks; the image is what makes a local run reproducible on a machine with no PHP on it, and what pins the PHP version when a version question is the one being investigated. -One PHP version, both databases: what the job proves is the installer's MySQL -and PostgreSQL paths, which do not vary with the PHP minor. 8.3 and 8.4 are both -covered by the analysis and unit test jobs. +One PHP version, every database: what the job proves is the installer's database +paths, which do not vary with the PHP minor. 8.3 and 8.4 are both covered by the +analysis and unit test jobs. The sqlite leg starts no container. + +The upgrade checks live in their own workflow, `.github/workflows/upgrade.yml`, +off `push` and `pull_request` on purpose: an upgrade regression comes from a +change to the migrations or the seeders, not from every commit, and each leg +costs a full install plus a full update. It runs nightly, and on demand with the +versions to go from and to as inputs (Actions -> Upgrade -> Run workflow): +`from` takes one or more refs of any repository, `to` a ref of this one or blank +for the checked out tree, which is the default - so the unreleased version is +what an upgrade is tested against unless you say otherwise. ## What a run asserts @@ -50,7 +101,7 @@ covered by the analysis and unit test jobs. migrations inside an output buffer it later discards, so the image also routes `error_log` to stderr; a warning raised during a migration cannot be swallowed. -3. `smoke.php` checks what reached the database, reading the connection the +3. `smoke.php --mode=install` checks what reached the database, reading the connection the installer just wrote: every table created, the columns the core migrations add, the seeded document, template, event names, settings, roles and permissions, the admin account with a hashed password and the Administrator @@ -60,3 +111,18 @@ covered by the analysis and unit test jobs. run, which is what catches a migration or seeder that is not idempotent. 5. The site answers over HTTP: the front page renders the seeded document, and the manager renders its login form. + +An upgrade run asserts the same things through `smoke.php --mode=upgrade`, with +the expectations that belong to a fresh install relaxed - the content and the +settings are whichever ones the older site had, not the ones this installer +would have chosen. Two checks replace them, and they are the point of the mode: + +- the counts recorded from the older site by `--mode=baseline`, taken before the + copy, must not have **dropped** anywhere. A seeded catalogue may grow, because + topping those up is what the update seeder is for; losing rows is a bug. +- `site_content`, `users` and `user_attributes` must be **unchanged**. Those are + the site, not the release, and an upgrade may not touch them. + +`manager_language` and `emailsender` stay exact in both modes: they are answers +the operator gave the installer, and a seeder writing defaults over them would +reset a live site. diff --git a/.github/docker/ci/install-and-smoke.sh b/.github/docker/ci/install-and-smoke.sh index ba2ad27299..a9ff874965 100755 --- a/.github/docker/ci/install-and-smoke.sh +++ b/.github/docker/ci/install-and-smoke.sh @@ -4,20 +4,23 @@ # seeded data present, the updater able to replay the chain over it without # changing anything, and the site answering over HTTP. # -# Runs as the container entrypoint (see Dockerfile); every knob has a default -# so `docker run` with only EVO_DB_HOST set does something sensible. +# Runs as the container entrypoint (see Dockerfile) and, in CI, directly on the +# runner; every knob has a default so `docker run` with only EVO_DB_HOST set +# does something sensible. EVO_DB_TYPE=sqlite needs no server at all. +# +# For the upgrade of an older release to this tree, see upgrade-and-smoke.sh. # pipefail: the installer output goes through tee, and a pipeline that hides # the exit code of its first command would report a failed install as a pass. set -euo pipefail APP_DIR=${EVO_APP_DIR:-/var/www/html} -EVO_DB_TYPE=${EVO_DB_TYPE:-mysql} -EVO_DB_HOST=${EVO_DB_HOST:-127.0.0.1} -EVO_DB_USER=${EVO_DB_USER:-root} -EVO_DB_PASSWORD=${EVO_DB_PASSWORD:-secret} -EVO_DB_NAME=${EVO_DB_NAME:-evolution} -EVO_DB_PREFIX=${EVO_DB_PREFIX:-evo_} +export EVO_DB_TYPE=${EVO_DB_TYPE:-mysql} +export EVO_DB_HOST=${EVO_DB_HOST:-127.0.0.1} +export EVO_DB_USER=${EVO_DB_USER:-root} +export EVO_DB_PASSWORD=${EVO_DB_PASSWORD:-secret} +export EVO_DB_NAME=${EVO_DB_NAME:-evolution} +export EVO_DB_PREFIX=${EVO_DB_PREFIX:-evo_} # Exported: smoke.php reads these to check what the installer stored. export EVO_ADMIN=${EVO_ADMIN:-admin} @@ -25,56 +28,13 @@ export EVO_ADMIN_EMAIL=${EVO_ADMIN_EMAIL:-admin@evo.local} export EVO_ADMIN_PASSWORD=${EVO_ADMIN_PASSWORD:-Passw0rd123} export EVO_LANGUAGE=${EVO_LANGUAGE:-en} +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + LOG=/tmp/install.log UPDATE_LOG=/tmp/update.log -PORT=${EVO_HTTP_PORT:-8899} - -say() { - printf '\n\033[1;36m== %s\033[0m\n' "$1" -} - -fail() { - printf '\033[1;31m!! %s\033[0m\n' "$1" >&2 - exit 1 -} - -# Rejects a run that printed anything PHP or PDO would call a problem. -# -# php.ini in the image sends diagnostics to stdout and to stderr (see -# Dockerfile), and both are captured here, so a warning raised inside the output -# buffer the installer later discards still reaches this log. -# -# The patterns are anchored on how PHP formats a diagnostic ("Warning: text in -# /file on line N") rather than on the bare words, which turn up in plenty of -# legitimate output - the settings seeder alone writes rows whose names contain -# "error". -check_log() { - if grep -nE '(^|PHP )(Warning|Notice|Deprecated|Fatal error|Parse error|Recoverable fatal error|Strict Standards):' "$1"; then - fail "the $2 emitted PHP diagnostics" - fi - # SQLSTATE is how both PDO drivers label a failed statement, and the - # installer swallows some of those into plain output instead of exiting. - if grep -nE 'SQLSTATE\[|Uncaught .*Exception|Migration not found|✖' "$1"; then - fail "the $2 reported a database or migration error" - fi - echo "no diagnostics in $(wc -l < "$1") lines of $2 output" -} -say "Waiting for ${EVO_DB_TYPE} at ${EVO_DB_HOST}" -# Compose already gates on a healthcheck, but this script is also meant to be -# runnable on its own against any reachable server. -attempt=0 -until php -r ' - $dsn = $argv[1] === "pgsql" - ? "pgsql:host=" . $argv[2] . ";dbname=" . $argv[5] - : "mysql:host=" . $argv[2]; - new PDO($dsn, $argv[3], $argv[4]); -' "$EVO_DB_TYPE" "$EVO_DB_HOST" "$EVO_DB_USER" "$EVO_DB_PASSWORD" "${EVO_DB_BOOTSTRAP_NAME:-$EVO_DB_USER}" 2>/dev/null; do - attempt=$((attempt + 1)) - [ "$attempt" -lt 60 ] || fail "database never became reachable" - sleep 2 -done -echo "reachable after ${attempt} attempt(s)" +wait_for_db say "Installing Evolution CMS (${EVO_DB_TYPE}, database '${EVO_DB_NAME}', prefix '${EVO_DB_PREFIX}')" # --skipComposer=y: core/vendor is committed, so there is nothing to install and @@ -83,16 +43,12 @@ say "Installing Evolution CMS (${EVO_DB_TYPE}, database '${EVO_DB_NAME}', prefix # sqlite leg of the build workflow already covers removal. # Interactive prompts read from stdin; /tmp/server.log 2>&1 & -server=$! -trap 'kill "$server" 2>/dev/null || true' EXIT - -php -r ' - // The front end and the manager are the two entry points an installed site - // has to serve. The manager answers 404 without an Accept-Language header - // by design, hence the header on the second request. - $port = $argv[1]; - $get = function (string $path, array $headers = []) use ($port): array { - $context = stream_context_create(["http" => [ - "ignore_errors" => true, - "timeout" => 20, - "header" => $headers, - ]]); - $body = @file_get_contents("http://127.0.0.1:{$port}{$path}", false, $context); - $status = isset($http_response_header[0]) ? (int) substr($http_response_header[0], 9, 3) : 0; - return [$status, (string) $body]; - }; - - for ($i = 0; $i < 30; $i++) { - [$status] = $get("/"); - if ($status !== 0) { - break; - } - sleep(1); - } - - $failures = []; - [$status, $body] = $get("/"); - echo "front page: {$status}\n"; - $status === 200 or $failures[] = "the front page answered {$status}"; - // The seeded document, rendered through the seeded template - proof the - // CMS read its own data rather than merely booting. - str_contains($body, "Install Successful!") or $failures[] = "the front page did not render the seeded document"; - - [$status, $body] = $get("/manager/index.php", ["Accept-Language: en-US,en;q=0.9"]); - echo "manager: {$status}\n"; - $status === 200 or $failures[] = "the manager answered {$status}"; - stripos($body, "password") !== false or $failures[] = "the manager did not render its login form"; - - if ($failures !== []) { - fwrite(STDERR, " - " . implode("\n - ", $failures) . "\n"); - exit(1); - } - echo "both entry points served the installed site\n"; -' "$PORT" || { - tail -40 /tmp/server.log >&2 - fail "the installed site did not answer correctly" -} +http_check "$APP_DIR" say "OK: ${EVO_DB_TYPE} installation is clean, migrated and seeded" diff --git a/.github/docker/ci/lib.sh b/.github/docker/ci/lib.sh new file mode 100644 index 0000000000..bdd06656bc --- /dev/null +++ b/.github/docker/ci/lib.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# Shared by install-and-smoke.sh and upgrade-and-smoke.sh: the parts that are +# about proving an installation is sound, rather than about how it got there. +# +# Sourced, never executed. The caller sets EVO_DB_* and APP_DIR first. + +say() { + printf '\n\033[1;36m== %s\033[0m\n' "$1" +} + +fail() { + printf '\033[1;31m!! %s\033[0m\n' "$1" >&2 + exit 1 +} + +# A file based database has no server to reach, no user and no password, so +# every step that talks to a server is skipped for it. +is_sqlite() { + [ "${EVO_DB_TYPE}" = "sqlite" ] +} + +# Rejects a run that printed anything PHP or PDO would call a problem. +# +# php.ini sends diagnostics to stdout and to stderr, and both are captured here, +# so a warning raised inside the output buffer the installer later discards +# still reaches this log. +# +# The patterns are anchored on how PHP formats a diagnostic ("Warning: text in +# /file on line N") rather than on the bare words, which turn up in plenty of +# legitimate output - the settings seeder alone writes rows whose names contain +# "error". +check_log() { + if grep -nE '(^|PHP )(Warning|Notice|Deprecated|Fatal error|Parse error|Recoverable fatal error|Strict Standards):' "$1"; then + fail "the $2 emitted PHP diagnostics" + fi + # SQLSTATE is how both PDO drivers label a failed statement, and the + # installer swallows some of those into plain output instead of exiting. + if grep -nE 'SQLSTATE\[|Uncaught .*Exception|Migration not found|✖' "$1"; then + fail "the $2 reported a database or migration error" + fi + echo "no diagnostics in $(wc -l < "$1") lines of $2 output" +} + +# Compose already gates on a healthcheck, but the scripts are also meant to be +# runnable on their own against any reachable server. +wait_for_db() { + if is_sqlite; then + echo "sqlite: no server to wait for" + return + fi + + say "Waiting for ${EVO_DB_TYPE} at ${EVO_DB_HOST}" + local attempt=0 + until php -r ' + $dsn = $argv[1] === "pgsql" + ? "pgsql:host=" . $argv[2] . ";dbname=" . $argv[5] + : "mysql:host=" . $argv[2]; + new PDO($dsn, $argv[3], $argv[4]); + ' "$EVO_DB_TYPE" "$EVO_DB_HOST" "$EVO_DB_USER" "$EVO_DB_PASSWORD" "${EVO_DB_BOOTSTRAP_NAME:-$EVO_DB_USER}" 2>/dev/null; do + attempt=$((attempt + 1)) + [ "$attempt" -lt 60 ] || fail "database never became reachable" + sleep 2 + done + echo "reachable after ${attempt} attempt(s)" +} + +# The connection arguments of cli-install.php --typeInstall=1, echoed one per +# line for the caller to read into an array. sqlite is a path, not a server. +installer_db_args() { + printf '%s\n' "--databaseType=${EVO_DB_TYPE}" "--database=${EVO_DB_NAME}" + if ! is_sqlite; then + printf '%s\n' \ + "--databaseServer=${EVO_DB_HOST}" \ + "--databaseUser=${EVO_DB_USER}" \ + "--databasePassword=${EVO_DB_PASSWORD}" + fi + printf '%s\n' "--tablePrefix=${EVO_DB_PREFIX}" +} + +# Serves the installed site and asks it for the two entry points every site has. +http_check() { + local root=$1 + local port=${EVO_HTTP_PORT:-8899} + + php -S "127.0.0.1:${port}" -t "$root" > /tmp/server.log 2>&1 & + local server=$! + trap 'kill "$server" 2>/dev/null || true' EXIT + + php -r ' + // The front end and the manager are the two entry points an installed + // site has to serve. The manager answers 404 without an Accept-Language + // header by design, hence the header on the second request. + $port = $argv[1]; + $expect = $argv[2]; + $get = function (string $path, array $headers = []) use ($port): array { + $context = stream_context_create(["http" => [ + "ignore_errors" => true, + "timeout" => 20, + "header" => $headers, + ]]); + $body = @file_get_contents("http://127.0.0.1:{$port}{$path}", false, $context); + $status = isset($http_response_header[0]) ? (int) substr($http_response_header[0], 9, 3) : 0; + return [$status, (string) $body]; + }; + + for ($i = 0; $i < 30; $i++) { + [$status] = $get("/"); + if ($status !== 0) { + break; + } + sleep(1); + } + + $failures = []; + [$status, $body] = $get("/"); + echo "front page: {$status}\n"; + $status === 200 or $failures[] = "the front page answered {$status}"; + // The seeded document, rendered through the seeded template - proof the + // CMS read its own data rather than merely booting. An upgraded site + // carries whatever document it already had, so the caller passes "" to + // ask only that something rendered. + if ($expect !== "") { + str_contains($body, $expect) or $failures[] = "the front page did not render the seeded document"; + } elseif (trim($body) === "") { + $failures[] = "the front page rendered nothing"; + } + + [$status, $body] = $get("/manager/index.php", ["Accept-Language: en-US,en;q=0.9"]); + echo "manager: {$status}\n"; + $status === 200 or $failures[] = "the manager answered {$status}"; + stripos($body, "password") !== false or $failures[] = "the manager did not render its login form"; + + if ($failures !== []) { + fwrite(STDERR, " - " . implode("\n - ", $failures) . "\n"); + exit(1); + } + echo "both entry points served the installed site\n"; + ' "$port" "${2-Install Successful!}" || { + tail -40 /tmp/server.log >&2 + fail "the installed site did not answer correctly" + } + + kill "$server" 2>/dev/null || true + trap - EXIT +} diff --git a/.github/docker/ci/smoke.php b/.github/docker/ci/smoke.php index 9a2796212c..a65b353208 100644 --- a/.github/docker/ci/smoke.php +++ b/.github/docker/ci/smoke.php @@ -1,17 +1,41 @@ getAttribute(PDO::ATTR_SERVER_VERSION) +echo 'Smoke test (' . $mode . '): ' . $driver . ' ' . $pdo->getAttribute(PDO::ATTR_SERVER_VERSION) . ", prefix '" . $prefix . "'" . PHP_EOL; +$baselineFile = getenv('EVO_SMOKE_BASELINE') ?: sys_get_temp_dir() . '/evo-smoke-baseline.json'; + +// baseline runs against the site as the OLDER release left it, so none of the +// expectations below apply to it - its schema is the old one by definition. +// All it does is write down what is there for the upgrade run to compare with. +if ($mode === 'baseline') { + $counts = []; + foreach (counted_tables() as $table) { + $rows = count_rows_or_null($table); + if ($rows !== null) { + $counts[$table] = $rows; + } + } + file_put_contents($baselineFile, json_encode($counts, JSON_PRETTY_PRINT)); + echo PHP_EOL . 'Recorded ' . count($counts) . ' table counts in ' . $baselineFile . PHP_EOL; + foreach ($counts as $table => $rows) { + echo ' -- ' . $prefix . $table . ': ' . $rows . PHP_EOL; + } + exit(0); +} + +$upgraded = $mode === 'upgrade'; + echo PHP_EOL . 'Schema' . PHP_EOL; // Every table the migration chain creates has to be there: a migration that // silently skipped its Schema::create leaves the site half installed. @@ -174,11 +246,19 @@ function setting(string $name) check('migrations were recorded', $applied > 0, 'recorded: ' . $applied); echo PHP_EOL . 'Seeded content' . PHP_EOL; -check('one document seeded', count_rows('site_content') === 1, 'rows: ' . count_rows('site_content')); +// An upgraded site carries the content the older release seeded and whatever +// its owner added since, so only a fresh install can be held to one document +// with a known alias. What has to hold either way is that the front page still +// has something published to render. $home = $pdo->query('SELECT * FROM ' . t('site_content') . ' ORDER BY id LIMIT 1')->fetch() ?: []; -check('the document is the install success page', ($home['alias'] ?? '') === 'minimal-base', 'alias: ' . ($home['alias'] ?? 'none')); +if ($upgraded) { + check('the documents survived the upgrade', count_rows('site_content') >= 1, 'rows: ' . count_rows('site_content')); +} else { + check('one document seeded', count_rows('site_content') === 1, 'rows: ' . count_rows('site_content')); + check('the document is the install success page', ($home['alias'] ?? '') === 'minimal-base', 'alias: ' . ($home['alias'] ?? 'none')); + check('the document uses the seeded template', (int) ($home['template'] ?? 0) === 1); +} check('the document is published', (int) ($home['published'] ?? 0) === 1); -check('the document uses the seeded template', (int) ($home['template'] ?? 0) === 1); check('a template was seeded', count_rows('site_templates') >= 1); check('event names were seeded', count_rows('system_eventnames') > 50, 'rows: ' . count_rows('system_eventnames')); check('settings were seeded', count_rows('system_settings') >= 40, 'rows: ' . count_rows('system_settings')); @@ -186,12 +266,23 @@ function setting(string $name) check('permissions were seeded', count_rows('permissions') > 0); check('role permissions were seeded', count_rows('role_permissions') > 0); -echo PHP_EOL . 'Settings written by the installer' . PHP_EOL; +echo PHP_EOL . 'Settings' . PHP_EOL; +// These two are answers the operator gave the installer, and an upgrade has no +// business overwriting them - so they stay exact in both modes. The upgrade run +// is where that matters most: a seeder that writes defaults instead of leaving +// existing rows alone would reset a live site's language here. check("manager_language is '" . $language . "'", setting('manager_language') === $language, 'got: ' . var_export(setting('manager_language'), true)); -check("manager_theme is 'default'", setting('manager_theme') === 'default', 'got: ' . var_export(setting('manager_theme'), true)); -check('site_id was generated', is_string(setting('site_id')) && setting('site_id') !== ''); check('emailsender is the admin email', setting('emailsender') === $adminEmail, 'got: ' . var_export(setting('emailsender'), true)); -check('auto_template_logic is on', (string) setting('auto_template_logic') === '1'); +check('site_id is set', is_string(setting('site_id')) && setting('site_id') !== ''); +if ($upgraded) { + // Defaults that have moved between releases: on an upgrade the assertion is + // that the setting exists at all, which is what the update seeder is for. + check('manager_theme is set', is_string(setting('manager_theme')) && setting('manager_theme') !== '', 'got: ' . var_export(setting('manager_theme'), true)); + check('auto_template_logic is set', setting('auto_template_logic') !== null); +} else { + check("manager_theme is 'default'", setting('manager_theme') === 'default', 'got: ' . var_export(setting('manager_theme'), true)); + check('auto_template_logic is on', (string) setting('auto_template_logic') === '1'); +} echo PHP_EOL . 'Admin account' . PHP_EOL; $statement = $pdo->prepare('SELECT * FROM ' . t('users') . ' WHERE username = ?'); @@ -219,17 +310,15 @@ function setting(string $name) check('modules were installed', count_rows('site_modules') > 0, 'rows: ' . count_rows('site_modules')); echo PHP_EOL . 'Stability' . PHP_EOL; -// The entrypoint runs this script once after installing and again after the -// updater has been over the same site. The second run compares against the -// counts the first one recorded: a migration or seeder that is not idempotent -// shows up here as rows appearing twice. -$baselineFile = getenv('EVO_SMOKE_BASELINE') ?: sys_get_temp_dir() . '/evo-smoke-baseline.json'; -$counted = [ - 'migrations_install', 'site_content', 'site_templates', 'system_eventnames', - 'system_settings', 'permissions', 'permissions_groups', 'role_permissions', - 'user_roles', 'users', 'user_attributes', 'site_plugins', 'site_plugin_events', - 'site_modules', 'site_snippets', 'site_htmlsnippets', -]; +// install: the entrypoint runs this script once after installing and again +// after the updater has been over the same site, so the counts have to match +// exactly - a migration or seeder that is not idempotent doubles its table. +// +// upgrade: the baseline came from the older release, so the catalogues the +// update seeder tops up are expected to grow. What may never happen is a table +// shrinking, and the tables holding what the site's owner would call their own +// data may not move at all. +$counted = counted_tables(); $counts = []; foreach ($counted as $table) { $counts[$table] = count_rows($table); @@ -240,13 +329,32 @@ function setting(string $name) echo ' -- baseline recorded in ' . $baselineFile . PHP_EOL; } else { $baseline = json_decode((string) file_get_contents($baselineFile), true) ?: []; + // Rows an upgrade has to carry across untouched: the documents, the accounts + // and the accounts' attributes are the site, not the release. + $preserved = ['site_content', 'users', 'user_attributes']; $drifted = []; + $lost = []; foreach ($counts as $table => $rows) { - if (($baseline[$table] ?? $rows) !== $rows) { - $drifted[] = $prefix . $table . ': ' . $baseline[$table] . ' -> ' . $rows; + if (!array_key_exists($table, $baseline)) { + // Absent from the older schema, so there is nothing to compare. + continue; + } + $before = (int) $baseline[$table]; + $exact = !$upgraded || in_array($table, $preserved, true); + $moved = $prefix . $table . ': ' . $before . ' -> ' . $rows; + if ($exact && $before !== $rows) { + $drifted[] = $moved; + } elseif (!$exact && $before > $rows) { + $lost[] = $moved; } } - check('row counts unchanged since the first run', $drifted === [], implode(', ', $drifted)); + + if ($upgraded) { + check('the rows belonging to the site came through unchanged', $drifted === [], implode(', ', $drifted)); + check('no seeded table lost rows in the upgrade', $lost === [], implode(', ', $lost)); + } else { + check('row counts unchanged since the first run', $drifted === [], implode(', ', $drifted)); + } } echo PHP_EOL; diff --git a/.github/docker/ci/upgrade-and-smoke.sh b/.github/docker/ci/upgrade-and-smoke.sh new file mode 100755 index 0000000000..5f44c148d0 --- /dev/null +++ b/.github/docker/ci/upgrade-and-smoke.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# Upgrades an older Evolution CMS to this tree and proves the result is sound. +# +# Installs the older version, copies this tree over it the way unpacking a +# release archive would, runs `cli-install.php --typeInstall=2`, and checks what +# the updater left behind. That exercises what install-and-smoke.sh cannot: the +# migration chain replayed over a schema it did not create, including +# bootstrapInstallMigrationHistory() back-filling the history of a database that +# predates the 2025_12_25 baseline. +# +# EVO_FROM_DIR=/path/to/3.5.7 EVO_NEW_DIR=/path/to/this/tree \ +# EVO_DB_TYPE=mysql EVO_DB_HOST=127.0.0.1 ./upgrade-and-smoke.sh +# +# The older tree is installed into in place and is left dirty, so hand it a +# checkout that can be thrown away, never a working tree. +set -euo pipefail + +NEW_DIR=${EVO_NEW_DIR:-$(cd "$(dirname "$0")/../../.." && pwd)} +APP_DIR=${EVO_FROM_DIR:?EVO_FROM_DIR must point at the older tree to upgrade} + +export EVO_DB_TYPE=${EVO_DB_TYPE:-mysql} +export EVO_DB_HOST=${EVO_DB_HOST:-127.0.0.1} +export EVO_DB_USER=${EVO_DB_USER:-root} +export EVO_DB_PASSWORD=${EVO_DB_PASSWORD:-secret} +export EVO_DB_NAME=${EVO_DB_NAME:-evolution} +export EVO_DB_PREFIX=${EVO_DB_PREFIX:-evo_} + +export EVO_ADMIN=${EVO_ADMIN:-admin} +export EVO_ADMIN_EMAIL=${EVO_ADMIN_EMAIL:-admin@evo.local} +export EVO_ADMIN_PASSWORD=${EVO_ADMIN_PASSWORD:-Passw0rd123} +export EVO_LANGUAGE=${EVO_LANGUAGE:-en} + +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +# Always this tree's smoke test: the older one may not have it, and an old copy +# would check the old expectations. +SMOKE="$NEW_DIR/.github/docker/ci/smoke.php" +export EVO_SMOKE_BASELINE=/tmp/evo-upgrade-baseline.json +rm -f "$EVO_SMOKE_BASELINE" + +SOURCE_LOG=/tmp/source-install.log +UPDATE_LOG=/tmp/upgrade.log + +from_version=$(php -r 'echo (include $argv[1])["version"] ?? "unknown";' "$APP_DIR/core/factory/version.php" 2>/dev/null || echo unknown) +to_version=$(php -r 'echo (include $argv[1])["version"] ?? "unknown";' "$NEW_DIR/core/factory/version.php") + +wait_for_db + +say "Installing the source version ${from_version} (${EVO_DB_TYPE})" +# No --skipComposer before 3.5.8: composerUpdate() runs whatever it finds at +# core/vendor/bin/composer and only warns when that is missing. Moving the shim +# aside is how an older installer is told to leave the committed vendor tree +# alone - a composer update here would test the network rather than the CMS. +composer_shim="$APP_DIR/core/vendor/bin/composer" +[ -f "$composer_shim" ] && mv "$composer_shim" "${composer_shim}.ci-disabled" + +mapfile -t db_args < <(installer_db_args) +cd "$APP_DIR/install" +set +e +php cli-install.php \ + --typeInstall=1 \ + "${db_args[@]}" \ + --cmsAdmin="$EVO_ADMIN" \ + --cmsAdminEmail="$EVO_ADMIN_EMAIL" \ + --cmsPassword="$EVO_ADMIN_PASSWORD" \ + --language="$EVO_LANGUAGE" \ + --removeInstall=n \ + --skipComposer=y \ + < /dev/null 2>&1 | tee "$SOURCE_LOG" +status=$? +set -e +[ "$status" -eq 0 ] || fail "the ${from_version} installer exited with ${status}" +grep -q 'Now you use' "$SOURCE_LOG" || fail "the ${from_version} installer did not run to completion" +cd "$APP_DIR" + +# Deliberately not check_log: an older release run on a newer PHP may emit +# deprecations that are not this branch's to fix, and it is not what is under +# test. Anything that actually broke the install shows up in the exit code +# above, in the baseline below, or in the strict check of the updater's own log. +if grep -nE 'SQLSTATE\[|Fatal error:' "$SOURCE_LOG"; then + fail "the ${from_version} installer failed to build a database to upgrade" +fi + +say "Recording what ${from_version} left in the database" +php "$SMOKE" --mode=baseline "$APP_DIR" || fail "the ${from_version} install is not usable as an upgrade source" + +say "Copying ${to_version} over ${from_version}" +# What unpacking a release archive over a site does. The connection config is +# git ignored, so it is not in this tree to copy and the installed site keeps +# the one it wrote - which is exactly why a real upgrade keeps working. It is +# excluded anyway, so a local run in a tree that has been installed into does +# not carry its own connection across. +tar -C "$NEW_DIR" \ + --exclude=./.git \ + --exclude=./core/config/database/connections/default.php \ + --exclude='./core/database/*.sqlite' \ + -cf - . | tar -C "$APP_DIR" -xf - +installed_version=$(php -r 'echo (include $argv[1])["version"] ?? "unknown";' "$APP_DIR/core/factory/version.php") +[ "$installed_version" = "$to_version" ] || fail "the copy left version ${installed_version}, expected ${to_version}" + +say "Running the ${to_version} updater over the ${from_version} site" +cd "$APP_DIR/install" +set +e +php cli-install.php --typeInstall=2 --removeInstall=n < /dev/null 2>&1 | tee "$UPDATE_LOG" +status=$? +set -e +[ "$status" -eq 0 ] || fail "the updater exited with ${status}" +cd "$APP_DIR" + +say "Checking the updater output for diagnostics" +# Strict here: the updater is this branch's code, running the chain this branch +# ships. A warning raised inside a migration reaches this log because php.ini +# routes error_log to stderr, outside the buffer the installer discards. +check_log "$UPDATE_LOG" "updater" + +say "Checking the upgraded site" +php "$SMOKE" --mode=upgrade "$APP_DIR" || fail "the upgrade from ${from_version} did not produce a sound site" + +say "Checking the upgraded site answers over HTTP" +# No expected text: the document is whichever one the older version seeded, and +# what matters is that the upgraded site still renders it. +http_check "$APP_DIR" "" + +say "OK: ${from_version} -> ${to_version} on ${EVO_DB_TYPE} upgraded cleanly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0adc59f463..d7113c89f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,12 +98,12 @@ jobs: strategy: fail-fast: false matrix: - # The two database servers the CLI installer supports besides sqlite, - # each on its current long term release. The sqlite path is covered by - # the build workflow, which installs on it to produce the nightly zip. - # One PHP version only: what this job proves is the installer's MySQL - # and PostgreSQL paths, and those do not vary with the PHP minor. The - # matrix above already runs the analysis and the tests on both. + # Every database the CLI installer supports. The build workflow also + # installs on sqlite to produce the nightly zip, but it stops at an HTTP + # check - the seeded data and the updater are only asserted here. + # One PHP version only: what this job proves is the installer's database + # paths, and those do not vary with the PHP minor. The matrix above + # already runs the analysis and the tests on both. include: - db: mysql user: root @@ -111,6 +111,8 @@ jobs: - db: pgsql user: evo password: secret + # A file, so there is no server, no user and no password. + - db: sqlite env: COMPOSE: .github/docker/ci/docker-compose.yml steps: @@ -123,6 +125,7 @@ jobs: # prebuilt. .github/docker/ci/ still describes the full containerised run # for local use. - name: Start ${{ matrix.db }} + if: matrix.db != 'sqlite' run: docker compose -f "$COMPOSE" --profile ${{ matrix.db }} up -d --wait ${{ matrix.db }} # error_log to stderr for the same reason the CI image sets it: the @@ -133,7 +136,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.3' - extensions: ${{ env.PHP_EXTENSIONS }}, pdo_mysql, pdo_pgsql + extensions: ${{ env.PHP_EXTENSIONS }}, pdo_mysql, pdo_pgsql, sqlite3 ini-values: error_reporting=E_ALL, display_errors=On, display_startup_errors=On, log_errors=On, error_log=/dev/stderr coverage: none tools: composer:v2 @@ -154,9 +157,9 @@ jobs: run: .github/docker/ci/install-and-smoke.sh - name: Database server log - if: failure() + if: failure() && matrix.db != 'sqlite' run: docker compose -f "$COMPOSE" --profile ${{ matrix.db }} logs ${{ matrix.db }} - name: Tear down - if: always() + if: always() && matrix.db != 'sqlite' run: docker compose -f "$COMPOSE" --profile ${{ matrix.db }} down -v diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml new file mode 100644 index 0000000000..8924575561 --- /dev/null +++ b/.github/workflows/upgrade.yml @@ -0,0 +1,142 @@ +name: Upgrade + +# Upgrading an older Evolution CMS to this tree is a different check from +# installing this tree: it is the only one that runs the migration chain over a +# schema this branch did not create, which is what every existing site does when +# it moves to a new release. +# +# Not on push or pull_request on purpose. An upgrade regression comes from a +# change to the migrations or the seeders, not from every commit, and each leg +# costs a full install plus a full update. Nightly, plus on demand: +# +# Actions -> Upgrade -> Run workflow +# +# and fill in the versions to go from and to. +on: + schedule: + # After the nightly build, so a failure here is about the tree that was just + # published rather than the one before it. + - cron: '40 3 * * *' + workflow_dispatch: + inputs: + from: + description: 'Upgrade from - one or more git refs of the source repository, comma separated (tag, branch or sha)' + default: '3.5.7' + to: + description: 'Upgrade to - a ref of this repository, or blank for the checked out tree' + default: '' + db: + description: 'Database' + type: choice + default: all + options: [all, mysql, pgsql, sqlite] + from_repo: + description: 'Repository the source versions are taken from' + default: 'evolution-cms/evolution' + +permissions: + contents: read + +concurrency: + group: upgrade-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + matrix: + name: Legs to run + runs-on: ubuntu-latest + outputs: + legs: ${{ steps.build.outputs.legs }} + steps: + # sqlite is the reason the legs are listed as pairs rather than crossed: + # no released version can be installed on it (cli-install.php offered only + # pgsql and mysql through 3.5.7), so its source has to be a ref that + # carries the sqlite installer. Once a release ships it, a tag works here + # like any other. + - name: Build the leg list + id: build + env: + FROM: ${{ inputs.from }} + DB: ${{ inputs.db || 'all' }} + run: | + if [ -z "${FROM:-}" ]; then + # The nightly sweep: the oldest version still worth supporting, the + # last release, and sqlite from the branch that introduced it. + legs='[{"db":"mysql","from":"3.2.7"},{"db":"mysql","from":"3.5.7"},{"db":"pgsql","from":"3.2.7"},{"db":"pgsql","from":"3.5.7"},{"db":"sqlite","from":"nightly-3.5.x"}]' + else + dbs=$([ "$DB" = "all" ] && echo "mysql pgsql sqlite" || echo "$DB") + legs=$( + for db in $dbs; do + echo "$FROM" | tr ',' '\n' | while read -r ref; do + ref=$(echo "$ref" | tr -d '[:space:]') + [ -n "$ref" ] && printf '{"db":"%s","from":"%s"}\n' "$db" "$ref" + done + done | paste -sd, - | sed 's/^/[/; s/$/]/' + ) + fi + echo "legs=$legs" >> "$GITHUB_OUTPUT" + echo "$legs" + + upgrade: + name: ${{ matrix.leg.from }} -> ${{ inputs.to || 'this tree' }} on ${{ matrix.leg.db }} + needs: matrix + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + leg: ${{ fromJSON(needs.matrix.outputs.legs) }} + env: + COMPOSE: .github/docker/ci/docker-compose.yml + steps: + - name: Check out the version being upgraded to + uses: actions/checkout@v7 + with: + ref: ${{ inputs.to }} + path: new + + # Its core/vendor is committed at every tag, so the older tree needs no + # composer step either - see upgrade-and-smoke.sh for how its installer is + # kept from running one anyway. + - name: Check out the version being upgraded from + uses: actions/checkout@v7 + with: + repository: ${{ inputs.from_repo || 'evolution-cms/evolution' }} + ref: ${{ matrix.leg.from }} + path: old + + # Only the database server runs in a container, straight from its official + # image - nothing is built. sqlite is a file and needs no server at all. + - name: Start ${{ matrix.leg.db }} + if: matrix.leg.db != 'sqlite' + run: docker compose -f "new/$COMPOSE" --profile ${{ matrix.leg.db }} up -d --wait ${{ matrix.leg.db }} + + # 8.3 is the lowest version both trees support, so it is the one that can + # run the old installer and the new updater in the same job. + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: ctype, dom, fileinfo, filter, iconv, intl, json, mbstring, openssl, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, readline, simplexml, sqlite3, tokenizer, xml, xmlreader, zip + ini-values: error_reporting=E_ALL, display_errors=On, display_startup_errors=On, log_errors=On, error_log=/dev/stderr + coverage: none + tools: composer:v2 + + - name: Upgrade and smoke test + env: + EVO_NEW_DIR: ${{ github.workspace }}/new + EVO_FROM_DIR: ${{ github.workspace }}/old + EVO_DB_TYPE: ${{ matrix.leg.db }} + EVO_DB_HOST: 127.0.0.1 + EVO_DB_USER: ${{ matrix.leg.db == 'pgsql' && 'evo' || 'root' }} + EVO_DB_PASSWORD: secret + EVO_DB_NAME: evolution + EVO_DB_PREFIX: evo_ + run: new/.github/docker/ci/upgrade-and-smoke.sh + + - name: Database server log + if: failure() && matrix.leg.db != 'sqlite' + run: docker compose -f "new/$COMPOSE" --profile ${{ matrix.leg.db }} logs ${{ matrix.leg.db }} + + - name: Tear down + if: always() && matrix.leg.db != 'sqlite' + run: docker compose -f "new/$COMPOSE" --profile ${{ matrix.leg.db }} down -v