ci(integration): install app via git of the PR's exact head SHA (#7) #18
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
| # SPDX-FileCopyrightText: 2026 Inter Fonts App Contributors | |
| # SPDX-License-Identifier: AGPL-3.0-or-later | |
| # | |
| # .github/workflows/integration.yml | |
| # | |
| # End-to-end integration test. For every supported Nextcloud major | |
| # (32–35 per appinfo/info.xml), this workflow: | |
| # | |
| # 1. Pulls the official `nextcloud:N-apache` Docker image | |
| # 2. Spins up the container with SQLite (no separate DB service — | |
| # keeps each matrix entry fully self-contained) | |
| # 3. Copies the app into /var/www/html/custom_apps/interfonts using | |
| # the SAME allowlist that release.yml ships to the App Store, so | |
| # we test the production deliverable rather than the dev tree | |
| # 4. Enables the app via `occ app:enable` | |
| # 5. Smoke-tests the actual HTTP endpoints with curl: | |
| # - GET /apps/interfonts/stylesheet?v=<ver> returns text/css | |
| # with @font-face rules + the Inter font-family | |
| # - GET /apps/interfonts/font/<filename> returns font/woff2 | |
| # with the correct cache and CORS headers, and the body | |
| # starts with the WOFF2 magic bytes (0x77 4F 46 32 = "wOF2") | |
| # - GET /login HTML contains both preload <link>s and the | |
| # stylesheet <link> — exercises the | |
| # BeforeLoginTemplateRenderedEvent listener path | |
| # | |
| # Why curl + assertions instead of a browser harness? | |
| # - Curl tests every wire-level invariant we actually care about | |
| # (status code, headers, body bytes) at <1s per request, with | |
| # zero browser-driver flake. | |
| # - The CSS engine and font-face semantics are universally browser- | |
| # tested upstream; what we own is the HTTP surface. | |
| # | |
| # fail-fast: false so one NC version's regression doesn't mask the | |
| # others. | |
| # | |
| # Matrix scope: Nextcloud majors that currently have a published | |
| # Docker image on Docker Hub (`nextcloud:N-apache`). appinfo/info.xml | |
| # declares support up to NC 35, but NC 34 and 35 had not been | |
| # released as images at the time this workflow was authored — add | |
| # them to the matrix below once they appear, no other change needed. | |
| name: Integration | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| # Same reason as ci.yml: release-prepare.yml fires this on the | |
| # release/* branch via `gh workflow run` so the PR's required-checks | |
| # gate is satisfied without a PAT. | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| jobs: | |
| smoke-test: | |
| name: Nextcloud ${{ matrix.nextcloud }} smoke test | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| # Extend to '34', '35' once those majors are published on | |
| # Docker Hub. The rest of the workflow is version-agnostic. | |
| nextcloud: ['32', '33'] | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Spin up Nextcloud ${{ matrix.nextcloud }} | |
| run: | | |
| set -euo pipefail | |
| docker run -d --name nc \ | |
| -p 8080:80 \ | |
| -e SQLITE_DATABASE=nextcloud \ | |
| -e NEXTCLOUD_ADMIN_USER=admin \ | |
| -e NEXTCLOUD_ADMIN_PASSWORD=admin \ | |
| -e NEXTCLOUD_TRUSTED_DOMAINS=localhost \ | |
| "nextcloud:${{ matrix.nextcloud }}-apache" | |
| - name: Wait for Nextcloud to finish initial install | |
| run: | | |
| set -euo pipefail | |
| # Apache starts after the entrypoint's auto-installer exits. | |
| # status.php returns {"installed":true,...} once the install | |
| # finishes — usually within ~30-60s on a clean SQLite boot. | |
| for i in $(seq 1 60); do | |
| sleep 5 | |
| STATUS=$(curl -sS http://localhost:8080/status.php 2>/dev/null || true) | |
| if printf '%s' "$STATUS" | grep -q '"installed":true'; then | |
| echo "Nextcloud ready after $((i*5))s" | |
| echo "$STATUS" | |
| exit 0 | |
| fi | |
| echo "[$i/60] not ready yet" | |
| done | |
| echo "::error::Nextcloud did not finish installing within 5 minutes" | |
| docker logs nc 2>&1 | tail -200 || true | |
| exit 1 | |
| - name: Install the Inter Fonts app into the container (via git) | |
| # `occ app:enable` does not download anything — it just enables | |
| # whatever's on disk under custom_apps/<id>/. To make the test's | |
| # provenance unmistakable to readers, we install the app inside | |
| # the container by `git clone`-ing this exact repo + checking | |
| # out the precise commit under test (the PR's head SHA on | |
| # pull_request events; github.sha on pushes). This guarantees | |
| # we're testing the PR's tree — never an App Store release that | |
| # might happen to share the same app id. | |
| env: | |
| # On fork PRs the head lives on a different repo; fall back to | |
| # the base repo URL for push events and same-repo PRs (e.g. | |
| # Dependabot). | |
| HEAD_REPO_URL: ${{ github.event.pull_request.head.repo.clone_url }} | |
| REPO: ${{ github.repository }} | |
| REF: ${{ github.event.pull_request.head.sha || github.sha }} | |
| run: | | |
| set -euo pipefail | |
| CLONE_URL="${HEAD_REPO_URL:-https://github.com/${REPO}.git}" | |
| echo "Cloning ${CLONE_URL} @ ${REF}" | |
| # Install git inside the container. NC's official image is | |
| # Debian-based and does not ship git by default. | |
| docker exec --user root nc bash -c ' | |
| set -euo pipefail | |
| apt-get update -qq | |
| DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ | |
| git ca-certificates >/dev/null | |
| ' | |
| # Clone the repo and check out the exact ref under test. | |
| # --depth 1 + --filter=blob:none keeps it fast (~1-2 s). | |
| docker exec --user root --env CLONE_URL="${CLONE_URL}" --env REF="${REF}" nc bash -c ' | |
| set -euo pipefail | |
| mkdir -p /tmp/interfonts-src | |
| git clone --no-checkout --filter=blob:none "${CLONE_URL}" /tmp/interfonts-src | |
| git -C /tmp/interfonts-src fetch --depth 1 origin "${REF}" | |
| git -C /tmp/interfonts-src checkout FETCH_HEAD | |
| echo "--- commit under test ---" | |
| git -C /tmp/interfonts-src log -1 --format="%H %s%n by %an <%ae>%n on %ai" | |
| ' | |
| # Stage the tree into custom_apps/ using the SAME allowlist as | |
| # the release-publish tarball (and the tarball-dry-run job in | |
| # this same workflow file): only what real users will load. | |
| # If these three lists ever drift, we are testing something | |
| # different from what we ship. | |
| docker exec --user root nc bash -c ' | |
| set -euo pipefail | |
| APP_DIR=/var/www/html/custom_apps/interfonts | |
| mkdir -p "${APP_DIR}" | |
| cp -r /tmp/interfonts-src/appinfo "${APP_DIR}/" | |
| cp -r /tmp/interfonts-src/fonts "${APP_DIR}/" | |
| cp -r /tmp/interfonts-src/img "${APP_DIR}/" | |
| cp -r /tmp/interfonts-src/lib "${APP_DIR}/" | |
| cp -r /tmp/interfonts-src/LICENSES "${APP_DIR}/" | |
| cp /tmp/interfonts-src/COPYING "${APP_DIR}/" | |
| cp /tmp/interfonts-src/README.md "${APP_DIR}/" | |
| cp /tmp/interfonts-src/CHANGELOG.md "${APP_DIR}/" | |
| chown -R www-data:www-data "${APP_DIR}" | |
| ' | |
| docker exec --user www-data nc php occ app:enable interfonts | |
| # Proof step — assert the version Nextcloud has loaded matches | |
| # the version in the PR's appinfo/info.xml. If a previously | |
| # released app version were somehow shadowing our install, | |
| # the two would diverge and this fails loudly. | |
| INSTALLED=$(docker exec --user www-data nc php occ app:list \ | |
| | grep -oE 'interfonts: [0-9]+\.[0-9]+\.[0-9]+' \ | |
| | head -1 | awk '{print $2}') | |
| EXPECTED=$(grep -oP '(?<=<version>)[^<]+' appinfo/info.xml | tr -d '[:space:]') | |
| echo "Installed version : ${INSTALLED}" | |
| echo "Expected (PR) : ${EXPECTED}" | |
| if [ "${INSTALLED}" != "${EXPECTED}" ]; then | |
| echo "::error::version mismatch — Nextcloud loaded a different interfonts than the PR" | |
| exit 1 | |
| fi | |
| - name: Warm up route cache | |
| # The first request after `occ app:enable` can return 404 because | |
| # Apache prefork workers carry their own PHP opcache and only | |
| # rebuild the route map on the stale-route miss. We make a few | |
| # throwaway requests so every worker that subsequent tests might | |
| # land on has rebuilt its routes. Confirmed via local repro: | |
| # without warmup attempt 1 returns 404, attempt 2 returns 200 — | |
| # adding warmup makes the smoke tests deterministic. | |
| run: | | |
| for i in 1 2 3 4 5; do | |
| curl -fsS -o /dev/null \ | |
| "http://localhost:8080/apps/interfonts/stylesheet?v=warmup-$i" || true | |
| sleep 1 | |
| done | |
| - name: Smoke-test — stylesheet endpoint | |
| run: | | |
| set -euo pipefail | |
| VER=$(grep -oP '(?<=<version>)[^<]+' appinfo/info.xml | tr -d '[:space:]') | |
| URL="http://localhost:8080/apps/interfonts/stylesheet?v=${VER}" | |
| echo "GET $URL" | |
| HTTP=$(curl -sS -o /tmp/css.out -w '%{http_code}' "$URL") | |
| test "$HTTP" = "200" || { | |
| echo "::error::expected 200, got $HTTP" | |
| cat /tmp/css.out | |
| exit 1 | |
| } | |
| HEADERS=$(curl -sSI "$URL") | |
| echo "--- response headers ---" | |
| echo "$HEADERS" | |
| echo "$HEADERS" | grep -qi '^content-type: text/css' || { echo "::error::wrong Content-Type"; exit 1; } | |
| echo "$HEADERS" | grep -qi '^cache-control:.*immutable' || { echo "::error::missing immutable Cache-Control"; exit 1; } | |
| echo "$HEADERS" | grep -qi '^x-content-type-options: nosniff' || { echo "::error::missing nosniff"; exit 1; } | |
| grep -q '@font-face' /tmp/css.out || { echo "::error::no @font-face block"; exit 1; } | |
| grep -q "'InterVariable'" /tmp/css.out || { echo "::error::Inter font-family missing"; exit 1; } | |
| grep -q "'InterVariable Fallback'" /tmp/css.out || { echo "::error::metric-fallback missing"; exit 1; } | |
| grep -q 'apps/interfonts/font/' /tmp/css.out || { echo "::error::font src URL missing"; exit 1; } | |
| echo "Stylesheet OK ($(wc -c </tmp/css.out) bytes)" | |
| - name: Smoke-test — font binary endpoint | |
| run: | | |
| set -euo pipefail | |
| IVER=$(tr -d '[:space:]' < fonts/inter-version.txt | sed 's/^v//') | |
| URL="http://localhost:8080/apps/interfonts/font/InterVariable-${IVER}.woff2" | |
| echo "GET $URL" | |
| HTTP=$(curl -sS -o /tmp/font.bin -w '%{http_code}' "$URL") | |
| test "$HTTP" = "200" || { echo "::error::expected 200, got $HTTP"; exit 1; } | |
| HEADERS=$(curl -sSI "$URL") | |
| echo "--- response headers ---" | |
| echo "$HEADERS" | |
| echo "$HEADERS" | grep -qi '^content-type: font/woff2' || { echo "::error::wrong Content-Type"; exit 1; } | |
| echo "$HEADERS" | grep -qi '^cache-control:.*immutable' || { echo "::error::missing immutable cache"; exit 1; } | |
| echo "$HEADERS" | grep -qi '^x-content-type-options: nosniff' || { echo "::error::missing nosniff"; exit 1; } | |
| echo "$HEADERS" | grep -qiE '^access-control-allow-origin:[[:space:]]*\*' || { echo "::error::missing wildcard CORS"; exit 1; } | |
| # WOFF2 magic bytes: 0x77 0x4F 0x46 0x32 = "wOF2" | |
| MAGIC=$(head -c 4 /tmp/font.bin | xxd -p) | |
| test "$MAGIC" = "774f4632" || { | |
| echo "::error::body is not a WOFF2 (first 4 bytes = 0x$MAGIC, expected 0x774f4632)" | |
| exit 1 | |
| } | |
| echo "Font OK ($(wc -c </tmp/font.bin) bytes, WOFF2 magic verified)" | |
| - name: Smoke-test — italic font endpoint | |
| run: | | |
| set -euo pipefail | |
| IVER=$(tr -d '[:space:]' < fonts/inter-version.txt | sed 's/^v//') | |
| URL="http://localhost:8080/apps/interfonts/font/InterVariable-Italic-${IVER}.woff2" | |
| HTTP=$(curl -sS -o /tmp/italic.bin -w '%{http_code}' "$URL") | |
| test "$HTTP" = "200" || { echo "::error::expected 200, got $HTTP"; exit 1; } | |
| MAGIC=$(head -c 4 /tmp/italic.bin | xxd -p) | |
| test "$MAGIC" = "774f4632" || { echo "::error::italic is not a WOFF2 (magic=0x$MAGIC)"; exit 1; } | |
| echo "Italic font OK ($(wc -c </tmp/italic.bin) bytes)" | |
| - name: Smoke-test — non-allowlisted filename returns 404 | |
| run: | | |
| set -euo pipefail | |
| # Path traversal is impossible (route regex + basename) but | |
| # we still verify the allowlist itself rejects unknown files. | |
| URL="http://localhost:8080/apps/interfonts/font/EvilFont-1.0.woff2" | |
| HTTP=$(curl -sS -o /dev/null -w '%{http_code}' "$URL") | |
| test "$HTTP" = "404" || { | |
| echo "::error::expected 404 for non-allowlisted filename, got $HTTP" | |
| exit 1 | |
| } | |
| echo "Allowlist correctly rejects unknown filenames (404)" | |
| - name: Smoke-test — login page injects preload + stylesheet | |
| run: | | |
| set -euo pipefail | |
| # /login is unauthenticated → exercises the listener's | |
| # BeforeLoginTemplateRenderedEvent registration. If only | |
| # BeforeTemplateRenderedEvent were registered, this page | |
| # would render without the Inter assets — a regression | |
| # this assertion catches. | |
| curl -sSL http://localhost:8080/login -o /tmp/login.html | |
| grep -qE '<link[^>]*rel="preload"[^>]*as="font"' /tmp/login.html \ | |
| || { echo "::error::no preload <link rel=preload as=font> in login page"; head -200 /tmp/login.html; exit 1; } | |
| grep -q 'apps/interfonts/font/InterVariable-' /tmp/login.html \ | |
| || { echo "::error::Inter font URL missing from login page"; exit 1; } | |
| grep -q 'apps/interfonts/font/InterVariable-Italic-' /tmp/login.html \ | |
| || { echo "::error::Italic font URL missing from login page"; exit 1; } | |
| grep -q 'apps/interfonts/stylesheet' /tmp/login.html \ | |
| || { echo "::error::stylesheet URL missing from login page"; exit 1; } | |
| echo "Login page injects roman + italic preload + stylesheet" | |
| - name: Dump container logs on failure | |
| if: failure() | |
| run: | | |
| echo "--- docker ps -a ---" | |
| docker ps -a || true | |
| echo "--- docker logs nc (tail 300) ---" | |
| docker logs nc 2>&1 | tail -300 || true | |
| echo "--- nextcloud.log (tail 300) ---" | |
| docker exec --user www-data nc cat /var/www/html/data/nextcloud.log 2>/dev/null | tail -300 || true | |
| echo "--- enabled apps ---" | |
| docker exec --user www-data nc php occ app:list 2>&1 || true |