diff --git a/app/Livewire/Admin/ConfirmPinRelease.php b/app/Livewire/Admin/ConfirmPinRelease.php
new file mode 100644
index 0000000..09e7d43
--- /dev/null
+++ b/app/Livewire/Admin/ConfirmPinRelease.php
@@ -0,0 +1,51 @@
+authorize('site.manage');
+
+ $this->version = $version;
+ }
+
+ public function confirm(): void
+ {
+ $this->authorize('site.manage');
+
+ // 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();
+ }
+
+ public function render()
+ {
+ return view('livewire.admin.confirm-pin-release');
+ }
+}
diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php
index 8d5558d..77baa34 100644
--- a/app/Livewire/Admin/Settings.php
+++ b/app/Livewire/Admin/Settings.php
@@ -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() ?? '';
}
/**
@@ -717,6 +723,70 @@ class Settings extends Component
: 'admin_settings.update_already_requested'));
}
+ /** Die im Auswahlfeld stehende Version, ehe sie bestätigt wird. */
+ public string $ceilingChoice = '';
+
+ /**
+ * 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.
+ *
+ * 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 ``,
+ * 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(string $version): void
+ {
+ $this->authorize('site.manage');
+
+ if (! $operator = $this->currentOperator()) {
+ return;
+ }
+
+ 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'
+ : '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'));
+ }
+
/**
* Eine hängende Sperre auf dem Wirt lösen lassen.
*
diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php
index d648981..20590ec 100644
--- a/app/Services/Deployment/UpdateChannel.php
+++ b/app/Services/Deployment/UpdateChannel.php
@@ -46,6 +46,16 @@ final class UpdateChannel
/** Written by the panel, consumed by the agent. */
private const REQUEST = 'deploy/update-request.json';
+ /**
+ * 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';
+
/**
* Die Bitte, eine hängende Sperre zu lösen — in einer EIGENEN Datei.
*
@@ -275,6 +285,11 @@ final class UpdateChannel
// "three updates behind" from last week reads exactly like now.
$behind = $agentWorking && isset($status['behind']) ? (int) $status['behind'] : null;
+ // Aus der Datei, nicht aus $status: siehe ceiling() weiter unten. Nur
+ // einmal gelesen, weil state() bei jedem Seitenaufbau läuft und die
+ // Datei sonst zweimal geöffnet würde, um zweimal dasselbe zu sagen.
+ $ceiling = $this->ceiling();
+
return [
'version' => $release->version,
'commit' => $release->commit,
@@ -284,6 +299,22 @@ final class UpdateChannel
// Null means "we do not know", which is not the same as "up to
// date" and must not be shown as it.
'behind' => $behind,
+ // Die Decke aus der Datei, ihre Beanstandung vom Agenten: nur er
+ // sieht die Tags und kann sagen, ob sie ins Leere zeigt.
+ 'ceiling' => $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' => $ceiling !== null
+ && version_compare(ltrim($ceiling, 'v'), $release->version, '<'),
+ 'releases' => is_array($status['releases'] ?? null)
+ ? array_values(array_filter($status['releases'], 'is_string'))
+ : [],
'available' => $behind !== null && $behind > 0,
// The tag an update would install, e.g. "v1.1.0". An update is
// always to a released version now — a commit landing on main is
@@ -559,6 +590,118 @@ final class UpdateChannel
return $this->submit($by, self::KIND_CHECK);
}
+ /**
+ * Den Server auf eine Version festnageln — oder die Decke abnehmen.
+ *
+ * `null` nimmt sie ab. Rückgabe `false` heißt: die Form stimmt nicht, oder
+ * das Schreiben ist fehlgeschlagen (volle Platte, Rechteproblem auf
+ * storage/app/deploy) — in beiden Fällen wurde nichts Halbes hinterlassen,
+ * das der Agent falsch lesen müsste.
+ *
+ * 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;
+ }
+
+ if (! $this->writeAtomic(self::CEILING, $tag)) {
+ return false;
+ }
+
+ $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.
+ *
+ * Zwischen `File::exists()` und `File::get()` liegt ein Zeitfenster —
+ * verschwindet die Datei genau dort (ein zweiter Aufruf nimmt die Decke
+ * 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.
+ */
+ public function ceiling(): ?string
+ {
+ try {
+ $path = storage_path('app/'.self::CEILING);
+
+ if (! File::exists($path)) {
+ return null;
+ }
+
+ $tag = trim((string) File::get($path));
+
+ return $tag === '' ? null : $tag;
+ } catch (Throwable) {
+ return null;
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * Prüft beide Rückgabewerte, statt ihnen zu glauben: `File::move()` ruft
+ * nur `rename()` auf, und ein fehlgeschlagenes `rename()` liefert `false`
+ * ohne von sich aus eine Ausnahme zu werfen. Schlägt einer der beiden
+ * Schritte fehl — volle Platte, Rechteproblem — räumt diese Methode die
+ * `.tmp` weg und meldet `false`, statt einen Erfolg zu behaupten, der
+ * nicht stattfand.
+ */
+ private function writeAtomic(string $relative, string $contents): bool
+ {
+ $path = storage_path('app/'.$relative);
+ $tmp = $path.'.tmp';
+
+ try {
+ File::ensureDirectoryExists(dirname($path));
+
+ if (File::put($tmp, $contents) === false || ! File::move($tmp, $path)) {
+ File::delete($tmp);
+
+ return false;
+ }
+
+ return true;
+ } catch (Throwable) {
+ File::delete($tmp);
+
+ return false;
+ }
+ }
+
/**
* Den Proxy die Hostnamen aus der Konsole übernehmen lassen.
*
diff --git a/deploy/lib/release.sh b/deploy/lib/release.sh
index ef53480..25849bd 100644
--- a/deploy/lib/release.sh
+++ b/deploy/lib/release.sh
@@ -38,8 +38,17 @@ release_manifest_commit() {
# failed in between leaves a newer number on disk than is serving. Deciding
# "is there something newer" against the file would then offer nothing, because
# the checkout already claims to be the version it never finished installing.
+#
+# `|| true` at the end, same idiom as release_version()'s `|| echo '0.0.0'`:
+# guarantee a zero exit status regardless of what happened upstream. Without
+# it, a missing manifest — an ordinary state before the first install — makes
+# `sed` exit 2, `pipefail` carries that out of the pipeline, and the caller's
+# `DEPLOYED_VERSION="$(release_manifest_version)"` is a bare assignment: under
+# `set -e` that ends the agent on every tick, before it ever writes a status.
+# Empty output either way, so the caller's existing fallback to
+# release_version() still fires exactly as before.
release_manifest_version() {
- sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_FILE" 2>/dev/null | head -1
+ sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_FILE" 2>/dev/null | head -1 || true
}
# release_version_gt A B — true when A is strictly a higher version than B.
@@ -51,7 +60,12 @@ release_version_gt() {
[[ "$1" != "$2" ]] && [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" == "$1" ]]
}
-# release_newest_tag — the highest v* tag by version order, or nothing.
+# 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
@@ -59,9 +73,13 @@ release_version_gt() {
# 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 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)
@@ -69,31 +87,82 @@ release_newest_tag() {
return 0
}
-# release_tags_ahead VERSION — how many v* tags are higher than VERSION.
+# 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. The console then showed a
-# check that never finished and a last-checked time frozen at the deployment.
+# 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.
+# 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" tag count=0
+ local current="$1" ceiling="${2-}" tag count=0
while read -r tag; do
[[ -n "$tag" ]] || continue
if release_version_gt "${tag#v}" "$current"; then
- count=$((count + 1))
+ 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 — 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
+}
+
+# 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"
+}
+
release_remember() { # release_remember MODE SOURCE
mkdir -p "$(dirname "$MODE_FILE")"
printf '%s' "$1" > "$MODE_FILE"
diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh
index 0b5cd9d..d8b4273 100755
--- a/deploy/update-agent.sh
+++ b/deploy/update-agent.sh
@@ -43,6 +43,13 @@ RESTARTLAST="$STATE_DIR/restart-last-run.json"
# initial admin password an instance holds until somebody notes it down.
ARCHIVE_KEY="$STATE_DIR/archive-key.json"
LOCK="$STATE_DIR/.agent.lock"
+# 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"
# Ein Lebenszeichen, geschrieben als ERSTES bei jedem Lauf — vor dem Abruf der
# Gegenstelle, vor allem, was blockieren kann.
#
@@ -329,6 +336,13 @@ sync_console_allowlist() {
sync_vpn_certificate() {
local stamp seen="$STATE_DIR/.vpn-cert-stamp" path
+ # Ohne diese Wache stirbt der ganze Agent an dieser Stelle, wenn `.env`
+ # fehlt: `sed` auf einer nicht vorhandenen Datei liefert Status 2, und eine
+ # einfache (nicht-`local`) Zuweisung reicht diesen Status unter `pipefail`
+ # an `set -e` weiter — bevor je eine Statusdatei geschrieben wurde. Auf
+ # einem echten Wirt existiert `.env` immer; das aendert hier nichts.
+ [[ -f "$ROOT/.env" ]] || return 0
+
path="$(sed -n 's/^VPN_CERT_PATH=//p' "$ROOT/.env" 2>/dev/null | tail -1)"
[[ -n "$path" ]] || return 0
@@ -368,6 +382,17 @@ json_escape() {
# needed once, to record which step a failed run died at.
read_phase() { cut -f1 "$PHASE_FILE" 2>/dev/null | tr -d '\r\n' || true; }
+# 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%,}"
+}
+
write_status() {
local state="$1" error="${2-}"
cat > "$STATUS.tmp" </dev/null || true)"
+
+ if [[ ! "$CEILING" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then
+ CEILING_ERROR='ceiling_invalid'
+ fi
+fi
+
DEPLOYED_VERSION="$(release_manifest_version)"
[[ -n "$DEPLOYED_VERSION" ]] || DEPLOYED_VERSION="$(release_version)"
@@ -451,25 +493,36 @@ DEPLOYED_VERSION="$(release_manifest_version)"
# grosszuegig fuer einen fetch gegen EINE Gegenstelle; laenger ist kein
# langsames Netz mehr, sondern eines, das nicht antwortet.
if timeout -k 10 120 git fetch --quiet --tags --force origin 2>/dev/null; then
- # Newest by version order, not by tag date. Through the helper rather than
- # `| head -1`: head exits after one line, git takes SIGPIPE, and pipefail
- # ends the agent — see release_newest_tag.
- NEWEST_TAG="$(release_newest_tag)"
- NEWEST_VERSION="${NEWEST_TAG#v}"
+ # 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
- if [[ -n "$NEWEST_TAG" ]] && release_version_gt "$NEWEST_VERSION" "$DEPLOYED_VERSION"; then
- # How many releases ahead, so the console can say "2 Aktualisierungen"
- # rather than only that something exists. Both helpers live in
- # deploy/lib/release.sh so a test can run them — the version that lived
- # here ended the agent on every tick under `set -e`, and no test in a
- # PHP suite can reach a bash pipeline.
- BEHIND="$(release_tags_ahead "$DEPLOYED_VERSION")"
- TARGET_RELEASE="$NEWEST_TAG"
- REMOTE_COMMIT="$(git rev-parse "refs/tags/${NEWEST_TAG}^{commit}" 2>/dev/null || echo '')"
- else
- # Includes "no tags at all yet": nothing has been released, so there is
- # nothing to offer, and saying "up to date" is the truth.
+ # 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
# Reported, not swallowed: "cannot reach the repository" and "already up to
diff --git a/docs/superpowers/plans/2026-08-04-release-decke.md b/docs/superpowers/plans/2026-08-04-release-decke.md
index 5629256..4cd2ddd 100644
--- a/docs/superpowers/plans/2026-08-04-release-decke.md
+++ b/docs/superpowers/plans/2026-08-04-release-decke.md
@@ -275,29 +275,50 @@ use Illuminate\Support\Facades\Process;
*/
function runAgentWithCeiling(?string $ceiling, array $tags, string $deployedVersion = '1.7.3'): array
{
- $dir = storage_path('app/deploy');
- File::ensureDirectoryExists($dir);
- File::delete(File::glob($dir.'/*'));
+ // Ein WEGWERF-Checkout mit EIGENEM .git — niemals das Repository dieses
+ // Arbeitsbaums.
+ //
+ // Tags liegen im gemeinsamen .git und sind damit auch fuer den Hauptbaum
+ // und jede Parallelsitzung sichtbar. Ein hier angelegtes v9.9.9 wuerde
+ // `git tag -l 'v*' --sort=-v:refname | head -1` falsch beantworten — und
+ // genau diese Frage entscheidet, wohin ein Server aktualisiert. Stirbt der
+ // Test vor seinem Aufraeumen, bliebe es liegen.
+ //
+ // Der Agent bestimmt seine Wurzel aus dem eigenen Pfad
+ // (`ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"`). Es genuegt
+ // also, deploy/ zu kopieren und ihn dort zu starten.
+ $root = sys_get_temp_dir().'/clupilot-ceiling-'.bin2hex(random_bytes(6));
+ File::ensureDirectoryExists($root.'/deploy/lib');
+ File::ensureDirectoryExists($root.'/storage/app/deploy');
- // Der Agent liest die ausgelieferte Version aus dem Manifest.
- File::put($dir.'/deployment.json', json_encode([
+ File::copy(base_path('deploy/update-agent.sh'), $root.'/deploy/update-agent.sh');
+ File::copy(base_path('deploy/lib/release.sh'), $root.'/deploy/lib/release.sh');
+
+ // MANIFEST_FILE ist `storage/app/deployment.json` — eine Ebene UEBER
+ // storage/app/deploy/. Siehe deploy/lib/release.sh:19.
+ File::put($root.'/storage/app/deployment.json', json_encode([
'version' => $deployedVersion,
'commit' => 'deadbeef',
'source' => 'refs/tags/v'.$deployedVersion,
]));
if ($ceiling !== null) {
- File::put($dir.'/release-ceiling', $ceiling);
+ File::put($root.'/storage/app/deploy/release-ceiling', $ceiling);
}
$tagCommands = '';
foreach ($tags as $tag) {
- $tagCommands .= "git tag {$tag} 2>/dev/null || true\n";
+ $tagCommands .= "git tag {$tag}\n";
}
- $result = Process::path(base_path())->timeout(90)->run(<<timeout(90)->run(<< "\$stub/git" <<'GIT'
#!/bin/sh
if [ "\$1" = "fetch" ]; then exit 0; fi
@@ -306,20 +327,18 @@ function runAgentWithCeiling(?string $ceiling, array $tags, string $deployedVers
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);
-}
+ $status = json_decode(File::get($root.'/storage/app/deploy/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");
-});
+ File::deleteDirectory($root);
+
+ return $status;
+}
it('offers only up to the ceiling', function () {
// Ausgeliefert 1.7.3, vorhanden bis v9.9.9, Decke auf v9.9.8: genau eine
diff --git a/docs/superpowers/specs/2026-08-04-release-decke-design.md b/docs/superpowers/specs/2026-08-04-release-decke-design.md
index 7ec9312..1d8bb17 100644
--- a/docs/superpowers/specs/2026-08-04-release-decke-design.md
+++ b/docs/superpowers/specs/2026-08-04-release-decke-design.md
@@ -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 |
diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php
index 6da0b3d..541f2d8 100644
--- a/lang/de/admin_settings.php
+++ b/lang/de/admin_settings.php
@@ -152,6 +152,30 @@ return [
'release_lock_requested' => 'Wird gelöst — der Dienst meldet sich binnen einer Minute zurück.',
'release_lock_already_requested' => 'Ist schon angefordert — bitte kurz warten.',
+ // ── Festnageln ────────────────────────────────────────────────────────
+ // Die Decke, die den Update-Agenten UND das Wartungsfenster begrenzt
+ // (App\Services\Deployment\UpdateChannel::setCeiling()). Bestätigt wird
+ // nur das Festnageln selbst im Modal (R23); das Auswahlfeld daneben
+ // braucht keins (R20).
+ '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_cancel' => 'Abbrechen',
+ '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',
+ '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.',
+
'update_error' => [
'repo_unreachable' => 'Das Repository ist vom Server aus nicht erreichbar.',
'detached_no_release' => 'Der Checkout hängt an keinem Branch und ist auf keine Version gepinnt.',
diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php
index 1de8b8c..ada1cd6 100644
--- a/lang/en/admin_settings.php
+++ b/lang/en/admin_settings.php
@@ -149,6 +149,30 @@ return [
'release_lock_requested' => 'Releasing — the service reports back within a minute.',
'release_lock_already_requested' => 'Already requested — please wait a moment.',
+ // ── Pinning a ceiling ────────────────────────────────────────────────
+ // The ceiling that limits both the update agent AND the maintenance
+ // window (App\Services\Deployment\UpdateChannel::setCeiling()). Only
+ // pinning itself is confirmed in a modal (R23); the select beside it
+ // needs none (R20).
+ '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_cancel' => 'Cancel',
+ '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',
+ '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.',
+
'update_error' => [
'repo_unreachable' => 'The repository cannot be reached from the server.',
'detached_no_release' => 'The checkout is on no branch and pinned to no release.',
diff --git a/resources/views/livewire/admin/confirm-pin-release.blade.php b/resources/views/livewire/admin/confirm-pin-release.blade.php
new file mode 100644
index 0000000..549b5b3
--- /dev/null
+++ b/resources/views/livewire/admin/confirm-pin-release.blade.php
@@ -0,0 +1,19 @@
+{{-- Zweizeilige Rückfrage mit einem Knopf: braucht nach R24 kein
+ , weil nichts darin scrollen kann. --}}
+
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php
index d4fbd00..a24c59f 100644
--- a/resources/views/livewire/admin/settings.blade.php
+++ b/resources/views/livewire/admin/settings.blade.php
@@ -173,10 +173,68 @@
{{ __('admin_settings.update_now') }}
+
+ {{-- Festnageln, in derselben Handlungsleiste wie Prüfen/Aktualisieren
+ statt einer zweiten Leiste daneben — dieselbe Begründung wie bei
+ den beiden Knöpfen selbst (flex-wrap, w-full sm:w-auto). Kein
+ Modal für das Auswahlfeld selbst (R20 gilt fürs Bearbeiten
+ bestehender Datensätze in Tabellenzeilen, nicht für ein
+ Auswahlfeld, das Teil der Seite ist); bestätigt wird nur das
+ Festnageln selbst im Modal (R23), weil die Wahl entscheidet,
+ welche Fassung dieser Server je bekommt. --}}
+ @if ($update['releases'])
+
+
+
+
+ {{ __('admin_settings.release_pin_action') }}
+
+ @endif
@endif
+ {{-- Der Zustand der Decke selbst — direkt unter der Leiste, die sie
+ setzt. „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).
+
+ 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'])
+
+ @endif
+
+ @if ($update['ceiling_error'])
+
+ {{ __('admin_settings.release_ceiling_'.$update['ceiling_error']) }}
+
+ @endif
+
@if (! $update['agent_seen'])
{{ __('admin_settings.update_no_agent') }}
@elseif ($update['blocked_since'])
diff --git a/tests/Feature/ReleaseCeilingAgentTest.php b/tests/Feature/ReleaseCeilingAgentTest.php
new file mode 100644
index 0000000..146a9fb
--- /dev/null
+++ b/tests/Feature/ReleaseCeilingAgentTest.php
@@ -0,0 +1,123 @@
+ $deployedVersion,
+ 'commit' => 'deadbeef',
+ 'source' => 'refs/tags/v'.$deployedVersion,
+ ]));
+
+ if ($ceiling !== null) {
+ File::put($root.'/storage/app/deploy/release-ceiling', $ceiling);
+ }
+
+ $tagCommands = '';
+ foreach ($tags as $tag) {
+ $tagCommands .= "git tag {$tag}\n";
+ }
+
+ $result = Process::path($root)->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"
+ PATH="\$stub:\$PATH" bash deploy/update-agent.sh >/dev/null 2>&1 || true
+ rm -rf "\$stub"
+ BASH);
+
+ expect($result->successful())->toBeTrue($result->errorOutput());
+
+ $status = json_decode(File::get($root.'/storage/app/deploy/update-status.json'), true);
+
+ File::deleteDirectory($root);
+
+ return $status;
+}
+
+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');
+});
diff --git a/tests/Feature/ReleaseCeilingConsoleTest.php b/tests/Feature/ReleaseCeilingConsoleTest.php
new file mode 100644
index 0000000..b5a4b5f
--- /dev/null
+++ b/tests/Feature/ReleaseCeilingConsoleTest.php
@@ -0,0 +1,339 @@
+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();
+});
+
+it('does not throw when the ceiling file exists but cannot be read as a file', function () {
+ // Kein echtes Wettrennen im Test moeglich, aber derselbe Codepfad wie ein
+ // Verschwinden zwischen File::exists() und File::get(): ein Verzeichnis
+ // existiert (File::exists() prueft file_exists(), das ist fuer
+ // Verzeichnisse wahr), ist aber keine Datei (File::get() prueft
+ // isFile() und wirft eine FileNotFoundException). Ohne Absicherung
+ // wuerfe state() genau hier — die Vorgabe, die es unter keinen
+ // Umstaenden darf.
+ //
+ // Bewusst kein expect(fn () => ...)->not->toThrow(Throwable::class):
+ // Throwable ist ein Interface, class_exists() verneint es, und die
+ // Pruefung faellt intern auf einen Substring-Vergleich zurueck, der
+ // nichts mehr ueber ein tatsaechliches Werfen aussagt. Ein direkter
+ // Aufruf laesst eine durchschlagende Exception den Test regulaer als
+ // fehlgeschlagen melden — das ist der verlaessliche Nachweis.
+ File::ensureDirectoryExists(storage_path('app/deploy/release-ceiling'));
+
+ $state = app(UpdateChannel::class)->state();
+
+ expect($state['ceiling'])->toBeNull();
+});
+
+it('does not claim success when writing the ceiling fails', function () {
+ // Kein File-Facade-Mock: eine echte Rechteverweigerung auf einem
+ // Verzeichnis, das dieser (nicht-root) Testbenutzer selbst besitzt.
+ // File::put() auf die .tmp darin schlaegt wirklich fehl, nicht nur dem
+ // Namen nach.
+ $deployDir = storage_path('app/deploy');
+ chmod($deployDir, 0500);
+
+ try {
+ $result = app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0');
+ } finally {
+ chmod($deployDir, 0755);
+ }
+
+ expect($result)->toBeFalse()
+ ->and(File::exists($deployDir.'/release-ceiling.tmp'))->toBeFalse()
+ ->and(File::exists($deployDir.'/release-ceiling'))->toBeFalse();
+});
+
+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, kein Beleg.
+ //
+ // Damit ALLEIN die Decke den Ausschlag gibt, muessen alle anderen
+ // Vorbedingungen aus AutoUpdate::handle() erfuellt sein: das Fenster ist
+ // eingeschaltet (--force uebergeht nur isOpen(), nicht enabled() — ein
+ // ausgeschaltetes Fenster wuerde den Test aus dem falschen Grund gruen
+ // machen), der Agent gilt als lebendig, und es laeuft nichts. Der Agent
+ // hat gemeldet: nichts verfuegbar, weil die Decke erreicht ist.
+ AppSettings::set(UpdateWindow::KEY_ENABLED, true);
+
+ File::put(storage_path('app/deploy/update-status.json'), json_encode([
+ 'state' => 'idle',
+ 'behind' => 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,
+ ]));
+
+ // Der Zustand, den Knopf und Wartungsfenster BEIDE lesen: schon hier
+ // steht `available` auf false, bevor der Befehl ueberhaupt laeuft.
+ expect(app(UpdateChannel::class)->state()['available'])->toBeFalse();
+
+ $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();
+});
+
+it('takes the same state without a ceiling, and does request an update', function () {
+ // Der Gegenbeweis zum Test oben: exakt derselbe Aufbau — Fenster an, Agent
+ // lebendig, nichts laeuft — nur `behind` steht auf 1 statt 0, so wie der
+ // Agent es meldet, wenn keine Decke ihn bremst. Erst beide Tests
+ // zusammen belegen, dass die Decke — und keine der anderen
+ // Vorbedingungen — den Ausschlag gibt: ohne sie legt dieselbe Automatik
+ // sehr wohl eine Anfrage ab.
+ AppSettings::set(UpdateWindow::KEY_ENABLED, true);
+
+ File::put(storage_path('app/deploy/update-status.json'), json_encode([
+ 'state' => 'idle',
+ 'behind' => 1,
+ 'ceiling_error' => '',
+ 'checked_at' => now()->toIso8601String(),
+ ]));
+ File::put(storage_path('app/deploy/agent-alive.json'), json_encode([
+ 'at' => now()->toIso8601String(),
+ 'state' => 'running',
+ 'skips' => 0,
+ ]));
+
+ expect(app(UpdateChannel::class)->state()['available'])->toBeTrue();
+
+ $this->artisan('clupilot:auto-update', ['--force' => true])
+ ->assertSuccessful();
+
+ expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeTrue();
+});
+
+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', 'v1.8.0');
+
+ 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', '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 `