Re-apply what the last release commit silently reverted
tests / pest (push) Failing after 7m54s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

The English paths and the zone migration went out in 5f74a5f and were gone
again in the tag. Reported for the fourth time, and the fourth report was
right: nothing had changed on the server.

What happened is mine and worth writing down. I have been building commits in
a private index (GIT_INDEX_FILE) to avoid staging a parallel session's
in-flight work, and moving the branch with `git update-ref`. That moves HEAD
without touching the shared index — so the index still described the PREVIOUS
commit. The very next ordinary `git commit` (the version bump) wrote that
stale index as a tree, which reverted every file of the commit before it. The
tag was cut from the revert.

Two things follow. The private-index technique is only safe while the branch
is not checked out; once it is, `git branch -f` refuses for exactly this
reason and `update-ref` is the same operation with the safety removed. And a
release is not verified by a green push — it is verified by reading the tag,
which is what finally found this.

Content unchanged from 5f74a5f: English fragments and legal paths with
permanent redirects, the guarded zone migration, and the test that walks every
route and fragment so a German path fails the suite instead of being reported
again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus v1.3.23
nexxo 2026-07-29 17:36:43 +02:00
parent 1620cb9395
commit 98afd1d7e0
9 changed files with 144 additions and 25 deletions

View File

@ -1 +1 @@
1.3.22
1.3.23

View File

@ -0,0 +1,59 @@
<?php
use App\Models\Instance;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Log;
/**
* Puts customer instances on their own registrable domain.
*
* The zone has been a setting all along, and saying "change it in the console"
* has now been said four times without the thing actually changing which is
* a fair complaint. An installation that has nothing to migrate should simply
* arrive on the new zone.
*
* Guarded by exactly the condition `clupilot:check-zone` reports on: with no
* instance in service there is nothing carrying the old name no DNS record,
* no certificate, no trusted_domains entry so the change is free. With even
* one, this does nothing and says so: re-addressing a running Nextcloud is a
* migration with an outage, and a migration is not the place to start one.
*/
return new class extends Migration
{
private const OLD = 'clupilot.com';
private const NEW = 'clupilot.cloud';
public function up(): void
{
$current = ProvisioningSettings::dnsZone();
// Somebody else's installation, or one already moved. Not ours to
// touch — the whole point of the zone being a setting is that another
// operator may run this on their own domain.
if ($current !== self::OLD) {
return;
}
$inService = Instance::query()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->count();
if ($inService > 0) {
Log::warning('Instance zone left on '.self::OLD.': '.$inService.' instance(s) in service carry it in DNS, certificates and trusted_domains. Run clupilot:check-zone and plan a window.');
return;
}
Settings::set('provisioning.dns_zone', self::NEW);
}
public function down(): void
{
if (ProvisioningSettings::dnsZone() === self::NEW) {
Settings::set('provisioning.dns_zone', self::OLD);
}
}
};

View File

@ -42,14 +42,14 @@
<a href="{{ route('home') }}"><x-ui.brand size="md" /></a>
<nav class="hidden items-center gap-1 text-sm text-muted lg:flex">
@foreach (['#produkt' => 'Produkt', '#ablauf' => 'Ablauf', '#sicherheit' => 'Sicherheit', '#preise' => 'Preise', '#fragen' => 'Fragen'] as $href => $label)
@foreach (['#product' => 'Produkt', '#process' => 'Ablauf', '#security' => 'Sicherheit', '#pricing' => 'Preise', '#faq' => 'Fragen'] as $href => $label)
<a href="{{ route('home') }}{{ $href }}" class="rounded px-3 py-1.5 transition-colors hover:bg-surface-hover hover:text-ink">{{ $label }}</a>
@endforeach
</nav>
<div class="ml-auto flex items-center gap-2">
<a href="{{ route('login') }}" class="hidden rounded px-3 py-1.5 text-sm text-muted transition-colors hover:bg-surface-hover hover:text-ink sm:block">Anmelden</a>
<x-ui.button href="{{ route('home') }}#preise" variant="ink" size="sm">Preise</x-ui.button>
<x-ui.button href="{{ route('home') }}#pricing" variant="ink" size="sm">Preise</x-ui.button>
<button type="button" class="grid size-9 place-items-center rounded text-muted transition-colors hover:bg-surface-hover hover:text-ink lg:hidden" @click="open = !open" :aria-expanded="open" aria-label="Menü">
<x-ui.icon name="menu" class="size-5" x-show="!open" />
<x-ui.icon name="x" class="size-5" x-show="open" x-cloak />
@ -58,7 +58,7 @@
</div>
<nav x-show="open" x-cloak @click="open = false" class="border-t border-line px-4 pb-2 lg:hidden">
@foreach (['#produkt' => 'Produkt', '#ablauf' => 'Ablauf', '#sicherheit' => 'Sicherheit', '#preise' => 'Preise', '#fragen' => 'Fragen', route('login') => 'Anmelden'] as $href => $label)
@foreach (['#product' => 'Produkt', '#process' => 'Ablauf', '#security' => 'Sicherheit', '#pricing' => 'Preise', '#faq' => 'Fragen', route('login') => 'Anmelden'] as $href => $label)
<a href="{{ Str::startsWith($href, '#') ? route('home').$href : $href }}" class="flex min-h-11 items-center border-b border-line text-sm text-body last:border-0">{{ $label }}</a>
@endforeach
</nav>
@ -86,10 +86,10 @@
<div>
<p class="lbl">Produkt</p>
<div class="mt-4 flex flex-col gap-2.5 text-sm text-muted">
<a href="{{ route('home') }}#produkt" class="transition-colors hover:text-ink">Was enthalten ist</a>
<a href="{{ route('home') }}#ablauf" class="transition-colors hover:text-ink">Ablauf</a>
<a href="{{ route('home') }}#sicherheit" class="transition-colors hover:text-ink">Sicherheit</a>
<a href="{{ route('home') }}#preise" class="transition-colors hover:text-ink">Preise</a>
<a href="{{ route('home') }}#product" class="transition-colors hover:text-ink">Was enthalten ist</a>
<a href="{{ route('home') }}#process" class="transition-colors hover:text-ink">Ablauf</a>
<a href="{{ route('home') }}#security" class="transition-colors hover:text-ink">Sicherheit</a>
<a href="{{ route('home') }}#pricing" class="transition-colors hover:text-ink">Preise</a>
</div>
</div>

View File

@ -25,7 +25,7 @@
<div class="pointer-events-none absolute -top-56 left-1/2 size-[720px] -translate-x-1/2 rounded-pill bg-accent opacity-[0.09] blur-3xl" aria-hidden="true"></div>
<div class="relative mx-auto max-w-[1120px] px-5 pt-36 text-center sm:px-6 lg:pt-44">
<a href="#sicherheit" class="lbl inline-flex items-center gap-2 rounded-pill border border-line bg-surface px-3.5 py-2 shadow-xs transition-colors hover:border-accent-border">
<a href="#security" class="lbl inline-flex items-center gap-2 rounded-pill border border-line bg-surface px-3.5 py-2 shadow-xs transition-colors hover:border-accent-border">
<span class="relative flex size-1.5">
<span class="absolute inline-flex size-full animate-ping rounded-pill bg-success-bright opacity-60"></span>
<span class="relative inline-flex size-1.5 rounded-pill bg-success-bright"></span>
@ -42,8 +42,8 @@
</p>
<div class="mt-9 flex flex-wrap justify-center gap-3">
<x-ui.button href="#preise" variant="ink" size="lg">Preise ansehen</x-ui.button>
<x-ui.button href="#kontakt" variant="secondary" size="lg">Erstgespräch vereinbaren</x-ui.button>
<x-ui.button href="#pricing" variant="ink" size="lg">Preise ansehen</x-ui.button>
<x-ui.button href="#contact" variant="secondary" size="lg">Erstgespräch vereinbaren</x-ui.button>
</div>
<p class="mt-4 text-sm text-muted">Fixer Preis pro Firma · keine Abrechnung pro Kopf</p>
</div>
@ -150,7 +150,7 @@
Six equal cards in two rows was the old page's answer to every section.
Tiles of different weight let the important thing be bigger than the rest,
which is the whole point of having a layout. --}}
<section id="produkt" class="mx-auto max-w-[1120px] px-5 py-24 sm:px-6 lg:py-32">
<section id="product" class="mx-auto max-w-[1120px] px-5 py-24 sm:px-6 lg:py-32">
<div class="rv max-w-[46ch]">
<p class="lbl !text-accent-text">Was enthalten ist</p>
<h2 class="mt-3 text-[clamp(2rem,4vw,3.1rem)] font-bold leading-[1.06] tracking-[-0.035em] text-ink">
@ -260,7 +260,7 @@
</section>
{{-- ════ The dark hinge how it arrives ════════════════════════════════ --}}
<section id="ablauf" class="bg-plate py-24 text-plate-text lg:py-32">
<section id="process" class="bg-plate py-24 text-plate-text lg:py-32">
<div class="mx-auto max-w-[1120px] px-5 sm:px-6">
{{-- No clock anywhere in this section any more.
"In 20 Minuten betriebsbereit", a log with minute marks down the
@ -341,7 +341,7 @@
A three-column table was the old page's version of this. What a visitor
needs from it is the pairing measure, rhythm, and the piece of paper
they can hand to somebody so the pairing is what the row shows. --}}
<section id="sicherheit" class="mx-auto max-w-[1120px] px-5 py-24 sm:px-6 lg:py-32">
<section id="security" class="mx-auto max-w-[1120px] px-5 py-24 sm:px-6 lg:py-32">
<div class="rv max-w-[46ch]">
<p class="lbl !text-accent-text">Sicherheit &amp; Nachweis</p>
<h2 class="mt-3 text-[clamp(2rem,4vw,3.1rem)] font-bold leading-[1.06] tracking-[-0.035em] text-ink">
@ -456,7 +456,7 @@
Cards, with the matrix folded away underneath. The matrix was the first
thing a visitor met on the old page: twelve rows of ticks before a single
price was legible. --}}
<section id="preise" class="mx-auto max-w-[1120px] px-5 py-24 sm:px-6 lg:py-32">
<section id="pricing" class="mx-auto max-w-[1120px] px-5 py-24 sm:px-6 lg:py-32">
<div class="rv mx-auto max-w-[46ch] text-center">
<p class="lbl !text-accent-text">Preise</p>
<h2 class="mt-3 text-[clamp(2rem,4vw,3.1rem)] font-bold leading-[1.06] tracking-[-0.035em] text-ink">
@ -475,7 +475,7 @@
<p class="mx-auto mt-3 max-w-[52ch] text-sm leading-relaxed text-muted">
Die Paketübersicht steht gerade nicht zur Verfügung. Wir nennen Ihnen die aktuellen Konditionen gerne direkt.
</p>
<x-ui.button href="#kontakt" variant="ink" size="lg" class="mt-7">Preise anfragen</x-ui.button>
<x-ui.button href="#contact" variant="ink" size="lg" class="mt-7">Preise anfragen</x-ui.button>
</div>
@else
@php
@ -540,7 +540,7 @@
@endif
</ul>
<x-ui.button href="#kontakt" :variant="$plan['recommended'] ? 'ink' : 'secondary'" class="mt-7 w-full">
<x-ui.button href="#contact" :variant="$plan['recommended'] ? 'ink' : 'secondary'" class="mt-7 w-full">
{{ $plan['name'] }} anfragen
</x-ui.button>
</div>
@ -669,7 +669,7 @@
</section>
{{-- ════ Questions ══════════════════════════════════════════════════════ --}}
<section id="fragen" class="border-t border-line bg-surface-2 py-24 lg:py-32">
<section id="faq" class="border-t border-line bg-surface-2 py-24 lg:py-32">
<div class="mx-auto grid max-w-[1120px] gap-12 px-5 sm:px-6 lg:grid-cols-[.75fr_1.25fr] lg:gap-20">
<div class="rv lg:sticky lg:top-28 lg:self-start">
<p class="lbl !text-accent-text">Häufige Fragen</p>
@ -709,7 +709,7 @@
{{-- ════ Close ══════════════════════════════════════════════════════════
The one large accent area. Orange appears in few places on this page, and
this is allowed to be all of it. --}}
<section id="kontakt" class="bg-accent-active">
<section id="contact" class="bg-accent-active">
<div class="mx-auto max-w-[1120px] px-5 py-24 text-center sm:px-6 lg:py-32">
{{-- The old headline asked who you call when a folder is missing on a
Monday morning. It promised the wrong thing: what happens inside a
@ -736,7 +736,7 @@
<a href="mailto:office&#64;clupilot.com" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] bg-white px-[22px] text-[15px] font-semibold leading-none text-accent-active transition hover:bg-accent-subtle">
<x-ui.icon name="mail" class="size-4" />Erstgespräch anfragen
</a>
<a href="#preise" class="inline-flex min-h-[46px] items-center justify-center rounded-[10px] border border-white/40 px-[22px] text-[15px] font-semibold leading-none text-white transition hover:bg-white/10">
<a href="#pricing" class="inline-flex min-h-[46px] items-center justify-center rounded-[10px] border border-white/40 px-[22px] text-[15px] font-semibold leading-none text-white transition hover:bg-white/10">
Preise ansehen
</a>
</div>

View File

@ -203,9 +203,18 @@ $publicSite = function () {
// what makes route('legal.impressum') generate a www URL from a queued
// mail, where there is no request to take a hostname from.
Route::prefix('legal')->name('legal.')->group(function () {
Route::get('/impressum', fn () => view('legal', ['title' => 'Impressum']))->name('impressum');
Route::get('/datenschutz', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz');
Route::get('/agb', fn () => view('legal', ['title' => 'AGB']))->name('agb');
// R13: paths are English, headings are not. The page still says
// "Impressum" — that is the legal term an Austrian company must use —
// but the address is not part of the copy.
Route::get('/imprint', fn () => view('legal', ['title' => 'Impressum']))->name('impressum');
Route::get('/privacy', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz');
Route::get('/terms', fn () => view('legal', ['title' => 'AGB']))->name('agb');
// The German paths these shipped under. Permanent, because they have
// been in mail footers and in the site footer for weeks.
foreach (['impressum' => 'impressum', 'datenschutz' => 'datenschutz', 'agb' => 'agb'] as $old => $to) {
Route::get('/'.$old, fn () => redirect()->route('legal.'.$to, status: 301));
}
// Kept so existing links and bookmarks do not 404 — permanently,
// because the page has genuinely moved.
Route::get('/status', fn () => redirect()->to(route('status'), 301))->name('status');

View File

@ -309,6 +309,10 @@ it('binds the form to a key Livewire can actually write to', function () {
// ---- Plain settings: ported from the former InfrastructureSettingsTest. ----
it('loads the .env-derived value when nothing has been saved from the console yet', function () {
// The premise has to be established now: a migration moves customer
// instances onto their own zone on a fresh install, so "nothing saved" is
// no longer the state a new database is in.
App\Support\Settings::forget('provisioning.dns_zone');
config()->set('provisioning.dns.zone', 'env-zone.example');
Livewire::actingAs(operator('Owner'), 'operator')

View File

@ -233,7 +233,10 @@ it('shows one support level per plan, and never a dash for the best of them', fu
it('shows the platform address in the shape a customer will type it, under the configured zone', function () {
// "Eigene CluPilot-Subdomain" as a table row was meaningless twice over:
// every instance gets one, and the phrase does not say what it looks like.
config()->set('provisioning.dns.zone', 'wolke.test');
// Through the setting, not the config: the setting is what
// ProvisioningSettings::dnsZone() reads first, and since instances moved to
// their own zone there is a row in it on every installation.
App\Support\Settings::set('provisioning.dns_zone', 'wolke.test');
$this->get('/')
->assertOk()

View File

@ -82,3 +82,45 @@ it('says on the sign-in form which host it is', function () {
->assertOk()
->assertSee(__('auth.phishing_link'));
});
it('puts customer instances on their own registrable domain out of the box', function () {
// Asked for four times, and "change it in the console" was the wrong
// answer each time: an installation with nothing to migrate should simply
// arrive on the new zone. The migration does it, guarded by exactly the
// condition clupilot:check-zone reports on.
expect(App\Support\ProvisioningSettings::dnsZone())->toBe('clupilot.cloud');
});
it('keeps every public path in English', function () {
// R13, applied to ALL of them this time rather than to the one that was
// pointed at. A fragment is part of a URL too, and the section anchors were
// German for weeks after the rule was first cited.
$routes = collect(app('router')->getRoutes())
->map(fn ($route) => $route->uri())
->filter(fn (string $uri) => ! str_starts_with($uri, '_'));
$german = ['sicherheit', 'impressum', 'datenschutz', 'agb', 'preise', 'produkt', 'ablauf', 'fragen', 'kontakt', 'einstellungen', 'rechnungen'];
foreach ($routes as $uri) {
foreach ($german as $word) {
// The redirects that keep old addresses alive are the exception,
// and they are the only routes allowed to carry these words.
$isRedirect = collect(app('router')->getRoutes())
->first(fn ($r) => $r->uri() === $uri)?->getActionName() === 'Closure';
if (str_contains($uri, $word) && ! $isRedirect) {
expect($uri)->toBe("no German path: {$uri}");
}
}
}
// And the fragments the site links to.
foreach (['landing.blade.php', 'components/layouts/site.blade.php'] as $view) {
$source = Illuminate\Support\Facades\File::get(resource_path('views/'.$view));
foreach (['#produkt', '#ablauf', '#sicherheit', '#preise', '#fragen', '#kontakt'] as $fragment) {
expect($source)->not->toContain($fragment.'"')
->and($source)->not->toContain($fragment."'");
}
}
});

View File

@ -32,7 +32,9 @@ it('binds the cloud card to the real instance', function () {
$this->actingAs($user)->get(route('cloud'))
->assertOk()
->assertSee('acme-ag.'.config('provisioning.dns.zone')) // real subdomain, not a fixture
// Through the same accessor the page uses. Reading config() directly
// stopped matching when the zone moved into the settings table.
->assertSee('acme-ag.'.App\Support\ProvisioningSettings::dnsZone()) // real subdomain, not a fixture
->assertSee(__('billing.plan.business'))
->assertSee(__('billing.perf.high'));
});