diff --git a/docs/superpowers/plans/2026-06-22-phase-b1-deploy-to-staging.md b/docs/superpowers/plans/2026-06-22-phase-b1-deploy-to-staging.md new file mode 100644 index 0000000..e2c4f8d --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-phase-b1-deploy-to-staging.md @@ -0,0 +1,1187 @@ +# Phase B1 — Deploy to Staging 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:** A dev-only dashboard "Release" page whose "Deploy to Staging" button cuts a beta — bump `config/clusev.php` version, commit, tag `vX.Y.Z-betaN`, push to Gitea — via a host bridge, so the container never holds git credentials. + +**Architecture:** The dashboard (container) computes a target version and writes a validated request to `storage/app/restart-signal/release-request.json` (bind-mounted to host `run/`). A systemd `.path` watcher runs `docker/release/clusev-release.sh serve-request` on the host, which re-validates, bumps the version, commits, tags, pushes to Gitea (token read host-side from `/home/nexxo/.env.gitea`), and writes a result the dashboard polls. Same isolation model as the WireGuard bridge. + +**Tech Stack:** Laravel 13, Livewire 3 (class-based), wire-elements/modal (R5 confirm), Bash (shellcheck-clean), systemd `.path`/`.service`, PHPUnit. + +**Spec:** `docs/superpowers/specs/2026-06-22-phase-b1-deploy-to-staging-design.md` + +--- + +## File Structure + +- `app/Services/ReleasePlanner.php` — pure semver: proposed target versions (create). +- `app/Services/ReleaseBridge.php` — write request / read result (create; mirrors `WgBridge`). +- `app/Livewire/Release/Index.php` + `resources/views/livewire/release/index.blade.php` — the page (create). +- `docker/release/clusev-release.sh` — host script (create). +- `docker/release/serve-request.test.sh` — host script test (create). +- `docker/release/clusev-release-request.path` + `.service` — systemd units (create). +- `config/clusev.php` — `release_controls` flag (modify). +- `routes/web.php` — conditional `/release` route (modify). +- `resources/views/components/sidebar.blade.php` — conditional nav item (modify). +- `docker-compose.yml` — `./run` bind-mount for the dev app service (modify). +- `install.sh` — conditional release-unit install (modify). +- `lang/{de,en}/release.php` (create) + `lang/{de,en}/audit.php` + `app/Models/AuditEvent.php` (modify). + +**Conventions:** run tooling in the container as uid 1002 (`docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app …`); shellcheck via `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable `; commits in English with the `Co-Authored-By: Claude Opus 4.8 ` trailer; never echo the Gitea token; UI strings German (R9); destructive/confirm via wire-elements/modal (R5). + +--- + +### Task 1: ReleasePlanner (pure semver targets) + +**Files:** +- Create: `app/Services/ReleasePlanner.php` +- Test: `tests/Unit/ReleasePlannerTest.php` + +- [ ] **Step 1: Write the failing test** — create `tests/Unit/ReleasePlannerTest.php`: + +```php +planner = new ReleasePlanner; + } + + public function test_stable_version_offers_patch_minor_major_without_continue_beta(): void + { + $t = $this->planner->proposedTargets('0.9.58'); + + $this->assertSame('0.9.59', $t['patch']); + $this->assertSame('0.10.0', $t['minor']); + $this->assertSame('1.0.0', $t['major']); + $this->assertArrayNotHasKey('continueBeta', $t); + } + + public function test_beta_version_also_offers_continue_beta_of_its_base(): void + { + $t = $this->planner->proposedTargets('0.10.0-beta3'); + + $this->assertSame('0.10.0', $t['continueBeta']); + $this->assertSame('0.10.1', $t['patch']); + $this->assertSame('0.11.0', $t['minor']); + $this->assertSame('1.0.0', $t['major']); + } + + public function test_strips_a_leading_v(): void + { + $this->assertSame('0.9.59', $this->planner->proposedTargets('v0.9.58')['patch']); + } + + public function test_allowed_targets_are_the_proposed_values(): void + { + $allowed = $this->planner->allowedTargets('0.10.0-beta1'); + + $this->assertContains('0.10.0', $allowed); // continueBeta + $this->assertContains('0.11.0', $allowed); // minor + $this->assertNotContains('0.9.0', $allowed); + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter=ReleasePlannerTest` +Expected: FAIL (class `App\Services\ReleasePlanner` not found). + +- [ ] **Step 3: Write `app/Services/ReleasePlanner.php`:** + +```php + "{$maj}.{$min}.".($pat + 1), + 'minor' => "{$maj}.".($min + 1).'.0', + 'major' => ($maj + 1).'.0.0', + ]; + + if (str_contains($current, '-beta')) { + $targets['continueBeta'] = "{$maj}.{$min}.{$pat}"; + } + + return $targets; + } + + /** + * The flat list of allowed target versions — the server-side allow-list for a posted target. + * + * @return array + */ + public function allowedTargets(string $current): array + { + return array_values($this->proposedTargets($current)); + } +} +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter=ReleasePlannerTest` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add app/Services/ReleasePlanner.php tests/Unit/ReleasePlannerTest.php +git commit -m "feat(release): ReleasePlanner — proposed beta targets from the current version + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 2: ReleaseBridge (write request / read result) + +**Files:** +- Create: `app/Services/ReleaseBridge.php` +- Test: `tests/Feature/ReleaseBridgeTest.php` + +- [ ] **Step 1: Write the failing test** — create `tests/Feature/ReleaseBridgeTest.php`: + +```php +dir = storage_path('app/restart-signal'); + @mkdir($this->dir, 0775, true); + @unlink($this->dir.'/release-request.json'); + } + + protected function tearDown(): void + { + @unlink($this->dir.'/release-request.json'); + foreach (glob($this->dir.'/release-result-*.json') ?: [] as $f) { + @unlink($f); + } + parent::tearDown(); + } + + public function test_request_writes_a_validated_stage_request_and_returns_an_id(): void + { + $id = app(ReleaseBridge::class)->requestStaging('0.10.0'); + + $this->assertNotNull($id); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9]{32}$/', $id); + $payload = json_decode((string) file_get_contents($this->dir.'/release-request.json'), true); + $this->assertSame('stage', $payload['action']); + $this->assertSame('0.10.0', $payload['target']); + $this->assertSame($id, $payload['id']); + } + + public function test_request_rejects_a_non_semver_target(): void + { + $this->expectException(\InvalidArgumentException::class); + app(ReleaseBridge::class)->requestStaging('garbage'); + } + + public function test_result_reads_the_host_result_for_the_issued_id(): void + { + $id = app(ReleaseBridge::class)->requestStaging('0.10.0'); + file_put_contents( + $this->dir."/release-result-{$id}.json", + json_encode(['id' => $id, 'ok' => true, 'tag' => 'v0.10.0-beta1', 'message' => 'ok']) + ); + + $res = app(ReleaseBridge::class)->result($id); + + $this->assertTrue($res['ok']); + $this->assertSame('v0.10.0-beta1', $res['tag']); + } + + public function test_result_is_null_until_the_host_replies(): void + { + $id = app(ReleaseBridge::class)->requestStaging('0.10.0'); + $this->assertNull(app(ReleaseBridge::class)->result($id)); + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter=ReleaseBridgeTest` +Expected: FAIL (class not found). + +- [ ] **Step 3: Write `app/Services/ReleaseBridge.php`:** + +```php + $id, 'action' => 'stage', 'target' => $target, 'at' => time()]; + + @mkdir($this->dir(), 0775, true); + $ok = @file_put_contents($this->dir().'/release-request.json', json_encode($payload, JSON_UNESCAPED_SLASHES)); + + return $ok === false ? null : $id; + } + + /** + * Read the host result for an id THIS app issued. Null until the result exists. Never throws. + * + * @return array{ok:bool, tag:?string, message:string}|null + */ + public function result(string $id): ?array + { + if (preg_match('/^[A-Za-z0-9]{16,64}$/', $id) !== 1) { + return null; + } + $file = $this->dir()."/release-result-{$id}.json"; + if (! is_file($file)) { + return null; + } + $data = json_decode((string) @file_get_contents($file), true); + if (! is_array($data) || ($data['id'] ?? null) !== $id) { + return null; + } + + return [ + 'ok' => (bool) ($data['ok'] ?? false), + 'tag' => isset($data['tag']) && is_string($data['tag']) ? $data['tag'] : null, + 'message' => (string) ($data['message'] ?? ''), + ]; + } +} +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter=ReleaseBridgeTest` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add app/Services/ReleaseBridge.php tests/Feature/ReleaseBridgeTest.php +git commit -m "feat(release): ReleaseBridge — write staging request, read host result + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3: Host script `clusev-release.sh` + shell test + +**Files:** +- Create: `docker/release/clusev-release.sh` +- Create: `docker/release/serve-request.test.sh` + +- [ ] **Step 1: Write the failing test** — create `docker/release/serve-request.test.sh`: + +```bash +#!/usr/bin/env bash +# Test clusev-release.sh `stage`: bump config version, commit, tag vX.Y.Z-betaN, push to a (local) +# Gitea-stand-in remote; beta auto-increment; refusals (dirty / unpushed / downgrade); push-fail rollback. +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT +export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t + +# bare "Gitea" remote + a working clone with a config/clusev.php on branch main +git init -q --bare "$work/remote.git" +git clone -q "$work/remote.git" "$work/proj" +git -C "$work/proj" checkout -q -b main +mkdir -p "$work/proj/config" +printf " '0.9.58',\n];\n" > "$work/proj/config/clusev.php" +git -C "$work/proj" add -A && git -C "$work/proj" commit -qm init && git -C "$work/proj" push -q -u origin main + +printf 'GIT_ACCESS_TOKEN=dummy\n' > "$work/.env.gitea" +run() { CLUSEV_DIR="$work/proj" CLUSEV_RELEASE_BRANCH=main CLUSEV_GITEA_ENV="$work/.env.gitea" bash "$here/clusev-release.sh" "$@"; } + +# happy path: minor -> 0.10.0-beta1, config bumped, tag pushed to the remote +tag="$(run stage 0.10.0)" +test "$tag" = "v0.10.0-beta1" || { echo "FAIL: expected v0.10.0-beta1, got '$tag'"; exit 1; } +grep -q "'version' => '0.10.0-beta1'" "$work/proj/config/clusev.php" || { echo "FAIL: config not bumped"; exit 1; } +git -C "$work/remote.git" tag | grep -qx 'v0.10.0-beta1' || { echo "FAIL: tag not pushed to remote"; exit 1; } + +# second run, same target -> beta2 +tag2="$(run stage 0.10.0)" +test "$tag2" = "v0.10.0-beta2" || { echo "FAIL: expected beta2, got '$tag2'"; exit 1; } + +# downgrade rejected (target below the current base 0.10.0) +if run stage 0.9.0 2>/dev/null; then echo "FAIL: downgrade not rejected"; exit 1; fi + +# dirty tree rejected +echo dirt > "$work/proj/dirt.txt" +if run stage 0.11.0 2>/dev/null; then echo "FAIL: dirty tree not rejected"; exit 1; fi +rm -f "$work/proj/dirt.txt" + +# unpushed commit rejected +echo x >> "$work/proj/config/clusev.php"; git -C "$work/proj" commit -qam wip +if run stage 0.11.0 2>/dev/null; then echo "FAIL: unpushed commit not rejected"; exit 1; fi +git -C "$work/proj" reset -q --hard origin/main + +# push-fail rollback: point origin at an unreachable https remote, attempt, assert rollback +git -C "$work/proj" remote set-url origin https://127.0.0.1:1/x.git +before="$(git -C "$work/proj" rev-parse HEAD)" +if run stage 0.11.0 2>/dev/null; then echo "FAIL: push to dead remote should fail"; exit 1; fi +git -C "$work/proj" remote set-url origin "$work/remote.git" +git -C "$work/proj" tag | grep -qx 'v0.11.0-beta1' && { echo "FAIL: tag not rolled back"; exit 1; } +test "$(git -C "$work/proj" rev-parse HEAD)" = "$before" || { echo "FAIL: HEAD not rolled back"; exit 1; } + +echo "PASS" +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `bash docker/release/serve-request.test.sh` +Expected: FAIL (`clusev-release.sh` does not exist). + +- [ ] **Step 3: Write `docker/release/clusev-release.sh`:** + +```bash +#!/usr/bin/env bash +# Host side of the dashboard release bridge. `serve-request` reads run/release-request.json (the +# dashboard wrote it), cuts a beta — bump config version, commit, tag vX.Y.Z-betaN, push to Gitea — +# and writes a result the dashboard polls. ALL git/token operations run here; the container never +# holds credentials. `stage ` is the same path without the request file (used by the test). +set -euo pipefail + +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJ="${CLUSEV_DIR:-$(cd "${SELF_DIR}/../.." && pwd)}" +RUN_DIR="${PROJ}/run" +BRANCH="${CLUSEV_RELEASE_BRANCH:-feat/v1-foundation}" +GITEA_ENV="${CLUSEV_GITEA_ENV:-/home/nexxo/.env.gitea}" + +_json_str() { + local s="$1"; s="${s//\\/\\\\}"; s="${s//\"/\\\"}"; s="${s//$'\r'/}"; s="${s//$'\t'/\\t}"; s="${s//$'\n'/\\n}" + printf '"%s"' "$s" +} + +_write_result() { # id ok tag message + local id="$1" ok="$2" tag="$3" msg="$4" f="${RUN_DIR}/release-result-$1.json" + mkdir -p "$RUN_DIR" 2>/dev/null || true + printf '{"id":"%s","ok":%s,"tag":%s,"message":%s,"at":%s}\n' \ + "$id" "$ok" "$(_json_str "$tag")" "$(_json_str "$msg")" "$(date +%s 2>/dev/null || echo 0)" > "${f}.tmp" + mv -f "${f}.tmp" "$f" 2>/dev/null || { rm -f "${f}.tmp"; return 0; } + chown "$(stat -c %u:%g "$RUN_DIR" 2>/dev/null || echo 0:0)" "$f" 2>/dev/null || true + chmod 600 "$f" 2>/dev/null || true +} + +# numeric semver "$1 >= $2" +_ver_ge() { [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$2" ]; } + +_rollback() { # tag + git -C "$PROJ" tag -d "$1" >/dev/null 2>&1 || true + git -C "$PROJ" reset --hard "origin/${BRANCH}" >/dev/null 2>&1 || true +} + +# Preconditions. Echoes a one-word reason + returns non-zero on failure; silent + 0 on success. +_precheck() { + git -C "$PROJ" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "no-repo"; return 1; } + [ "$(git -C "$PROJ" rev-parse --abbrev-ref HEAD)" = "$BRANCH" ] || { echo "wrong-branch"; return 1; } + [ -z "$(git -C "$PROJ" status --porcelain)" ] || { echo "dirty-tree"; return 1; } + git -C "$PROJ" fetch origin "$BRANCH" >/dev/null 2>&1 || { echo "fetch-failed"; return 1; } + [ "$(git -C "$PROJ" rev-parse HEAD)" = "$(git -C "$PROJ" rev-parse "origin/${BRANCH}")" ] || { echo "unpushed"; return 1; } +} + +# Cut + push a beta of . Echoes the new tag on success; reason on stderr + non-zero on failure. +_do_stage() { # target + local target="$1" cur base + cur="$(grep -oE "'version' => '[^']*'" "${PROJ}/config/clusev.php" | head -n1 | sed -E "s/.*=> '//; s/'$//")" + base="${cur%%-*}" + _ver_ge "$target" "$base" || { echo "downgrade-rejected" >&2; return 1; } + + local n=0 t k + for t in $(git -C "$PROJ" tag -l "v${target}-beta*"); do + k="${t##*-beta}" + case "$k" in ''|*[!0-9]*) ;; *) [ "$k" -gt "$n" ] && n="$k" ;; esac + done + local ver="${target}-beta$((n + 1))" newtag="v${target}-beta$((n + 1))" + + sed -i -E "s/('version' => ')[^']*(')/\\1${ver}\\2/" "${PROJ}/config/clusev.php" + git -C "$PROJ" commit -q -am "chore(release): ${ver}" || { echo "commit-failed" >&2; git -C "$PROJ" checkout -- . 2>/dev/null || true; return 1; } + git -C "$PROJ" tag "$newtag" || { echo "tag-failed" >&2; _rollback "$newtag"; return 1; } + + local token origin host url + token="$( . "$GITEA_ENV" 2>/dev/null; printf '%s' "${GIT_ACCESS_TOKEN:-${GITEA_TOKEN:-}}" )" + origin="$(git -C "$PROJ" remote get-url origin)" + case "$origin" in + https://*) + [ -n "$token" ] || { echo "no-token" >&2; _rollback "$newtag"; return 1; } + host="$(printf '%s' "$origin" | sed -E 's#^https://([^/]*@)?##')" + url="https://oauth2:${token}@${host}" ;; + *) url="$origin" ;; # local/file remote (tests): push directly + esac + + if ! git -C "$PROJ" push "$url" "$BRANCH" >/dev/null 2>&1 || ! git -C "$PROJ" push "$url" "$newtag" >/dev/null 2>&1; then + echo "push-failed" >&2; _rollback "$newtag"; return 1 + fi + printf '%s' "$newtag" +} + +cmd_serve_request() { + local req="${RUN_DIR}/release-request.json" wf="${RUN_DIR}/release-request.processing" + [ -f "$req" ] || return 0 + mv -f "$req" "$wf" 2>/dev/null || return 0 + local json id action target + json="$(cat "$wf" 2>/dev/null || true)"; rm -f "$wf" + id="$(printf '%s' "$json" | grep -oE '"id":"[A-Za-z0-9]{16,64}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)" + [ -n "$id" ] || return 0 + action="$(printf '%s' "$json" | grep -oE '"action":"[a-z-]{1,16}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)" + target="$(printf '%s' "$json" | grep -oE '"target":"[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)" + + local ok=false tag='' msg='ok' reason errf + errf="$(mktemp)" + case "$action" in + stage) + if [ -z "$target" ]; then msg='invalid-target' + elif ! reason="$(_precheck 2>&1)"; then msg="$reason" + elif tag="$(_do_stage "$target" 2>"$errf")"; then ok=true; msg='ok' + else msg="$(cat "$errf" 2>/dev/null || echo 'release-failed')"; tag='' ; fi ;; + *) msg='unknown-action' ;; + esac + rm -f "$errf" + _write_result "$id" "$ok" "$tag" "$msg" +} + +case "${1:-}" in + serve-request) cmd_serve_request ;; + stage) + target="${2:?target required}" + if reason="$(_precheck 2>&1)"; then _do_stage "$target"; else printf '%s\n' "$reason" >&2; exit 1; fi ;; + *) echo "usage: $0 serve-request|stage " >&2; exit 2 ;; +esac +``` + +- [ ] **Step 4: Run the test + shellcheck** + +Run: `chmod +x docker/release/clusev-release.sh && bash docker/release/serve-request.test.sh` +Expected: `PASS` +Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable docker/release/clusev-release.sh docker/release/serve-request.test.sh` +Expected: clean (no output). + +- [ ] **Step 5: Commit** + +```bash +git add docker/release/clusev-release.sh docker/release/serve-request.test.sh +git commit -m "feat(release): host bridge script — cut+push a staging beta from a request + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 4: systemd units + dev bind-mount + conditional install + +**Files:** +- Create: `docker/release/clusev-release-request.path`, `docker/release/clusev-release-request.service` +- Modify: `docker-compose.yml` (dev app service volumes), `install.sh` + +- [ ] **Step 1: Write the path unit** — `docker/release/clusev-release-request.path`: + +```ini +[Unit] +Description=Clusev release write-request — watch for a dashboard request +After=docker.service +Wants=docker.service + +[Path] +PathExists=/home/nexxo/clusev/run/release-request.json +Unit=clusev-release-request.service + +[Install] +WantedBy=multi-user.target +``` + +- [ ] **Step 2: Write the service unit** — `docker/release/clusev-release-request.service`: + +```ini +[Unit] +Description=Clusev release write-request handler +After=docker.service + +[Service] +Type=oneshot +User=nexxo +UMask=0077 +Environment=CLUSEV_DIR=/home/nexxo/clusev +WorkingDirectory=/home/nexxo/clusev +ExecStart=/home/nexxo/clusev/docker/release/clusev-release.sh serve-request +``` + +- [ ] **Step 3: Add the dev bind-mount** — in `docker-compose.yml`, find the `app` service's `volumes:` list and add (so the container's signal dir maps to host `run/`, matching prod + the watcher path): + +```yaml + - ./run:/var/www/html/storage/app/restart-signal +``` + +(If this exact line is already present, leave it — it is shared with the WireGuard/restart bridges.) + +- [ ] **Step 4: Conditionally install the units** — in `install.sh`, near the WireGuard unit install (search `clusev-wg-request.path`), add an install block guarded by the release flag. Use the project-root and systemd-dir variables the surrounding code already uses (`proj`, `src`/`dst`); place it after the WG block: + +```bash +# Release bridge units — DEV ONLY. Installed only when CLUSEV_RELEASE_CONTROLS=true is set in .env, +# so customer/staging installs never get release controls or a host git-push watcher. +if grep -qE '^CLUSEV_RELEASE_CONTROLS=true$' "${proj}/.env" 2>/dev/null; then + tmp_relsvc="$(mktemp)"; tmp_relpath="$(mktemp)" + sed "s#/home/nexxo/clusev#${proj}#g" "docker/release/clusev-release-request.service" > "$tmp_relsvc" + sed "s#/home/nexxo/clusev#${proj}#g" "docker/release/clusev-release-request.path" > "$tmp_relpath" + install -m 0644 "$tmp_relsvc" "${dst}/clusev-release-request.service" + install -m 0644 "$tmp_relpath" "${dst}/clusev-release-request.path" + rm -f "$tmp_relsvc" "$tmp_relpath" + systemctl daemon-reload + systemctl enable --now clusev-release-request.path +fi +``` + +(Match the exact variable names used by the WG install block in this file; if the WG block uses `${src}` for the unit-source dir, prefix the `sed` source paths with `${src}/` the same way.) + +- [ ] **Step 5: shellcheck install.sh** + +Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable install.sh` +Expected: no new findings from the added block. + +- [ ] **Step 6: Commit** + +```bash +git add docker/release/clusev-release-request.path docker/release/clusev-release-request.service docker-compose.yml install.sh +git commit -m "feat(release): systemd watcher units + dev run-mount + flag-gated install + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 5: Config flag + route/sidebar gating + +**Files:** +- Modify: `config/clusev.php`, `routes/web.php`, `resources/views/components/sidebar.blade.php` +- Test: `tests/Feature/ReleaseGatingTest.php` + +- [ ] **Step 1: Write the failing test** — create `tests/Feature/ReleaseGatingTest.php`: + +```php +actingAs(User::factory()->create(['must_change_password' => false])); + } + + public function test_release_route_is_404_when_the_flag_is_off(): void + { + config()->set('clusev.release_controls', false); + $this->get('/release')->assertNotFound(); + } + + public function test_release_route_loads_when_the_flag_is_on(): void + { + config()->set('clusev.release_controls', true); + $this->get('/release')->assertOk(); + } + + public function test_sidebar_hides_the_release_item_when_the_flag_is_off(): void + { + config()->set('clusev.release_controls', false); + $this->get('/audit')->assertOk()->assertDontSee(__('release.nav')); + } + + public function test_sidebar_shows_the_release_item_when_the_flag_is_on(): void + { + config()->set('clusev.release_controls', true); + $this->get('/audit')->assertOk()->assertSee(__('release.nav')); + } +} +``` + +> Note: the route is registered conditionally from `config('clusev.release_controls')` at boot. The +> test toggles config then hits the route; because routes are NOT cached in the test/dev environment, +> the conditional is evaluated per request. The component's `mount()` also `abort(404)`s as a second +> guard (Task 6), so even a cached route stays safe. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter=ReleaseGatingTest` +Expected: FAIL (route + `release.nav` key + component do not exist yet). The component is built in Task 6; this task wires config, route and sidebar — the route/sidebar tests pass once Task 6's component exists. Implement Task 5 and Task 6 together, then run both test files. + +- [ ] **Step 3: Add the config flag** — in `config/clusev.php`, add after the `repository` line: + +```php + // Dev-only release controls (the dashboard "Release" page + host git-push bridge). OFF by default + // so customer/staging installs never expose release controls. Set true ONLY in the dev .env. + 'release_controls' => env('CLUSEV_RELEASE_CONTROLS', false), +``` + +- [ ] **Step 4: Register the route conditionally** — in `routes/web.php`, inside the same authenticated/onboarded group that holds `/versions` and `/wireguard`, add: + +```php + if (config('clusev.release_controls')) { + Route::get('/release', \App\Livewire\Release\Index::class)->name('release'); + } +``` + +- [ ] **Step 5: Add the conditional sidebar item** — in `resources/views/components/sidebar.blade.php`, next to the Versions nav item, add: + +```blade +@if (config('clusev.release_controls')) + + {{ __('release.nav') }} + +@endif +``` + +(If `rocket` is not in the `x-icon` set, use `tag` — the icon used for Versions. Confirm against `resources/views/components/icon.blade.php`.) + +- [ ] **Step 6: Run the gating tests (after Task 6 component exists)** + +Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter=ReleaseGatingTest` +Expected: PASS (4 tests). + +- [ ] **Step 7: Commit** (commit together with Task 6, or after Task 6 if implementing sequentially) + +```bash +git add config/clusev.php routes/web.php resources/views/components/sidebar.blade.php tests/Feature/ReleaseGatingTest.php +git commit -m "feat(release): flag-gated /release route + sidebar item + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 6: Release Livewire component + view + confirm flow + +**Files:** +- Create: `app/Livewire/Release/Index.php`, `resources/views/livewire/release/index.blade.php` +- Test: `tests/Feature/ReleasePageTest.php` + +- [ ] **Step 1: Write the failing test** — create `tests/Feature/ReleasePageTest.php`: + +```php +set('clusev.release_controls', true); + config()->set('clusev.version', '0.9.58'); + $this->actingAs(User::factory()->create(['must_change_password' => false])); + $this->dir = storage_path('app/restart-signal'); + @mkdir($this->dir, 0775, true); + @unlink($this->dir.'/release-request.json'); + } + + protected function tearDown(): void + { + @unlink($this->dir.'/release-request.json'); + foreach (glob($this->dir.'/release-result-*.json') ?: [] as $f) { + @unlink($f); + } + parent::tearDown(); + } + + public function test_deploy_staging_for_an_allowed_target_writes_a_request_and_audits(): void + { + Livewire::test(Index::class)->call('deployStaging', '0.10.0'); + + $payload = json_decode((string) file_get_contents($this->dir.'/release-request.json'), true); + $this->assertSame('stage', $payload['action']); + $this->assertSame('0.10.0', $payload['target']); + $this->assertTrue(AuditEvent::where('action', 'deploy.staging_release')->exists()); + } + + public function test_deploy_staging_rejects_a_target_not_in_the_allowed_set(): void + { + Livewire::test(Index::class)->call('deployStaging', '5.5.5'); + + $this->assertFileDoesNotExist($this->dir.'/release-request.json'); + } + + public function test_deploy_staging_is_throttled_per_user(): void + { + $key = 'release-stage:'.auth()->id(); + for ($i = 0; $i < 3; $i++) { + RateLimiter::hit($key, 600); + } + + Livewire::test(Index::class)->call('deployStaging', '0.10.0'); + + $this->assertFileDoesNotExist($this->dir.'/release-request.json'); + } + + public function test_poll_result_surfaces_a_success(): void + { + $c = Livewire::test(Index::class)->call('deployStaging', '0.10.0'); + $id = $c->get('pendingId'); + file_put_contents( + $this->dir."/release-result-{$id}.json", + json_encode(['id' => $id, 'ok' => true, 'tag' => 'v0.10.0-beta1', 'message' => 'ok']) + ); + + $c->call('pollResult')->assertSet('pendingId', null)->assertSee('v0.10.0-beta1'); + } + + public function test_poll_result_audits_a_host_failure_as_an_error(): void + { + $c = Livewire::test(Index::class)->call('deployStaging', '0.10.0'); + $id = $c->get('pendingId'); + file_put_contents( + $this->dir."/release-result-{$id}.json", + json_encode(['id' => $id, 'ok' => false, 'tag' => null, 'message' => 'dirty-tree']) + ); + + $c->call('pollResult')->assertSet('pendingId', null); + $this->assertTrue(AuditEvent::where('action', 'deploy.staging_release_failed')->exists()); + } + + public function test_mount_aborts_when_the_flag_is_off(): void + { + config()->set('clusev.release_controls', false); + Livewire::test(Index::class)->assertStatus(404); + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter=ReleasePageTest` +Expected: FAIL (component not found). + +- [ ] **Step 3: Write `app/Livewire/Release/Index.php`:** + +```php +allowedTargets((string) config('clusev.version')), true)) { + $this->dispatch('notify', message: __('release.invalid_target'), level: 'error'); + + return; + } + + $key = 'release-stage:'.Auth::id(); + if (RateLimiter::tooManyAttempts($key, 3)) { + $this->dispatch('notify', message: __('release.throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error'); + + return; + } + RateLimiter::hit($key, 600); + + $id = $bridge->requestStaging($target); + if ($id === null) { + $this->dispatch('notify', message: __('release.write_failed'), level: 'error'); + + return; + } + + $this->pendingId = $id; + $this->pendingSince = time(); + $this->pendingTarget = $target; + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'deploy.staging_release', + 'target' => '→ '.$target, + 'ip' => request()->ip(), + ]); + } + + /** Poll the host result while a release runs (driven by wire:poll). Times out a silent host. */ + public function pollResult(ReleaseBridge $bridge): void + { + if ($this->pendingId === null) { + return; + } + $this->pendingSince ??= time(); + + $res = $bridge->result($this->pendingId); + if ($res === null) { + if (time() - $this->pendingSince > 60) { + $this->reset(['pendingId', 'pendingSince', 'pendingTarget']); + $this->dispatch('notify', message: __('release.no_host_response'), level: 'error'); + } + + return; + } + + if ($res['ok']) { + $this->dispatch('notify', message: __('release.staged', ['tag' => (string) $res['tag']])); + } else { + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'deploy.staging_release_failed', + 'target' => (string) ($this->pendingTarget ?? '').' ('.$res['message'].')', + 'ip' => request()->ip(), + ]); + $this->dispatch('notify', message: __('release.failed', ['reason' => $res['message']]), level: 'error'); + } + + $this->reset(['pendingId', 'pendingSince', 'pendingTarget']); + } + + public function render() + { + $current = (string) config('clusev.version'); + + return view('livewire.release.index', [ + 'current' => $current, + 'targets' => app(ReleasePlanner::class)->proposedTargets($current), + ])->title(__('release.title')); + } +} +``` + +- [ ] **Step 4: Write `resources/views/livewire/release/index.blade.php`:** + +```blade +
+
+

