openvibers/openvibe.live Commits

Release notification after a deploy (roadmap WS-P task 9): deploy.sh runs `ovhost announce live` once the release went live

OP OpenVibers committed 26 Sept 2026 · 5162b094d2 · 3 files, +70 −1

After a release-layout deploy, a --rollback, or a legacy-layout deploy that went live (with or without a restart), the script runs `ovhost announce live` (OpenVibe.Host). ovhost publishes host.deploy.activated for the release Live's own /release.json reports now, once per release, to OpenVibe.Events, and open tabs (openvibe-shared 1.17.0 release-watch, on the Events realtime stream) check /release.json within about 20 s instead of at their next poll. Best effort: skipped without an ovhost on PATH (OVHOST sets another path) or with one whose --help has no `announce <service>` (that is said once); `timeout 20`; the lock fd is closed for it; its output is indented into the deploy log and a failure only adds "release notification not sent (the deploy stands)": the exit code never changes. A deploy that did not go live (exit 3), a dry run, or one with nothing new announces nothing. A static-only switch keeps the running release (the manifest is read at boot), so ovhost finds nothing new there.

test/deploy-sim.test.js gets a fake ovhost that records its calls: every deploy and rollback that went live, in both layouts, calls `announce live` once; the not-ready deploy and the no-op deploy do not; a failing ovhost (exit 2), an ovhost without announce and no ovhost at all each leave the deploy at exit 0. docs/deploy.md and the script's header say so.

