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..03217e38d6 --- /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.3 +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..d789473b43 --- /dev/null +++ b/.github/docker/ci/README.md @@ -0,0 +1,128 @@ +# Install and upgrade checks + +Two scripts, one set of assertions: + +- `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 \ + --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. + +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. +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, 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 + +`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 --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 + 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. + +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/docker-compose.yml b/.github/docker/ci/docker-compose.yml new file mode 100644 index 0000000000..4af612dca3 --- /dev/null +++ b/.github/docker/ci/docker-compose.yml @@ -0,0 +1,88 @@ +# 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} + # 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 + # 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} + ports: + - "${POSTGRES_PORT:-5432}:5432" + 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.3} + 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.3} + 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 100755 index 0000000000..a9ff874965 --- /dev/null +++ b/.github/docker/ci/install-and-smoke.sh @@ -0,0 +1,101 @@ +#!/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) 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} + +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} +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 + +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 +# 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" +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 new file mode 100644 index 0000000000..a65b353208 --- /dev/null +++ b/.github/docker/ci/smoke.php @@ -0,0 +1,368 @@ + '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)); +} + +/** Null instead of an error for a table the schema in front of us lacks. */ +function count_rows_or_null(string $table): ?int +{ + try { + return count_rows($table); + } catch (PDOException $e) { + return null; + } +} + +/** + * The tables whose row counts are compared between two runs. Every one of them + * is filled by a seeder, so a seeder that inserts where it should update shows + * up here - and on an upgrade, so does one that deletes what it should keep. + */ +function counted_tables(): array +{ + return [ + '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', + ]; +} + +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 (' . $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. +$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; +// 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() ?: []; +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('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' . 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('emailsender is the admin email', setting('emailsender') === $adminEmail, 'got: ' . var_export(setting('emailsender'), true)); +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 = ?'); +$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; +// 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); +} + +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) ?: []; + // 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 (!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; + } + } + + 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; +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/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 5677867ce6..d7113c89f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,3 +91,75 @@ jobs: - name: Run Pest working-directory: core run: composer test -- --compact + + install: + name: Install on ${{ matrix.db }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 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 + password: secret + - 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: + - uses: actions/checkout@v7 + + # 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 }} + 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 + # 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, 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 + + # 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 + 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() && matrix.db != 'sqlite' + run: docker compose -f "$COMPOSE" --profile ${{ matrix.db }} logs ${{ matrix.db }} + + - name: Tear down + 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 diff --git a/.gitignore b/.gitignore index 5c330741a0..eba71044fc 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ Desktop.ini **/.* !.gitignore !/.github +!/.dockerignore