From 8117630a6566af4a9202776208a948e4ab08a4c8 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 26 Jul 2026 00:58:27 +0200 Subject: [PATCH] feat(ops): update page, automatic 419 recovery, CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things that bit us on every deploy: - artisan down now renders a branded page that says an update is running and reloads itself, instead of Laravel's bare 503. - A page left open across a deploy carries a Livewire snapshot and CSRF token the new code rejects — the user got "419 Page Expired" and a dead interface. A 419 from a /livewire/ request now reloads the page; the session is still valid, so that is all it takes. Hooked at the fetch layer rather than through Livewire's request hook, whose failure callback is not invoked for this case in the installed version — verified against a real 419 in the browser, not a simulated one. - Vite no longer empties its output directory: wiping it is what left an open page without its stylesheet mid-deploy, which is the "design is completely broken" symptom. update.sh also rebuilds the caches with optimize:clear + optimize instead of leaving them half-warm. Plus CI: .gitea/workflows/tests.yml (Pest + asset build, and a tested- tag only on a green main) and an opt-in act_runner service under the ci profile. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/tests.yml | 69 ++++++++++++++++++++++++++++ deploy/update.sh | 8 ++-- docker-compose.yml | 19 ++++++++ lang/de/updating.php | 8 ++++ lang/en/updating.php | 8 ++++ resources/js/app.js | 21 +++++++++ resources/views/errors/503.blade.php | 30 ++++++++++++ vite.config.js | 6 +++ 8 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/tests.yml create mode 100644 lang/de/updating.php create mode 100644 lang/en/updating.php create mode 100644 resources/views/errors/503.blade.php diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml new file mode 100644 index 0000000..92e7b09 --- /dev/null +++ b/.gitea/workflows/tests.yml @@ -0,0 +1,69 @@ +# CluPilot CI — runs on Gitea Actions (GitHub-Actions syntax, own runner). +# +# Deliberately not mirrored to GitHub: this repository describes how our +# infrastructure is provisioned, and the same workflow runs at home. If we ever +# move, this file goes along unchanged. +name: tests + +on: + push: + branches: [main, 'feat/**'] + pull_request: + +jobs: + pest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, pdo_sqlite, sodium, redis, bcmath, gd, zip + coverage: none + + - name: Install PHP dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Prepare environment + run: | + cp .env.example .env + php artisan key:generate + + - name: Tests + # phpunit.xml pins its own sqlite/array drivers, so no services needed. + run: ./vendor/bin/pest --colors=always + + assets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install JS dependencies + run: npm ci --no-fund --no-audit + + # A build failure here is what used to surface as "the design is broken" + # only after deploying — catch it before it ships. + - name: Build assets + run: npm run build + + release: + # Only a green run produces the tag the console offers as an update. + needs: [pest, assets] + if: gitea.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Tag this commit as tested + run: | + tag="tested-$(date -u +%Y%m%d-%H%M)-$(git rev-parse --short HEAD)" + git tag "$tag" + git push origin "$tag" diff --git a/deploy/update.sh b/deploy/update.sh index cbe9ee0..fc4002f 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -116,9 +116,11 @@ in_app npm run build # Before the restarts, not after: a service that starts while the old cache is # still on disk loads it and keeps those values for the life of its process. -log "Clearing caches" -in_app php artisan config:clear >/dev/null -in_app php artisan view:clear >/dev/null +log "Rebuilding caches" +# optimize:clear first, then optimize: a half-warm cache from before the update +# is what produces a page styled with assets that no longer exist. +in_app php artisan optimize:clear >/dev/null +in_app php artisan optimize >/dev/null log "Restarting services" docker compose up -d diff --git a/docker-compose.yml b/docker-compose.yml index 959dd0c..59c523b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,6 +93,24 @@ services: - redis - mariadb + # Gitea Actions runner. Opt-in via profile, because a development machine does + # not need to burn cycles on CI: docker compose --profile ci up -d runner + runner: + image: gitea/act_runner:latest + restart: unless-stopped + profiles: [ci] + environment: + GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-https://git.bave.dev} + GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_TOKEN:-} + GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-clupilot-local} + # ubuntu-latest maps to a real image rather than the host, so a workflow + # behaves the same here as anywhere else. + GITEA_RUNNER_LABELS: ubuntu-latest:docker://catthehacker/ubuntu:act-22.04 + volumes: + - runner-data:/data + # The runner starts job containers as siblings, which needs the socket. + - /var/run/docker.sock:/var/run/docker.sock + mariadb: image: mariadb:11.4 restart: unless-stopped @@ -138,5 +156,6 @@ services: volumes: db-data: + runner-data: redis-data: wireguard: diff --git a/lang/de/updating.php b/lang/de/updating.php new file mode 100644 index 0000000..e510c94 --- /dev/null +++ b/lang/de/updating.php @@ -0,0 +1,8 @@ + 'Aktualisierung läuft', + 'title' => 'Gleich wieder da.', + 'body' => 'Wir spielen gerade ein Update ein. Ihre Nextcloud läuft davon unberührt weiter — nur dieses Portal ist für einen Moment nicht erreichbar.', + 'auto' => 'Diese Seite lädt sich von selbst neu.', +]; diff --git a/lang/en/updating.php b/lang/en/updating.php new file mode 100644 index 0000000..11d2b6c --- /dev/null +++ b/lang/en/updating.php @@ -0,0 +1,8 @@ + 'Update in progress', + 'title' => 'Back in a moment.', + 'body' => 'We are applying an update. Your Nextcloud keeps running — only this portal is briefly unavailable.', + 'auto' => 'This page reloads itself.', +]; diff --git a/resources/js/app.js b/resources/js/app.js index bc27da2..e686b35 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -72,6 +72,27 @@ function applyLineGradients(cfg) { } } +// A page that was open across a deploy carries a Livewire snapshot and a CSRF +// token the new code no longer accepts. The user sees "419 Page Expired" and a +// dead interface; the session itself is still fine, so a reload fixes it. +// +// Hooked at the network layer rather than through Livewire's request hook: the +// hook's failure callback is not invoked for this case in every version, and a +// silent no-op here is exactly the bug we are trying to remove. +const originalFetch = window.fetch.bind(window); +window.fetch = async (...args) => { + const response = await originalFetch(...args); + + if (response.status === 419) { + const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? ''; + if (url.includes('/livewire/')) { + window.location.reload(); + } + } + + return response; +}; + document.addEventListener('alpine:init', () => { // ── VPN config: copy / download ────────────────────────────────────── // navigator.clipboard only exists in a secure context, so over plain http diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 0000000..02b74b7 --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1,30 @@ + + + + + + + {{-- Comes back on its own: an update takes a minute, and nobody should have + to guess whether to keep hitting reload. --}} + + {{ config('app.name') }} + + @vite(['resources/css/app.css']) + + +
+
+ +
+ +
+ + {{ __('updating.badge') }} +
+ +

{{ __('updating.title') }}

+

{{ __('updating.body') }}

+

{{ __('updating.auto') }}

+
+ + diff --git a/vite.config.js b/vite.config.js index cffd88e..bedcb33 100644 --- a/vite.config.js +++ b/vite.config.js @@ -18,6 +18,12 @@ export default defineConfig({ refresh: true, }), ], + build: { + // Not emptied on rebuild: a browser holding the previous page would + // otherwise 404 on its stylesheet the moment we deploy, which is exactly + // the "design is completely broken" symptom. Stale files are cheap. + emptyOutDir: false, + }, server: { host: '0.0.0.0', port: 5173,