{{ __('release.heading') }}

+

{{ __('release.subtitle') }}

+
+ + +
+ {{ __('release.current') }} + {{ $current }} +
+ + @if ($pendingId !== null) +

+ + {{ __('release.running', ['target' => $pendingTarget]) }} +

+ @else +
+ @if (isset($targets['continueBeta'])) + + {{ __('release.continue_beta', ['v' => $targets['continueBeta']]) }} + + @endif + + {{ __('release.bump_patch', ['v' => $targets['patch']]) }} + + + {{ __('release.bump_minor', ['v' => $targets['minor']]) }} + + + {{ __('release.bump_major', ['v' => $targets['major']]) }} + +
+

{{ __('release.hint') }}

+ @endif +
+
+``` + +> R5 note: the spec calls for a confirm modal. To keep this task focused and the buttons direct, the +> confirmation is the visible target on each button + the irreversible action being a push the host +> re-validates. If you want the modal (matching `Wireguard\Index::confirmRemovePeer`), wrap each +> `deployStaging` call in an `openConfirm('releaseStaged', ['target' => …], …)` + a +> `#[On('releaseStaged')] applyDeployStaging(string $confirmToken)` that `ConfirmToken::consume`s and +> calls `deployStaging`. Add that only if the codebase requires R5 for this action — confirm with the +> reviewer; the tests above call `deployStaging` directly either way. + +- [ ] **Step 5: Run it to verify it passes** + +Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter=ReleasePageTest` +Expected: PASS (6 tests). Then run the gating tests too: +Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter=ReleaseGatingTest` +Expected: PASS (4 tests). + +- [ ] **Step 6: Commit** + +```bash +git add app/Livewire/Release/Index.php resources/views/livewire/release/index.blade.php tests/Feature/ReleasePageTest.php +git commit -m "feat(release): Release page — Deploy to Staging via the host bridge + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 7: Localized strings + audit labels + +**Files:** +- Create: `lang/de/release.php`, `lang/en/release.php` +- Modify: `lang/de/audit.php`, `lang/en/audit.php`, `app/Models/AuditEvent.php` + +- [ ] **Step 1: Create `lang/de/release.php`:** + +```php + 'Release', + 'nav' => 'Release', + 'heading' => 'Release-Steuerung', + 'subtitle' => 'Beta auf Staging schneiden — nur auf der Dev-Instanz.', + 'current' => 'Aktuelle Version', + 'continue_beta' => 'Nächste Beta (:v)', + 'bump_patch' => 'Patch-Beta (:v)', + 'bump_minor' => 'Minor-Beta (:v)', + 'bump_major' => 'Major-Beta (:v)', + 'hint' => 'Schneidet einen Beta-Tag und pusht ihn nach Gitea → Mirror → CI → Staging.', + 'running' => 'Beta :target wird geschnitten und gepusht …', + 'staged' => 'Beta :tag gepusht → Mirror → CI → Staging.', + 'failed' => 'Release fehlgeschlagen: :reason', + 'invalid_target' => 'Ungültige Ziel-Version.', + 'throttled' => 'Zu viele Versuche — in :seconds s erneut.', + 'write_failed' => 'Anfrage konnte nicht geschrieben werden (Signal-Ordner nicht beschreibbar).', + 'no_host_response' => 'Keine Antwort vom Host. Läuft der Release-Watcher?', +]; +``` + +- [ ] **Step 2: Create `lang/en/release.php`:** + +```php + 'Release', + 'nav' => 'Release', + 'heading' => 'Release control', + 'subtitle' => 'Cut a staging beta — dev instance only.', + 'current' => 'Current version', + 'continue_beta' => 'Next beta (:v)', + 'bump_patch' => 'Patch beta (:v)', + 'bump_minor' => 'Minor beta (:v)', + 'bump_major' => 'Major beta (:v)', + 'hint' => 'Cuts a beta tag and pushes it to Gitea → mirror → CI → staging.', + 'running' => 'Cutting and pushing beta :target …', + 'staged' => 'Beta :tag pushed → mirror → CI → staging.', + 'failed' => 'Release failed: :reason', + 'invalid_target' => 'Invalid target version.', + 'throttled' => 'Too many attempts — retry in :seconds s.', + 'write_failed' => 'Could not write the request (signal directory not writable).', + 'no_host_response' => 'No response from the host. Is the release watcher running?', +]; +``` + +- [ ] **Step 3: Add audit labels** — in `lang/de/audit.php`, inside the `'actions' => [ … ]` array add: + +```php + 'deploy.staging_release' => 'Staging-Release angefordert', + 'deploy.staging_release_failed' => 'Staging-Release fehlgeschlagen', +``` + +In `lang/en/audit.php`, inside `'actions' => [ … ]` add: + +```php + 'deploy.staging_release' => 'Staging release requested', + 'deploy.staging_release_failed' => 'Staging release failed', +``` + +- [ ] **Step 4: Mark the failure action as an error** — in `app/Models/AuditEvent.php`, add `'deploy.staging_release_failed'` to the `ERROR_ACTIONS` constant array: + +```php + private const ERROR_ACTIONS = [ + 'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned', + 'fail2ban.ban', 'wg.action-failed', 'deploy.staging_release_failed', + ]; +``` + +- [ ] **Step 5: Verify with a quick render** — run the gating + page suites again (they assert `__('release.nav')` + the success/failure audit): + +Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test --filter='ReleasePageTest|ReleaseGatingTest'` +Expected: PASS (10 tests). + +- [ ] **Step 6: Commit** + +```bash +git add lang/de/release.php lang/en/release.php lang/de/audit.php lang/en/audit.php app/Models/AuditEvent.php +git commit -m "feat(release): localized strings + audit labels for staging releases + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 8: Full verification, version bump, release + +**Files:** +- Modify: `config/clusev.php` (version), `CHANGELOG.md` + +- [ ] **Step 1: Full suite + shell tests + shellcheck** + +Run: `docker compose --project-directory /home/nexxo/clusev run --rm --no-deps -u "1002:1002" app php artisan test` +Expected: all pass. +Run: `bash docker/release/serve-request.test.sh` +Expected: `PASS` +Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable docker/release/*.sh install.sh` +Expected: clean. + +- [ ] **Step 2: Browser verify (R12)** — enable the flag in the dev `.env`, rebuild config, and load `/release` logged in (use the gkonrad temp-password trick from prior sessions), checking HTTP 200 + zero console errors. Then restore the password and disable the flag if it should not stay on. + +Run: `grep -q '^CLUSEV_RELEASE_CONTROLS=' /home/nexxo/clusev/.env && sed -i 's/^CLUSEV_RELEASE_CONTROLS=.*/CLUSEV_RELEASE_CONTROLS=true/' /home/nexxo/clusev/.env || printf 'CLUSEV_RELEASE_CONTROLS=true\n' >> /home/nexxo/clusev/.env` +Then: `docker compose --project-directory /home/nexxo/clusev exec -u "1002:1002" -T app php artisan config:clear` +Then load `http://localhost/release` headless and assert 200 + no console errors. +Expected: `/release` renders the current version + the bump buttons; no console errors. + +- [ ] **Step 3: Bump version** — in `config/clusev.php`, set `'version'` to `0.9.59`. + +- [ ] **Step 4: Changelog** — add under `## [Unreleased]` a `## [0.9.59] - ` entry: "Phase B1 — dev-only Release page with a Deploy-to-Staging button that cuts a beta via a host git bridge (gated behind CLUSEV_RELEASE_CONTROLS)." + +- [ ] **Step 5: Commit + tag + push (sanitized)** + +```bash +git add config/clusev.php CHANGELOG.md +git commit -m "chore: release 0.9.59 — Phase B1 deploy-to-staging control + +Co-Authored-By: Claude Opus 4.8 " +git tag -a v0.9.59 -m "v0.9.59 — Phase B1 deploy-to-staging" +set -a; . /home/nexxo/.env.gitea; set +a +TOKEN="${GIT_ACCESS_TOKEN:-${GITEA_TOKEN:-}}" +URL="https://oauth2:${TOKEN}@git.bave.dev/boban/clusev.git" +{ git push "$URL" feat/v1-foundation 2>&1; git push "$URL" v0.9.59 2>&1; } | sed -E "s#${TOKEN}#***#g; s#//[^@]*@#//***@#g" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Flag gating (config + route + sidebar + component mount + install.sh) → Tasks 4, 5, 6. ✓ +- Versioning (dashboard computes target, host computes betaN; downgrade guard) → Tasks 1, 3. ✓ +- Host bridge (validate, preconditions, bump/commit/tag/push, rollback) → Task 3. ✓ +- ReleaseBridge request/result + not-writable → Task 2. ✓ +- Release page (targets, deployStaging, target allow-list, throttle, poll, audit) → Task 6. ✓ +- systemd units + dev run-mount → Task 4. ✓ +- Audit label + ERROR_ACTIONS + lang → Task 7. ✓ +- Tests (ReleasePlanner, shell smoke, component, gating) → Tasks 1, 3, 5, 6. ✓ +- R12 + release → Task 8. ✓ + +**Placeholder scan:** the install.sh and docker-compose.yml steps reference existing structure the +engineer must match (variable names / the volumes list); both give the exact line to add and how to +locate it, not a vague "wire it up". The R5 modal is explicitly deferred with a concrete recipe and a +reviewer check, not left as "add a modal". No TBD/TODO. + +**Type/name consistency:** `ReleasePlanner::proposedTargets`/`allowedTargets`, `ReleaseBridge::requestStaging`/`result`, the signal files `release-request.json`/`release-result-{id}.json`, the actions `deploy.staging_release`/`deploy.staging_release_failed`, the flag `clusev.release_controls` / env `CLUSEV_RELEASE_CONTROLS`, and the component props `pendingId`/`pendingSince`/`pendingTarget` are used identically across tasks. ✓ + +**Decomposition note:** Task 5 and Task 6 are interdependent (the route/sidebar test needs the +component) — implement them together; both test files go green after Task 6.