openvibers/openvibe.codes Commits

Codes checks: a kind: 'check' job that runs a workflow job with act (#443)

OP OpenVibers committed 4 h ago · e4aa56b103 · 3 files, +978 −0

Changes

ddiff --git a/harness/check.js b/harness/check.js
nnew file mode 100644
iindex 0000000..21f52a2
---- /dev/null
++++ b/harness/check.js
@@@ -0,0 +1,694 @@
++'use strict';
++
++/**
++ * `openvibe-codes job` with `kind: 'check'`: one workflow job of a repository, run with act in host mode on this
++ * machine instead of on a GitHub-hosted runner (one-system-v2 step 4, piece 1).
++ *
++ * { kind: 'check', repo, sha, workflow, job, matrix?, event: 'push'|'pull_request', ref?, timeout_ms? }
++ *
++ * It clones `repo` at `sha` (depth 1, with the same per-host credential as a task), reads the workflow job, and runs
++ * act with `-P ubuntu-latest=-self-hosted` (no Docker): each step is a `{ t: 'step', name, state, conclusion?, ms? }`
++ * line, other output rides the job's existing `{ t: 'step', text }` lines, and the result is
++ * `{ ok, kind: 'check', conclusion, reason?, steps, log_tail, ms }`.
++ *
++ * A job that needs services, a container, a secret other than `GITHUB_TOKEN`, a Docker action or a runner that is not
++ * an ubuntu label is `skipped` with the reason: the scheduler leaves it to Actions. No secret reaches a step: act gets
++ * a fixed, small environment, never `GITHUB_TOKEN` nor the Codes runner's own variables.
++ *
++ * The act release is pinned here and checked against its sha256 before it is started; the binary is fetched into the
++ * job's own directory and thrown away with it.
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const zlib = require('zlib');
++const { StringDecoder } = require('string_decoder');
++const { spawn, execFile } = require('child_process');
++const YAML = require('yaml');
++const { gitAuthFor, cloneProblem, scrub, JOB_PROTOCOL } = require('./job');
++
++const ACT_VERSION = '0.2.97';
++const ACT_URL = `https://github.com/nektos/act/releases/download/v${ACT_VERSION}/act_Linux_x86_64.tar.gz`;
++const ACT_SHA256 = '67b4976a56f33ea7870b798912e738ec44eef38e7578ae7b532f2def1c73a5ad';
++const MAX_ACT_BYTES = 24 * 1024 * 1024;
++const MAX_ACT_UNPACKED = 64 * 1024 * 1024;
++const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
++const MAX_TIMEOUT_MS = 2 * 60 * 60 * 1000;
++const MAX_WORKFLOW_BYTES = 1024 * 1024;
++const MAX_LINE = 4_000; // one live line (a task's step output is capped the same way)
++const MAX_LOG_TAIL = 16 * 1024; // the result's log tail
++const MAX_RAW_LOG = 128 * 1024;
++const MAX_STEP_MS = Number.MAX_SAFE_INTEGER;
++
++const SHA = /^[0-9a-f]{40}$/i;
++const JOB_ID = /^[A-Za-z_][A-Za-z0-9_-]{0,99}$/;
++const OWNER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
++const MATRIX_KEY = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
++const REF = /^[A-Za-z0-9._/-]{1,200}$/;
++const REPO_HOSTS = new Set(['github.com', 'git.openvibe.codes']);
++const UBUNTU = /^ubuntu(-|$)/i;
++
++/** Both scrubbers a line goes through: provider keys/JWTs and URL credentials/GitHub tokens. */
++// The URL/token scrubber runs first: it removes a credential from a URL as a whole, before the events scrubber turns
++// the token into `[redacted]` and leaves the URL's user part in place.
++const clean = (s) => require('./events').scrub(scrub(String(s == null ? '' : s)));
++const cutTo = (s, n) => { const t = String(s == null ? '' : s); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
++
++function has(object, key) {
++ return !!object && typeof object === 'object' && Object.prototype.hasOwnProperty.call(object, key);
++}
++
++/** git with no prompt and no credential helper; rejects with the command's stderr (tokens scrubbed). */
++function git(args, { cwd, env = {}, timeout = 120_000 } = {}) {
++ return new Promise((resolve, reject) => {
++ execFile('git', args, {
++ cwd, timeout, maxBuffer: 32 * 1024 * 1024,
++ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo', ...env },
++ }, (err, stdout, stderr) => {
++ if (err) reject(new Error(scrub(String(stderr || err.message)).trim().split('\n').slice(-3).join(' ')));
++ else resolve(String(stdout));
++ });
++ });
++}
++
++/**
++ * The input checked. A repository is an https github.com or git.openvibe.codes URL; `file://` is accepted only with
++ * OPENVIBE_CODES_ALLOW_FILE_REPO=1, the same test hook the task path uses. Every field the check needs is validated,
++ * so nothing in the input is a path, a command or a flag.
++ */
++function parseCheckArgs(raw, env = process.env) {
++ let a;
++ try {
++ a = typeof raw === 'string' ? JSON.parse(String(raw || '').trim() || '{}') : raw;
++ } catch {
++ throw new Error('the job input is not JSON');
++ }
++ if (!a || typeof a !== 'object' || Array.isArray(a)) throw new Error('the job input must be an object');
++ if (a.kind !== 'check') throw new Error('the job input is not a check');
++
++ const repo = parseRepo(a.repo, env);
++ const sha = String(a.sha == null ? '' : a.sha).trim();
++ if (!SHA.test(sha)) throw new Error('sha must be a 40-character hex commit');
++ const workflow = String(a.workflow == null ? '' : a.workflow).trim();
++ if (!isWorkflowPath(workflow)) throw new Error('workflow must be a path under .github/workflows/ ending in .yml or .yaml');
++ const job = String(a.job == null ? '' : a.job).trim();
++ if (!JOB_ID.test(job)) throw new Error('job must be a workflow job id');
++ const event = a.event;
++ if (event !== 'push' && event !== 'pull_request') throw new Error('event must be push or pull_request');
++ let ref = null;
++ if (a.ref != null && a.ref !== '') {
++ ref = String(a.ref).trim();
++ if (!REF.test(ref) || ref.startsWith('-') || ref.startsWith('/') || ref.includes('..') || ref.includes('//')) throw new Error('ref must be a branch or tag name');
++ }
++ const matrix = parseMatrix(a.matrix);
++ let timeout_ms = DEFAULT_TIMEOUT_MS;
++ if (a.timeout_ms != null) {
++ if (!Number.isInteger(a.timeout_ms) || a.timeout_ms <= 0) throw new Error('timeout_ms must be a positive integer');
++ timeout_ms = Math.min(a.timeout_ms, MAX_TIMEOUT_MS);
++ }
++ return {
++ kind: 'check', repo: repo.url, repoHost: repo.host, repoOwner: repo.owner, repoName: repo.name,
++ sha: sha.toLowerCase(), workflow, job, matrix, event, ref, timeout_ms,
++ };
++}
++
++/** A repository URL: https on one of the two hosts, or file:// for tests. No credentials, query, fragment or port. */
++function parseRepo(raw, env) {
++ let u;
++ try { u = new URL(String(raw == null ? '' : raw).trim()); } catch { throw new Error('repo must be a repository URL'); }
++ const fileOk = env.OPENVIBE_CODES_ALLOW_FILE_REPO === '1' && u.protocol === 'file:';
++ if (u.protocol !== 'https:' && !fileOk) throw new Error('repo must be an https github.com or git.openvibe.codes URL');
++ if (u.username || u.password) throw new Error('repo must not carry credentials');
++ if (u.search || u.hash) throw new Error('repo must not carry a query or fragment');
++ if (u.port) throw new Error('repo must not name a port');
++ if (fileOk) {
++ if (!u.pathname || u.pathname === '/') throw new Error('repo must be a repository path');
++ return { url: u.toString(), host: 'local', owner: 'local', name: path.basename(u.pathname) || 'repo' };
++ }
++ const host = u.hostname.toLowerCase();
++ if (!REPO_HOSTS.has(host)) throw new Error('repo must be an https github.com or git.openvibe.codes URL');
++ const parts = u.pathname.replace(/\/+$/, '').replace(/\.git$/i, '').split('/').filter(Boolean);
++ if (parts.length !== 2 || !OWNER_NAME.test(parts[0]) || !OWNER_NAME.test(parts[1]) || parts[0] === '.' || parts[0] === '..' || parts[1] === '.' || parts[1] === '..') {
++ throw new Error('repo must name one owner and one repository');
++ }
++ return { url: `https://${host}/${parts[0]}/${parts[1]}`, host, owner: parts[0], name: parts[1] };
++}
++
++/** A workflow file under `.github/workflows/`, no traversal, no absolute path, no control characters. */
++function isWorkflowPath(value) {
++ if (typeof value !== 'string' || !value || value.length > 300 || /[\0-\x1f\x7f\\]/.test(value)) return false;
++ const parts = value.split('/');
++ if (parts.length < 3 || parts[0] !== '.github' || parts[1] !== 'workflows') return false;
++ if (!parts.every((part) => part && part !== '.' && part !== '..' && /^[A-Za-z0-9._-]+$/.test(part))) return false;
++ return /\.ya?ml$/i.test(parts[parts.length - 1]);
++}
++
++/** The matrix act receives: scalar keys and values only, a bounded number of them. */
++function parseMatrix(raw) {
++ if (raw == null) return {};
++ if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('matrix must be an object of scalar values');
++ const out = {};
++ for (const [key, value] of Object.entries(raw)) {
++ if (!MATRIX_KEY.test(key) || ['__proto__', 'constructor', 'prototype'].includes(key)) throw new Error('matrix keys must be names');
++ let v;
++ if (typeof value === 'string') {
++ if (!value || value.length > 200 || /[\0\r\n]/.test(value)) throw new Error('matrix values must be short strings, numbers or booleans');
++ v = value;
++ } else if (typeof value === 'number') {
++ if (!Number.isFinite(value)) throw new Error('matrix values must be finite');
++ v = String(value);
++ } else if (typeof value === 'boolean') {
++ v = String(value);
++ } else {
++ throw new Error('matrix values must be strings, numbers or booleans');
++ }
++ out[key] = v;
++ }
++ return out;
++}
++
++/** A workflow parsed as plain objects; an unreadable one is an error (a failure), not a skip. */
++function parsedWorkflow(source) {
++ let doc;
++ try {
++ doc = YAML.parseDocument(String(source), { uniqueKeys: true, maxAliasCount: 0, strict: true });
++ } catch (e) {
++ throw new Error(`the workflow could not be read: ${e.message}`);
++ }
++ if (doc.errors && doc.errors.length) throw new Error(`the workflow could not be read: ${doc.errors[0].message}`);
++ let wf;
++ try {
++ wf = doc.toJS({ maxAliasCount: 0 });
++ } catch (e) {
++ throw new Error(`the workflow could not be read: ${e.message}`);
++ }
++ if (!wf || typeof wf !== 'object' || Array.isArray(wf) || !wf.jobs || typeof wf.jobs !== 'object' || Array.isArray(wf.jobs)) {
++ throw new Error('the workflow has no jobs');
++ }
++ return wf;
++}
++
++function jobIn(wf, jobId) {
++ const job = has(wf.jobs, jobId) ? wf.jobs[jobId] : null;
++ if (!job || typeof job !== 'object' || Array.isArray(job)) throw new Error(`the workflow has no job named ${jobId}`);
++ return job;
++}
++
++/** The workflow's jobs.<id> as a plain object; an unreadable workflow or a missing job is an error, not a skip. */
++function workflowJob(source, jobId) {
++ return jobIn(parsedWorkflow(source), jobId);
++}
++
++/**
++ * Why this job must be left to Actions (`services:`, `container:`, another secret, a non-ubuntu runner, a Docker
++ * action), or null when act host mode can run it. The scheduler reads `skipped` and does not retry another node.
++ */
++function skipReason(source, jobId, matrix = {}) {
++ const wf = parsedWorkflow(source);
++ const job = jobIn(wf, jobId);
++ if (has(job, 'services')) return 'the job uses services:, which act in host mode cannot run';
++ if (has(job, 'container')) return 'the job uses container:, which act in host mode cannot run';
++ if (has(job, 'secrets')) return 'the job passes secrets to another workflow, which is not run on a node';
++ // A workflow-level env applies to every job: a secret there reaches this job's steps too.
++ const secret = secretUse(job) || secretUse(wf.env);
++ if (secret) return secret.name ? `the job uses the secret ${secret.name}, not only GITHUB_TOKEN` : 'the job uses a secret whose name it computes, not only GITHUB_TOKEN';
++ const runner = runnerCheck(job, matrix);
++ if (!runner.ok) return runner.reason;
++ const docker = dockerUse(job);
++ if (docker) return `the job uses the Docker action ${docker}, which act in host mode cannot run`;
++ return null;
++}
++
++/** Whether a job reads `secrets.*` (or `secrets[...]`) other than GITHUB_TOKEN; walks every string it may evaluate. */
++function secretUse(value, depth = 0, seen = new Set()) {
++ if (depth > 30 || value == null) return null;
++ if (typeof value === 'string') {
++ const re = /\bsecrets\s*(?:\.\s*([A-Za-z_][A-Za-z0-9_]*)|\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\]|\[)/g;
++ let m;
++ while ((m = re.exec(value))) {
++ const name = m[1] || m[2] || null;
++ if (name && name.toUpperCase() === 'GITHUB_TOKEN') continue;
++ return { name };
++ }
++ return null;
++ }
++ if (typeof value !== 'object') return null;
++ if (seen.has(value)) return null;
++ seen.add(value);
++ if (Array.isArray(value)) {
++ for (const item of value) { const found = secretUse(item, depth + 1, seen); if (found) return found; }
++ return null;
++ }
++ for (const item of Object.values(value)) { const found = secretUse(item, depth + 1, seen); if (found) return found; }
++ return null;
++}
++
++/** The runner the job asks for: every label it resolves to must be an ubuntu one, and act must be told so. */
++function runnerCheck(job, matrix) {
++ const labels = runnerLabels(job, matrix);
++ if (!labels || !labels.length) return { ok: false, reason: 'the job runs on a runner act cannot resolve to an ubuntu label' };
++ const other = labels.filter((label) => !UBUNTU.test(label));
++ if (other.length) return { ok: false, reason: `the job runs on ${other.join(', ')}, not an ubuntu label` };
++ // The pinned act binary is linux x64; an ARM ubuntu runner would run here on the wrong architecture.
++ const arm = labels.find((label) => /(?:^|[-_.])arm(?:64)?(?:$|[-_.])|aarch64/i.test(label));
++ if (arm) return { ok: false, reason: `the job runs on ${arm}, not an x64 ubuntu label` };
++ return { ok: true, labels: [...new Set(labels)] };
++}
++
++function runnerLabels(job, matrix) {
++ const raw = job['runs-on'];
++ if (typeof raw === 'string') {
++ const expr = /^\s*\$\{\{\s*matrix\.([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}\s*$/.exec(raw);
++ if (expr) {
++ const key = expr[1];
++ if (matrix && has(matrix, key)) return [String(matrix[key])];
++ const values = workflowMatrixValues(job, key);
++ return values.length ? values : null;
++ }
++ if (/\$\{\{/.test(raw)) return null;
++ return [raw];
++ }
++ if (Array.isArray(raw)) {
++ const out = [];
++ for (const item of raw) {
++ if (typeof item !== 'string' || /\$\{\{/.test(item)) return null;
++ out.push(item);
++ }
++ return out;
++ }
++ if (raw && typeof raw === 'object' && Array.isArray(raw.labels)) return runnerLabels({ ...job, 'runs-on': raw.labels }, matrix);
++ return null;
++}
++
++/** The values a workflow's strategy.matrix gives a key; `include` may add one, an expression cannot be read here. */
++function workflowMatrixValues(job, key) {
++ const m = job.strategy && typeof job.strategy === 'object' ? job.strategy.matrix : null;
++ if (!m || typeof m !== 'object' || Array.isArray(m)) return [];
++ const values = [];
++ const add = (v) => {
++ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') values.push(String(v));
++ else if (v != null) values.push(null);
++ };
++ if (has(m, key)) {
++ if (Array.isArray(m[key])) for (const v of m[key]) add(v);
++ else add(m[key]);
++ }
++ if (Array.isArray(m.include)) {
++ for (const inc of m.include) if (inc && typeof inc === 'object' && has(inc, key)) add(inc[key]);
++ }
++ return values.filter((v) => v !== null);
++}
++
++/** A step that needs the Docker daemon (`uses: docker://…`): act host mode would call Docker. */
++function dockerUse(job) {
++ for (const step of Array.isArray(job.steps) ? job.steps : []) {
++ if (step && typeof step === 'object' && typeof step.uses === 'string' && /^docker:\/\//i.test(step.uses.trim())) return step.uses.trim();
++ }
++ return null;
++}
++
++/**
++ * The event JSON act reads. It carries the repository, the commit and the branch/PR the job is for; nothing secret.
++ * `ref` is the branch the scheduler named (a push) or the PR head (a pull_request).
++ */
++function eventPayload(job) {
++ const branch = (job.ref || 'main').replace(/^refs\/heads\//, '');
++ const fullRef = job.ref && job.ref.startsWith('refs/') ? job.ref : `refs/heads/${branch}`;
++ const repository = {
++ full_name: `${job.repoOwner}/${job.repoName}`,
++ name: job.repoName,
++ owner: { login: job.repoOwner },
++ html_url: job.repo,
++ clone_url: `${job.repo}.git`,
++ };
++ const sender = { login: 'openvibe-codes' };
++ if (job.event === 'pull_request') {
++ const pr = job.ref ? /refs\/pull\/(\d+)\//.exec(job.ref) : null;
++ const number = pr ? Number(pr[1]) : 1;
++ return {
++ action: 'synchronize', number, repository, sender,
++ pull_request: {
++ number, state: 'open',
++ head: { ref: branch, sha: job.sha, repo: repository },
++ base: { ref: 'main', sha: job.sha, repo: repository },
++ },
++ };
++ }
++ return {
++ ref: fullRef, before: '0'.repeat(40), after: job.sha, repository, sender,
++ pusher: { name: 'openvibe-codes' }, head_commit: { id: job.sha },
++ };
++}
++
++/**
++ * A line of act's output, as the step it starts/ends or as plain output. Recorded act output:
++ *
++ * [ci/test] ⭐ Run Main say hello
++ * [ci/test] | hello
++ * [ci/test] ✅ Success - Main say hello [11.7ms]
++ */
++function parseActLine(raw) {
++ const line = String(raw == null ? '' : raw).replace(/\x1b\[[0-9;]*m/g, '').replace(/\r$/, '');
++ let m = /^\[[^\]]+\]\s+⭐\s+Run\s+(?:(Pre|Post|Main)\s+)?(.+?)\s*$/u.exec(line);
++ if (m) return { type: 'started', phase: m[1] || null, name: m[2].trim() };
++ // Go's duration String(): 11.75651ms, 1.2s, 500µs, and 1m2.3s for anything over a minute.
++ m = /^\[[^\]]+\]\s+([^\s|])\ufe0f?\s+(Success|Failure|Skipped|Cancelled)\s+-\s+(?:(Pre|Post|Main)\s+)?(.+?)(?:\s+\[((?:\d+(?:\.\d+)?(?:h|ms|µs|us|ns|m|s))+)\])?\s*$/u.exec(line);
++ if (m) {
++ const ms = m[5] == null ? null : durationMs(m[5]);
++ return { type: 'completed', phase: m[3] || null, name: m[4].trim(), conclusion: m[2].toLowerCase(), ms };
++ }
++ return { type: 'output', text: line };
++}
++
++/** A Go-style duration (`1m2.3s`, `11.7ms`) as whole milliseconds. */
++function durationMs(text) {
++ let total = 0;
++ for (const m of String(text).matchAll(/(\d+(?:\.\d+)?)(h|ms|µs|us|ns|m|s)/g)) {
++ total += Number(m[1]) * ({ h: 3_600_000, m: 60_000, s: 1_000, ms: 1, 'µs': 0.001, us: 0.001, ns: 0.000001 }[m[2]] || 1);
++ }
++ return Math.max(0, Math.round(total));
++}
++
++/** The text of an output line: act's `[workflow/job]` prefix and its `| ` output marker removed, not the words. */
++function outputText(line) {
++ let text = String(line == null ? '' : line);
++ const prefix = /^\[[^\]]+\]\s?(.*)$/.exec(text);
++ if (prefix) text = prefix[1];
++ text = text.replace(/^\s*\| ?/, '').replace(/\s+$/, '');
++ return text;
++}
++
++function phaseName(phase, name) {
++ if (!phase || phase === 'Main') return name;
++ return `${phase} ${name}`;
++}
++
++/** The pinned act release, verified against its sha256, written into the job's directory. */
++async function prepareAct(dir, { env = process.env, fetchImpl = globalThis.fetch } = {}) {
++ const stub = env.OPENVIBE_CODES_ACT_BIN;
++ if (stub) {
++ const real = fs.realpathSync(String(stub));
++ const st = fs.statSync(real);
++ if (!st.isFile()) throw new Error('OPENVIBE_CODES_ACT_BIN is not a file');
++ return real;
++ }
++ const response = await fetchImpl(ACT_URL, {
++ redirect: 'follow',
++ headers: { 'User-Agent': 'openvibe-codes' },
++ signal: AbortSignal.timeout(120_000),
++ });
++ if (!response || !response.ok) throw new Error(`the act release could not be fetched (${response ? response.status : 'no answer'})`);
++ const bytes = Buffer.from(await response.arrayBuffer());
++ if (!bytes.length || bytes.length > MAX_ACT_BYTES) throw new Error('the act release is not the expected size');
++ const digest = crypto.createHash('sha256').update(bytes).digest('hex');
++ if (digest !== ACT_SHA256) throw new Error(`the act release did not match its sha256 (got ${digest})`);
++ let unpacked;
++ try {
++ unpacked = zlib.gunzipSync(bytes, { maxOutputLength: MAX_ACT_UNPACKED });
++ } catch (e) {
++ throw new Error(`the act release could not be unpacked: ${e.message}`);
++ }
++ const binary = tarEntry(unpacked, 'act');
++ const dest = path.join(dir, 'act');
++ fs.writeFileSync(dest, binary, { mode: 0o755 });
++ return dest;
++}
++
++/** The bytes of one regular file in an in-memory tar; `act` is the only entry this needs. */
++function tarEntry(tar, wanted) {
++ let offset = 0;
++ while (offset + 512 <= tar.length) {
++ const header = tar.subarray(offset, offset + 512);
++ if (header.every((b) => b === 0)) break;
++ const read = (from, to) => header.subarray(from, to).toString('utf8').replace(/\0.*$/s, '');
++ const name = read(0, 100);
++ const prefix = read(345, 500);
++ const full = (prefix ? `${prefix}/${name}` : name).replace(/^\.\//, '');
++ const sizeText = read(124, 136).trim();
++ const size = sizeText ? parseInt(sizeText, 8) : 0;
++ if (!Number.isFinite(size) || size < 0 || offset + 512 + size > tar.length) throw new Error('the act release has a broken tar entry');
++ const type = String.fromCharCode(header[156] || 48);
++ if ((type === '0' || type === '\0' || type === '7') && full === wanted) return Buffer.from(tar.subarray(offset + 512, offset + 512 + size));
++ offset += 512 + Math.ceil(size / 512) * 512;
++ }
++ throw new Error('the act release does not hold the act binary');
++}
++
++/**
++ * The environment act and the steps get: only safe system values, a writable HOME/cache/tmp inside the job's own
++ * directory, and never a token. `GITHUB_TOKEN` and every other variable of the Codes runner's environment are left
++ * out (a job's workflow env is act's own business and is read from the workflow, not from this process).
++ */
++function actEnvironment(env, dir) {
++ const out = {};
++ for (const key of ['PATH', 'LANG', 'LC_ALL', 'LC_CTYPE', 'LC_NUMERIC', 'LC_TIME', 'LC_COLLATE', 'LC_MONETARY', 'LC_MESSAGES', 'LC_PAPER', 'LC_NAME', 'LC_ADDRESS', 'LC_TELEPHONE', 'LC_MEASUREMENT', 'LC_IDENTIFICATION', 'TZ', 'TERM', 'COLORTERM', 'SHELL']) {
++ if (typeof env[key] === 'string' && env[key] && !/[\0\r\n]/.test(env[key])) out[key] = env[key];
++ }
++ const home = path.join(dir, 'home');
++ const cache = path.join(dir, 'cache');
++ const tmp = path.join(dir, 'tmp');
++ for (const d of [home, cache, tmp]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
++ out.HOME = home;
++ out.XDG_CACHE_HOME = cache;
++ out.TMPDIR = tmp;
++ out.GIT_TERMINAL_PROMPT = '0';
++ out.GIT_ASKPASS = 'echo';
++ out.CI = 'true';
++ return out;
++}
++
++/** Kill act's whole process group: a step that ignores SIGTERM must not outlive its check. */
++function stopGroup(child, signal) {
++ if (!child || !child.pid) return;
++ try { process.kill(-child.pid, signal); } catch {
++ try { child.kill(signal); } catch { /* already gone */ }
++ }
++}
++
++/**
++ * Run act and stream its output line by line. Resolves with the exit code/signal, whether this call timed it out, or
++ * the error that kept it from starting. The timeout starts when act starts, not while the clone/download runs.
++ */
++function runAct(binary, args, { cwd, env, timeout_ms, onLine }) {
++ return new Promise((resolve) => {
++ let child;
++ try {
++ child = spawn(binary, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
++ } catch (e) {
++ resolve({ error: e });
++ return;
++ }
++ let settled = false;
++ let timedOut = false;
++ let timer = null;
++ let killTimer = null;
++ // A line can arrive in pieces (or a UTF-8 character can straddle two chunks): keep both streams' remains.
++ const decoder = { out: new StringDecoder('utf8'), err: new StringDecoder('utf8') };
++ const remains = { out: '', err: '' };
++ const finish = (value) => {
++ if (settled) return;
++ settled = true;
++ if (timer) clearTimeout(timer);
++ if (killTimer) clearTimeout(killTimer);
++ resolve(value);
++ };
++ const emitLine = (line) => onLine(line.length > 64 * 1024 ? `${line.slice(0, 64 * 1024)}…` : line);
++ const consume = (stream, chunk) => {
++ const text = remains[stream] + decoder[stream].write(chunk);
++ const parts = text.split('\n');
++ remains[stream] = parts.pop();
++ for (const line of parts) emitLine(line);
++ };
++ const flush = () => {
++ for (const stream of ['out', 'err']) {
++ const last = remains[stream] + decoder[stream].end();
++ remains[stream] = '';
++ if (last) emitLine(last);
++ }
++ };
++ child.stdout.on('data', (chunk) => consume('out', chunk));
++ child.stderr.on('data', (chunk) => consume('err', chunk));
++ child.on('error', (e) => finish({ error: e, timedOut }));
++ child.on('close', (code, signal) => {
++ flush();
++ finish({ code, signal, timedOut });
++ });
++ timer = setTimeout(() => {
++ timedOut = true;
++ stopGroup(child, 'SIGTERM');
++ killTimer = setTimeout(() => stopGroup(child, 'SIGKILL'), 2_000);
++ if (killTimer.unref) killTimer.unref();
++ }, timeout_ms);
++ if (timer.unref) timer.unref();
++ });
++}
++
++/**
++ * A check job. `emit(line)` prints a line, `writeResult(doc)` hands the result over (once); it never throws for a bad
++ * input or a failed run: the result document says what happened so Run can report it and retry or skip.
++ */
++async function runCheck({ input, emit = () => {}, writeResult = () => {}, env = process.env, tmp = os.tmpdir(), fetchImpl = globalThis.fetch } = {}) {
++ const started = Date.now();
++ const say = (doc) => emit(JSON.stringify(doc));
++ const steps = [];
++ let rawLog = '';
++ let job = null;
++ let dir = null;
++ let conclusion = 'failure';
++ let reason = null;
++
++ const onLine = (line) => {
++ if (line == null) return;
++ rawLog += `${line}\n`;
++ if (rawLog.length > MAX_RAW_LOG) rawLog = rawLog.slice(-MAX_RAW_LOG);
++ const parsed = parseActLine(line);
++ if (parsed.type === 'started') {
++ const name = clean(cutTo(phaseName(parsed.phase, parsed.name), 200));
++ const step = { name, startedAt: Date.now(), conclusion: null, ms: null };
++ steps.push(step);
++ say({ t: 'step', name, state: 'started' });
++ return;
++ }
++ if (parsed.type === 'completed') {
++ const name = clean(cutTo(phaseName(parsed.phase, parsed.name), 200));
++ let step = null;
++ for (let i = steps.length - 1; i >= 0; i--) {
++ if (steps[i].name === name && steps[i].conclusion == null) { step = steps[i]; break; }
++ }
++ if (!step) {
++ step = { name, startedAt: Date.now(), conclusion: null, ms: null };
++ steps.push(step);
++ }
++ step.conclusion = parsed.conclusion;
++ step.ms = parsed.ms == null ? Math.max(0, Date.now() - step.startedAt) : parsed.ms;
++ say({ t: 'step', name, state: 'completed', conclusion: step.conclusion, ms: step.ms });
++ return;
++ }
++ const text = clean(cutTo(outputText(parsed.text), MAX_LINE));
++ if (text) say({ t: 'step', text });
++ };
++
++ const finish = () => {
++ const result = {
++ protocol: JOB_PROTOCOL,
++ ok: conclusion === 'success' || conclusion === 'skipped',
++ kind: 'check',
++ conclusion,
++ ...(reason ? { reason: clean(reason) } : {}),
++ steps: steps.map((step) => ({
++ name: step.name,
++ conclusion: step.conclusion || 'cancelled',
++ ms: step.ms == null ? Math.max(0, Date.now() - step.startedAt) : Math.min(step.ms, MAX_STEP_MS),
++ })),
++ log_tail: tail(rawLog),
++ ms: Date.now() - started,
++ };
++ writeResult(result);
++ return result;
++ };
++
++ try {
++ job = parseCheckArgs(input, env);
++ dir = fs.mkdtempSync(path.join(tmp, 'codes-check-'));
++ const work = path.join(dir, 'repo');
++ say({ t: 'step', text: `Cloning ${scrub(job.repo)} at ${job.sha.slice(0, 12)}` });
++
++ await git(['init', '-q', work], { timeout: 60_000 });
++ await git(['remote', 'add', 'origin', job.repo], { cwd: work });
++ const auth = gitAuthFor(job.repo, env);
++ try {
++ await git(['fetch', '--depth', '1', '--no-tags', 'origin', job.sha], { cwd: work, timeout: 300_000, ...(auth.env ? { env: auth.env } : {}) });
++ } catch (first) {
++ // Some servers do not serve an arbitrary commit by sha; the branch the scheduler names can still hold it.
++ if (!job.ref) throw new Error(cloneProblem(first.message, job.repo));
++ await git(['fetch', '--no-tags', 'origin', job.ref], { cwd: work, timeout: 300_000, ...(auth.env ? { env: auth.env } : {}) }).catch((e) => { throw new Error(cloneProblem(e.message, job.repo)); });
++ try { await git(['cat-file', '-e', `${job.sha}^{commit}`], { cwd: work }); } catch { throw new Error(`commit ${job.sha} is not in ${job.repo}`); }
++ }
++ await git(['checkout', '-q', '--detach', job.sha], { cwd: work });
++ const head = (await git(['rev-parse', 'HEAD'], { cwd: work })).trim().toLowerCase();
++ if (head !== job.sha) throw new Error('the cloned commit is not the one asked for');
++ if (auth.env) say({ t: 'step', text: 'Cloned with this machine\'s token for a private repository' });
++
++ const workflowFile = path.join(work, job.workflow);
++ let realWorkflow;
++ try { realWorkflow = fs.realpathSync(workflowFile); } catch { throw new Error(`the workflow ${job.workflow} is not in the repository at ${job.sha}`); }
++ if (!realWorkflow.startsWith(`${fs.realpathSync(work)}${path.sep}`)) throw new Error('the workflow must be inside the repository');
++ const st = fs.statSync(realWorkflow);
++ if (!st.isFile()) throw new Error(`the workflow ${job.workflow} is not a file`);
++ if (st.size > MAX_WORKFLOW_BYTES) throw new Error(`the workflow ${job.workflow} is over 1 MiB`);
++ const source = fs.readFileSync(realWorkflow, 'utf8');
++ const skip = skipReason(source, job.job, job.matrix);
++ if (skip) {
++ conclusion = 'skipped';
++ reason = skip;
++ say({ t: 'step', text: `Skipped: ${skip}` });
++ return finish();
++ }
++
++ // act reads ./.actrc, ./.secrets, ./.vars and ./.env when it runs from the checkout. They are local act
++ // configuration, not actions the workflow takes on GitHub: move them out of the clone, outside the sandbox's
++ // HOME, so a repository cannot point act at another file or feed the steps a repo-defined environment.
++ for (const name of ['.actrc', '.secrets', '.vars', '.env']) {
++ const file = path.join(work, name);
++ try { fs.renameSync(file, path.join(dir, `repo${name}`)); } catch { /* absent: nothing to read */ }
++ }
++ say({ t: 'step', text: 'Preparing act' });
++ const binary = await prepareAct(dir, { env, fetchImpl });
++ const eventPath = path.join(dir, 'event.json');
++ fs.writeFileSync(eventPath, JSON.stringify(eventPayload(job)), { mode: 0o600 });
++
++ const platform = runnerCheck(workflowJob(source, job.job), job.matrix);
++ const labels = platform.ok ? platform.labels : ['ubuntu-latest'];
++ const args = [
++ job.event, '-W', job.workflow, '-j', job.job,
++ '-P', 'ubuntu-latest=-self-hosted',
++ ...labels.filter((label) => label.toLowerCase() !== 'ubuntu-latest').flatMap((label) => ['-P', `${label}=-self-hosted`]),
++ '--eventpath', eventPath,
++ ...Object.entries(job.matrix).flatMap(([key, value]) => ['--matrix', `${key}:${value}`]),
++ ];
++ say({ t: 'step', text: `Running ${job.workflow} job ${job.job} with act ${ACT_VERSION}` });
++ const child = await runAct(binary, args, { cwd: work, env: actEnvironment(env, dir), timeout_ms: job.timeout_ms, onLine });
++ if (child.error) throw new Error(`act could not start: ${child.error.message}`);
++ if (child.timedOut) {
++ conclusion = 'timed_out';
++ reason = `the check did not finish within ${job.timeout_ms} ms`;
++ } else if (child.signal) {
++ conclusion = 'cancelled';
++ reason = `act was stopped by ${child.signal}`;
++ } else if (child.code === 0) {
++ // act exits 0 without a line when the job's if: is false or the matrix value is not in the workflow.
++ // Do not call that a green check: it is a skip, and Actions still runs the job.
++ if (!steps.length) {
++ conclusion = 'skipped';
++ reason = 'act ran no step: the job was skipped, or the matrix value is not one of its configurations';
++ } else {
++ conclusion = 'success';
++ }
++ } else {
++ conclusion = 'failure';
++ reason = `act ended with ${child.signal || `exit code ${child.code}`}`;
++ }
++ } catch (e) {
++ conclusion = 'failure';
++ reason = e && e.message ? e.message : String(e);
++ say({ t: 'note', level: 'error', text: clean(cutTo(reason, MAX_LINE)) });
++ } finally {
++ if (dir) fs.rmSync(dir, { recursive: true, force: true });
++ }
++ return finish();
++}
++
++/** The result's log tail: the last 16 KB, scrubbed, and never a token. */
++function tail(raw) {
++ const text = clean(raw || '');
++ const bytes = Buffer.from(text, 'utf8');
++ if (bytes.length <= MAX_LOG_TAIL) return text;
++ return bytes.subarray(bytes.length - MAX_LOG_TAIL).toString('utf8');
++}
++
++module.exports = {
++ ACT_VERSION, ACT_URL, ACT_SHA256, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS,
++ parseCheckArgs, parseArgs: parseCheckArgs, skipReason, workflowJob, eventPayload, parseActLine, outputText, prepareAct,
++ runCheck, tail,
++};
ddiff --git a/harness/job.js b/harness/job.js
iindex c2d3627..792e118 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -731,6 +731,14 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
const started = Date.now();
const log = [];
const say = (doc) => { if (doc && doc.t && doc.t !== 'result_doc') log.push(doc); emit(JSON.stringify(doc)); };
++ // A check is one more kind of Codes job (harness/check.js): no agent and no task workspace; act runs one workflow
++ // job in host mode. It has its own input and result, so it is handed over before the task path parses anything.
++ let requested = null;
++ try { requested = typeof input === 'string' ? JSON.parse(String(input || '').trim() || '{}') : input; } catch { /* parseArgs answers a bad task input */ }
++ if (requested && typeof requested === 'object' && !Array.isArray(requested) && requested.kind === 'check') {
++ const { runCheck } = require('./check');
++ return runCheck({ input, emit, writeResult, env, tmp, fetchImpl });
++ }
let job;
try {
job = parseArgs(input, env);
ddiff --git a/test/check.test.js b/test/check.test.js
nnew file mode 100644
iindex 0000000..222aa42
---- /dev/null
++++ b/test/check.test.js
@@@ -0,0 +1,276 @@
++'use strict';
++/**
++ * `kind: 'check'` jobs (harness/check.js): the input is checked, a workflow job that needs services, a container, a
++ * non-GITHUB_TOKEN secret, a Docker action or a non-ubuntu runner is skipped with its reason, act is started in host
++ * mode with a pinned binary and without a token in its argv or environment, its output becomes step lines and a
++ * result, and a timeout is answered `timed_out`. A stubbed act (OPENVIBE_CODES_ACT_BIN) records what it was given;
++ * the small file:// repositories are the same test hook a task uses.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { execFileSync } = require('child_process');
++const { runJob } = require('../harness/job');
++const {
++ ACT_VERSION, ACT_URL, ACT_SHA256, parseCheckArgs, skipReason, parseActLine, outputText, prepareAct, runCheck, tail,
++} = require('../harness/check');
++const { check, done } = require('./helpers/boot');
++
++const sh = (cwd, ...args) => execFileSync('git', args, {
++ cwd,
++ env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' },
++});
++const temps = [];
++const tmpDir = (prefix) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); temps.push(dir); return dir; };
++
++/** A small repository with the workflows a test names, as a file:// URL. */
++function repository(workflows) {
++ const dir = tmpDir('codes-check-src-');
++ sh(dir, 'init', '-q', '-b', 'main');
++ fs.mkdirSync(path.join(dir, '.github', 'workflows'), { recursive: true });
++ for (const [name, source] of Object.entries(workflows)) fs.writeFileSync(path.join(dir, '.github', 'workflows', name), source);
++ sh(dir, 'add', '-A');
++ sh(dir, 'commit', '-q', '-m', 'first');
++ return { dir, url: `file://${dir}`, sha: sh(dir, 'rev-parse', 'HEAD').toString().trim() };
++}
++
++const workflow = ({ job = 'test', runsOn = 'ubuntu-latest', steps = '- run: echo hello' } = {}) => `name: ci
++on: push
++jobs:
++ ${job}:
++ runs-on: ${runsOn}
++ steps:
++ ${steps}
++`;
++
++/** A stand-in act: records argv and environment, prints recorded output, then exits (or keeps running for a timeout). */
++function stubAct({ output = '', code = 0, sleep = 0 } = {}) {
++ const dir = tmpDir('codes-check-stub-');
++ const record = path.join(dir, 'record.json');
++ const script = path.join(dir, 'act');
++ const source = `#!${process.execPath}
++const fs = require('fs');
++fs.writeFileSync(${JSON.stringify(record)}, JSON.stringify({ argv: process.argv.slice(2), env: process.env }));
++process.stdout.write(${JSON.stringify(output)});
++process.exitCode = ${code};
++${sleep ? `setTimeout(() => {}, ${sleep});` : ''}
++`;
++ fs.writeFileSync(script, source, { mode: 0o755 });
++ return { dir, script, record, read: () => JSON.parse(fs.readFileSync(record, 'utf8')) };
++}
++
++const checkInput = (repo, extra = {}) => JSON.stringify({
++ kind: 'check', repo: repo.url, sha: repo.sha, workflow: '.github/workflows/ci.yml', job: 'test', event: 'push', ...extra,
++});
++
++(async () => {
++ const env = { ...process.env, OPENVIBE_CODES_ALLOW_FILE_REPO: '1' };
++
++ await check('the input is checked: repository, commit, workflow path, job, event, matrix and timeout', () => {
++ const ok = parseCheckArgs(JSON.stringify({
++ kind: 'check', repo: 'https://github.com/OpenVibers/OpenVibe.Codes.git', sha: 'A'.repeat(40),
++ workflow: '.github/workflows/ci.yml', job: 'test', event: 'push', ref: 'main', matrix: { node: 20 }, timeout_ms: 60_000,
++ }), env);
++ assert.strictEqual(ok.repo, 'https://github.com/OpenVibers/OpenVibe.Codes');
++ assert.strictEqual(ok.sha, 'a'.repeat(40));
++ assert.deepStrictEqual([ok.workflow, ok.job, ok.event, ok.ref], ['.github/workflows/ci.yml', 'test', 'push', 'main']);
++ assert.deepStrictEqual(ok.matrix, { node: '20' });
++ assert.strictEqual(ok.timeout_ms, 60_000);
++ assert.strictEqual(parseCheckArgs({ ...JSON.parse(checkInput({ url: 'https://git.openvibe.codes/o/r', sha: 'b'.repeat(40) })) }, env).repo, 'https://git.openvibe.codes/o/r');
++
++ const base = { kind: 'check', repo: 'https://github.com/a/b', sha: 'a'.repeat(40), workflow: '.github/workflows/ci.yml', job: 'test', event: 'push' };
++ for (const [bad, why] of [
++ [{ ...base, repo: 'http://github.com/a/b' }, /https/],
++ [{ ...base, repo: 'https://evil.example/a/b' }, /github\.com|git\.openvibe\.codes/],
++ [{ ...base, repo: 'https://user:[email protected]/a/b' }, /credentials/],
++ [{ ...base, repo: 'https://github.com/a/b?x=1' }, /query|fragment/],
++ [{ ...base, sha: 'abc' }, /sha/],
++ [{ ...base, sha: 'g'.repeat(40) }, /sha/],
++ [{ ...base, workflow: 'ci.yml' }, /workflow/],
++ [{ ...base, workflow: '.github/workflows/../ci.yml' }, /workflow/],
++ [{ ...base, workflow: '.github/workflows/ci.txt' }, /workflow/],
++ [{ ...base, workflow: '/etc/passwd' }, /workflow/],
++ [{ ...base, job: 'test job' }, /job/],
++ [{ ...base, job: '-x' }, /job/],
++ [{ ...base, event: 'schedule' }, /event/],
++ [{ ...base, matrix: { node: { version: 20 } } }, /matrix/],
++ [{ ...base, timeout_ms: 0 }, /timeout_ms/],
++ [{ ...base, timeout_ms: '5' }, /timeout_ms/],
++ [{ ...base, kind: 'code' }, /check/],
++ ]) assert.throws(() => parseCheckArgs(JSON.stringify(bad), env), why);
++ assert.throws(() => parseCheckArgs(JSON.stringify({ ...base, repo: 'file:///tmp/x' }), process.env), /https/);
++ assert.throws(() => parseCheckArgs('not json', env), /JSON/);
++ });
++
++ await check('the skip rules: services, container, another secret, a Docker action, a non-ubuntu runner', () => {
++ const base = workflow();
++ assert.strictEqual(skipReason(base, 'test'), null);
++ assert.match(skipReason(workflow().replace(' runs-on:', ' services:\n redis:\n image: redis\n runs-on:'), 'test'), /services:/);
++ assert.match(skipReason(workflow().replace(' runs-on:', ' container: node:20\n runs-on:'), 'test'), /container:/);
++ assert.match(skipReason(workflow({ steps: '- run: echo "${{ secrets.MY_SECRET }}"' }), 'test'), /MY_SECRET/);
++ assert.strictEqual(skipReason(workflow({ steps: '- run: echo "${{ secrets.GITHUB_TOKEN }}"' }), 'test'), null, 'GITHUB_TOKEN alone is allowed (and never passed)');
++ assert.match(skipReason(workflow({ steps: '- run: echo "${{ secrets[matrix.name] }}"' }), 'test'), /computes/);
++ assert.match(skipReason(workflow().replace('jobs:', 'env:\n TOKEN: ${{ secrets.WORKFLOW_SECRET }}\njobs:'), 'test'), /WORKFLOW_SECRET/);
++ assert.match(skipReason(workflow({ steps: '- uses: docker://alpine:3\n run: echo x' }), 'test'), /Docker action/);
++ assert.match(skipReason(workflow({ runsOn: 'macos-latest' }), 'test'), /macos-latest.*ubuntu/);
++ assert.match(skipReason(workflow({ runsOn: '[self-hosted, linux]' }), 'test'), /self-hosted.*ubuntu/);
++ assert.match(skipReason(workflow({ runsOn: 'ubuntu-24.04-arm' }), 'test'), /arm.*x64/);
++ assert.match(skipReason(workflow({ runsOn: '${{ matrix.os }}' }).replace(' steps:', ' strategy:\n matrix:\n os: [ubuntu-latest, macos-latest]\n steps:'), 'test'), /macos-latest/);
++ assert.strictEqual(skipReason(workflow({ runsOn: '${{ matrix.os }}' }).replace(' steps:', ' strategy:\n matrix:\n os: [ubuntu-latest, macos-latest]\n steps:'), 'test', { os: 'ubuntu-latest' }), null, 'the scheduler selected the ubuntu matrix value');
++ assert.match(skipReason(workflow({ runsOn: '${{ inputs.os }}' }), 'test'), /cannot resolve/);
++ });
++
++ await check('a skipped job answers skipped with the reason, before act is fetched or run', async () => {
++ const repo = repository({
++ 'services.yml': workflow().replace(' runs-on:', ' services:\n redis:\n image: redis\n runs-on:'),
++ 'macos.yml': workflow({ runsOn: 'macos-latest' }),
++ });
++ const docs = [];
++ const lines = [];
++ const services = await runCheck({
++ input: JSON.stringify({ kind: 'check', repo: repo.url, sha: repo.sha, workflow: '.github/workflows/services.yml', job: 'test', event: 'push' }),
++ emit: (l) => lines.push(JSON.parse(l)), writeResult: (d) => docs.push(d), env, tmp: tmpDir('codes-check-tmp-'),
++ });
++ assert.strictEqual(docs.length, 1);
++ assert.strictEqual(docs[0], services);
++ assert.deepStrictEqual([services.ok, services.kind, services.conclusion], [true, 'check', 'skipped']);
++ assert.match(services.reason, /services:/);
++ assert.deepStrictEqual(services.steps, []);
++ assert.ok(lines.some((l) => l.t === 'step' && /Skipped/.test(l.text)));
++ const mac = await runCheck({
++ input: JSON.stringify({ kind: 'check', repo: repo.url, sha: repo.sha, workflow: '.github/workflows/macos.yml', job: 'test', event: 'push' }),
++ emit: () => {}, writeResult: () => {}, env, tmp: tmpDir('codes-check-tmp-'),
++ });
++ assert.strictEqual(mac.conclusion, 'skipped');
++ assert.match(mac.reason, /macos-latest/);
++ // act exits 0 with no line for a job whose if: is false or a matrix value it does not have: not green.
++ const emptyRepo = repository({ 'ci.yml': workflow() });
++ const quiet = stubAct();
++ const none = await runCheck({
++ input: checkInput(emptyRepo), emit: () => {}, writeResult: () => {},
++ env: { ...env, OPENVIBE_CODES_ACT_BIN: quiet.script }, tmp: tmpDir('codes-check-tmp-'),
++ });
++ assert.strictEqual(none.conclusion, 'skipped');
++ assert.match(none.reason, /ran no step/);
++ });
++
++ await check('runJob hands a kind check input to check.js (and leaves a bad task input to parseArgs)', async () => {
++ const repo = repository({ 'ci.yml': workflow().replace(' runs-on:', ' container: node:20\n runs-on:') });
++ const docs = [];
++ const doc = await runJob({
++ input: JSON.stringify({ kind: 'check', repo: repo.url, sha: repo.sha, workflow: '.github/workflows/ci.yml', job: 'test', event: 'push' }),
++ harness: {}, emit: () => {}, writeResult: (d) => docs.push(d), env, tmp: tmpDir('codes-check-tmp-'),
++ });
++ assert.strictEqual(doc.kind, 'check');
++ assert.strictEqual(doc.conclusion, 'skipped');
++ assert.deepStrictEqual(docs, [doc]);
++ const bad = await runJob({ input: '{"kind":"check","repo":"http://x"}', harness: {}, emit: () => {}, writeResult: () => {}, env });
++ assert.deepStrictEqual([bad.kind, bad.ok, bad.conclusion], ['check', false, 'failure']);
++ });
++
++ await check('act runs with a pinned binary, the expected argv, and no GITHUB_TOKEN or job environment variable', async () => {
++ const repo = repository({ 'ci.yml': workflow() });
++ const stub = stubAct({
++ output: '[ci/test] ⭐ Run Main say hello\n[ci/test] | hello\n[ci/test] ✅ Success - Main say hello [11.75651ms]\n[ci/test] 🏁 Job succeeded\n',
++ });
++ const jobEnv = {
++ ...process.env, OPENVIBE_CODES_ALLOW_FILE_REPO: '1', OPENVIBE_CODES_ACT_BIN: stub.script,
++ GITHUB_TOKEN: 'ghp_abcdefghijklmnopqrstuvwxyz0123', CHECK_JOB_SECRET: 'this must not reach act',
++ };
++ const lines = [];
++ const docs = [];
++ const result = await runCheck({
++ input: checkInput(repo, { matrix: { node: 20 } }),
++ emit: (l) => lines.push(JSON.parse(l)), writeResult: (d) => docs.push(d), env: jobEnv, tmp: tmpDir('codes-check-tmp-'),
++ });
++ assert.strictEqual(docs.length, 1);
++ assert.strictEqual(docs[0], result);
++ assert.deepStrictEqual([result.ok, result.kind, result.conclusion], [true, 'check', 'success']);
++ assert.deepStrictEqual(result.steps, [
++ { name: 'say hello', conclusion: 'success', ms: 12 },
++ ]);
++ const record = stub.read();
++ assert.strictEqual(record.argv[0], 'push');
++ assert.ok(record.argv.includes('-W') && record.argv[record.argv.indexOf('-W') + 1] === '.github/workflows/ci.yml');
++ assert.ok(record.argv.includes('-j') && record.argv[record.argv.indexOf('-j') + 1] === 'test');
++ assert.ok(record.argv.includes('-P') && record.argv[record.argv.indexOf('-P') + 1] === 'ubuntu-latest=-self-hosted');
++ assert.ok(record.argv.includes('--eventpath'));
++ assert.ok(record.argv.includes('--matrix') && record.argv[record.argv.indexOf('--matrix') + 1] === 'node:20');
++ assert.ok(!record.argv.includes('--secret') && !record.argv.includes('-s'));
++ assert.ok(!record.argv.includes('ghp_abcdefghijklmnopqrstuvwxyz0123'));
++ assert.strictEqual(record.env.GITHUB_TOKEN, undefined);
++ assert.strictEqual(record.env.CHECK_JOB_SECRET, undefined);
++ assert.strictEqual(record.env.PATH, process.env.PATH, 'PATH stays so the job can find its tools');
++ assert.ok(record.env.HOME && record.env.HOME !== os.homedir(), 'act gets a HOME inside the job directory, not the host home');
++ // The step lines are the check lines; the output is the job's existing text line.
++ assert.deepStrictEqual(lines.find((l) => l.name === 'say hello' && l.state === 'started'), { t: 'step', name: 'say hello', state: 'started' });
++ assert.deepStrictEqual(lines.find((l) => l.name === 'say hello' && l.state === 'completed'), { t: 'step', name: 'say hello', state: 'completed', conclusion: 'success', ms: 12 });
++ assert.ok(lines.some((l) => l.t === 'step' && l.text === 'hello'));
++ assert.ok(result.log_tail.includes('hello') && result.log_tail.includes('Job succeeded'));
++ assert.ok(result.ms >= 0);
++
++ // The pinned release is a real, reproducible constant (and a wrong digest is refused before anything runs).
++ assert.match(ACT_VERSION, /^\d+\.\d+\.\d+$/);
++ assert.strictEqual(ACT_URL, `https://github.com/nektos/act/releases/download/v${ACT_VERSION}/act_Linux_x86_64.tar.gz`);
++ assert.match(ACT_SHA256, /^[0-9a-f]{64}$/);
++ await assert.rejects(prepareAct(tmpDir('codes-check-act-'), {
++ env: {},
++ fetchImpl: async () => ({ ok: true, status: 200, arrayBuffer: async () => Buffer.from('not the act release') }),
++ }), /sha256/);
++ });
++
++ await check('a failed act job is a failure, its step and output are kept, and tokens in the log are scrubbed', async () => {
++ const repo = repository({ 'ci.yml': workflow() });
++ const stub = stubAct({
++ output: [
++ '[ci/test] ⭐ Run Main fail me',
++ '[ci/test] | token ghp_abcdefghijklmnopqrstuvwxyz0123 and https://x-access-token:[email protected]/a/b.git',
++ '[ci/test] ❌ Failure - Main fail me [5.317803ms]',
++ '[ci/test] 🏁 Job failed',
++ ].join('\n') + '\n',
++ code: 1,
++ });
++ const lines = [];
++ const result = await runCheck({
++ input: checkInput(repo), emit: (l) => lines.push(JSON.parse(l)), writeResult: () => {},
++ env: { ...env, OPENVIBE_CODES_ACT_BIN: stub.script }, tmp: tmpDir('codes-check-tmp-'),
++ });
++ assert.deepStrictEqual([result.ok, result.conclusion], [false, 'failure']);
++ assert.match(result.reason, /exit code 1/);
++ assert.deepStrictEqual(result.steps, [{ name: 'fail me', conclusion: 'failure', ms: 5 }]);
++ assert.ok(!/ghp_abcdefghijklmnopqrstuvwxyz0123/.test(JSON.stringify(result)), 'the result carries no token');
++ assert.ok(!/ghp_abcdefghijklmnopqrstuvwxyz0123/.test(JSON.stringify(lines)), 'no line carries a token');
++ assert.ok(result.log_tail.includes('Job failed'));
++ assert.ok(!/x-access-token:/.test(result.log_tail));
++ });
++
++ await check('a timeout stops act and answers timed_out', async () => {
++ const repo = repository({ 'ci.yml': workflow() });
++ const stub = stubAct({ sleep: 30_000 });
++ const started = Date.now();
++ const result = await runCheck({
++ input: checkInput(repo, { timeout_ms: 250 }), emit: () => {}, writeResult: () => {},
++ env: { ...env, OPENVIBE_CODES_ACT_BIN: stub.script }, tmp: tmpDir('codes-check-tmp-'),
++ });
++ assert.deepStrictEqual([result.ok, result.conclusion], [false, 'timed_out']);
++ assert.match(result.reason, /250 ms/);
++ assert.ok(Date.now() - started < 10_000, 'the stub did not outlive its timeout');
++ });
++
++ await check('act output lines and the 16 KB tail', () => {
++ assert.deepStrictEqual(parseActLine('[ci/test] ⭐ Run Main say hello'), { type: 'started', phase: 'Main', name: 'say hello' });
++ assert.deepStrictEqual(parseActLine('[ci/test] ✅ Success - Main say hello [11.75651ms]'), { type: 'completed', phase: 'Main', name: 'say hello', conclusion: 'success', ms: 12 });
++ assert.deepStrictEqual(parseActLine('[ci/test] ✅ Success - Main slow [1m2.3s]'), { type: 'completed', phase: 'Main', name: 'slow', conclusion: 'success', ms: 62_300 });
++ assert.deepStrictEqual(parseActLine('[ci/test] ⚠️ Skipped - Post cleanup'), { type: 'completed', phase: 'Post', name: 'cleanup', conclusion: 'skipped', ms: null });
++ assert.strictEqual(outputText('[ci/test] | hello'), 'hello');
++ assert.strictEqual(outputText('time="2026" level=info msg="using docker"'), 'time="2026" level=info msg="using docker"');
++ const big = `${'x'.repeat(30_000)} ghp_abcdefghijklmnopqrstuvwxyz0123`;
++ const cut = tail(big);
++ assert.ok(cut.length <= 16 * 1024);
++ assert.ok(!/ghp_abcdefghijklmnopqrstuvwxyz0123/.test(cut));
++ });
++
++ for (const dir of temps) fs.rmSync(dir, { recursive: true, force: true });
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });