openvibers/openvibe-git Commits

Forge runner: make the five real repositories pass (#34)

OP OpenVibers committed 2 days ago · 0dba274990 · 5 files, +86 −26

* Forge runner: log like the service, and use the container's paths

createRunner received console (an object) as log and called it as a function, so the installed service exited before registering. Normalize a logger object the way every other service module does.

The sandbox environment named HOME, npm's cache and PNPM_HOME with the host work path; inside the container they are /workspace/..., so npm tried to create /home/workstation. Use the container path (the host path is only the bind source).

* Forge runner: the relay is CommonJS, and pnpm is in the image

A repository with "type": "module" made node treat .ci/relay.js as ESM; it is now relay.cjs.

Corepack downloads pnpm with Node's fetch, which does not honour http_proxy, so Games could not install. The install script builds localhost/openvibe-ci-node22:1 (node 22 + pnpm 10.6.5) and Games uses it; no run-time download.

* Forge runner: the hooks that depend on real repositories

The relay and proxy socket move to the container's /run tmpfs, so a repository's linter never sees the runner's files (Games' eslint failed on .ci/relay.cjs). /run is mode 1777 so the unprivileged user can write it.

Games installs with a pnpm store under node_modules; Chat uses a local image with ffmpeg; Live uses a local image with the mediasoup 3.25.0 kernel6 worker and skips the CUDA NuGet download. The install script builds all three images.

docs/ci.md records the five measured runs and why Chat is the one slower one.

* Merge origin/main into forge-ci-log-fix

Changes

