Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
cf773b7
Adds a Docker development environment using PostgreSQL
albertlast Jul 28, 2026
77d46cf
Gives each action subclass its own instance in ActionTrait::load()
albertlast Jul 29, 2026
2746307
Adds a PHPUnit suite for code that needs no database
albertlast Jul 29, 2026
120f6a9
Merge branch 'fix/action-trait-subclass-instances' into tests/phpunit
albertlast Jul 29, 2026
10dfe94
Merge branch 'fix/createpost-notify-time-offset' into tests/phpunit
albertlast Jul 29, 2026
a142e5c
Runs the unit tests in CI and documents them
albertlast Jul 29, 2026
99ee104
Broadens the unit tests to the rest of the stateless surface
albertlast Jul 29, 2026
cf6ff4e
Parses memory settings that carry no unit designator
albertlast Jul 29, 2026
560dfb2
Compares URL schemes case insensitively
albertlast Jul 29, 2026
4caec1d
Merge branch 'fix/sapi-memory-return-bytes' into tests/phpunit
albertlast Jul 29, 2026
f9d38cd
Merge branch 'fix/url-is-scheme-case' into tests/phpunit
albertlast Jul 29, 2026
7d656ce
Turns the two noted defects into regression tests
albertlast Jul 29, 2026
3e45ec4
Marks the ActionTrait test as covering a trait
albertlast Jul 29, 2026
0b0217d
Merge branch 'release-3.0' into docker-dev-env
albertlast Jul 29, 2026
2c42ccc
Adds MySQL to the dev environment and makes it the default
albertlast Jul 29, 2026
fa76555
Merge branch 'docs/agent-instructions' into tests/phpunit
albertlast Jul 30, 2026
d4f484c
Documents the postgres log as the way to debug SQL errors
albertlast Jul 30, 2026
b5f342f
Documents when the unit test suite can cover a change
albertlast Jul 31, 2026
64621b1
Merge branch 'release-3.0' into tests/install-cli
albertlast Aug 2, 2026
459b271
Reports maintenance tool failures on the command line
albertlast Aug 2, 2026
11fe20d
Stops the installer assuming there is a web request
albertlast Aug 2, 2026
516f453
Merge branch 'fix/install-finalize-user-not-loaded' into tests/instal…
albertlast Aug 2, 2026
7477a6c
Skips the browser sign-in when installing from the command line
albertlast Aug 2, 2026
89324b1
Reports the step a maintenance tool actually paused on
albertlast Aug 2, 2026
7665045
Installs the forum from the command line
albertlast Aug 2, 2026
7876ac1
Marks the dev environment scripts executable
albertlast Aug 2, 2026
170679c
Merge branch 'tests/phpunit' into tests/integration
albertlast Aug 2, 2026
b79d33c
Adds an integration suite that runs against a real forum
albertlast Aug 2, 2026
241250d
Lets the section comment fixer place its own banners
albertlast Aug 2, 2026
c7b53cd
Removes the trailing tabs from a blank line in PM search
albertlast Aug 2, 2026
61a35f5
Removes install.php once the forum is installed
albertlast Aug 2, 2026
78f54a1
Merge branch 'tests/install-cli' into tests/integration
albertlast Aug 2, 2026
c67166e
Adds HTTP smoke tests that drive a running forum
albertlast Aug 2, 2026
5df86c6
Documents how to write an HTTP test
albertlast Aug 2, 2026
c7b9f5a
Adds a script for checking and resetting account passwords
albertlast Aug 2, 2026
4ed7ed2
Merge branch 'tests/install-cli' into tests/integration
albertlast Aug 2, 2026
f66a6be
Merge branch 'tests/integration' into tests/http
albertlast Aug 2, 2026
218d95f
Points the credentials note at user.sh
albertlast Aug 2, 2026
8c939ce
Merge remote-tracking branch 'origin/release-3.0' into tests/phpunit
albertlast Aug 5, 2026
ebd1d45
Merge branch 'tests/phpunit' into tests/integration
albertlast Aug 5, 2026
6c7690e
Merge branch 'tests/integration' into tests/http
albertlast Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
457 changes: 457 additions & 0 deletions .docker/README.md

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions .docker/env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copy to the repository root as `.env` to override any of the defaults.
# compose.yaml works without this file.

# Which engine the forum runs on: mysql (default) or postgresql.
# Both database services start either way. This only decides what the generated
# Settings.php points at, so it has no effect once the forum is installed --
# Settings.php wins from then on.
SMF_DB_TYPE=mysql

# Host ports
WEB_PORT=8080
ADMINER_PORT=8081
MAILPIT_PORT=8025
MYSQL_PORT=3307
POSTGRES_PORT=5433

# Versions
PHP_VERSION=8.4
MYSQL_VERSION=8.4
POSTGRES_VERSION=17-alpine

# Database credentials (dev only). Shared by both engines so that switching
# SMF_DB_TYPE needs no other change.
DB_NAME=smf
DB_USER=smf
DB_PASSWORD=smf
DB_ROOT_PASSWORD=smf

# Which service Adminer pre-fills in its server field. Service name, not engine
# name: mysql or postgres.
ADMINER_SERVER=mysql
205 changes: 205 additions & 0 deletions .docker/install-forum.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
#!/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"
)

# reset.sh does not return until the entrypoint has staged this, so its
# absence means something went wrong there rather than here. Worth saying so:
# without it php reports "Could not open input file: install.php", which reads
# like a broken script rather than a forum that was never made installable.
docker compose exec -T web test -f install.php \
|| die "${smf_type}: install.php is not staged, so there is nothing to run (docker compose logs web)"

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"

# The installer tells you to delete this and cannot do it itself: its ?delete
# link is a GET, and command line arguments only ever reach $_POST. Leaving it
# is not cosmetic - Settings.php redirects every request back into the
# installer while it is there, and SMF puts a "MAJOR SECURITY RISK: you have
# not removed install.php" box on every page it shows an administrator.
#
# Safe to delete even though a reinstall needs it again: install_one() always
# calls reset.sh first, and reset.sh clears Settings.php and waits for the
# entrypoint to put a fresh copy back before returning.
rm -f install.php

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})"
123 changes: 123 additions & 0 deletions .docker/lib.sh
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions .docker/mysql/init/10-smf.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/bin/bash
# Runs once, on first initialisation of the mysql data volume.
set -eu

# SMF creates its own tables as InnoDB/utf8mb4, but the database's own default is
# what anything created outside that path inherits. Pinning it means it cannot
# drift out from under the forum, the same reason the postgres side pins
# standard_conforming_strings.
#
# The collation is left to whatever utf8mb4 defaults to on this server, because
# that is what SMF's tables get: its DDL sets CHARSET but never COLLATE.
mysql --protocol=socket -uroot -p"$MYSQL_ROOT_PASSWORD" <<-EOSQL
ALTER DATABASE \`${MYSQL_DATABASE}\` CHARACTER SET utf8mb4;
EOSQL

echo "[smf-dev] database ${MYSQL_DATABASE} initialised"
Loading
Loading