Bake Flarum into the image and seed the volume from it (#2) #26
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' |