diff --git a/docs/superpowers/plans/2026-08-04-release-decke.md b/docs/superpowers/plans/2026-08-04-release-decke.md new file mode 100644 index 0000000..5629256 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-release-decke.md @@ -0,0 +1,1071 @@ +# Release-Decke Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ein Betreiber nagelt einen CluPilot-Server aus der Konsole auf eine bestimmte Release-Version fest, statt immer die neueste zu bekommen — damit sich eine Auslieferung über zehn Server staffeln lässt. + +**Architecture:** Eine Datei `storage/app/deploy/release-ceiling` im Checkout hält die Obergrenze. Der Agent auf dem Wirt liest sie und klemmt damit `TARGET_RELEASE` und `BEHIND`. Weil `deploy/update-agent.sh:669` bereits `RELEASE="$TARGET_RELEASE"` an `update.sh` übergibt und sowohl der Knopf als auch `clupilot:auto-update` ihren Zustand aus `UpdateChannel::state()` beziehen, folgt der Rest ohne zweiten Weg in eine Auslieferung. + +**Tech Stack:** Bash (Agent + `deploy/lib/release.sh`), Laravel 13.8, Livewire 3, Pest, Tailwind v4. + +## Global Constraints + +- **Kein Rückschritt.** `deploy/update.sh:222` weist einen Rückschritt ab; daran wird nichts geändert. Die Decke begrenzt nur nach oben. +- **Die Decke fällt zu, nicht auf.** Unlesbar, formwidrig oder ins Leere zeigend ⇒ `behind = 0` und ein Fehlercode. Nie Rückfall auf „neueste Version". +- **Kein zweiter Weg in eine Auslieferung.** Knopf und Automatik lesen denselben `state()`. Es wird keine Decken-Sonderregel in `AutoUpdate` oder `requestUpdate()` eingebaut. +- **Bash-Fallen in `deploy/lib/release.sh`:** Schleifenrümpfe enden mit `if`, nie mit `&&` — sonst verlässt die letzte Iteration die Schleife mit Status ≠ 0, `pipefail` trägt ihn heraus und `set -e` beendet den Agenten. Keine Pipelines mit `head`; Prozess-Substitution stattdessen (SIGPIPE). +- **R23:** Bestätigung im Modal, kein `wire:confirm`, kein `confirm(` in JavaScript. +- **R21:** Operator-Guard (`Auth::guard('operator')`), Berechtigung `site.manage` — sie liegt am **`operator`**-Guard bei den Rollen `Owner` und `Admin`. **Keine neue Berechtigung anlegen:** Festnageln entscheidet, welche Fassung dieser Server je bekommt, und ist damit dieselbe Entscheidung wie ein Update auszulösen, nur früher. +- **Atomar schreiben:** `.tmp` + `rename`. `UpdateChannel::write()` benutzt `File::put` und ist dafür **nicht** geeignet. +- **Sprachdateien:** jede Zeichenkette in `lang/de/admin_settings.php` **und** `lang/en/admin_settings.php`. +- **Testlauf:** `docker compose exec -T -u 1000:1000 app php artisan test --filter=` +- **Vor jedem Commit:** `pwd`, `git branch --show-current`. Nie `git add -A` — es laufen mehrere Sitzungen in diesem Arbeitsbaum. Nur die eigenen Pfade nennen. + +--- + +## File Structure + +| Datei | Verantwortung | +|---|---| +| `deploy/lib/release.sh` | Versions-Arithmetik, jetzt decken-fähig. Einzige Stelle, die Tags vergleicht. | +| `deploy/update-agent.sh` | Liest und prüft die Decke, klemmt Ziel und Zähler, meldet sie in `update-status.json`. | +| `app/Services/Deployment/UpdateChannel.php` | Schreibt/entfernt die Decke atomar, reicht sie an die Konsole durch. | +| `app/Livewire/Admin/Settings.php` | Aktionen `pinRelease` / `unpinRelease`, Berechtigung. | +| `app/Livewire/Admin/ConfirmPinRelease.php` | Bestätigungs-Modal (R23). | +| `resources/views/livewire/admin/settings.blade.php` | Auswahlfeld + Knöpfe im bestehenden Update-Abschnitt. | +| `tests/Feature/ReleaseComparisonTest.php` | Bash-Helfer mit Decke (bestehender Prüfstand `runRelease`). | +| `tests/Feature/ReleaseCeilingAgentTest.php` | Der echte Agent gegen ein Wegwerf-Repo. | +| `tests/Feature/ReleaseCeilingConsoleTest.php` | Kanal, Automatik und Konsole. | + +--- + +### Task 1: Decken-fähige Versions-Arithmetik + +**Files:** +- Modify: `deploy/lib/release.sh:54-95` +- Test: `tests/Feature/ReleaseComparisonTest.php` + +**Interfaces:** +- Consumes: `release_version_gt A B` (besteht) +- Produces: + - `release_newest_tag [CEILING]` → höchster `v*`-Tag, der nicht über CEILING liegt; leer wenn keiner + - `release_tags_ahead VERSION [CEILING]` → Anzahl Tags > VERSION und ≤ CEILING + - `release_tag_exists TAG` → Status 0, wenn der Tag existiert + - `release_tags_from VERSION [LIMIT=20]` → wählbare Tags, neueste zuerst, VERSION **eingeschlossen**, je Zeile einer + +- [ ] **Step 1: Write the failing tests** + +An `tests/Feature/ReleaseComparisonTest.php` anhängen (der Prüfstand `runRelease($snippet, $tags)` steht oben in der Datei): + +```php +it('stops at the ceiling instead of taking the newest tag', function () { + // Der Kern des Festnagelns: v1.8.1 existiert, darf aber nicht genommen + // werden, weil die Decke auf v1.8.0 steht. + $p = runRelease('release_newest_tag v1.8.0', ['v1.7.3', 'v1.8.0', 'v1.8.1']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe('v1.8.0'); +}); + +it('takes the newest tag when no ceiling is given', function () { + // Das bisherige Verhalten muss unveraendert bleiben — ohne Decke aendert + // sich nichts an einem Server, der nie festgenagelt wurde. + $p = runRelease('release_newest_tag', ['v1.7.3', 'v1.8.0', 'v1.8.1']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe('v1.8.1'); +}); + +it('answers nothing when every tag is above the ceiling', function () { + $p = runRelease('release_newest_tag v1.0.0', ['v1.7.3', 'v1.8.1']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe(''); +}); + +it('counts only the tags up to the ceiling as ahead', function () { + // Ausgeliefert 1.7.3, Decke v1.8.0, vorhanden bis v1.8.2: genau EINE + // Aktualisierung ist verfuegbar, nicht drei. + $p = runRelease('release_tags_ahead 1.7.3 v1.8.0', ['v1.7.3', 'v1.8.0', 'v1.8.1', 'v1.8.2']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe('1'); +}); + +it('counts nothing ahead when the ceiling equals what is deployed', function () { + // Der haeufigste Fall: "hier einfrieren". Muss sich wie "aktuell" + // rechnen, sonst bietet die Konsole etwas an, das nie installiert wird. + $p = runRelease('release_tags_ahead 1.8.0 v1.8.0', ['v1.8.0', 'v1.8.1', 'v1.8.2']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe('0'); +}); + +it('knows whether a tag really exists', function () { + // Form und Existenz sind zwei Fragen. Der Release-Prozess loescht einen + // falschen Tag und ueberspringt die Nummer — eine Decke kann also ohne + // Zutun ihres Setzers ins Leere zeigen. + $p = runRelease('release_tag_exists v1.8.0 && echo ja; release_tag_exists v9.9.9 || echo nein', ['v1.8.0']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe("ja\nnein"); +}); + +it('offers the deployed version itself as a ceiling, newest first', function () { + // Die ausgelieferte Version steht MIT in der Liste: "einfrieren, nichts + // Neues nehmen" ist das, was neun von zehn Servern brauchen. + $p = runRelease('release_tags_from 1.8.0', ['v1.7.3', 'v1.8.0', 'v1.8.1']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe("v1.8.1\nv1.8.0"); +}); + +it('caps the offered list', function () { + $p = runRelease('release_tags_from 1.0.0 2', ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v1.3.0']); + + expect($p->getExitCode())->toBe(0) + ->and(trim($p->getOutput()))->toBe("v1.3.0\nv1.2.0"); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `docker compose exec -T -u 1000:1000 app php artisan test --filter=ReleaseComparison` +Expected: FAIL — `release_tag_exists`/`release_tags_from` gibt es nicht, und die Decken-Argumente werden ignoriert. + +- [ ] **Step 3: Write the implementation** + +In `deploy/lib/release.sh` `release_newest_tag` und `release_tags_ahead` ersetzen und die zwei neuen Helfer dahinter setzen: + +```bash +# release_newest_tag [CEILING] — the highest v* tag by version order, or nothing. +# +# Mit CEILING: der hoechste Tag, der NICHT darueber liegt. Das ist die ganze +# Mechanik des Festnagelns — der Agent fragt nach dem neuesten Tag, den er +# nehmen DARF, und `behind`, der Knopf und das Wartungsfenster fallen alle aus +# dieser einen Antwort. +# +# Deliberately not `git tag … | head -1`. head exits after the first line, git +# gets SIGPIPE, and under `set -o pipefail` that non-zero status leaves the +# command substitution and ends the caller — the same way the tag count did, +# and the same trap install-agent.sh already carries a warning about. A read +# loop over a process substitution is not a pipeline and cannot do it. +release_newest_tag() { + local ceiling="${1-}" tag + + while read -r tag; do + [[ -n "$tag" ]] || continue + if [[ -n "$ceiling" ]] && release_version_gt "${tag#v}" "${ceiling#v}"; then + continue + fi + printf '%s' "$tag" + return 0 + done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null) + + return 0 +} + +# release_tags_ahead VERSION [CEILING] — how many v* tags are higher than +# VERSION, counting only up to CEILING. +# +# Lives here rather than inline in the agent because it has to be testable. The +# inline version killed the update agent on every tick: the loop body was +# `release_version_gt … && echo`, so the LAST iteration — almost always a tag +# that is not newer — left the loop with a non-zero status, `pipefail` carried +# it out of the pipeline, the command substitution inherited it, and `set -e` +# ended the agent before it could write a status. +# +# `if` rather than `&&` is the whole fix: an if whose condition is false still +# returns 0. Anything added here must keep that property — auch die +# Decken-Pruefung unten ist deshalb ein verschachteltes `if` und kein `&&`. +release_tags_ahead() { + local current="$1" ceiling="${2-}" tag count=0 + + while read -r tag; do + [[ -n "$tag" ]] || continue + if release_version_gt "${tag#v}" "$current"; then + if [[ -z "$ceiling" ]] || ! release_version_gt "${tag#v}" "${ceiling#v}"; then + count=$((count + 1)) + fi + fi + done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null) + + printf '%s' "$count" +} + +# 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. +release_tag_exists() { + [[ -n "${1-}" ]] || return 1 + git rev-parse -q --verify "refs/tags/${1}^{commit}" >/dev/null 2>&1 +} + +# release_tags_from VERSION [LIMIT] — die Tags, auf die festgenagelt werden +# darf: VERSION selbst und alles darueber, neueste zuerst, einer je Zeile. +# +# VERSION ist EINGESCHLOSSEN, und das ist der haeufigste Fall: „hier +# einfrieren, nichts Neues nehmen" ist das, was neun von zehn Servern wollen, +# waehrend der zehnte die neue Fassung bekommt. +# +# LIMIT, weil die Statusdatei sonst mit der Tag-Historie mitwaechst. +release_tags_from() { + local current="$1" limit="${2:-20}" tag count=0 out='' + + while read -r tag; do + [[ -n "$tag" ]] || continue + if [[ "${tag#v}" == "$current" ]] || release_version_gt "${tag#v}" "$current"; then + out+="${tag}"$'\n' + count=$((count + 1)) + if (( count >= limit )); then + break + fi + fi + done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null) + + printf '%s' "$out" +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `docker compose exec -T -u 1000:1000 app php artisan test --filter=ReleaseComparison` +Expected: PASS, alle bisherigen Fälle eingeschlossen. + +- [ ] **Step 5: Commit** + +```bash +git add deploy/lib/release.sh tests/Feature/ReleaseComparisonTest.php +git commit -m "Versions-Arithmetik kennt eine Decke" +``` + +--- + +### Task 2: Der Agent liest die Decke, prüft sie und klemmt daran + +**Files:** +- Modify: `deploy/update-agent.sh` (Zustandspfade ~Z. 45, `write_status` ~Z. 371, Fetch-Block ~Z. 445-480) +- Test: `tests/Feature/ReleaseCeilingAgentTest.php` (neu) + +**Interfaces:** +- Consumes: `release_newest_tag`, `release_tags_ahead`, `release_tag_exists`, `release_tags_from` aus Task 1 +- Produces: `update-status.json` bekommt drei Felder — `ceiling` (String, leer wenn keine), `ceiling_error` (`''` | `ceiling_invalid` | `ceiling_missing`), `releases` (Array von Tag-Strings) + +- [ ] **Step 1: Write the failing test** + +Neue Datei `tests/Feature/ReleaseCeilingAgentTest.php`: + +```php + $deployedVersion, + 'commit' => 'deadbeef', + 'source' => 'refs/tags/v'.$deployedVersion, + ])); + + if ($ceiling !== null) { + File::put($dir.'/release-ceiling', $ceiling); + } + + $tagCommands = ''; + foreach ($tags as $tag) { + $tagCommands .= "git tag {$tag} 2>/dev/null || true\n"; + } + + $result = Process::path(base_path())->timeout(90)->run(<< "\$stub/git" <<'GIT' + #!/bin/sh + if [ "\$1" = "fetch" ]; then exit 0; fi + exec /usr/bin/git "\$@" + GIT + chmod +x "\$stub/git" + printf '#!/bin/sh\nexit 1\n' > "\$stub/docker" + chmod +x "\$stub/docker" + {$tagCommands} + PATH="\$stub:\$PATH" bash deploy/update-agent.sh >/dev/null 2>&1 || true + rm -rf "\$stub" + BASH); + + expect($result->successful())->toBeTrue($result->errorOutput()); + + return json_decode(File::get($dir.'/update-status.json'), true); +} + +afterEach(function () { + File::deleteDirectory(storage_path('app/deploy')); + Process::path(base_path())->run("git tag -d v9.9.8 v9.9.9 2>/dev/null || true"); +}); + +it('offers only up to the ceiling', function () { + // Ausgeliefert 1.7.3, vorhanden bis v9.9.9, Decke auf v9.9.8: genau eine + // Aktualisierung, und das Ziel ist die Decke. + $status = runAgentWithCeiling('v9.9.8', ['v9.9.8', 'v9.9.9']); + + expect($status['target_release'])->toBe('v9.9.8') + ->and($status['behind'])->toBe(1) + ->and($status['ceiling'])->toBe('v9.9.8') + ->and($status['ceiling_error'])->toBe(''); +}); + +it('offers nothing when the ceiling points at a tag that does not exist', function () { + // Die Decke faellt ZU. Ein Rueckfall auf "neueste" installierte genau das, + // wovon der Besitzer weggenagelt hat. + $status = runAgentWithCeiling('v9.9.7', ['v9.9.8', 'v9.9.9']); + + expect($status['behind'])->toBe(0) + ->and($status['ceiling_error'])->toBe('ceiling_missing') + ->and($status['target_release'])->toBe(''); +}); + +it('offers nothing when the ceiling is malformed', function () { + $status = runAgentWithCeiling('neueste bitte', ['v9.9.8', 'v9.9.9']); + + expect($status['behind'])->toBe(0) + ->and($status['ceiling_error'])->toBe('ceiling_invalid'); +}); + +it('behaves exactly as before without a ceiling', function () { + $status = runAgentWithCeiling(null, ['v9.9.8', 'v9.9.9']); + + expect($status['target_release'])->toBe('v9.9.9') + ->and($status['ceiling'])->toBe('') + ->and($status['ceiling_error'])->toBe(''); +}); + +it('reports the versions that may be pinned to', function () { + $status = runAgentWithCeiling(null, ['v9.9.8', 'v9.9.9']); + + expect($status['releases'])->toContain('v9.9.9') + ->and($status['releases'])->toContain('v9.9.8'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T -u 1000:1000 app php artisan test --filter=ReleaseCeilingAgent` +Expected: FAIL — `ceiling`, `ceiling_error` und `releases` fehlen in der Statusdatei. + +- [ ] **Step 3: Implement — Zustandspfad und Decke lesen** + +In `deploy/update-agent.sh` hinter `LOCK="$STATE_DIR/.agent.lock"` (Z. 45) einfügen: + +```bash +# Wie weit dieser Server gehen darf. Von der Konsole geschrieben, hier gelesen. +# +# Sie faellt ZU, nicht auf: ist die Datei unlesbar, formwidrig oder zeigt sie +# auf einen Tag, den es nicht (mehr) gibt, wird NICHTS angeboten. Ein Rueckfall +# auf "neueste Version" installierte genau das, wovon weggenagelt wurde — eine +# Sicherung, die im Zweifel oeffnet, ist keine. +CEILING_FILE="$STATE_DIR/release-ceiling" +``` + +Direkt vor `DEPLOYED_VERSION=` (Z. 445) einfügen: + +```bash +# Die Decke, in zwei Schritten geprueft: Form hier, Existenz nach dem Abruf — +# vorher sind die Tags noch nicht da. +CEILING='' +CEILING_ERROR='' +RELEASES='' + +if [[ -f "$CEILING_FILE" ]]; then + CEILING="$(tr -d ' \t\r\n' < "$CEILING_FILE" 2>/dev/null || true)" + + if [[ ! "$CEILING" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then + CEILING_ERROR='ceiling_invalid' + fi +fi +``` + +- [ ] **Step 4: Implement — Klemmung im Fetch-Block** + +Den Block ab `if timeout -k 10 120 git fetch …` (Z. 453-473) ersetzen: + +```bash +if timeout -k 10 120 git fetch --quiet --tags --force origin 2>/dev/null; then + # Erst jetzt sind die Tags da, also erst jetzt laesst sich sagen, ob die + # Decke auf etwas Wirkliches zeigt. + if [[ -n "$CEILING" && -z "$CEILING_ERROR" ]] && ! release_tag_exists "$CEILING"; then + CEILING_ERROR='ceiling_missing' + fi + + # Was ueberhaupt als Decke in Frage kommt — unabhaengig davon, ob gerade + # eine gesetzt ist. Ohne diese Liste hat die Konsole nichts anzubieten. + RELEASES="$(release_tags_from "$DEPLOYED_VERSION" 20)" + + if [[ -n "$CEILING_ERROR" ]]; then + # Eine kaputte Decke bietet nichts an. Bewusst kein `else`-Zweig, der + # auf die neueste Version zurueckfaellt. + BEHIND=0 + else + # Newest by version order, not by tag date — und nicht ueber die Decke + # hinaus. Durch den Helfer statt `| head -1`: head exits after one + # line, git takes SIGPIPE, and pipefail ends the agent. + NEWEST_TAG="$(release_newest_tag "$CEILING")" + NEWEST_VERSION="${NEWEST_TAG#v}" + + if [[ -n "$NEWEST_TAG" ]] && release_version_gt "$NEWEST_VERSION" "$DEPLOYED_VERSION"; then + BEHIND="$(release_tags_ahead "$DEPLOYED_VERSION" "$CEILING")" + TARGET_RELEASE="$NEWEST_TAG" + REMOTE_COMMIT="$(git rev-parse "refs/tags/${NEWEST_TAG}^{commit}" 2>/dev/null || echo '')" + else + # Schliesst „gar keine Tags" und „Decke gleich dem Ausgelieferten" + # ein: beides heisst, es gibt nichts anzubieten. + BEHIND=0 + fi + fi +else + FETCH_ERROR='repo_unreachable' +fi +``` + +- [ ] **Step 5: Implement — die drei Felder in die Statusdatei** + +Vor `write_status()` (Z. 371) einfügen: + +```bash +# Die wählbaren Versionen als JSON-Liste. Eigene Funktion, weil eine Schleife +# nicht in eine Here-Dokument-Ersetzung passt. +releases_json() { + local tag out='' + while read -r tag; do + [[ -n "$tag" ]] || continue + out+="\"$(json_escape "$tag")\"," + done <<< "${RELEASES:-}" + printf '%s' "${out%,}" +} +``` + +In `write_status()` hinter der `"behind"`-Zeile einfügen: + +```bash + "ceiling": "$(json_escape "${CEILING:-}")", + "ceiling_error": "$(json_escape "${CEILING_ERROR:-}")", + "releases": [$(releases_json)], +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `docker compose exec -T -u 1000:1000 app php artisan test --filter="ReleaseCeilingAgent|ReleaseComparison|UpdateAgentSkipCount"` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add deploy/update-agent.sh tests/Feature/ReleaseCeilingAgentTest.php +git commit -m "Der Agent klemmt Ziel und Zaehler an der Decke" +``` + +--- + +### Task 3: Der Kanal schreibt die Decke atomar und reicht sie durch + +**Files:** +- Modify: `app/Services/Deployment/UpdateChannel.php` +- Test: `tests/Feature/ReleaseCeilingConsoleTest.php` (neu) + +**Interfaces:** +- Consumes: `update-status.json` mit `ceiling_error` und `releases` aus Task 2 +- Produces: + - `UpdateChannel::setCeiling(string $by, ?string $tag): bool` — `null` nimmt die Decke ab; `false` bei formwidrigem Tag + - `state()` um `ceiling` (?string), `ceiling_error` (?string), `releases` (string[]) erweitert + +- [ ] **Step 1: Write the failing test** + +Neue Datei `tests/Feature/ReleaseCeilingConsoleTest.php`: + +```php +setCeiling('chef@example.com', 'v1.8.0'))->toBeTrue() + ->and(trim(File::get($path)))->toBe('v1.8.0'); + + expect($channel->setCeiling('chef@example.com', null))->toBeTrue() + ->and(File::exists($path))->toBeFalse(); +}); + +it('refuses a ceiling that is not a version', function () { + // Die Form wird auf BEIDEN Seiten geprueft. Der Agent kann sich auf nichts + // verlassen, was aus einer Datei kommt — und die Konsole soll gar nicht + // erst etwas hinlegen, das er ablehnen muss. + $channel = app(UpdateChannel::class); + + expect($channel->setCeiling('chef@example.com', 'neueste'))->toBeFalse() + ->and(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse(); +}); + +it('leaves no temporary file behind', function () { + // Atomar heisst: der Agent sieht die Datei ganz oder gar nicht. Bliebe die + // .tmp liegen, waere das der Beleg fuer ein File::put statt eines rename. + app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0'); + + expect(File::exists(storage_path('app/deploy/release-ceiling.tmp')))->toBeFalse(); +}); + +it('reports the ceiling from the file, not from the agent', function () { + // Die Decke ist der Konsole eigener Zustand. Sie muss sofort stehen, statt + // zu warten, bis der Agent sie zurueckmeldet. + app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0'); + + expect(app(UpdateChannel::class)->state()['ceiling'])->toBe('v1.8.0'); +}); + +it('passes the ceiling complaint from the agent through', function () { + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'ceiling_error' => 'ceiling_missing', + 'releases' => ['v1.8.1', 'v1.8.0'], + ])); + + $state = app(UpdateChannel::class)->state(); + + expect($state['ceiling_error'])->toBe('ceiling_missing') + ->and($state['releases'])->toBe(['v1.8.1', 'v1.8.0']); +}); + +it('marks a ceiling that sits below what is deployed', function () { + // Über das Auswahlfeld nicht erreichbar, über die Kommandozeile schon: + // Decke auf v1.8.0, jemand fährt `RELEASE=v1.8.1 update.sh`. Die Lage + // muss lesbar sein statt als „aktuell" durchzugehen — es steht ja eine + // Decke, sie ist nur überholt. + File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0'); + + $state = app(UpdateChannel::class)->state(); + + // `version` kommt aus Release::current(); in der Testumgebung ist das die + // VERSION-Datei des Checkouts, die über 1.8.0 liegt. + expect($state['ceiling_passed'])->toBeTrue(); +}); + +it('does not mark a ceiling that is still ahead', function () { + File::put(storage_path('app/deploy/release-ceiling'), 'v99.0.0'); + + expect(app(UpdateChannel::class)->state()['ceiling_passed'])->toBeFalse(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T -u 1000:1000 app php artisan test --filter=ReleaseCeilingConsole` +Expected: FAIL — `setCeiling` existiert nicht. + +- [ ] **Step 3: Implement** + +In `UpdateChannel.php` bei den übrigen Pfad-Konstanten einfügen: + +```php + /** + * Wie weit dieser Server gehen darf. + * + * Keine Anfrage an den Agenten, sondern Zustand, den er liest: die Datei + * bleibt liegen und gilt, bis sie jemand ändert. Deshalb steht sie neben + * dem Postkasten und nicht darin — eine Anfrage wird verbraucht, eine + * Decke nicht. + */ + private const CEILING = 'deploy/release-ceiling'; +``` + +Und die Methoden (etwa bei `requestCheck()`): + +```php + /** + * Den Server auf eine Version festnageln — oder die Decke abnehmen. + * + * `null` nimmt sie ab. Rückgabe `false` heißt: die Form stimmt nicht, es + * wurde nichts geschrieben. + * + * Die Decke wirkt NICHT dadurch, dass hier etwas ausgelöst wird. Der Agent + * liest die Datei bei jedem Takt und rechnet `behind` dagegen; Knopf und + * Wartungsfenster folgen daraus von selbst. Die `KIND_CHECK`-Anfrage + * daneben ist reine Höflichkeit gegenüber der Anzeige: ohne sie stünde die + * alte Zahl bis zum nächsten Takt, und ein Betreiber, der gerade + * festgenagelt hat, läse sie als „hat nicht gewirkt". + */ + public function setCeiling(string $by, ?string $tag): bool + { + if ($tag === null) { + File::delete(storage_path('app/'.self::CEILING)); + $this->requestCheck($by); + + return true; + } + + // Dieselbe Form, die der Agent prüft. Beide Seiten prüfen sie, weil + // keine der anderen glauben kann: der Agent nicht der Datei, die + // Konsole nicht der Eingabe. + if (! preg_match('/^v\d+(\.\d+)*$/', $tag)) { + return false; + } + + $this->writeAtomic(self::CEILING, $tag); + $this->requestCheck($by); + + return true; + } + + /** + * Die gesetzte Decke, direkt aus der Datei. + * + * Nicht aus der Statusdatei des Agenten: die ist seine Rückmeldung und + * hinkt bis zu einem Takt hinterher. Was die Konsole selbst geschrieben + * hat, muss sie sofort anzeigen können. + */ + private function ceiling(): ?string + { + $path = storage_path('app/'.self::CEILING); + + if (! File::exists($path)) { + return null; + } + + $tag = trim((string) File::get($path)); + + return $tag === '' ? null : $tag; + } + + /** + * Schreiben, das der Agent nie halb sieht. + * + * `write()` daneben benutzt File::put und taugt dafür nicht: der Agent + * liest diese Datei bei jedem Takt, und eine halbe Zeile ist genau die + * Art Decke, die er als kaputt ablehnen müsste. + */ + private function writeAtomic(string $relative, string $contents): void + { + $path = storage_path('app/'.$relative); + File::ensureDirectoryExists(dirname($path)); + File::put($path.'.tmp', $contents); + File::move($path.'.tmp', $path); + } +``` + +In `state()` im `return`-Array hinter `'behind'` einfügen: + +```php + // Die Decke aus der Datei, ihre Beanstandung vom Agenten: nur er + // sieht die Tags und kann sagen, ob sie ins Leere zeigt. + 'ceiling' => $this->ceiling(), + 'ceiling_error' => ($status['ceiling_error'] ?? '') !== '' + ? (string) $status['ceiling_error'] + : null, + // Die Decke steht UNTER dem, was läuft. Kein Fehler, aber auch + // nicht „aktuell": es steht eine Decke, sie ist nur überholt. + // Hier gerechnet und nicht vom Agenten geholt — die Konsole hat + // beide Zahlen bereits, und eine Frage weniger an den Wirt ist + // eine Frage weniger, die veralten kann. + 'ceiling_passed' => $this->ceiling() !== null + && version_compare(ltrim($this->ceiling(), 'v'), $release->version, '<'), + 'releases' => is_array($status['releases'] ?? null) + ? array_values(array_filter($status['releases'], 'is_string')) + : [], +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `docker compose exec -T -u 1000:1000 app php artisan test --filter=ReleaseCeilingConsole` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/Services/Deployment/UpdateChannel.php tests/Feature/ReleaseCeilingConsoleTest.php +git commit -m "Der Kanal schreibt die Decke atomar und reicht sie durch" +``` + +--- + +### Task 4: Beleg, dass die Automatik die Decke von selbst achtet + +**Files:** +- Test: `tests/Feature/ReleaseCeilingConsoleTest.php` (erweitern) +- **Kein Produktivcode.** Wenn dieser Test ohne Änderung an `AutoUpdate.php` grün wird, ist genau das der Befund. + +**Interfaces:** +- Consumes: `UpdateChannel::state()['available']` aus Task 3 + +- [ ] **Step 1: Write the test** + +An `tests/Feature/ReleaseCeilingConsoleTest.php` anhängen: + +```php +it('makes the automatic window do nothing while the ceiling is reached', function () { + // Der wichtigste Test des ganzen Entwurfs. Er belegt, dass Knopf und + // Wartungsfenster denselben Zustand lesen — die Behauptung, auf der alles + // ruht. Ohne ihn ist „es gibt keinen zweiten Weg in eine Auslieferung" + // eine Absichtserklaerung im Kommentar von AutoUpdate.php. + // + // Der Agent hat gemeldet: nichts verfuegbar, weil die Decke erreicht ist. + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'behind' => 0, + 'ceiling' => 'v1.8.0', + 'ceiling_error' => '', + 'checked_at' => now()->toIso8601String(), + ])); + File::put(storage_path('app/deploy/agent-alive.json'), json_encode([ + 'at' => now()->toIso8601String(), + 'state' => 'running', + 'skips' => 0, + ])); + + $this->artisan('clupilot:auto-update', ['--force' => true]) + ->assertSuccessful(); + + // Nichts abgelegt: keine Anfrage, kein Wartungsfenster, kein Lauf. + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); +}); +``` + +- [ ] **Step 2: Run the test** + +Run: `docker compose exec -T -u 1000:1000 app php artisan test --filter="makes the automatic window"` +Expected: PASS **ohne** Änderung an `AutoUpdate.php`. + +Falls er FAILT: nicht `AutoUpdate.php` um eine Decken-Sonderregel ergänzen — das wäre der zweite Weg, den der Entwurf ausschließt. Stattdessen prüfen, warum `state()['available']` nicht schon `false` ist, und den Fehler dort beheben. + +- [ ] **Step 3: Commit** + +```bash +git add tests/Feature/ReleaseCeilingConsoleTest.php +git commit -m "Beleg: das Wartungsfenster achtet die Decke ohne eigene Regel" +``` + +--- + +### Task 5: Der Griff in der Konsole + +**Files:** +- Create: `app/Livewire/Admin/ConfirmPinRelease.php` +- Create: `resources/views/livewire/admin/confirm-pin-release.blade.php` +- Modify: `app/Livewire/Admin/Settings.php` (bei `requestCheck()`, ~Z. 705) +- Modify: `resources/views/livewire/admin/settings.blade.php` (Update-Abschnitt, ~Z. 160-180) +- Modify: `lang/de/admin_settings.php`, `lang/en/admin_settings.php` +- Test: `tests/Feature/ReleaseCeilingConsoleTest.php` (erweitern) + +**Interfaces:** +- Consumes: `UpdateChannel::setCeiling()` und `state()['ceiling'|'releases'|'ceiling_error']` aus Task 3 +- Produces: Livewire-Aktionen `Settings::pinRelease()` und `Settings::unpinRelease()`; öffentliche Eigenschaft `Settings::$ceilingChoice` + +- [ ] **Step 1: Write the failing test** + +An `tests/Feature/ReleaseCeilingConsoleTest.php` anhängen: + +```php +use App\Livewire\Admin\Settings; +use App\Models\Operator; +use Livewire\Livewire; + +it('pins from the console', function () { + // Rolle, nicht Einzelberechtigung: die siebzehn Konsolen-Berechtigungen + // hängen am `operator`-Guard über Rollen (R21). `->role('Owner')` ist das + // Muster der übrigen Tests, z. B. SwitchOperatingModeTest. + $owner = Operator::factory()->role('Owner')->create(); + + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'releases' => ['v1.8.1', 'v1.8.0'], + ])); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->set('ceilingChoice', 'v1.8.0') + ->call('pinRelease'); + + expect(trim(File::get(storage_path('app/deploy/release-ceiling'))))->toBe('v1.8.0'); +}); + +it('takes the ceiling off again', function () { + $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('unpinRelease'); + + expect(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse(); +}); + +it('refuses to pin without site.manage', function () { + // Eine Livewire-Aktion ist ein oeffentlicher Endpunkt. Die eine Stelle, an + // der eine Regel NICHT allein stehen darf, ist das disabled-Attribut eines + // Knopfes. + // + // Angemeldet, aber ohne die Rolle, die `site.manage` traegt: `site.manage` + // liegt am operator-Guard bei Owner und Admin (siehe die Migration + // 2026_07_25_220001 und die Begruendung in 2026_08_04_160000). Geprueft + // wird also die Berechtigung, nicht die Anmeldung. + $staff = Operator::factory()->create(); + + Livewire::actingAs($staff, 'operator') + ->test(Settings::class) + ->set('ceilingChoice', 'v1.8.0') + ->call('pinRelease') + ->assertForbidden(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T -u 1000:1000 app php artisan test --filter="pins from the console|takes the ceiling off|refuses to pin"` +Expected: FAIL — `pinRelease` existiert nicht. + +- [ ] **Step 3: Implement — Aktionen in `Settings.php`** + +Öffentliche Eigenschaft zu den übrigen stellen: + +```php + /** Die im Auswahlfeld stehende Version, ehe sie bestätigt wird. */ + public string $ceilingChoice = ''; +``` + +Hinter `requestCheck()` einfügen: + +```php + /** + * Den Server auf eine Version festnageln. + * + * Der Auslöser kommt aus dem Bestätigungs-Modal (R23); die Prüfung steht + * hier, an derselben Stelle wie bei jeder anderen Handlung dieser Seite, + * statt im Modal verdoppelt zu werden. + * + * `site.manage` wie `requestUpdate()`: festnageln entscheidet, welche + * Fassung dieser Server je bekommt, und ist damit dieselbe Entscheidung + * wie sie auszulösen — nur früher. + */ + #[On('pin-release-confirmed')] + public function pinRelease(): void + { + $this->authorize('site.manage'); + + if (! $operator = $this->currentOperator()) { + return; + } + + $accepted = app(UpdateChannel::class)->setCeiling($operator->email, $this->ceilingChoice ?: null); + + $this->dispatch('notify', message: __($accepted + ? 'admin_settings.release_pinned' + : 'admin_settings.release_pin_invalid')); + } + + /** + * Die Decke abnehmen — der Server nimmt wieder, was neu ist. + */ + public function unpinRelease(): void + { + $this->authorize('site.manage'); + + if (! $operator = $this->currentOperator()) { + return; + } + + app(UpdateChannel::class)->setCeiling($operator->email, null); + + $this->dispatch('notify', message: __('admin_settings.release_unpinned')); + } +``` + +- [ ] **Step 4: Implement — Bestätigungs-Modal (R23)** + +`app/Livewire/Admin/ConfirmPinRelease.php`: + +```php + +

