openvibers/openvibe-git Commits
Forge CI: sandboxed runner for member branch pushes (#30)
Changes
@@@ -40,6 +40,18 @@ GIT_MAX_REPO_BYTES=3221225472GIT_MAX_FILE_BYTES=2097152GIT_MAX_ARCHIVE_BYTES=268435456++# Forge CI (docs/ci.md): the workstation runner's queue. A run is made only for a branch push to an official++# repository by a GitHub login that may push there (the owning account, GIT_CI_MEMBERS, or a collaborator).++# The runner token is never here: this service reads only its scrypt hash from deploy/ci/runner-token.++GIT_CI_ENABLED=true++GIT_CI_ORG=openvibers++GIT_CI_CONTEXT=forge/test++GIT_CI_MEMBERS=++GIT_CI_MEMBER_CACHE_MS=600000++GIT_CI_REAP_MS=60000++GIT_CI_RUNNER_TOKEN=++GIT_CI_RUNNER_TOKEN_FILE=++# Mirrors: the ten-minute poll and how long one fetch may take.GIT_MIRROR_POLL_MS=600000GIT_MIRROR_TIMEOUT_MS=600000@@@ -74,6 +74,11 @@ mergeable only when its required contexts are green — a per-repository list, efor the open pull-request heads and the default branch (at most every two minutes per repository, and onlywhen a head moved), so the pages show the truth until our own runners report.++**Forge CI** runs a repository's tests on the workstation, in a sandbox, for branch pushes to official repositories++by OpenVibers members only; the verdict is the `forge/test` status and the log is at `/<owner>/<name>/ci/<run>`.++Forks, pull requests and non-members keep GitHub Actions. What runs, how it is contained and how to add a repository:++[docs/ci.md](docs/ci.md).++## Layout- `server/git/` — repositories on disk: creation, refs, trees, blobs, commits, diffs, bunches, and `http-backend`.@@@ -82,6 +87,9 @@ when a head moved), so the pages show the truth until our own runners report.- `server/official.js` — the due-based seed of the official mirrors from a GitHub organisation or the contracts.- `server/contracts.js` — the repository list named by openvibe-contracts when GitHub's listing is refused.- `server/insights.js` — views, unique visitors, clones, stars and commits per week.++- `server/ci.js`, `server/ci-auth.js` — the forge CI queue, the member rule, supersede and the `forge/test` status.++- `runner/` — the workstation runner: poll, clone, sandbox phases, allowlisted install proxy, log and verdict.++- `deploy/ci/`, `deploy/systemd-user/` — the runner's install script, example config and user unit.- `contracts` — `git.repo@1`, `git.commit@1`, `git.tree@1`, `git.insights@1` (OpenVibe.Contracts).- `deploy/` — the systemd unit and the nginx vhost for `git.openvibe.codes`.nnew file mode 100644@@@ -0,0 +1,38 @@++{++ "_about": "The forge CI runner on the workstation. This file is not a secret; it names the forge, the token file, the sandbox engine and one entry per repository. deploy/ci/install.sh copies it to ~/.config/openvibe/git-ci.json when none is there.",++ "api": "https://git.openvibe.codes",++ "tokenFile": "~/.config/openvibe/git-ci.token",++ "workRoot": "~/.local/share/openvibe-git-ci/work",++ "engine": "auto",++ "concurrency": 1,++ "pollMs": 5000,++ "heartbeatMs": 15000,++ "proxyAllow": ["registry.npmjs.org", "codeload.github.com"],++ "repos": {++ "openvibers/openvibe.shared": {++ "install_command": "npm ci --no-audit --no-fund",++ "test_command": "npm test",++ "timeout_seconds": 1800++ },++ "openvibers/openvibe.games": {++ "install_command": "corepack enable --install-directory /workspace/.pnpm/bin && pnpm install --frozen-lockfile",++ "test_command": "pnpm typecheck && pnpm lint && pnpm test",++ "timeout_seconds": 1800++ },++ "openvibers/openvibe.network": {++ "install_command": "npm ci --no-audit --no-fund",++ "test_command": "npm test",++ "timeout_seconds": 1800++ },++ "openvibers/openvibe.chat": {++ "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",++ "test_command": "npm test",++ "timeout_seconds": 1800++ }++ }++}nnew file mode 100755@@@ -0,0 +1,109 @@++#!/bin/sh++# Install the OpenVibe.Git forge CI runner on the workstation as a systemd *user* unit (the portal's pattern:++# no root, the owner's user manager keeps it alive). Run from a checkout of OpenVibe.Git:++#++# deploy/ci/install.sh # install, pull the image, do not start++# deploy/ci/install.sh --start # ... and enable --now++#++# It never writes a secret into the repository: the token stays in ~/.config/openvibe/git-ci.token (0600) and the++# service holds only its scrypt hash (deploy/ci/runner-token). A new token prints the hash to put in that file.++set -eu++++HERE=$(cd "$(dirname "$0")" && pwd)++CHECKOUT=${GIT_CI_CHECKOUT:-$HOME/openvibe/git-ci}++CONFIG_DIR=$HOME/.config/openvibe++CONFIG=$CONFIG_DIR/git-ci.json++TOKEN=$CONFIG_DIR/git-ci.token++UNIT_DIR=$HOME/.config/systemd/user++STORAGE_ROOT=$HOME/.local/share/openvibe-git-ci/containers/storage++RUN_ROOT=/run/user/$(id -u)/ovci++PODMAN="podman --root $STORAGE_ROOT --runroot $RUN_ROOT"++START=0++for arg in "$@"; do++ case "$arg" in++ --start) START=1 ;;++ *) echo "usage: $0 [--start]" >&2; exit 2 ;;++ esac++done++++say() { printf '[git-ci-install] %s\n' "$*"; }++++# 1. A sandbox engine. Rootless podman is preferred; bubblewrap is the fallback (runner/sandbox.js).++if podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null | grep -q true; then++ say "sandbox engine: rootless podman $(podman --version | awk '{print $3}')"++ mkdir -p "$STORAGE_ROOT/libpod" "$STORAGE_ROOT/volumes" "$RUN_ROOT" "$CONFIG_DIR"++ # The workstation's shared podman store lives on a disk with no inodes left. The runner gets its own store++ # under ~/.local/share (the home filesystem) and its own containers.conf so libpod's database agrees.++ if [ ! -f "$CONFIG_DIR/containers.conf" ]; then++ cat > "$CONFIG_DIR/containers.conf" <<EOF++[engine]++static_dir = "$STORAGE_ROOT/libpod"++volume_path = "$STORAGE_ROOT/volumes"++EOF++ fi++ if [ ! -f "$CONFIG_DIR/containers-storage.conf" ]; then++ cat > "$CONFIG_DIR/containers-storage.conf" <<EOF++[storage]++driver = "overlay"++graphroot = "$STORAGE_ROOT"++runroot = "$RUN_ROOT"++++[storage.options]++mount_program = "/usr/bin/fuse-overlayfs"++EOF++ fi++ export CONTAINERS_CONF=$CONFIG_DIR/containers.conf++ export CONTAINERS_STORAGE_CONF=$CONFIG_DIR/containers-storage.conf++ if ! $PODMAN image exists docker.io/library/node:22-bookworm; then++ say "pulling node:22-bookworm into $STORAGE_ROOT (the run image)"++ $PODMAN pull docker.io/library/node:22-bookworm++ 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++ echo "no sandbox engine: install rootless podman or bubblewrap" >&2++ exit 1++fi++++# 2. A checkout the unit runs from. It is a clone of the forge, so an update is a fast-forward.++if [ -d "$CHECKOUT/.git" ]; then++ say "updating $CHECKOUT"++ git -C "$CHECKOUT" fetch --quiet origin main++ git -C "$CHECKOUT" merge --ff-only --quiet origin/main++else++ say "cloning https://git.openvibe.codes/openvibers/openvibe.git into $CHECKOUT"++ git clone --quiet --depth 1 https://git.openvibe.codes/openvibers/openvibe.git "$CHECKOUT"++fi++++# 3. The config file (no secret) and the token (0600, never in the repository).++mkdir -p "$CONFIG_DIR"++if [ ! -f "$CONFIG" ]; then++ install -m 0644 "$CHECKOUT/deploy/ci/git-ci.example.json" "$CONFIG"++ say "wrote $CONFIG from the example (repos and commands are edited there)"++else++ say "keeping $CONFIG"++fi++if [ ! -s "$TOKEN" ]; then++ say "making a runner token in $TOKEN"++ hash=$(/usr/bin/node "$CHECKOUT/runner/token.js" --out "$TOKEN" | tail -1)++ say "write this line to deploy/ci/runner-token, commit it, deploy, then restart:"++ printf '%s\n' "$hash"++fi++chmod 0600 "$TOKEN" 2>/dev/null || true++++# 4. The user unit. It names the default checkout; an explicit GIT_CI_CHECKOUT is written into it.++mkdir -p "$UNIT_DIR"++if [ "$CHECKOUT" != "$HOME/openvibe/git-ci" ]; then++ sed "s#%h/openvibe/git-ci#$CHECKOUT#g" "$CHECKOUT/deploy/systemd-user/openvibe-git-ci.service" > "$UNIT_DIR/openvibe-git-ci.service"++else++ install -m 0644 "$CHECKOUT/deploy/systemd-user/openvibe-git-ci.service" "$UNIT_DIR/openvibe-git-ci.service"++fi++systemctl --user daemon-reload++say "installed $UNIT_DIR/openvibe-git-ci.service"++++if [ "$START" = 1 ]; then++ systemctl --user enable --now openvibe-git-ci.service++ say "started; follow it with: journalctl --user -u openvibe-git-ci -f"++else++ say "not started (pass --start, or: systemctl --user enable --now openvibe-git-ci)"++finnew file mode 100644@@@ -0,0 +1 @@++scrypt$27f76d9288b2c473023c06ad8c7740eb$3f21ac5143cd95004fe924546b00d7bf937c58b6005b05d7846653fc57bc32ddnnew file mode 100644@@@ -0,0 +1,26 @@++[Unit]++Description=OpenVibe.Git forge CI runner (workstation; rootless podman or bubblewrap sandboxes)++Documentation=https://git.openvibe.codes/openvibers/openvibe++After=network-online.target++Wants=network-online.target++++[Service]++Type=simple++WorkingDirectory=%h/openvibe/git-ci++Environment=NODE_ENV=production++Environment=PATH=/usr/local/bin:/usr/bin:/bin++Environment=GIT_CI_CONFIG=%h/.config/openvibe/git-ci.json++# The runner has its own podman store under ~/.local/share (the shared store is on the inode-full offload disk).++Environment=CONTAINERS_CONF=%h/.config/openvibe/containers.conf++Environment=CONTAINERS_STORAGE_CONF=%h/.config/openvibe/containers-storage.conf++ExecStart=/usr/bin/node %h/openvibe/git-ci/runner/index.js++Restart=always++RestartSec=10++KillMode=control-group++KillSignal=SIGTERM++# The current run is killed on SIGTERM; a podman cleanup may take a few seconds after it.++TimeoutStopSec=120++LimitNOFILE=65536++++[Install]++WantedBy=default.targetnnew file mode 100644@@@ -0,0 +1,133 @@++# Forge CI++++`git.openvibe.codes` runs the tests for a push to an OpenVibers repository on the workstation, in a sandbox, and++reports the verdict on the commit. GitHub Actions stays exactly as it is: this is the first-party path that runs++beside it, not a replacement yet. Nothing in this document turns a GitHub workflow off.++++## What runs, and when++++A run is made only when all of these are true:++++- the push is to a **branch** (`refs/heads/…`, never a tag or a deletion);++- the repository is an **official** OpenVibers mirror on the forge (its owner is in `GIT_OFFICIAL_OWNERS`, by++ default `openvibers`, and it mirrors `OpenVibers/<name>` on GitHub);++- the **push event's pusher** is the owning GitHub account, a login in `GIT_CI_MEMBERS`, or a collaborator GitHub++ says has `admin`, `maintain` or `write` on that repository. GitHub's answer is cached for 10 minutes;++- the repository has a CI entry (the runner registers the repositories it is configured for, see below).++++It never runs for a pull request, a pull request from a fork, a push to a fork, a tag, a bot, or a non-member.++A repository with no CI entry is not tested here. The checks intake from GitHub (`server/checks-intake.js`) keeps++copying GitHub's check-runs, so a change can show both `test` (GitHub) and `forge/test` (here).++++**One run per repository is live.** A newer branch push supersedes the queued or running one: the older run becomes++`superseded`, its runner sees that on the next heartbeat (every 15 s), kills its sandbox and reports no verdict.++The newer run then starts. The repository's `forge/test` status is pending from enqueue, and success, failure or++error when the runner reports; a timed-out run is a failure.++++## What a run does++++1. The runner claims one queued run from `POST /api/v1/ci/runs/claim` (its own token).++2. It clones **the pushed commit** from the forge (`https://git.openvibe.codes/<owner>/<name>.git`), retrying++ 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.++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++ `forge/test` commit status. The log is on `/<owner>/<name>/ci/<run>` and its JSON is++ `GET /api/v1/repos/<owner>/<name>/ci/runs/<id>`.++++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`).++++## How it is contained++++| | |++|---|---|++| engine | rootless **podman** if `podman info` says rootless, else **bubblewrap** (the workstation has both; podman is used) |++| identity | a new user namespace; container root is not host root. The podman user namespace keeps the host uid (`--userns=keep-id`); bubblewrap uses uid/gid 1000 inside its own namespace |++| filesystem | read-only root (`--read-only`), a tmpfs `/tmp`, and **one** host path: the per-run work directory bind-mounted at `/workspace`. No home, no `/etc/openvibe`, no git checkout of the runner, no Docker socket, no SSH agent |++| secrets | none. The runner's own token is not mounted; no repository secret is passed; the container environment is an allowlist (HOME, PATH, locale, npm cache inside the workspace) |++| network | install: loopback only, with the proxy on a Unix socket (the sandbox has no route anywhere else). test: no network namespace route at all |++| limits | `--cpus 4`, `--memory 6144m` (swap the same), `--pids-limit 2048`, 30 minutes wall clock (configurable 30 s–1 h). A run past it is killed and reported `timed_out`; a runner that disappears is reaped by the service a minute after its limit |++| capabilities | all dropped, `no-new-privileges`, a new session; bubblewrap also disables nested user namespaces |++| concurrency | one run per repository (the forge's rule), and one at a time on this runner by default (`concurrency`) |++++**What is not contained, and why.** A container is not a virtual machine: a kernel or runtime escape is still a++host compromise, and the workstation is the busiest machine in the estate (it also runs the portal and agent jobs).++The design reduces what an escape reaches (no secrets, no home, no socket, no host network in the test phase), but++it does not make the host disposable. The repositories are public and accept forks, which is why forks and pull++requests never reach this runner; the trigger is a same-repository branch push by a person GitHub says may push.++The full `test:pg` path (PostgreSQL + PgBouncer + Valkey containers) is not run here — there is no Docker socket++inside the sandbox, so the runner runs the repository's `npm test` (PGlite). For those suites GitHub Actions remains++the gate. If the machine is ever dedicated to CI, a disposable VM per run is the next step (see++`research-2026-11-10/forge-first.md` §4).++++## Adding a repository++++1. It must already be an official mirror on the forge (the official mirrors seed creates one for every public++ `OpenVibers` repository). Check: `GET https://git.openvibe.codes/api/v1/repos/openvibers/<name>`.++2. Add an entry to the runner's `repos` in `~/.config/openvibe/git-ci.json`:++++ ```json++ "openvibers/openvibe.example": {++ "install_command": "npm ci --no-audit --no-fund",++ "test_command": "npm test",++ "timeout_seconds": 1800++ }++ ```++++ Optional: `image`, `cpus` (1–8), `memory_mb` (512–12288). Only official repositories are accepted.++3. Restart the runner: `systemctl --user restart openvibe-git-ci`. On start it registers the entry through++ `POST /api/v1/ci/repos`; the forge now queues its member pushes.++4. Push to a branch, or start one by hand as the owner:++ `POST /api/v1/repos/openvibers/<name>/ci/runs` with `{"branch":"main"}` (a signed-in owner, or the runner token).++ The run appears at `https://git.openvibe.codes/openvibers/<name>/ci`.++++Removing an entry stops new runs; it does not delete past runs or their statuses.++++## Running it on the workstation++++```sh++# from a checkout of OpenVibe.Git, as the workstation user (no sudo)++deploy/ci/install.sh --start++systemctl --user status openvibe-git-ci++journalctl --user -u openvibe-git-ci -f++```++++`install.sh` checks the sandbox engine, pulls `node:22-bookworm`, keeps a checkout at `~/openvibe/git-ci`,++copies `deploy/ci/git-ci.example.json` to `~/.config/openvibe/git-ci.json` if there is none, installs++`deploy/systemd-user/openvibe-git-ci.service` into the user manager and (with `--start`) enables it.++++The workstation's shared podman store is on the offload disk (`/mnt/120gb`), which ran out of **inodes**, not bytes.++The runner therefore gets its own store under `~/.local/share/openvibe-git-ci/containers/storage` on the home++filesystem, passed to every podman call with `--root`/`--runroot` and with its own `containers.conf` and++`containers-storage.conf` in `~/.config/openvibe` (libpod's database names the same graph root). `install.sh` writes++both files when they are absent and pulls the image into that store. Nothing global is changed.++++The token is made once, on the workstation:++++```sh++node runner/token.js --out ~/.config/openvibe/git-ci.token # prints the scrypt line, not the token++# put that line in deploy/ci/runner-token, commit, deploy, restart the runner++```++++The service holds only the scrypt hash of the token (`deploy/ci/runner-token`, or `GIT_CI_RUNNER_TOKEN` in++`/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.++++## The service side++++- `migrations/0008_ci_runs.sql` — `git_ci_repos` (enabled, commands, image, limits) and `git_ci_runs` (state, log,++ timings), with a partial unique index that keeps one live run per repository.++- `server/ci.js` — the member rule, enqueue and supersede, claim/heartbeat/log/finish, the reaper and the++ `forge/test` status.++- `server/ci-auth.js` — the runner token's scrypt hash and the constant-time check.++- `server/http/api.js` — `/api/v1/ci/*` (runner token) and `/api/v1/repos/:owner/:name/ci*` (public reads, owner++ manual runs).++- `server/views/ci.js`, `server/http/pages.js` — the CI tab, the run list and the log page.++- `runner/` — `index.js` (poll), `run.js` (clone, phases, verdict), `sandbox.js` (podman/bubblewrap),++ `proxy.js` (the install allowlist), `process.js` (the wall clock), `config.js`, `api.js`, `token.js`.nnew file mode 100644@@@ -0,0 +1,55 @@++-- phase: expand++-- Forge CI: which repositories the workstation runner tests, and one row per run it did.++--++-- A repository is enabled with a command pair (install, with the network proxy; test, with no network at all),++-- an image and a time limit. The runner registers them over its own token. One run per repository is live at a++-- time: a newer push supersedes the queued or running one (the runner sees it on its heartbeat and stops).++-- The log is bounded; the run's verdict is copied into git_commit_statuses as "forge/test".++++CREATE TABLE git_ci_repos (++ repo_id text PRIMARY KEY REFERENCES git_repos (id) ON DELETE CASCADE,++ enabled boolean NOT NULL DEFAULT true,++ install_command text NOT NULL DEFAULT 'npm ci --no-audit --no-fund',++ test_command text NOT NULL DEFAULT 'npm test',++ image text NOT NULL DEFAULT 'docker.io/library/node:22-bookworm',++ timeout_seconds integer NOT NULL DEFAULT 1800,++ cpus integer NOT NULL DEFAULT 4,++ memory_mb integer NOT NULL DEFAULT 6144,++ registered_by text,++ updated_at timestamptz NOT NULL DEFAULT now(),++ CHECK (timeout_seconds BETWEEN 30 AND 3600),++ CHECK (cpus BETWEEN 1 AND 8),++ CHECK (memory_mb BETWEEN 512 AND 12288),++ CHECK (char_length(install_command) BETWEEN 1 AND 2000),++ CHECK (char_length(test_command) BETWEEN 1 AND 2000),++ CHECK (char_length(image) BETWEEN 1 AND 200)++);++++CREATE TABLE git_ci_runs (++ id text PRIMARY KEY, -- cir_<ulid>++ repo_id text NOT NULL REFERENCES git_repos (id) ON DELETE CASCADE,++ sha text NOT NULL,++ branch text NOT NULL,++ pusher text, -- the push event's pusher, or "runner" for a manual run++ source text NOT NULL DEFAULT 'github', -- github | forge | manual++ state text NOT NULL CHECK (state IN ('queued', 'running', 'success', 'failure', 'error', 'timed_out', 'superseded')),++ description text NOT NULL DEFAULT '',++ exit_code integer,++ log text NOT NULL DEFAULT '',++ log_bytes integer NOT NULL DEFAULT 0,++ runner text,++ started_at timestamptz,++ finished_at timestamptz,++ duration_ms integer,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CHECK (sha ~ '^[0-9a-f]{40}([0-9a-f]{24})?$'),++ CHECK (char_length(branch) BETWEEN 1 AND 255),++ CHECK (char_length(description) <= 1024),++ CHECK (char_length(log) <= 4194304)++);++++CREATE INDEX git_ci_runs_repo_created ON git_ci_runs (repo_id, created_at DESC);++CREATE INDEX git_ci_runs_state_created ON git_ci_runs (state, created_at);++-- At most one queued or running run per repository; a new push supersedes the old one before this is inserted.++CREATE UNIQUE INDEX git_ci_runs_one_live ON git_ci_runs (repo_id) WHERE state IN ('queued', 'running');@@@ -519,3 +519,17 @@.git-review .git-diff-line { grid-template-columns: 2.6em 2.6em minmax(0, 1fr); }.git-inline { margin-left: 12px; }}++++/* Forge CI: one row per run, and the run's log. Plain by design; the kit carries the rest. */++.git-ci-list { list-style: none; margin: 0 0 18px; padding: 0; }++.git-ci-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; align-items: baseline; padding: 12px 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card); margin-top: 8px; }++.git-ci-row .git-check { grid-row: span 2; align-self: center; }++.git-ci-main { display: inline-flex; gap: 8px; align-items: baseline; min-width: 0; overflow-wrap: anywhere; }++.git-ci-main code { color: var(--text-muted); font-size: .82rem; }++.git-ci-facts { list-style: none; margin: 0 0 14px; padding: 0; display: grid; gap: 6px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }++.git-ci-facts li { display: grid; gap: 2px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-card); min-width: 0; overflow-wrap: anywhere; }++.git-ci-facts span { color: var(--text-muted); font-size: .78rem; }++.git-ci-log { max-height: 70vh; overflow: auto; }++.git-check--success { color: var(--git-add, #3a9); }++.git-check--failure, .git-check--error, .git-check--timed_out { color: var(--git-del, #c55); }++.git-check--running, .git-check--queued { color: var(--text-muted); }nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++++/**++ * The runner's HTTP client. One bearer token in the Authorization header, never in a URL, a log or an error.++ * A claim answered 204 is "nothing queued"; a verdict answered 409 is "that run was superseded", not a failure.++ */++const os = require('node:os');++++function createApi({ baseUrl, token, log = console }) {++ async function request(pathname, { method = 'GET', body = null, timeoutMs = 20_000 } = {}) {++ const res = await fetch(`${baseUrl}${pathname}`, {++ method,++ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'user-agent': `openvibe-git-ci/${os.hostname()}` },++ body: body == null ? undefined : JSON.stringify(body),++ signal: AbortSignal.timeout(timeoutMs),++ });++ const text = await res.text();++ if (!res.ok) {++ const err = new Error(`the forge answered ${res.status} for ${pathname}`);++ err.status = res.status;++ err.body = text.slice(0, 500);++ throw err;++ }++ return text ? JSON.parse(text) : null;++ }++++ return {++ request,++ register: (repos) => request('/api/v1/ci/repos', { method: 'POST', body: { repos } }),++ claim: () => request('/api/v1/ci/runs/claim', { method: 'POST', body: { runner: os.hostname() || 'forge-runner' } }),++ heartbeat: (id) => request(`/api/v1/ci/runs/${encodeURIComponent(id)}/heartbeat`),++ log: (id, chunk) => request(`/api/v1/ci/runs/${encodeURIComponent(id)}/log`, { method: 'POST', body: { chunk }, timeoutMs: 30_000 }),++ finish: async (id, verdict) => {++ try {++ return await request(`/api/v1/ci/runs/${encodeURIComponent(id)}/status`, { method: 'POST', body: verdict, timeoutMs: 30_000 });++ } catch (err) {++ if (err.status === 409) return null;++ throw err;++ }++ },++ };++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,95 @@++'use strict';++++/**++ * The forge CI runner's configuration. It lives on the workstation (default ~/.config/openvibe/git-ci.json),++ * never in the repository: it names the API, the token file, the engine, and one entry per repository.++ *++ * {++ * "api": "https://git.openvibe.codes",++ * "tokenFile": "~/.config/openvibe/git-ci.token",++ * "workRoot": "~/.cache/openvibe-git-ci",++ * "engine": "auto", // auto | podman | bwrap++ * "concurrency": 1,++ * "pollMs": 5000,++ * "proxyAllow": ["registry.npmjs.org", "codeload.github.com"],++ * "repos": {++ * "openvibers/openvibe.shared": {++ * "install_command": "npm ci --no-audit --no-fund",++ * "test_command": "npm test",++ * "timeout_seconds": 1800++ * }++ * }++ * }++ *++ * The token file holds the raw runner token (0600); the service holds only its scrypt hash in++ * deploy/ci/runner-token. The config holds no secret.++ */++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++++const HOME = os.homedir();++const expand = (value) => String(value || '').replace(/^~(?=$|\/)/, HOME);++const int = (value, fallback, min, max) => {++ const n = parseInt(value, 10);++ return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : fallback;++};++++function loadConfig(env = process.env) {++ const file = expand(env.GIT_CI_CONFIG || path.join(HOME, '.config', 'openvibe', 'git-ci.json'));++ let raw = {};++ try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) {++ if (env.GIT_CI_CONFIG) throw new Error(`the runner configuration ${file} cannot be read: ${err.message}`);++ }++ const tokenFile = expand(raw.tokenFile || env.GIT_CI_TOKEN_FILE || path.join(HOME, '.config', 'openvibe', 'git-ci.token'));++ let token = String(env.GIT_CI_RUNNER_TOKEN || '').trim();++ if (!token) {++ try { token = fs.readFileSync(tokenFile, 'utf8').trim(); } catch { /* reported below */ }++ }++ const cfg = {++ file,++ api: String(raw.api || env.GIT_CI_API || 'https://git.openvibe.codes').replace(/\/+$/, ''),++ token,++ tokenFile,++ workRoot: expand(raw.workRoot || path.join(HOME, '.local', 'share', 'openvibe-git-ci', 'work')),++ stateFile: expand(raw.stateFile || path.join(HOME, '.local', 'share', 'openvibe-git-ci', 'runner.json')),++ // The workstation's shared podman store is on a disk with no inodes left; the runner's own store is on++ // the home filesystem. Both are passed to every podman call, never changed globally.++ podmanRoot: expand(raw.podmanRoot || path.join(HOME, '.local', 'share', 'openvibe-git-ci', 'containers', 'storage')),++ podmanRunRoot: expand(raw.podmanRunRoot || `/run/user/${process.getuid()}/ovci`),++ podmanConf: expand(raw.podmanConf || path.join(HOME, '.config', 'openvibe', 'containers.conf')),++ podmanStorageConf: expand(raw.podmanStorageConf || path.join(HOME, '.config', 'openvibe', 'containers-storage.conf')),++ engine: ['auto', 'podman', 'bwrap'].includes(raw.engine) ? raw.engine : 'auto',++ image: String(raw.image || 'docker.io/library/node:22-bookworm'),++ concurrency: int(raw.concurrency, 1, 1, 4),++ pollMs: int(raw.pollMs, 5000, 1000, 60_000),++ heartbeatMs: int(raw.heartbeatMs, 15_000, 2000, 60_000),++ proxyAllow: Array.isArray(raw.proxyAllow) && raw.proxyAllow.length++ ? raw.proxyAllow.map((h) => String(h).toLowerCase()).filter(Boolean)++ : ['registry.npmjs.org', 'codeload.github.com'],++ repos: raw.repos && typeof raw.repos === 'object' ? raw.repos : {},++ };++ cfg.repos = Object.fromEntries(Object.entries(cfg.repos).map(([full, item]) => [++ String(full).toLowerCase(),++ {++ install_command: String((item && item.install_command) || 'npm ci --no-audit --no-fund'),++ test_command: String((item && item.test_command) || 'npm test'),++ timeout_seconds: int(item && item.timeout_seconds, 1800, 30, 3600),++ image: String((item && item.image) || cfg.image),++ cpus: int(item && item.cpus, 4, 1, 8),++ memory_mb: int(item && item.memory_mb, 6144, 512, 12288),++ },++ ]));++ if (!cfg.token) throw new Error(`no runner token: write it to ${tokenFile} (0600) or set GIT_CI_RUNNER_TOKEN`);++ if (!cfg.api.startsWith('https://') && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(cfg.api)) {++ throw new Error('the API must be https (or loopback http for a test)');++ }++ return cfg;++}++++/** What the runner registers with the forge: one object per configured repository. */++function registrations(cfg) {++ return Object.entries(cfg.repos).map(([full_name, item]) => ({ full_name, ...item }));++}++++module.exports = { loadConfig, registrations, expand };nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++++/**++ * The forge CI runner (workstation). It polls OpenVibe.Git for the next queued run, executes it in the sandbox++ * (runner/run.js) and reports the log and the verdict. One run at a time by default; the forge decides what is++ * queued and supersedes, so the runner never chooses or trusts a repository itself.++ *++ * node runner/index.js++ * GIT_CI_CONFIG=... GIT_CI_RUNNER_TOKEN=... node runner/index.js++ */++const fs = require('node:fs');++const os = require('node:os');++const { loadConfig, registrations } = require('./config');++const { createApi } = require('./api');++const { createRunner } = require('./run');++++const wait = (ms) => new Promise((r) => setTimeout(r, ms));++++async function main(env = process.env) {++ const cfg = loadConfig(env);++ fs.mkdirSync(cfg.workRoot, { recursive: true });++ const api = createApi({ baseUrl: cfg.api, token: cfg.token });++ const runner = createRunner({ cfg, api });++ const repos = registrations(cfg);++ let stopping = false;++ const onSignal = (signal) => {++ process.stdout.write(`[git-ci-runner] ${signal}: stopping after the current run\n`);++ stopping = true;++ runner.stop();++ };++ process.on('SIGTERM', () => onSignal('SIGTERM'));++ process.on('SIGINT', () => onSignal('SIGINT'));++++ // Register the repositories this runner is configured for. The forge refuses anything not official.++ for (let i = 0; !stopping; i += 1) {++ try {++ const out = await api.register(repos);++ process.stdout.write(`[git-ci-runner] registered ${out.repositories.length} repositories with ${cfg.api}\n`);++ break;++ } catch (err) {++ const seconds = Math.min(60, 5 * (i + 1));++ process.stdout.write(`[git-ci-runner] registration failed (${err.message}); retrying in ${seconds}s\n`);++ await wait(seconds * 1000);++ }++ }++ if (!stopping && !repos.length) process.stdout.write('[git-ci-runner] no repositories configured; only claiming nothing\n');++++ let lastEmpty = 0;++ while (!stopping) {++ try {++ const job = await api.claim();++ if (!job) {++ if (Date.now() - lastEmpty > 60_000) {++ process.stdout.write(`[git-ci-runner] idle on ${os.hostname()} (${cfg.api})\n`);++ lastEmpty = Date.now();++ }++ await wait(cfg.pollMs);++ continue;++ }++ lastEmpty = Date.now();++ const out = await runner.runJob(job);++ process.stdout.write(`[git-ci-runner] ${job.run.id} ${out.state} in ${Math.round(out.duration / 1000)}s\n`);++ } catch (err) {++ process.stdout.write(`[git-ci-runner] loop: ${err.message}\n`);++ await wait(Math.max(cfg.pollMs, 5000));++ }++ }++ process.stdout.write('[git-ci-runner] stopped\n');++}++++if (require.main === module) {++ main().catch((err) => {++ process.stderr.write(`[git-ci-runner] failed: ${err.message}\n`);++ process.exit(1);++ });++}++++module.exports = { main };nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++++/**++ * One phase of a run as a child process with a wall-clock limit. On the limit it calls onKill (the container or++ * the scope is removed), kills the child's process group and resolves `timedOut: true`; the run then reports it.++ * It never buffers the whole output: onData gets every chunk and only a short tail is kept for the error line.++ */++const { spawn } = require('node:child_process');++++function runProcess({ command, args = [], env = {}, cwd = null, timeoutMs = 1800_000, onData = () => {}, onKill = null, log = console }) {++ const started = Date.now();++ return new Promise((resolve) => {++ let child;++ try {++ child = spawn(command, args, {++ cwd,++ env,++ stdio: ['ignore', 'pipe', 'pipe'],++ detached: process.platform !== 'win32',++ windowsHide: true,++ });++ } catch (err) {++ resolve({ code: null, signal: null, timedOut: false, durationMs: 0, error: String(err.message) });++ return;++ }++ let settled = false;++ let timedOut = false;++ let tail = '';++ let fallback = null;++ const keep = (chunk) => {++ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);++ tail = (tail + text).slice(-8192);++ try { onData(chunk); } catch (err) { log.warn('[git-ci-runner] log sink:', err.message); }++ };++ const kill = () => {++ if (onKill) { try { Promise.resolve(onKill()).catch(() => {}); } catch { /* best effort */ } }++ try {++ if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL');++ else child.kill('SIGKILL');++ } catch { try { child.kill('SIGKILL'); } catch { /* gone */ } }++ };++ const finish = (value) => {++ if (settled) return;++ settled = true;++ clearTimeout(timer);++ if (fallback) clearTimeout(fallback);++ resolve({ ...value, durationMs: Date.now() - started, timedOut, tail });++ };++ const timer = setTimeout(() => {++ timedOut = true;++ log.warn(`[git-ci-runner] ${command} reached its ${Math.round(timeoutMs / 1000)}s limit; killing it`);++ kill();++ // A container client that does not die would hold the queue: resolve anyway.++ fallback = setTimeout(() => finish({ code: null, signal: 'SIGKILL' }), 10_000);++ }, Math.max(100, Number(timeoutMs) || 1800_000));++ child.stdout.on('data', keep);++ child.stderr.on('data', keep);++ child.on('error', (err) => finish({ code: null, signal: null, error: String(err.message) }));++ child.on('close', (code, signal) => finish({ code, signal }));++ });++}++++module.exports = { runProcess };nnew file mode 100644@@@ -0,0 +1,140 @@++'use strict';++++/**++ * The install-phase egress proxy: an HTTP CONNECT proxy on a Unix socket, bound into the sandbox for the one++ * phase that may install dependencies. It answers only the hosts the runner names (npm's registry and GitHub's++ * codeload for the pinned tarballs), resolves them itself and refuses every non-public address. The test phase++ * gets no proxy and no network at all.++ *++ * It is a plain TCP relay after the CONNECT answer: it never reads TLS, keeps no request bodies and logs only++ * the host it refused.++ */++const fs = require('node:fs');++const http = require('node:http');++const net = require('node:net');++const dns = require('node:dns').promises;++++const PRIVATE_V4 = [++ [/^0\./, 'this network'], [/^10\./, 'private'], [/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, 'carrier NAT'],++ [/^127\./, 'loopback'], [/^169\.254\./, 'link local'], [/^172\.(1[6-9]|2\d|3[01])\./, 'private'],++ [/^192\.0\.0\./, 'IETF'], [/^192\.0\.2\./, 'documentation'], [/^192\.168\./, 'private'],++ [/^198\.(1[89]|51\.100)\./, 'benchmark/documentation'], [/^203\.0\.113\./, 'documentation'],++ [/^22[4-9]\./, 'multicast'], [/^2[3-5]\d\./, 'multicast'], [/^255\./, 'broadcast'],++];++++function isPublicAddress(address) {++ const value = String(address || '').toLowerCase();++ if (net.isIPv4(value)) return !PRIVATE_V4.some(([re]) => re.test(value));++ if (net.isIPv6(value)) {++ if (value === '::' || value === '::1') return false;++ if (/^f[cd]/.test(value) || /^fe[89ab]/.test(value) || /^ff/.test(value)) return false;++ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(value);++ if (mapped) return isPublicAddress(mapped[1]);++ return true;++ }++ return false;++}++++async function resolvePublic(host) {++ if (!host || net.isIP(host)) throw new Error('an IP literal is not allowed');++ const answers = await dns.lookup(host, { all: true, verbatim: true });++ if (!answers.length) throw new Error('no address');++ for (const answer of answers) {++ if (!isPublicAddress(answer.address)) throw new Error(`resolves to a non-public address (${answer.address})`);++ }++ return answers[0];++}++++function createProxy({ allow = ['registry.npmjs.org', 'codeload.github.com'], log = console } = {}) {++ const allowed = new Set(allow.map((h) => String(h).toLowerCase()));++ const sockets = new Set();++ let server = null;++++ const permit = (host) => allowed.has(String(host || '').toLowerCase());++ const track = (socket) => {++ sockets.add(socket);++ socket.on('close', () => sockets.delete(socket));++ socket.on('error', () => { try { socket.destroy(); } catch { /* gone */ } });++ return socket;++ };++ const deny = (client, code, text) => {++ try { client.end(`HTTP/1.1 ${code} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); } catch { /* gone */ }++ };++++ async function target(host, port) {++ if (!permit(host)) throw Object.assign(new Error('host not on the allowlist'), { code: 403, reason: 'not allowed' });++ if (![80, 443].includes(Number(port))) throw Object.assign(new Error('port'), { code: 403, reason: 'port not allowed' });++ const answer = await resolvePublic(host);++ return { host, port: Number(port), address: answer.address, family: answer.family };++ }++++ function onConnect(req, client, head) {++ const [host, portText] = String(req.url || '').split(':');++ const port = parseInt(portText, 10) || 443;++ target(host, port).then((t) => {++ const upstream = track(net.connect({ host: t.address, port: t.port, family: t.family }));++ upstream.once('connect', () => {++ client.write('HTTP/1.1 200 Connection Established\r\n\r\n');++ if (head && head.length) upstream.write(head);++ client.pipe(upstream);++ upstream.pipe(client);++ });++ upstream.once('error', (err) => {++ log.warn(`[git-ci-proxy] ${t.host}: ${err.code || err.message}`);++ try { client.destroy(); } catch { /* gone */ }++ });++ client.on('error', () => { try { upstream.destroy(); } catch { /* gone */ } });++ }).catch((err) => {++ log.warn(`[git-ci-proxy] refused ${host}: ${err.reason || err.message}`);++ deny(client, 403, 'Forbidden');++ });++ }++++ function onRequest(req, res) {++ let url;++ try { url = new URL(req.url); } catch { res.writeHead(400); res.end(); return; }++ if (url.protocol !== 'http:') { res.writeHead(403); res.end(); return; }++ target(url.hostname, url.port || 80).then((t) => {++ const upstream = track(net.connect({ host: t.address, port: t.port, family: t.family }));++ upstream.once('connect', () => {++ const path = `${url.pathname}${url.search}`;++ upstream.write(`${req.method} ${path} HTTP/1.1\r\n`);++ for (const [k, v] of Object.entries(req.headers)) {++ if (['proxy-connection', 'proxy-authorization'].includes(k.toLowerCase())) continue;++ upstream.write(`${k}: ${v}\r\n`);++ }++ upstream.write('\r\n');++ req.pipe(upstream);++ upstream.pipe(res);++ });++ upstream.once('error', () => { try { res.writeHead(502); res.end(); } catch { /* gone */ } });++ }).catch((err) => {++ log.warn(`[git-ci-proxy] refused ${url.hostname}: ${err.reason || err.message}`);++ res.writeHead(403); res.end();++ });++ }++++ async function listen(socketPath) {++ fs.rmSync(socketPath, { force: true });++ server = http.createServer(onRequest);++ server.on('connect', onConnect);++ server.keepAliveTimeout = 5000;++ await new Promise((resolve, reject) => {++ server.once('error', reject);++ server.listen(socketPath, () => { try { fs.chmodSync(socketPath, 0o600); } catch { /* best effort */ } resolve(); });++ });++ return socketPath;++ }++++ async function close() {++ for (const socket of sockets) { try { socket.destroy(); } catch { /* gone */ } }++ sockets.clear();++ if (!server) return;++ await new Promise((resolve) => server.close(resolve));++ server = null;++ }++++ return { listen, close, permit, target, allowed };++}++++module.exports = { createProxy, isPublicAddress, resolvePublic };nnew file mode 100644@@@ -0,0 +1,224 @@++'use strict';++++/**++ * One claimed run: clone the pushed commit from the forge, install dependencies in a sandbox whose only network++ * is the allowlisted proxy, then run the tests in a sandbox with no network at all. The log streams to the forge++ * while it runs; the verdict goes back with the duration. A superseded run is killed at the next heartbeat and++ * reports nothing (the newer run owns the status).++ */++const fs = require('node:fs');++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 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) => {++ if (err) reject(new Error(`git ${args[0]} failed: ${String(stderr || err.message).trim().slice(0, 300)}`));++ else resolve(String(stdout));++ });++});++++const wait = (ms) => new Promise((r) => setTimeout(r, ms));++const words = (ms) => {++ const s = Math.max(0, Math.round(Number(ms) / 1000));++ return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;++};++++/** Clone exactly the pushed commit; the mirror may be a few seconds behind the webhook, so retry. */++async function cloneAt({ dir, cloneUrl, sha, branch, log, attempts = 24, waitMs = 5000 }) {++ fs.mkdirSync(dir, { recursive: true });++ await git(['init', '--quiet'], dir);++ await git(['remote', 'add', 'origin', cloneUrl], dir);++ let last = null;++ for (let i = 0; i < attempts; i += 1) {++ try {++ await git(['fetch', '--quiet', '--no-tags', '--depth=1', 'origin', sha], dir);++ } catch (err) {++ last = err;++ try {++ await git(['fetch', '--quiet', '--no-tags', '--depth=1', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], dir);++ } catch (err2) { last = err2; }++ }++ try {++ await git(['cat-file', '-e', `${sha}^{commit}`], dir, 20_000);++ await git(['checkout', '--quiet', '--detach', sha], dir);++ return sha;++ } catch (err) {++ last = err;++ if (i < attempts - 1) {++ log(`the pushed commit is not in the mirror yet (${i + 1}/${attempts}); waiting for the sync`);++ await wait(waitMs);++ }++ }++ }++ throw new Error(`the pushed commit never reached the mirror: ${last ? last.message : sha}`);++}++++function createRunner({ cfg, api, log = console, clock = Date.now }) {++ const engine = detectEngine(cfg.engine);++ const rootArgs = engine.kind === 'podman' ? ['--root', cfg.podmanRoot, '--runroot', cfg.podmanRunRoot] : [];++ // Only what podman and systemd-run need: never the runner's token or the rest of the service environment.++ const sandboxEnv = {++ PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',++ HOME: process.env.HOME,++ XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,++ DBUS_SESSION_BUS_ADDRESS: process.env.DBUS_SESSION_BUS_ADDRESS,++ USER: process.env.USER, LOGNAME: process.env.LOGNAME, LANG: process.env.LANG, TZ: 'UTC',++ CONTAINERS_CONF: cfg.podmanConf, CONTAINERS_STORAGE_CONF: cfg.podmanStorageConf,++ };++ if (engine.kind === 'podman') {++ const image = spawnSync('podman', [...rootArgs, 'image', 'exists', cfg.image], { encoding: 'utf8', timeout: 20_000 });++ if (image.error || image.status !== 0) log(`[git-ci-runner] image ${cfg.image} is not in ${cfg.podmanRoot}; run deploy/ci/install.sh`);++ }++ log(`[git-ci-runner] sandbox: ${engine.why}; ${Object.keys(cfg.repos).length} repositories configured`);++ let activeKill = null;++ let stopping = false;++++ async function runJob(job) {++ const started = clock();++ const run = job.run;++ const runDir = path.join(cfg.workRoot, `run-${run.id}`);++ const localLog = path.join(cfg.workRoot, `run-${run.id}.log`);++ fs.rmSync(runDir, { recursive: true, force: true });++ fs.mkdirSync(path.join(runDir, '.ci'), { recursive: true });++ 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.js'), RELAY, { mode: 0o600 });++ fs.writeFileSync(localLog, '', { mode: 0o600 });++++ const limits = job.limits || { timeout_seconds: 1800, cpus: 4, memory_mb: 6144 };++ const totalMs = Math.max(30, Number(limits.timeout_seconds) || 1800) * 1000;++ const deadline = started + totalMs;++ let buffer = '';++ let truncated = false;++ let lastFlush = 0;++ let currentKill = null;++ let superseded = false;++ let proxy = null;++++ const appendLocal = (text) => {++ try { fs.appendFileSync(localLog, text); } catch { /* disk */ }++ };++ const flush = async (force = false) => {++ if (!buffer || (!force && clock() - lastFlush < 1000)) return;++ const chunk = buffer;++ buffer = '';++ lastFlush = clock();++ try { await api.log(run.id, chunk); } catch (err) { log(`[git-ci-runner] log not sent: ${err.message}`); }++ };++ const onData = (data) => {++ const text = Buffer.isBuffer(data) ? data.toString('utf8') : String(data);++ appendLocal(text);++ if (truncated) return;++ buffer += text;++ if (Buffer.byteLength(buffer) > 256 * 1024) buffer = buffer.slice(-192 * 1024);++ if (buffer.length > 512 * 1024) { buffer = `${buffer.slice(0, 256 * 1024)}\n[output truncated]\n`; truncated = true; }++ };++++ const heartbeat = setInterval(() => {++ api.heartbeat(run.id).then((out) => {++ if (out && out.superseded && !superseded) {++ superseded = true;++ log(`[git-ci-runner] ${run.id} was superseded; stopping it`);++ if (currentKill) currentKill();++ }++ }).catch((err) => log(`[git-ci-runner] heartbeat: ${err.message}`));++ }, cfg.heartbeatMs);++++ const sandbox = (phase, command, proxySock) => {++ const name = `ov-ci-${run.id.replace(/[^A-Za-z0-9_.-]/g, '')}-${phase}`;++ if (engine.kind === 'podman') {++ return {++ command: 'podman',++ args: [...rootArgs, ...podmanArgs({ name, workspace: runDir, image: job.image, limits, proxySock, script: command })],++ kill: () => podmanCleanup(name, rootArgs),++ };++ }++ const wrapped = bwrapScope({ name, limits, args: bwrapArgs({ workspace: runDir, limits, proxySock, script: command }).args });++ return { command: wrapped.command, args: wrapped.args, kill: () => scopeCleanup(wrapped.killUnit) };++ };++++ const phase = async (label, script, proxySock) => {++ const left = deadline - clock();++ if (left < 5000) return { timedOut: true, code: null, skipped: true };++ const box = sandbox(label, script, proxySock);++ log(`[git-ci-runner] ${run.id} ${label}`);++ activeKill = () => box.kill();++ const res = await runProcess({++ command: box.command, args: box.args, env: sandboxEnv, timeoutMs: left,++ onData,++ onKill: () => { box.kill(); },++ });++ activeKill = null;++ await flush(true);++ return res;++ };++++ let state = null;++ let description = '';++ let exitCode = null;++ try {++ log(`[git-ci-runner] ${run.id} ${job.repo.full_name} ${run.branch} ${run.sha.slice(0, 12)}`);++ await cloneAt({ dir: runDir, cloneUrl: job.repo.clone_url, sha: run.sha, branch: run.branch, log: (line) => onData(`${line}\n`) });++++ proxy = createProxy({ allow: cfg.proxyAllow, log: { warn: (line) => onData(`${line}\n`) } });++ const socket = path.join(runDir, '.ci', 'proxy.sock');++ await proxy.listen(socket);++ const install = await phase('install', installScript(job.command.install), socket);++ if (superseded) { state = 'superseded'; } else if (install.timedOut) {++ state = 'timed_out'; description = `Timed out installing dependencies after ${words(totalMs)}`;++ } else if (install.code !== 0) {++ state = 'failure'; exitCode = install.code; description = `Dependency install failed (exit ${install.code})`;++ } else {++ const test = await phase('test', installScript(job.command.test, { proxy: false }), null);++ if (superseded) state = 'superseded';++ else if (test.timedOut) { state = 'timed_out'; description = `Timed out after ${words(totalMs)}`; }++ else if (test.code === 0) { state = 'success'; description = `Forge runner passed in ${words(clock() - started)}`; }++ else { state = 'failure'; exitCode = test.code; description = `Forge runner failed (exit ${test.code}) in ${words(clock() - started)}`; }++ }++ } catch (err) {++ if (superseded) state = 'superseded';++ else { state = 'error'; description = String(err.message || err).slice(0, 500); onData(`\n${err.message}\n`); }++ } finally {++ clearInterval(heartbeat);++ if (proxy) await proxy.close().catch(() => {});++ await flush(true);++ }++++ if (stopping) { state = 'error'; description = 'The runner stopped before the run finished'; }++ const duration = clock() - started;++ if (state !== 'superseded') {++ try {++ await api.finish(run.id, { state, description, exit_code: exitCode, duration_ms: duration });++ } catch (err) {++ log(`[git-ci-runner] ${run.id} verdict not sent (${state}): ${err.message}`);++ }++ }++ fs.rmSync(runDir, { recursive: true, force: true });++ return { state, duration };++ }++++ function stop() {++ stopping = true;++ if (activeKill) { try { activeKill(); } catch { /* gone */ } }++ }++++ return { engine, runJob, stop };++}++++module.exports = { createRunner, cloneAt, RELAY };nnew file mode 100644@@@ -0,0 +1,185 @@++'use strict';++++/**++ * The sandbox around one phase. Podman (rootless) when the host has it, bubblewrap otherwise; both get the same++ * shape: no host home, no secrets, no host path but the per-run work directory, a new user/pid/network namespace,++ * 4 CPUs, 6 GB, a 30-minute (configurable) wall, and only the install phase gets the allowlisted proxy socket.++ *++ * Podman: `--network=none`, read-only root, workspace bind only, all capabilities dropped, no-new-privileges.++ * Bubblewrap: no network namespace, read-only /usr and certificates, the same workspace bind, a per-run++ * passwd/group/hosts, wrapped in a systemd user scope for the memory and CPU caps.++ */++const fs = require('node:fs');++const path = require('node:path');++const { spawnSync } = require('node:child_process');++++const UID = 1000;++const GID = 1000;++++function have(command) {++ const out = spawnSync(command, ['--version'], { encoding: 'utf8', timeout: 10_000 });++ return !out.error && out.status === 0;++}++++function podmanWorks() {++ const out = spawnSync('podman', ['info', '--format', '{{.Host.Security.Rootless}}'], { encoding: 'utf8', timeout: 20_000 });++ return !out.error && out.status === 0;++}++++/** `auto` picks rootless podman, then bubblewrap; an unavailable engine is an error, never a silent host run. */++function detectEngine(preferred = 'auto') {++ if (preferred === 'podman' || preferred === 'auto') {++ if (podmanWorks()) return { kind: 'podman', why: 'rootless podman' };++ if (preferred === 'podman') throw new Error('podman is not working rootless on this host');++ }++ if (preferred === 'bwrap' || preferred === 'auto') {++ if (have('bwrap')) return { kind: 'bwrap', why: 'bubblewrap' };++ if (preferred === 'bwrap') throw new Error('bubblewrap (bwrap) is not installed');++ }++ throw new Error('no sandbox engine: rootless podman or bubblewrap is required');++}++++const envList = (workspace, extra = {}) => ({++ HOME: `${workspace}/.home`,++ TMPDIR: '/tmp',++ PATH: '/opt/node/bin:/usr/local/bin:/usr/bin:/bin',++ LANG: 'C.UTF-8',++ LC_ALL: 'C.UTF-8',++ CI: 'true',++ NO_COLOR: '1',++ TZ: 'UTC',++ npm_config_cache: `${workspace}/.cache/npm`,++ npm_config_fund: 'false',++ npm_config_audit: 'false',++ npm_config_update_notifier: 'false',++ PNPM_HOME: `${workspace}/.pnpm`,++ ...extra,++});++++/** The install phase's script: relay the Unix-socket proxy on loopback, point npm/pnpm at it, run the command. */++function installScript(command, { proxy = true } = {}) {++ return `#!/bin/sh++set -eu++${proxy ? `node /workspace/.ci/relay.js /workspace/.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++export HTTP_PROXY="$http_proxy" HTTPS_PROXY="$https_proxy"++export npm_config_proxy="$http_proxy" npm_config_https_proxy="$https_proxy"++` : ''}mkdir -p /workspace/.pnpm/bin++export PATH=/workspace/.pnpm/bin:$PATH++cd /workspace++${command}++`;++}++++/**++ * The podman command line for one phase. proxySock is null for the test phase (no network at all).++ */++function podmanArgs({ name, workspace, image, limits, proxySock = null, script }) {++ const args = [++ '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',++ '--userns=keep-id', '--workdir', '/workspace',++ '-v', `${workspace}:/workspace:rw`,++ '--stop-timeout', '5',++ ];++ for (const [key, value] of Object.entries(envList(workspace))) args.push('-e', `${key}=${value}`);++ if (proxySock) args.push('-v', `${proxySock}:/workspace/.ci/proxy.sock:rw`);++ args.push(image, '/bin/sh', '-c', script);++ return args;++}++++const ETC_FILES = ['ssl/certs', 'ssl/openssl.cnf', 'ca-certificates', 'ca-certificates.conf', 'pki/tls/certs', 'pki/ca-trust/extracted',++ 'fonts', 'ld.so.cache', 'ld.so.conf', 'ld.so.conf.d', 'localtime', 'timezone', 'alternatives', 'mime.types', 'magic',++ 'vim', 'vimrc', 'nanorc', 'bash.bashrc', 'inputrc', 'terminfo', 'protocols', 'services'];++const ROOT_LINKS = ['bin', 'sbin', 'lib', 'lib32', 'lib64', 'libx32'];++++function bwrapEtc(workspace) {++ const dir = path.join(workspace, '.ci', 'etc');++ fs.mkdirSync(dir, { recursive: true });++ const files = {++ passwd: `ci:x:${UID}:${GID}:ci:/workspace/.home:/bin/sh\nnobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n`,++ group: `ci:x:${GID}:\nnogroup:x:65534:\n`,++ hosts: '127.0.0.1 localhost ci\n::1 localhost ip6-localhost ip6-loopback\n',++ hostname: 'ci\n',++ 'resolv.conf': '# No DNS in the sandbox: the install proxy resolves the names it allows.\n',++ 'nsswitch.conf': 'passwd: files\ngroup: files\nhosts: files\n',++ };++ for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);++ return dir;++}++++/**++ * The bubblewrap command line for one phase (no systemd scope here; the caller wraps it). Node is bound from++ * the running runner's own installation, because a workstation's Node is rarely in /usr.++ */++function bwrapArgs({ workspace, limits, proxySock = null, script }) {++ const node = fs.realpathSync(process.execPath);++ const prefix = path.dirname(path.dirname(node));++ const nodeInside = path.join('/opt/node', path.relative(prefix, node));++ const nodeBin = path.dirname(nodeInside);++ const etc = bwrapEtc(workspace);++ const args = [++ '--unshare-user', '--unshare-pid', '--unshare-net', '--unshare-ipc', '--unshare-uts', '--unshare-cgroup',++ '--disable-userns', '--die-with-parent', '--new-session', '--cap-drop', 'ALL', '--clearenv',++ '--uid', String(UID), '--gid', String(GID), '--hostname', 'ci',++ ];++ for (const [key, value] of Object.entries({ ...envList(workspace), PATH: `${nodeBin}:/usr/local/bin:/usr/bin:/bin` })) {++ args.push('--setenv', key, value);++ }++ args.push('--ro-bind', '/usr', '/usr');++ if (fs.existsSync('/usr/local')) args.push('--ro-bind', '/usr/local', '/usr/local');++ for (const name of ROOT_LINKS) {++ const p = `/${name}`;++ try {++ const st = fs.lstatSync(p);++ if (st.isSymbolicLink()) args.push('--symlink', fs.readlinkSync(p), p);++ else args.push('--ro-bind', p, p);++ } catch { /* absent */ }++ }++ args.push('--ro-bind', prefix, '/opt/node');++ args.push('--dir', '/etc');++ for (const name of ETC_FILES) {++ const p = `/etc/${name}`;++ if (fs.existsSync(p)) args.push('--ro-bind', p, p);++ }++ 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('--bind', workspace, '/workspace');++ if (proxySock) args.push('--bind', proxySock, '/workspace/.ci/proxy.sock');++ args.push('--remount-ro', '/', '--chdir', '/workspace', '--', '/bin/sh', '-c', script);++ return { args, nodeInside };++}++++/** Maybe wrap a bwrap command in a transient user scope for memory, CPU and process caps. */++function bwrapScope({ name, limits, args }) {++ return {++ command: 'systemd-run',++ args: [++ '--user', '--scope', '--quiet', '--collect', '--unit', `ov-ci-${name}`,++ '-p', `MemoryMax=${limits.memory_mb}M`, '-p', 'MemorySwapMax=0',++ '-p', `CPUQuota=${Math.round(limits.cpus * 100)}%`, '-p', 'TasksMax=2048',++ '--', 'bwrap', ...args,++ ],++ killUnit: `ov-ci-${name}.scope`,++ };++}++++/** Remove a podman container, whether it is still running or already gone. */++function podmanCleanup(name, rootArgs = []) {++ spawnSync('podman', [...rootArgs, 'rm', '-f', name], { encoding: 'utf8', timeout: 20_000 });++}++++function scopeCleanup(unit) {++ if (!unit) return;++ spawnSync('systemctl', ['--user', 'kill', '--signal=SIGKILL', unit], { encoding: 'utf8', timeout: 10_000 });++}++++module.exports = {++ detectEngine, podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, envList, UID, GID,++};nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++++/**++ * Make the runner's token, or print the hash of the one it already has.++ *++ * node runner/token.js --out ~/.config/openvibe/git-ci.token++ * creates a random token, writes it with mode 0600 and prints the scrypt line for++ * deploy/ci/runner-token (the service never sees the token itself).++ *++ * node runner/token.js --from ~/.config/openvibe/git-ci.token++ * prints the scrypt line for an existing token (rotation: write the same line to the repo).++ */++const crypto = require('node:crypto');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { hashToken } = require('../server/ci-auth');++++function main(argv) {++ const arg = (name, fallback = null) => {++ const i = argv.indexOf(name);++ return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;++ };++ const from = arg('--from');++ let token;++ let out = arg('--out');++ if (from) {++ token = fs.readFileSync(String(from).replace(/^~(?=$|\/)/, os.homedir()), 'utf8').trim();++ } else {++ token = crypto.randomBytes(32).toString('base64url');++ out = String(out || path.join(os.homedir(), '.config', 'openvibe', 'git-ci.token')).replace(/^~(?=$|\/)/, os.homedir());++ fs.mkdirSync(path.dirname(out), { recursive: true, mode: 0o700 });++ fs.writeFileSync(out, `${token}\n`, { mode: 0o600 });++ process.stdout.write(`token written to ${out} (0600)\n`);++ }++ if (!token || token.length < 16) throw new Error('the token is empty or too short');++ process.stdout.write(`${hashToken(token)}\n`);++}++++if (require.main === module) {++ try { main(process.argv.slice(2)); } catch (err) { process.stderr.write(`${err.message}\n`); process.exit(1); }++}++++module.exports = { main };@@@ -36,6 +36,8 @@ const { createPageRoutes } = require('./http/pages');const { createReviewRoutes } = require('./http/review');const { createReviews } = require('./reviews');const { createChecks } = require('./checks');++const { createCi } = require('./ci');++const { createCiAuth } = require('./ci-auth');const { createArchive } = require('./archive');const { createReviewServices } = require('./review-services');const { createLinkRelay } = require('./link-relay');@@@ -57,14 +59,16 @@ function createApp(opts = {}) {const repos = opts.repos || createRepos({ store: s, config, git, github, insights, log });const mirrors = opts.mirrors || createMirrors({ store: s, repos, config, git, github, log });const official = opts.official || createOfficialMirrors({ store: s, repos, config, github, log });-- const smartHttp = opts.smartHttp || createSmartHttp({ config, repos, mirrors, insights, log });const sso = opts.sso || createSsoClient({site: 'git', baseUrl: config.baseUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl,secureCookies: config.cookies.secure, fetch: fetchImpl, log,});-- const ctx = { config, store: s, s, git, github, insights, repos, mirrors, official, smartHttp, sso, log };++ const ctx = { config, store: s, s, git, github, insights, repos, mirrors, official, sso, log };ctx.checks = createChecks({ store: s, log });++ ctx.ci = opts.ci || createCi({ config, store: s, checks: ctx.checks, git, github, repos, log });++ ctx.smartHttp = opts.smartHttp || createSmartHttp({ config, repos, mirrors, insights, ci: ctx.ci, log });++ ctx.ciAuth = opts.ciAuth || createCiAuth({ config, log });ctx.archive = createArchive({ config, git });ctx.checksIntake = opts.checksIntake || createChecksIntake({ store: s, checks: ctx.checks, config, git, github, log });ctx.relay = createLinkRelay(ctx);@@@ -126,7 +130,7 @@ function createApp(opts = {}) {const row = await repos.getRow(parsed.owner, parsed.name);if (!row) return res.status(404).type('text/plain').send('No such repository.');const identity = await identify(req, { store: s, config });-- return smartHttp.run(req, res, { row, suffix: parsed.suffix, identity });++ return ctx.smartHttp.run(req, res, { row, suffix: parsed.suffix, identity });})().catch((err) => {log.error('[OpenVibe.Git] git route:', err && err.message);if (!res.headersSent) res.status(500).type('text/plain').send('The git server could not answer.');nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++++/**++ * The forge runner's own credential. The runner holds one random token; this service holds only a scrypt hash++ * (deploy/ci/runner-token, or GIT_CI_RUNNER_TOKEN for a test). A request presents it as `Authorization: Bearer …`++ * or `X-OV-CI-Token`; the comparison is constant time. An absent or malformed token is refused, never logged.++ */++const crypto = require('node:crypto');++const fs = require('node:fs');++++const TOKEN_RE = /^[A-Za-z0-9._~-]{16,200}$/;++++/** scrypt$<salt hex>$<hash hex> (what deploy/ci/runner-token holds), or null. */++function parseHash(text) {++ const parts = String(text || '').trim().split('$');++ if (parts.length !== 3 || parts[0] !== 'scrypt') return null;++ if (!/^[0-9a-f]{16,64}$/i.test(parts[1]) || !/^[0-9a-f]{64}$/i.test(parts[2])) return null;++ return { salt: Buffer.from(parts[1], 'hex'), hash: Buffer.from(parts[2], 'hex') };++}++++function readHash(file) {++ if (!file) return null;++ try { return parseHash(fs.readFileSync(file, 'utf8')); } catch { return null; }++}++++/** A token's raw value → the scrypt line deploy/ci/runner-token holds. Used by the local setup, never by the service. */++function hashToken(token, { salt = crypto.randomBytes(16) } = {}) {++ const hash = crypto.scryptSync(String(token), salt, 32);++ return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`;++}++++function createCiAuth({ config, log = console } = {}) {++ const ci = (config && config.ci) || {};++ const parsed = ci.runnerToken ? parseHash(hashToken(ci.runnerToken)) : readHash(ci.runnerTokenFile);++ const raw = ci.runnerToken ? String(ci.runnerToken) : '';++++ function tokenOf(req) {++ const h = String((req.headers && req.headers.authorization) || '');++ if (/^bearer /i.test(h)) return h.slice(7).trim();++ const named = req.get ? req.get('x-ov-ci-token') : (req.headers && req.headers['x-ov-ci-token']);++ return String(named || '').trim();++ }++++ /** The raw token from the request, checked against the stored hash. */++ function ok(req) {++ const token = tokenOf(req);++ if (!TOKEN_RE.test(token)) return false;++ if (raw) {++ const a = Buffer.from(token);++ const b = Buffer.from(raw);++ return a.length === b.length && crypto.timingSafeEqual(a, b);++ }++ if (!parsed) return false;++ const got = crypto.scryptSync(token, parsed.salt, 32);++ return got.length === parsed.hash.length && crypto.timingSafeEqual(got, parsed.hash);++ }++++ return { ok, configured: Boolean(raw || parsed), tokenOf };++}++++module.exports = { createCiAuth, hashToken, parseHash, TOKEN_RE };nnew file mode 100644@@@ -0,0 +1,370 @@++'use strict';++++/**++ * Forge CI: the queue of runs the workstation runner executes, and the member rule that decides whether a push++ * may start one.++ *++ * A run exists only for a push to a branch of an official (OpenVibers) repository, made by a person GitHub says++ * may push there — the push event's own pusher, checked against the repository's collaborators (10 minutes of++ * cache) and always against the owning account. A fork, a pull request from a fork, a tag, a bot and a non-member++ * are skipped; those keep GitHub Actions. One run per repository is live: a newer push supersedes the queued or++ * running one, and the runner sees that on its heartbeat and stops.++ *++ * The verdict is copied into git_commit_statuses as `forge/test` (the same intake the GitHub check-runs use), and++ * the log lives on the run so the forge can show it.++ */++const STATES = Object.freeze(['queued', 'running', 'success', 'failure', 'error', 'timed_out', 'superseded']);++const FINAL = new Set(['success', 'failure', 'error', 'timed_out']);++const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;++const BRANCH_RE = /^[A-Za-z0-9._/-]{1,255}$/;++const MAX_LOG = 4 * 1024 * 1024;++const CONTEXT = 'forge/test';++++const fail = (status, code, message) => Object.assign(new Error(message), { status, code });++const text = (value, max) => String(value == null ? '' : value).slice(0, max);++++function cleanSha(sha) {++ const value = String(sha || '').toLowerCase();++ if (!SHA_RE.test(value)) throw fail(422, 'ci.sha_invalid', 'A run names the full commit id.');++ return value;++}++++function cleanBranch(branch) {++ const value = String(branch || '');++ if (!BRANCH_RE.test(value) || value.includes('..') || value.startsWith('-') || value.endsWith('/') || value.endsWith('.lock')) {++ throw fail(422, 'ci.branch_invalid', 'A branch is 1–255 letters, digits, dots, dashes, underscores or slashes.');++ }++ return value;++}++++function cleanCommand(value, fallback, code) {++ const command = String(value == null || value === '' ? fallback : value).trim();++ if (!command || command.length > 2000 || /[\u0000\r]/.test(command)) throw fail(422, code, 'A command is 1–2000 characters on one logical line.');++ return command;++}++++function intBetween(value, fallback, min, max, code) {++ const n = value == null || value === '' ? fallback : parseInt(value, 10);++ if (!Number.isFinite(n) || n < min || n > max) throw fail(422, code, `The value is ${min}–${max}.`);++ return n;++}++++function rowToRun(row, { baseUrl = '', owner = null, name = null, log = false } = {}) {++ if (!row) return null;++ const full = owner && name ? `${owner}/${name}` : null;++ return {++ id: row.id,++ repo: full,++ sha: row.sha,++ branch: row.branch,++ pusher: row.pusher || null,++ source: row.source,++ state: row.state,++ description: row.description || '',++ exit_code: row.exit_code == null ? null : Number(row.exit_code),++ log_bytes: Number(row.log_bytes) || 0,++ runner: row.runner || null,++ started_at: row.started_at || null,++ finished_at: row.finished_at || null,++ duration_ms: row.duration_ms == null ? null : Number(row.duration_ms),++ created_at: row.created_at,++ url: full ? `${String(baseUrl).replace(/\/+$/, '')}/${full}/ci/${row.id}` : null,++ ...(log ? { log: row.log || '' } : {}),++ };++}++++function createCi({ config, store, checks, git, github, repos, log = console, clock = null }) {++ const settings = config.ci;++ const now = clock && clock.now ? () => clock.now() : () => store.now();++ const members = new Map(); // `${full}|${login}` → { at, value }++ let timer = null;++++ const base = () => String(config.baseUrl || '').replace(/\/+$/, '');++ const runUrl = (row, id) => `${base()}/${row.owner_username}/${row.name}/ci/${id}`;++ const fullName = (row) => `${row.owner_username}/${row.name}`;++++ async function settingsFor(repoId) {++ return await store.maybe('SELECT * FROM git_ci_repos WHERE repo_id = ?', [repoId]);++ }++++ async function enabledFor(row) {++ if (!row || !row.official) return null;++ const conf = await settingsFor(row.id);++ return conf && conf.enabled ? conf : null;++ }++++ /** A GitHub login may push to this repository: the owning account, a configured member, or a collaborator. */++ async function member({ full, login, owner = settings.org }) {++ const who = String(login || '').trim();++ if (!who) return { ok: false, why: 'no pusher' };++ const key = `${String(full).toLowerCase()}|${who.toLowerCase()}`;++ const hit = members.get(key);++ if (hit && now() - hit.at < settings.membershipCacheMs) return hit.value;++ let value;++ if (who.toLowerCase() === String(owner || '').toLowerCase()) {++ value = { ok: true, why: 'owner' };++ } else if (settings.members.includes(who.toLowerCase())) {++ value = { ok: true, why: 'member' };++ } else {++ try {++ const permission = await github.permission(full, who);++ value = { ok: ['admin', 'maintain', 'write'].includes(permission), why: permission || 'none' };++ } catch (err) {++ // Never run because GitHub could not answer: deny, and retry after a minute, not ten.++ const valueNow = { ok: false, why: `github:${err.status || 'unavailable'}` };++ members.set(key, { at: now() - settings.membershipCacheMs + 60_000, value: valueNow });++ log.warn(`[OpenVibe.Git] CI member check ${full} ${who}: ${text(err.message, 200)}`);++ return valueNow;++ }++ }++ members.set(key, { at: now(), value });++ return value;++ }++++ /** Insert a run, superseding the repository's queued or running one first. */++ async function enqueue(row, { sha, branch, pusher = null, source = 'github', force = false } = {}) {++ const conf = await enabledFor(row);++ if (!conf) return { skipped: 'not-enabled' };++ const clean = cleanSha(sha);++ const ref = cleanBranch(branch);++ const live = await store.maybe("SELECT * FROM git_ci_runs WHERE repo_id = ? AND state IN ('queued', 'running')", [row.id]);++ if (!force && live && live.sha === clean && live.branch === ref) {++ return { skipped: 'duplicate', run: rowToRun(live, { baseUrl: base(), owner: row.owner_username, name: row.name }) };++ }++ const id = store.newId('cir');++ await store.execute(++ "UPDATE git_ci_runs SET state = 'superseded', description = ?, finished_at = now(), updated_at = now() WHERE repo_id = ? AND state IN ('queued', 'running')",++ [`Superseded by ${clean.slice(0, 12)}`, row.id],++ );++ try {++ await store.execute(++ `INSERT INTO git_ci_runs (id, repo_id, sha, branch, pusher, source, state, description)++ VALUES (?, ?, ?, ?, ?, ?, 'queued', ?)`,++ [id, row.id, clean, ref, pusher ? text(pusher, 200) : null, text(source, 20), 'Queued on the forge runner'],++ );++ } catch (err) {++ if (/duplicate key|unique/i.test(String(err.message))) {++ const current = await store.maybe("SELECT * FROM git_ci_runs WHERE repo_id = ? AND state IN ('queued', 'running')", [row.id]);++ return { skipped: 'duplicate', run: rowToRun(current, { baseUrl: base(), owner: row.owner_username, name: row.name }) };++ }++ throw err;++ }++ const run = await store.maybe('SELECT * FROM git_ci_runs WHERE id = ?', [id]);++ await checks.recordIfChanged(row.id, clean, {++ context: settings.context || CONTEXT, state: 'pending', description: 'Queued on the forge runner', target_url: runUrl(row, id),++ }, 'svc:forge-runner').catch((err) => log.warn('[OpenVibe.Git] CI pending status:', err.message));++ return { run: rowToRun(run, { baseUrl: base(), owner: row.owner_username, name: row.name }) };++ }++++ /**++ * A GitHub push webhook. Runs only a branch push to the official repository that this mirror follows, by a++ * login GitHub says may push there. Everything else (fork, tag, deletion, PR, non-member, bot) is skipped.++ */++ async function onGithubPush(row, payload) {++ if (!settings.enabled) return { skipped: 'off' };++ if (!row || !row.official || !row.github_full_name) return { skipped: 'not-official' };++ const repo = (payload && payload.repository) || {};++ const full = String(repo.full_name || '');++ if (!full || full.toLowerCase() !== String(row.github_full_name).toLowerCase()) return { skipped: 'not-this-repository' };++ if (repo.fork) return { skipped: 'fork' };++ const owner = full.split('/')[0];++ if (owner.toLowerCase() !== settings.org.toLowerCase()) return { skipped: 'not-the-network' };++ if (payload.deleted) return { skipped: 'deleted' };++ const ref = String(payload.ref || '');++ if (!ref.startsWith('refs/heads/')) return { skipped: 'not-a-branch' };++ const after = String(payload.after || '');++ if (!SHA_RE.test(after.toLowerCase()) || /^0+$/.test(after)) return { skipped: 'no-commit' };++ const pusher = (payload.pusher && payload.pusher.name) || (payload.sender && payload.sender.login) || null;++ const who = await member({ full, login: pusher, owner });++ if (!who.ok) return { skipped: 'not-member', pusher, why: who.why };++ if (!(await enabledFor(row))) return { skipped: 'not-enabled' };++ const out = await enqueue(row, { sha: after, branch: ref.slice('refs/heads/'.length), pusher, source: 'github' });++ return { ...out, pusher, member: who.why };++ }++++ /** A push that landed on the forge itself: only the network's own service tokens count as its members. */++ async function onForgePush(row, identity, { ref, sha, force = false } = {}) {++ if (!settings.enabled) return { skipped: 'off' };++ if (!row || !row.official) return { skipped: 'not-official' };++ if (!identity) return { skipped: 'no-identity' };++ if (identity.actor_type !== 'service' || !identity.scopes || !identity.scopes.has('git.repo.write')) {++ return { skipped: 'not-a-service' };++ }++ if (!String(ref || '').startsWith('refs/heads/')) return { skipped: 'not-a-branch' };++ if (!(await enabledFor(row))) return { skipped: 'not-enabled' };++ return await enqueue(row, { sha, branch: String(ref).slice('refs/heads/'.length), pusher: identity.subject || 'svc', source: 'forge', force });++ }++++ /** The runner registers the repositories it is configured to test; only official ones are accepted. */++ async function registerRepos(list, actor = 'svc:forge-runner') {++ if (!Array.isArray(list) || !list.length || list.length > 100) throw fail(422, 'ci.repos_invalid', 'One to 100 repositories are registered.');++ const out = [];++ for (const item of list) {++ const full = String((item && (item.full_name || `${item.owner || ''}/${item.name || ''}`)) || '').trim();++ const [owner, name] = full.split('/');++ const row = owner && name ? await repos.getRow(owner.toLowerCase(), name.toLowerCase()) : null;++ if (!row || !row.official) throw fail(422, 'ci.repo_not_official', `Only an official repository can run on the forge: ${full || 'no name'}`);++ const body = {++ enabled: item.enabled !== false,++ install_command: cleanCommand(item.install_command, 'npm ci --no-audit --no-fund', 'ci.install_invalid'),++ test_command: cleanCommand(item.test_command, 'npm test', 'ci.test_invalid'),++ image: cleanCommand(item.image, 'docker.io/library/node:22-bookworm', 'ci.image_invalid'),++ timeout_seconds: intBetween(item.timeout_seconds, 1800, 30, 3600, 'ci.timeout_invalid'),++ cpus: intBetween(item.cpus, 4, 1, 8, 'ci.cpus_invalid'),++ memory_mb: intBetween(item.memory_mb, 6144, 512, 12288, 'ci.memory_invalid'),++ };++ if (body.image.length > 200) throw fail(422, 'ci.image_invalid', 'An image name is at most 200 characters.');++ await store.execute(++ `INSERT INTO git_ci_repos (repo_id, enabled, install_command, test_command, image, timeout_seconds, cpus, memory_mb, registered_by, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, now())++ ON CONFLICT (repo_id) DO UPDATE SET enabled = EXCLUDED.enabled, install_command = EXCLUDED.install_command,++ test_command = EXCLUDED.test_command, image = EXCLUDED.image, timeout_seconds = EXCLUDED.timeout_seconds,++ cpus = EXCLUDED.cpus, memory_mb = EXCLUDED.memory_mb, registered_by = EXCLUDED.registered_by, updated_at = now()`,++ [row.id, body.enabled, body.install_command, body.test_command, body.image, body.timeout_seconds, body.cpus, body.memory_mb, actor],++ );++ out.push({ full_name: fullName(row), ...body });++ }++ return out;++ }++++ /** Start the current head (or a named commit) by hand: an owner, or the runner for its own checks. */++ async function manual(row, { sha = null, branch = null, pusher = null } = {}, actor = 'manual') {++ if (!(await enabledFor(row))) throw fail(409, 'ci.not_enabled', 'CI is not enabled for this repository.');++ const ref = cleanBranch(branch || row.default_branch || 'main');++ const wanted = sha ? cleanSha(sha) : await git.resolveCommit(row.owner_username, row.name, ref);++ if (!wanted) throw fail(422, 'ci.commit_missing', 'That branch or commit is not in this repository.');++ const found = await git.resolveCommit(row.owner_username, row.name, wanted);++ if (!found || String(found).toLowerCase() !== wanted) throw fail(422, 'ci.commit_missing', 'That commit is not in this repository.');++ const out = await enqueue(row, { sha: wanted, branch: ref, pusher: pusher || settings.org, source: 'manual', force: true });++ return { ...out, actor };++ }++++ /** The next queued run, marked running. One runner claims one run at a time. */++ async function claim(runner = 'forge-runner') {++ const row = await store.maybe(++ `WITH next AS (++ SELECT id FROM git_ci_runs WHERE state = 'queued' ORDER BY created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED++ )++ UPDATE git_ci_runs r SET state = 'running', started_at = now(), runner = ?, updated_at = now()++ FROM next WHERE r.id = next.id RETURNING r.*`,++ [text(runner, 100)],++ );++ if (!row) return null;++ const repo = await store.maybe('SELECT * FROM git_repos WHERE id = ?', [row.repo_id]);++ const conf = repo ? await settingsFor(repo.id) : null;++ if (!repo || !conf) {++ await finish(row.id, { state: 'error', description: 'The repository or its CI configuration disappeared' });++ return null;++ }++ return {++ run: rowToRun(row, { baseUrl: base(), owner: repo.owner_username, name: repo.name }),++ repo: {++ full_name: fullName(repo),++ clone_url: `${base()}/${repo.owner_username}/${repo.name}.git`,++ default_branch: repo.default_branch || 'main',++ },++ image: conf.image,++ command: { install: conf.install_command, test: conf.test_command },++ limits: { timeout_seconds: conf.timeout_seconds, cpus: conf.cpus, memory_mb: conf.memory_mb },++ };++ }++++ async function runRow(id) {++ return await store.maybe('SELECT * FROM git_ci_runs WHERE id = ?', [text(id, 100)]);++ }++++ async function repoOfRun(row) {++ return row ? await store.maybe('SELECT * FROM git_repos WHERE id = ?', [row.repo_id]) : null;++ }++++ /** Whether a running run is still wanted (the runner asks every few seconds). */++ async function heartbeat(id) {++ const row = await runRow(id);++ if (!row) return { found: false, state: 'missing', superseded: true };++ return { found: true, state: row.state, superseded: row.state !== 'running' };++ }++++ async function appendLog(id, chunk) {++ const row = await runRow(id);++ if (!row || row.state !== 'running') return { ok: false, state: row ? row.state : 'missing' };++ const piece = String(chunk == null ? '' : chunk);++ if (!piece) return { ok: true, log_bytes: Number(row.log_bytes) || 0 };++ let next = `${row.log || ''}${piece}`;++ if (next.length > MAX_LOG) next = `${next.slice(0, MAX_LOG)}\n[the rest of this log was too long to keep]\n`;++ const bytes = Buffer.byteLength(next, 'utf8');++ await store.execute('UPDATE git_ci_runs SET log = ?, log_bytes = ?, updated_at = now() WHERE id = ?', [next, bytes, id]);++ return { ok: true, log_bytes: bytes, truncated: next.length >= MAX_LOG };++ }++++ /** The runner's verdict. A superseded run keeps the newer run's status; it is never overwritten. */++ async function finish(id, { state, description = '', exit_code = null, duration_ms = null } = {}) {++ const row = await runRow(id);++ if (!row) return { ok: false, state: 'missing' };++ if (row.state !== 'running') return { ok: false, state: row.state };++ if (!FINAL.has(state)) throw fail(422, 'ci.state_invalid', 'A final state is success, failure, error or timed_out.');++ const repo = await repoOfRun(row);++ const started = row.started_at ? Date.parse(row.started_at) : now();++ const elapsed = duration_ms == null ? Math.max(0, now() - started) : Math.max(0, Number(duration_ms) || 0);++ const word = state === 'timed_out' ? 'timed out' : state;++ const line = text(description, 1024) || `Forge runner ${word} in ${Math.round(elapsed / 1000)}s`;++ await store.execute(++ `UPDATE git_ci_runs SET state = ?, description = ?, exit_code = ?, duration_ms = ?, finished_at = now(), updated_at = now()++ WHERE id = ? AND state = 'running'`,++ [state, line, exit_code == null ? null : Number(exit_code), elapsed, id],++ );++ const checkState = state === 'success' ? 'success' : state === 'error' ? 'error' : 'failure';++ if (repo) {++ await checks.recordIfChanged(repo.id, row.sha, {++ context: settings.context || CONTEXT, state: checkState, description: line, target_url: runUrl(repo, id),++ }, 'svc:forge-runner').catch((err) => log.warn('[OpenVibe.Git] CI status:', err.message));++ }++ return { ok: true, run: rowToRun({ ...row, state, description: line, exit_code, duration_ms: elapsed, finished_at: new Date(now()).toISOString() }, { baseUrl: base(), owner: repo && repo.owner_username, name: repo && repo.name }) };++ }++++ /** Fail a run whose runner disappeared: one left running past its own limit plus a minute cannot still be alive. */++ async function reap() {++ const rows = await store.many(++ `SELECT r.*, c.timeout_seconds FROM git_ci_runs r JOIN git_ci_repos c ON c.repo_id = r.repo_id++ WHERE r.state = 'running'`,++ );++ const out = [];++ for (const row of rows) {++ const started = row.started_at ? Date.parse(row.started_at) : 0;++ if (!started || now() - started < (Number(row.timeout_seconds) || 1800) * 1000 + 60_000) continue;++ const minutes = Math.round((Number(row.timeout_seconds) || 1800) / 60);++ out.push(await finish(row.id, { state: 'timed_out', description: `The runner stopped answering; timed out after ${minutes} minutes` }));++ }++ return out;++ }++++ async function runsFor(row, { limit = 30 } = {}) {++ const rows = await store.many('SELECT * FROM git_ci_runs WHERE repo_id = ? ORDER BY created_at DESC LIMIT ?', [row.id, Math.max(1, Math.min(100, Number(limit) || 30))]);++ return rows.map((r) => rowToRun(r, { baseUrl: base(), owner: row.owner_username, name: row.name }));++ }++++ async function runFor(row, id) {++ const run = await store.maybe('SELECT * FROM git_ci_runs WHERE id = ? AND repo_id = ?', [text(id, 100), row.id]);++ return rowToRun(run, { baseUrl: base(), owner: row.owner_username, name: row.name, log: true });++ }++++ async function configFor(row) {++ return await settingsFor(row.id);++ }++++ function start() {++ if (timer || !settings.enabled) return false;++ timer = setInterval(() => { reap().catch((err) => log.warn('[OpenVibe.Git] CI reap:', err.message)); }, settings.reapMs);++ timer.unref();++ return true;++ }++++ function stop() { if (timer) clearInterval(timer); timer = null; }++++ return {++ onGithubPush, onForgePush, registerRepos, manual, enqueue, claim, heartbeat, appendLog, finish, reap,++ runsFor, runFor, configFor, settingsFor, enabledFor, member, rowToRun, start, stop,++ STATES, CONTEXT, SHA_RE, BRANCH_RE,++ };++}++++module.exports = { createCi, rowToRun, cleanSha, cleanBranch, STATES, CONTEXT };@@@ -7,6 +7,7 @@*/require('dotenv').config();++const path = require('node:path');const trim = (s) => String(s || '').replace(/\/+$/, '');const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);const bool = (v, def = false) => (v == null || v === '' ? def : !/^(0|false|no|off)$/i.test(String(v)));@@@ -67,6 +68,20 @@ function load(env = process.env) {cacheMs: Math.max(60_000, int(env.GIT_GITHUB_CACHE_MS, 10 * 60_000)),},++ // Forge CI: the workstation runner's queue. A run is made only for a branch push to an official++ // repository by a login GitHub says may push there (the owning account, a configured member, or a++ // collaborator with write). The token file holds an scrypt hash, never the token.++ ci: {++ enabled: bool(env.GIT_CI_ENABLED, true),++ org: String(env.GIT_CI_ORG || officialOwners[0] || 'openvibers').toLowerCase(),++ context: String(env.GIT_CI_CONTEXT || 'forge/test').slice(0, 255),++ members: list(env.GIT_CI_MEMBERS).map((m) => m.toLowerCase()),++ membershipCacheMs: Math.max(60_000, int(env.GIT_CI_MEMBER_CACHE_MS, 10 * 60_000)),++ reapMs: Math.max(30_000, int(env.GIT_CI_REAP_MS, 60_000)),++ runnerToken: env.GIT_CI_RUNNER_TOKEN || '',++ runnerTokenFile: env.GIT_CI_RUNNER_TOKEN_FILE || path.join(__dirname, '..', 'deploy', 'ci', 'runner-token'),++ },++// Clone tokens (opaque, hashed at rest) and the key that seals a stored push-mirror token.tokens: {ttlHours: Math.max(1, Math.min(24 * 30, int(env.GIT_TOKEN_TTL_HOURS, 1))),@@@ -63,7 +63,7 @@ function cgiHeaders(text) {return { status, headers, bodyAt: idx + sep };}--function createSmartHttp({ config, repos, mirrors, insights, log = console }) {++function createSmartHttp({ config, repos, mirrors, insights, ci = null, log = console }) {const mayRead = (row, identity) => {if (row.visibility === 'public') return true;if (!identity) return false;@@@ -146,6 +146,15 @@ function createSmartHttp({ config, repos, mirrors, insights, log = console }) {await repos.recordEvent(row, 'push', identity || { subject: null, username: null }, { service });await repos.audit(identity, 'git.push', row, {}, req.ip || null);await mirrors.afterPush(row);++ // A branch push that landed here (the estate's own tools) can queue a forge run; a person's forge++ // account is not a GitHub login, so only the network's service tokens count (server/ci.js).++ if (ci) {++ for (const change of parsePushCommands(body)) {++ if (!change.ref.startsWith('refs/heads/') || /^0+$/.test(change.new)) continue;++ ci.onForgePush(row, identity, { ref: change.ref, sha: change.new })++ .catch((err) => log.warn(`[OpenVibe.Git] CI forge push ${row.owner_username}/${row.name}: ${String(err.message || err).slice(0, 200)}`));++ }++ }} else if (suffix === 'info/refs' && service === 'git-upload-pack' && req.method === 'GET') {// A clone or fetch begins here; count it once, not again on the pack that follows.if (insights) await insights.recordClone(row, { address: req.ip || '', subject: identity ? identity.subject : null });@@@ -155,6 +164,29 @@ function createSmartHttp({ config, repos, mirrors, insights, log = console }) {return { run, mayRead, mayWrite, parseGitPath };}++/** The ref updates at the start of a git-receive-pack request body: [{ old, new, ref }]. */++function parsePushCommands(body) {++ const out = [];++ if (!Buffer.isBuffer(body)) return out;++ let i = 0;++ while (i + 4 <= body.length) {++ const size = parseInt(body.subarray(i, i + 4).toString('latin1'), 16);++ i += 4;++ if (size === 0) break; // flush: the pack follows++ if (!Number.isFinite(size) || size < 4 || i + size - 4 > body.length) break;++ const line = body.subarray(i, i + size - 4).toString('utf8');++ i += size - 4;++ const command = line.split('\u0000')[0].trim();++ const parts = command.split(' ');++ if (parts.length < 3) continue;++ const [oldSha, newSha, ref] = parts;++ if (!/^[0-9a-f]{40,64}$/i.test(oldSha) || !/^[0-9a-f]{40,64}$/i.test(newSha)) continue;++ if (!ref.startsWith('refs/')) continue;++ out.push({ old: oldSha.toLowerCase(), new: newSha.toLowerCase(), ref });++ }++ return out;++}++/** Byte offset of the end of the CGI header block (after the blank line). */function findHeaderEnd(buf) {const crlf = buf.indexOf('\r\n\r\n');@@@ -163,4 +195,4 @@ function findHeaderEnd(buf) {return lf >= 0 ? lf + 2 : -1;}--module.exports = { createSmartHttp, parseGitPath, cgiHeaders, findHeaderEnd, ROUTE };++module.exports = { createSmartHttp, parseGitPath, cgiHeaders, findHeaderEnd, parsePushCommands, ROUTE };@@@ -271,6 +271,30 @@ function createGithubClient({ config, fetchImpl = globalThis.fetch, now = () =>return Array.isArray(body && body.check_runs) ? body.check_runs : [];}++ /**++ * The permission a GitHub login has on one repository: admin, maintain, write, triage, read, or null when++ * the login is not a collaborator. This is the push-access check the forge runner trusts (10-minute cache++ * in server/ci.js); a 404 is an answer, not an error.++ */++ async function permission(full, login) {++ if (!FULL_RE.test(String(full || ''))) return null;++ const who = String(login || '');++ if (!/^[A-Za-z0-9-]{1,39}$/.test(who)) return null;++ const [owner, name] = String(full).split('/');++ const res = await fetchImpl(++ `${config.github.apiUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/collaborators/${encodeURIComponent(who)}/permission`,++ { headers: headers(), signal: AbortSignal.timeout(10_000) },++ );++ if (res.status === 404) return null;++ if (!res.ok) {++ const err = new Error(`GitHub ${res.status} for ${full} permission`);++ err.status = res.status;++ throw err;++ }++ const body = await res.json();++ return body && typeof body.permission === 'string' ? body.permission : null;++ }++/** GitHub's X-Hub-Signature-256 over the raw body, constant time. */function signatureOk(rawBody, signature) {const secret = config.github.webhookSecret;@@@ -281,7 +305,7 @@ function createGithubClient({ config, fetchImpl = globalThis.fetch, now = () =>return a.length === b.length && crypto.timingSafeEqual(a, b);}-- return { parseGithubUrl, card, meta, probe, orgRepos, openPulls, checkRuns, signatureOk, _cache: cache };++ return { parseGithubUrl, card, meta, probe, orgRepos, openPulls, checkRuns, permission, signatureOk, _cache: cache };}module.exports = { createGithubClient, parseGithubUrl, parseRefs, pktLine, FULL_RE };@@@ -19,7 +19,7 @@ const fail = (req, res, err) => problem(req, res, err.status || 500, err.code ||function boolParam(v) { return v === true || v === '1' || v === 'true' || v === 'yes'; }function createApi(ctx) {-- const { config, repos, mirrors, git, insights, store, sso, checks, archive, log } = ctx;++ const { config, repos, mirrors, git, insights, store, sso, checks, archive, ci, ciAuth, log } = ctx;const r = express.Router();const h = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);// One identity read per request: the session, or a token (a person's or a service's, and for a service the@@@ -264,6 +264,86 @@ function createApi(ctx) {} catch (err) { return fail(req, res, err); }}));++ // ── Forge CI (the workstation runner) ──────────────────++ // The runner holds one token; the service holds its scrypt hash. Nothing here is reachable with a session.++ const runnerOnly = (req, res) => {++ if (!ciAuth.ok(req)) {++ problem(req, res, 401, 'ci.runner_token', 'The forge runner token is missing or wrong.');++ return false;++ }++ return true;++ };++++ r.post('/ci/repos', h(async (req, res) => {++ if (!runnerOnly(req, res)) return undefined;++ try {++ const list = Array.isArray(req.body) ? req.body : (req.body && Array.isArray(req.body.repos) ? req.body.repos : []);++ return res.json({ repositories: await ci.registerRepos(list) });++ } catch (err) { return fail(req, res, err); }++ }));++++ r.post('/ci/runs/claim', h(async (req, res) => {++ if (!runnerOnly(req, res)) return undefined;++ const job = await ci.claim((req.body && req.body.runner) || 'forge-runner');++ if (!job) return res.status(204).end();++ return res.json(job);++ }));++++ r.get('/ci/runs/:id/heartbeat', h(async (req, res) => {++ if (!runnerOnly(req, res)) return undefined;++ return res.json(await ci.heartbeat(req.params.id));++ }));++++ r.post('/ci/runs/:id/log', h(async (req, res) => {++ if (!runnerOnly(req, res)) return undefined;++ const out = await ci.appendLog(req.params.id, String((req.body || {}).chunk || ''));++ if (!out.ok) return problem(req, res, 409, 'ci.run_not_running', `This run is ${out.state}.`);++ return res.json(out);++ }));++++ r.post('/ci/runs/:id/status', h(async (req, res) => {++ if (!runnerOnly(req, res)) return undefined;++ try {++ const out = await ci.finish(req.params.id, req.body || {});++ if (!out.ok) return problem(req, res, 409, 'ci.run_not_running', `This run is ${out.state}.`);++ return res.json(out);++ } catch (err) { return fail(req, res, err); }++ }));++++ r.get('/repos/:owner/:name/ci', h(async (req, res) => {++ const found = await visible(req, req.params.owner, req.params.name);++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');++ const conf = await ci.configFor(found.row);++ return res.json({++ repo: `${found.row.owner_username}/${found.row.name}`,++ enabled: Boolean(conf && conf.enabled),++ context: config.ci.context,++ runs: await ci.runsFor(found.row, { limit: req.query.limit }),++ });++ }));++++ r.get('/repos/:owner/:name/ci/runs/:id', h(async (req, res) => {++ const found = await visible(req, req.params.owner, req.params.name);++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');++ const run = await ci.runFor(found.row, req.params.id);++ if (!run) return problem(req, res, 404, 'ci.run_not_found', 'No such run.');++ return res.json(run);++ }));++++ r.post('/repos/:owner/:name/ci/runs', h(async (req, res) => {++ const found = await visible(req, req.params.owner, req.params.name);++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');++ const caller = await callerFor(req);++ const runner = ciAuth.ok(req);++ if (!runner && (!caller || !repos.isOwner(found.row, actingAs(caller)))) {++ return problem(req, res, 403, 'repo.forbidden', 'Only the owner or the forge runner may start a run.');++ }++ try {++ const out = await ci.manual(found.row, (req.body || {}), runner ? 'runner' : 'owner');++ return res.status(201).json(out);++ } catch (err) { return fail(req, res, err); }++ }));++r.get('/repos/:owner/:name/branches', h(async (req, res) => {const found = await visible(req, req.params.owner, req.params.name);if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');@@@ -360,7 +440,14 @@ function createApi(ctx) {r.post('/webhooks/github', h(async (req, res) => {if (!Buffer.isBuffer(req.body)) return problem(req, res, 415, 'webhook.raw_required', 'The webhook body is read raw.');const out = await mirrors.handleWebhook(req.body, req.headers);-- return res.status(out.status).json(out.body);++ res.status(out.status).json(out.body);++ // A push starts a forge run only after the mirror has the pushed commit; a pull request never does.++ if (out.event === 'push' && out.row && out.payload && out.sync) {++ out.sync++ .then(() => ci.onGithubPush(out.row, out.payload))++ .catch((err) => log.warn(`[OpenVibe.Git] CI push ${out.row.owner_username}/${out.row.name}: ${String(err.message || err).slice(0, 200)}`));++ }++ return undefined;}));r.use((req, res) => problem(req, res, 404, 'route.not_found', 'No such API route.'));@@@ -22,6 +22,7 @@ const express = require('express');const contracts = require('openvibe-contracts');const { send } = require('../render/layout');const views = require('../views');++const ciViews = require('../views/ci');const machineViews = require('../views/machine');const { viewerOf } = require('./identity');const { esc } = require('../render/html');@@@ -30,7 +31,7 @@ const { validRef, validPath } = require('../git/refs');const { serveArchive } = require('../archive');function createPageRoutes(ctx) {-- const { config, repos, git, github, mirrors, insights, store, relay, archive, log } = ctx;++ const { config, repos, git, github, mirrors, insights, store, relay, archive, ci, log } = ctx;const r = express.Router();r.use(express.urlencoded({ extended: false, limit: '256kb' }));const h = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);@@@ -391,6 +392,24 @@ function createPageRoutes(ctx) {return page(req, res, views.insightsPage({ row, data }), meta(req, row, { title: 'Insights', description: `Views, clones, stars and commits for ${row.owner_username}/${row.name}.` }));}));++ r.get('/:owner/:name/ci', h(async (req, res) => {++ const row = await rowFor(req, res);++ if (!row) return undefined;++ const conf = await ci.configFor(row);++ return page(req, res, ciViews.list({++ row, enabled: Boolean(conf && conf.enabled), context: config.ci.context,++ runs: await ci.runsFor(row, { limit: req.query.limit }),++ }), meta(req, row, { title: 'CI', index: false }));++ }));++++ r.get('/:owner/:name/ci/:id', h(async (req, res) => {++ const row = await rowFor(req, res);++ if (!row) return undefined;++ const run = await ci.runFor(row, req.params.id);++ if (!run) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });++ return page(req, res, ciViews.run({ row, run }), meta(req, row, { title: `CI · ${run.branch}`, index: false }));++ }));++// ── Writes ──────────────────────────────────────────────r.post('/:owner/:name/star', h(async (req, res) => {if (!guard(req, res)) return undefined;@@@ -13,6 +13,7 @@ function createLifecycle({ server, exit, signals, ctx } = {}) {const mirrors = ctx && ctx.mirrors;const official = ctx && ctx.official;const checksIntake = ctx && ctx.checksIntake;++ const ci = ctx && ctx.ci;const store = ctx && ctx.store;return gracefulStop({name: 'OpenVibe.Git',@@@ -21,7 +22,7 @@ function createLifecycle({ server, exit, signals, ctx } = {}) {exit,signals,deadlineMs: 10_000,-- stop: [() => ctx.reviews && ctx.reviews.stop(), () => (mirrors ? mirrors.stop() : undefined), () => (official ? official.stop() : undefined), () => (checksIntake ? checksIntake.stop() : undefined)],++ stop: [() => ctx.reviews && ctx.reviews.stop(), () => (mirrors ? mirrors.stop() : undefined), () => (official ? official.stop() : undefined), () => (checksIntake ? checksIntake.stop() : undefined), () => (ci ? ci.stop() : undefined)],close: [() => catFiles.close(), () => (store ? store.close() : undefined)],});}@@@ -33,6 +34,7 @@ async function start() {// Due-based and off unless GIT_OFFICIAL_ORG is set: the first check is shortly after boot, never in this path.ctx.official.start();ctx.checksIntake.start();++ ctx.ci.start();ctx.reviews.start();const server = app.listen(config.port, config.host, () => {console.log(`[OpenVibe.Git] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (release ${release.release})`);@@@ -86,9 +86,10 @@ function createMirrors({ store, repos, config, git, github, log = console }) {if (!full) return { status: 200, body: { ok: true, ignored: true } };const row = await store.maybe('SELECT * FROM git_repos WHERE lower(github_full_name) = lower(?)', [full]);if (!row) return { status: 200, body: { ok: true, ignored: 'no mirror' } };-- // Acknowledge now, fetch after: GitHub's delivery does not wait on a large clone.-- syncRow(row).catch(() => {});-- return { status: 202, body: { ok: true, queued: true, repo: full } };++ // Acknowledge now, fetch after: GitHub's delivery does not wait on a large clone. The CI runner is told++ // after the fetch has brought the pushed commit in (the caller schedules it), never before.++ const sync = syncRow(row).catch(() => {});++ return { status: 202, body: { ok: true, queued: true, repo: full }, row, payload, event: headers['x-github-event'], sync };}/** After a push: mark it and, if a push mirror is on, push back to GitHub. */nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++++/**++ * The CI pages: the runs of one repository and one run's log. Plain and readable, no JavaScript needed. The++ * verdict is also a commit status ("forge/test") on the change page and in the API.++ */++const { esc } = require('../render/html');++const { repoTabs } = require('./index');++++const base = (r) => `/${r.owner_username}/${r.name}`;++const STATE = {++ queued: 'Queued', running: 'Running', success: 'Passed', failure: 'Failed', error: 'Error',++ timed_out: 'Timed out', superseded: 'Superseded',++};++++function when(value) {++ if (!value) return '—';++ const d = new Date(value);++ if (Number.isNaN(d.getTime())) return '—';++ return d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');++}++++function duration(ms) {++ if (ms == null) return '—';++ const s = Math.max(0, Math.round(Number(ms) / 1000));++ const m = Math.floor(s / 60);++ return m ? `${m}m ${s % 60}s` : `${s}s`;++}++++function state(s) { return `<span class="git-check git-check--${esc(s)}">${esc(STATE[s] || s)}</span>`; }++++function list({ row, enabled, runs, context }) {++ const b = base(row);++ const rows = runs.map((run) => `<li class="git-ci-row">++ ${state(run.state)}++ <a class="git-ci-main" href="${b}/ci/${esc(run.id)}"><b>${esc(run.branch)}</b><code>${esc(String(run.sha).slice(0, 12))}</code></a>++ <span class="git-muted">${esc(run.pusher || '—')} · ${esc(duration(run.duration_ms))} · ${esc(when(run.created_at))}</span>++</li>`).join('');++ return `<div class="git-list-page"><header class="git-page-head"><p class="git-crumb"><a href="${b}">${esc(row.owner_username)}/${esc(row.name)}</a></p><h1>CI</h1>++<p>${enabled ? `The forge runner tests branch pushes by OpenVibers members and reports <code>${esc(context)}</code>.` : 'Forge CI is not enabled for this repository; GitHub Actions still runs.'}</p></header>++${repoTabs(row, 'ci')}++${runs.length ? `<ul class="git-ci-list">${rows}</ul>` : '<p class="git-empty">No forge runs yet.</p>'}++<p class="git-muted git-small">The runner clones the branch, installs dependencies through an allowlisted proxy, then runs the tests with no network at all. Logs stay on the run they belong to.</p></div>`;++}++++function run({ row, run: r }) {++ const b = base(row);++ const log = String(r.log || '');++ return `<div class="git-list-page"><header class="git-page-head"><p class="git-crumb"><a href="${b}">${esc(row.owner_username)}/${esc(row.name)}</a> · <a href="${b}/ci">CI</a></p>++<h1>${esc(r.branch)} <code>${esc(String(r.sha).slice(0, 12))}</code></h1>++<p>${state(r.state)} <span class="git-muted">${esc(r.description || '')}</span></p></header>++${repoTabs(row, 'ci')}++<ul class="git-ci-facts">++ <li><span>Commit</span><a href="${b}/commit/${esc(r.sha)}"><code>${esc(r.sha)}</code></a></li>++ <li><span>Pusher</span>${esc(r.pusher || '—')}</li>++ <li><span>Started</span>${esc(when(r.started_at))}</li>++ <li><span>Finished</span>${esc(when(r.finished_at))}</li>++ <li><span>Duration</span>${esc(duration(r.duration_ms))}</li>++ <li><span>Exit code</span>${r.exit_code == null ? '—' : esc(r.exit_code)}</li>++</ul>++<section class="git-card" aria-labelledby="git-ci-log-h"><h2 id="git-ci-log-h">Log</h2>++${log ? `<pre class="git-cmd git-ci-log" aria-live="polite">${esc(log)}</pre>` : '<p class="git-muted">No output was kept.</p>'}++${r.state === 'running' ? '<p class="git-muted git-small">This run is still writing its log; reload to see more.</p>' : ''}</section></div>`;++}++++module.exports = { list, run, when, duration, STATE };@@@ -123,6 +123,7 @@ function repoTabs(row, active, ref) {['code', 'Code', base],['changes', 'Changes', `${base}/changes`],['issues', 'Issues', `${base}/issues`],++ ['ci', 'CI', `${base}/ci`],['search', 'Search code', `${base}/search`],['commits', 'Commits', `${base}/commits`],['branches', 'Branches', `${base}/branches`],nnew file mode 100644@@@ -0,0 +1,150 @@++'use strict';++/**++ * The runner's own parts, without podman: the config and its registrations, the token hash, the egress allowlist,++ * the container command line (network off, 4 CPUs, 6 GB, one writable path), the install/test scripts, and the++ * wall-clock kill.++ */++const assert = require('assert');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { loadConfig, registrations } = require('../runner/config');++const { podmanArgs, installScript, envList } = require('../runner/sandbox');++const { isPublicAddress } = require('../runner/proxy');++const { createProxy } = require('../runner/proxy');++const { runProcess } = require('../runner/process');++const { createRunner } = require('../runner/run');++const { detectEngine } = require('../runner/sandbox');++const { execFileSync, spawnSync } = require('node:child_process');++const { hashToken, parseHash, createCiAuth } = require('../server/ci-auth');++++(async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-ci-runner-test-'));++ try {++ // A token's hash is what the service holds; the raw token is never in it.++ const token = 'a-test-runner-token-0123456789';++ const line = hashToken(token);++ assert.match(line, /^scrypt\$[0-9a-f]{32}\$[0-9a-f]{64}$/);++ assert.ok(parseHash(line));++ assert.equal(parseHash('not-a-hash'), null);++ const auth = createCiAuth({ config: { ci: { runnerToken: token } } });++ const req = (value) => ({ headers: { authorization: `Bearer ${value}` } });++ assert.equal(auth.ok(req(token)), true);++ assert.equal(auth.ok(req(`${token}x`)), false);++ assert.equal(auth.ok(req('')), false);++++ // The configuration names repositories and their commands; a missing token is an error.++ const tokenFile = path.join(dir, 'token');++ fs.writeFileSync(tokenFile, `${token}\n`, { mode: 0o600 });++ const configFile = path.join(dir, 'ci.json');++ fs.writeFileSync(configFile, JSON.stringify({++ tokenFile, api: 'https://git.openvibe.codes', engine: 'bwrap', workRoot: path.join(dir, 'work'),++ repos: { 'openvibers/openvibe.shared': { install_command: 'npm ci', test_command: 'npm test', timeout_seconds: 1800 } },++ }));++ const cfg = loadConfig({ GIT_CI_CONFIG: configFile });++ assert.equal(cfg.engine, 'bwrap');++ assert.equal(cfg.repos['openvibers/openvibe.shared'].test_command, 'npm test');++ assert.equal(cfg.concurrency, 1);++ assert.deepStrictEqual(registrations(cfg).map((r) => r.full_name), ['openvibers/openvibe.shared']);++ assert.throws(() => loadConfig({ GIT_CI_CONFIG: path.join(dir, 'missing.json'), GIT_CI_TOKEN_FILE: path.join(dir, 'none') }), /cannot be read/);++++ // The egress allowlist refuses hostnames outside it and any host that resolves to a private address.++ assert.equal(isPublicAddress('8.8.8.8'), true);++ assert.equal(isPublicAddress('127.0.0.1'), false);++ assert.equal(isPublicAddress('10.1.2.3'), false);++ assert.equal(isPublicAddress('192.168.1.1'), false);++ assert.equal(isPublicAddress('::1'), false);++ assert.equal(isPublicAddress('fd00::1'), false);++ const proxy = createProxy({ allow: ['registry.npmjs.org'], log: { warn() {} } });++ await assert.rejects(() => proxy.target('example.com', 443), /allowlist/);++ await assert.rejects(() => proxy.target('localhost', 443), /allowlist/);++ assert.equal(proxy.permit('registry.npmjs.org'), true);++ assert.equal(proxy.permit('registry.npmjs.org.evil.example'), false);++++ // The phase command: no network, 4 CPUs, 6 GB, the work directory as the only host path, and the proxy++ // socket only in the install phase.++ const limits = { cpus: 4, memory_mb: 6144 };++ const install = podmanArgs({++ name: 'ov-ci-cir_1-install', workspace: '/tmp/run-1', image: 'node:22-bookworm', limits,++ proxySock: '/tmp/run-1/.ci/proxy.sock', script: installScript('npm ci'),++ });++ assert.equal(install[0], 'run');++ assert.ok(install.includes('--network=none'));++ assert.ok(install.includes('--read-only'));++ assert.ok(install.includes('--cap-drop'));++ assert.equal(install[install.indexOf('--cpus') + 1], '4');++ assert.equal(install[install.indexOf('--memory') + 1], '6144m');++ assert.ok(install.includes('/tmp/run-1:/workspace:rw'));++ assert.ok(install.some((a) => a.includes('/tmp/run-1/.ci/proxy.sock')));++ assert.ok(!install.includes('--privileged'));++ assert.match(installScript('npm ci'), /http_proxy=http:\/\/127\.0\.0\.1:3128/);++ assert.doesNotMatch(installScript('npm test', { proxy: false }), /http_proxy/);++ assert.equal(envList('/tmp/run-1').HOME, '/tmp/run-1/.home');++++ const testPhase = podmanArgs({++ name: 'ov-ci-cir_1-test', workspace: '/tmp/run-1', image: 'node:22-bookworm', limits,++ proxySock: null, script: installScript('npm test', { proxy: false }),++ });++ assert.ok(!testPhase.some((a) => a.includes('proxy.sock')), 'the test phase mounts no proxy');++ assert.ok(testPhase.includes('--network=none'));++++ // The wall clock kills a process that would otherwise run forever.++ const started = Date.now();++ const killed = await runProcess({++ command: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'], timeoutMs: 300,++ });++ assert.equal(killed.timedOut, true);++ assert.ok(Date.now() - started < 10_000, 'the kill resolves promptly');++ const quick = await runProcess({ command: process.execPath, args: ['-e', 'process.exit(0)'], timeoutMs: 5000 });++ assert.equal(quick.timedOut, false);++ assert.equal(quick.code, 0);++++ // A complete run against a local repository, in the real sandbox, when this host has one and the image.++ // GitHub's runners have neither, so the test says why it skipped instead of failing there.++ let engine = null;++ try { engine = detectEngine('auto'); } catch { engine = null; }++ if (!engine) {++ console.log('ci-runner: no sandbox engine here; the end-to-end run is skipped');++ } else {++ const image = 'docker.io/library/node:22-bookworm';++ const hasImage = engine.kind === 'bwrap' ? true : spawnSync('podman', ['--root', cfg.podmanRoot, '--runroot', cfg.podmanRunRoot, 'image', 'exists', image], { encoding: 'utf8' }).status === 0;++ if (!hasImage) {++ console.log(`ci-runner: ${image} is not pulled here; the end-to-end run is skipped`);++ } else {++ const git = (...args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });++ const source = path.join(dir, 'source'), bare = path.join(dir, 'bare.git');++ fs.mkdirSync(source); git('init', '--quiet', '-b', 'main', source);++ fs.writeFileSync(path.join(source, 'README.md'), 'runner test\n');++ git('-C', source, 'add', 'README.md');++ git('-C', source, '-c', 'user.name=Test', '-c', '[email protected]', 'commit', '--quiet', '-m', 'first');++ const sha = git('-C', source, 'rev-parse', 'HEAD').trim();++ git('clone', '--quiet', '--bare', source, bare);++++ const runConfig = path.join(dir, 'run.json');++ fs.writeFileSync(runConfig, JSON.stringify({ tokenFile, engine: 'auto', workRoot: path.join(dir, 'work2') }));++ const runCfg = loadConfig({ GIT_CI_CONFIG: runConfig });++ let finished = null;++ const api = {++ log: async () => ({ ok: true }),++ heartbeat: async () => ({ found: true, state: 'running', superseded: false }),++ finish: async (_id, verdict) => { finished = verdict; return { ok: true }; },++ };++ const runner = createRunner({ cfg: runCfg, api, log: () => {} });++ const out = await runner.runJob({++ run: { id: 'cir_runner_e2e', sha, branch: 'main' },++ repo: { full_name: 'openvibers/e2e', clone_url: `file://${bare}`, default_branch: 'main' },++ image,++ command: { install: 'true', test: 'node -e "process.exit(0)"' },++ limits: { timeout_seconds: 30, cpus: 1, memory_mb: 1024 },++ });++ assert.equal(out.state, 'success', JSON.stringify(out));++ assert.equal(finished.state, 'success');++ assert.ok(finished.duration_ms >= 0);++ }++ }++++ console.log('ci-runner: config, token hash, egress allowlist, sandbox limits, scripts, timeout and one real run passed');++ } finally {++ fs.rmSync(dir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,158 @@++'use strict';++/**++ * Forge CI: a branch push by a repository's own writer queues a run; a non-member, a fork and a pull request do++ * not. One run per repository, the newer push supersedes the older. The runner claims, writes a log, reports a++ * verdict and that verdict is the commit status. A run left running past its limit is reaped and reported red.++ */++const assert = require('assert');++const { boot } = require('./helpers/boot');++const { parsePushCommands } = require('../server/git/http-backend');++++const TOKEN = 'test-forge-runner-token-0123456789';++const ok = (body, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body });++++(async () => {++ const calls = [];++ const realFetch = globalThis.fetch;++ const fetchImpl = async (url, init) => {++ const u = String(url);++ if (!u.startsWith('https://api.github.com/')) return realFetch(url, init);++ if (u.includes('/collaborators/')) {++ const login = decodeURIComponent(u.split('/collaborators/')[1].split('/')[0]);++ calls.push(login);++ return login.toLowerCase() === 'mallory' ? { ok: false, status: 404, json: async () => ({}) } : ok({ permission: 'write' });++ }++ throw new Error(`unexpected GitHub call: ${u}`);++ };++ const t = await boot({ fetchImpl, env: { GIT_CI_RUNNER_TOKEN: TOKEN } });++ try {++ const owner = t.asUser('openvibers');++ const make = async (name) => {++ await t.ctx.repos.create({ actor: owner, name, starter: 'static', visibility: 'public' });++ const row = await t.ctx.repos.getRow('openvibers', name);++ const full = `OpenVibers/${name}`;++ await t.store.execute('UPDATE git_repos SET github_full_name = ?, mirror_url = ? WHERE id = ?', [full, `https://github.com/${full}.git`, row.id]);++ const head = await t.ctx.git.resolveCommit('openvibers', name, 'main');++ const fresh = await t.ctx.repos.getRow('openvibers', name);++ await t.ctx.ci.registerRepos([{ full_name: full, install_command: 'npm ci --no-audit --no-fund', test_command: 'npm test', timeout_seconds: 60 }]);++ return { row: fresh, head, full };++ };++ const push = (sha, pusher, full, extra = {}) => ({++ ref: 'refs/heads/main', after: sha, pusher: { name: pusher },++ repository: { full_name: full, fork: false, owner: { login: full.split('/')[0] } },++ ...extra,++ });++ const live = await make('live');++++ // The runner registers the repository; a session cannot, and neither can a wrong token.++ assert.equal((await t.get('/api/v1/ci/repos', { json: { repos: [{ full_name: 'openvibers/live' }] } })).status, 401);++ assert.equal((await t.get('/api/v1/ci/repos', { bearer: 'not-the-token', json: { repos: [{ full_name: 'openvibers/live' }] } })).status, 401);++ const reg = await t.get('/api/v1/ci/repos', {++ bearer: TOKEN,++ json: { repos: [{ full_name: 'openvibers/live', install_command: 'npm ci --no-audit --no-fund', test_command: 'npm test', timeout_seconds: 60 }] },++ });++ assert.equal(reg.status, 200, reg.text);++ assert.equal(reg.json().repositories[0].full_name, 'openvibers/live');++++ // The owner's account is a member without a GitHub call; the run is queued and the status pends.++ const member = await t.ctx.ci.onGithubPush(live.row, push(live.head, 'OpenVibers', live.full));++ assert.equal(member.run.state, 'queued', JSON.stringify(member));++ assert.equal(calls.length, 0, 'the owning account costs no GitHub call');++ let statuses = await t.get(`/api/v1/repos/openvibers/live/commits/${live.head}/statuses`);++ assert.deepStrictEqual(statuses.json().statuses.map((s) => [s.context, s.state]), [['forge/test', 'pending']]);++ assert.match(statuses.json().statuses[0].target_url, /\/openvibers\/live\/ci\/cir_/);++++ // A collaborator with write runs only after GitHub says so; the same answer is cached for ten minutes.++ const friend = await make('friend');++ const friendRun = await t.ctx.ci.onGithubPush(friend.row, push(friend.head, 'Friend', friend.full));++ assert.equal(friendRun.run.state, 'queued');++ assert.deepStrictEqual(calls, ['Friend']);++ assert.equal((await t.ctx.ci.onGithubPush(friend.row, push(friend.head, 'Friend', friend.full))).skipped, 'duplicate');++ assert.deepStrictEqual(calls, ['Friend'], 'the second check is cached');++++ // A non-member, a fork, a tag and a pull request never run.++ const stranger = await make('stranger');++ const denied = await t.ctx.ci.onGithubPush(stranger.row, push(stranger.head, 'Mallory', stranger.full));++ assert.equal(denied.skipped, 'not-member');++ const forked = await t.ctx.ci.onGithubPush(stranger.row, push(stranger.head, 'Mallory', stranger.full, { repository: { full_name: stranger.full, fork: true, owner: { login: 'OpenVibers' } } }));++ assert.equal(forked.skipped, 'fork');++ assert.equal((await t.ctx.ci.onGithubPush(stranger.row, push(stranger.head, 'OpenVibers', stranger.full, { ref: 'refs/tags/v1' }))).skipped, 'not-a-branch');++ assert.equal((await t.ctx.ci.onGithubPush(stranger.row, push(stranger.head, 'OpenVibers', 'OpenVibers/SomewhereElse'))).skipped, 'not-this-repository');++ assert.equal((await t.ctx.ci.runsFor(stranger.row)).length, 0);++++ // Claim hands out the repository, the clone URL and the commands; a claim is one at a time.++ const claimed = await t.get('/api/v1/ci/runs/claim', { bearer: TOKEN, json: {} });++ assert.equal(claimed.status, 200, claimed.text);++ const job = claimed.json();++ assert.equal(job.repo.full_name, 'openvibers/live');++ assert.match(job.repo.clone_url, /\/openvibers\/live\.git$/);++ assert.equal(job.command.test, 'npm test');++ assert.equal(job.limits.timeout_seconds, 60);++ assert.equal((await t.get(`/api/v1/ci/runs/${job.run.id}/heartbeat`, { bearer: TOKEN })).json().state, 'running');++++ // Log chunks reach the run and its page; the verdict becomes the commit status.++ assert.equal((await t.get(`/api/v1/ci/runs/${job.run.id}/log`, { bearer: TOKEN, json: { chunk: 'installing…\n' } })).status, 200);++ assert.equal((await t.get(`/api/v1/ci/runs/${job.run.id}/log`, { bearer: TOKEN, json: { chunk: '47 tests passed\n' } })).status, 200);++ const done = await t.get(`/api/v1/ci/runs/${job.run.id}/status`, { bearer: TOKEN, json: { state: 'success', description: 'Forge runner passed in 9s', exit_code: 0, duration_ms: 9000 } });++ assert.equal(done.status, 200, done.text);++ assert.equal(done.json().run.state, 'success');++ statuses = await t.get(`/api/v1/repos/openvibers/live/commits/${live.head}/statuses`);++ assert.equal(statuses.json().state, 'success');++ const page = await t.get(`/openvibers/live/ci/${job.run.id}`);++ assert.match(page.text, /47 tests passed/);++ assert.match(page.text, /Forge runner passed in 9s/);++ const apiRun = await t.get(`/api/v1/repos/openvibers/live/ci/runs/${job.run.id}`);++ assert.equal(apiRun.json().log, 'installing…\n47 tests passed\n');++ assert.equal((await t.get(`/api/v1/ci/runs/${job.run.id}/status`, { bearer: TOKEN, json: { state: 'failure' } })).status, 409, 'a finished run cannot be changed');++++ // The collaborator's queued run is drained too, so the next queue is the supersede case alone.++ const friendClaim = (await t.get('/api/v1/ci/runs/claim', { bearer: TOKEN, json: {} })).json();++ assert.equal(friendClaim.run.id, friendRun.run.id);++ assert.equal((await t.get(`/api/v1/ci/runs/${friendClaim.run.id}/status`, { bearer: TOKEN, json: { state: 'success' } })).status, 200);++++ // The supersede rule: a newer push cancels the live one, and the runner's heartbeat says so.++ const live2 = await make('live2');++ await t.get('/api/v1/ci/repos', { bearer: TOKEN, json: { repos: [{ full_name: 'openvibers/live2', timeout_seconds: 60 }] } });++ const first = await t.ctx.ci.onGithubPush(live2.row, push(live2.head, 'OpenVibers', live2.full));++ const claimedFirst = (await t.get('/api/v1/ci/runs/claim', { bearer: TOKEN, json: {} })).json();++ assert.equal(claimedFirst.run.id, first.run.id);++ const nextHead = await t.ctx.git.commitFiles('openvibers', 'live2', {++ branch: 'main', base: 'main', message: 'A newer push', files: [{ path: 'new.txt', content: 'new\n' }],++ author: { name: 'OpenVibers', email: '[email protected]' },++ });++ const second = await t.ctx.ci.onGithubPush(live2.row, push(nextHead.sha, 'OpenVibers', live2.full));++ assert.equal(second.run.state, 'queued');++ assert.equal(second.run.sha, nextHead.sha);++ assert.equal((await t.get(`/api/v1/ci/runs/${claimedFirst.run.id}/heartbeat`, { bearer: TOKEN })).json().superseded, true);++ assert.equal((await t.get(`/api/v1/ci/runs/${claimedFirst.run.id}/status`, { bearer: TOKEN, json: { state: 'success' } })).status, 409);++ const claimedSecond = (await t.get('/api/v1/ci/runs/claim', { bearer: TOKEN, json: {} })).json();++ assert.equal(claimedSecond.run.id, second.run.id);++++ // A runner that disappeared: past its limit, the next sweep times it out and reports it red.++ await t.store.execute("UPDATE git_ci_runs SET started_at = now() - interval '3 minutes' WHERE id = ?", [claimedSecond.run.id]);++ await t.ctx.ci.reap();++ const reaped = await t.ctx.ci.runFor(live2.row, claimedSecond.run.id);++ assert.equal(reaped.state, 'timed_out');++ const reapedStatus = await t.get(`/api/v1/repos/openvibers/live2/commits/${nextHead.sha}/statuses`);++ assert.equal(reapedStatus.json().statuses[0].state, 'failure');++ assert.match(reapedStatus.json().statuses[0].description, /timed out after 1 minutes/);++++ // A push that lands on the forge itself: the network's own service token may run it, a person's token++ // may not (the forge knows an OpenVibe account, not the GitHub login the member rule is about).++ const pkt = (line) => `${(Buffer.byteLength(line) + 4).toString(16).padStart(4, '0')}${line}`;++ const pack = Buffer.from(`${pkt(`0000000000000000000000000000000000000000 ${stranger.head} refs/heads/main\u0000 report-status\n`)}0000PACK`);++ assert.deepStrictEqual(parsePushCommands(pack).map((c) => [c.new, c.ref]), [[stranger.head, 'refs/heads/main']]);++ const forgeRun = await t.ctx.ci.onForgePush(stranger.row, { actor_type: 'service', subject: 'svc:release', scopes: new Set(['git.repo.write']) }, { ref: 'refs/heads/main', sha: stranger.head });++ assert.equal(forgeRun.run.state, 'queued');++ assert.equal((await t.ctx.ci.onForgePush(live.row, { actor_type: 'user', subject: 'usr_x', scopes: new Set(['git.repo.write']) }, { ref: 'refs/heads/main', sha: live.head })).skipped, 'not-a-service');++++ // A manual run by the owner starts the head; a stranger cannot start one.++ const manual = await t.get('/api/v1/repos/openvibers/live/ci/runs', { as: owner, json: {} });++ assert.equal(manual.status, 201, manual.text);++ assert.equal(manual.json().run.source, 'manual');++ assert.equal((await t.get('/api/v1/repos/openvibers/live/ci/runs', { as: t.asUser('mallory'), json: {} })).status, 403);++ assert.ok(!t.logs().join('\n').includes(TOKEN), 'the runner token is never logged');++++ console.log('ci: member, non-member, fork, supersede, claim, log, status intake and timeout passed');++ } finally { await t.close(); }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -40,6 +40,18 @@ GIT_MAX_REPO_BYTES=3221225472
@@ -40,6 +40,18 @@ GIT_MAX_REPO_BYTES=3221225472
GIT_MAX_FILE_BYTES=2097152
GIT_MAX_FILE_BYTES=2097152
GIT_MAX_ARCHIVE_BYTES=268435456
GIT_MAX_ARCHIVE_BYTES=268435456
+# Forge CI (docs/ci.md): the workstation runner's queue. A run is made only for a branch push to an official
+# repository by a GitHub login that may push there (the owning account, GIT_CI_MEMBERS, or a collaborator).
+# The runner token is never here: this service reads only its scrypt hash from deploy/ci/runner-token.
+GIT_CI_ENABLED=true
+GIT_CI_ORG=openvibers
+GIT_CI_CONTEXT=forge/test
+GIT_CI_MEMBERS=
+GIT_CI_MEMBER_CACHE_MS=600000
+GIT_CI_REAP_MS=60000
+GIT_CI_RUNNER_TOKEN=
+GIT_CI_RUNNER_TOKEN_FILE=
+
# Mirrors: the ten-minute poll and how long one fetch may take.
# Mirrors: the ten-minute poll and how long one fetch may take.
GIT_MIRROR_POLL_MS=600000
GIT_MIRROR_POLL_MS=600000
GIT_MIRROR_TIMEOUT_MS=600000
GIT_MIRROR_TIMEOUT_MS=600000
@@ -74,6 +74,11 @@ mergeable only when its required contexts are green — a per-repository list, e
@@ -74,6 +74,11 @@ mergeable only when its required contexts are green — a per-repository list, e
for the open pull-request heads and the default branch (at most every two minutes per repository, and only
for the open pull-request heads and the default branch (at most every two minutes per repository, and only
when a head moved), so the pages show the truth until our own runners report.
when a head moved), so the pages show the truth until our own runners report.
+**Forge CI** runs a repository's tests on the workstation, in a sandbox, for branch pushes to official repositories
+by OpenVibers members only; the verdict is the `forge/test` status and the log is at `/<owner>/<name>/ci/<run>`.
+Forks, pull requests and non-members keep GitHub Actions. What runs, how it is contained and how to add a repository:
+[docs/ci.md](docs/ci.md).
+
## Layout
## Layout
- `server/git/` — repositories on disk: creation, refs, trees, blobs, commits, diffs, bunches, and `http-backend`.
- `server/git/` — repositories on disk: creation, refs, trees, blobs, commits, diffs, bunches, and `http-backend`.
@@ -82,6 +87,9 @@ when a head moved), so the pages show the truth until our own runners report.
@@ -82,6 +87,9 @@ when a head moved), so the pages show the truth until our own runners report.
- `server/official.js` — the due-based seed of the official mirrors from a GitHub organisation or the contracts.
- `server/official.js` — the due-based seed of the official mirrors from a GitHub organisation or the contracts.
- `server/contracts.js` — the repository list named by openvibe-contracts when GitHub's listing is refused.
- `server/contracts.js` — the repository list named by openvibe-contracts when GitHub's listing is refused.
- `server/insights.js` — views, unique visitors, clones, stars and commits per week.
- `server/insights.js` — views, unique visitors, clones, stars and commits per week.
+- `server/ci.js`, `server/ci-auth.js` — the forge CI queue, the member rule, supersede and the `forge/test` status.
+- `runner/` — the workstation runner: poll, clone, sandbox phases, allowlisted install proxy, log and verdict.
+- `deploy/ci/`, `deploy/systemd-user/` — the runner's install script, example config and user unit.
- `contracts` — `git.repo@1`, `git.commit@1`, `git.tree@1`, `git.insights@1` (OpenVibe.Contracts).
- `contracts` — `git.repo@1`, `git.commit@1`, `git.tree@1`, `git.insights@1` (OpenVibe.Contracts).
- `deploy/` — the systemd unit and the nginx vhost for `git.openvibe.codes`.
- `deploy/` — the systemd unit and the nginx vhost for `git.openvibe.codes`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+{
+ "_about": "The forge CI runner on the workstation. This file is not a secret; it names the forge, the token file, the sandbox engine and one entry per repository. deploy/ci/install.sh copies it to ~/.config/openvibe/git-ci.json when none is there.",
+ "api": "https://git.openvibe.codes",
+ "tokenFile": "~/.config/openvibe/git-ci.token",
+ "workRoot": "~/.local/share/openvibe-git-ci/work",
+ "engine": "auto",
+ "concurrency": 1,
+ "pollMs": 5000,
+ "heartbeatMs": 15000,
+ "proxyAllow": ["registry.npmjs.org", "codeload.github.com"],
+ "repos": {
+ "openvibers/openvibe.shared": {
+ "install_command": "npm ci --no-audit --no-fund",
+ "test_command": "npm test",
+ "timeout_seconds": 1800
+ },
+ "openvibers/openvibe.games": {
+ "install_command": "corepack enable --install-directory /workspace/.pnpm/bin && pnpm install --frozen-lockfile",
+ "test_command": "pnpm typecheck && pnpm lint && pnpm test",
+ "timeout_seconds": 1800
+ },
+ "openvibers/openvibe.network": {
+ "install_command": "npm ci --no-audit --no-fund",
+ "test_command": "npm test",
+ "timeout_seconds": 1800
+ },
+ "openvibers/openvibe.chat": {
+ "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",
+ "test_command": "npm test",
+ "timeout_seconds": 1800
+ }
+ }
+}
new file mode 100755
new file mode 100755
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+#!/bin/sh
+# Install the OpenVibe.Git forge CI runner on the workstation as a systemd *user* unit (the portal's pattern:
+# no root, the owner's user manager keeps it alive). Run from a checkout of OpenVibe.Git:
+#
+# deploy/ci/install.sh # install, pull the image, do not start
+# deploy/ci/install.sh --start # ... and enable --now
+#
+# It never writes a secret into the repository: the token stays in ~/.config/openvibe/git-ci.token (0600) and the
+# service holds only its scrypt hash (deploy/ci/runner-token). A new token prints the hash to put in that file.
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+CHECKOUT=${GIT_CI_CHECKOUT:-$HOME/openvibe/git-ci}
+CONFIG_DIR=$HOME/.config/openvibe
+CONFIG=$CONFIG_DIR/git-ci.json
+TOKEN=$CONFIG_DIR/git-ci.token
+UNIT_DIR=$HOME/.config/systemd/user
+STORAGE_ROOT=$HOME/.local/share/openvibe-git-ci/containers/storage
+RUN_ROOT=/run/user/$(id -u)/ovci
+PODMAN="podman --root $STORAGE_ROOT --runroot $RUN_ROOT"
+START=0
+for arg in "$@"; do
+ case "$arg" in
+ --start) START=1 ;;
+ *) echo "usage: $0 [--start]" >&2; exit 2 ;;
+ esac
+done
+
+say() { printf '[git-ci-install] %s\n' "$*"; }
+
+# 1. A sandbox engine. Rootless podman is preferred; bubblewrap is the fallback (runner/sandbox.js).
+if podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null | grep -q true; then
+ say "sandbox engine: rootless podman $(podman --version | awk '{print $3}')"
+ mkdir -p "$STORAGE_ROOT/libpod" "$STORAGE_ROOT/volumes" "$RUN_ROOT" "$CONFIG_DIR"
+ # The workstation's shared podman store lives on a disk with no inodes left. The runner gets its own store
+ # under ~/.local/share (the home filesystem) and its own containers.conf so libpod's database agrees.
+ if [ ! -f "$CONFIG_DIR/containers.conf" ]; then
+ cat > "$CONFIG_DIR/containers.conf" <<EOF
+[engine]
+static_dir = "$STORAGE_ROOT/libpod"
+volume_path = "$STORAGE_ROOT/volumes"
+EOF
+ fi
+ if [ ! -f "$CONFIG_DIR/containers-storage.conf" ]; then
+ cat > "$CONFIG_DIR/containers-storage.conf" <<EOF
+[storage]
+driver = "overlay"
+graphroot = "$STORAGE_ROOT"
+runroot = "$RUN_ROOT"
+
+[storage.options]
+mount_program = "/usr/bin/fuse-overlayfs"
+EOF
+ fi
+ export CONTAINERS_CONF=$CONFIG_DIR/containers.conf
+ export CONTAINERS_STORAGE_CONF=$CONFIG_DIR/containers-storage.conf
+ if ! $PODMAN image exists docker.io/library/node:22-bookworm; then
+ say "pulling node:22-bookworm into $STORAGE_ROOT (the run image)"
+ $PODMAN pull docker.io/library/node:22-bookworm
+ 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
+ echo "no sandbox engine: install rootless podman or bubblewrap" >&2
+ exit 1
+fi
+
+# 2. A checkout the unit runs from. It is a clone of the forge, so an update is a fast-forward.
+if [ -d "$CHECKOUT/.git" ]; then
+ say "updating $CHECKOUT"
+ git -C "$CHECKOUT" fetch --quiet origin main
+ git -C "$CHECKOUT" merge --ff-only --quiet origin/main
+else
+ say "cloning https://git.openvibe.codes/openvibers/openvibe.git into $CHECKOUT"
+ git clone --quiet --depth 1 https://git.openvibe.codes/openvibers/openvibe.git "$CHECKOUT"
+fi
+
+# 3. The config file (no secret) and the token (0600, never in the repository).
+mkdir -p "$CONFIG_DIR"
+if [ ! -f "$CONFIG" ]; then
+ install -m 0644 "$CHECKOUT/deploy/ci/git-ci.example.json" "$CONFIG"
+ say "wrote $CONFIG from the example (repos and commands are edited there)"
+else
+ say "keeping $CONFIG"
+fi
+if [ ! -s "$TOKEN" ]; then
+ say "making a runner token in $TOKEN"
+ hash=$(/usr/bin/node "$CHECKOUT/runner/token.js" --out "$TOKEN" | tail -1)
+ say "write this line to deploy/ci/runner-token, commit it, deploy, then restart:"
+ printf '%s\n' "$hash"
+fi
+chmod 0600 "$TOKEN" 2>/dev/null || true
+
+# 4. The user unit. It names the default checkout; an explicit GIT_CI_CHECKOUT is written into it.
+mkdir -p "$UNIT_DIR"
+if [ "$CHECKOUT" != "$HOME/openvibe/git-ci" ]; then
+ sed "s#%h/openvibe/git-ci#$CHECKOUT#g" "$CHECKOUT/deploy/systemd-user/openvibe-git-ci.service" > "$UNIT_DIR/openvibe-git-ci.service"
+else
+ install -m 0644 "$CHECKOUT/deploy/systemd-user/openvibe-git-ci.service" "$UNIT_DIR/openvibe-git-ci.service"
+fi
+systemctl --user daemon-reload
+say "installed $UNIT_DIR/openvibe-git-ci.service"
+
+if [ "$START" = 1 ]; then
+ systemctl --user enable --now openvibe-git-ci.service
+ say "started; follow it with: journalctl --user -u openvibe-git-ci -f"
+else
+ say "not started (pass --start, or: systemctl --user enable --now openvibe-git-ci)"
+fi
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+scrypt$27f76d9288b2c473023c06ad8c7740eb$3f21ac5143cd95004fe924546b00d7bf937c58b6005b05d7846653fc57bc32dd
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+[Unit]
+Description=OpenVibe.Git forge CI runner (workstation; rootless podman or bubblewrap sandboxes)
+Documentation=https://git.openvibe.codes/openvibers/openvibe
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+WorkingDirectory=%h/openvibe/git-ci
+Environment=NODE_ENV=production
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+Environment=GIT_CI_CONFIG=%h/.config/openvibe/git-ci.json
+# The runner has its own podman store under ~/.local/share (the shared store is on the inode-full offload disk).
+Environment=CONTAINERS_CONF=%h/.config/openvibe/containers.conf
+Environment=CONTAINERS_STORAGE_CONF=%h/.config/openvibe/containers-storage.conf
+ExecStart=/usr/bin/node %h/openvibe/git-ci/runner/index.js
+Restart=always
+RestartSec=10
+KillMode=control-group
+KillSignal=SIGTERM
+# The current run is killed on SIGTERM; a podman cleanup may take a few seconds after it.
+TimeoutStopSec=120
+LimitNOFILE=65536
+
+[Install]
+WantedBy=default.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
+# Forge CI
+
+`git.openvibe.codes` runs the tests for a push to an OpenVibers repository on the workstation, in a sandbox, and
+reports the verdict on the commit. GitHub Actions stays exactly as it is: this is the first-party path that runs
+beside it, not a replacement yet. Nothing in this document turns a GitHub workflow off.
+
+## What runs, and when
+
+A run is made only when all of these are true:
+
+- the push is to a **branch** (`refs/heads/…`, never a tag or a deletion);
+- the repository is an **official** OpenVibers mirror on the forge (its owner is in `GIT_OFFICIAL_OWNERS`, by
+ default `openvibers`, and it mirrors `OpenVibers/<name>` on GitHub);
+- the **push event's pusher** is the owning GitHub account, a login in `GIT_CI_MEMBERS`, or a collaborator GitHub
+ says has `admin`, `maintain` or `write` on that repository. GitHub's answer is cached for 10 minutes;
+- the repository has a CI entry (the runner registers the repositories it is configured for, see below).
+
+It never runs for a pull request, a pull request from a fork, a push to a fork, a tag, a bot, or a non-member.
+A repository with no CI entry is not tested here. The checks intake from GitHub (`server/checks-intake.js`) keeps
+copying GitHub's check-runs, so a change can show both `test` (GitHub) and `forge/test` (here).
+
+**One run per repository is live.** A newer branch push supersedes the queued or running one: the older run becomes
+`superseded`, its runner sees that on the next heartbeat (every 15 s), kills its sandbox and reports no verdict.
+The newer run then starts. The repository's `forge/test` status is pending from enqueue, and success, failure or
+error when the runner reports; a timed-out run is a failure.
+
+## What a run does
+
+1. The runner claims one queued run from `POST /api/v1/ci/runs/claim` (its own token).
+2. It clones **the pushed commit** from the forge (`https://git.openvibe.codes/<owner>/<name>.git`), retrying
+ 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.
+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
+ `forge/test` commit status. The log is on `/<owner>/<name>/ci/<run>` and its JSON is
+ `GET /api/v1/repos/<owner>/<name>/ci/runs/<id>`.
+
+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`).
+
+## How it is contained
+
+| | |
+|---|---|
+| engine | rootless **podman** if `podman info` says rootless, else **bubblewrap** (the workstation has both; podman is used) |
+| identity | a new user namespace; container root is not host root. The podman user namespace keeps the host uid (`--userns=keep-id`); bubblewrap uses uid/gid 1000 inside its own namespace |
+| filesystem | read-only root (`--read-only`), a tmpfs `/tmp`, and **one** host path: the per-run work directory bind-mounted at `/workspace`. No home, no `/etc/openvibe`, no git checkout of the runner, no Docker socket, no SSH agent |
+| secrets | none. The runner's own token is not mounted; no repository secret is passed; the container environment is an allowlist (HOME, PATH, locale, npm cache inside the workspace) |
+| network | install: loopback only, with the proxy on a Unix socket (the sandbox has no route anywhere else). test: no network namespace route at all |
+| limits | `--cpus 4`, `--memory 6144m` (swap the same), `--pids-limit 2048`, 30 minutes wall clock (configurable 30 s–1 h). A run past it is killed and reported `timed_out`; a runner that disappears is reaped by the service a minute after its limit |
+| capabilities | all dropped, `no-new-privileges`, a new session; bubblewrap also disables nested user namespaces |
+| concurrency | one run per repository (the forge's rule), and one at a time on this runner by default (`concurrency`) |
+
+**What is not contained, and why.** A container is not a virtual machine: a kernel or runtime escape is still a
+host compromise, and the workstation is the busiest machine in the estate (it also runs the portal and agent jobs).
+The design reduces what an escape reaches (no secrets, no home, no socket, no host network in the test phase), but
+it does not make the host disposable. The repositories are public and accept forks, which is why forks and pull
+requests never reach this runner; the trigger is a same-repository branch push by a person GitHub says may push.
+The full `test:pg` path (PostgreSQL + PgBouncer + Valkey containers) is not run here — there is no Docker socket
+inside the sandbox, so the runner runs the repository's `npm test` (PGlite). For those suites GitHub Actions remains
+the gate. If the machine is ever dedicated to CI, a disposable VM per run is the next step (see
+`research-2026-11-10/forge-first.md` §4).
+
+## Adding a repository
+
+1. It must already be an official mirror on the forge (the official mirrors seed creates one for every public
+ `OpenVibers` repository). Check: `GET https://git.openvibe.codes/api/v1/repos/openvibers/<name>`.
+2. Add an entry to the runner's `repos` in `~/.config/openvibe/git-ci.json`:
+
+ ```json
+ "openvibers/openvibe.example": {
+ "install_command": "npm ci --no-audit --no-fund",
+ "test_command": "npm test",
+ "timeout_seconds": 1800
+ }
+ ```
+
+ Optional: `image`, `cpus` (1–8), `memory_mb` (512–12288). Only official repositories are accepted.
+3. Restart the runner: `systemctl --user restart openvibe-git-ci`. On start it registers the entry through
+ `POST /api/v1/ci/repos`; the forge now queues its member pushes.
+4. Push to a branch, or start one by hand as the owner:
+ `POST /api/v1/repos/openvibers/<name>/ci/runs` with `{"branch":"main"}` (a signed-in owner, or the runner token).
+ The run appears at `https://git.openvibe.codes/openvibers/<name>/ci`.
+
+Removing an entry stops new runs; it does not delete past runs or their statuses.
+
+## Running it on the workstation
+
+```sh
+# from a checkout of OpenVibe.Git, as the workstation user (no sudo)
+deploy/ci/install.sh --start
+systemctl --user status openvibe-git-ci
+journalctl --user -u openvibe-git-ci -f
+```
+
+`install.sh` checks the sandbox engine, pulls `node:22-bookworm`, keeps a checkout at `~/openvibe/git-ci`,
+copies `deploy/ci/git-ci.example.json` to `~/.config/openvibe/git-ci.json` if there is none, installs
+`deploy/systemd-user/openvibe-git-ci.service` into the user manager and (with `--start`) enables it.
+
+The workstation's shared podman store is on the offload disk (`/mnt/120gb`), which ran out of **inodes**, not bytes.
+The runner therefore gets its own store under `~/.local/share/openvibe-git-ci/containers/storage` on the home
+filesystem, passed to every podman call with `--root`/`--runroot` and with its own `containers.conf` and
+`containers-storage.conf` in `~/.config/openvibe` (libpod's database names the same graph root). `install.sh` writes
+both files when they are absent and pulls the image into that store. Nothing global is changed.
+
+The token is made once, on the workstation:
+
+```sh
+node runner/token.js --out ~/.config/openvibe/git-ci.token # prints the scrypt line, not the token
+# put that line in deploy/ci/runner-token, commit, deploy, restart the runner
+```
+
+The service holds only the scrypt hash of the token (`deploy/ci/runner-token`, or `GIT_CI_RUNNER_TOKEN` in
+`/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.
+
+## The service side
+
+- `migrations/0008_ci_runs.sql` — `git_ci_repos` (enabled, commands, image, limits) and `git_ci_runs` (state, log,
+ timings), with a partial unique index that keeps one live run per repository.
+- `server/ci.js` — the member rule, enqueue and supersede, claim/heartbeat/log/finish, the reaper and the
+ `forge/test` status.
+- `server/ci-auth.js` — the runner token's scrypt hash and the constant-time check.
+- `server/http/api.js` — `/api/v1/ci/*` (runner token) and `/api/v1/repos/:owner/:name/ci*` (public reads, owner
+ manual runs).
+- `server/views/ci.js`, `server/http/pages.js` — the CI tab, the run list and the log page.
+- `runner/` — `index.js` (poll), `run.js` (clone, phases, verdict), `sandbox.js` (podman/bubblewrap),
+ `proxy.js` (the install allowlist), `process.js` (the wall clock), `config.js`, `api.js`, `token.js`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+-- phase: expand
+-- Forge CI: which repositories the workstation runner tests, and one row per run it did.
+--
+-- A repository is enabled with a command pair (install, with the network proxy; test, with no network at all),
+-- an image and a time limit. The runner registers them over its own token. One run per repository is live at a
+-- time: a newer push supersedes the queued or running one (the runner sees it on its heartbeat and stops).
+-- The log is bounded; the run's verdict is copied into git_commit_statuses as "forge/test".
+
+CREATE TABLE git_ci_repos (
+ repo_id text PRIMARY KEY REFERENCES git_repos (id) ON DELETE CASCADE,
+ enabled boolean NOT NULL DEFAULT true,
+ install_command text NOT NULL DEFAULT 'npm ci --no-audit --no-fund',
+ test_command text NOT NULL DEFAULT 'npm test',
+ image text NOT NULL DEFAULT 'docker.io/library/node:22-bookworm',
+ timeout_seconds integer NOT NULL DEFAULT 1800,
+ cpus integer NOT NULL DEFAULT 4,
+ memory_mb integer NOT NULL DEFAULT 6144,
+ registered_by text,
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CHECK (timeout_seconds BETWEEN 30 AND 3600),
+ CHECK (cpus BETWEEN 1 AND 8),
+ CHECK (memory_mb BETWEEN 512 AND 12288),
+ CHECK (char_length(install_command) BETWEEN 1 AND 2000),
+ CHECK (char_length(test_command) BETWEEN 1 AND 2000),
+ CHECK (char_length(image) BETWEEN 1 AND 200)
+);
+
+CREATE TABLE git_ci_runs (
+ id text PRIMARY KEY, -- cir_<ulid>
+ repo_id text NOT NULL REFERENCES git_repos (id) ON DELETE CASCADE,
+ sha text NOT NULL,
+ branch text NOT NULL,
+ pusher text, -- the push event's pusher, or "runner" for a manual run
+ source text NOT NULL DEFAULT 'github', -- github | forge | manual
+ state text NOT NULL CHECK (state IN ('queued', 'running', 'success', 'failure', 'error', 'timed_out', 'superseded')),
+ description text NOT NULL DEFAULT '',
+ exit_code integer,
+ log text NOT NULL DEFAULT '',
+ log_bytes integer NOT NULL DEFAULT 0,
+ runner text,
+ started_at timestamptz,
+ finished_at timestamptz,
+ duration_ms integer,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CHECK (sha ~ '^[0-9a-f]{40}([0-9a-f]{24})?$'),
+ CHECK (char_length(branch) BETWEEN 1 AND 255),
+ CHECK (char_length(description) <= 1024),
+ CHECK (char_length(log) <= 4194304)
+);
+
+CREATE INDEX git_ci_runs_repo_created ON git_ci_runs (repo_id, created_at DESC);
+CREATE INDEX git_ci_runs_state_created ON git_ci_runs (state, created_at);
+-- At most one queued or running run per repository; a new push supersedes the old one before this is inserted.
+CREATE UNIQUE INDEX git_ci_runs_one_live ON git_ci_runs (repo_id) WHERE state IN ('queued', 'running');
@@ -519,3 +519,17 @@
@@ -519,3 +519,17 @@
.git-review .git-diff-line { grid-template-columns: 2.6em 2.6em minmax(0, 1fr); }
.git-review .git-diff-line { grid-template-columns: 2.6em 2.6em minmax(0, 1fr); }
.git-inline { margin-left: 12px; }
.git-inline { margin-left: 12px; }
}
}
+
+/* Forge CI: one row per run, and the run's log. Plain by design; the kit carries the rest. */
+.git-ci-list { list-style: none; margin: 0 0 18px; padding: 0; }
+.git-ci-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; align-items: baseline; padding: 12px 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card); margin-top: 8px; }
+.git-ci-row .git-check { grid-row: span 2; align-self: center; }
+.git-ci-main { display: inline-flex; gap: 8px; align-items: baseline; min-width: 0; overflow-wrap: anywhere; }
+.git-ci-main code { color: var(--text-muted); font-size: .82rem; }
+.git-ci-facts { list-style: none; margin: 0 0 14px; padding: 0; display: grid; gap: 6px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
+.git-ci-facts li { display: grid; gap: 2px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-card); min-width: 0; overflow-wrap: anywhere; }
+.git-ci-facts span { color: var(--text-muted); font-size: .78rem; }
+.git-ci-log { max-height: 70vh; overflow: auto; }
+.git-check--success { color: var(--git-add, #3a9); }
+.git-check--failure, .git-check--error, .git-check--timed_out { color: var(--git-del, #c55); }
+.git-check--running, .git-check--queued { color: var(--text-muted); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+
+/**
+ * The runner's HTTP client. One bearer token in the Authorization header, never in a URL, a log or an error.
+ * A claim answered 204 is "nothing queued"; a verdict answered 409 is "that run was superseded", not a failure.
+ */
+const os = require('node:os');
+
+function createApi({ baseUrl, token, log = console }) {
+ async function request(pathname, { method = 'GET', body = null, timeoutMs = 20_000 } = {}) {
+ const res = await fetch(`${baseUrl}${pathname}`, {
+ method,
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'user-agent': `openvibe-git-ci/${os.hostname()}` },
+ body: body == null ? undefined : JSON.stringify(body),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ const text = await res.text();
+ if (!res.ok) {
+ const err = new Error(`the forge answered ${res.status} for ${pathname}`);
+ err.status = res.status;
+ err.body = text.slice(0, 500);
+ throw err;
+ }
+ return text ? JSON.parse(text) : null;
+ }
+
+ return {
+ request,
+ register: (repos) => request('/api/v1/ci/repos', { method: 'POST', body: { repos } }),
+ claim: () => request('/api/v1/ci/runs/claim', { method: 'POST', body: { runner: os.hostname() || 'forge-runner' } }),
+ heartbeat: (id) => request(`/api/v1/ci/runs/${encodeURIComponent(id)}/heartbeat`),
+ log: (id, chunk) => request(`/api/v1/ci/runs/${encodeURIComponent(id)}/log`, { method: 'POST', body: { chunk }, timeoutMs: 30_000 }),
+ finish: async (id, verdict) => {
+ try {
+ return await request(`/api/v1/ci/runs/${encodeURIComponent(id)}/status`, { method: 'POST', body: verdict, timeoutMs: 30_000 });
+ } catch (err) {
+ if (err.status === 409) return null;
+ throw err;
+ }
+ },
+ };
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+'use strict';
+
+/**
+ * The forge CI runner's configuration. It lives on the workstation (default ~/.config/openvibe/git-ci.json),
+ * never in the repository: it names the API, the token file, the engine, and one entry per repository.
+ *
+ * {
+ * "api": "https://git.openvibe.codes",
+ * "tokenFile": "~/.config/openvibe/git-ci.token",
+ * "workRoot": "~/.cache/openvibe-git-ci",
+ * "engine": "auto", // auto | podman | bwrap
+ * "concurrency": 1,
+ * "pollMs": 5000,
+ * "proxyAllow": ["registry.npmjs.org", "codeload.github.com"],
+ * "repos": {
+ * "openvibers/openvibe.shared": {
+ * "install_command": "npm ci --no-audit --no-fund",
+ * "test_command": "npm test",
+ * "timeout_seconds": 1800
+ * }
+ * }
+ * }
+ *
+ * The token file holds the raw runner token (0600); the service holds only its scrypt hash in
+ * deploy/ci/runner-token. The config holds no secret.
+ */
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+
+const HOME = os.homedir();
+const expand = (value) => String(value || '').replace(/^~(?=$|\/)/, HOME);
+const int = (value, fallback, min, max) => {
+ const n = parseInt(value, 10);
+ return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : fallback;
+};
+
+function loadConfig(env = process.env) {
+ const file = expand(env.GIT_CI_CONFIG || path.join(HOME, '.config', 'openvibe', 'git-ci.json'));
+ let raw = {};
+ try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) {
+ if (env.GIT_CI_CONFIG) throw new Error(`the runner configuration ${file} cannot be read: ${err.message}`);
+ }
+ const tokenFile = expand(raw.tokenFile || env.GIT_CI_TOKEN_FILE || path.join(HOME, '.config', 'openvibe', 'git-ci.token'));
+ let token = String(env.GIT_CI_RUNNER_TOKEN || '').trim();
+ if (!token) {
+ try { token = fs.readFileSync(tokenFile, 'utf8').trim(); } catch { /* reported below */ }
+ }
+ const cfg = {
+ file,
+ api: String(raw.api || env.GIT_CI_API || 'https://git.openvibe.codes').replace(/\/+$/, ''),
+ token,
+ tokenFile,
+ workRoot: expand(raw.workRoot || path.join(HOME, '.local', 'share', 'openvibe-git-ci', 'work')),
+ stateFile: expand(raw.stateFile || path.join(HOME, '.local', 'share', 'openvibe-git-ci', 'runner.json')),
+ // The workstation's shared podman store is on a disk with no inodes left; the runner's own store is on
+ // the home filesystem. Both are passed to every podman call, never changed globally.
+ podmanRoot: expand(raw.podmanRoot || path.join(HOME, '.local', 'share', 'openvibe-git-ci', 'containers', 'storage')),
+ podmanRunRoot: expand(raw.podmanRunRoot || `/run/user/${process.getuid()}/ovci`),
+ podmanConf: expand(raw.podmanConf || path.join(HOME, '.config', 'openvibe', 'containers.conf')),
+ podmanStorageConf: expand(raw.podmanStorageConf || path.join(HOME, '.config', 'openvibe', 'containers-storage.conf')),
+ engine: ['auto', 'podman', 'bwrap'].includes(raw.engine) ? raw.engine : 'auto',
+ image: String(raw.image || 'docker.io/library/node:22-bookworm'),
+ concurrency: int(raw.concurrency, 1, 1, 4),
+ pollMs: int(raw.pollMs, 5000, 1000, 60_000),
+ heartbeatMs: int(raw.heartbeatMs, 15_000, 2000, 60_000),
+ proxyAllow: Array.isArray(raw.proxyAllow) && raw.proxyAllow.length
+ ? raw.proxyAllow.map((h) => String(h).toLowerCase()).filter(Boolean)
+ : ['registry.npmjs.org', 'codeload.github.com'],
+ repos: raw.repos && typeof raw.repos === 'object' ? raw.repos : {},
+ };
+ cfg.repos = Object.fromEntries(Object.entries(cfg.repos).map(([full, item]) => [
+ String(full).toLowerCase(),
+ {
+ install_command: String((item && item.install_command) || 'npm ci --no-audit --no-fund'),
+ test_command: String((item && item.test_command) || 'npm test'),
+ timeout_seconds: int(item && item.timeout_seconds, 1800, 30, 3600),
+ image: String((item && item.image) || cfg.image),
+ cpus: int(item && item.cpus, 4, 1, 8),
+ memory_mb: int(item && item.memory_mb, 6144, 512, 12288),
+ },
+ ]));
+ if (!cfg.token) throw new Error(`no runner token: write it to ${tokenFile} (0600) or set GIT_CI_RUNNER_TOKEN`);
+ if (!cfg.api.startsWith('https://') && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(cfg.api)) {
+ throw new Error('the API must be https (or loopback http for a test)');
+ }
+ return cfg;
+}
+
+/** What the runner registers with the forge: one object per configured repository. */
+function registrations(cfg) {
+ return Object.entries(cfg.repos).map(([full_name, item]) => ({ full_name, ...item }));
+}
+
+module.exports = { loadConfig, registrations, expand };
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+
+/**
+ * The forge CI runner (workstation). It polls OpenVibe.Git for the next queued run, executes it in the sandbox
+ * (runner/run.js) and reports the log and the verdict. One run at a time by default; the forge decides what is
+ * queued and supersedes, so the runner never chooses or trusts a repository itself.
+ *
+ * node runner/index.js
+ * GIT_CI_CONFIG=... GIT_CI_RUNNER_TOKEN=... node runner/index.js
+ */
+const fs = require('node:fs');
+const os = require('node:os');
+const { loadConfig, registrations } = require('./config');
+const { createApi } = require('./api');
+const { createRunner } = require('./run');
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function main(env = process.env) {
+ const cfg = loadConfig(env);
+ fs.mkdirSync(cfg.workRoot, { recursive: true });
+ const api = createApi({ baseUrl: cfg.api, token: cfg.token });
+ const runner = createRunner({ cfg, api });
+ const repos = registrations(cfg);
+ let stopping = false;
+ const onSignal = (signal) => {
+ process.stdout.write(`[git-ci-runner] ${signal}: stopping after the current run\n`);
+ stopping = true;
+ runner.stop();
+ };
+ process.on('SIGTERM', () => onSignal('SIGTERM'));
+ process.on('SIGINT', () => onSignal('SIGINT'));
+
+ // Register the repositories this runner is configured for. The forge refuses anything not official.
+ for (let i = 0; !stopping; i += 1) {
+ try {
+ const out = await api.register(repos);
+ process.stdout.write(`[git-ci-runner] registered ${out.repositories.length} repositories with ${cfg.api}\n`);
+ break;
+ } catch (err) {
+ const seconds = Math.min(60, 5 * (i + 1));
+ process.stdout.write(`[git-ci-runner] registration failed (${err.message}); retrying in ${seconds}s\n`);
+ await wait(seconds * 1000);
+ }
+ }
+ if (!stopping && !repos.length) process.stdout.write('[git-ci-runner] no repositories configured; only claiming nothing\n');
+
+ let lastEmpty = 0;
+ while (!stopping) {
+ try {
+ const job = await api.claim();
+ if (!job) {
+ if (Date.now() - lastEmpty > 60_000) {
+ process.stdout.write(`[git-ci-runner] idle on ${os.hostname()} (${cfg.api})\n`);
+ lastEmpty = Date.now();
+ }
+ await wait(cfg.pollMs);
+ continue;
+ }
+ lastEmpty = Date.now();
+ const out = await runner.runJob(job);
+ process.stdout.write(`[git-ci-runner] ${job.run.id} ${out.state} in ${Math.round(out.duration / 1000)}s\n`);
+ } catch (err) {
+ process.stdout.write(`[git-ci-runner] loop: ${err.message}\n`);
+ await wait(Math.max(cfg.pollMs, 5000));
+ }
+ }
+ process.stdout.write('[git-ci-runner] stopped\n');
+}
+
+if (require.main === module) {
+ main().catch((err) => {
+ process.stderr.write(`[git-ci-runner] failed: ${err.message}\n`);
+ process.exit(1);
+ });
+}
+
+module.exports = { main };
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+
+/**
+ * One phase of a run as a child process with a wall-clock limit. On the limit it calls onKill (the container or
+ * the scope is removed), kills the child's process group and resolves `timedOut: true`; the run then reports it.
+ * It never buffers the whole output: onData gets every chunk and only a short tail is kept for the error line.
+ */
+const { spawn } = require('node:child_process');
+
+function runProcess({ command, args = [], env = {}, cwd = null, timeoutMs = 1800_000, onData = () => {}, onKill = null, log = console }) {
+ const started = Date.now();
+ return new Promise((resolve) => {
+ let child;
+ try {
+ child = spawn(command, args, {
+ cwd,
+ env,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ detached: process.platform !== 'win32',
+ windowsHide: true,
+ });
+ } catch (err) {
+ resolve({ code: null, signal: null, timedOut: false, durationMs: 0, error: String(err.message) });
+ return;
+ }
+ let settled = false;
+ let timedOut = false;
+ let tail = '';
+ let fallback = null;
+ const keep = (chunk) => {
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
+ tail = (tail + text).slice(-8192);
+ try { onData(chunk); } catch (err) { log.warn('[git-ci-runner] log sink:', err.message); }
+ };
+ const kill = () => {
+ if (onKill) { try { Promise.resolve(onKill()).catch(() => {}); } catch { /* best effort */ } }
+ try {
+ if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL');
+ else child.kill('SIGKILL');
+ } catch { try { child.kill('SIGKILL'); } catch { /* gone */ } }
+ };
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ if (fallback) clearTimeout(fallback);
+ resolve({ ...value, durationMs: Date.now() - started, timedOut, tail });
+ };
+ const timer = setTimeout(() => {
+ timedOut = true;
+ log.warn(`[git-ci-runner] ${command} reached its ${Math.round(timeoutMs / 1000)}s limit; killing it`);
+ kill();
+ // A container client that does not die would hold the queue: resolve anyway.
+ fallback = setTimeout(() => finish({ code: null, signal: 'SIGKILL' }), 10_000);
+ }, Math.max(100, Number(timeoutMs) || 1800_000));
+ child.stdout.on('data', keep);
+ child.stderr.on('data', keep);
+ child.on('error', (err) => finish({ code: null, signal: null, error: String(err.message) }));
+ child.on('close', (code, signal) => finish({ code, signal }));
+ });
+}
+
+module.exports = { runProcess };
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+'use strict';
+
+/**
+ * The install-phase egress proxy: an HTTP CONNECT proxy on a Unix socket, bound into the sandbox for the one
+ * phase that may install dependencies. It answers only the hosts the runner names (npm's registry and GitHub's
+ * codeload for the pinned tarballs), resolves them itself and refuses every non-public address. The test phase
+ * gets no proxy and no network at all.
+ *
+ * It is a plain TCP relay after the CONNECT answer: it never reads TLS, keeps no request bodies and logs only
+ * the host it refused.
+ */
+const fs = require('node:fs');
+const http = require('node:http');
+const net = require('node:net');
+const dns = require('node:dns').promises;
+
+const PRIVATE_V4 = [
+ [/^0\./, 'this network'], [/^10\./, 'private'], [/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, 'carrier NAT'],
+ [/^127\./, 'loopback'], [/^169\.254\./, 'link local'], [/^172\.(1[6-9]|2\d|3[01])\./, 'private'],
+ [/^192\.0\.0\./, 'IETF'], [/^192\.0\.2\./, 'documentation'], [/^192\.168\./, 'private'],
+ [/^198\.(1[89]|51\.100)\./, 'benchmark/documentation'], [/^203\.0\.113\./, 'documentation'],
+ [/^22[4-9]\./, 'multicast'], [/^2[3-5]\d\./, 'multicast'], [/^255\./, 'broadcast'],
+];
+
+function isPublicAddress(address) {
+ const value = String(address || '').toLowerCase();
+ if (net.isIPv4(value)) return !PRIVATE_V4.some(([re]) => re.test(value));
+ if (net.isIPv6(value)) {
+ if (value === '::' || value === '::1') return false;
+ if (/^f[cd]/.test(value) || /^fe[89ab]/.test(value) || /^ff/.test(value)) return false;
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(value);
+ if (mapped) return isPublicAddress(mapped[1]);
+ return true;
+ }
+ return false;
+}
+
+async function resolvePublic(host) {
+ if (!host || net.isIP(host)) throw new Error('an IP literal is not allowed');
+ const answers = await dns.lookup(host, { all: true, verbatim: true });
+ if (!answers.length) throw new Error('no address');
+ for (const answer of answers) {
+ if (!isPublicAddress(answer.address)) throw new Error(`resolves to a non-public address (${answer.address})`);
+ }
+ return answers[0];
+}
+
+function createProxy({ allow = ['registry.npmjs.org', 'codeload.github.com'], log = console } = {}) {
+ const allowed = new Set(allow.map((h) => String(h).toLowerCase()));
+ const sockets = new Set();
+ let server = null;
+
+ const permit = (host) => allowed.has(String(host || '').toLowerCase());
+ const track = (socket) => {
+ sockets.add(socket);
+ socket.on('close', () => sockets.delete(socket));
+ socket.on('error', () => { try { socket.destroy(); } catch { /* gone */ } });
+ return socket;
+ };
+ const deny = (client, code, text) => {
+ try { client.end(`HTTP/1.1 ${code} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); } catch { /* gone */ }
+ };
+
+ async function target(host, port) {
+ if (!permit(host)) throw Object.assign(new Error('host not on the allowlist'), { code: 403, reason: 'not allowed' });
+ if (![80, 443].includes(Number(port))) throw Object.assign(new Error('port'), { code: 403, reason: 'port not allowed' });
+ const answer = await resolvePublic(host);
+ return { host, port: Number(port), address: answer.address, family: answer.family };
+ }
+
+ function onConnect(req, client, head) {
+ const [host, portText] = String(req.url || '').split(':');
+ const port = parseInt(portText, 10) || 443;
+ target(host, port).then((t) => {
+ const upstream = track(net.connect({ host: t.address, port: t.port, family: t.family }));
+ upstream.once('connect', () => {
+ client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
+ if (head && head.length) upstream.write(head);
+ client.pipe(upstream);
+ upstream.pipe(client);
+ });
+ upstream.once('error', (err) => {
+ log.warn(`[git-ci-proxy] ${t.host}: ${err.code || err.message}`);
+ try { client.destroy(); } catch { /* gone */ }
+ });
+ client.on('error', () => { try { upstream.destroy(); } catch { /* gone */ } });
+ }).catch((err) => {
+ log.warn(`[git-ci-proxy] refused ${host}: ${err.reason || err.message}`);
+ deny(client, 403, 'Forbidden');
+ });
+ }
+
+ function onRequest(req, res) {
+ let url;
+ try { url = new URL(req.url); } catch { res.writeHead(400); res.end(); return; }
+ if (url.protocol !== 'http:') { res.writeHead(403); res.end(); return; }
+ target(url.hostname, url.port || 80).then((t) => {
+ const upstream = track(net.connect({ host: t.address, port: t.port, family: t.family }));
+ upstream.once('connect', () => {
+ const path = `${url.pathname}${url.search}`;
+ upstream.write(`${req.method} ${path} HTTP/1.1\r\n`);
+ for (const [k, v] of Object.entries(req.headers)) {
+ if (['proxy-connection', 'proxy-authorization'].includes(k.toLowerCase())) continue;
+ upstream.write(`${k}: ${v}\r\n`);
+ }
+ upstream.write('\r\n');
+ req.pipe(upstream);
+ upstream.pipe(res);
+ });
+ upstream.once('error', () => { try { res.writeHead(502); res.end(); } catch { /* gone */ } });
+ }).catch((err) => {
+ log.warn(`[git-ci-proxy] refused ${url.hostname}: ${err.reason || err.message}`);
+ res.writeHead(403); res.end();
+ });
+ }
+
+ async function listen(socketPath) {
+ fs.rmSync(socketPath, { force: true });
+ server = http.createServer(onRequest);
+ server.on('connect', onConnect);
+ server.keepAliveTimeout = 5000;
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(socketPath, () => { try { fs.chmodSync(socketPath, 0o600); } catch { /* best effort */ } resolve(); });
+ });
+ return socketPath;
+ }
+
+ async function close() {
+ for (const socket of sockets) { try { socket.destroy(); } catch { /* gone */ } }
+ sockets.clear();
+ if (!server) return;
+ await new Promise((resolve) => server.close(resolve));
+ server = null;
+ }
+
+ return { listen, close, permit, target, allowed };
+}
+
+module.exports = { createProxy, isPublicAddress, resolvePublic };
new file mode 100644
new file mode 100644
@@ -0,0 +1,224 @@
@@ -0,0 +1,224 @@
+'use strict';
+
+/**
+ * One claimed run: clone the pushed commit from the forge, install dependencies in a sandbox whose only network
+ * is the allowlisted proxy, then run the tests in a sandbox with no network at all. The log streams to the forge
+ * while it runs; the verdict goes back with the duration. A superseded run is killed at the next heartbeat and
+ * reports nothing (the newer run owns the status).
+ */
+const fs = require('node:fs');
+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 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) => {
+ if (err) reject(new Error(`git ${args[0]} failed: ${String(stderr || err.message).trim().slice(0, 300)}`));
+ else resolve(String(stdout));
+ });
+});
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+const words = (ms) => {
+ const s = Math.max(0, Math.round(Number(ms) / 1000));
+ return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
+};
+
+/** Clone exactly the pushed commit; the mirror may be a few seconds behind the webhook, so retry. */
+async function cloneAt({ dir, cloneUrl, sha, branch, log, attempts = 24, waitMs = 5000 }) {
+ fs.mkdirSync(dir, { recursive: true });
+ await git(['init', '--quiet'], dir);
+ await git(['remote', 'add', 'origin', cloneUrl], dir);
+ let last = null;
+ for (let i = 0; i < attempts; i += 1) {
+ try {
+ await git(['fetch', '--quiet', '--no-tags', '--depth=1', 'origin', sha], dir);
+ } catch (err) {
+ last = err;
+ try {
+ await git(['fetch', '--quiet', '--no-tags', '--depth=1', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], dir);
+ } catch (err2) { last = err2; }
+ }
+ try {
+ await git(['cat-file', '-e', `${sha}^{commit}`], dir, 20_000);
+ await git(['checkout', '--quiet', '--detach', sha], dir);
+ return sha;
+ } catch (err) {
+ last = err;
+ if (i < attempts - 1) {
+ log(`the pushed commit is not in the mirror yet (${i + 1}/${attempts}); waiting for the sync`);
+ await wait(waitMs);
+ }
+ }
+ }
+ throw new Error(`the pushed commit never reached the mirror: ${last ? last.message : sha}`);
+}
+
+function createRunner({ cfg, api, log = console, clock = Date.now }) {
+ const engine = detectEngine(cfg.engine);
+ const rootArgs = engine.kind === 'podman' ? ['--root', cfg.podmanRoot, '--runroot', cfg.podmanRunRoot] : [];
+ // Only what podman and systemd-run need: never the runner's token or the rest of the service environment.
+ const sandboxEnv = {
+ PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
+ HOME: process.env.HOME,
+ XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
+ DBUS_SESSION_BUS_ADDRESS: process.env.DBUS_SESSION_BUS_ADDRESS,
+ USER: process.env.USER, LOGNAME: process.env.LOGNAME, LANG: process.env.LANG, TZ: 'UTC',
+ CONTAINERS_CONF: cfg.podmanConf, CONTAINERS_STORAGE_CONF: cfg.podmanStorageConf,
+ };
+ if (engine.kind === 'podman') {
+ const image = spawnSync('podman', [...rootArgs, 'image', 'exists', cfg.image], { encoding: 'utf8', timeout: 20_000 });
+ if (image.error || image.status !== 0) log(`[git-ci-runner] image ${cfg.image} is not in ${cfg.podmanRoot}; run deploy/ci/install.sh`);
+ }
+ log(`[git-ci-runner] sandbox: ${engine.why}; ${Object.keys(cfg.repos).length} repositories configured`);
+ let activeKill = null;
+ let stopping = false;
+
+ async function runJob(job) {
+ const started = clock();
+ const run = job.run;
+ const runDir = path.join(cfg.workRoot, `run-${run.id}`);
+ const localLog = path.join(cfg.workRoot, `run-${run.id}.log`);
+ fs.rmSync(runDir, { recursive: true, force: true });
+ fs.mkdirSync(path.join(runDir, '.ci'), { recursive: true });
+ 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.js'), RELAY, { mode: 0o600 });
+ fs.writeFileSync(localLog, '', { mode: 0o600 });
+
+ const limits = job.limits || { timeout_seconds: 1800, cpus: 4, memory_mb: 6144 };
+ const totalMs = Math.max(30, Number(limits.timeout_seconds) || 1800) * 1000;
+ const deadline = started + totalMs;
+ let buffer = '';
+ let truncated = false;
+ let lastFlush = 0;
+ let currentKill = null;
+ let superseded = false;
+ let proxy = null;
+
+ const appendLocal = (text) => {
+ try { fs.appendFileSync(localLog, text); } catch { /* disk */ }
+ };
+ const flush = async (force = false) => {
+ if (!buffer || (!force && clock() - lastFlush < 1000)) return;
+ const chunk = buffer;
+ buffer = '';
+ lastFlush = clock();
+ try { await api.log(run.id, chunk); } catch (err) { log(`[git-ci-runner] log not sent: ${err.message}`); }
+ };
+ const onData = (data) => {
+ const text = Buffer.isBuffer(data) ? data.toString('utf8') : String(data);
+ appendLocal(text);
+ if (truncated) return;
+ buffer += text;
+ if (Buffer.byteLength(buffer) > 256 * 1024) buffer = buffer.slice(-192 * 1024);
+ if (buffer.length > 512 * 1024) { buffer = `${buffer.slice(0, 256 * 1024)}\n[output truncated]\n`; truncated = true; }
+ };
+
+ const heartbeat = setInterval(() => {
+ api.heartbeat(run.id).then((out) => {
+ if (out && out.superseded && !superseded) {
+ superseded = true;
+ log(`[git-ci-runner] ${run.id} was superseded; stopping it`);
+ if (currentKill) currentKill();
+ }
+ }).catch((err) => log(`[git-ci-runner] heartbeat: ${err.message}`));
+ }, cfg.heartbeatMs);
+
+ const sandbox = (phase, command, proxySock) => {
+ const name = `ov-ci-${run.id.replace(/[^A-Za-z0-9_.-]/g, '')}-${phase}`;
+ if (engine.kind === 'podman') {
+ return {
+ command: 'podman',
+ args: [...rootArgs, ...podmanArgs({ name, workspace: runDir, image: job.image, limits, proxySock, script: command })],
+ kill: () => podmanCleanup(name, rootArgs),
+ };
+ }
+ const wrapped = bwrapScope({ name, limits, args: bwrapArgs({ workspace: runDir, limits, proxySock, script: command }).args });
+ return { command: wrapped.command, args: wrapped.args, kill: () => scopeCleanup(wrapped.killUnit) };
+ };
+
+ const phase = async (label, script, proxySock) => {
+ const left = deadline - clock();
+ if (left < 5000) return { timedOut: true, code: null, skipped: true };
+ const box = sandbox(label, script, proxySock);
+ log(`[git-ci-runner] ${run.id} ${label}`);
+ activeKill = () => box.kill();
+ const res = await runProcess({
+ command: box.command, args: box.args, env: sandboxEnv, timeoutMs: left,
+ onData,
+ onKill: () => { box.kill(); },
+ });
+ activeKill = null;
+ await flush(true);
+ return res;
+ };
+
+ let state = null;
+ let description = '';
+ let exitCode = null;
+ try {
+ log(`[git-ci-runner] ${run.id} ${job.repo.full_name} ${run.branch} ${run.sha.slice(0, 12)}`);
+ await cloneAt({ dir: runDir, cloneUrl: job.repo.clone_url, sha: run.sha, branch: run.branch, log: (line) => onData(`${line}\n`) });
+
+ proxy = createProxy({ allow: cfg.proxyAllow, log: { warn: (line) => onData(`${line}\n`) } });
+ const socket = path.join(runDir, '.ci', 'proxy.sock');
+ await proxy.listen(socket);
+ const install = await phase('install', installScript(job.command.install), socket);
+ if (superseded) { state = 'superseded'; } else if (install.timedOut) {
+ state = 'timed_out'; description = `Timed out installing dependencies after ${words(totalMs)}`;
+ } else if (install.code !== 0) {
+ state = 'failure'; exitCode = install.code; description = `Dependency install failed (exit ${install.code})`;
+ } else {
+ const test = await phase('test', installScript(job.command.test, { proxy: false }), null);
+ if (superseded) state = 'superseded';
+ else if (test.timedOut) { state = 'timed_out'; description = `Timed out after ${words(totalMs)}`; }
+ else if (test.code === 0) { state = 'success'; description = `Forge runner passed in ${words(clock() - started)}`; }
+ else { state = 'failure'; exitCode = test.code; description = `Forge runner failed (exit ${test.code}) in ${words(clock() - started)}`; }
+ }
+ } catch (err) {
+ if (superseded) state = 'superseded';
+ else { state = 'error'; description = String(err.message || err).slice(0, 500); onData(`\n${err.message}\n`); }
+ } finally {
+ clearInterval(heartbeat);
+ if (proxy) await proxy.close().catch(() => {});
+ await flush(true);
+ }
+
+ if (stopping) { state = 'error'; description = 'The runner stopped before the run finished'; }
+ const duration = clock() - started;
+ if (state !== 'superseded') {
+ try {
+ await api.finish(run.id, { state, description, exit_code: exitCode, duration_ms: duration });
+ } catch (err) {
+ log(`[git-ci-runner] ${run.id} verdict not sent (${state}): ${err.message}`);
+ }
+ }
+ fs.rmSync(runDir, { recursive: true, force: true });
+ return { state, duration };
+ }
+
+ function stop() {
+ stopping = true;
+ if (activeKill) { try { activeKill(); } catch { /* gone */ } }
+ }
+
+ return { engine, runJob, stop };
+}
+
+module.exports = { createRunner, cloneAt, RELAY };
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -0,0 +1,185 @@
+'use strict';
+
+/**
+ * The sandbox around one phase. Podman (rootless) when the host has it, bubblewrap otherwise; both get the same
+ * shape: no host home, no secrets, no host path but the per-run work directory, a new user/pid/network namespace,
+ * 4 CPUs, 6 GB, a 30-minute (configurable) wall, and only the install phase gets the allowlisted proxy socket.
+ *
+ * Podman: `--network=none`, read-only root, workspace bind only, all capabilities dropped, no-new-privileges.
+ * Bubblewrap: no network namespace, read-only /usr and certificates, the same workspace bind, a per-run
+ * passwd/group/hosts, wrapped in a systemd user scope for the memory and CPU caps.
+ */
+const fs = require('node:fs');
+const path = require('node:path');
+const { spawnSync } = require('node:child_process');
+
+const UID = 1000;
+const GID = 1000;
+
+function have(command) {
+ const out = spawnSync(command, ['--version'], { encoding: 'utf8', timeout: 10_000 });
+ return !out.error && out.status === 0;
+}
+
+function podmanWorks() {
+ const out = spawnSync('podman', ['info', '--format', '{{.Host.Security.Rootless}}'], { encoding: 'utf8', timeout: 20_000 });
+ return !out.error && out.status === 0;
+}
+
+/** `auto` picks rootless podman, then bubblewrap; an unavailable engine is an error, never a silent host run. */
+function detectEngine(preferred = 'auto') {
+ if (preferred === 'podman' || preferred === 'auto') {
+ if (podmanWorks()) return { kind: 'podman', why: 'rootless podman' };
+ if (preferred === 'podman') throw new Error('podman is not working rootless on this host');
+ }
+ if (preferred === 'bwrap' || preferred === 'auto') {
+ if (have('bwrap')) return { kind: 'bwrap', why: 'bubblewrap' };
+ if (preferred === 'bwrap') throw new Error('bubblewrap (bwrap) is not installed');
+ }
+ throw new Error('no sandbox engine: rootless podman or bubblewrap is required');
+}
+
+const envList = (workspace, extra = {}) => ({
+ HOME: `${workspace}/.home`,
+ TMPDIR: '/tmp',
+ PATH: '/opt/node/bin:/usr/local/bin:/usr/bin:/bin',
+ LANG: 'C.UTF-8',
+ LC_ALL: 'C.UTF-8',
+ CI: 'true',
+ NO_COLOR: '1',
+ TZ: 'UTC',
+ npm_config_cache: `${workspace}/.cache/npm`,
+ npm_config_fund: 'false',
+ npm_config_audit: 'false',
+ npm_config_update_notifier: 'false',
+ PNPM_HOME: `${workspace}/.pnpm`,
+ ...extra,
+});
+
+/** The install phase's script: relay the Unix-socket proxy on loopback, point npm/pnpm at it, run the command. */
+function installScript(command, { proxy = true } = {}) {
+ return `#!/bin/sh
+set -eu
+${proxy ? `node /workspace/.ci/relay.js /workspace/.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
+export HTTP_PROXY="$http_proxy" HTTPS_PROXY="$https_proxy"
+export npm_config_proxy="$http_proxy" npm_config_https_proxy="$https_proxy"
+` : ''}mkdir -p /workspace/.pnpm/bin
+export PATH=/workspace/.pnpm/bin:$PATH
+cd /workspace
+${command}
+`;
+}
+
+/**
+ * The podman command line for one phase. proxySock is null for the test phase (no network at all).
+ */
+function podmanArgs({ name, workspace, image, limits, proxySock = null, script }) {
+ const args = [
+ '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',
+ '--userns=keep-id', '--workdir', '/workspace',
+ '-v', `${workspace}:/workspace:rw`,
+ '--stop-timeout', '5',
+ ];
+ for (const [key, value] of Object.entries(envList(workspace))) args.push('-e', `${key}=${value}`);
+ if (proxySock) args.push('-v', `${proxySock}:/workspace/.ci/proxy.sock:rw`);
+ args.push(image, '/bin/sh', '-c', script);
+ return args;
+}
+
+const ETC_FILES = ['ssl/certs', 'ssl/openssl.cnf', 'ca-certificates', 'ca-certificates.conf', 'pki/tls/certs', 'pki/ca-trust/extracted',
+ 'fonts', 'ld.so.cache', 'ld.so.conf', 'ld.so.conf.d', 'localtime', 'timezone', 'alternatives', 'mime.types', 'magic',
+ 'vim', 'vimrc', 'nanorc', 'bash.bashrc', 'inputrc', 'terminfo', 'protocols', 'services'];
+const ROOT_LINKS = ['bin', 'sbin', 'lib', 'lib32', 'lib64', 'libx32'];
+
+function bwrapEtc(workspace) {
+ const dir = path.join(workspace, '.ci', 'etc');
+ fs.mkdirSync(dir, { recursive: true });
+ const files = {
+ passwd: `ci:x:${UID}:${GID}:ci:/workspace/.home:/bin/sh\nnobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n`,
+ group: `ci:x:${GID}:\nnogroup:x:65534:\n`,
+ hosts: '127.0.0.1 localhost ci\n::1 localhost ip6-localhost ip6-loopback\n',
+ hostname: 'ci\n',
+ 'resolv.conf': '# No DNS in the sandbox: the install proxy resolves the names it allows.\n',
+ 'nsswitch.conf': 'passwd: files\ngroup: files\nhosts: files\n',
+ };
+ for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);
+ return dir;
+}
+
+/**
+ * The bubblewrap command line for one phase (no systemd scope here; the caller wraps it). Node is bound from
+ * the running runner's own installation, because a workstation's Node is rarely in /usr.
+ */
+function bwrapArgs({ workspace, limits, proxySock = null, script }) {
+ const node = fs.realpathSync(process.execPath);
+ const prefix = path.dirname(path.dirname(node));
+ const nodeInside = path.join('/opt/node', path.relative(prefix, node));
+ const nodeBin = path.dirname(nodeInside);
+ const etc = bwrapEtc(workspace);
+ const args = [
+ '--unshare-user', '--unshare-pid', '--unshare-net', '--unshare-ipc', '--unshare-uts', '--unshare-cgroup',
+ '--disable-userns', '--die-with-parent', '--new-session', '--cap-drop', 'ALL', '--clearenv',
+ '--uid', String(UID), '--gid', String(GID), '--hostname', 'ci',
+ ];
+ for (const [key, value] of Object.entries({ ...envList(workspace), PATH: `${nodeBin}:/usr/local/bin:/usr/bin:/bin` })) {
+ args.push('--setenv', key, value);
+ }
+ args.push('--ro-bind', '/usr', '/usr');
+ if (fs.existsSync('/usr/local')) args.push('--ro-bind', '/usr/local', '/usr/local');
+ for (const name of ROOT_LINKS) {
+ const p = `/${name}`;
+ try {
+ const st = fs.lstatSync(p);
+ if (st.isSymbolicLink()) args.push('--symlink', fs.readlinkSync(p), p);
+ else args.push('--ro-bind', p, p);
+ } catch { /* absent */ }
+ }
+ args.push('--ro-bind', prefix, '/opt/node');
+ args.push('--dir', '/etc');
+ for (const name of ETC_FILES) {
+ const p = `/etc/${name}`;
+ if (fs.existsSync(p)) args.push('--ro-bind', p, p);
+ }
+ 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('--bind', workspace, '/workspace');
+ if (proxySock) args.push('--bind', proxySock, '/workspace/.ci/proxy.sock');
+ args.push('--remount-ro', '/', '--chdir', '/workspace', '--', '/bin/sh', '-c', script);
+ return { args, nodeInside };
+}
+
+/** Maybe wrap a bwrap command in a transient user scope for memory, CPU and process caps. */
+function bwrapScope({ name, limits, args }) {
+ return {
+ command: 'systemd-run',
+ args: [
+ '--user', '--scope', '--quiet', '--collect', '--unit', `ov-ci-${name}`,
+ '-p', `MemoryMax=${limits.memory_mb}M`, '-p', 'MemorySwapMax=0',
+ '-p', `CPUQuota=${Math.round(limits.cpus * 100)}%`, '-p', 'TasksMax=2048',
+ '--', 'bwrap', ...args,
+ ],
+ killUnit: `ov-ci-${name}.scope`,
+ };
+}
+
+/** Remove a podman container, whether it is still running or already gone. */
+function podmanCleanup(name, rootArgs = []) {
+ spawnSync('podman', [...rootArgs, 'rm', '-f', name], { encoding: 'utf8', timeout: 20_000 });
+}
+
+function scopeCleanup(unit) {
+ if (!unit) return;
+ spawnSync('systemctl', ['--user', 'kill', '--signal=SIGKILL', unit], { encoding: 'utf8', timeout: 10_000 });
+}
+
+module.exports = {
+ detectEngine, podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, envList, UID, GID,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+
+/**
+ * Make the runner's token, or print the hash of the one it already has.
+ *
+ * node runner/token.js --out ~/.config/openvibe/git-ci.token
+ * creates a random token, writes it with mode 0600 and prints the scrypt line for
+ * deploy/ci/runner-token (the service never sees the token itself).
+ *
+ * node runner/token.js --from ~/.config/openvibe/git-ci.token
+ * prints the scrypt line for an existing token (rotation: write the same line to the repo).
+ */
+const crypto = require('node:crypto');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { hashToken } = require('../server/ci-auth');
+
+function main(argv) {
+ const arg = (name, fallback = null) => {
+ const i = argv.indexOf(name);
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
+ };
+ const from = arg('--from');
+ let token;
+ let out = arg('--out');
+ if (from) {
+ token = fs.readFileSync(String(from).replace(/^~(?=$|\/)/, os.homedir()), 'utf8').trim();
+ } else {
+ token = crypto.randomBytes(32).toString('base64url');
+ out = String(out || path.join(os.homedir(), '.config', 'openvibe', 'git-ci.token')).replace(/^~(?=$|\/)/, os.homedir());
+ fs.mkdirSync(path.dirname(out), { recursive: true, mode: 0o700 });
+ fs.writeFileSync(out, `${token}\n`, { mode: 0o600 });
+ process.stdout.write(`token written to ${out} (0600)\n`);
+ }
+ if (!token || token.length < 16) throw new Error('the token is empty or too short');
+ process.stdout.write(`${hashToken(token)}\n`);
+}
+
+if (require.main === module) {
+ try { main(process.argv.slice(2)); } catch (err) { process.stderr.write(`${err.message}\n`); process.exit(1); }
+}
+
+module.exports = { main };
@@ -36,6 +36,8 @@ const { createPageRoutes } = require('./http/pages');
@@ -36,6 +36,8 @@ const { createPageRoutes } = require('./http/pages');
const { createReviewRoutes } = require('./http/review');
const { createReviewRoutes } = require('./http/review');
const { createReviews } = require('./reviews');
const { createReviews } = require('./reviews');
const { createChecks } = require('./checks');
const { createChecks } = require('./checks');
+const { createCi } = require('./ci');
+const { createCiAuth } = require('./ci-auth');
const { createArchive } = require('./archive');
const { createArchive } = require('./archive');
const { createReviewServices } = require('./review-services');
const { createReviewServices } = require('./review-services');
const { createLinkRelay } = require('./link-relay');
const { createLinkRelay } = require('./link-relay');
@@ -57,14 +59,16 @@ function createApp(opts = {}) {
@@ -57,14 +59,16 @@ function createApp(opts = {}) {
const repos = opts.repos || createRepos({ store: s, config, git, github, insights, log });
const repos = opts.repos || createRepos({ store: s, config, git, github, insights, log });
const mirrors = opts.mirrors || createMirrors({ store: s, repos, config, git, github, log });
const mirrors = opts.mirrors || createMirrors({ store: s, repos, config, git, github, log });
const official = opts.official || createOfficialMirrors({ store: s, repos, config, github, log });
const official = opts.official || createOfficialMirrors({ store: s, repos, config, github, log });
- const smartHttp = opts.smartHttp || createSmartHttp({ config, repos, mirrors, insights, log });
const sso = opts.sso || createSsoClient({
const sso = opts.sso || createSsoClient({
site: 'git', baseUrl: config.baseUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
site: 'git', baseUrl: config.baseUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl,
networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl,
secureCookies: config.cookies.secure, fetch: fetchImpl, log,
secureCookies: config.cookies.secure, fetch: fetchImpl, log,
});
});
- const ctx = { config, store: s, s, git, github, insights, repos, mirrors, official, smartHttp, sso, log };
+ const ctx = { config, store: s, s, git, github, insights, repos, mirrors, official, sso, log };
ctx.checks = createChecks({ store: s, log });
ctx.checks = createChecks({ store: s, log });
+ ctx.ci = opts.ci || createCi({ config, store: s, checks: ctx.checks, git, github, repos, log });
+ ctx.smartHttp = opts.smartHttp || createSmartHttp({ config, repos, mirrors, insights, ci: ctx.ci, log });
+ ctx.ciAuth = opts.ciAuth || createCiAuth({ config, log });
ctx.archive = createArchive({ config, git });
ctx.archive = createArchive({ config, git });
ctx.checksIntake = opts.checksIntake || createChecksIntake({ store: s, checks: ctx.checks, config, git, github, log });
ctx.checksIntake = opts.checksIntake || createChecksIntake({ store: s, checks: ctx.checks, config, git, github, log });
ctx.relay = createLinkRelay(ctx);
ctx.relay = createLinkRelay(ctx);
@@ -126,7 +130,7 @@ function createApp(opts = {}) {
@@ -126,7 +130,7 @@ function createApp(opts = {}) {
const row = await repos.getRow(parsed.owner, parsed.name);
const row = await repos.getRow(parsed.owner, parsed.name);
if (!row) return res.status(404).type('text/plain').send('No such repository.');
if (!row) return res.status(404).type('text/plain').send('No such repository.');
const identity = await identify(req, { store: s, config });
const identity = await identify(req, { store: s, config });
- return smartHttp.run(req, res, { row, suffix: parsed.suffix, identity });
+ return ctx.smartHttp.run(req, res, { row, suffix: parsed.suffix, identity });
})().catch((err) => {
})().catch((err) => {
log.error('[OpenVibe.Git] git route:', err && err.message);
log.error('[OpenVibe.Git] git route:', err && err.message);
if (!res.headersSent) res.status(500).type('text/plain').send('The git server could not answer.');
if (!res.headersSent) res.status(500).type('text/plain').send('The git server could not answer.');
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+
+/**
+ * The forge runner's own credential. The runner holds one random token; this service holds only a scrypt hash
+ * (deploy/ci/runner-token, or GIT_CI_RUNNER_TOKEN for a test). A request presents it as `Authorization: Bearer …`
+ * or `X-OV-CI-Token`; the comparison is constant time. An absent or malformed token is refused, never logged.
+ */
+const crypto = require('node:crypto');
+const fs = require('node:fs');
+
+const TOKEN_RE = /^[A-Za-z0-9._~-]{16,200}$/;
+
+/** scrypt$<salt hex>$<hash hex> (what deploy/ci/runner-token holds), or null. */
+function parseHash(text) {
+ const parts = String(text || '').trim().split('$');
+ if (parts.length !== 3 || parts[0] !== 'scrypt') return null;
+ if (!/^[0-9a-f]{16,64}$/i.test(parts[1]) || !/^[0-9a-f]{64}$/i.test(parts[2])) return null;
+ return { salt: Buffer.from(parts[1], 'hex'), hash: Buffer.from(parts[2], 'hex') };
+}
+
+function readHash(file) {
+ if (!file) return null;
+ try { return parseHash(fs.readFileSync(file, 'utf8')); } catch { return null; }
+}
+
+/** A token's raw value → the scrypt line deploy/ci/runner-token holds. Used by the local setup, never by the service. */
+function hashToken(token, { salt = crypto.randomBytes(16) } = {}) {
+ const hash = crypto.scryptSync(String(token), salt, 32);
+ return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`;
+}
+
+function createCiAuth({ config, log = console } = {}) {
+ const ci = (config && config.ci) || {};
+ const parsed = ci.runnerToken ? parseHash(hashToken(ci.runnerToken)) : readHash(ci.runnerTokenFile);
+ const raw = ci.runnerToken ? String(ci.runnerToken) : '';
+
+ function tokenOf(req) {
+ const h = String((req.headers && req.headers.authorization) || '');
+ if (/^bearer /i.test(h)) return h.slice(7).trim();
+ const named = req.get ? req.get('x-ov-ci-token') : (req.headers && req.headers['x-ov-ci-token']);
+ return String(named || '').trim();
+ }
+
+ /** The raw token from the request, checked against the stored hash. */
+ function ok(req) {
+ const token = tokenOf(req);
+ if (!TOKEN_RE.test(token)) return false;
+ if (raw) {
+ const a = Buffer.from(token);
+ const b = Buffer.from(raw);
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+ }
+ if (!parsed) return false;
+ const got = crypto.scryptSync(token, parsed.salt, 32);
+ return got.length === parsed.hash.length && crypto.timingSafeEqual(got, parsed.hash);
+ }
+
+ return { ok, configured: Boolean(raw || parsed), tokenOf };
+}
+
+module.exports = { createCiAuth, hashToken, parseHash, TOKEN_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,370 @@
@@ -0,0 +1,370 @@
+'use strict';
+
+/**
+ * Forge CI: the queue of runs the workstation runner executes, and the member rule that decides whether a push
+ * may start one.
+ *
+ * A run exists only for a push to a branch of an official (OpenVibers) repository, made by a person GitHub says
+ * may push there — the push event's own pusher, checked against the repository's collaborators (10 minutes of
+ * cache) and always against the owning account. A fork, a pull request from a fork, a tag, a bot and a non-member
+ * are skipped; those keep GitHub Actions. One run per repository is live: a newer push supersedes the queued or
+ * running one, and the runner sees that on its heartbeat and stops.
+ *
+ * The verdict is copied into git_commit_statuses as `forge/test` (the same intake the GitHub check-runs use), and
+ * the log lives on the run so the forge can show it.
+ */
+const STATES = Object.freeze(['queued', 'running', 'success', 'failure', 'error', 'timed_out', 'superseded']);
+const FINAL = new Set(['success', 'failure', 'error', 'timed_out']);
+const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
+const BRANCH_RE = /^[A-Za-z0-9._/-]{1,255}$/;
+const MAX_LOG = 4 * 1024 * 1024;
+const CONTEXT = 'forge/test';
+
+const fail = (status, code, message) => Object.assign(new Error(message), { status, code });
+const text = (value, max) => String(value == null ? '' : value).slice(0, max);
+
+function cleanSha(sha) {
+ const value = String(sha || '').toLowerCase();
+ if (!SHA_RE.test(value)) throw fail(422, 'ci.sha_invalid', 'A run names the full commit id.');
+ return value;
+}
+
+function cleanBranch(branch) {
+ const value = String(branch || '');
+ if (!BRANCH_RE.test(value) || value.includes('..') || value.startsWith('-') || value.endsWith('/') || value.endsWith('.lock')) {
+ throw fail(422, 'ci.branch_invalid', 'A branch is 1–255 letters, digits, dots, dashes, underscores or slashes.');
+ }
+ return value;
+}
+
+function cleanCommand(value, fallback, code) {
+ const command = String(value == null || value === '' ? fallback : value).trim();
+ if (!command || command.length > 2000 || /[\u0000\r]/.test(command)) throw fail(422, code, 'A command is 1–2000 characters on one logical line.');
+ return command;
+}
+
+function intBetween(value, fallback, min, max, code) {
+ const n = value == null || value === '' ? fallback : parseInt(value, 10);
+ if (!Number.isFinite(n) || n < min || n > max) throw fail(422, code, `The value is ${min}–${max}.`);
+ return n;
+}
+
+function rowToRun(row, { baseUrl = '', owner = null, name = null, log = false } = {}) {
+ if (!row) return null;
+ const full = owner && name ? `${owner}/${name}` : null;
+ return {
+ id: row.id,
+ repo: full,
+ sha: row.sha,
+ branch: row.branch,
+ pusher: row.pusher || null,
+ source: row.source,
+ state: row.state,
+ description: row.description || '',
+ exit_code: row.exit_code == null ? null : Number(row.exit_code),
+ log_bytes: Number(row.log_bytes) || 0,
+ runner: row.runner || null,
+ started_at: row.started_at || null,
+ finished_at: row.finished_at || null,
+ duration_ms: row.duration_ms == null ? null : Number(row.duration_ms),
+ created_at: row.created_at,
+ url: full ? `${String(baseUrl).replace(/\/+$/, '')}/${full}/ci/${row.id}` : null,
+ ...(log ? { log: row.log || '' } : {}),
+ };
+}
+
+function createCi({ config, store, checks, git, github, repos, log = console, clock = null }) {
+ const settings = config.ci;
+ const now = clock && clock.now ? () => clock.now() : () => store.now();
+ const members = new Map(); // `${full}|${login}` → { at, value }
+ let timer = null;
+
+ const base = () => String(config.baseUrl || '').replace(/\/+$/, '');
+ const runUrl = (row, id) => `${base()}/${row.owner_username}/${row.name}/ci/${id}`;
+ const fullName = (row) => `${row.owner_username}/${row.name}`;
+
+ async function settingsFor(repoId) {
+ return await store.maybe('SELECT * FROM git_ci_repos WHERE repo_id = ?', [repoId]);
+ }
+
+ async function enabledFor(row) {
+ if (!row || !row.official) return null;
+ const conf = await settingsFor(row.id);
+ return conf && conf.enabled ? conf : null;
+ }
+
+ /** A GitHub login may push to this repository: the owning account, a configured member, or a collaborator. */
+ async function member({ full, login, owner = settings.org }) {
+ const who = String(login || '').trim();
+ if (!who) return { ok: false, why: 'no pusher' };
+ const key = `${String(full).toLowerCase()}|${who.toLowerCase()}`;
+ const hit = members.get(key);
+ if (hit && now() - hit.at < settings.membershipCacheMs) return hit.value;
+ let value;
+ if (who.toLowerCase() === String(owner || '').toLowerCase()) {
+ value = { ok: true, why: 'owner' };
+ } else if (settings.members.includes(who.toLowerCase())) {
+ value = { ok: true, why: 'member' };
+ } else {
+ try {
+ const permission = await github.permission(full, who);
+ value = { ok: ['admin', 'maintain', 'write'].includes(permission), why: permission || 'none' };
+ } catch (err) {
+ // Never run because GitHub could not answer: deny, and retry after a minute, not ten.
+ const valueNow = { ok: false, why: `github:${err.status || 'unavailable'}` };
+ members.set(key, { at: now() - settings.membershipCacheMs + 60_000, value: valueNow });
+ log.warn(`[OpenVibe.Git] CI member check ${full} ${who}: ${text(err.message, 200)}`);
+ return valueNow;
+ }
+ }
+ members.set(key, { at: now(), value });
+ return value;
+ }
+
+ /** Insert a run, superseding the repository's queued or running one first. */
+ async function enqueue(row, { sha, branch, pusher = null, source = 'github', force = false } = {}) {
+ const conf = await enabledFor(row);
+ if (!conf) return { skipped: 'not-enabled' };
+ const clean = cleanSha(sha);
+ const ref = cleanBranch(branch);
+ const live = await store.maybe("SELECT * FROM git_ci_runs WHERE repo_id = ? AND state IN ('queued', 'running')", [row.id]);
+ if (!force && live && live.sha === clean && live.branch === ref) {
+ return { skipped: 'duplicate', run: rowToRun(live, { baseUrl: base(), owner: row.owner_username, name: row.name }) };
+ }
+ const id = store.newId('cir');
+ await store.execute(
+ "UPDATE git_ci_runs SET state = 'superseded', description = ?, finished_at = now(), updated_at = now() WHERE repo_id = ? AND state IN ('queued', 'running')",
+ [`Superseded by ${clean.slice(0, 12)}`, row.id],
+ );
+ try {
+ await store.execute(
+ `INSERT INTO git_ci_runs (id, repo_id, sha, branch, pusher, source, state, description)
+ VALUES (?, ?, ?, ?, ?, ?, 'queued', ?)`,
+ [id, row.id, clean, ref, pusher ? text(pusher, 200) : null, text(source, 20), 'Queued on the forge runner'],
+ );
+ } catch (err) {
+ if (/duplicate key|unique/i.test(String(err.message))) {
+ const current = await store.maybe("SELECT * FROM git_ci_runs WHERE repo_id = ? AND state IN ('queued', 'running')", [row.id]);
+ return { skipped: 'duplicate', run: rowToRun(current, { baseUrl: base(), owner: row.owner_username, name: row.name }) };
+ }
+ throw err;
+ }
+ const run = await store.maybe('SELECT * FROM git_ci_runs WHERE id = ?', [id]);
+ await checks.recordIfChanged(row.id, clean, {
+ context: settings.context || CONTEXT, state: 'pending', description: 'Queued on the forge runner', target_url: runUrl(row, id),
+ }, 'svc:forge-runner').catch((err) => log.warn('[OpenVibe.Git] CI pending status:', err.message));
+ return { run: rowToRun(run, { baseUrl: base(), owner: row.owner_username, name: row.name }) };
+ }
+
+ /**
+ * A GitHub push webhook. Runs only a branch push to the official repository that this mirror follows, by a
+ * login GitHub says may push there. Everything else (fork, tag, deletion, PR, non-member, bot) is skipped.
+ */
+ async function onGithubPush(row, payload) {
+ if (!settings.enabled) return { skipped: 'off' };
+ if (!row || !row.official || !row.github_full_name) return { skipped: 'not-official' };
+ const repo = (payload && payload.repository) || {};
+ const full = String(repo.full_name || '');
+ if (!full || full.toLowerCase() !== String(row.github_full_name).toLowerCase()) return { skipped: 'not-this-repository' };
+ if (repo.fork) return { skipped: 'fork' };
+ const owner = full.split('/')[0];
+ if (owner.toLowerCase() !== settings.org.toLowerCase()) return { skipped: 'not-the-network' };
+ if (payload.deleted) return { skipped: 'deleted' };
+ const ref = String(payload.ref || '');
+ if (!ref.startsWith('refs/heads/')) return { skipped: 'not-a-branch' };
+ const after = String(payload.after || '');
+ if (!SHA_RE.test(after.toLowerCase()) || /^0+$/.test(after)) return { skipped: 'no-commit' };
+ const pusher = (payload.pusher && payload.pusher.name) || (payload.sender && payload.sender.login) || null;
+ const who = await member({ full, login: pusher, owner });
+ if (!who.ok) return { skipped: 'not-member', pusher, why: who.why };
+ if (!(await enabledFor(row))) return { skipped: 'not-enabled' };
+ const out = await enqueue(row, { sha: after, branch: ref.slice('refs/heads/'.length), pusher, source: 'github' });
+ return { ...out, pusher, member: who.why };
+ }
+
+ /** A push that landed on the forge itself: only the network's own service tokens count as its members. */
+ async function onForgePush(row, identity, { ref, sha, force = false } = {}) {
+ if (!settings.enabled) return { skipped: 'off' };
+ if (!row || !row.official) return { skipped: 'not-official' };
+ if (!identity) return { skipped: 'no-identity' };
+ if (identity.actor_type !== 'service' || !identity.scopes || !identity.scopes.has('git.repo.write')) {
+ return { skipped: 'not-a-service' };
+ }
+ if (!String(ref || '').startsWith('refs/heads/')) return { skipped: 'not-a-branch' };
+ if (!(await enabledFor(row))) return { skipped: 'not-enabled' };
+ return await enqueue(row, { sha, branch: String(ref).slice('refs/heads/'.length), pusher: identity.subject || 'svc', source: 'forge', force });
+ }
+
+ /** The runner registers the repositories it is configured to test; only official ones are accepted. */
+ async function registerRepos(list, actor = 'svc:forge-runner') {
+ if (!Array.isArray(list) || !list.length || list.length > 100) throw fail(422, 'ci.repos_invalid', 'One to 100 repositories are registered.');
+ const out = [];
+ for (const item of list) {
+ const full = String((item && (item.full_name || `${item.owner || ''}/${item.name || ''}`)) || '').trim();
+ const [owner, name] = full.split('/');
+ const row = owner && name ? await repos.getRow(owner.toLowerCase(), name.toLowerCase()) : null;
+ if (!row || !row.official) throw fail(422, 'ci.repo_not_official', `Only an official repository can run on the forge: ${full || 'no name'}`);
+ const body = {
+ enabled: item.enabled !== false,
+ install_command: cleanCommand(item.install_command, 'npm ci --no-audit --no-fund', 'ci.install_invalid'),
+ test_command: cleanCommand(item.test_command, 'npm test', 'ci.test_invalid'),
+ image: cleanCommand(item.image, 'docker.io/library/node:22-bookworm', 'ci.image_invalid'),
+ timeout_seconds: intBetween(item.timeout_seconds, 1800, 30, 3600, 'ci.timeout_invalid'),
+ cpus: intBetween(item.cpus, 4, 1, 8, 'ci.cpus_invalid'),
+ memory_mb: intBetween(item.memory_mb, 6144, 512, 12288, 'ci.memory_invalid'),
+ };
+ if (body.image.length > 200) throw fail(422, 'ci.image_invalid', 'An image name is at most 200 characters.');
+ await store.execute(
+ `INSERT INTO git_ci_repos (repo_id, enabled, install_command, test_command, image, timeout_seconds, cpus, memory_mb, registered_by, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, now())
+ ON CONFLICT (repo_id) DO UPDATE SET enabled = EXCLUDED.enabled, install_command = EXCLUDED.install_command,
+ test_command = EXCLUDED.test_command, image = EXCLUDED.image, timeout_seconds = EXCLUDED.timeout_seconds,
+ cpus = EXCLUDED.cpus, memory_mb = EXCLUDED.memory_mb, registered_by = EXCLUDED.registered_by, updated_at = now()`,
+ [row.id, body.enabled, body.install_command, body.test_command, body.image, body.timeout_seconds, body.cpus, body.memory_mb, actor],
+ );
+ out.push({ full_name: fullName(row), ...body });
+ }
+ return out;
+ }
+
+ /** Start the current head (or a named commit) by hand: an owner, or the runner for its own checks. */
+ async function manual(row, { sha = null, branch = null, pusher = null } = {}, actor = 'manual') {
+ if (!(await enabledFor(row))) throw fail(409, 'ci.not_enabled', 'CI is not enabled for this repository.');
+ const ref = cleanBranch(branch || row.default_branch || 'main');
+ const wanted = sha ? cleanSha(sha) : await git.resolveCommit(row.owner_username, row.name, ref);
+ if (!wanted) throw fail(422, 'ci.commit_missing', 'That branch or commit is not in this repository.');
+ const found = await git.resolveCommit(row.owner_username, row.name, wanted);
+ if (!found || String(found).toLowerCase() !== wanted) throw fail(422, 'ci.commit_missing', 'That commit is not in this repository.');
+ const out = await enqueue(row, { sha: wanted, branch: ref, pusher: pusher || settings.org, source: 'manual', force: true });
+ return { ...out, actor };
+ }
+
+ /** The next queued run, marked running. One runner claims one run at a time. */
+ async function claim(runner = 'forge-runner') {
+ const row = await store.maybe(
+ `WITH next AS (
+ SELECT id FROM git_ci_runs WHERE state = 'queued' ORDER BY created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED
+ )
+ UPDATE git_ci_runs r SET state = 'running', started_at = now(), runner = ?, updated_at = now()
+ FROM next WHERE r.id = next.id RETURNING r.*`,
+ [text(runner, 100)],
+ );
+ if (!row) return null;
+ const repo = await store.maybe('SELECT * FROM git_repos WHERE id = ?', [row.repo_id]);
+ const conf = repo ? await settingsFor(repo.id) : null;
+ if (!repo || !conf) {
+ await finish(row.id, { state: 'error', description: 'The repository or its CI configuration disappeared' });
+ return null;
+ }
+ return {
+ run: rowToRun(row, { baseUrl: base(), owner: repo.owner_username, name: repo.name }),
+ repo: {
+ full_name: fullName(repo),
+ clone_url: `${base()}/${repo.owner_username}/${repo.name}.git`,
+ default_branch: repo.default_branch || 'main',
+ },
+ image: conf.image,
+ command: { install: conf.install_command, test: conf.test_command },
+ limits: { timeout_seconds: conf.timeout_seconds, cpus: conf.cpus, memory_mb: conf.memory_mb },
+ };
+ }
+
+ async function runRow(id) {
+ return await store.maybe('SELECT * FROM git_ci_runs WHERE id = ?', [text(id, 100)]);
+ }
+
+ async function repoOfRun(row) {
+ return row ? await store.maybe('SELECT * FROM git_repos WHERE id = ?', [row.repo_id]) : null;
+ }
+
+ /** Whether a running run is still wanted (the runner asks every few seconds). */
+ async function heartbeat(id) {
+ const row = await runRow(id);
+ if (!row) return { found: false, state: 'missing', superseded: true };
+ return { found: true, state: row.state, superseded: row.state !== 'running' };
+ }
+
+ async function appendLog(id, chunk) {
+ const row = await runRow(id);
+ if (!row || row.state !== 'running') return { ok: false, state: row ? row.state : 'missing' };
+ const piece = String(chunk == null ? '' : chunk);
+ if (!piece) return { ok: true, log_bytes: Number(row.log_bytes) || 0 };
+ let next = `${row.log || ''}${piece}`;
+ if (next.length > MAX_LOG) next = `${next.slice(0, MAX_LOG)}\n[the rest of this log was too long to keep]\n`;
+ const bytes = Buffer.byteLength(next, 'utf8');
+ await store.execute('UPDATE git_ci_runs SET log = ?, log_bytes = ?, updated_at = now() WHERE id = ?', [next, bytes, id]);
+ return { ok: true, log_bytes: bytes, truncated: next.length >= MAX_LOG };
+ }
+
+ /** The runner's verdict. A superseded run keeps the newer run's status; it is never overwritten. */
+ async function finish(id, { state, description = '', exit_code = null, duration_ms = null } = {}) {
+ const row = await runRow(id);
+ if (!row) return { ok: false, state: 'missing' };
+ if (row.state !== 'running') return { ok: false, state: row.state };
+ if (!FINAL.has(state)) throw fail(422, 'ci.state_invalid', 'A final state is success, failure, error or timed_out.');
+ const repo = await repoOfRun(row);
+ const started = row.started_at ? Date.parse(row.started_at) : now();
+ const elapsed = duration_ms == null ? Math.max(0, now() - started) : Math.max(0, Number(duration_ms) || 0);
+ const word = state === 'timed_out' ? 'timed out' : state;
+ const line = text(description, 1024) || `Forge runner ${word} in ${Math.round(elapsed / 1000)}s`;
+ await store.execute(
+ `UPDATE git_ci_runs SET state = ?, description = ?, exit_code = ?, duration_ms = ?, finished_at = now(), updated_at = now()
+ WHERE id = ? AND state = 'running'`,
+ [state, line, exit_code == null ? null : Number(exit_code), elapsed, id],
+ );
+ const checkState = state === 'success' ? 'success' : state === 'error' ? 'error' : 'failure';
+ if (repo) {
+ await checks.recordIfChanged(repo.id, row.sha, {
+ context: settings.context || CONTEXT, state: checkState, description: line, target_url: runUrl(repo, id),
+ }, 'svc:forge-runner').catch((err) => log.warn('[OpenVibe.Git] CI status:', err.message));
+ }
+ return { ok: true, run: rowToRun({ ...row, state, description: line, exit_code, duration_ms: elapsed, finished_at: new Date(now()).toISOString() }, { baseUrl: base(), owner: repo && repo.owner_username, name: repo && repo.name }) };
+ }
+
+ /** Fail a run whose runner disappeared: one left running past its own limit plus a minute cannot still be alive. */
+ async function reap() {
+ const rows = await store.many(
+ `SELECT r.*, c.timeout_seconds FROM git_ci_runs r JOIN git_ci_repos c ON c.repo_id = r.repo_id
+ WHERE r.state = 'running'`,
+ );
+ const out = [];
+ for (const row of rows) {
+ const started = row.started_at ? Date.parse(row.started_at) : 0;
+ if (!started || now() - started < (Number(row.timeout_seconds) || 1800) * 1000 + 60_000) continue;
+ const minutes = Math.round((Number(row.timeout_seconds) || 1800) / 60);
+ out.push(await finish(row.id, { state: 'timed_out', description: `The runner stopped answering; timed out after ${minutes} minutes` }));
+ }
+ return out;
+ }
+
+ async function runsFor(row, { limit = 30 } = {}) {
+ const rows = await store.many('SELECT * FROM git_ci_runs WHERE repo_id = ? ORDER BY created_at DESC LIMIT ?', [row.id, Math.max(1, Math.min(100, Number(limit) || 30))]);
+ return rows.map((r) => rowToRun(r, { baseUrl: base(), owner: row.owner_username, name: row.name }));
+ }
+
+ async function runFor(row, id) {
+ const run = await store.maybe('SELECT * FROM git_ci_runs WHERE id = ? AND repo_id = ?', [text(id, 100), row.id]);
+ return rowToRun(run, { baseUrl: base(), owner: row.owner_username, name: row.name, log: true });
+ }
+
+ async function configFor(row) {
+ return await settingsFor(row.id);
+ }
+
+ function start() {
+ if (timer || !settings.enabled) return false;
+ timer = setInterval(() => { reap().catch((err) => log.warn('[OpenVibe.Git] CI reap:', err.message)); }, settings.reapMs);
+ timer.unref();
+ return true;
+ }
+
+ function stop() { if (timer) clearInterval(timer); timer = null; }
+
+ return {
+ onGithubPush, onForgePush, registerRepos, manual, enqueue, claim, heartbeat, appendLog, finish, reap,
+ runsFor, runFor, configFor, settingsFor, enabledFor, member, rowToRun, start, stop,
+ STATES, CONTEXT, SHA_RE, BRANCH_RE,
+ };
+}
+
+module.exports = { createCi, rowToRun, cleanSha, cleanBranch, STATES, CONTEXT };
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
*/
*/
require('dotenv').config();
require('dotenv').config();
+const path = require('node:path');
const trim = (s) => String(s || '').replace(/\/+$/, '');
const trim = (s) => String(s || '').replace(/\/+$/, '');
const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
const bool = (v, def = false) => (v == null || v === '' ? def : !/^(0|false|no|off)$/i.test(String(v)));
const bool = (v, def = false) => (v == null || v === '' ? def : !/^(0|false|no|off)$/i.test(String(v)));
@@ -67,6 +68,20 @@ function load(env = process.env) {
@@ -67,6 +68,20 @@ function load(env = process.env) {
cacheMs: Math.max(60_000, int(env.GIT_GITHUB_CACHE_MS, 10 * 60_000)),
cacheMs: Math.max(60_000, int(env.GIT_GITHUB_CACHE_MS, 10 * 60_000)),
},
},
+ // Forge CI: the workstation runner's queue. A run is made only for a branch push to an official
+ // repository by a login GitHub says may push there (the owning account, a configured member, or a
+ // collaborator with write). The token file holds an scrypt hash, never the token.
+ ci: {
+ enabled: bool(env.GIT_CI_ENABLED, true),
+ org: String(env.GIT_CI_ORG || officialOwners[0] || 'openvibers').toLowerCase(),
+ context: String(env.GIT_CI_CONTEXT || 'forge/test').slice(0, 255),
+ members: list(env.GIT_CI_MEMBERS).map((m) => m.toLowerCase()),
+ membershipCacheMs: Math.max(60_000, int(env.GIT_CI_MEMBER_CACHE_MS, 10 * 60_000)),
+ reapMs: Math.max(30_000, int(env.GIT_CI_REAP_MS, 60_000)),
+ runnerToken: env.GIT_CI_RUNNER_TOKEN || '',
+ runnerTokenFile: env.GIT_CI_RUNNER_TOKEN_FILE || path.join(__dirname, '..', 'deploy', 'ci', 'runner-token'),
+ },
+
// Clone tokens (opaque, hashed at rest) and the key that seals a stored push-mirror token.
// Clone tokens (opaque, hashed at rest) and the key that seals a stored push-mirror token.
tokens: {
tokens: {
ttlHours: Math.max(1, Math.min(24 * 30, int(env.GIT_TOKEN_TTL_HOURS, 1))),
ttlHours: Math.max(1, Math.min(24 * 30, int(env.GIT_TOKEN_TTL_HOURS, 1))),
@@ -63,7 +63,7 @@ function cgiHeaders(text) {
@@ -63,7 +63,7 @@ function cgiHeaders(text) {
return { status, headers, bodyAt: idx + sep };
return { status, headers, bodyAt: idx + sep };
}
}
-function createSmartHttp({ config, repos, mirrors, insights, log = console }) {
+function createSmartHttp({ config, repos, mirrors, insights, ci = null, log = console }) {
const mayRead = (row, identity) => {
const mayRead = (row, identity) => {
if (row.visibility === 'public') return true;
if (row.visibility === 'public') return true;
if (!identity) return false;
if (!identity) return false;
@@ -146,6 +146,15 @@ function createSmartHttp({ config, repos, mirrors, insights, log = console }) {
@@ -146,6 +146,15 @@ function createSmartHttp({ config, repos, mirrors, insights, log = console }) {
await repos.recordEvent(row, 'push', identity || { subject: null, username: null }, { service });
await repos.recordEvent(row, 'push', identity || { subject: null, username: null }, { service });
await repos.audit(identity, 'git.push', row, {}, req.ip || null);
await repos.audit(identity, 'git.push', row, {}, req.ip || null);
await mirrors.afterPush(row);
await mirrors.afterPush(row);
+ // A branch push that landed here (the estate's own tools) can queue a forge run; a person's forge
+ // account is not a GitHub login, so only the network's service tokens count (server/ci.js).
+ if (ci) {
+ for (const change of parsePushCommands(body)) {
+ if (!change.ref.startsWith('refs/heads/') || /^0+$/.test(change.new)) continue;
+ ci.onForgePush(row, identity, { ref: change.ref, sha: change.new })
+ .catch((err) => log.warn(`[OpenVibe.Git] CI forge push ${row.owner_username}/${row.name}: ${String(err.message || err).slice(0, 200)}`));
+ }
+ }
} else if (suffix === 'info/refs' && service === 'git-upload-pack' && req.method === 'GET') {
} else if (suffix === 'info/refs' && service === 'git-upload-pack' && req.method === 'GET') {
// A clone or fetch begins here; count it once, not again on the pack that follows.
// A clone or fetch begins here; count it once, not again on the pack that follows.
if (insights) await insights.recordClone(row, { address: req.ip || '', subject: identity ? identity.subject : null });
if (insights) await insights.recordClone(row, { address: req.ip || '', subject: identity ? identity.subject : null });
@@ -155,6 +164,29 @@ function createSmartHttp({ config, repos, mirrors, insights, log = console }) {
@@ -155,6 +164,29 @@ function createSmartHttp({ config, repos, mirrors, insights, log = console }) {
return { run, mayRead, mayWrite, parseGitPath };
return { run, mayRead, mayWrite, parseGitPath };
}
}
+/** The ref updates at the start of a git-receive-pack request body: [{ old, new, ref }]. */
+function parsePushCommands(body) {
+ const out = [];
+ if (!Buffer.isBuffer(body)) return out;
+ let i = 0;
+ while (i + 4 <= body.length) {
+ const size = parseInt(body.subarray(i, i + 4).toString('latin1'), 16);
+ i += 4;
+ if (size === 0) break; // flush: the pack follows
+ if (!Number.isFinite(size) || size < 4 || i + size - 4 > body.length) break;
+ const line = body.subarray(i, i + size - 4).toString('utf8');
+ i += size - 4;
+ const command = line.split('\u0000')[0].trim();
+ const parts = command.split(' ');
+ if (parts.length < 3) continue;
+ const [oldSha, newSha, ref] = parts;
+ if (!/^[0-9a-f]{40,64}$/i.test(oldSha) || !/^[0-9a-f]{40,64}$/i.test(newSha)) continue;
+ if (!ref.startsWith('refs/')) continue;
+ out.push({ old: oldSha.toLowerCase(), new: newSha.toLowerCase(), ref });
+ }
+ return out;
+}
+
/** Byte offset of the end of the CGI header block (after the blank line). */
/** Byte offset of the end of the CGI header block (after the blank line). */
function findHeaderEnd(buf) {
function findHeaderEnd(buf) {
const crlf = buf.indexOf('\r\n\r\n');
const crlf = buf.indexOf('\r\n\r\n');
@@ -163,4 +195,4 @@ function findHeaderEnd(buf) {
@@ -163,4 +195,4 @@ function findHeaderEnd(buf) {
return lf >= 0 ? lf + 2 : -1;
return lf >= 0 ? lf + 2 : -1;
}
}
-module.exports = { createSmartHttp, parseGitPath, cgiHeaders, findHeaderEnd, ROUTE };
+module.exports = { createSmartHttp, parseGitPath, cgiHeaders, findHeaderEnd, parsePushCommands, ROUTE };
@@ -271,6 +271,30 @@ function createGithubClient({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -271,6 +271,30 @@ function createGithubClient({ config, fetchImpl = globalThis.fetch, now = () =>
return Array.isArray(body && body.check_runs) ? body.check_runs : [];
return Array.isArray(body && body.check_runs) ? body.check_runs : [];
}
}
+ /**
+ * The permission a GitHub login has on one repository: admin, maintain, write, triage, read, or null when
+ * the login is not a collaborator. This is the push-access check the forge runner trusts (10-minute cache
+ * in server/ci.js); a 404 is an answer, not an error.
+ */
+ async function permission(full, login) {
+ if (!FULL_RE.test(String(full || ''))) return null;
+ const who = String(login || '');
+ if (!/^[A-Za-z0-9-]{1,39}$/.test(who)) return null;
+ const [owner, name] = String(full).split('/');
+ const res = await fetchImpl(
+ `${config.github.apiUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/collaborators/${encodeURIComponent(who)}/permission`,
+ { headers: headers(), signal: AbortSignal.timeout(10_000) },
+ );
+ if (res.status === 404) return null;
+ if (!res.ok) {
+ const err = new Error(`GitHub ${res.status} for ${full} permission`);
+ err.status = res.status;
+ throw err;
+ }
+ const body = await res.json();
+ return body && typeof body.permission === 'string' ? body.permission : null;
+ }
+
/** GitHub's X-Hub-Signature-256 over the raw body, constant time. */
/** GitHub's X-Hub-Signature-256 over the raw body, constant time. */
function signatureOk(rawBody, signature) {
function signatureOk(rawBody, signature) {
const secret = config.github.webhookSecret;
const secret = config.github.webhookSecret;
@@ -281,7 +305,7 @@ function createGithubClient({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -281,7 +305,7 @@ function createGithubClient({ config, fetchImpl = globalThis.fetch, now = () =>
return a.length === b.length && crypto.timingSafeEqual(a, b);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
}
- return { parseGithubUrl, card, meta, probe, orgRepos, openPulls, checkRuns, signatureOk, _cache: cache };
+ return { parseGithubUrl, card, meta, probe, orgRepos, openPulls, checkRuns, permission, signatureOk, _cache: cache };
}
}
module.exports = { createGithubClient, parseGithubUrl, parseRefs, pktLine, FULL_RE };
module.exports = { createGithubClient, parseGithubUrl, parseRefs, pktLine, FULL_RE };
@@ -19,7 +19,7 @@ const fail = (req, res, err) => problem(req, res, err.status || 500, err.code ||
@@ -19,7 +19,7 @@ const fail = (req, res, err) => problem(req, res, err.status || 500, err.code ||
function boolParam(v) { return v === true || v === '1' || v === 'true' || v === 'yes'; }
function boolParam(v) { return v === true || v === '1' || v === 'true' || v === 'yes'; }
function createApi(ctx) {
function createApi(ctx) {
- const { config, repos, mirrors, git, insights, store, sso, checks, archive, log } = ctx;
+ const { config, repos, mirrors, git, insights, store, sso, checks, archive, ci, ciAuth, log } = ctx;
const r = express.Router();
const r = express.Router();
const h = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
const h = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
// One identity read per request: the session, or a token (a person's or a service's, and for a service the
// One identity read per request: the session, or a token (a person's or a service's, and for a service the
@@ -264,6 +264,86 @@ function createApi(ctx) {
@@ -264,6 +264,86 @@ function createApi(ctx) {
} catch (err) { return fail(req, res, err); }
} catch (err) { return fail(req, res, err); }
}));
}));
+ // ── Forge CI (the workstation runner) ──────────────────
+ // The runner holds one token; the service holds its scrypt hash. Nothing here is reachable with a session.
+ const runnerOnly = (req, res) => {
+ if (!ciAuth.ok(req)) {
+ problem(req, res, 401, 'ci.runner_token', 'The forge runner token is missing or wrong.');
+ return false;
+ }
+ return true;
+ };
+
+ r.post('/ci/repos', h(async (req, res) => {
+ if (!runnerOnly(req, res)) return undefined;
+ try {
+ const list = Array.isArray(req.body) ? req.body : (req.body && Array.isArray(req.body.repos) ? req.body.repos : []);
+ return res.json({ repositories: await ci.registerRepos(list) });
+ } catch (err) { return fail(req, res, err); }
+ }));
+
+ r.post('/ci/runs/claim', h(async (req, res) => {
+ if (!runnerOnly(req, res)) return undefined;
+ const job = await ci.claim((req.body && req.body.runner) || 'forge-runner');
+ if (!job) return res.status(204).end();
+ return res.json(job);
+ }));
+
+ r.get('/ci/runs/:id/heartbeat', h(async (req, res) => {
+ if (!runnerOnly(req, res)) return undefined;
+ return res.json(await ci.heartbeat(req.params.id));
+ }));
+
+ r.post('/ci/runs/:id/log', h(async (req, res) => {
+ if (!runnerOnly(req, res)) return undefined;
+ const out = await ci.appendLog(req.params.id, String((req.body || {}).chunk || ''));
+ if (!out.ok) return problem(req, res, 409, 'ci.run_not_running', `This run is ${out.state}.`);
+ return res.json(out);
+ }));
+
+ r.post('/ci/runs/:id/status', h(async (req, res) => {
+ if (!runnerOnly(req, res)) return undefined;
+ try {
+ const out = await ci.finish(req.params.id, req.body || {});
+ if (!out.ok) return problem(req, res, 409, 'ci.run_not_running', `This run is ${out.state}.`);
+ return res.json(out);
+ } catch (err) { return fail(req, res, err); }
+ }));
+
+ r.get('/repos/:owner/:name/ci', h(async (req, res) => {
+ const found = await visible(req, req.params.owner, req.params.name);
+ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
+ const conf = await ci.configFor(found.row);
+ return res.json({
+ repo: `${found.row.owner_username}/${found.row.name}`,
+ enabled: Boolean(conf && conf.enabled),
+ context: config.ci.context,
+ runs: await ci.runsFor(found.row, { limit: req.query.limit }),
+ });
+ }));
+
+ r.get('/repos/:owner/:name/ci/runs/:id', h(async (req, res) => {
+ const found = await visible(req, req.params.owner, req.params.name);
+ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
+ const run = await ci.runFor(found.row, req.params.id);
+ if (!run) return problem(req, res, 404, 'ci.run_not_found', 'No such run.');
+ return res.json(run);
+ }));
+
+ r.post('/repos/:owner/:name/ci/runs', h(async (req, res) => {
+ const found = await visible(req, req.params.owner, req.params.name);
+ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
+ const caller = await callerFor(req);
+ const runner = ciAuth.ok(req);
+ if (!runner && (!caller || !repos.isOwner(found.row, actingAs(caller)))) {
+ return problem(req, res, 403, 'repo.forbidden', 'Only the owner or the forge runner may start a run.');
+ }
+ try {
+ const out = await ci.manual(found.row, (req.body || {}), runner ? 'runner' : 'owner');
+ return res.status(201).json(out);
+ } catch (err) { return fail(req, res, err); }
+ }));
+
r.get('/repos/:owner/:name/branches', h(async (req, res) => {
r.get('/repos/:owner/:name/branches', h(async (req, res) => {
const found = await visible(req, req.params.owner, req.params.name);
const found = await visible(req, req.params.owner, req.params.name);
if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
@@ -360,7 +440,14 @@ function createApi(ctx) {
@@ -360,7 +440,14 @@ function createApi(ctx) {
r.post('/webhooks/github', h(async (req, res) => {
r.post('/webhooks/github', h(async (req, res) => {
if (!Buffer.isBuffer(req.body)) return problem(req, res, 415, 'webhook.raw_required', 'The webhook body is read raw.');
if (!Buffer.isBuffer(req.body)) return problem(req, res, 415, 'webhook.raw_required', 'The webhook body is read raw.');
const out = await mirrors.handleWebhook(req.body, req.headers);
const out = await mirrors.handleWebhook(req.body, req.headers);
- return res.status(out.status).json(out.body);
+ res.status(out.status).json(out.body);
+ // A push starts a forge run only after the mirror has the pushed commit; a pull request never does.
+ if (out.event === 'push' && out.row && out.payload && out.sync) {
+ out.sync
+ .then(() => ci.onGithubPush(out.row, out.payload))
+ .catch((err) => log.warn(`[OpenVibe.Git] CI push ${out.row.owner_username}/${out.row.name}: ${String(err.message || err).slice(0, 200)}`));
+ }
+ return undefined;
}));
}));
r.use((req, res) => problem(req, res, 404, 'route.not_found', 'No such API route.'));
r.use((req, res) => problem(req, res, 404, 'route.not_found', 'No such API route.'));
@@ -22,6 +22,7 @@ const express = require('express');
@@ -22,6 +22,7 @@ const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const views = require('../views');
const views = require('../views');
+const ciViews = require('../views/ci');
const machineViews = require('../views/machine');
const machineViews = require('../views/machine');
const { viewerOf } = require('./identity');
const { viewerOf } = require('./identity');
const { esc } = require('../render/html');
const { esc } = require('../render/html');
@@ -30,7 +31,7 @@ const { validRef, validPath } = require('../git/refs');
@@ -30,7 +31,7 @@ const { validRef, validPath } = require('../git/refs');
const { serveArchive } = require('../archive');
const { serveArchive } = require('../archive');
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config, repos, git, github, mirrors, insights, store, relay, archive, log } = ctx;
+ const { config, repos, git, github, mirrors, insights, store, relay, archive, ci, log } = ctx;
const r = express.Router();
const r = express.Router();
r.use(express.urlencoded({ extended: false, limit: '256kb' }));
r.use(express.urlencoded({ extended: false, limit: '256kb' }));
const h = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
const h = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
@@ -391,6 +392,24 @@ function createPageRoutes(ctx) {
@@ -391,6 +392,24 @@ function createPageRoutes(ctx) {
return page(req, res, views.insightsPage({ row, data }), meta(req, row, { title: 'Insights', description: `Views, clones, stars and commits for ${row.owner_username}/${row.name}.` }));
return page(req, res, views.insightsPage({ row, data }), meta(req, row, { title: 'Insights', description: `Views, clones, stars and commits for ${row.owner_username}/${row.name}.` }));
}));
}));
+ r.get('/:owner/:name/ci', h(async (req, res) => {
+ const row = await rowFor(req, res);
+ if (!row) return undefined;
+ const conf = await ci.configFor(row);
+ return page(req, res, ciViews.list({
+ row, enabled: Boolean(conf && conf.enabled), context: config.ci.context,
+ runs: await ci.runsFor(row, { limit: req.query.limit }),
+ }), meta(req, row, { title: 'CI', index: false }));
+ }));
+
+ r.get('/:owner/:name/ci/:id', h(async (req, res) => {
+ const row = await rowFor(req, res);
+ if (!row) return undefined;
+ const run = await ci.runFor(row, req.params.id);
+ if (!run) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
+ return page(req, res, ciViews.run({ row, run }), meta(req, row, { title: `CI · ${run.branch}`, index: false }));
+ }));
+
// ── Writes ──────────────────────────────────────────────
// ── Writes ──────────────────────────────────────────────
r.post('/:owner/:name/star', h(async (req, res) => {
r.post('/:owner/:name/star', h(async (req, res) => {
if (!guard(req, res)) return undefined;
if (!guard(req, res)) return undefined;
@@ -13,6 +13,7 @@ function createLifecycle({ server, exit, signals, ctx } = {}) {
@@ -13,6 +13,7 @@ function createLifecycle({ server, exit, signals, ctx } = {}) {
const mirrors = ctx && ctx.mirrors;
const mirrors = ctx && ctx.mirrors;
const official = ctx && ctx.official;
const official = ctx && ctx.official;
const checksIntake = ctx && ctx.checksIntake;
const checksIntake = ctx && ctx.checksIntake;
+ const ci = ctx && ctx.ci;
const store = ctx && ctx.store;
const store = ctx && ctx.store;
return gracefulStop({
return gracefulStop({
name: 'OpenVibe.Git',
name: 'OpenVibe.Git',
@@ -21,7 +22,7 @@ function createLifecycle({ server, exit, signals, ctx } = {}) {
@@ -21,7 +22,7 @@ function createLifecycle({ server, exit, signals, ctx } = {}) {
exit,
exit,
signals,
signals,
deadlineMs: 10_000,
deadlineMs: 10_000,
- stop: [() => ctx.reviews && ctx.reviews.stop(), () => (mirrors ? mirrors.stop() : undefined), () => (official ? official.stop() : undefined), () => (checksIntake ? checksIntake.stop() : undefined)],
+ stop: [() => ctx.reviews && ctx.reviews.stop(), () => (mirrors ? mirrors.stop() : undefined), () => (official ? official.stop() : undefined), () => (checksIntake ? checksIntake.stop() : undefined), () => (ci ? ci.stop() : undefined)],
close: [() => catFiles.close(), () => (store ? store.close() : undefined)],
close: [() => catFiles.close(), () => (store ? store.close() : undefined)],
});
});
}
}
@@ -33,6 +34,7 @@ async function start() {
@@ -33,6 +34,7 @@ async function start() {
// Due-based and off unless GIT_OFFICIAL_ORG is set: the first check is shortly after boot, never in this path.
// Due-based and off unless GIT_OFFICIAL_ORG is set: the first check is shortly after boot, never in this path.
ctx.official.start();
ctx.official.start();
ctx.checksIntake.start();
ctx.checksIntake.start();
+ ctx.ci.start();
ctx.reviews.start();
ctx.reviews.start();
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
console.log(`[OpenVibe.Git] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (release ${release.release})`);
console.log(`[OpenVibe.Git] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (release ${release.release})`);
@@ -86,9 +86,10 @@ function createMirrors({ store, repos, config, git, github, log = console }) {
@@ -86,9 +86,10 @@ function createMirrors({ store, repos, config, git, github, log = console }) {
if (!full) return { status: 200, body: { ok: true, ignored: true } };
if (!full) return { status: 200, body: { ok: true, ignored: true } };
const row = await store.maybe('SELECT * FROM git_repos WHERE lower(github_full_name) = lower(?)', [full]);
const row = await store.maybe('SELECT * FROM git_repos WHERE lower(github_full_name) = lower(?)', [full]);
if (!row) return { status: 200, body: { ok: true, ignored: 'no mirror' } };
if (!row) return { status: 200, body: { ok: true, ignored: 'no mirror' } };
- // Acknowledge now, fetch after: GitHub's delivery does not wait on a large clone.
- syncRow(row).catch(() => {});
- return { status: 202, body: { ok: true, queued: true, repo: full } };
+ // Acknowledge now, fetch after: GitHub's delivery does not wait on a large clone. The CI runner is told
+ // after the fetch has brought the pushed commit in (the caller schedules it), never before.
+ const sync = syncRow(row).catch(() => {});
+ return { status: 202, body: { ok: true, queued: true, repo: full }, row, payload, event: headers['x-github-event'], sync };
}
}
/** After a push: mark it and, if a push mirror is on, push back to GitHub. */
/** After a push: mark it and, if a push mirror is on, push back to GitHub. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+
+/**
+ * The CI pages: the runs of one repository and one run's log. Plain and readable, no JavaScript needed. The
+ * verdict is also a commit status ("forge/test") on the change page and in the API.
+ */
+const { esc } = require('../render/html');
+const { repoTabs } = require('./index');
+
+const base = (r) => `/${r.owner_username}/${r.name}`;
+const STATE = {
+ queued: 'Queued', running: 'Running', success: 'Passed', failure: 'Failed', error: 'Error',
+ timed_out: 'Timed out', superseded: 'Superseded',
+};
+
+function when(value) {
+ if (!value) return '—';
+ const d = new Date(value);
+ if (Number.isNaN(d.getTime())) return '—';
+ return d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
+}
+
+function duration(ms) {
+ if (ms == null) return '—';
+ const s = Math.max(0, Math.round(Number(ms) / 1000));
+ const m = Math.floor(s / 60);
+ return m ? `${m}m ${s % 60}s` : `${s}s`;
+}
+
+function state(s) { return `<span class="git-check git-check--${esc(s)}">${esc(STATE[s] || s)}</span>`; }
+
+function list({ row, enabled, runs, context }) {
+ const b = base(row);
+ const rows = runs.map((run) => `<li class="git-ci-row">
+ ${state(run.state)}
+ <a class="git-ci-main" href="${b}/ci/${esc(run.id)}"><b>${esc(run.branch)}</b><code>${esc(String(run.sha).slice(0, 12))}</code></a>
+ <span class="git-muted">${esc(run.pusher || '—')} · ${esc(duration(run.duration_ms))} · ${esc(when(run.created_at))}</span>
+</li>`).join('');
+ return `<div class="git-list-page"><header class="git-page-head"><p class="git-crumb"><a href="${b}">${esc(row.owner_username)}/${esc(row.name)}</a></p><h1>CI</h1>
+<p>${enabled ? `The forge runner tests branch pushes by OpenVibers members and reports <code>${esc(context)}</code>.` : 'Forge CI is not enabled for this repository; GitHub Actions still runs.'}</p></header>
+${repoTabs(row, 'ci')}
+${runs.length ? `<ul class="git-ci-list">${rows}</ul>` : '<p class="git-empty">No forge runs yet.</p>'}
+<p class="git-muted git-small">The runner clones the branch, installs dependencies through an allowlisted proxy, then runs the tests with no network at all. Logs stay on the run they belong to.</p></div>`;
+}
+
+function run({ row, run: r }) {
+ const b = base(row);
+ const log = String(r.log || '');
+ return `<div class="git-list-page"><header class="git-page-head"><p class="git-crumb"><a href="${b}">${esc(row.owner_username)}/${esc(row.name)}</a> · <a href="${b}/ci">CI</a></p>
+<h1>${esc(r.branch)} <code>${esc(String(r.sha).slice(0, 12))}</code></h1>
+<p>${state(r.state)} <span class="git-muted">${esc(r.description || '')}</span></p></header>
+${repoTabs(row, 'ci')}
+<ul class="git-ci-facts">
+ <li><span>Commit</span><a href="${b}/commit/${esc(r.sha)}"><code>${esc(r.sha)}</code></a></li>
+ <li><span>Pusher</span>${esc(r.pusher || '—')}</li>
+ <li><span>Started</span>${esc(when(r.started_at))}</li>
+ <li><span>Finished</span>${esc(when(r.finished_at))}</li>
+ <li><span>Duration</span>${esc(duration(r.duration_ms))}</li>
+ <li><span>Exit code</span>${r.exit_code == null ? '—' : esc(r.exit_code)}</li>
+</ul>
+<section class="git-card" aria-labelledby="git-ci-log-h"><h2 id="git-ci-log-h">Log</h2>
+${log ? `<pre class="git-cmd git-ci-log" aria-live="polite">${esc(log)}</pre>` : '<p class="git-muted">No output was kept.</p>'}
+${r.state === 'running' ? '<p class="git-muted git-small">This run is still writing its log; reload to see more.</p>' : ''}</section></div>`;
+}
+
+module.exports = { list, run, when, duration, STATE };
@@ -123,6 +123,7 @@ function repoTabs(row, active, ref) {
@@ -123,6 +123,7 @@ function repoTabs(row, active, ref) {
['code', 'Code', base],
['code', 'Code', base],
['changes', 'Changes', `${base}/changes`],
['changes', 'Changes', `${base}/changes`],
['issues', 'Issues', `${base}/issues`],
['issues', 'Issues', `${base}/issues`],
+ ['ci', 'CI', `${base}/ci`],
['search', 'Search code', `${base}/search`],
['search', 'Search code', `${base}/search`],
['commits', 'Commits', `${base}/commits`],
['commits', 'Commits', `${base}/commits`],
['branches', 'Branches', `${base}/branches`],
['branches', 'Branches', `${base}/branches`],
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+'use strict';
+/**
+ * The runner's own parts, without podman: the config and its registrations, the token hash, the egress allowlist,
+ * the container command line (network off, 4 CPUs, 6 GB, one writable path), the install/test scripts, and the
+ * wall-clock kill.
+ */
+const assert = require('assert');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { loadConfig, registrations } = require('../runner/config');
+const { podmanArgs, installScript, envList } = require('../runner/sandbox');
+const { isPublicAddress } = require('../runner/proxy');
+const { createProxy } = require('../runner/proxy');
+const { runProcess } = require('../runner/process');
+const { createRunner } = require('../runner/run');
+const { detectEngine } = require('../runner/sandbox');
+const { execFileSync, spawnSync } = require('node:child_process');
+const { hashToken, parseHash, createCiAuth } = require('../server/ci-auth');
+
+(async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-ci-runner-test-'));
+ try {
+ // A token's hash is what the service holds; the raw token is never in it.
+ const token = 'a-test-runner-token-0123456789';
+ const line = hashToken(token);
+ assert.match(line, /^scrypt\$[0-9a-f]{32}\$[0-9a-f]{64}$/);
+ assert.ok(parseHash(line));
+ assert.equal(parseHash('not-a-hash'), null);
+ const auth = createCiAuth({ config: { ci: { runnerToken: token } } });
+ const req = (value) => ({ headers: { authorization: `Bearer ${value}` } });
+ assert.equal(auth.ok(req(token)), true);
+ assert.equal(auth.ok(req(`${token}x`)), false);
+ assert.equal(auth.ok(req('')), false);
+
+ // The configuration names repositories and their commands; a missing token is an error.
+ const tokenFile = path.join(dir, 'token');
+ fs.writeFileSync(tokenFile, `${token}\n`, { mode: 0o600 });
+ const configFile = path.join(dir, 'ci.json');
+ fs.writeFileSync(configFile, JSON.stringify({
+ tokenFile, api: 'https://git.openvibe.codes', engine: 'bwrap', workRoot: path.join(dir, 'work'),
+ repos: { 'openvibers/openvibe.shared': { install_command: 'npm ci', test_command: 'npm test', timeout_seconds: 1800 } },
+ }));
+ const cfg = loadConfig({ GIT_CI_CONFIG: configFile });
+ assert.equal(cfg.engine, 'bwrap');
+ assert.equal(cfg.repos['openvibers/openvibe.shared'].test_command, 'npm test');
+ assert.equal(cfg.concurrency, 1);
+ assert.deepStrictEqual(registrations(cfg).map((r) => r.full_name), ['openvibers/openvibe.shared']);
+ assert.throws(() => loadConfig({ GIT_CI_CONFIG: path.join(dir, 'missing.json'), GIT_CI_TOKEN_FILE: path.join(dir, 'none') }), /cannot be read/);
+
+ // The egress allowlist refuses hostnames outside it and any host that resolves to a private address.
+ assert.equal(isPublicAddress('8.8.8.8'), true);
+ assert.equal(isPublicAddress('127.0.0.1'), false);
+ assert.equal(isPublicAddress('10.1.2.3'), false);
+ assert.equal(isPublicAddress('192.168.1.1'), false);
+ assert.equal(isPublicAddress('::1'), false);
+ assert.equal(isPublicAddress('fd00::1'), false);
+ const proxy = createProxy({ allow: ['registry.npmjs.org'], log: { warn() {} } });
+ await assert.rejects(() => proxy.target('example.com', 443), /allowlist/);
+ await assert.rejects(() => proxy.target('localhost', 443), /allowlist/);
+ assert.equal(proxy.permit('registry.npmjs.org'), true);
+ assert.equal(proxy.permit('registry.npmjs.org.evil.example'), false);
+
+ // The phase command: no network, 4 CPUs, 6 GB, the work directory as the only host path, and the proxy
+ // socket only in the install phase.
+ const limits = { cpus: 4, memory_mb: 6144 };
+ const install = podmanArgs({
+ name: 'ov-ci-cir_1-install', workspace: '/tmp/run-1', image: 'node:22-bookworm', limits,
+ proxySock: '/tmp/run-1/.ci/proxy.sock', script: installScript('npm ci'),
+ });
+ assert.equal(install[0], 'run');
+ assert.ok(install.includes('--network=none'));
+ assert.ok(install.includes('--read-only'));
+ assert.ok(install.includes('--cap-drop'));
+ assert.equal(install[install.indexOf('--cpus') + 1], '4');
+ assert.equal(install[install.indexOf('--memory') + 1], '6144m');
+ assert.ok(install.includes('/tmp/run-1:/workspace:rw'));
+ assert.ok(install.some((a) => a.includes('/tmp/run-1/.ci/proxy.sock')));
+ assert.ok(!install.includes('--privileged'));
+ assert.match(installScript('npm ci'), /http_proxy=http:\/\/127\.0\.0\.1:3128/);
+ assert.doesNotMatch(installScript('npm test', { proxy: false }), /http_proxy/);
+ assert.equal(envList('/tmp/run-1').HOME, '/tmp/run-1/.home');
+
+ const testPhase = podmanArgs({
+ name: 'ov-ci-cir_1-test', workspace: '/tmp/run-1', image: 'node:22-bookworm', limits,
+ proxySock: null, script: installScript('npm test', { proxy: false }),
+ });
+ assert.ok(!testPhase.some((a) => a.includes('proxy.sock')), 'the test phase mounts no proxy');
+ assert.ok(testPhase.includes('--network=none'));
+
+ // The wall clock kills a process that would otherwise run forever.
+ const started = Date.now();
+ const killed = await runProcess({
+ command: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'], timeoutMs: 300,
+ });
+ assert.equal(killed.timedOut, true);
+ assert.ok(Date.now() - started < 10_000, 'the kill resolves promptly');
+ const quick = await runProcess({ command: process.execPath, args: ['-e', 'process.exit(0)'], timeoutMs: 5000 });
+ assert.equal(quick.timedOut, false);
+ assert.equal(quick.code, 0);
+
+ // A complete run against a local repository, in the real sandbox, when this host has one and the image.
+ // GitHub's runners have neither, so the test says why it skipped instead of failing there.
+ let engine = null;
+ try { engine = detectEngine('auto'); } catch { engine = null; }
+ if (!engine) {
+ console.log('ci-runner: no sandbox engine here; the end-to-end run is skipped');
+ } else {
+ const image = 'docker.io/library/node:22-bookworm';
+ const hasImage = engine.kind === 'bwrap' ? true : spawnSync('podman', ['--root', cfg.podmanRoot, '--runroot', cfg.podmanRunRoot, 'image', 'exists', image], { encoding: 'utf8' }).status === 0;
+ if (!hasImage) {
+ console.log(`ci-runner: ${image} is not pulled here; the end-to-end run is skipped`);
+ } else {
+ const git = (...args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
+ const source = path.join(dir, 'source'), bare = path.join(dir, 'bare.git');
+ fs.mkdirSync(source); git('init', '--quiet', '-b', 'main', source);
+ fs.writeFileSync(path.join(source, 'README.md'), 'runner test\n');
+ git('-C', source, 'add', 'README.md');
+ git('-C', source, '-c', 'user.name=Test', '-c', '[email protected]', 'commit', '--quiet', '-m', 'first');
+ const sha = git('-C', source, 'rev-parse', 'HEAD').trim();
+ git('clone', '--quiet', '--bare', source, bare);
+
+ const runConfig = path.join(dir, 'run.json');
+ fs.writeFileSync(runConfig, JSON.stringify({ tokenFile, engine: 'auto', workRoot: path.join(dir, 'work2') }));
+ const runCfg = loadConfig({ GIT_CI_CONFIG: runConfig });
+ let finished = null;
+ const api = {
+ log: async () => ({ ok: true }),
+ heartbeat: async () => ({ found: true, state: 'running', superseded: false }),
+ finish: async (_id, verdict) => { finished = verdict; return { ok: true }; },
+ };
+ const runner = createRunner({ cfg: runCfg, api, log: () => {} });
+ const out = await runner.runJob({
+ run: { id: 'cir_runner_e2e', sha, branch: 'main' },
+ repo: { full_name: 'openvibers/e2e', clone_url: `file://${bare}`, default_branch: 'main' },
+ image,
+ command: { install: 'true', test: 'node -e "process.exit(0)"' },
+ limits: { timeout_seconds: 30, cpus: 1, memory_mb: 1024 },
+ });
+ assert.equal(out.state, 'success', JSON.stringify(out));
+ assert.equal(finished.state, 'success');
+ assert.ok(finished.duration_ms >= 0);
+ }
+ }
+
+ console.log('ci-runner: config, token hash, egress allowlist, sandbox limits, scripts, timeout and one real run passed');
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
+'use strict';
+/**
+ * Forge CI: a branch push by a repository's own writer queues a run; a non-member, a fork and a pull request do
+ * not. One run per repository, the newer push supersedes the older. The runner claims, writes a log, reports a
+ * verdict and that verdict is the commit status. A run left running past its limit is reaped and reported red.
+ */
+const assert = require('assert');
+const { boot } = require('./helpers/boot');
+const { parsePushCommands } = require('../server/git/http-backend');
+
+const TOKEN = 'test-forge-runner-token-0123456789';
+const ok = (body, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body });
+
+(async () => {
+ const calls = [];
+ const realFetch = globalThis.fetch;
+ const fetchImpl = async (url, init) => {
+ const u = String(url);
+ if (!u.startsWith('https://api.github.com/')) return realFetch(url, init);
+ if (u.includes('/collaborators/')) {
+ const login = decodeURIComponent(u.split('/collaborators/')[1].split('/')[0]);
+ calls.push(login);
+ return login.toLowerCase() === 'mallory' ? { ok: false, status: 404, json: async () => ({}) } : ok({ permission: 'write' });
+ }
+ throw new Error(`unexpected GitHub call: ${u}`);
+ };
+ const t = await boot({ fetchImpl, env: { GIT_CI_RUNNER_TOKEN: TOKEN } });
+ try {
+ const owner = t.asUser('openvibers');
+ const make = async (name) => {
+ await t.ctx.repos.create({ actor: owner, name, starter: 'static', visibility: 'public' });
+ const row = await t.ctx.repos.getRow('openvibers', name);
+ const full = `OpenVibers/${name}`;
+ await t.store.execute('UPDATE git_repos SET github_full_name = ?, mirror_url = ? WHERE id = ?', [full, `https://github.com/${full}.git`, row.id]);
+ const head = await t.ctx.git.resolveCommit('openvibers', name, 'main');
+ const fresh = await t.ctx.repos.getRow('openvibers', name);
+ await t.ctx.ci.registerRepos([{ full_name: full, install_command: 'npm ci --no-audit --no-fund', test_command: 'npm test', timeout_seconds: 60 }]);
+ return { row: fresh, head, full };
+ };
+ const push = (sha, pusher, full, extra = {}) => ({
+ ref: 'refs/heads/main', after: sha, pusher: { name: pusher },
+ repository: { full_name: full, fork: false, owner: { login: full.split('/')[0] } },
+ ...extra,
+ });
+ const live = await make('live');
+
+ // The runner registers the repository; a session cannot, and neither can a wrong token.
+ assert.equal((await t.get('/api/v1/ci/repos', { json: { repos: [{ full_name: 'openvibers/live' }] } })).status, 401);
+ assert.equal((await t.get('/api/v1/ci/repos', { bearer: 'not-the-token', json: { repos: [{ full_name: 'openvibers/live' }] } })).status, 401);
+ const reg = await t.get('/api/v1/ci/repos', {
+ bearer: TOKEN,
+ json: { repos: [{ full_name: 'openvibers/live', install_command: 'npm ci --no-audit --no-fund', test_command: 'npm test', timeout_seconds: 60 }] },
+ });
+ assert.equal(reg.status, 200, reg.text);
+ assert.equal(reg.json().repositories[0].full_name, 'openvibers/live');
+
+ // The owner's account is a member without a GitHub call; the run is queued and the status pends.
+ const member = await t.ctx.ci.onGithubPush(live.row, push(live.head, 'OpenVibers', live.full));
+ assert.equal(member.run.state, 'queued', JSON.stringify(member));
+ assert.equal(calls.length, 0, 'the owning account costs no GitHub call');
+ let statuses = await t.get(`/api/v1/repos/openvibers/live/commits/${live.head}/statuses`);
+ assert.deepStrictEqual(statuses.json().statuses.map((s) => [s.context, s.state]), [['forge/test', 'pending']]);
+ assert.match(statuses.json().statuses[0].target_url, /\/openvibers\/live\/ci\/cir_/);
+
+ // A collaborator with write runs only after GitHub says so; the same answer is cached for ten minutes.
+ const friend = await make('friend');
+ const friendRun = await t.ctx.ci.onGithubPush(friend.row, push(friend.head, 'Friend', friend.full));
+ assert.equal(friendRun.run.state, 'queued');
+ assert.deepStrictEqual(calls, ['Friend']);
+ assert.equal((await t.ctx.ci.onGithubPush(friend.row, push(friend.head, 'Friend', friend.full))).skipped, 'duplicate');
+ assert.deepStrictEqual(calls, ['Friend'], 'the second check is cached');
+
+ // A non-member, a fork, a tag and a pull request never run.
+ const stranger = await make('stranger');
+ const denied = await t.ctx.ci.onGithubPush(stranger.row, push(stranger.head, 'Mallory', stranger.full));
+ assert.equal(denied.skipped, 'not-member');
+ const forked = await t.ctx.ci.onGithubPush(stranger.row, push(stranger.head, 'Mallory', stranger.full, { repository: { full_name: stranger.full, fork: true, owner: { login: 'OpenVibers' } } }));
+ assert.equal(forked.skipped, 'fork');
+ assert.equal((await t.ctx.ci.onGithubPush(stranger.row, push(stranger.head, 'OpenVibers', stranger.full, { ref: 'refs/tags/v1' }))).skipped, 'not-a-branch');
+ assert.equal((await t.ctx.ci.onGithubPush(stranger.row, push(stranger.head, 'OpenVibers', 'OpenVibers/SomewhereElse'))).skipped, 'not-this-repository');
+ assert.equal((await t.ctx.ci.runsFor(stranger.row)).length, 0);
+
+ // Claim hands out the repository, the clone URL and the commands; a claim is one at a time.
+ const claimed = await t.get('/api/v1/ci/runs/claim', { bearer: TOKEN, json: {} });
+ assert.equal(claimed.status, 200, claimed.text);
+ const job = claimed.json();
+ assert.equal(job.repo.full_name, 'openvibers/live');
+ assert.match(job.repo.clone_url, /\/openvibers\/live\.git$/);
+ assert.equal(job.command.test, 'npm test');
+ assert.equal(job.limits.timeout_seconds, 60);
+ assert.equal((await t.get(`/api/v1/ci/runs/${job.run.id}/heartbeat`, { bearer: TOKEN })).json().state, 'running');
+
+ // Log chunks reach the run and its page; the verdict becomes the commit status.
+ assert.equal((await t.get(`/api/v1/ci/runs/${job.run.id}/log`, { bearer: TOKEN, json: { chunk: 'installing…\n' } })).status, 200);
+ assert.equal((await t.get(`/api/v1/ci/runs/${job.run.id}/log`, { bearer: TOKEN, json: { chunk: '47 tests passed\n' } })).status, 200);
+ const done = await t.get(`/api/v1/ci/runs/${job.run.id}/status`, { bearer: TOKEN, json: { state: 'success', description: 'Forge runner passed in 9s', exit_code: 0, duration_ms: 9000 } });
+ assert.equal(done.status, 200, done.text);
+ assert.equal(done.json().run.state, 'success');
+ statuses = await t.get(`/api/v1/repos/openvibers/live/commits/${live.head}/statuses`);
+ assert.equal(statuses.json().state, 'success');
+ const page = await t.get(`/openvibers/live/ci/${job.run.id}`);
+ assert.match(page.text, /47 tests passed/);
+ assert.match(page.text, /Forge runner passed in 9s/);
+ const apiRun = await t.get(`/api/v1/repos/openvibers/live/ci/runs/${job.run.id}`);
+ assert.equal(apiRun.json().log, 'installing…\n47 tests passed\n');
+ assert.equal((await t.get(`/api/v1/ci/runs/${job.run.id}/status`, { bearer: TOKEN, json: { state: 'failure' } })).status, 409, 'a finished run cannot be changed');
+
+ // The collaborator's queued run is drained too, so the next queue is the supersede case alone.
+ const friendClaim = (await t.get('/api/v1/ci/runs/claim', { bearer: TOKEN, json: {} })).json();
+ assert.equal(friendClaim.run.id, friendRun.run.id);
+ assert.equal((await t.get(`/api/v1/ci/runs/${friendClaim.run.id}/status`, { bearer: TOKEN, json: { state: 'success' } })).status, 200);
+
+ // The supersede rule: a newer push cancels the live one, and the runner's heartbeat says so.
+ const live2 = await make('live2');
+ await t.get('/api/v1/ci/repos', { bearer: TOKEN, json: { repos: [{ full_name: 'openvibers/live2', timeout_seconds: 60 }] } });
+ const first = await t.ctx.ci.onGithubPush(live2.row, push(live2.head, 'OpenVibers', live2.full));
+ const claimedFirst = (await t.get('/api/v1/ci/runs/claim', { bearer: TOKEN, json: {} })).json();
+ assert.equal(claimedFirst.run.id, first.run.id);
+ const nextHead = await t.ctx.git.commitFiles('openvibers', 'live2', {
+ branch: 'main', base: 'main', message: 'A newer push', files: [{ path: 'new.txt', content: 'new\n' }],
+ author: { name: 'OpenVibers', email: '[email protected]' },
+ });
+ const second = await t.ctx.ci.onGithubPush(live2.row, push(nextHead.sha, 'OpenVibers', live2.full));
+ assert.equal(second.run.state, 'queued');
+ assert.equal(second.run.sha, nextHead.sha);
+ assert.equal((await t.get(`/api/v1/ci/runs/${claimedFirst.run.id}/heartbeat`, { bearer: TOKEN })).json().superseded, true);
+ assert.equal((await t.get(`/api/v1/ci/runs/${claimedFirst.run.id}/status`, { bearer: TOKEN, json: { state: 'success' } })).status, 409);
+ const claimedSecond = (await t.get('/api/v1/ci/runs/claim', { bearer: TOKEN, json: {} })).json();
+ assert.equal(claimedSecond.run.id, second.run.id);
+
+ // A runner that disappeared: past its limit, the next sweep times it out and reports it red.
+ await t.store.execute("UPDATE git_ci_runs SET started_at = now() - interval '3 minutes' WHERE id = ?", [claimedSecond.run.id]);
+ await t.ctx.ci.reap();
+ const reaped = await t.ctx.ci.runFor(live2.row, claimedSecond.run.id);
+ assert.equal(reaped.state, 'timed_out');
+ const reapedStatus = await t.get(`/api/v1/repos/openvibers/live2/commits/${nextHead.sha}/statuses`);
+ assert.equal(reapedStatus.json().statuses[0].state, 'failure');
+ assert.match(reapedStatus.json().statuses[0].description, /timed out after 1 minutes/);
+
+ // A push that lands on the forge itself: the network's own service token may run it, a person's token
+ // may not (the forge knows an OpenVibe account, not the GitHub login the member rule is about).
+ const pkt = (line) => `${(Buffer.byteLength(line) + 4).toString(16).padStart(4, '0')}${line}`;
+ const pack = Buffer.from(`${pkt(`0000000000000000000000000000000000000000 ${stranger.head} refs/heads/main\u0000 report-status\n`)}0000PACK`);
+ assert.deepStrictEqual(parsePushCommands(pack).map((c) => [c.new, c.ref]), [[stranger.head, 'refs/heads/main']]);
+ const forgeRun = await t.ctx.ci.onForgePush(stranger.row, { actor_type: 'service', subject: 'svc:release', scopes: new Set(['git.repo.write']) }, { ref: 'refs/heads/main', sha: stranger.head });
+ assert.equal(forgeRun.run.state, 'queued');
+ assert.equal((await t.ctx.ci.onForgePush(live.row, { actor_type: 'user', subject: 'usr_x', scopes: new Set(['git.repo.write']) }, { ref: 'refs/heads/main', sha: live.head })).skipped, 'not-a-service');
+
+ // A manual run by the owner starts the head; a stranger cannot start one.
+ const manual = await t.get('/api/v1/repos/openvibers/live/ci/runs', { as: owner, json: {} });
+ assert.equal(manual.status, 201, manual.text);
+ assert.equal(manual.json().run.source, 'manual');
+ assert.equal((await t.get('/api/v1/repos/openvibers/live/ci/runs', { as: t.asUser('mallory'), json: {} })).status, 403);
+ assert.ok(!t.logs().join('\n').includes(TOKEN), 'the runner token is never logged');
+
+ console.log('ci: member, non-member, fork, supersede, claim, log, status intake and timeout passed');
+ } finally { await t.close(); }
+})().catch((e) => { console.error(e); process.exit(1); });