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 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-23 00:16:22 +02:00
parent baecb0c6d5
commit d502756b66
7 changed files with 69 additions and 14 deletions

5
.gitignore vendored
View File

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

34
docker/release/README.md Normal file
View File

@ -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-<id>.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.

View File

@ -46,7 +46,7 @@ _precheck() {
# Cut + push a beta of <target>. 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; }

View File

@ -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"

View File

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

0
run/.gitkeep Normal file
View File

View File

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