ddiff --git a/deploy/ci/git-ci.example.json b/deploy/ci/git-ci.example.json
iindex feba4cf..25dd180 100644
---- a/deploy/ci/git-ci.example.json
++++ b/deploy/ci/git-ci.example.json
@@@ -16,7 +16,7 @@
},
"openvibers/openvibe.games": {
"image": "localhost/openvibe-ci-node22:1",
-- "install_command": "pnpm install --frozen-lockfile",
++ "install_command": "pnpm install --frozen-lockfile --store-dir /workspace/node_modules/.pnpm-store",
"test_command": "pnpm typecheck && pnpm lint && pnpm test",
"timeout_seconds": 1800
},
@@@ -26,12 +26,14 @@
"timeout_seconds": 1800
},
"openvibers/openvibe.chat": {
++ "image": "localhost/openvibe-ci-node22-ffmpeg:1",
"install_command": "npm ci --no-audit --no-fund",
"test_command": "npm test",
"timeout_seconds": 1800
},
"openvibers/openvibe.live": {
-- "install_command": "npm ci --no-audit --no-fund",
++ "image": "localhost/openvibe-ci-node22-mediasoup:1",
++ "install_command": "ONNXRUNTIME_NODE_INSTALL=skip npm ci --no-audit --no-fund",
"test_command": "npm test",
"timeout_seconds": 1800
}
ddiff --git a/deploy/ci/install.sh b/deploy/ci/install.sh
iindex 30130c3..13569d4 100755
---- a/deploy/ci/install.sh
++++ b/deploy/ci/install.sh
@@@ -64,6 +64,27 @@ EOF
printf 'FROM docker.io/library/node:22-bookworm\nRUN npm install -g [email protected]\n' \
| $PODMAN build --pull=never -t localhost/openvibe-ci-node22:1 -
fi
++ # Chat's sound tests convert audio, as GitHub's ffmpeg apt step does.
++ if ! $PODMAN image exists localhost/openvibe-ci-node22-ffmpeg:1; then
++ say "building localhost/openvibe-ci-node22-ffmpeg:1 (the pnpm image + ffmpeg)"
++ printf 'FROM localhost/openvibe-ci-node22:1\nRUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*\n' \
++ | $PODMAN build --pull=never -t localhost/openvibe-ci-node22-ffmpeg:1 -
++ fi
++ # Live's mediasoup postinstall downloads a prebuilt worker from GitHub; Node's fetch has no proxy, so the
++ # worker is baked into the image instead. kernel6 is the asset whose libstdc++ matches bookworm.
++ if ! $PODMAN image exists localhost/openvibe-ci-node22-mediasoup:1; then
++ say "building localhost/openvibe-ci-node22-mediasoup:1 (node 22 + the mediasoup worker)"
++ cat <<'DOCKER' | $PODMAN build --pull=never -t localhost/openvibe-ci-node22-mediasoup:1 -
++FROM docker.io/library/node:22-bookworm
++RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
++ && mkdir -p /opt/mediasoup \
++ && curl -fsSL https://github.com/versatica/mediasoup/releases/download/3.25.0/mediasoup-worker-3.25.0-linux-x64-kernel6.tgz -o /tmp/w.tgz \
++ && tar -xzf /tmp/w.tgz -C /opt/mediasoup \
++ && chmod 0755 /opt/mediasoup/mediasoup-worker \
++ && rm -f /tmp/w.tgz && rm -rf /var/lib/apt/lists/*
++ENV MEDIASOUP_WORKER_BIN=/opt/mediasoup/mediasoup-worker
++DOCKER
++ fi
elif command -v bwrap >/dev/null 2>&1; then
say "sandbox engine: bubblewrap $(bwrap --version | awk '{print $2}') (podman is not rootless here)"
else
ddiff --git a/docs/ci.md b/docs/ci.md
iindex bd953d0..4adec33 100644
---- a/docs/ci.md
++++ b/docs/ci.md
@@@ -31,7 +31,9 @@ error when the runner reports; a timed-out run is a failure.
while the mirror catches up with the webhook.
3. **Install phase** — inside the sandbox, with one writable path (`/workspace`, the per-run directory) and the
network only through the runner's proxy, which allows exactly `registry.npmjs.org` and `codeload.github.com`
-- (the pinned OpenVibe tarballs). Everything else is refused, including private and link-local addresses.
++ (the pinned OpenVibe tarballs). Everything else is refused, including private and link-local addresses. A
++ dependency whose postinstall downloads with Node's own `fetch` (mediasoup's worker, onnxruntime's CUDA package)
++ does not see that proxy, so those binaries are baked into a local image instead of fetched during the run.
4. **Test phase** — a fresh sandbox with **no network at all** (`--network=none` / an empty network namespace),
the same workspace, no proxy socket mounted.
5. The log streams to the forge while it runs; the verdict goes back with the duration and is written as the
@@@ -40,8 +42,20 @@ error when the runner reports; a timed-out run is a failure.
The commands, image and limit are per repository, registered by the runner from `~/.config/openvibe/git-ci.json`.
The defaults are `npm ci --no-audit --no-fund` then `npm test`, the image `docker.io/library/node:22-bookworm`, and
--30 minutes. `OpenVibe.Games` uses pnpm (`corepack enable --install-directory /workspace/.pnpm/bin &&
--pnpm install --frozen-lockfile`, then `pnpm typecheck && pnpm lint && pnpm test`).
++30 minutes. The first five repositories use three local images on top of the node image:
++
++- `localhost/openvibe-ci-node22:1` — node 22 + pnpm 10.6.5 (`OpenVibe.Games`), because Corepack downloads pnpm with
++ Node's `fetch`, which has no proxy.
++- `localhost/openvibe-ci-node22-ffmpeg:1` — the pnpm image + ffmpeg (`OpenVibe.Chat`), matching GitHub's
++ `apt-packages: ffmpeg`.
++- `localhost/openvibe-ci-node22-mediasoup:1` — node 22 + the mediasoup 3.25.0 prebuilt worker, kernel6 asset
++ (`OpenVibe.Live`); `MEDIASOUP_WORKER_BIN` points at it, so the postinstall does not fetch from GitHub. Live's
++ install also sets `ONNXRUNTIME_NODE_INSTALL=skip`: the CUDA package is not needed on this CPU-only host and
++ would fetch from NuGet.
++
++The runner's own relay and proxy socket live in the container's `/run` tmpfs, never in the checkout: a
++repository's linter must not see them. `OpenVibe.Games`' pnpm store is `node_modules/.pnpm-store` for the same
++reason.
## How it is contained
@@@ -119,6 +133,23 @@ The service holds only the scrypt hash of the token (`deploy/ci/runner-token`, o
`/etc/openvibe/git.env`). To rotate: make a new token, put the new line in the repository, deploy, restart the
runner. The old token stops working when the new line is live.
++## Measured (2026-11-11 UTC)
++
++Same commit per row; forge time is claim → verdict, GitHub time is `run_started_at` → `updated_at`. Both include
++install and tests; GitHub's queue is not included.
++
++| repository | head | forge | GitHub |
++|---|---|---|---|
++| OpenVibe.Shared | `58934cc` | **1m 35s** success | 10m 53s success (the same head also had a 12m 59s failure) |
++| OpenVibe.Games | `4070e5f` | **3m 46s** success | 13m 56s success |
++| OpenVibe.Network | `ea86dc2` | **11m 29s** success | 22m 52s success |
++| OpenVibe.Chat | `85d60a8` | **20m 13s** success | 18m 30s success |
++| OpenVibe.Live | `9e4c000` | **7m 56s** success | 10m 59s success |
++
++Chat is the one slower forge run (20m 13s against 18m 30s): the old i7-4770K runs the 134-file suite serially, while
++GitHub's runner is a newer 4-vCPU machine, and the forge run has no warm cache. The other four are faster, mostly
++because there is no install cache on GitHub and no queue here.
++
## The service side
- `migrations/0008_ci_runs.sql` — `git_ci_repos` (enabled, commands, image, limits) and `git_ci_runs` (state, log,
ddiff --git a/runner/run.js b/runner/run.js
iindex 9fed0e3..0884944 100644
---- a/runner/run.js
++++ b/runner/run.js
@@@ -11,19 +11,9 @@ const path = require('node:path');
const { execFile, spawnSync } = require('node:child_process');
const { createProxy } = require('./proxy');
const { runProcess } = require('./process');
--const { podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, detectEngine } = require('./sandbox');
--
--const RELAY = `'use strict';
--const net = require('node:net');
--const [sock, port] = process.argv.slice(2);
--const server = net.createServer((client) => {
-- const upstream = net.connect(sock);
-- client.pipe(upstream); upstream.pipe(client);
-- client.on('error', () => upstream.destroy());
-- upstream.on('error', () => client.destroy());
--});
--server.listen(Number(port), '127.0.0.1');
--`;
++const { podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, detectEngine, RELAY } = require('./sandbox');
++
++
const git = (args, cwd, timeoutMs = 180_000) => new Promise((resolve, reject) => {
execFile('git', args, { cwd, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '' } }, (err, stdout, stderr) => {
@@@ -99,7 +89,6 @@ function createRunner({ cfg, api, log = console, clock = Date.now }) {
fs.mkdirSync(path.join(runDir, '.home'), { recursive: true });
fs.mkdirSync(path.join(runDir, '.cache', 'npm'), { recursive: true });
fs.mkdirSync(path.join(runDir, '.pnpm'), { recursive: true });
-- fs.writeFileSync(path.join(runDir, '.ci', 'relay.cjs'), RELAY, { mode: 0o600 });
fs.writeFileSync(localLog, '', { mode: 0o600 });
const limits = job.limits || { timeout_seconds: 1800, cpus: 4, memory_mb: 6144 };
ddiff --git a/runner/sandbox.js b/runner/sandbox.js
iindex 4e03a0a..df51714 100644
---- a/runner/sandbox.js
++++ b/runner/sandbox.js
@@@ -16,6 +16,20 @@ const { spawnSync } = require('node:child_process');
const UID = 1000;
const GID = 1000;
++// The loopback relay from the container's TCP proxy port to the Unix socket the runner exposes. It lives in
++// /run (a tmpfs), never in the checked-out repository: a repository's linter must not see the runner's files.
++const RELAY = `'use strict';
++const net = require('node:net');
++const [sock, port] = process.argv.slice(2);
++const server = net.createServer((client) => {
++ const upstream = net.connect(sock);
++ client.pipe(upstream); upstream.pipe(client);
++ client.on('error', () => upstream.destroy());
++ upstream.on('error', () => client.destroy());
++});
++server.listen(Number(port), '127.0.0.1');
++`;
++
function have(command) {
const out = spawnSync(command, ['--version'], { encoding: 'utf8', timeout: 10_000 });
return !out.error && out.status === 0;
@@@ -52,7 +66,8 @@ const envList = (extra = {}) => ({
npm_config_fund: 'false',
npm_config_audit: 'false',
npm_config_update_notifier: 'false',
-- PNPM_HOME: '/workspace/.pnpm',
++ PNPM_HOME: '/workspace/.home/.pnpm',
++ npm_config_store_dir: '/workspace/node_modules/.pnpm-store',
...extra,
});
@@@ -60,7 +75,9 @@ const envList = (extra = {}) => ({
function installScript(command, { proxy = true } = {}) {
return `#!/bin/sh
set -eu
--${proxy ? `node /workspace/.ci/relay.cjs /workspace/.ci/proxy.sock 3128 &
++${proxy ? `cat > /run/relay.cjs <<'OV_RELAY'
++${RELAY}OV_RELAY
++node /run/relay.cjs /run/ci-proxy.sock 3128 &
relay=$!
trap 'kill "$relay" 2>/dev/null || true' EXIT
export http_proxy=http://127.0.0.1:3128 https_proxy=http://127.0.0.1:3128
@@@ -81,13 +98,13 @@ function podmanArgs({ name, workspace, image, limits, proxySock = null, script }
'run', '--rm', '--name', name, '--pull=never', '--network=none',
'--cpus', String(limits.cpus), '--memory', `${limits.memory_mb}m`, '--memory-swap', `${limits.memory_mb}m`,
'--pids-limit', '2048', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
-- '--read-only', '--tmpfs', '/tmp:rw,exec,size=2g', '--shm-size', '512m',
++ '--read-only', '--tmpfs', '/tmp:rw,exec,size=2g', '--tmpfs', '/run:rw,exec,size=64m,mode=1777', '--shm-size', '512m',
'--userns=keep-id', '--workdir', '/workspace',
'-v', `${workspace}:/workspace:rw`,
'--stop-timeout', '5',
];
for (const [key, value] of Object.entries(envList())) args.push('-e', `${key}=${value}`);
-- if (proxySock) args.push('-v', `${proxySock}:/workspace/.ci/proxy.sock:rw`);
++ if (proxySock) args.push('-v', `${proxySock}:/run/ci-proxy.sock:rw`);
args.push(image, '/bin/sh', '-c', script);
return args;
}
@@@ -149,9 +166,9 @@ function bwrapArgs({ workspace, limits, proxySock = null, script }) {
for (const name of ['passwd', 'group', 'hosts', 'hostname', 'resolv.conf', 'nsswitch.conf']) {
args.push('--ro-bind', path.join(etc, name), `/etc/${name}`);
}
-- args.push('--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp');
++ args.push('--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--perms', '1777', '--tmpfs', '/run');
args.push('--bind', workspace, '/workspace');
-- if (proxySock) args.push('--bind', proxySock, '/workspace/.ci/proxy.sock');
++ if (proxySock) args.push('--bind', proxySock, '/run/ci-proxy.sock');
args.push('--remount-ro', '/', '--chdir', '/workspace', '--', '/bin/sh', '-c', script);
return { args, nodeInside };
}
@@@ -181,5 +198,5 @@ function scopeCleanup(unit) {
}
module.exports = {
-- detectEngine, podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, envList, UID, GID,
++ detectEngine, podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, envList, UID, GID, RELAY,
};