openvibers/openvibe-git Commits

Forge CI: sandboxed runner for member branch pushes (#30)

OP OpenVibers committed 2 days ago · 5071233cb5 · 31 files, +2272 −13

* Forge CI: a sandboxed runner for member branch pushes

OpenVibe.Git queues a run only for a branch push to an official repository by a login GitHub says may push there (owner, configured member, or collaborator with write; 10-minute cache). Forks, pull requests, tags, bots and non-members keep GitHub Actions. A newer push supersedes the live run.

The workstation runner claims over its own scrypt-hashed token, clones the pushed commit from the forge, installs through an allowlisted Unix-socket proxy (npm registry and codeload only) and runs the tests with no network. Rootless podman (or bubblewrap) with 4 CPUs, 6 GB, a 30-minute limit and one writable path; no secrets or host paths. The verdict is the forge/test commit status; the log is on the forge.

Adds migrations/0008, server/ci.js, server/ci-auth.js, runner/, the CI pages, docs/ci.md, the user unit and install script.

* Fix the git route's smartHttp reference

createApp assigns smartHttp on the context after CI is made; the git route closure still read the removed local. The git-http, security and service-delegation tests caught it.

Changes

ddiff --git a/.env.example b/.env.example
iindex 3794665..7aea982 100644
---- a/.env.example
++++ b/.env.example
@@@ -40,6 +40,18 @@ GIT_MAX_REPO_BYTES=3221225472
GIT_MAX_FILE_BYTES=2097152
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.
GIT_MIRROR_POLL_MS=600000
GIT_MIRROR_TIMEOUT_MS=600000
ddiff --git a/README.md b/README.md
iindex 1d4308b..8e92ede 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
- `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`.
ddiff --git a/deploy/ci/git-ci.example.json b/deploy/ci/git-ci.example.json
nnew file mode 100644
iindex 0000000..c8af98d
---- /dev/null
++++ b/deploy/ci/git-ci.example.json
@@@ -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
++ }
++ }
++}
ddiff --git a/deploy/ci/install.sh b/deploy/ci/install.sh
nnew file mode 100755
iindex 0000000..27a074f
---- /dev/null
++++ b/deploy/ci/install.sh
@@@ -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
ddiff --git a/deploy/ci/runner-token b/deploy/ci/runner-token
nnew file mode 100644
iindex 0000000..7ae457a
---- /dev/null
++++ b/deploy/ci/runner-token
@@@ -0,0 +1 @@
++scrypt$27f76d9288b2c473023c06ad8c7740eb$3f21ac5143cd95004fe924546b00d7bf937c58b6005b05d7846653fc57bc32dd
ddiff --git a/deploy/systemd-user/openvibe-git-ci.service b/deploy/systemd-user/openvibe-git-ci.service
nnew file mode 100644
iindex 0000000..0627c6b
---- /dev/null
++++ b/deploy/systemd-user/openvibe-git-ci.service
@@@ -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
ddiff --git a/docs/ci.md b/docs/ci.md
nnew file mode 100644
iindex 0000000..bd953d0
---- /dev/null
++++ b/docs/ci.md
@@@ -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`.
ddiff --git a/migrations/0008_ci_runs.sql b/migrations/0008_ci_runs.sql
nnew file mode 100644
iindex 0000000..1d87cfd
---- /dev/null
++++ b/migrations/0008_ci_runs.sql
@@@ -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');
ddiff --git a/public/css/git.css b/public/css/git.css
iindex db3d902..f8454bb 100644
---- a/public/css/git.css
++++ b/public/css/git.css
@@@ -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); }
ddiff --git a/runner/api.js b/runner/api.js
nnew file mode 100644
iindex 0000000..4017b48
---- /dev/null
++++ b/runner/api.js
@@@ -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 };
ddiff --git a/runner/config.js b/runner/config.js
nnew file mode 100644
iindex 0000000..0f67084
---- /dev/null
++++ b/runner/config.js
@@@ -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 };
ddiff --git a/runner/index.js b/runner/index.js
nnew file mode 100644
iindex 0000000..77d8c6d
---- /dev/null
++++ b/runner/index.js
@@@ -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 };
ddiff --git a/runner/process.js b/runner/process.js
nnew file mode 100644
iindex 0000000..9eb9d43
---- /dev/null
++++ b/runner/process.js
@@@ -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 };
ddiff --git a/runner/proxy.js b/runner/proxy.js
nnew file mode 100644
iindex 0000000..62d89a2
---- /dev/null
++++ b/runner/proxy.js
@@@ -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 };
ddiff --git a/runner/run.js b/runner/run.js
nnew file mode 100644
iindex 0000000..9a15aed
---- /dev/null
++++ b/runner/run.js
@@@ -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 };
ddiff --git a/runner/sandbox.js b/runner/sandbox.js
nnew file mode 100644
iindex 0000000..18bb6ed
---- /dev/null
++++ b/runner/sandbox.js
@@@ -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,
++};
ddiff --git a/runner/token.js b/runner/token.js
nnew file mode 100644
iindex 0000000..c4da900
---- /dev/null
++++ b/runner/token.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex fd47667..33e8595 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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.');
ddiff --git a/server/ci-auth.js b/server/ci-auth.js
nnew file mode 100644
iindex 0000000..3a47d1e
---- /dev/null
++++ b/server/ci-auth.js
@@@ -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 };
ddiff --git a/server/ci.js b/server/ci.js
nnew file mode 100644
iindex 0000000..0957d3b
---- /dev/null
++++ b/server/ci.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
iindex e8f5c37..56cb943 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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))),
ddiff --git a/server/git/http-backend.js b/server/git/http-backend.js
iindex 5b5dd6e..0773d42 100644
---- a/server/git/http-backend.js
++++ b/server/git/http-backend.js
@@@ -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 };
ddiff --git a/server/github.js b/server/github.js
iindex 50ed8e1..9441b13 100644
---- a/server/github.js
++++ b/server/github.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 84e247e..220a65f 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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.'));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex ee48510..85119d6 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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;
ddiff --git a/server/index.js b/server/index.js
iindex 17979dc..623b458 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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})`);
ddiff --git a/server/mirror.js b/server/mirror.js
iindex ad22552..a26362b 100644
---- a/server/mirror.js
++++ b/server/mirror.js
@@@ -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. */
ddiff --git a/server/views/ci.js b/server/views/ci.js
nnew file mode 100644
iindex 0000000..e1c5ba8
---- /dev/null
++++ b/server/views/ci.js
@@@ -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 };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex ecf698b..c55b8d3 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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`],
ddiff --git a/test/ci-runner.test.js b/test/ci-runner.test.js
nnew file mode 100644
iindex 0000000..22623e4
---- /dev/null
++++ b/test/ci-runner.test.js
@@@ -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); });
ddiff --git a/test/ci.test.js b/test/ci.test.js
nnew file mode 100644
iindex 0000000..d1aa926
---- /dev/null
++++ b/test/ci.test.js
@@@ -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); });