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
parent
baecb0c6d5
commit
d502756b66
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
13
install.sh
13
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue