Fix-Welle: Schlussreview-Befunde 1-6 zur Release-Decke

Sechs Befunde aus dem Schlussreview, in einer Welle behoben:

- BEFUND 1 (Important): eine leere Auswahl im Festnageln-Feld liess
  pinRelease() ueber `$this->ceilingChoice ?: null` in setCeiling(null)
  laufen — die Gegenhandlung (Decke abnehmen) — und meldete dabei die
  Erfolgsmeldung des Festnagelns. ConfirmPinRelease::confirm() schickt die
  Version jetzt als Event-Nutzlast (wie ConfirmSaveSecret den Schluessel),
  und pinRelease(string $version) weist eine leere Version ausdruecklich
  ab, mit einer eigenen Meldung (release_pin_empty).
- BEFUND 2 (Minor, durch 1 miterledigt): Modal und Seite lasen bisher zwei
  getrennte Eigenschaften ($version vs. $ceilingChoice). Der Fix oben
  beseitigt die Trennung.
- BEFUND 3 (Important, Text only): der Kommentar bei release_tag_exists()
  in deploy/lib/release.sh und der Fehlerbehandlungs-Abschnitt der Spec
  behaupteten, ceiling_missing schuetze gegen einen vom Release-Prozess
  geloeschten Tag. Tut es nicht: `git fetch --tags --force` (ohne
  --prune-tags, bewusst) entfernt keine lokal bereits geholten Tags, die
  drueben verschwunden sind. Beide Stellen beschreiben jetzt, wogegen die
  Pruefung tatsaechlich schuetzt (ein nie geholter oder nie existierender
  Tag) und wogegen nicht. Kein --prune-tags hinzugefuegt.
- BEFUND 4 (Minor): ConfirmPinRelease hatte keinen Test. Zwei neue Tests
  nach dem Vorbild von ConfirmSaveSecret in IntegrationsPageTest.
- BEFUND 5 (Minor): ceilingChoice wurde nie aus dem gesetzten Zustand
  vorbelegt. UpdateChannel::ceiling() ist jetzt public, Settings::mount()
  belegt das Feld damit vor.
- BEFUND 6 (Minor): eine von Hand geleerte Deckendatei liest die Konsole
  als "keine Decke" (ceiling() -> null), der Agent meldet dafuer aber
  ceiling_error. Der "Decke abnehmen"-Knopf stand nur hinter
  @if($update['ceiling']) und verschwand damit genau in dem Zustand, aus
  dem er zurueckfuehren muesste. Bedingung erweitert auf
  ($update['ceiling'] || $update['ceiling_error']).

