From d502756b662d25d029929d1f9500cb6017db39b6 Mon Sep 17 00:00:00 2001 From: boban Date: Tue, 23 Jun 2026 00:16:22 +0200 Subject: [PATCH] fix(release): genuine rollback-path test + install/gitignore hardening (review) - serve-request.test.sh: the push-fail case pointed origin at a dead https remote, so _precheck's fetch failed first and the rollback path in _do_stage was never reached (assertions passed trivially). Use a fetch-OK / push-rejecting remote (pre-receive exit 1) so the commit+tag are created and _rollback genuinely runs; also assert the tree is restored clean. - clusev-release.sh: |\| true on the version extraction (defensive under pipefail). - install.sh: make the release-unit install best-effort (if/then/warn) like the WG block so a systemctl failure cannot abort the installer. - .gitignore: ignore runtime run/ contents, keep the dir via run/.gitkeep. - docker/release/README.md: dev-watcher runbook (the manual end-to-end gate). - ReleaseBridgeTest: assert a rejected target writes no request file. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 5 ++++ docker/release/README.md | 34 ++++++++++++++++++++++++++++ docker/release/clusev-release.sh | 2 +- docker/release/serve-request.test.sh | 17 +++++++++----- install.sh | 13 +++++++---- run/.gitkeep | 0 tests/Feature/ReleaseBridgeTest.php | 12 +++++++--- 7 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 docker/release/README.md create mode 100644 run/.gitkeep diff --git a/.gitignore b/.gitignore index 70ad099..1edfee4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,8 @@ yarn-error.log # local sqlite (we use MariaDB) /database/*.sqlite* + +# runtime host-bridge signal dir (request/result files written by the dashboard + host watchers); +# the dir itself is kept (bind-mount target) but its runtime contents are never committed +/run/* +!/run/.gitkeep diff --git a/docker/release/README.md b/docker/release/README.md new file mode 100644 index 0000000..e09843b --- /dev/null +++ b/docker/release/README.md @@ -0,0 +1,34 @@ +# Release bridge (host watcher) — DEV ONLY + +The dashboard "Release" page (Deploy to Staging) cuts a beta by asking the **host** to do the git +work, so the container never holds git credentials — the same isolation as the WireGuard bridge. + +- `clusev-release.sh serve-request` — reads `run/release-request.json` (the dashboard wrote it), + re-validates, bumps `config/clusev.php`, commits, tags `vX.Y.Z-betaN`, pushes to Gitea (token read + host-side from `/home/nexxo/.env.gitea`), and writes `run/release-result-.json` for the + dashboard to poll. Roll back leaves no half-release if the push fails. +- `clusev-release-request.path` / `.service` — a systemd `.path` watcher that runs the handler when a + request file appears. + +## Enable on the dev box + +These units are installed **only** when `CLUSEV_RELEASE_CONTROLS=true` is in the project `.env` +(`install.sh` gates them). To enable manually: + +```bash +cd /home/nexxo/clusev +sudo install -m 0644 docker/release/clusev-release-request.service /etc/systemd/system/ +sudo install -m 0644 docker/release/clusev-release-request.path /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now clusev-release-request.path +``` + +The dev `docker-compose.yml` must bind-mount `./run` to the container's +`storage/app/restart-signal` (shared with the WireGuard/restart bridges) so the dashboard's request +lands where the watcher looks. + +## Verify (manual gate) + +This is the end-to-end gate (like the WireGuard runbook): with the watcher enabled and the flag on, +open `/release`, click a bump button, confirm, and check that `vX.Y.Z-betaN` lands on Gitea (and, via +the push-mirror, on GitHub-private). The handler never logs the Gitea token. diff --git a/docker/release/clusev-release.sh b/docker/release/clusev-release.sh index 1f42035..739f7b8 100755 --- a/docker/release/clusev-release.sh +++ b/docker/release/clusev-release.sh @@ -46,7 +46,7 @@ _precheck() { # 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/'$//")" + cur="$(grep -oE "'version' => '[^']*'" "${PROJ}/config/clusev.php" | head -n1 | sed -E "s/.*=> '//; s/'$//" || true)" base="${cur%%-*}" _ver_ge "$target" "$base" || { echo "downgrade-rejected" >&2; return 1; } diff --git a/docker/release/serve-request.test.sh b/docker/release/serve-request.test.sh index 2cace9f..2cc9e12 100644 --- a/docker/release/serve-request.test.sh +++ b/docker/release/serve-request.test.sh @@ -40,12 +40,17 @@ 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 +# push-fail rollback: a remote that ACCEPTS fetch but REJECTS push (pre-receive exit 1). _precheck +# passes (fetch ok, HEAD == origin), _do_stage creates the commit+tag, the push is rejected, and +# _rollback must delete the tag + reset HEAD so no half-release lingers. (A dead remote would fail at +# _precheck's fetch and never reach _do_stage, leaving the rollback path untested.) +printf '#!/bin/sh\nexit 1\n' > "$work/remote.git/hooks/pre-receive" +chmod +x "$work/remote.git/hooks/pre-receive" 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; } +if run stage 0.11.0 2>/dev/null; then echo "FAIL: push to a rejecting remote should fail"; exit 1; fi +rm -f "$work/remote.git/hooks/pre-receive" +git -C "$work/proj" tag | grep -qx 'v0.11.0-beta1' && { echo "FAIL: tag not rolled back after push reject"; exit 1; } +test "$(git -C "$work/proj" rev-parse HEAD)" = "$before" || { echo "FAIL: HEAD not rolled back after push reject"; exit 1; } +test -z "$(git -C "$work/proj" status --porcelain)" || { echo "FAIL: config not restored (dirty) after rollback"; exit 1; } echo "PASS" diff --git a/install.sh b/install.sh index 720495c..a046a59 100755 --- a/install.sh +++ b/install.sh @@ -332,11 +332,16 @@ install_host_watchers() { local tmp_relsvc tmp_relpath; 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" + # Best-effort like the WG/restart block above — a systemctl failure must not abort the installer. + if install -m 0644 "$tmp_relsvc" "${dst}/clusev-release-request.service" \ + && install -m 0644 "$tmp_relpath" "${dst}/clusev-release-request.path" \ + && systemctl daemon-reload \ + && systemctl enable --now clusev-release-request.path; then + info "Release-Watcher aktiv (clusev-release-request.path, DEV)" + else + warn "Release-Watcher konnte nicht installiert werden — siehe docker/release/README.md." + fi rm -f "$tmp_relsvc" "$tmp_relpath" - systemctl daemon-reload - systemctl enable --now clusev-release-request.path fi } install_host_watchers diff --git a/run/.gitkeep b/run/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Feature/ReleaseBridgeTest.php b/tests/Feature/ReleaseBridgeTest.php index c74a406..3dcc24b 100644 --- a/tests/Feature/ReleaseBridgeTest.php +++ b/tests/Feature/ReleaseBridgeTest.php @@ -38,10 +38,16 @@ class ReleaseBridgeTest extends TestCase $this->assertSame($id, $payload['id']); } - public function test_request_rejects_a_non_semver_target(): void + public function test_request_rejects_a_non_semver_target_without_writing_a_request(): void { - $this->expectException(\InvalidArgumentException::class); - app(ReleaseBridge::class)->requestStaging('garbage'); + try { + app(ReleaseBridge::class)->requestStaging('garbage'); + $this->fail('expected InvalidArgumentException for a non-semver target'); + } catch (\InvalidArgumentException) { + // expected — validation throws before any write + } + + $this->assertFileDoesNotExist($this->dir.'/release-request.json'); } public function test_result_reads_the_host_result_for_the_issued_id(): void