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
parent
ac86efd9c3
commit
8117630a65
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue