Skip to content

Bake Flarum into the image and seed the volume from it (#2) #26

Bake Flarum into the image and seed the volume from it (#2)

Bake Flarum into the image and seed the volume from it (#2) #26

Workflow file for this run

name: CI — build + backup/restore round-trip
# Builds the stack, confirms a fresh install serves, then proves the
# backup→restore loop: plant a marker in the DB, back it up, destroy the data
# volumes (simulating a fresh deploy), bring the stack back up, and verify the
# marker came back — i.e. it RESTORED from the backup instead of fresh-installing.
on:
push:
pull_request:
workflow_dispatch:
jobs:
roundtrip:
runs-on: ubuntu-latest
timeout-minutes: 40
env:
DB_ROOT_PASS: ci-root-pass
MARKER: RESTORED-OK-${{ github.run_id }}
MARKER2: FORCE-RESTORED-${{ github.run_id }}
steps:
- uses: actions/checkout@v4
- name: Prepare a CI .env
run: |
cp .env.example .env
sed -i \
-e 's#^APP_URL=.*#APP_URL=http://localhost#' \
-e 's#^FORUM_TITLE=.*#FORUM_TITLE=CI Fresh Install#' \
-e 's#^ADMIN_PASS=.*#ADMIN_PASS=ci-admin-pass#' \
-e 's#^DB_PASS=.*#DB_PASS=ci-db-pass#' \
-e "s#^DB_ROOT_PASS=.*#DB_ROOT_PASS=${DB_ROOT_PASS}#" \
-e 's#^MAIL_HOST=.*#MAIL_HOST=#' \
.env
- name: Build + start the stack
run: docker compose up -d --build
- name: Wait for the forum to come up (first-run install)
run: |
for i in $(seq 1 90); do
if curl -fsS http://localhost/ >/dev/null 2>&1; then
echo "forum is serving after ~$((i*10))s"; exit 0
fi
sleep 10
done
echo "::error::forum did not come up in time"; exit 1
- 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 \
-e "UPDATE settings SET value='${MARKER}' WHERE \`key\`='forum_title';"
- name: Create a backup (backup.sh -> ./restore)
run: docker compose exec -T flarum backup.sh
- name: Confirm backup artifacts exist
run: |
test -s restore/database.sql.gz || { echo "::error::database.sql.gz missing"; exit 1; }
test -s restore/storage.tar.gz || { echo "::error::storage.tar.gz missing"; exit 1; }
ls -lh restore/
- name: Destroy volumes (simulate a fresh deploy)
run: docker compose down -v
- name: Bring the stack back up (should RESTORE from ./restore)
run: docker compose up -d
- name: Wait for the forum to come up (restore path)
run: |
for i in $(seq 1 90); do
if curl -fsS http://localhost/ >/dev/null 2>&1; then
echo "forum is serving after ~$((i*10))s"; exit 0
fi
sleep 10
done
echo "::error::forum did not come up after restore"; exit 1
- name: Verify it RESTORED (marker survived — not a fresh install)
run: |
TITLE=$(docker compose exec -T mariadb mariadb -uroot -p"${DB_ROOT_PASS}" flarum -N -B \
-e "SELECT value FROM settings WHERE \`key\`='forum_title';")
echo "forum_title after restore: '$TITLE'"
if [ "$TITLE" != "${MARKER}" ]; then
echo "::error::restore failed — expected '${MARKER}', got '$TITLE' (looks like a fresh install)"
exit 1
fi
echo "Restore verified: the backed-up forum_title came back."
# ── Forced restore: load a backup INTO an already-installed forum ──────────
- name: Plant a second marker + back up again
run: |
docker compose exec -T mariadb mariadb -uroot -p"${DB_ROOT_PASS}" flarum \
-e "UPDATE settings SET value='${MARKER2}' WHERE \`key\`='forum_title';"
docker compose exec -T flarum backup.sh
- name: Force-restore into the EXISTING forum (RESTORE_FORCE=true)
run: |
echo 'RESTORE_FORCE=true' >> .env
docker compose up -d --force-recreate flarum
- name: Wait for the forum to come up (forced restore)
run: |
for i in $(seq 1 60); do
if curl -fsS http://localhost/ >/dev/null 2>&1; then
echo "forum is serving after ~$((i*10))s"; exit 0
fi
sleep 10
done
echo "::error::forum did not come up after forced restore"; exit 1
- name: Verify the forced restore imported the new backup
run: |
TITLE=$(docker compose exec -T mariadb mariadb -uroot -p"${DB_ROOT_PASS}" flarum -N -B \
-e "SELECT value FROM settings WHERE \`key\`='forum_title';")
echo "forum_title after forced restore: '$TITLE'"
if [ "$TITLE" != "${MARKER2}" ]; then
echo "::error::forced restore failed — expected '${MARKER2}', got '$TITLE'"
exit 1
fi
echo "Forced restore verified."
- name: Dump container logs on failure
if: failure()
run: docker compose logs --tail=300
# Build each architecture on its OWN native runner, push by digest, then stitch
# the digests into one multi-arch tag.
#
# This used to be a single job building linux/amd64,linux/arm64 together with
# QEMU. That emulates arm64 instruction-for-instruction, and this image
# COMPILES php extensions (gd, intl) during the build, which is close to the
# worst case for emulation — the publish step ran ~30 minutes while the
# round-trip test beside it finished in a few. GitHub provides free arm64
# runners for public repositories, so neither architecture needs emulating.
#
# Only on main / version tags, and only after the round-trip passes, so a
# failed build/backup/restore is never published.
build:
needs: roundtrip
# workflow_dispatch is included so the publish path can be exercised from a
# branch. Without it these jobs only ever run on main or a tag, which means
# a change to the build itself cannot be tested before it is merged — the
# pull request would show a green roundtrip and tell you nothing about
# whether the image still publishes.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')))
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build + push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
# No tag here: each arch is pushed as a bare digest and only the merge
# job below names it. Tagging per-arch would leave the tag pointing at
# whichever architecture happened to finish last.
outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
# Cache is scoped per-architecture — a shared scope would have the two
# jobs overwrite each other's layers.
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
- name: Export digest
run: |
mkdir -p /tmp/digests
# Store the BARE hex, not the full "sha256:..." digest:
# actions/upload-artifact rejects ':' in a filename. The merge job
# puts the sha256: prefix back when it builds the manifest.
digest='${{ steps.build.outputs.digest }}'
touch "/tmp/digests/${digest#sha256:}"
- uses: actions/upload-artifact@v4
with:
# One artifact per arch; the merge job globs them back together.
name: digest-${{ strategy.job-index }}
path: /tmp/digests/*
retention-days: 1
if-no-files-found: error
publish:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Image tags + labels
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=sha,format=short
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Create the multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Verify both architectures are present
run: |
docker buildx imagetools inspect \
"ghcr.io/${{ github.repository }}:$(jq -r '.tags[0] | split(":")[1]' <<< "$DOCKER_METADATA_OUTPUT_JSON")" \
--format '{{json .Manifest}}' | jq -e '
[.manifests[].platform | select(.os != "unknown") | "\(.os)/\(.architecture)"] as $p
| if ($p | index("linux/amd64")) and ($p | index("linux/arm64"))
then "ok" else error("missing an architecture: \($p)") end'