Changes

ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
iindex ae68566e..2b4cf7a6 100755
---- a/deploy/scripts/deploy.sh
++++ b/deploy/scripts/deploy.sh
@@@ -21,6 +21,9 @@
# deploy/nginx/ never auto-installed (the live config is generated elsewhere);
# the script says so
#
++# After a deploy or rollback that went live: `ovhost announce live` (release notification to open tabs;
++# best effort, skipped without an ovhost that has `announce`, never changes the exit code).
++#
# Two layouts:
# release /opt/openvibe.live/releases/<id> (code + node_modules), current -> releases/<id>,
# shared/data. A deploy builds a new release while the old one serves; switching is an
@@@ -221,6 +224,21 @@ restart_and_verify() {
$on_fail
}
++# ── Release notification (WS-P task 9) ───────────────────────────────────────
++# OpenVibe.Host publishes host.deploy.activated to OpenVibe.Events, so open tabs check /release.json now
++# instead of at their next poll. ovhost announces the release Live's own /release.json reports, once per
++# release. Best effort: skipped when ovhost is missing or has no `announce`, 20 s at most, and it never
++# fails a deploy (see OpenVibe.Host docs/release-notifications.md).
++OVHOST="${OVHOST:-ovhost}"
++announce_release() {
++ [ "$DRY_RUN" = "1" ] && return 0
++ command -v "$OVHOST" >/dev/null 2>&1 || return 0
++ local help
++ help=$("$OVHOST" --help 2>/dev/null || true)
++ case "$help" in *"announce <service>"*) ;; *) say "release notification skipped: this ovhost has no announce"; return 0 ;; esac
++ timeout 20 "$OVHOST" announce live 9>&- 2>&1 | sed 's/^/ /' || say "release notification not sent (the deploy stands)"
++}
++
# ═══════════════════════════════════════════════════════════════
# Release layout
# ═══════════════════════════════════════════════════════════════
@@@ -256,6 +274,7 @@ if [ "$LAYOUT" = release ]; then
# Keep the release we left so it can be re-selected; just move it out of "newest".
touch -h "$PREV"
say "rolled back to $(basename "$PREV") ✅"
++ announce_release
exit 0
fi
@@@ -338,6 +357,7 @@ if [ "$LAYOUT" = release ]; then
done
git -C "$REPO" worktree prune 2>/dev/null || true
say "deployed $CUR_ID → $NEW_ID ✅"
++ announce_release
exit 0
fi
@@@ -401,6 +421,7 @@ if [ "$DRY_RUN" = "1" ]; then say "dry run complete — nothing was changed. res
if [ "$NEEDS_RESTART" != true ]; then
say "change classes:${CATS} — no restart needed; the server re-reads public/ and docs/ from disk."
say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} without interrupting anyone ✅"
++ announce_release
exit 0
fi
@@@ -421,3 +442,4 @@ curl -sf --max-time 5 "${API_URL}/api/health" >/dev/null || die "health endpoint
say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} (${COMMIT_COUNT} commit(s))"
say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."
say "done ✅"
++announce_release
ddiff --git a/docs/deploy.md b/docs/deploy.md
iindex eab48827..342f7e8d 100644
---- a/docs/deploy.md
++++ b/docs/deploy.md
@@@ -65,6 +65,13 @@ but could not restore previous `node_modules`.
- The last 5 releases are kept.
- Content-hashed assets from the previous release are still served under their old hashes, so a page
rendered before the switch never loads JavaScript from after it.
++- **Release notification.** After a deploy or `--rollback` that went live, the script runs
++ `ovhost announce live` (OpenVibe.Host, WS-P task 9). ovhost publishes `host.deploy.activated` for the
++ release `/release.json` now reports, once per release, to OpenVibe.Events. Open tabs (openvibe-shared
++ 1.17.0 release-watch) then check `/release.json` within about 20 s instead of at their next poll. It is
++ best effort: skipped when `ovhost` is missing or has no `announce` (set `OVHOST` for another path), 20 s at
++ most, and it never changes the exit code. A static-only switch keeps the running release, so there is
++ nothing new to announce. The credentials and the setup are in OpenVibe.Host `docs/release-notifications.md`.
`test/deploy-sim.test.js` runs the script against a simulated host (real git, fake systemctl) and
checks each of these behaviours.
ddiff --git a/test/deploy-sim.test.js b/test/deploy-sim.test.js
iindex 4d87eb32..e0c67f2c 100644
---- a/test/deploy-sim.test.js
++++ b/test/deploy-sim.test.js
@@@ -9,7 +9,9 @@
* - a release that never becomes ready is rolled back automatically (exit 3), still serving;
* - --rollback returns to the previous release, restarting only if server code differs;
* - a lockfile change installs into the NEW release, so rolling back gets the OLD node_modules;
-- * - the legacy in-place layout also skips the restart for public/-only changes.
++ * - the legacy in-place layout also skips the restart for public/-only changes;
++ * - every deploy or rollback that went live runs `ovhost announce live` (a fake ovhost records it),
++ * one that did not go live or changed nothing does not, and a failing or older ovhost never fails a deploy.
*
* node test/deploy-sim.test.js
*/
@@@ -89,6 +91,15 @@ esac
`, { mode: 0o755 });
fs.writeFileSync(path.join(bin, 'npm'), `#!/usr/bin/env bash\nmkdir -p node_modules && echo "lock-$(node -e 'console.log(require(\"./package-lock.json\").v)')" > node_modules/marker\n`, { mode: 0o755 });
fs.writeFileSync(path.join(bin, 'journalctl'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
++ // Fake ovhost: records each call. OVHOST_FAKE_EXIT makes announce fail; OVHOST_FAKE_OLD drops announce from --help.
++ const ovhostLog = path.join(tmp, 'ovhost.log');
++ fs.writeFileSync(path.join(bin, 'ovhost'), `#!/usr/bin/env bash
++if [ "$1" = "--help" ]; then if [ -n "$OVHOST_FAKE_OLD" ]; then echo " deploy <service>"; else echo " announce <service> [--release <id>]"; fi; exit 0; fi
++echo "$*" >> "${ovhostLog}"
++echo "[ovhost] release notification sent: $2"
++exit "\${OVHOST_FAKE_EXIT:-0}"
++`, { mode: 0o755 });
++ const announced = () => { try { return fs.readFileSync(ovhostLog, 'utf8').split('\n').filter(Boolean); } catch { return []; } };
// Release layout, as migrate-to-releases.sh leaves it.
fs.mkdirSync(path.join(base, 'releases'), { recursive: true });
@@@ -113,6 +124,8 @@ esac
const r = deploy();
assert.strictEqual(r.status, 0, r.stdout + r.stderr);
assert.match(r.stdout, /without a restart/);
++ assert.deepStrictEqual(announced(), ['announce live'], 'the release notification ran once');
++ assert.match(r.stdout, /release notification sent: live/);
assert.strictEqual((await get('/static')).body, 'static-2');
assert.strictEqual(await pid(), before, 'pid changed');
});
@@@ -122,6 +135,7 @@ esac
write('server/version.txt', 'v2'); commit('server');
const r = deploy();
assert.strictEqual(r.status, 0, r.stdout + r.stderr);
++ assert.strictEqual(announced().length, 2);
await new Promise((res) => setTimeout(res, 300));
const code = (await get('/code')).body;
assert.ok(code.startsWith('v2'), code);
@@@ -138,8 +152,10 @@ esac
});
await check('--rollback returns code AND node_modules of the previous release', async () => {
++ const n = announced().length;
const r = deploy('--rollback');
assert.strictEqual(r.status, 0, r.stdout + r.stderr);
++ assert.strictEqual(announced().length, n + 1, 'a rollback is announced too');
await new Promise((res) => setTimeout(res, 500));
assert.ok((await get('/code')).body.startsWith('v2'));
assert.strictEqual((await get('/deps')).body.trim(), 'lock-1');
@@@ -150,8 +166,10 @@ esac
write('server/version.txt', 'v4'); commit('v4');
assert.strictEqual(deploy().status, 0);
write('server/BROKEN', '1'); write('server/version.txt', 'v5-broken'); commit('broken');
++ const n = announced().length;
const r = deploy();
assert.strictEqual(r.status, 3, r.stdout + r.stderr);
++ assert.strictEqual(announced().length, n, 'a release that did not go live is not announced');
await new Promise((res) => setTimeout(res, 500));
const code = (await get('/code')).body;
assert.ok(code.startsWith('v4'), code);
@@@ -162,9 +180,29 @@ esac
// origin still has the broken commit; the deploy would try again, so first fix forward.
fs.unlinkSync(path.join(work, 'server/BROKEN')); write('server/version.txt', 'v6'); commit('fix');
assert.strictEqual(deploy().status, 0);
++ const n = announced().length;
const r = deploy();
assert.strictEqual(r.status, 0);
assert.match(r.stdout, /nothing to deploy/);
++ assert.strictEqual(announced().length, n, 'nothing new, nothing announced');
++ });
++
++ await check('a failing release notification, or an ovhost without announce, never fails a deploy', async () => {
++ write('public/x.txt', 'static-3'); commit('static 3');
++ const failing = spawnSync('bash', [DEPLOY], { env: { ...env, OVHOST_FAKE_EXIT: '2' }, encoding: 'utf8' });
++ assert.strictEqual(failing.status, 0, failing.stdout + failing.stderr);
++ assert.match(failing.stdout, /release notification not sent \(the deploy stands\)/);
++ write('public/x.txt', 'static-4'); commit('static 4');
++ const n = announced().length;
++ const old = spawnSync('bash', [DEPLOY], { env: { ...env, OVHOST_FAKE_OLD: '1' }, encoding: 'utf8' });
++ assert.strictEqual(old.status, 0, old.stdout + old.stderr);
++ assert.match(old.stdout, /release notification skipped: this ovhost has no announce/);
++ assert.strictEqual(announced().length, n);
++ write('public/x.txt', 'static-5'); commit('static 5');
++ const none = spawnSync('bash', [DEPLOY], { env: { ...env, OVHOST: path.join(tmp, 'no-such-ovhost') }, encoding: 'utf8' });
++ assert.strictEqual(none.status, 0, none.stdout + none.stderr);
++ assert.strictEqual(announced().length, n, 'no ovhost: nothing to call');
++ assert.doesNotMatch(none.stdout, /release notification/);
});
await check('old releases are pruned to KEEP_RELEASES', async () => {
@@@ -184,9 +222,11 @@ esac
await check('legacy layout: public/-only change pulls without restarting', async () => {
const before = await pid();
write('public/x.txt', 'legacy-static'); commit('legacy static');
++ const n = announced().length;
const r = spawnSync('bash', [DEPLOY], { env: lenv, encoding: 'utf8' });
assert.strictEqual(r.status, 0, r.stdout + r.stderr);
assert.match(r.stdout, /no restart needed/);
++ assert.strictEqual(announced().length, n + 1, 'the legacy layout announces too');
assert.strictEqual((await get('/static')).body, 'legacy-static', r.stdout + r.stderr + ' ready=' + (await get('/api/ready')).status);
assert.strictEqual(await pid(), before);
});