feat(ops): update page, automatic 419 recovery, CI workflow

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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 00:58:27 +02:00
parent ac86efd9c3
commit 8117630a65
8 changed files with 166 additions and 3 deletions

View File

@ -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"

View File

@ -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

View File

@ -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:

8
lang/de/updating.php Normal file
View File

@ -0,0 +1,8 @@
<?php
return [
'badge' => '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.',
];

8
lang/en/updating.php Normal file
View File

@ -0,0 +1,8 @@
<?php
return [
'badge' => '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.',
];

View File

@ -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

View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
{{-- Comes back on its own: an update takes a minute, and nobody should have
to guess whether to keep hitting reload. --}}
<meta http-equiv="refresh" content="20">
<title>{{ config('app.name') }}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
@vite(['resources/css/app.css'])
</head>
<body class="grid min-h-full place-items-center bg-canvas px-6 py-16 text-center">
<main class="max-w-md">
<div class="mx-auto w-fit">
<x-ui.logo class="h-9" />
</div>
<div class="mx-auto mt-8 flex w-fit items-center gap-2 rounded-pill border border-accent-border bg-accent-bg px-3 py-1">
<span class="size-2 animate-pulse rounded-pill bg-accent" aria-hidden="true"></span>
<span class="text-xs font-semibold text-accent-text">{{ __('updating.badge') }}</span>
</div>
<h1 class="mt-5 text-2xl font-semibold tracking-tight text-ink">{{ __('updating.title') }}</h1>
<p class="mt-3 text-sm leading-relaxed text-muted">{{ __('updating.body') }}</p>
<p class="mt-8 text-xs text-faint">{{ __('updating.auto') }}</p>
</main>
</body>
</html>

View File

@ -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,