From 459b2719ca265d35e2c7dd5f9362456417856bf4 Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 07:44:46 +0200 Subject: [PATCH 1/8] Reports maintenance tool failures on the command line Maintenance::exit() renders the tool's templates, and those are the only place errors are ever shown. On the command line it takes the fallthrough path instead and goes straight to die(), so nothing was reported and the exit status was always 0: a scripted install that died on step three looked exactly like one that had finished. ToolsBase::updateSettingsFile() made the same assumption more directly, calling die() outright when Settings.php could not be written rather than recording the error the way the web path does. Writes the warnings and errors to stderr and exits non-zero when the tool actually failed. A step that merely wants input it was not given sets neither, so pausing part way through is still a success - the installer is meant to be called more than once - and that case now says which step it stopped on instead of nothing at all. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- Sources/Maintenance/Maintenance.php | 56 +++++++++++++++++++++++++ Sources/Maintenance/Tools/ToolsBase.php | 9 ++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/Sources/Maintenance/Maintenance.php b/Sources/Maintenance/Maintenance.php index 8809ee6f5fb..1b2a1b2c04f 100644 --- a/Sources/Maintenance/Maintenance.php +++ b/Sources/Maintenance/Maintenance.php @@ -808,6 +808,46 @@ public static function setQueryString(): string */ public static function exit(bool $fallthrough = false): void { + // On the command line there is no template to render, so everything the + // tool wanted to tell us has nowhere to go: a scripted install that died + // on step three looks exactly like one that finished. Put the problems on + // stderr and leave a non-zero status behind instead. + // + // A step that simply needs more input sets neither of these, so pausing + // part way through is still a success -- the installer is meant to be + // called more than once. + if ($fallthrough && Sapi::isCLI()) { + foreach (self::$warnings as $warning) { + fwrite(STDERR, 'warning: ' . self::plainText($warning) . "\n"); + } + + $problems = self::$errors; + + if (self::$fatal_error !== '') { + array_unshift($problems, self::$fatal_error); + } + + if ($problems !== []) { + foreach ($problems as $problem) { + fwrite(STDERR, 'error: ' . self::plainText($problem) . "\n"); + } + + exit(1); + } + + // Nothing went wrong, but we are not finished either: a step wanted + // input it was not given. Say which one, so a script that has to be + // run more than once can tell where it got to. + if (isset(self::$tool) && self::getCurrentStep() <= \count(self::$tool->getSteps())) { + fwrite( + STDERR, + 'stopped at step ' . self::getCurrentStep() + . ' of ' . \count(self::$tool->getSteps()) + . ' (' . (self::$tool->getSteps()[self::getCurrentStep()]?->getName() ?? 'unknown') . ")\n", + ); + } + } + // We usually dump our templates out. if (!$fallthrough) { // Send character set. @@ -920,4 +960,20 @@ private static function setCurrentStep(?int $step = null): void { $_GET['step'] = $step ?? (self::getCurrentStep() + 1); } + + /** + * Flattens one of our messages into something worth reading in a terminal. + * + * The steps build these for a browser, so they arrive carrying markup: the + * database errors in particular wrap the driver's own message in a div. + * + * @param string $message The message, as the step wrote it. + * @return string The same message, without the markup. + */ + private static function plainText(string $message): string + { + $message = preg_replace('~~i', "\n", $message) ?? $message; + + return trim(html_entity_decode(strip_tags($message), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } } diff --git a/Sources/Maintenance/Tools/ToolsBase.php b/Sources/Maintenance/Tools/ToolsBase.php index 7193b974371..d8c0a0be4d4 100644 --- a/Sources/Maintenance/Tools/ToolsBase.php +++ b/Sources/Maintenance/Tools/ToolsBase.php @@ -612,10 +612,11 @@ public function updateSettingsFile(array $config_vars, ?bool $keep_quotes = null if (!Config::updateSettingsFile($config_vars, $keep_quotes, $rebuild)) { $this->logProgress(Lang::getTxt('log_failed_with_error', ['error' => Lang::getTxt('settings_error', file: 'Maintenance')], file: 'Maintenance')); - if (Sapi::isCLI()) { - die(); - } - + // This used to die() outright on the command line, which reported + // nothing and exited 0 -- a scripted install that could not write + // Settings.php looked exactly like one that worked. Recording the + // error and returning stops the run just as firmly, and now the + // caller gets to say why. Maintenance::$fatal_error = Lang::getTxt('settings_error', file: 'Maintenance'); return false; From 11fe20d2959d6d95e8b09a6f94f1dcc97b45458a Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 07:44:59 +0200 Subject: [PATCH 2/8] Stops the installer assuming there is a web request Two things in the installer only hold when a browser is on the other end, and both are reached before the forum exists, so neither could be worked around from outside. defaultHost() reads $_SERVER['SERVER_NAME'] and ['SERVER_PORT'] whenever HTTP_HOST is absent. On the command line none of the three is set, so every run began with an undefined index warning. Falls back to localhost: the value only seeds the suggested board URL on the form, and a scripted install passes its own boardurl in. forumSettings() then built the same suggestion with substr($self, 0, strrpos($self, '/')). getSelf() is $_SERVER['PHP_SELF'], which in a request is a rooted path but on the command line is whatever was typed - usually a bare 'install.php' with no directory in it. strrpos() returns false, and substr() with a false length is fatal on PHP 8, so the installer died here on every CLI run. While in there: an unrecognised database type reported Lang::getTxt('upgrade_unknown_error'), which is not a string that exists. The fatal error was therefore blank in the browser too. Names the type that was rejected and the ones that would have been accepted, which matters most on the command line where the type is typed by hand rather than picked from a list of exactly those keys. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- Languages/en_US/Maintenance.php | 1 + Sources/Maintenance/Tools/Install.php | 38 ++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/Languages/en_US/Maintenance.php b/Languages/en_US/Maintenance.php index 3f3f4ee4477..21dceeb9f5b 100644 --- a/Languages/en_US/Maintenance.php +++ b/Languages/en_US/Maintenance.php @@ -124,6 +124,7 @@ It is recommended that you visit the Simple Machines website to ensure you are installing the latest version.'; $txt['error_already_installed'] = 'The installer has detected that you already have SMF installed. It is strongly advised that you do not try to overwrite an existing installation, continuing with installation may result in the loss or corruption of existing data.

If you wish to upgrade please visit the Simple Machines Website and download the latest upgrade package.

If you wish to overwrite your existing installation, including all data, it is recommended that you delete the existing database tables and replace Settings.php and try again.'; $txt['error_db_missing'] = 'The installer was unable to detect any database support in PHP. Please ask your host to ensure that PHP was compiled with the desired database, or that the proper extension is being loaded.'; +$txt['error_db_type_unknown'] = '“{db_type}” is not a database type this server supports. Supported types: {supported}.'; $txt['error_session_missing'] = 'The installer was unable to detect sessions support in your server’s installation of PHP. Please ask your host to ensure that PHP was compiled with session support (which in fact is the PHP default, meaning your host currently has explicitly disabled it).'; $txt['error_missing_files'] = 'Unable to find crucial installation files in the directory of this script!

Please make sure you uploaded the entire installation package, including the sql file, and then try again.'; $txt['error_session_save_path'] = 'Please inform your host that the session.save_path specified in php.ini is not valid! It needs to be changed to a directory that exists and is writable by the user PHP is running under.
'; diff --git a/Sources/Maintenance/Tools/Install.php b/Sources/Maintenance/Tools/Install.php index 51c1879883a..3a0a526b2a1 100644 --- a/Sources/Maintenance/Tools/Install.php +++ b/Sources/Maintenance/Tools/Install.php @@ -439,7 +439,19 @@ public function databaseSettings(): bool $db_prefix = $_POST['db_prefix']; if (!isset(Maintenance::$context['databases'][$db_type])) { - Maintenance::$fatal_error = Lang::getTxt('upgrade_unknown_error', file: 'Maintenance'); + // upgrade_unknown_error, which used to be reported here, does not + // exist -- so this produced an empty fatal error and left no clue + // what had gone wrong. Naming the type and the alternatives matters + // most on the command line, where the type is typed out by hand + // rather than picked from a list of exactly these keys. + Maintenance::$fatal_error = Lang::getTxt( + 'error_db_type_unknown', + [ + 'db_type' => $db_type, + 'supported' => Lang::sentenceList(array_keys(Maintenance::$context['databases'])), + ], + file: 'Maintenance', + ); $this->logProgress(Maintenance::$fatal_error); return false; @@ -609,7 +621,15 @@ public function forumSettings(): bool Db::load(); // Now, to put what we've learned together... and add a path. - Maintenance::$context['detected_url'] = 'http' . (Sapi::httpsOn() ? 's' : '') . '://' . $this->defaultHost() . substr(Maintenance::getSelf(), 0, strrpos(Maintenance::getSelf(), '/')); + // getSelf() is $_SERVER['PHP_SELF'], which in a request is a rooted path + // but on the command line is whatever was typed -- usually a bare + // 'install.php' with no directory in it at all. strrpos() then returns + // false, and substr() with a false length is fatal on PHP 8, so the + // installer died here on every CLI run. + $self = Maintenance::getSelf(); + $last_slash = strrpos($self, '/'); + + Maintenance::$context['detected_url'] = 'http' . (Sapi::httpsOn() ? 's' : '') . '://' . $this->defaultHost() . ($last_slash === false ? '' : substr($self, 0, $last_slash)); // Check if the database sessions will even work. Maintenance::$context['test_dbsession'] = (\ini_get('session.auto_start') != 1); @@ -1416,7 +1436,19 @@ private function saveProgress(): bool */ private function defaultHost(): string { - return empty($_SERVER['HTTP_HOST']) ? $_SERVER['SERVER_NAME'] . (empty($_SERVER['SERVER_PORT']) || $_SERVER['SERVER_PORT'] == '80' ? '' : ':' . $_SERVER['SERVER_PORT']) : $_SERVER['HTTP_HOST']; + if (!empty($_SERVER['HTTP_HOST'])) { + return $_SERVER['HTTP_HOST']; + } + + // On the command line there is no request to describe, so neither of + // these is set. This value only seeds the suggested board URL on the + // form, and a scripted install passes its own boardurl in, so a + // placeholder is enough -- but reading the keys unguarded was a warning + // on every CLI run. + $host = $_SERVER['SERVER_NAME'] ?? 'localhost'; + $port = $_SERVER['SERVER_PORT'] ?? ''; + + return $host . (empty($port) || $port == '80' ? '' : ':' . $port); } /** From 7477a6c37ee14b72f68f1747bb4927f450cc560e Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 07:57:51 +0200 Subject: [PATCH 3/8] Skips the browser sign-in when installing from the command line finalize() ends by signing the new administrator in, so the browser that just ran the installer lands on an admin session instead of a login form. It sets a login cookie, then records the session against the user agent that asked for it. None of that has any meaning on the command line. There is no browser to hold the cookie and no user agent to key the session on, so every CLI install ended with four warnings - headers sent after output had already started, a session that could not be started, and an id that could not be regenerated - and then wrote a sessions row built from an undefined HTTP_USER_AGENT. Runs the whole block only when there is a request behind it. The stats that follow it are untouched, so an install still records latestMember, totalMessages and totalTopics either way. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- Sources/Maintenance/Tools/Install.php | 82 +++++++++++++++------------ 1 file changed, 46 insertions(+), 36 deletions(-) diff --git a/Sources/Maintenance/Tools/Install.php b/Sources/Maintenance/Tools/Install.php index a0c2d284a53..73d9d286228 100644 --- a/Sources/Maintenance/Tools/Install.php +++ b/Sources/Maintenance/Tools/Install.php @@ -1202,48 +1202,58 @@ public function finalize(): bool Db::$db->free_result($request); } - // Automatically log them in ;) - if (isset(Maintenance::$context['id_member'], Maintenance::$context['password_salt'])) { - Cookie::setLoginCookie(3153600 * 60, Maintenance::$context['id_member'], Cookie::encrypt($_POST['password1'], Maintenance::$context['password_salt'])); - } + // Sign the new administrator in, so the browser that just ran the + // installer lands on an admin session rather than a login form. + // + // None of that means anything on the command line: there is no browser + // to hold the cookie, and no user agent to record against the session. + // Attempting it anyway sent headers after output had already started and + // left four warnings on every run, then wrote a session row keyed on an + // undefined HTTP_USER_AGENT. + if (!Sapi::isCLI()) { + // Automatically log them in ;) + if (isset(Maintenance::$context['id_member'], Maintenance::$context['password_salt'])) { + Cookie::setLoginCookie(3153600 * 60, Maintenance::$context['id_member'], Cookie::encrypt($_POST['password1'], Maintenance::$context['password_salt'])); + } - $result = Db::$db->query( - 'SELECT value - FROM {db_prefix}settings - WHERE variable = {string:db_sessions}', - [ - 'db_sessions' => 'databaseSession_enable', - 'db_error_skip' => true, - ], - ); + $result = Db::$db->query( + 'SELECT value + FROM {db_prefix}settings + WHERE variable = {string:db_sessions}', + [ + 'db_sessions' => 'databaseSession_enable', + 'db_error_skip' => true, + ], + ); - if (Db::$db->num_rows($result) != 0) { - list($db_sessions) = Db::$db->fetch_row($result); - } - Db::$db->free_result($result); + if (Db::$db->num_rows($result) != 0) { + list($db_sessions) = Db::$db->fetch_row($result); + } + Db::$db->free_result($result); - if (empty($db_sessions)) { - $_SESSION['admin_time'] = time(); - } else { - $_SERVER['HTTP_USER_AGENT'] = substr($_SERVER['HTTP_USER_AGENT'], 0, 211); + if (empty($db_sessions)) { + $_SESSION['admin_time'] = time(); + } else { + $_SERVER['HTTP_USER_AGENT'] = substr($_SERVER['HTTP_USER_AGENT'], 0, 211); - Db::$db->insert( - 'replace', - '{db_prefix}sessions', - [ - 'session_id' => 'string', - 'last_update' => 'int', - 'data' => 'string', - ], - [ + Db::$db->insert( + 'replace', + '{db_prefix}sessions', [ - session_id(), - time(), - 'USER_AGENT|s:' . \strlen($_SERVER['HTTP_USER_AGENT']) . ':"' . $_SERVER['HTTP_USER_AGENT'] . '";admin_time|i:' . time() . ';', + 'session_id' => 'string', + 'last_update' => 'int', + 'data' => 'string', ], - ], - ['session_id'], - ); + [ + [ + session_id(), + time(), + 'USER_AGENT|s:' . \strlen($_SERVER['HTTP_USER_AGENT']) . ':"' . $_SERVER['HTTP_USER_AGENT'] . '";admin_time|i:' . time() . ';', + ], + ], + ['session_id'], + ); + } } Logging::updateStats('member'); From 89324b1d038f9238c8323bca5d78cdd119b20b58 Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 07:57:51 +0200 Subject: [PATCH 4/8] Reports the step a maintenance tool actually paused on Two things were wrong with the note the command line prints when a tool stops part way. It indexed the step list to get the number, which counts from zero, while every other line of output uses the step's own id, which counts from one - so it disagreed with the "Step 3: Database Settings" lines immediately above it. It also fired on a successful run. Tools deliberately return false from their last step so the web flow stops and renders its "all done" template, which means reaching that step is success rather than a pause, and a completed install claimed to have stopped at it. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- Sources/Maintenance/Maintenance.php | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/Sources/Maintenance/Maintenance.php b/Sources/Maintenance/Maintenance.php index 1b2a1b2c04f..8c47056f45d 100644 --- a/Sources/Maintenance/Maintenance.php +++ b/Sources/Maintenance/Maintenance.php @@ -835,15 +835,25 @@ public static function exit(bool $fallthrough = false): void exit(1); } - // Nothing went wrong, but we are not finished either: a step wanted - // input it was not given. Say which one, so a script that has to be - // run more than once can tell where it got to. - if (isset(self::$tool) && self::getCurrentStep() <= \count(self::$tool->getSteps())) { + // Nothing went wrong, but we may not be finished either: a step can + // stop because it wanted input it was not given. Say which one, so + // a script that has to be run more than once can tell where it got + // to. The step numbers its own id from one, which is what every + // other line of output uses. + // + // The last step is excluded on purpose. Tools end by returning false + // from it so that the web flow stops and renders its "all done" + // template, which means reaching it is success, not a pause. + $steps = isset(self::$tool) ? self::$tool->getSteps() : []; + + if (isset($steps[self::getCurrentStep()]) && self::getCurrentStep() < \count($steps) - 1) { + $stopped = $steps[self::getCurrentStep()]; + fwrite( STDERR, - 'stopped at step ' . self::getCurrentStep() - . ' of ' . \count(self::$tool->getSteps()) - . ' (' . (self::$tool->getSteps()[self::getCurrentStep()]?->getName() ?? 'unknown') . ")\n", + 'stopped at step ' . $stopped->getId() + . ' of ' . \count($steps) + . ' (' . $stopped->getName() . ")\n", ); } } From 7665045176b5aa7987dc16480f031b64398891cf Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 07:58:06 +0200 Subject: [PATCH 5/8] Installs the forum from the command line The dev environment stopped at a Settings.php and a staged install.php, leaving the actual install to a human clicking through a browser. That is the one step between a fresh clone and a running forum that could not be scripted, and everything that wants to test against a real install has to start by doing it. Adds four scripts under .docker/: install-forum.sh installs a forum, no browser involved use-engine.sh switches which installed forum is live reset.sh empties one engine's database and restages lib.sh shared settings and engine name normalisation The installer is already CLI-native - parseCliArguments() turns --name=value into $_POST and execute() runs every step in one process - so this is two passes rather than 2.1's five curl requests. The second pass carries pop_done, which is the short-circuit past the population report; passing it on the first pass would skip building the schema. --engine both installs MySQL and then PostgreSQL. It has to be sequential: Settings.php pins a single db_type and Db::load() hands back the connection it already made, so only one engine is ever live in a process. Both installs are kept, and use-engine.sh swaps between them by putting the saved Settings.php back - no restart, because the entrypoint only writes one when there is not one already. --pin-secrets fixes auth_secret and image_proxy_secret, which are generated with random_bytes() and stored nowhere but Settings.php. Without it the two installs differ by more than their database and a login cookie does not survive the switch. The cookie name needs no such help: createCookieName() is a crc32 of the database name and prefix. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- .docker/README.md | 55 +++++++++++- .docker/install-forum.sh | 187 +++++++++++++++++++++++++++++++++++++++ .docker/lib.sh | 123 +++++++++++++++++++++++++ .docker/reset.sh | 99 +++++++++++++++++++++ .docker/use-engine.sh | 48 ++++++++++ .gitignore | 4 + 6 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 .docker/install-forum.sh create mode 100644 .docker/lib.sh create mode 100644 .docker/reset.sh create mode 100644 .docker/use-engine.sh diff --git a/.docker/README.md b/.docker/README.md index 2ba2f277fb3..111bef70986 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -53,6 +53,57 @@ forum. ## Installing the forum +```sh +.docker/install-forum.sh --engine mysql +.docker/install-forum.sh --engine postgresql +.docker/install-forum.sh --engine both +``` + +That resets the engine's database and installs a forum into it, with no browser +involved. It takes about a minute. Log in at http://localhost:8080 as +`admin` / `password`. + +SMF 3.0's installer is CLI-native: `Maintenance::parseCliArguments()` turns +`--name=value` into `$_POST`, and `Maintenance::execute()` then runs every step +in one process, stopping at the first that still needs input. The script makes +two passes, because `databasePopulation()` always stops the first time even +though it succeeded — it pauses so a human can read its "N duplicate tables +ignored" report, and the form's `pop_done` field is the short-circuit past it. +Passing `pop_done` on the first pass would skip building the schema entirely. + +Two flags worth knowing: + +- `--force` reinstalls even when a forum is already there. Without it the + script leaves an existing install alone. +- `--pin-secrets` fixes `auth_secret` and `image_proxy_secret` to known values + instead of the random ones `ForumSettings()` generates. Both installs then + differ only in their database, so a login cookie survives `use-engine.sh`. + Dev-only values for a throwaway forum: never reuse them. + +### Two forums at once + +`--engine both` installs MySQL first and PostgreSQL second, one after the other. +It has to be sequential: `Settings.php` pins a single `$db_type`, and +`Db::load()` hands back the connection it already made, so only one engine can +ever be live in a process. + +Both installs are kept. Switch between them with: + +```sh +.docker/use-engine.sh postgresql +``` + +That puts the saved `Settings.php` back and clears `cache/`. No restart is +needed — the entrypoint only writes `Settings.php` when there is not one, so it +leaves whatever is in place alone. The copies live in `.docker/settings/` and +are gitignored. + +`reset.sh` is the other half: it empties one engine's database and restages the +installer, discarding that forum. `use-engine.sh` switches between forums, +`reset.sh` throws one away. + +### Installing in a browser instead + On first boot the entrypoint writes a `Settings.php` pre-filled for the chosen engine and copies `other/install.php` to the web root, so http://localhost:8080 redirects into the installer. @@ -102,8 +153,8 @@ The repository is bind-mounted at `/var/www/html`, so edits on the host are live on the next request. Opcache is on but revalidates every request, so you never need to restart for a PHP change. -To reinstall from scratch: `docker compose down -v`, delete `Settings.php` and -`Settings_bak.php`, then `docker compose up -d`. +To reinstall from scratch: `.docker/install-forum.sh --engine mysql --force`. +To wipe everything including the volumes: `docker compose down -v`. ## Debugging SQL with the PostgreSQL log diff --git a/.docker/install-forum.sh b/.docker/install-forum.sh new file mode 100644 index 00000000000..613b604c90a --- /dev/null +++ b/.docker/install-forum.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Installs the forum without a browser. +# +# .docker/install-forum.sh --engine mysql +# .docker/install-forum.sh --engine postgresql +# .docker/install-forum.sh --engine both +# +# SMF 3.0's installer is CLI-native: Maintenance::parseCliArguments() turns +# --name=value into $_POST, and Maintenance::execute() then runs every step in +# one process, stopping at the first that still needs input. So unlike 2.1, +# which needs a five-request curl driver, this is two invocations: +# +# pass 1 Welcome -> Writable -> Database settings -> Forum settings +# -> Database population, which builds the schema and then stops +# pass 2 the same again, plus --pop_done, which walks straight past the +# population report into the admin account and finalise +# +# databasePopulation() always stops the first time even though it succeeded: it +# pauses so a human can read its "N duplicate tables ignored" report, and the +# form's pop_done field is the short-circuit that skips it. Passing pop_done on +# pass 1 would skip building the schema altogether, which is why this is two +# passes and not one. +# +# Every step re-runs on pass 2. They are all idempotent given the same input -- +# the settings steps rewrite the same values, and adminAccount() stops if an +# administrator already exists. +# +# Runs on the host. +set -euo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +ENGINE='' +PIN_SECRETS=0 +FORCE=0 + +while [ $# -gt 0 ]; do + case "$1" in + --engine) ENGINE="$2"; shift 2 ;; + --engine=*) ENGINE="${1#*=}"; shift ;; + --pin-secrets) PIN_SECRETS=1; shift ;; + --force) FORCE=1; shift ;; + -h|--help) sed -n '2,27p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +[ -n "$ENGINE" ] || die 'need --engine mysql|postgresql|both' +ENGINES=$(engine_list "$ENGINE") || die "unknown engine: $ENGINE" + +cd "$BOARD_DIR" + +# The installer's own name for each engine, which is the key of the array it +# builds from the drivers it found. These are capitalised, and a lowercase +# db_type is rejected outright -- so they are spelled exactly as the installer +# spells them rather than reusing the SMF type. +installer_db_type() { + case "$1" in + mysql) echo 'MySQL' ;; + postgresql) echo 'PostgreSQL' ;; + *) return 1 ;; + esac +} + +install_one() { + local smf_type="$1" db_type server port args + + db_type=$(installer_db_type "$smf_type") + server=$(engine_server "$smf_type") + port=$(engine_port "$smf_type") + + if [ "$FORCE" -eq 0 ] && [ -n "$(installed_version "$smf_type" || true)" ]; then + log "${smf_type}: already installed (SMF $(installed_version "$smf_type")), nothing to do" + + return 0 + fi + + log "${smf_type}: resetting" + "$DOCKER_DIR/reset.sh" --engine "$smf_type" >/dev/null + + args=( + --contbutt=1 + --db_type="$db_type" + --db_server="$server" + --db_port="$port" + --db_name="$DB_NAME" + --db_user="$DB_USER" + --db_passwd="$DB_PASSWORD" + --db_prefix="$DB_PREFIX" + --boardurl="$SMF_BOARDURL" + --mbname="$SMF_MBNAME" + --username="$SMF_ADMIN_USER" + --email="$SMF_ADMIN_EMAIL" + --server_email="$SMF_ADMIN_EMAIL" + --password1="$SMF_ADMIN_PASS" + --password2="$SMF_ADMIN_PASS" + ) + + log "${smf_type}: building the schema" + docker compose exec -T web php install.php "${args[@]}" >/dev/null + + log "${smf_type}: creating the administrator and finalising" + docker compose exec -T web php install.php "${args[@]}" --pop_done=1 >/dev/null + + local version + version=$(installed_version "$smf_type" || true) + + [ -n "$version" ] || die "${smf_type}: the installer finished but the forum is not installed" + + log "${smf_type}: installed SMF ${version}" + + if [ "$PIN_SECRETS" -eq 1 ]; then + pin_secrets + fi + + save_settings "$smf_type" +} + +# ForumSettings() generates auth_secret and image_proxy_secret with +# random_bytes() and stores them nowhere but Settings.php, so the two engines +# end up with different ones and a login cookie stops being valid the moment +# use-engine.sh switches. Pinning them leaves the database as the only thing +# that differs between the two installs. +# +# The cookie name needs no such help: createCookieName() is a crc32 of the +# database name and prefix, which are the same on both. +# +# Dev-only values for a throwaway forum, published here deliberately. Never +# reuse them anywhere real. +pin_secrets() { + log 'pinning auth_secret and image_proxy_secret' + + # The values have to be handed over with -e. Exporting them on the host does + # nothing: docker compose exec starts a fresh environment, so getenv() came + # back empty and this wrote two empty secrets over the generated ones. + docker compose exec -T \ + -e PIN_AUTH_SECRET="$PIN_AUTH_SECRET" \ + -e PIN_IMAGE_PROXY_SECRET="$PIN_IMAGE_PROXY_SECRET" \ + web php -r ' + define("SMF", 1); + define("SMF_SETTINGS_FILE", "/var/www/html/Settings.php"); + define("SMF_SETTINGS_BACKUP_FILE", "/var/www/html/Settings_bak.php"); + require_once "/var/www/html/index.php"; + + $auth = (string) getenv("PIN_AUTH_SECRET"); + $proxy = (string) getenv("PIN_IMAGE_PROXY_SECRET"); + + if ($auth === "" || $proxy === "") { + fwrite(STDERR, "pin-secrets: the secrets did not reach the container\n"); + exit(1); + } + + exit(SMF\Config::updateSettingsFile([ + "auth_secret" => $auth, + "image_proxy_secret" => $proxy, + ]) ? 0 : 1); + ' >/dev/null +} + +# Keep each engine's Settings.php so use-engine.sh can put it back without a +# reinstall. Gitignored: generated secrets and a machine-specific board URL. +save_settings() { + local smf_type="$1" + + mkdir -p "$SETTINGS_DIR" + cp Settings.php "$SETTINGS_DIR/Settings.${smf_type}.php" + cp Settings_bak.php "$SETTINGS_DIR/Settings_bak.${smf_type}.php" + + log "${smf_type}: settings saved to .docker/settings/" +} + +PIN_AUTH_SECRET="${PIN_AUTH_SECRET:-0b6e5f3c1a94d27e8f5b0c3a76d1e94f2b8c5a03e7d146f9b2c8a501d3e7f4c69}" +PIN_IMAGE_PROXY_SECRET="${PIN_IMAGE_PROXY_SECRET:-7f2a9c4e0b6d18a35c92}" + +# Sequential on purpose. Settings.php pins one $db_type and Db::load() returns +# the connection it already made, so only one engine can be live at a time -- +# "both" is a chain, never two connections. +for smf_type in $ENGINES; do + install_one "$smf_type" +done + +# Leave the first engine of a "both" run active rather than whichever happened +# to go last, so the result does not depend on the order. +FIRST_ENGINE="${ENGINES%% *}" +"$DOCKER_DIR/use-engine.sh" "$FIRST_ENGINE" >/dev/null + +log "active engine: ${FIRST_ENGINE} -- ${SMF_BOARDURL} (${SMF_ADMIN_USER} / ${SMF_ADMIN_PASS})" diff --git a/.docker/lib.sh b/.docker/lib.sh new file mode 100644 index 00000000000..5c13d410080 --- /dev/null +++ b/.docker/lib.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Shared settings and helpers for the .docker scripts. Sourced, never run. +# +# Host-side scripts (reset.sh, install-forum.sh, use-engine.sh) source this from +# wherever the caller happens to be standing; everything below resolves paths +# for itself rather than assuming a working directory. +# +# Everything defined here is consumed by the scripts that source this file, and +# a linter reading it on its own cannot see any of those uses -- hence the +# blanket disable below. Keep it on its own, with nothing after it that starts +# with the linter's name, or the following line gets parsed as a directive too. +# +# shellcheck disable=SC2034 + +# Git Bash on Windows rewrites anything that looks like a Unix path before +# handing it to a program, so a container-side path like /var/www/html/... is +# silently turned into C:/Program Files/Git/var/www/html/... and the command +# fails with "Could not open input file". These two switch that off. They mean +# nothing on Linux and macOS. +export MSYS_NO_PATHCONV=1 +export MSYS2_ARG_CONV_EXCL='*' + +# Repository root, regardless of where the caller was standing. +DOCKER_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +BOARD_DIR=$(cd -- "$DOCKER_DIR/.." && pwd) + +# Where use-engine.sh keeps each engine's Settings.php. Gitignored: these hold +# generated secrets and a machine-specific board URL. +SETTINGS_DIR="$DOCKER_DIR/settings" + +# ---------------------------------------------------------------- credentials +# These match compose.yaml's defaults. Override them in the environment if you +# changed them in .env. +DB_NAME="${DB_NAME:-smf}" +DB_USER="${DB_USER:-smf}" +DB_PASSWORD="${DB_PASSWORD:-smf}" +DB_ROOT_PASSWORD="${DB_ROOT_PASSWORD:-smf}" +DB_PREFIX="${DB_PREFIX:-smf_}" + +WEB_PORT="${WEB_PORT:-8080}" +SMF_BOARDURL="${SMF_BOARDURL:-http://localhost:${WEB_PORT}}" +SMF_MBNAME="${SMF_MBNAME:-SMF Dev}" + +# The administrator the installer creates. Dev-only values for a throwaway +# forum; never reuse them anywhere real. +SMF_ADMIN_USER="${SMF_ADMIN_USER:-admin}" +SMF_ADMIN_PASS="${SMF_ADMIN_PASS:-password}" +# example.com is reserved by RFC 2606, so this can never reach a real inbox. +# SMF's validator rejects dotless domains, so 'admin@localhost' is not an option. +SMF_ADMIN_EMAIL="${SMF_ADMIN_EMAIL:-admin@example.com}" + +# --------------------------------------------------------------------- output +log() { printf '[smf-dev] %s\n' "$*"; } +warn() { printf '[smf-dev] %s\n' "$*" >&2; } +die() { printf '[smf-dev] error: %s\n' "$*" >&2; exit 1; } + +# Engine name normalisation. Everything downstream uses either the SMF type +# ('mysql' / 'postgresql') or the compose service name ('mysql' / 'postgres'), +# and mixing them up is an easy way to waste an afternoon. +engine_smf_type() { + case "$1" in + mysql|mysqli|mariadb) echo 'mysql' ;; + postgres|postgresql|pgsql) echo 'postgresql' ;; + *) return 1 ;; + esac +} + +engine_service() { + case "$1" in + mysql|mysqli|mariadb) echo 'mysql' ;; + postgres|postgresql|pgsql) echo 'postgres' ;; + *) return 1 ;; + esac +} + +# Container-internal host and port for an engine. Not the host-side ports in +# compose.yaml: these are what Settings.php has to contain. +engine_server() { + case "$(engine_smf_type "$1")" in + mysql) echo "${SMF_MYSQL_SERVER:-mysql}" ;; + postgresql) echo "${SMF_POSTGRES_SERVER:-postgres}" ;; + *) return 1 ;; + esac +} + +engine_port() { + case "$(engine_smf_type "$1")" in + mysql) echo "${SMF_MYSQL_PORT:-3306}" ;; + postgresql) echo "${SMF_POSTGRES_PORT:-5432}" ;; + *) return 1 ;; + esac +} + +# Expands "both" into the engines to act on, in the order they run. Only one +# engine can be live at a time -- Settings.php pins $db_type and Db::load() +# early-returns once the connection exists -- so "both" is a sequential chain, +# never two connections. +engine_list() { + case "$1" in + both|all) echo 'mysql postgresql' ;; + *) engine_smf_type "$1" ;; + esac +} + +# The installed version for one engine, empty if the forum is not installed. +# Asks the database directly rather than trusting the presence of a file: +# Settings.php exists from the moment the entrypoint writes it, long before +# there is a forum behind it. +installed_version() { + local engine service + engine=$(engine_smf_type "$1") || return 1 + service=$(engine_service "$1") + + if [ "$engine" = 'mysql' ]; then + docker compose exec -T -e MYSQL_PWD="$DB_PASSWORD" "$service" \ + mysql -u"$DB_USER" -D "$DB_NAME" -N -B -e \ + "SELECT value FROM ${DB_PREFIX}settings WHERE variable = 'smfVersion';" 2>/dev/null + else + docker compose exec -T "$service" \ + psql -U "$DB_USER" -d "$DB_NAME" -tAX -c \ + "SELECT value FROM ${DB_PREFIX}settings WHERE variable = 'smfVersion';" 2>/dev/null + fi +} diff --git a/.docker/reset.sh b/.docker/reset.sh new file mode 100644 index 00000000000..00f2de5a404 --- /dev/null +++ b/.docker/reset.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Returns the stack to "installable": no forum, an empty database for the chosen +# engine, and a Settings.php regenerated for it. +# +# .docker/reset.sh --engine mysql +# .docker/reset.sh --engine postgresql +# +# This is also how you move an install between engines. Settings.php pins one +# engine and wins over SMF_DB_TYPE, so switching means throwing it away and +# letting the entrypoint write a new one. To keep an install rather than +# discard it, use use-engine.sh instead. +# +# Only the chosen engine's database is touched. The two engines keep separate +# volumes, so a MySQL reset can never disturb a PostgreSQL install or vice +# versa. +# +# Runs on the host. +set -euo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +ENGINE='' +KEEP_FILES=0 + +while [ $# -gt 0 ]; do + case "$1" in + --engine) ENGINE="$2"; shift 2 ;; + --engine=*) ENGINE="${1#*=}"; shift ;; + --keep-files) KEEP_FILES=1; shift ;; + -h|--help) sed -n '2,17p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +[ -n "$ENGINE" ] || die 'need --engine mysql|postgresql' +SERVICE=$(engine_service "$ENGINE") || die "unknown engine: $ENGINE" +SMF_TYPE=$(engine_smf_type "$ENGINE") + +cd "$BOARD_DIR" + +log "resetting for ${SMF_TYPE}" + +# ------------------------------------------------------------------ the forum +# Stop the web container first: Apache holding a half-installed forum open while +# its database vanishes underneath produces confusing errors in the log. +docker compose stop web >/dev/null 2>&1 || true + +rm -f Settings.php Settings_bak.php install.php upgrade.php + +# SMF's cache holds a serialised copy of $modSettings, which would otherwise +# outlive the database it describes. +find cache -type f ! -name 'index.php' ! -name '.htaccess' -delete 2>/dev/null || true + +if [ "$KEEP_FILES" -eq 0 ]; then + for dir in attachments custom_avatar; do + find "$dir" -type f ! -name 'index.php' ! -name '.htaccess' ! -name 'blank.png' -delete 2>/dev/null || true + done + rm -f Packages/installed.list +fi + +# --------------------------------------------------------------- the database +docker compose up -d "$SERVICE" >/dev/null + +if [ "$SMF_TYPE" = 'mysql' ]; then + # As root: the smf user has rights on the smf database but cannot drop and + # recreate it. utf8mb4 matches what compose.yaml asks the server for and + # what SMF's own DDL emits. + docker compose exec -T -e MYSQL_PWD="$DB_ROOT_PASSWORD" "$SERVICE" mysql -uroot -e " + DROP DATABASE IF EXISTS \`${DB_NAME}\`; + CREATE DATABASE \`${DB_NAME}\` CHARACTER SET utf8mb4; + GRANT ALL ON \`${DB_NAME}\`.* TO '${DB_USER}'@'%'; + " +else + # The database itself cannot be dropped while we are connected to it, and + # dropping the schema is enough: it takes the tables, sequences, functions + # and operators with it. smf owns the database, so it may recreate public. + docker compose exec -T "$SERVICE" psql -v ON_ERROR_STOP=1 -q -U "$DB_USER" -d "$DB_NAME" -c ' + DROP SCHEMA IF EXISTS public CASCADE; + CREATE SCHEMA public; + ' >/dev/null +fi + +log "${SMF_TYPE} database ${DB_NAME} is empty" + +# Bring web back up so the entrypoint regenerates Settings.php for this engine +# and stages the installer. +SMF_DB_TYPE="$SMF_TYPE" docker compose up -d web >/dev/null + +# The entrypoint waits for the database before it writes anything, so give it a +# moment to get there rather than racing whatever runs next. +for _ in $(seq 1 60); do + if docker compose exec -T web test -f install.php 2>/dev/null; then + log 'installer staged, ready to install' + exit 0 + fi + sleep 1 +done + +die 'timed out waiting for the entrypoint to stage install.php (docker compose logs web)' diff --git a/.docker/use-engine.sh b/.docker/use-engine.sh new file mode 100644 index 00000000000..5fd2b7a96b5 --- /dev/null +++ b/.docker/use-engine.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Switches which installed forum is live, without reinstalling either. +# +# .docker/use-engine.sh mysql +# .docker/use-engine.sh postgresql +# +# Both database services always run, on separate volumes, so each keeps its own +# forum. What decides which one you get is Settings.php: it pins $db_type, and +# it wins over SMF_DB_TYPE. install-forum.sh files a copy per engine, and this +# puts one of them back. +# +# No container restart is needed. The entrypoint only writes Settings.php when +# there is not one, so it leaves whatever is in place alone. +# +# To throw an install away and start over, use reset.sh instead. +# +# Runs on the host. +set -euo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +[ $# -eq 1 ] || die 'usage: use-engine.sh mysql|postgresql' + +case "$1" in + -h|--help) sed -n '2,16p' "${BASH_SOURCE[0]}"; exit 0 ;; +esac + +SMF_TYPE=$(engine_smf_type "$1") || die "unknown engine: $1" +SAVED="$SETTINGS_DIR/Settings.${SMF_TYPE}.php" + +cd "$BOARD_DIR" + +[ -f "$SAVED" ] || die "no saved settings for ${SMF_TYPE} -- run .docker/install-forum.sh --engine ${SMF_TYPE}" + +cp "$SAVED" Settings.php +cp "$SETTINGS_DIR/Settings_bak.${SMF_TYPE}.php" Settings_bak.php + +# SMF's cache holds a serialised copy of $modSettings, which describes the +# database we are switching away from. $cache_enable defaults to 0, so there is +# usually nothing there -- but the directory also holds db_last_error.php and +# the generated CSS and JS, and clearing it costs nothing. +find cache -type f ! -name 'index.php' ! -name '.htaccess' -delete 2>/dev/null || true + +VERSION=$(installed_version "$SMF_TYPE" || true) + +[ -n "$VERSION" ] || warn "${SMF_TYPE} has no forum installed -- Settings.php now points at an empty database" + +log "active engine: ${SMF_TYPE}${VERSION:+ (SMF ${VERSION})} -- ${SMF_BOARDURL}" diff --git a/.gitignore b/.gitignore index 6b46b9c3a5c..7bcc6bba3a0 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,10 @@ Thumbs.db /.env /compose.override.yaml /compose.override.yml +# One saved Settings.php per engine, so use-engine.sh can switch between two +# installs without reinstalling. Generated secrets and a machine-specific +# board URL: local to whoever ran the installer. +/.docker/settings/ # Test / Private files # ######################## From 7876ac1fcaaa91ec74da2353967f40ecc83336f4 Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 07:58:28 +0200 Subject: [PATCH 6/8] Marks the dev environment scripts executable The README invokes them as .docker/install-forum.sh rather than through bash, which only works with the bit set. Windows checkouts do not carry it, so it has to be recorded in the index. lib.sh is left alone: it is sourced, never run. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- .docker/install-forum.sh | 0 .docker/reset.sh | 0 .docker/use-engine.sh | 0 3 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 .docker/install-forum.sh mode change 100644 => 100755 .docker/reset.sh mode change 100644 => 100755 .docker/use-engine.sh diff --git a/.docker/install-forum.sh b/.docker/install-forum.sh old mode 100644 new mode 100755 diff --git a/.docker/reset.sh b/.docker/reset.sh old mode 100644 new mode 100755 diff --git a/.docker/use-engine.sh b/.docker/use-engine.sh old mode 100644 new mode 100755 From 8c45b523c5843d05291124acd4c19faf5a08f73e Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 08:46:48 +0200 Subject: [PATCH 7/8] Makes the section comment fixer run on the PHP this project supports SectionComments.php called array_first(), which arrived in PHP 8.5. The platform in composer.json is 8.4.1, and AGENTS.md tells contributors to run composer lint before every commit - so on the lowest PHP the project claims to support, the documented pre-commit check could not run at all. It died with "Call to undefined function array_first()" on every file, changed or not. CI never noticed because php-cs-fixer.yml runs in a container that happens to ship 8.5. array_key_first() has been available since 7.3 and says the same thing here, since what the call wants is the first value of an array keyed by token type. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- .github/phpcs/SectionComments.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/phpcs/SectionComments.php b/.github/phpcs/SectionComments.php index 3441cd9fda1..2b4cc5e1fdb 100644 --- a/.github/phpcs/SectionComments.php +++ b/.github/phpcs/SectionComments.php @@ -314,7 +314,7 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void if (!$exists[$insert_type]) { // Start by assuming we want to insert right before the // 'public', 'protected', or 'private' keyword. - $insert_at = array_first($in); + $insert_at = $in[array_key_first($in)]; // Walk back to include any preceding 'final' or 'readonly' // keywords, as well as any comments or whitespace. From efe5c2a452658f615cdffa35bd2fb1775f5c032c Mon Sep 17 00:00:00 2001 From: albertlast Date: Sun, 2 Aug 2026 08:46:48 +0200 Subject: [PATCH 8/8] Adds a script that runs the CI checks locally Reproducing a CI failure meant reading the workflow YAML and reconstructing the invocation by hand, which is how a code style problem got pushed twice in a row on the branch this came from. .docker/ci.sh runs sign-off, the four file integrity checks, phplint and php-cs-fixer, plus the test suite when the branch has one. It keeps going after a failure and lists everything that failed at the end, rather than stopping at the first and hiding the rest. --full asks for the whole-tree style check. That is not a nicety: the style workflow only looks at the files a pull request changed, except when composer.lock or the fixer config is in the diff, in which case it checks all seventeen hundred. A branch touching a dependency therefore inherits anything already non-compliant on release-3.0, and finding that out before pushing is worth the extra half minute. It cannot cover the other half of the matrix: CI runs on PHP 8.4 and 8.5 and the container is whichever PHP_VERSION built it. The header and the README say so, along with how to rebuild against the other one. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- .docker/README.md | 26 ++++++++++ .docker/ci.sh | 130 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100755 .docker/ci.sh diff --git a/.docker/README.md b/.docker/README.md index 111bef70986..2556731adf3 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -128,6 +128,32 @@ compose network. When the installer finishes, delete `install.php` from the repo root — while it exists, `Settings.php` redirects every request back into the installer. +## Running CI locally + +```sh +.docker/ci.sh # everything CI checks +.docker/ci.sh --full # style check over the whole tree, not just changes +.docker/ci.sh --fix # apply the style fixes rather than reporting them +``` + +Mirrors `php.yml` (sign-off, the four file integrity checks, phplint) and +`php-cs-fixer.yml`, and runs the test suite when the branch has one. Every check +runs even after one fails, because finding out about the second problem on the +next push is the thing this is meant to stop. + +`--full` is worth knowing about: the style workflow normally only looks at the +files a pull request changed, but switches to the whole tree when `composer.lock` +or the fixer config is in the diff. So a branch that touches a dependency +inherits every pre-existing violation in the repository. `--full` tells you that +before you push rather than after. + +Two things it cannot do for you: + +- **The other PHP version.** CI lints and tests on 8.4 *and* 8.5; the container + is whichever built it. To cover the other: + `PHP_VERSION=8.5 docker compose up -d --build web`. +- **The integration tests on both engines.** Use `.docker/test.sh` for that. + ## Everyday use ```sh diff --git a/.docker/ci.sh b/.docker/ci.sh new file mode 100755 index 00000000000..101c5a5ee18 --- /dev/null +++ b/.docker/ci.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Runs what CI runs, before you push instead of after. +# +# .docker/ci.sh every check +# .docker/ci.sh --full style check over the whole tree, not just changes +# .docker/ci.sh --fix apply the style fixes rather than reporting them +# +# The workflows this mirrors are php.yml (sign-off, the file integrity checks, +# phplint) and php-cs-fixer.yml. phpunit.yml is included when the branch has a +# test suite on it. +# +# Every check runs even after one fails, because finding out about the second +# problem on the next push is the thing this script exists to stop. +# +# One difference worth knowing: CI lints and tests on PHP 8.4 *and* 8.5, and the +# web container is whichever PHP_VERSION built it (8.4 by default). To cover the +# other one, rebuild against it: +# +# PHP_VERSION=8.5 docker compose up -d --build web +# +# Runs on the host. +set -uo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +FULL=0 +FIX=0 + +while [ $# -gt 0 ]; do + case "$1" in + --full) FULL=1; shift ;; + --fix) FIX=1; shift ;; + -h|--help) sed -n '2,21p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +# Unlike the other scripts here this one does not set -e, so that a failing +# check does not stop the ones after it. That means cd has to be checked. +cd "$BOARD_DIR" || die "cannot enter $BOARD_DIR" + +docker compose ps --status running --services 2>/dev/null | grep -qx web \ + || die 'the web container is not running -- docker compose up -d' + +FAILED='' + +# $1 label, rest: the command to run in the web container. +check() { + local label="$1" + shift + + printf '\n[smf-dev] --- %s ---\n' "$label" + + if docker compose exec -T web "$@"; then + return 0 + fi + + FAILED="${FAILED}\n - ${label}" + + return 1 +} + +# ------------------------------------------------------------------- php.yml +check 'sign-off (DCO)' php ./vendor/simplemachines/build-tools/check-signed-off.php + +check 'file integrity' sh -c ' + set -e + php ./vendor/simplemachines/build-tools/check-smf-license.php + php ./vendor/simplemachines/build-tools/check-smf-languages.php + php ./vendor/simplemachines/build-tools/check-smf-index.php + php ./vendor/simplemachines/build-tools/check-version.php + echo "all four integrity checks passed" +' + +check "syntax ($(docker compose exec -T web php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;' 2>/dev/null))" \ + vendor/bin/phplint --no-progress --exclude .git --exclude vendor . + +# ------------------------------------------------------------ php-cs-fixer.yml +# CI checks only the files a pull request changed, and switches to the whole +# tree when composer.lock or the fixer config is part of the diff. --full asks +# for that second behaviour, which is worth doing before touching a dependency: +# it surfaces anything already non-compliant on release-3.0. +FIXER_ARGS=(--config .php-cs-fixer.dist.php --allow-risky=yes --using-cache=no --show-progress=none) + +if [ "$FIX" -eq 1 ]; then + FIXER_MODE='fix' +else + FIXER_MODE='check' + FIXER_ARGS+=(--diff) +fi + +if [ "$FULL" -eq 1 ]; then + check 'code style (whole tree)' vendor/bin/php-cs-fixer "$FIXER_MODE" "${FIXER_ARGS[@]}" +else + # Same intersection CI builds, from the files this branch actually touches: + # committed since release-3.0, staged, unstaged, and - the one CI never has + # to think about - new files that are not in the index yet. + CHANGED=$( + { + git diff --name-only --diff-filter=d release-3.0...HEAD -- '*.php' + git diff --name-only --diff-filter=d HEAD -- '*.php' + git ls-files --others --exclude-standard -- '*.php' + } 2>/dev/null | sort -u | grep -v '^$' + ) + + if [ -z "$CHANGED" ]; then + printf '\n[smf-dev] --- code style --- no changed PHP files\n' + else + # shellcheck disable=SC2086 + check 'code style (changed files)' vendor/bin/php-cs-fixer "$FIXER_MODE" "${FIXER_ARGS[@]}" --path-mode=intersection $CHANGED + fi +fi + +# ---------------------------------------------------------------- phpunit.yml +if [ -f phpunit.xml.dist ]; then + check 'tests' vendor/bin/phpunit --no-coverage --colors=always +else + printf '\n[smf-dev] --- tests --- no phpunit.xml.dist on this branch, skipping\n' +fi + +# ---------------------------------------------------------------------- result +printf '\n' + +if [ -n "$FAILED" ]; then + # shellcheck disable=SC2059 + printf "[smf-dev] failed:${FAILED}\n" >&2 + exit 1 +fi + +log 'everything CI checks passes'