openvibers/openvibe-git Commits
Forge CI: show the forge verdict on the GitHub pull request (#37)
Changes
@@@ -5,6 +5,8 @@"workRoot": "~/.local/share/openvibe-git-ci/work","engine": "auto","concurrency": 1,++ "githubStatus": true,++ "githubContext": "forge/test","pollMs": 5000,"heartbeatMs": 15000,"proxyAllow": ["registry.npmjs.org", "codeload.github.com"],@@@ -17,7 +17,12 @@ A run is made only when all of these are true: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).++copying GitHub's check-runs, so a change can show both `test` (GitHub) and `forge/test` (here). When a run ends, the++runner also posts the same `forge/test` status to GitHub through the workstation's `gh` login (the forge host has no++GitHub token and is not given one), so the **pull request** on GitHub shows the forge's result too, with the run on++the forge as its target. The GitHub post is best effort: a missing `gh` login, a commit GitHub does not know (a++forge-only push) or a GitHub outage is logged, and the forge status stays final either way. `"githubStatus": false`++in `git-ci.json` turns the GitHub copy off.**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.@@@ -37,7 +42,8 @@ error when the runner reports; a timed-out run is a failure.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++ `forge/test` commit status. The runner then posts the same status to GitHub with `gh` (outside the sandbox),++ so the pull request shows it. 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`.@@@ -64,7 +70,7 @@ reason.| 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) |++| 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). The GitHub status is posted by the runner process after the sandbox exits, using the workstation's own `gh` login; no GitHub token is in the runner's config or in a container. || 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 |@@@ -155,10 +161,11 @@ because there is no install cache on GitHub and no queue here.- `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.++ `forge/test` status on the forge.- `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, ownermanual 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`.++ `proxy.js` (the install allowlist), `process.js` (the wall clock), `config.js`, `api.js`, `token.js`, and++ `github-status.js` (the verdict on the GitHub pull request, through `gh`, outside the sandbox).@@@ -10,6 +10,8 @@* "workRoot": "~/.cache/openvibe-git-ci",* "engine": "auto", // auto | podman | bwrap* "concurrency": 1,++ * "githubStatus": true, // also post the verdict to the GitHub pull request (via gh)++ * "githubContext": "forge/test",* "pollMs": 5000,* "proxyAllow": ["registry.npmjs.org", "codeload.github.com"],* "repos": {@@@ -62,6 +64,10 @@ function loadConfig(env = process.env) {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),++ // The verdict goes to GitHub through the workstation's gh login, so the pull request shows it beside++ // GitHub Actions. Off only when the owner says so; the forge status is written either way.++ githubStatus: raw.githubStatus !== false,++ githubContext: String(raw.githubContext || 'forge/test').slice(0, 255),pollMs: int(raw.pollMs, 5000, 1000, 60_000),heartbeatMs: int(raw.heartbeatMs, 15_000, 2000, 60_000),proxyAllow: Array.isArray(raw.proxyAllow) && raw.proxyAllow.lengthnnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++++/**++ * The verdict on GitHub too: the pull request shows `forge/test` beside GitHub Actions, with the run on the++ * forge as its target. The credential is the workstation's `gh` CLI — the forge host has no GitHub token and++ * does not get one; nothing is copied into the runner's config or its sandbox.++ *++ * The post is best effort. A repository missing from the config, a commit GitHub does not know (a forge-only++ * push), a missing `gh` login or a GitHub outage is logged and the verdict on the forge stays final.++ */++const { execFile } = require('node:child_process');++++const FULL_RE = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;++const SHA_RE = /^[0-9a-f]{40}$/i;++++/** A forge final state as GitHub's commit-status API names it. */++function githubState(state) {++ return state === 'success' ? 'success' : state === 'error' ? 'error' : 'failure';++}++++/**++ * Post one final verdict. Resolves { ok, skipped? } and never rejects, so a failure here cannot lose the run.++ * The `exec` argument is the seam the test uses instead of a real gh.++ */++function reportGithubStatus({ cfg, job, state, description = '', targetUrl = '', log = console, exec = execFile }) {++ if (!cfg || cfg.githubStatus === false) return Promise.resolve({ ok: false, skipped: 'off' });++ const full = String((job && job.repo && job.repo.full_name) || '');++ if (!FULL_RE.test(full) || !cfg.repos || !cfg.repos[full.toLowerCase()]) return Promise.resolve({ ok: false, skipped: 'repository' });++ const sha = String((job && job.run && job.run.sha) || '');++ if (!SHA_RE.test(sha)) return Promise.resolve({ ok: false, skipped: 'sha' });++ if (!['success', 'failure', 'error', 'timed_out'].includes(state)) return Promise.resolve({ ok: false, skipped: 'state' });++++ const args = [++ 'api', '--method', 'POST', `repos/${full}/statuses/${sha}`,++ '-f', `state=${githubState(state)}`,++ '-f', `context=${String(cfg.githubContext || 'forge/test').slice(0, 255)}`,++ '-f', `description=${String(description || '').slice(0, 140)}`,++ ];++ if (/^https?:\/\//.test(String(targetUrl || ''))) args.push('-f', `target_url=${targetUrl}`);++++ return new Promise((resolve) => {++ exec('gh', args, { timeout: 20_000, maxBuffer: 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: '1' } }, (err, stdout, stderr) => {++ if (err) {++ const why = String(stderr || err.message || '').trim().replace(/\s+/g, ' ').slice(0, 200);++ log(`[git-ci-runner] GitHub status not sent: ${why}`);++ resolve({ ok: false, error: err.code || 'gh' });++ } else resolve({ ok: true });++ });++ });++}++++module.exports = { reportGithubStatus, githubState };@@@ -12,6 +12,7 @@ const { execFile, spawnSync } = require('node:child_process');const { createProxy } = require('./proxy');const { runProcess } = require('./process');const { podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, detectEngine, RELAY } = require('./sandbox');++const { reportGithubStatus } = require('./github-status');@@@ -193,11 +194,18 @@ function createRunner({ cfg, api, log = console, clock = Date.now }) {if (stopping) { state = 'error'; description = 'The runner stopped before the run finished'; }const duration = clock() - started;if (state !== 'superseded') {++ let finished = null;try {-- await api.finish(run.id, { state, description, exit_code: exitCode, duration_ms: duration });++ const out = await api.finish(run.id, { state, description, exit_code: exitCode, duration_ms: duration });++ finished = out && out.run ? out.run : null;} catch (err) {say(`[git-ci-runner] ${run.id} verdict not sent (${state}): ${err.message}`);}++ // Only a verdict the forge accepted is repeated on GitHub: the forge's status is the source of truth.++ if (finished) {++ const targetUrl = finished.url || `${cfg.api}/${String(job.repo.full_name).toLowerCase()}/ci/${run.id}`;++ await reportGithubStatus({ cfg, job, state, description, targetUrl, log: (line) => say(line) });++ }}fs.rmSync(runDir, { recursive: true, force: true });return { state, duration };@@@ -46,8 +46,33 @@ const { hashToken, parseHash, createCiAuth } = require('../server/ci-auth');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.equal(cfg.githubStatus, true);++ assert.equal(cfg.githubContext, 'forge/test');assert.throws(() => loadConfig({ GIT_CI_CONFIG: path.join(dir, 'missing.json'), GIT_CI_TOKEN_FILE: path.join(dir, 'none') }), /cannot be read/);++ // The verdict on GitHub: gh gets a POST for a configured repository, with the run as the target; an++ // unknown repository, the off switch and a gh refusal all resolve instead of throwing.++ const { reportGithubStatus, githubState } = require('../runner/github-status');++ assert.equal(githubState('timed_out'), 'failure');++ const sha = 'a'.repeat(40);++ const job = { repo: { full_name: 'openvibers/openvibe.shared' }, run: { sha } };++ let ghArgv = null;++ const fakeGh = (cmd, args, _opts, cb) => { ghArgv = [cmd, ...args]; cb(null, '{}', ''); };++ const posted = await reportGithubStatus({++ cfg, job, state: 'success', description: 'Forge runner passed in 9s',++ targetUrl: 'https://git.openvibe.codes/openvibers/openvibe.shared/ci/cir_1', log: () => {}, exec: fakeGh,++ });++ assert.equal(posted.ok, true);++ assert.equal(ghArgv[0], 'gh');++ assert.deepStrictEqual(ghArgv.slice(1, 4), ['api', '--method', 'POST']);++ assert.ok(ghArgv.includes(`repos/openvibers/openvibe.shared/statuses/${sha}`));++ assert.ok(ghArgv.includes('state=success'));++ assert.ok(ghArgv.includes('context=forge/test'));++ assert.ok(ghArgv.includes('target_url=https://git.openvibe.codes/openvibers/openvibe.shared/ci/cir_1'));++ assert.equal((await reportGithubStatus({ cfg, job: { ...job, repo: { full_name: 'openvibers/other' } }, state: 'success' })).skipped, 'repository');++ assert.equal((await reportGithubStatus({ cfg: { ...cfg, githubStatus: false }, job, state: 'success' })).skipped, 'off');++ assert.equal((await reportGithubStatus({ cfg, job, state: 'failure', log: () => {}, exec: (_c, _a, _o, cb) => cb(new Error('boom'), '', 'HTTP 401') })).ok, false);++// 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);
@@ -5,6 +5,8 @@
@@ -5,6 +5,8 @@
"workRoot": "~/.local/share/openvibe-git-ci/work",
"workRoot": "~/.local/share/openvibe-git-ci/work",
"engine": "auto",
"engine": "auto",
"concurrency": 1,
"concurrency": 1,
+ "githubStatus": true,
+ "githubContext": "forge/test",
"pollMs": 5000,
"pollMs": 5000,
"heartbeatMs": 15000,
"heartbeatMs": 15000,
"proxyAllow": ["registry.npmjs.org", "codeload.github.com"],
"proxyAllow": ["registry.npmjs.org", "codeload.github.com"],
@@ -17,7 +17,12 @@ A run is made only when all of these are true:
@@ -17,7 +17,12 @@ A run is made only when all of these are true:
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.
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
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).
+copying GitHub's check-runs, so a change can show both `test` (GitHub) and `forge/test` (here). When a run ends, the
+runner also posts the same `forge/test` status to GitHub through the workstation's `gh` login (the forge host has no
+GitHub token and is not given one), so the **pull request** on GitHub shows the forge's result too, with the run on
+the forge as its target. The GitHub post is best effort: a missing `gh` login, a commit GitHub does not know (a
+forge-only push) or a GitHub outage is logged, and the forge status stays final either way. `"githubStatus": false`
+in `git-ci.json` turns the GitHub copy off.
**One run per repository is live.** A newer branch push supersedes the queued or running one: the older run becomes
**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.
`superseded`, its runner sees that on the next heartbeat (every 15 s), kills its sandbox and reports no verdict.
@@ -37,7 +42,8 @@ error when the runner reports; a timed-out run is a failure.
@@ -37,7 +42,8 @@ error when the runner reports; a timed-out run is a failure.
4. **Test phase** — a fresh sandbox with **no network at all** (`--network=none` / an empty network namespace),
4. **Test phase** — a fresh sandbox with **no network at all** (`--network=none` / an empty network namespace),
the same workspace, no proxy socket mounted.
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
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
+ `forge/test` commit status. The runner then posts the same status to GitHub with `gh` (outside the sandbox),
+ so the pull request shows it. The log is on `/<owner>/<name>/ci/<run>` and its JSON is
`GET /api/v1/repos/<owner>/<name>/ci/runs/<id>`.
`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 commands, image and limit are per repository, registered by the runner from `~/.config/openvibe/git-ci.json`.
@@ -64,7 +70,7 @@ reason.
@@ -64,7 +70,7 @@ reason.
| engine | rootless **podman** if `podman info` says rootless, else **bubblewrap** (the workstation has both; podman is used) |
| 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 |
| 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 |
| 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) |
+| 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). The GitHub status is posted by the runner process after the sandbox exits, using the workstation's own `gh` login; no GitHub token is in the runner's config or in a container. |
| 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 |
| 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 |
| 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 |
| capabilities | all dropped, `no-new-privileges`, a new session; bubblewrap also disables nested user namespaces |
@@ -155,10 +161,11 @@ because there is no install cache on GitHub and no queue here.
@@ -155,10 +161,11 @@ because there is no install cache on GitHub and no queue here.
- `migrations/0008_ci_runs.sql` — `git_ci_repos` (enabled, commands, image, limits) and `git_ci_runs` (state, log,
- `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.
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
- `server/ci.js` — the member rule, enqueue and supersede, claim/heartbeat/log/finish, the reaper and the
- `forge/test` status.
+ `forge/test` status on the forge.
- `server/ci-auth.js` — the runner token's scrypt hash and the constant-time check.
- `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
- `server/http/api.js` — `/api/v1/ci/*` (runner token) and `/api/v1/repos/:owner/:name/ci*` (public reads, owner
manual runs).
manual runs).
- `server/views/ci.js`, `server/http/pages.js` — the CI tab, the run list and the log page.
- `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),
- `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`.
+ `proxy.js` (the install allowlist), `process.js` (the wall clock), `config.js`, `api.js`, `token.js`, and
+ `github-status.js` (the verdict on the GitHub pull request, through `gh`, outside the sandbox).
@@ -10,6 +10,8 @@
@@ -10,6 +10,8 @@
* "workRoot": "~/.cache/openvibe-git-ci",
* "workRoot": "~/.cache/openvibe-git-ci",
* "engine": "auto", // auto | podman | bwrap
* "engine": "auto", // auto | podman | bwrap
* "concurrency": 1,
* "concurrency": 1,
+ * "githubStatus": true, // also post the verdict to the GitHub pull request (via gh)
+ * "githubContext": "forge/test",
* "pollMs": 5000,
* "pollMs": 5000,
* "proxyAllow": ["registry.npmjs.org", "codeload.github.com"],
* "proxyAllow": ["registry.npmjs.org", "codeload.github.com"],
* "repos": {
* "repos": {
@@ -62,6 +64,10 @@ function loadConfig(env = process.env) {
@@ -62,6 +64,10 @@ function loadConfig(env = process.env) {
engine: ['auto', 'podman', 'bwrap'].includes(raw.engine) ? raw.engine : 'auto',
engine: ['auto', 'podman', 'bwrap'].includes(raw.engine) ? raw.engine : 'auto',
image: String(raw.image || 'docker.io/library/node:22-bookworm'),
image: String(raw.image || 'docker.io/library/node:22-bookworm'),
concurrency: int(raw.concurrency, 1, 1, 4),
concurrency: int(raw.concurrency, 1, 1, 4),
+ // The verdict goes to GitHub through the workstation's gh login, so the pull request shows it beside
+ // GitHub Actions. Off only when the owner says so; the forge status is written either way.
+ githubStatus: raw.githubStatus !== false,
+ githubContext: String(raw.githubContext || 'forge/test').slice(0, 255),
pollMs: int(raw.pollMs, 5000, 1000, 60_000),
pollMs: int(raw.pollMs, 5000, 1000, 60_000),
heartbeatMs: int(raw.heartbeatMs, 15_000, 2000, 60_000),
heartbeatMs: int(raw.heartbeatMs, 15_000, 2000, 60_000),
proxyAllow: Array.isArray(raw.proxyAllow) && raw.proxyAllow.length
proxyAllow: Array.isArray(raw.proxyAllow) && raw.proxyAllow.length
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+
+/**
+ * The verdict on GitHub too: the pull request shows `forge/test` beside GitHub Actions, with the run on the
+ * forge as its target. The credential is the workstation's `gh` CLI — the forge host has no GitHub token and
+ * does not get one; nothing is copied into the runner's config or its sandbox.
+ *
+ * The post is best effort. A repository missing from the config, a commit GitHub does not know (a forge-only
+ * push), a missing `gh` login or a GitHub outage is logged and the verdict on the forge stays final.
+ */
+const { execFile } = require('node:child_process');
+
+const FULL_RE = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
+const SHA_RE = /^[0-9a-f]{40}$/i;
+
+/** A forge final state as GitHub's commit-status API names it. */
+function githubState(state) {
+ return state === 'success' ? 'success' : state === 'error' ? 'error' : 'failure';
+}
+
+/**
+ * Post one final verdict. Resolves { ok, skipped? } and never rejects, so a failure here cannot lose the run.
+ * The `exec` argument is the seam the test uses instead of a real gh.
+ */
+function reportGithubStatus({ cfg, job, state, description = '', targetUrl = '', log = console, exec = execFile }) {
+ if (!cfg || cfg.githubStatus === false) return Promise.resolve({ ok: false, skipped: 'off' });
+ const full = String((job && job.repo && job.repo.full_name) || '');
+ if (!FULL_RE.test(full) || !cfg.repos || !cfg.repos[full.toLowerCase()]) return Promise.resolve({ ok: false, skipped: 'repository' });
+ const sha = String((job && job.run && job.run.sha) || '');
+ if (!SHA_RE.test(sha)) return Promise.resolve({ ok: false, skipped: 'sha' });
+ if (!['success', 'failure', 'error', 'timed_out'].includes(state)) return Promise.resolve({ ok: false, skipped: 'state' });
+
+ const args = [
+ 'api', '--method', 'POST', `repos/${full}/statuses/${sha}`,
+ '-f', `state=${githubState(state)}`,
+ '-f', `context=${String(cfg.githubContext || 'forge/test').slice(0, 255)}`,
+ '-f', `description=${String(description || '').slice(0, 140)}`,
+ ];
+ if (/^https?:\/\//.test(String(targetUrl || ''))) args.push('-f', `target_url=${targetUrl}`);
+
+ return new Promise((resolve) => {
+ exec('gh', args, { timeout: 20_000, maxBuffer: 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: '1' } }, (err, stdout, stderr) => {
+ if (err) {
+ const why = String(stderr || err.message || '').trim().replace(/\s+/g, ' ').slice(0, 200);
+ log(`[git-ci-runner] GitHub status not sent: ${why}`);
+ resolve({ ok: false, error: err.code || 'gh' });
+ } else resolve({ ok: true });
+ });
+ });
+}
+
+module.exports = { reportGithubStatus, githubState };
@@ -12,6 +12,7 @@ const { execFile, spawnSync } = require('node:child_process');
@@ -12,6 +12,7 @@ const { execFile, spawnSync } = require('node:child_process');
const { createProxy } = require('./proxy');
const { createProxy } = require('./proxy');
const { runProcess } = require('./process');
const { runProcess } = require('./process');
const { podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, detectEngine, RELAY } = require('./sandbox');
const { podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, detectEngine, RELAY } = require('./sandbox');
+const { reportGithubStatus } = require('./github-status');
@@ -193,11 +194,18 @@ function createRunner({ cfg, api, log = console, clock = Date.now }) {
@@ -193,11 +194,18 @@ function createRunner({ cfg, api, log = console, clock = Date.now }) {
if (stopping) { state = 'error'; description = 'The runner stopped before the run finished'; }
if (stopping) { state = 'error'; description = 'The runner stopped before the run finished'; }
const duration = clock() - started;
const duration = clock() - started;
if (state !== 'superseded') {
if (state !== 'superseded') {
+ let finished = null;
try {
try {
- await api.finish(run.id, { state, description, exit_code: exitCode, duration_ms: duration });
+ const out = await api.finish(run.id, { state, description, exit_code: exitCode, duration_ms: duration });
+ finished = out && out.run ? out.run : null;
} catch (err) {
} catch (err) {
say(`[git-ci-runner] ${run.id} verdict not sent (${state}): ${err.message}`);
say(`[git-ci-runner] ${run.id} verdict not sent (${state}): ${err.message}`);
}
}
+ // Only a verdict the forge accepted is repeated on GitHub: the forge's status is the source of truth.
+ if (finished) {
+ const targetUrl = finished.url || `${cfg.api}/${String(job.repo.full_name).toLowerCase()}/ci/${run.id}`;
+ await reportGithubStatus({ cfg, job, state, description, targetUrl, log: (line) => say(line) });
+ }
}
}
fs.rmSync(runDir, { recursive: true, force: true });
fs.rmSync(runDir, { recursive: true, force: true });
return { state, duration };
return { state, duration };
@@ -46,8 +46,33 @@ const { hashToken, parseHash, createCiAuth } = require('../server/ci-auth');
@@ -46,8 +46,33 @@ const { hashToken, parseHash, createCiAuth } = require('../server/ci-auth');
assert.equal(cfg.repos['openvibers/openvibe.shared'].test_command, 'npm test');
assert.equal(cfg.repos['openvibers/openvibe.shared'].test_command, 'npm test');
assert.equal(cfg.concurrency, 1);
assert.equal(cfg.concurrency, 1);
assert.deepStrictEqual(registrations(cfg).map((r) => r.full_name), ['openvibers/openvibe.shared']);
assert.deepStrictEqual(registrations(cfg).map((r) => r.full_name), ['openvibers/openvibe.shared']);
+ assert.equal(cfg.githubStatus, true);
+ assert.equal(cfg.githubContext, 'forge/test');
assert.throws(() => loadConfig({ GIT_CI_CONFIG: path.join(dir, 'missing.json'), GIT_CI_TOKEN_FILE: path.join(dir, 'none') }), /cannot be read/);
assert.throws(() => loadConfig({ GIT_CI_CONFIG: path.join(dir, 'missing.json'), GIT_CI_TOKEN_FILE: path.join(dir, 'none') }), /cannot be read/);
+ // The verdict on GitHub: gh gets a POST for a configured repository, with the run as the target; an
+ // unknown repository, the off switch and a gh refusal all resolve instead of throwing.
+ const { reportGithubStatus, githubState } = require('../runner/github-status');
+ assert.equal(githubState('timed_out'), 'failure');
+ const sha = 'a'.repeat(40);
+ const job = { repo: { full_name: 'openvibers/openvibe.shared' }, run: { sha } };
+ let ghArgv = null;
+ const fakeGh = (cmd, args, _opts, cb) => { ghArgv = [cmd, ...args]; cb(null, '{}', ''); };
+ const posted = await reportGithubStatus({
+ cfg, job, state: 'success', description: 'Forge runner passed in 9s',
+ targetUrl: 'https://git.openvibe.codes/openvibers/openvibe.shared/ci/cir_1', log: () => {}, exec: fakeGh,
+ });
+ assert.equal(posted.ok, true);
+ assert.equal(ghArgv[0], 'gh');
+ assert.deepStrictEqual(ghArgv.slice(1, 4), ['api', '--method', 'POST']);
+ assert.ok(ghArgv.includes(`repos/openvibers/openvibe.shared/statuses/${sha}`));
+ assert.ok(ghArgv.includes('state=success'));
+ assert.ok(ghArgv.includes('context=forge/test'));
+ assert.ok(ghArgv.includes('target_url=https://git.openvibe.codes/openvibers/openvibe.shared/ci/cir_1'));
+ assert.equal((await reportGithubStatus({ cfg, job: { ...job, repo: { full_name: 'openvibers/other' } }, state: 'success' })).skipped, 'repository');
+ assert.equal((await reportGithubStatus({ cfg: { ...cfg, githubStatus: false }, job, state: 'success' })).skipped, 'off');
+ assert.equal((await reportGithubStatus({ cfg, job, state: 'failure', log: () => {}, exec: (_c, _a, _o, cb) => cb(new Error('boom'), '', 'HTTP 401') })).ok, false);
+
// The egress allowlist refuses hostnames outside it and any host that resolves to a private address.
// 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('8.8.8.8'), true);
assert.equal(isPublicAddress('127.0.0.1'), false);
assert.equal(isPublicAddress('127.0.0.1'), false);