CluPilotCloud/docs/superpowers/plans/2026-08-04-release-decke.md

42 KiB

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=<Name>
  • 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):

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:

# 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
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

use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;

/**
 * Die Decke, gefahren vom echten Agenten.
 *
 * Nicht nachgerechnet: die Klemmung sitzt zwischen `git fetch` und dem
 * Schreiben der Statusdatei, und genau dieses Zusammenspiel ist die Frage.
 * `git` liegt als Attrappe im PATH, deren `fetch` sofort gelingt, ohne eine
 * Gegenstelle zu brauchen — alle uebrigen git-Aufrufe gehen ans echte git im
 * Wegwerf-Repo.
 */
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.'/*'));

    // Der Agent liest die ausgelieferte Version aus dem Manifest.
    File::put($dir.'/deployment.json', json_encode([
        'version' => $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(<<<BASH
    stub="\$(mktemp -d)"
    # Nur `fetch` wird abgefangen; alles andere geht ans echte git.
    cat > "\$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:

# 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:

# 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:

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:

# 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:

  "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
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): boolnull 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

use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Facades\File;

beforeEach(function () {
    File::ensureDirectoryExists(storage_path('app/deploy'));
    File::delete(File::glob(storage_path('app/deploy/*')));
});

afterEach(function () {
    File::deleteDirectory(storage_path('app/deploy'));
});

it('writes and removes the ceiling', function () {
    $channel = app(UpdateChannel::class);
    $path = storage_path('app/deploy/release-ceiling');

    expect($channel->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:

    /**
     * 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()):

    /**
     * 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:

            // 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
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:

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
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:

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:

    /** Die im Auswahlfeld stehende Version, ehe sie bestätigt wird. */
    public string $ceilingChoice = '';

Hinter requestCheck() einfügen:

    /**
     * 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

namespace App\Livewire\Admin;

use LivewireUI\Modal\ModalComponent;

/**
 * Rückfrage vor dem Festnageln.
 *
 * Nach R23 im Modal und nicht in `window.confirm()`. Das Modal mutiert
 * nichts: es löst `pin-release-confirmed` aus, und die Seiten-Komponente
 * führt die Handlung mit ihrer eigenen Berechtigungsprüfung aus. Damit bleibt
 * die Prüfung an der einen Stelle, an der sie schon stand.
 */
class ConfirmPinRelease extends ModalComponent
{
    public string $version = '';

    public function render()
    {
        return view('livewire.admin.confirm-pin-release');
    }
}

resources/views/livewire/admin/confirm-pin-release.blade.php:

{{-- Zweizeilige Rückfrage mit einem Knopf: braucht nach R24 keinen
     feststehenden Fuß, weil nichts darin scrollen kann. --}}
<x-ui.modal :title="__('admin_settings.release_pin_title')">
    <p class="text-sm text-neutral-600 dark:text-neutral-300">
        {{ __('admin_settings.release_pin_body', ['version' => ltrim($version, 'v')]) }}
    </p>

    <x-slot:footer>
        <x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">
            {{ __('admin_settings.cancel') }}
        </x-ui.button>
        <x-ui.button wire:click="$dispatch('pin-release-confirmed'); $dispatch('closeModal')">
            {{ __('admin_settings.release_pin_confirm') }}
        </x-ui.button>
    </x-slot:footer>
</x-ui.modal>
  • 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:

{{-- 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'])
    <div class="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center">
        <select wire:model="ceilingChoice"
                class="w-full rounded-lg border-neutral-300 text-sm sm:w-auto dark:border-neutral-600 dark:bg-neutral-800">
            <option value="">{{ __('admin_settings.release_pin_none') }}</option>
            @foreach ($update['releases'] as $tag)
                <option value="{{ $tag }}">{{ ltrim($tag, 'v') }}</option>
            @endforeach
        </select>

        <x-ui.button variant="secondary" class="w-full sm:w-auto"
                     x-on:click="$dispatch('openModal', { component: 'admin.confirm-pin-release', arguments: { version: $wire.ceilingChoice } })"
                     x-bind:disabled="! $wire.ceilingChoice">
            {{ __('admin_settings.release_pin_action') }}
        </x-ui.button>
    </div>
@endif

@if ($update['ceiling'])
    <p class="mt-2 text-sm text-neutral-600 dark:text-neutral-300">
        {{-- „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')]) }}
        <button type="button" wire:click="unpinRelease" class="underline">
            {{ __('admin_settings.release_unpin_action') }}
        </button>
    </p>
@endif

@if ($update['ceiling_error'])
    <p class="mt-2 text-sm text-amber-700 dark:text-amber-400">
        {{ __('admin_settings.release_ceiling_'.$update['ceiling_error']) }}
    </p>
@endif
  • Step 6: Implement — Sprachdateien

lang/de/admin_settings.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:

    '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
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.