From cdb4355d2555c1a9596a82afe78569c8ce95ce4a Mon Sep 17 00:00:00 2001 From: linkrobins Date: Tue, 28 Jul 2026 22:43:36 -0400 Subject: [PATCH 1/2] Bake Flarum into the image and seed the volume from it (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image now contains Flarum. Until this commit the Dockerfile had zero references to flarum/flarum: it was a PHP runtime that downloaded a forum when a container first started, at ^2.0 with beta stability plus six extensions at bare '*'. Seven floating constraints, so the same tag could produce different forums a week apart, and there was no offline install. Build time resolves from the committed lock, which is fine and reproducible — the lock decides. create-project supplies the skeleton's own files (index.php, the flarum CLI, public/, storage/) since those belong to the flarum/flarum package and never appear in vendor/; --no-install skips its resolution because the committed lock replaces its composer.json immediately after. The build fails if the ARG and the lock disagree about flarum/core, because nothing else would notice them drifting apart. First boot with an empty volume copies that skeleton in. Nothing is fetched: the six extensions are already in the skeleton's composer.json, so the existing composer_require calls short-circuit as "already present". Setting TARGET_FLARUM_VERSION is now the deliberate opt-out — it means "resolve this from Packagist instead", the old behaviour, still supported. Images built before the skeleton existed take that path automatically, since the guard tests for the directory. An existing volume is untouched, exactly as before. Whether a running forum should adopt a new skeleton on image update is a separate decision and deliberately not made here. --- Dockerfile | 35 +++++++++++++++++++++++++++++++++++ README.md | 11 ++++++++--- entrypoint.sh | 37 ++++++++++++++++++++++++++++++++++--- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index f23d333..be01c57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,41 @@ RUN curl -sS --retry 5 --retry-delay 2 --retry-connrefused \ https://getcomposer.org/installer \ | php -- --install-dir=/usr/local/bin --filename=composer +# ── The pinned Flarum skeleton ─────────────────────────────────────────────── +# Flarum itself, resolved at BUILD time from the committed lock rather than +# from Packagist when a container first starts. Build-time network is fine and +# reproducible — the lock decides what gets installed. Runtime network is not: +# it made the same image tag produce different forums a week apart and left no +# offline install path at all. See issue #2 and skeleton/README.md. +# +# create-project supplies the skeleton's OWN files (index.php, the flarum CLI, +# public/, storage/) which are part of the flarum/flarum package and never +# appear in vendor/. --no-install skips dependency resolution, because the +# committed lock replaces its composer.json immediately below and decides that. +ARG FLARUM_VERSION=2.0.0-rc.5 +ENV FLARUM_SKELETON=/opt/flarum-skeleton +ENV FLARUM_SKELETON_VERSION=${FLARUM_VERSION} + +RUN COMPOSER_HOME=/tmp/composer composer create-project \ + "flarum/flarum:${FLARUM_VERSION}" "$FLARUM_SKELETON" \ + --stability=beta --no-install --no-interaction --no-progress + +COPY skeleton/composer.json skeleton/composer.lock ${FLARUM_SKELETON}/ + +# Fail the build rather than ship a skeleton whose files and lock disagree: the +# ARG picks the package that supplies the root files, the lock picks everything +# else, and nothing else would notice them drifting apart. +RUN set -eu; \ + locked="$(php -r '$l = json_decode(file_get_contents(getenv("FLARUM_SKELETON")."/composer.lock"), true); foreach ($l["packages"] as $p) { if ($p["name"] === "flarum/core") { echo ltrim($p["version"], "v"); exit; } } exit(1);')"; \ + if [ "$locked" != "${FLARUM_VERSION}" ]; then \ + echo "FLARUM_VERSION=${FLARUM_VERSION} but skeleton/composer.lock pins flarum/core $locked" >&2; \ + exit 1; \ + fi; \ + cd "$FLARUM_SKELETON"; \ + COMPOSER_HOME=/tmp/composer composer install \ + --no-dev --no-interaction --no-progress --optimize-autoloader; \ + rm -rf /tmp/composer + # Baked config + boot scripts (no runtime download). COPY nginx.conf /etc/nginx/conf.d/flarum.conf COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf diff --git a/README.md b/README.md index 3a39ea7..71adba0 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,14 @@ Two things set this apart: is tested rather than assumed — including restoring a backup from an older Flarum into a newer one. -Everything is baked into the image: nginx, php-fpm, Composer, all required PHP -extensions, and the boot script. There is **no runtime download** of a setup -script, no external services, and no telemetry. +**Flarum itself is in the image, at a pinned version.** It is not fetched from +Packagist when your container starts, so the same image tag always installs the +same forum, a first boot needs no internet access, and an upstream outage cannot +break a new install. The exact version set lives in +[`skeleton/`](skeleton/) and moves only in a reviewed pull request. + +Everything else is baked in too: nginx, php-fpm, Composer, all required PHP +extensions, and the boot script. No external services, and no telemetry. ## What you get diff --git a/entrypoint.sh b/entrypoint.sh index e713896..b85ae1d 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -86,7 +86,24 @@ REDIS_HOST=$(clean "${REDIS_HOST:-valkey}") REDIS_PORT=$(clean "${REDIS_PORT:-6379}") REDIS_PASSWORD=$(clean "${REDIS_PASSWORD:-}") +# Whether the operator pinned a version themselves. Checked BEFORE defaulting, +# because the default is exactly what "they said nothing" looks like afterwards. +# Setting TARGET_FLARUM_VERSION is the deliberate opt-out from the baked +# skeleton: it means "go and resolve this from Packagist instead", which is the +# old behaviour and still supported. +TARGET_FLARUM_VERSION_SET=$([ -n "${TARGET_FLARUM_VERSION:-}" ] && echo true || echo false) TARGET_FLARUM_VERSION=$(clean "${TARGET_FLARUM_VERSION:-^2.0}") + +# Seed from the image unless asked otherwise. The guard on composer.json keeps +# this correct for an image built before the skeleton existed, where the +# directory is simply absent. +FLARUM_SKELETON="${FLARUM_SKELETON:-/opt/flarum-skeleton}" +FLARUM_SKELETON_VERSION="${FLARUM_SKELETON_VERSION:-unknown}" +if [ "$TARGET_FLARUM_VERSION_SET" = "false" ] && [ -f "$FLARUM_SKELETON/composer.json" ]; then + FLARUM_FROM_SKELETON=true +else + FLARUM_FROM_SKELETON=false +fi REALTIME_ENABLED=$(echo "${REALTIME_ENABLED:-true}" | tr '[:upper:]' '[:lower:]') # Restore-on-deploy: drop a backup in the mounted /restore dir (database.sql[.gz] @@ -182,9 +199,23 @@ if [ ! -f "$CONFIG_FILE" ]; then TMP_INSTALL="$WORKDIR/storage/flarum_stage" rm -rf "$TMP_INSTALL" && mkdir -p "$TMP_INSTALL" && chown www-data:www-data "$TMP_INSTALL" - log "composer create-project flarum/flarum:${TARGET_FLARUM_VERSION}..." - composer_as_www create-project "flarum/flarum:${TARGET_FLARUM_VERSION}" --stability=beta "$TMP_INSTALL" --no-interaction \ - >> "$LOG_DIR/composer_install.log" 2>&1 || die "composer create-project failed — see $LOG_DIR/composer_install.log" + + # Seed from the skeleton baked into the image. No Packagist, no resolution, + # and the same image tag always produces the same forum — which was the + # whole point of issue #2. Falls back to resolving over the network only + # when the operator deliberately asked for a different version, or when the + # image predates the skeleton. + if [ "$FLARUM_FROM_SKELETON" = "true" ]; then + log "Seeding Flarum ${FLARUM_SKELETON_VERSION} from the baked skeleton (no network)." + cp -a "$FLARUM_SKELETON/." "$TMP_INSTALL/" \ + || die "could not copy the baked skeleton from $FLARUM_SKELETON" + chown -R www-data:www-data "$TMP_INSTALL" + else + log "composer create-project flarum/flarum:${TARGET_FLARUM_VERSION}..." + composer_as_www create-project "flarum/flarum:${TARGET_FLARUM_VERSION}" --stability=beta "$TMP_INSTALL" --no-interaction \ + >> "$LOG_DIR/composer_install.log" 2>&1 || die "composer create-project failed — see $LOG_DIR/composer_install.log" + fi + cp -an "$TMP_INSTALL/." "$WORKDIR/" && rm -rf "$TMP_INSTALL" chown -R www-data:www-data "$WORKDIR" find "$WORKDIR" -type d -exec chmod 775 {} + ; find "$WORKDIR" -type f -exec chmod 664 {} + From 3d4be476986511b630dbd32018fb3b5cbe72f481 Mon Sep 17 00:00:00 2001 From: linkrobins Date: Tue, 28 Jul 2026 22:48:54 -0400 Subject: [PATCH 2/2] Assert the install came from the skeleton, not the network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit roundtrip passing proved nothing about where Flarum came from. The runner has network, so the old Packagist path satisfies every check in the job just as well as the baked one — the pin could stop being load-bearing and the suite would stay green. Now it reads the container log and fails if the fresh install did not seed from the skeleton, or if it resolved over the network at all. --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e82843..598bb2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,24 @@ jobs: - name: Verify the fresh install serves Flarum run: curl -fsS http://localhost/ | grep -qi 'flarum' || { echo "::error::no Flarum markers in response"; exit 1; } + # A green install proves nothing about WHERE Flarum came from: this runner + # has network, so the old Packagist path would pass every check above just + # as well. Assert the offline path was actually taken, or the pin quietly + # stops being load-bearing the first time the guard breaks. + - name: Verify it installed from the baked skeleton, not from Packagist + run: | + logs="$(docker compose logs flarum)" + if ! echo "$logs" | grep -q 'from the baked skeleton'; then + echo "::error::fresh install did not seed from the baked skeleton" + echo "$logs" | tail -60 + exit 1 + fi + if echo "$logs" | grep -q 'composer create-project flarum/flarum'; then + echo "::error::fresh install resolved Flarum over the network" + exit 1 + fi + echo "$logs" | grep 'from the baked skeleton' + - name: Plant a marker in the database run: | docker compose exec -T mariadb mariadb -uroot -p"${DB_ROOT_PASS}" flarum \