From 40e42a88fec7575224eab52d0c91372dd9c93efc Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 14:34:41 +0200 Subject: [PATCH] Umsetzungsplan: Release-Decke in fuenf Schritten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fuenf Tasks, jeder mit eigenem Testzyklus: Versions-Arithmetik mit Decke, Agent klemmt Ziel und Zaehler, Kanal schreibt atomar und reicht durch, Beleg dass das Wartungsfenster von selbst folgt, und der Griff in der Konsole. Task 4 hat bewusst KEINEN Produktivcode. Wenn der Test ohne Aenderung an AutoUpdate.php gruen wird, ist genau das der Befund — und wenn nicht, ist die Antwort nicht eine Decken-Sonderregel in der Automatik, sondern ein Fehler in state(). Der Plan sagt das ausdruecklich, weil ein zweiter Weg in eine Auslieferung genau das ist, wovor AutoUpdate.php im Kopfkommentar warnt. Bei der Selbstpruefung gegen die Spec fielen drei Fehler auf: - Ein Apostroph in einem einfach zitierten Testnamen — Syntaxfehler. - `Operator::factory()->create()->givePermissionTo(...)` gibt es hier nicht; Berechtigungen haengen ueber Rollen am operator-Guard. - Eine Spec-Anforderung ohne Task: der Zustand „Decke unter dem Ausgelieferten". Er ist ueber das Auswahlfeld nicht erreichbar, ueber die Kommandozeile schon, und er darf nicht als „aktuell" durchgehen. Jetzt mit eigenem `ceiling_passed`, eigenem Satz und zwei Tests. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-04-release-decke.md | 1071 +++++++++++++++++ 1 file changed, 1071 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-release-decke.md 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.