+ {{ __('admin_settings.release_pin_body', ['version' => ltrim($version, 'v')]) }} +

+ + + + {{ __('admin_settings.cancel') }} + + + {{ __('admin_settings.release_pin_confirm') }} + + + +``` + +- [ ] **Step 5: Implement — Auswahlfeld im Blade** + +In `resources/views/livewire/admin/settings.blade.php` hinter der bestehenden Knopfleiste (bei `wire:click="requestUpdate"`, ~Z. 172) einfügen: + +```blade +{{-- Festnageln. Eine Zeile mit Auswahlfeld und Knopf — kein Modal für das + Feld selbst (R20 gilt für das Bearbeiten bestehender Datensätze in + Tabellenzeilen, nicht für ein Formular, das die Seite IST). Bestätigt + wird im Modal, weil die Wahl entscheidet, welche Fassung dieser Server je + bekommt. --}} +@if ($update['releases']) +
+ + + + {{ __('admin_settings.release_pin_action') }} + +
+@endif + +@if ($update['ceiling']) +

+ {{-- „Festgenagelt, aber bereits weiter" ist ein eigener Satz und 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). --}} + {{ $update['ceiling_passed'] + ? __('admin_settings.release_pinned_passed', ['version' => ltrim($update['ceiling'], 'v')]) + : __('admin_settings.release_pinned_at', ['version' => ltrim($update['ceiling'], 'v')]) }} + +

+@endif + +@if ($update['ceiling_error']) +

+ {{ __('admin_settings.release_ceiling_'.$update['ceiling_error']) }} +

+@endif +``` + +- [ ] **Step 6: Implement — Sprachdateien** + +`lang/de/admin_settings.php`: + +```php + 'release_pin_action' => 'Festnageln', + 'release_pin_none' => 'Neueste Version (nicht festgenagelt)', + 'release_pin_title' => 'Auf diese Version festnageln?', + 'release_pin_body' => 'Dieser Server nimmt dann nichts Neueres als :version — auch nicht im Wartungsfenster. Zurück geht es nicht; die Decke begrenzt nur nach oben.', + 'release_pin_confirm' => 'Festnageln', + 'release_pinned' => 'Festgenagelt.', + 'release_pin_invalid' => 'Das ist keine gültige Version.', + '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', + 'release_unpinned' => 'Decke abgenommen.', + 'release_ceiling_ceiling_missing' => 'Die festgenagelte Version gibt es nicht (mehr). Es wird solange nichts installiert — bitte neu festnageln oder die Decke abnehmen.', + 'release_ceiling_ceiling_invalid' => 'Die festgenagelte Version ist unlesbar. Es wird solange nichts installiert — bitte neu festnageln oder die Decke abnehmen.', +``` + +`lang/en/admin_settings.php`: + +```php + 'release_pin_action' => 'Pin', + 'release_pin_none' => 'Newest release (not pinned)', + 'release_pin_title' => 'Pin to this release?', + 'release_pin_body' => 'This server will then take nothing newer than :version — not in the maintenance window either. There is no way back; the ceiling only limits upwards.', + 'release_pin_confirm' => 'Pin', + 'release_pinned' => 'Pinned.', + 'release_pin_invalid' => 'That is not a valid release.', + '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', + 'release_unpinned' => 'Ceiling removed.', + 'release_ceiling_ceiling_missing' => 'The pinned release no longer exists. Nothing will be installed until this is fixed — pin again or remove the ceiling.', + 'release_ceiling_ceiling_invalid' => 'The pinned release is unreadable. Nothing will be installed until this is fixed — pin again or remove the ceiling.', +``` + +- [ ] **Step 7: Run the whole suite** + +Run: `docker compose exec -T -u 1000:1000 app php artisan test` +Expected: PASS. Besonders `ConfirmInModalTest` (kein `wire:confirm`), `ModalHeightTest`, `IconLayoutTest`, `IdentitySeparationTest`. + +- [ ] **Step 8: Commit** + +```bash +git add app/Livewire/Admin/Settings.php app/Livewire/Admin/ConfirmPinRelease.php \ + resources/views/livewire/admin/confirm-pin-release.blade.php \ + resources/views/livewire/admin/settings.blade.php \ + lang/de/admin_settings.php lang/en/admin_settings.php \ + tests/Feature/ReleaseCeilingConsoleTest.php +git commit -m "Festnageln aus der Konsole" +``` + +--- + +## Hinweis zum Ausliefern + +`settings.blade.php` ist die Datei, die eine **Parallelsitzung** am 4. August 2026 zuletzt angefasst hat (v1.8.1, „die Update-Knoepfe sind am Telefon eine Leiste"). Vor Task 5 `git log --oneline -3 -- resources/views/livewire/admin/settings.blade.php` lesen und die neue Zeile in die **bestehende** Knopfleiste einfügen, statt sie danebenzusetzen. + +Die Decke wirkt erst auf einem Server, der die neue Fassung hat — sie muss also als Tag ausgeliefert werden. Vorher `pwd`, `git branch --show-current` und `git tag -l 'v*' --sort=-v:refname | head -1` prüfen.