Jeder Befund traegt einen eigenen Test in ReleaseCeilingConsoleTest.php.
Volle Suite: 2973 passed (10389 assertions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 16:25:48 +02:00
parent 073e147ff8
commit 24eb7b3d80
9 changed files with 195 additions and 18 deletions

View File

@ -33,7 +33,14 @@ class ConfirmPinRelease extends ModalComponent
{
$this->authorize('site.manage');
$this->dispatch('pin-release-confirmed');
// Die Version geht als Event-Nutzlast mit, nicht nur als das, was
// dieses Modal auf dem Bildschirm zeigt — genau wie
// ConfirmSaveSecret::confirm() den Schlüssel mitschickt. Sonst
// bestätigt der Klick nur, DASS etwas festgenagelt wird, nicht WAS:
// die Seite las bisher ihr eigenes `ceilingChoice` erneut, und eine
// leere Auswahl dort landete unbestätigt in setCeiling(null) — der
// Gegenhandlung, nicht dem Festnageln.
$this->dispatch('pin-release-confirmed', version: $this->version);
$this->closeModal();
}

View File

@ -167,6 +167,12 @@ class Settings extends Component
$this->autoDays = $window->days();
$this->autoFrom = $window->from();
$this->autoTo = $window->to();
// Das Auswahlfeld zeigt, was tatsächlich gilt, statt auf "Neueste
// Version (nicht festgenagelt)" zu stehen, während die Zeile darunter
// "Festgenagelt auf 1.8.0" sagt — zwei Bedienelemente derselben
// Karte, die sich sonst widersprechen.
$this->ceilingChoice = app(UpdateChannel::class)->ceiling() ?? '';
}
/**
@ -730,9 +736,21 @@ class Settings extends Component
* `site.manage` wie `requestUpdate()`: festnageln entscheidet, welche
* Fassung dieser Server je bekommt, und ist damit dieselbe Entscheidung
* wie sie auszulösen nur früher.
*
* Die Version kommt als Parameter aus dem Event, nicht mehr aus
* `$this->ceilingChoice`: ConfirmPinRelease schickt mit, was im Modal
* tatsächlich bestätigt wurde (genau wie ConfirmSaveSecret den
* Schlüssel mitschickt). Eine leere Version wird HIER ausdrücklich
* abgewiesen, mit ihrer eigenen Meldung nicht länger `?: null` in
* setCeiling() gereicht, das eine leere Auswahl als "Decke abnehmen"
* gelesen und dabei die Festnageln-Erfolgsmeldung gezeigt hätte. Das
* Auswahlfeld trägt `<option value="">…nicht festgenagelt</option>`,
* und das Einzige, was einen Klick darauf bisher verhinderte, war ein
* Alpine-`disabled` kein Ersatz für eine serverseitige Prüfung, siehe
* `requestUpdate()` oben.
*/
#[On('pin-release-confirmed')]
public function pinRelease(): void
public function pinRelease(string $version): void
{
$this->authorize('site.manage');
@ -740,7 +758,13 @@ class Settings extends Component
return;
}
$accepted = app(UpdateChannel::class)->setCeiling($operator->email, $this->ceilingChoice ?: null);
if ($version === '') {
$this->dispatch('notify', message: __('admin_settings.release_pin_empty'));
return;
}
$accepted = app(UpdateChannel::class)->setCeiling($operator->email, $version);
$this->dispatch('notify', message: __($accepted
? 'admin_settings.release_pinned'

View File

@ -642,8 +642,14 @@ final class UpdateChannel
* ab, während dieser noch liest), wirft `File::get()` eine
* FileNotFoundException. Dasselbe Muster wie readJson() und lastLog()
* weiter unten: state() darf unter keinen Umständen werfen.
*
* Öffentlich, weil auch App\Livewire\Admin\Settings::mount() sie
* braucht um `ceilingChoice` mit der tatsächlich gesetzten Decke
* vorzubelegen, statt das Auswahlfeld auf einem festgenagelten Server
* "Neueste Version (nicht festgenagelt)" zeigen zu lassen, während der
* Satz darunter das Gegenteil sagt.
*/
private function ceiling(): ?string
public function ceiling(): ?string
{
try {
$path = storage_path('app/'.self::CEILING);

View File

@ -118,9 +118,21 @@ release_tags_ahead() {
# release_tag_exists TAG — true when TAG is a real tag in this checkout.
#
# Form und Existenz sind zwei Fragen. Eine Decke, die bloss AUSSIEHT wie eine
# Version, ist keine: der Release-Prozess loescht einen falschen Tag und
# ueberspringt die Nummer, eine Decke kann also ohne Zutun dessen, der sie
# gesetzt hat, ins Leere zeigen.
# Version, ist keine — ein Tag, den jemand von Hand in die Deckendatei
# geschrieben hat und der nie existiert hat, oder einer, den DIESER Wirt
# schlicht noch nie geholt hat.
#
# Was das NICHT abdeckt: einen Tag, der hier schon lag und auf der
# Gegenstelle geloescht wurde. Der Agent holt mit
# `git fetch --quiet --tags --force origin` (update-agent.sh), und das
# ENTFERNT keine lokalen Tags, die drueben verschwunden sind — dafuer
# braeuchte es `--prune --prune-tags`. Das ist hier absichtlich NICHT
# gesetzt: es waere eine Entscheidung darueber, was jede der zehn
# Server-Konsolen mit ihren eigenen lokalen Tags tut, sobald irgendwo einer
# zurueckgezogen wird — und die steht dem Besitzer zu, nicht diesem Helfer.
# `git rev-parse --verify` beantwortet also auch dann noch mit "ja", wenn
# der Tag auf der Gegenstelle laengst weg ist, solange dieser Wirt ihn
# irgendwann einmal geholt hatte.
release_tag_exists() {
[[ -n "${1-}" ]] || return 1
git rev-parse -q --verify "refs/tags/${1}^{commit}" >/dev/null 2>&1

View File

@ -154,9 +154,21 @@ weggenagelt hat. Stattdessen: `behind = 0`, nichts wird angeboten, und
Eine Sicherung, die im Zweifel öffnet, ist keine.
Dass ein Tag verschwindet, ist hier kein Randfall: der Release-Prozess sieht
ausdrücklich vor, dass ein **falscher Tag gelöscht und die Nummer übersprungen**
wird.
`ceiling_missing` ist **nicht** die Sicherung gegen ein zurückgezogenes
Release. Der Agent holt Tags mit `git fetch --quiet --tags --force origin`
(deploy/update-agent.sh), und das entfernt **keine** lokalen Tags, die auf
der Gegenstelle verschwunden sind — dafür bräuchte es
`--prune --prune-tags`, eine Änderung, die hier bewusst nicht gemacht ist:
sie würde festlegen, was jede der zehn Server-Konsolen mit ihren eigenen
lokalen Tags tut, sobald irgendwo ein Release zurückgezogen wird, und das ist
eine Entscheidung des Besitzers, keine dieser Spezifikation. Ein Tag, den ein
Wirt einmal geholt hat, bleibt für `release_tag_exists` (deploy/lib/release.sh)
also „vorhanden", auch nachdem er drüben gelöscht wurde.
Sinnvoll bleibt `ceiling_missing` trotzdem — nur gegen einen anderen Fall:
eine Decke, die auf einen Tag zeigt, den DIESER Wirt nie geholt hat (ein
Tippfehler, eine von Hand in die Datei geschriebene Version) oder der nie
existiert hat.
Weiter:
@ -175,7 +187,7 @@ Weiter:
|---|---|
| `ReleaseComparisonTest` (besteht) | `release_newest_tag`/`release_tags_ahead` mit Decke — die echten Bash-Helfer |
| neu, Agent | Echtes Skript gegen ein Wegwerf-Repo mit Tags: Decke gesetzt → `behind` zählt nur bis dorthin, `target_release` **ist** die Decke |
| neu, fällt zu | Decke zeigt auf gelöschten Tag`behind = 0` **und** `ceiling_error`, nicht „neueste" |
| neu, fällt zu | Decke zeigt auf einen Tag, den dieser Wirt nie geholt hat`behind = 0` **und** `ceiling_error`, nicht „neueste" |
| neu, Automatik | `clupilot:auto-update` mit gesetzter Decke tut nichts |
| neu, Konsole | Festnageln schreibt die Datei, „Decke abnehmen" löscht sie, ohne `site.manage` geht beides nicht |

View File

@ -165,6 +165,10 @@ return [
'release_pin_confirm' => 'Festnageln',
'release_pinned' => 'Festgenagelt.',
'release_pin_invalid' => 'Das ist keine gültige Version.',
// Eigene Meldung für eine leere Bestätigung — nicht die Erfolgsmeldung
// von oben. Eine leere Auswahl darf niemals als "Decke abnehmen" gelten,
// nur weil setCeiling(null) genau das bedeutet.
'release_pin_empty' => 'Keine Version ausgewählt — nichts wurde geändert.',
'release_pinned_at' => 'Festgenagelt auf :version.',
'release_pinned_passed' => 'Festgenagelt auf :version — dieser Server ist bereits weiter. Die Decke hält nichts mehr zurück; zurück geht es nicht.',
'release_unpin_action' => 'Decke abnehmen',

View File

@ -162,6 +162,10 @@ return [
'release_pin_confirm' => 'Pin',
'release_pinned' => 'Pinned.',
'release_pin_invalid' => 'That is not a valid release.',
// Its own message for an empty confirmation — not the success message
// above. An empty selection must never count as "remove the ceiling"
// just because setCeiling(null) means exactly that.
'release_pin_empty' => 'No release selected — nothing was changed.',
'release_pinned_at' => 'Pinned to :version.',
'release_pinned_passed' => 'Pinned to :version — this server is already past it. The ceiling no longer holds anything back; there is no way back.',
'release_unpin_action' => 'Remove ceiling',

View File

@ -207,12 +207,24 @@
nicht dieselbe Zeile mit anderer Zahl: wer v1.8.0 festgenagelt hat
und v1.8.1 laufen sieht, muss erfahren, dass die Decke nichts mehr
tut sonst liest er sie als Zusicherung, die sie nicht ist. Zurück
geht es nicht (update.sh:222). --}}
@if ($update['ceiling'])
geht es nicht (update.sh:222).
Der Knopf steht auch OHNE `$update['ceiling']`, sobald
`ceiling_error` etwas meldet: eine von Hand geleerte
Deckendatei liest die Konsole (UpdateChannel::ceiling())
als "keine Decke" leerer Inhalt wird zu null , während
der Agent dieselbe leere Datei als `ceiling_invalid`
zurückmeldet. Ohne diese Bedingung verschwand der einzige
Ort mit dem "Decke abnehmen"-Knopf genau in dem Zustand,
den nur eine Hand am Wirt erzeugen kann und aus dem die
Konsole ohne Kommandozeile zurückführen muss. --}}
@if ($update['ceiling'] || $update['ceiling_error'])
<p class="mt-3 text-xs text-muted">
{{ $update['ceiling_passed']
? __('admin_settings.release_pinned_passed', ['version' => ltrim($update['ceiling'], 'v')])
: __('admin_settings.release_pinned_at', ['version' => ltrim($update['ceiling'], 'v')]) }}
@if ($update['ceiling'])
{{ $update['ceiling_passed']
? __('admin_settings.release_pinned_passed', ['version' => ltrim($update['ceiling'], 'v')])
: __('admin_settings.release_pinned_at', ['version' => ltrim($update['ceiling'], 'v')]) }}
@endif
<button type="button" wire:click="unpinRelease" class="underline">{{ __('admin_settings.release_unpin_action') }}</button>
</p>
@endif

View File

@ -1,5 +1,6 @@
<?php
use App\Livewire\Admin\ConfirmPinRelease;
use App\Livewire\Admin\Settings;
use App\Models\Operator;
use App\Services\Deployment\UpdateChannel;
@ -208,7 +209,7 @@ it('pins from the console', function () {
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->set('ceilingChoice', 'v1.8.0')
->call('pinRelease');
->call('pinRelease', 'v1.8.0');
expect(trim(File::get(storage_path('app/deploy/release-ceiling'))))->toBe('v1.8.0');
});
@ -238,6 +239,101 @@ it('refuses to pin without site.manage', function () {
Livewire::actingAs($staff, 'operator')
->test(Settings::class)
->set('ceilingChoice', 'v1.8.0')
->call('pinRelease')
->call('pinRelease', 'v1.8.0')
->assertForbidden();
});
it('does not let an empty confirmation take the ceiling off while reporting success', function () {
// BEFUND 1: `setCeiling($operator->email, $this->ceilingChoice ?: null)`
// liess eine leere Auswahl als "Decke abnehmen" durchgehen — und meldete
// dabei `release_pinned`, die Erfolgsmeldung der GEGENHANDLUNG. Erreichbar
// war das, weil das Auswahlfeld `<option value="">` traegt und einzig ein
// Alpine-`x-bind:disabled` den Klick verhinderte — kein Ersatz fuer eine
// serverseitige Pruefung, siehe requestUpdate() im selben Bauteil.
//
// Die Decke steht bereits auf v1.8.0. Eine leere Bestaetigung darf sie
// weder loeschen noch die Festnageln-Erfolgsmeldung zeigen.
$owner = Operator::factory()->role('Owner')->create();
File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0');
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->call('pinRelease', '')
->assertDispatched('notify', message: __('admin_settings.release_pin_empty'));
expect(trim(File::get(storage_path('app/deploy/release-ceiling'))))->toBe('v1.8.0');
});
it('prefills the select with the ceiling that is actually set', function () {
// BEFUND 5: `ceilingChoice` wurde nie aus dem gesetzten Zustand
// vorbelegt. Auf einem festgenagelten Server las das Feld "Neueste
// Version (nicht festgenagelt)", waehrend die Zeile darunter
// "Festgenagelt auf 1.8.0" sagte — zwei Bedienelemente derselben Karte,
// die sich widersprachen.
File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0');
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->assertSet('ceilingChoice', 'v1.8.0');
});
it('leaves the select empty when nothing is pinned', function () {
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->assertSet('ceilingChoice', '');
});
it('keeps the "remove ceiling" button reachable when the file reads back empty', function () {
// BEFUND 6: Bash wertet eine leere Deckendatei als `ceiling_invalid`;
// PHP (UpdateChannel::ceiling()) liefert dafuer `null` — leerer Inhalt
// wird als "keine Decke" gelesen. Die Konsole zeigte dann zwar den
// Warnkasten, aber der `@if ($update['ceiling'])`-Block — der einzige
// Ort mit dem Knopf "Decke abnehmen" — wurde uebersprungen. Nur eine
// Hand am Wirt kann diesen Zustand erzeugen (eine leere Datei), aber die
// Konsole muss trotzdem einen Weg zurueck bieten, ohne Kommandozeile.
File::put(storage_path('app/deploy/release-ceiling'), '');
File::put(storage_path('app/deploy/update-status.json'), json_encode([
'state' => 'idle',
'ceiling_error' => 'ceiling_invalid',
'releases' => ['v1.8.1', 'v1.8.0'],
]));
$owner = Operator::factory()->role('Owner')->create();
// Die Konsole selbst liest die leere Datei als "keine Decke" — der Bug
// sass genau in diesem Widerspruch zwischen den beiden Seiten.
expect(app(UpdateChannel::class)->state()['ceiling'])->toBeNull();
Livewire::actingAs($owner, 'operator')
->test(Settings::class)
->assertSeeHtml('wire:click="unpinRelease"')
->call('unpinRelease');
expect(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse();
});
it('does not open the pin confirmation without the capability', function () {
// Nach dem Vorbild von IntegrationsPageTest::"does not open the
// save/forget confirmation without the capability" fuer ConfirmSaveSecret
// — ConfirmPinRelease trug dieselbe authorize('site.manage') in mount(),
// ohne dass irgendein Test sie je geprueft hat (BEFUND 4).
$staff = Operator::factory()->create();
Livewire::actingAs($staff, 'operator')
->test(ConfirmPinRelease::class, ['version' => 'v1.8.0'])
->assertForbidden();
});
it('confirms a pin through the modal without writing anything itself', function () {
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(ConfirmPinRelease::class, ['version' => 'v1.8.0'])
->assertSee('1.8.0')
->call('confirm')
->assertDispatched('pin-release-confirmed', version: 'v1.8.0');
expect(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse();
});