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 \ 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 {} +