forgedock 1.18.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +54 -4
- package/bin/engine/runlog.mjs +10 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +32 -2
- package/bin/engine.mjs +139 -7
- package/bin/runner.mjs +69 -9
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +136 -28
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +201 -40
- package/commands/review-pr-staging.md +4 -4
- package/commands/review-pr.md +25 -15
- package/commands/work-on/build/architect.md +53 -0
- package/commands/work-on/build/implement.md +19 -0
- package/commands/work-on/build/validate.md +320 -28
- package/commands/work-on/build.md +66 -9
- package/commands/work-on/investigate.md +7 -0
- package/commands/work-on/remediate.md +32 -13
- package/commands/work-on/review.md +32 -19
- package/docs/CONFIG.md +89 -2
- package/docs/OUTCOME-CODES.md +5 -1
- package/docs/STATS.md +11 -4
- package/docs/TELEMETRY.md +5 -2
- package/docs/WORK-ON-RUNTIME.md +18 -0
- package/package.json +2 -2
- package/packages/protocol/src/types.js +1 -1
- package/scripts/acceptance-red-green.sh +90 -0
- package/scripts/acceptance-red-green.test.sh +74 -0
- package/scripts/cap-findings.test.sh +2 -0
- package/scripts/check-contract-scope.test.sh +2 -0
- package/scripts/check-edge-cases.sh +143 -0
- package/scripts/check-edge-cases.test.sh +175 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/check-test-tmp-cleanup.sh +56 -0
- package/scripts/check-test-tmp-cleanup.test.sh +69 -0
- package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
- package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
- package/scripts/ci-local/adapters/buildkite.mjs +58 -0
- package/scripts/ci-local/adapters/circleci.mjs +101 -0
- package/scripts/ci-local/adapters/common.mjs +115 -0
- package/scripts/ci-local/adapters/explicit.mjs +21 -0
- package/scripts/ci-local/adapters/github-actions.mjs +349 -0
- package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
- package/scripts/ci-local/adapters/portability.mjs +104 -0
- package/scripts/ci-local/adapters/task-runners.mjs +67 -0
- package/scripts/ci-local/cli.mjs +50 -0
- package/scripts/ci-local/lib.mjs +371 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +345 -0
- package/scripts/ci-local/scoping.mjs +508 -0
- package/scripts/ci-local.sh +97 -0
- package/scripts/ci-local.test.sh +919 -0
- package/scripts/classify-finding.sh +32 -6
- package/scripts/classify-finding.test.sh +23 -0
- package/scripts/classify-test-run.sh +69 -0
- package/scripts/classify-test-run.test.sh +90 -0
- package/scripts/close-parent-c3.test.sh +2 -0
- package/scripts/consumer-sweep.sh +569 -0
- package/scripts/consumer-sweep.test.sh +357 -0
- package/scripts/detect-append-registries.sh +106 -0
- package/scripts/detect-append-registries.test.sh +64 -0
- package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
- package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
- package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
- package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
- package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
- package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
- package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
- package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
- package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
- package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
- package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
- package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
- package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
- package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
- package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
- package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
- package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
- package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/lib/telemetry.sh +1 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/review-acceptance-gate.sh +2 -2
- package/scripts/review-acceptance-gate.test.sh +20 -0
- package/scripts/review-delta.sh +8 -1
- package/scripts/review-delta.test.sh +19 -0
- package/scripts/review-finalize.sh +193 -41
- package/scripts/review-finalize.test.sh +211 -4
- package/scripts/review-pr-delta-wiring.test.sh +13 -0
- package/scripts/review-prepare.sh +5 -3
- package/scripts/review-prepare.test.sh +18 -2
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +2 -1
- package/scripts/verify-phase-trail.test.sh +8 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +41 -3
- package/scripts/with-run-tmpdir.sh +20 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// run.mjs - execute a plan in the build worktree. Independent jobs run in parallel, bounded per run (max_parallel) and
|
|
5
|
+
// machine-wide (a slot semaphore under ~/.forge/ci-local.lock), each with a timeout, inside an overall budget.
|
|
6
|
+
// Service containers (docker) are named per run and always torn down. Passing checks are cached by input hash.
|
|
7
|
+
// Nothing here talks to GitHub or triggers a remote workflow.
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import net from 'node:net';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { run as runSync, commandExists, scrubbedEnv, homeForgeDir, sha256, fileHash, toolVersion, shellSplit } from './lib.mjs';
|
|
15
|
+
import { shellArgv } from './adapters/common.mjs';
|
|
16
|
+
import { checkPortability } from './adapters/portability.mjs';
|
|
17
|
+
|
|
18
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
const cleanups = new Set();
|
|
20
|
+
let cleaning = false;
|
|
21
|
+
function cleanupAll() { if (cleaning) return; cleaning = true; for (const f of [...cleanups]) { try { f(); } catch { /* best effort */ } } }
|
|
22
|
+
process.on('exit', cleanupAll);
|
|
23
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => { cleanupAll(); process.exit(130); });
|
|
24
|
+
|
|
25
|
+
// ---- machine-wide slot semaphore ----
|
|
26
|
+
function slotCount() {
|
|
27
|
+
const n = Number(process.env.FORGE_CI_LOCAL_SLOTS);
|
|
28
|
+
return n > 0 ? n : Math.max(2, Math.min(8, Math.floor(os.cpus().length / 2)));
|
|
29
|
+
}
|
|
30
|
+
function alive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
|
|
31
|
+
|
|
32
|
+
async function acquireSlot(owner, deadline) {
|
|
33
|
+
const dir = path.join(homeForgeDir(), 'ci-local.lock');
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
const n = slotCount();
|
|
36
|
+
for (;;) {
|
|
37
|
+
for (let i = 0; i < n; i++) {
|
|
38
|
+
const slot = path.join(dir, `slot-${i}`);
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(slot);
|
|
41
|
+
fs.writeFileSync(path.join(slot, 'owner'), `${process.pid} ${Date.now()} ${owner}\n`);
|
|
42
|
+
const release = () => { try { fs.rmSync(slot, { recursive: true, force: true }); } catch { /* gone */ } cleanups.delete(release); };
|
|
43
|
+
cleanups.add(release);
|
|
44
|
+
return release;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
if (e.code !== 'EEXIST') throw e;
|
|
47
|
+
let pid = 0; let ts = 0;
|
|
48
|
+
try { const [p, t] = fs.readFileSync(path.join(slot, 'owner'), 'utf8').split(' '); pid = Number(p); ts = Number(t); } catch { /* owner file not written yet */ }
|
|
49
|
+
const stale = pid ? !alive(pid) : (Date.now() - (fs.statSync(slot, { throwIfNoEntry: false })?.mtimeMs || 0) > 30000);
|
|
50
|
+
if (stale || (ts && Date.now() - ts > 3 * 3600 * 1000)) { try { fs.rmSync(slot, { recursive: true, force: true }); } catch { /* raced */ } i--; continue; }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (Date.now() > deadline) return null;
|
|
54
|
+
await sleep(250);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---- process execution ----
|
|
59
|
+
function exec(argv, { cwd, env, timeoutMs, onData }) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
let child; let killed = false; let timer;
|
|
62
|
+
try { child = spawn(argv[0], argv.slice(1), { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { resolve({ code: 127, signal: null, error: e.message, timedOut: false }); return; }
|
|
63
|
+
const kill = () => { try { process.kill(-child.pid, 'SIGTERM'); } catch { /* gone */ } setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }, 3000).unref(); };
|
|
64
|
+
const unreg = () => { cleanups.delete(hard); };
|
|
65
|
+
const hard = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } };
|
|
66
|
+
cleanups.add(hard);
|
|
67
|
+
timer = setTimeout(() => { killed = true; kill(); }, timeoutMs);
|
|
68
|
+
child.stdout.on('data', onData); child.stderr.on('data', onData);
|
|
69
|
+
child.on('error', (e) => { clearTimeout(timer); unreg(); resolve({ code: 127, signal: null, error: e.message, timedOut: false }); });
|
|
70
|
+
child.on('close', (code, signal) => { clearTimeout(timer); unreg(); resolve({ code: code === null ? 1 : code, signal, timedOut: killed }); });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// forge#4040 (SEC-4): a path-SEGMENT containment check; a string prefix would accept `<repo>-sibling`.
|
|
75
|
+
export function insideRepo(repo, target) {
|
|
76
|
+
const root = path.resolve(repo); const t = path.resolve(target);
|
|
77
|
+
const rel = path.relative(root, t);
|
|
78
|
+
if (!(rel === '' || (rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel)))) return false;
|
|
79
|
+
try { const rr = path.relative(fs.realpathSync(root), fs.realpathSync(t)); if (rr === '..' || rr.startsWith('..' + path.sep) || path.isAbsolute(rr)) return false; } catch { /* target not created yet: the lexical check stands */ }
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
class Tail {
|
|
84
|
+
constructor(max = 120) { this.lines = []; this.max = max; this.partial = ''; }
|
|
85
|
+
push(buf) { const t = this.partial + buf.toString('utf8'); const parts = t.split('\n'); this.partial = parts.pop(); for (const l of parts) { this.lines.push(l); if (this.lines.length > this.max) this.lines.shift(); } }
|
|
86
|
+
text(n = 40) { const all = this.partial ? [...this.lines, this.partial] : this.lines; return all.slice(-n); }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- services ----
|
|
90
|
+
async function portFree(port) { return new Promise((res) => { const s = net.createServer(); s.once('error', () => res(false)); s.listen(port, '127.0.0.1', () => s.close(() => res(true))); }); }
|
|
91
|
+
const HEALTH_FLAGS = new Set(['--health-cmd', '--health-interval', '--health-timeout', '--health-retries', '--health-start-period', '--shm-size']);
|
|
92
|
+
|
|
93
|
+
async function startServices(job, log) {
|
|
94
|
+
const started = [];
|
|
95
|
+
const stop = () => { for (const n of started) runSync('docker', ['rm', '-f', n], { timeout: 30000 }); started.length = 0; };
|
|
96
|
+
cleanups.add(stop);
|
|
97
|
+
let net = '';
|
|
98
|
+
const dropNet = () => { if (net) { runSync('docker', ['network', 'rm', net], { timeout: 30000 }); net = ''; } };
|
|
99
|
+
const finish = () => { stop(); dropNet(); cleanups.delete(stop); cleanups.delete(dropNet); };
|
|
100
|
+
cleanups.add(dropNet);
|
|
101
|
+
if (!commandExists('docker') || runSync('docker', ['info'], { timeout: 20000 }).code !== 0) return { error: 'docker is not available', finish };
|
|
102
|
+
// forge#4040 (SEC-6): one docker network per job; nothing runs with --network host.
|
|
103
|
+
net = `forge-ci-net-${process.pid}-${job.id.replace(/[^A-Za-z0-9_.-]/g, '-')}`.slice(0, 120);
|
|
104
|
+
const nr = runSync('docker', ['network', 'create', net], { timeout: 30000 });
|
|
105
|
+
if (nr.code !== 0) { net = ''; return { error: `docker network create failed: ${(nr.err || nr.out).split('\n')[0]}`, finish }; }
|
|
106
|
+
for (const svc of job.services) {
|
|
107
|
+
const name = `forge-ci-${process.pid}-${job.id.replace(/[^A-Za-z0-9_.-]/g, '-')}-${svc.name}`.slice(0, 120);
|
|
108
|
+
const args = ['run', '-d', '--rm', '--name', name, '--network', net, '--network-alias', svc.name];
|
|
109
|
+
const hostPorts = [];
|
|
110
|
+
for (const p of svc.ports || []) {
|
|
111
|
+
const m = /^(?:(\d+):)?(\d+)(?:\/(tcp|udp))?$/.exec(String(p).trim());
|
|
112
|
+
if (!m) return { error: `unsupported port mapping ${p}`, finish };
|
|
113
|
+
if (m[1]) { if (!(await portFree(Number(m[1])))) return { error: `host port ${m[1]} is already in use`, finish }; hostPorts.push(Number(m[1])); args.push('-p', `127.0.0.1:${m[1]}:${m[2]}${m[3] ? '/' + m[3] : ''}`); } else args.push('-p', `127.0.0.1::${m[2]}`);
|
|
114
|
+
}
|
|
115
|
+
for (const [k, v] of Object.entries(svc.env || {})) args.push('-e', `${k}=${v}`);
|
|
116
|
+
const opts = shellSplit(svc.options || '') || [];
|
|
117
|
+
let health = false;
|
|
118
|
+
for (let i = 0; i < opts.length; i++) {
|
|
119
|
+
const [flag, inline] = opts[i].split(/=(.*)/s);
|
|
120
|
+
if (!HEALTH_FLAGS.has(flag)) { log(`service ${svc.name}: option ${flag} ignored (not on the safe list)`); if (inline === undefined && opts[i + 1] && !opts[i + 1].startsWith('-')) i++; continue; }
|
|
121
|
+
if (flag === '--health-cmd') health = true;
|
|
122
|
+
args.push(inline !== undefined ? `${flag}=${inline}` : flag); if (inline === undefined && opts[i + 1] !== undefined) args.push(opts[++i]);
|
|
123
|
+
}
|
|
124
|
+
args.push(svc.image);
|
|
125
|
+
const r = runSync('docker', args, { timeout: 300000 });
|
|
126
|
+
if (r.code !== 0) return { error: `docker run ${svc.image} failed: ${(r.err || r.out).split('\n')[0]}`, finish };
|
|
127
|
+
started.push(name);
|
|
128
|
+
const until = Date.now() + 90000;
|
|
129
|
+
for (;;) {
|
|
130
|
+
const st = runSync('docker', ['inspect', '-f', '{{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}', name], { timeout: 15000 }).out.trim();
|
|
131
|
+
const [running, h] = st.split('|');
|
|
132
|
+
if (running !== 'true') return { error: `service ${svc.name} exited: ${runSync('docker', ['logs', '--tail', '5', name], { timeout: 15000 }).err.trim().split('\n').pop()}`, finish };
|
|
133
|
+
if (health) { if (h === 'healthy') break; if (h === 'unhealthy') return { error: `service ${svc.name} is unhealthy`, finish }; }
|
|
134
|
+
else if (hostPorts.length) { const ok = await new Promise((res) => { const s = net.connect(hostPorts[0], '127.0.0.1'); s.once('connect', () => { s.destroy(); res(true); }); s.once('error', () => res(false)); }); if (ok) break; }
|
|
135
|
+
else { await sleep(2000); break; }
|
|
136
|
+
if (Date.now() > until) return { error: `service ${svc.name} did not become ready in 90s`, finish };
|
|
137
|
+
await sleep(1000);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { finish };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- result cache ----
|
|
144
|
+
function cacheDir(o) { return o.resultCacheDir || path.join(homeForgeDir(), 'ci-local', 'results'); }
|
|
145
|
+
function configFilesHash(ctx) {
|
|
146
|
+
if (!ctx.configHash) {
|
|
147
|
+
const re = /(^|\/)(\.shellcheckrc|\.eslintrc[^/]*|eslint\.config\.[^/]*|\.prettierrc[^/]*|\.flake8|ruff\.toml|\.ruff\.toml|pyproject\.toml|setup\.cfg|\.markdownlint[^/]*|\.yamllint[^/]*|\.editorconfig|tox\.ini)$/;
|
|
148
|
+
ctx.configHash = sha256(ctx.plan.changed_files.length ? ctx.tracked.filter((f) => re.test(f)).sort().map((f) => `${f}:${fileHash(ctx.repo, f)}`).join('\n') : '');
|
|
149
|
+
}
|
|
150
|
+
return ctx.configHash;
|
|
151
|
+
}
|
|
152
|
+
function changeHash(ctx) {
|
|
153
|
+
if (!ctx.changeHash) ctx.changeHash = sha256(JSON.stringify([ctx.plan.merge_base, ctx.plan.changed_files.map((f) => `${f}:${fileHash(ctx.repo, f)}`)]));
|
|
154
|
+
return ctx.changeHash;
|
|
155
|
+
}
|
|
156
|
+
function stepKey(step, job, ctx) {
|
|
157
|
+
const sel = step.scope || {};
|
|
158
|
+
const first = (shellSplit(step.command) || [])[0] || '';
|
|
159
|
+
const perFile = /:per-file$/.test(sel.selector || '') && sel.files;
|
|
160
|
+
const inputs = perFile
|
|
161
|
+
? { files: sel.files.map((f) => `${f}:${fileHash(ctx.repo, f)}`), config: configFilesHash(ctx) }
|
|
162
|
+
: { change: changeHash(ctx), selected: sel.files || sel.selector || '' };
|
|
163
|
+
const env = { ...job.env, ...step.env };
|
|
164
|
+
return sha256(JSON.stringify([step.command, step.cwd, step.shell, Object.keys(env).sort().map((k) => `${k}=${env[k]}`), toolVersion(path.basename(first)), process.version, inputs]));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- one job ----
|
|
168
|
+
function jobEnv(job, step, ctx, ghFiles) {
|
|
169
|
+
const extra = { ...job.env, ...step.env };
|
|
170
|
+
if (job.source === 'github-actions') Object.assign(extra, { GITHUB_WORKSPACE: ctx.repo, GITHUB_SHA: ctx.plan.head_sha, GITHUB_REF_NAME: ctx.plan.head_branch, GITHUB_BASE_REF: ctx.plan.base_branch, GITHUB_HEAD_REF: ctx.plan.head_branch, GITHUB_EVENT_NAME: 'pull_request', RUNNER_OS: { linux: 'Linux', darwin: 'macOS', win32: 'Windows' }[process.platform], RUNNER_TEMP: ctx.tmp, GITHUB_ENV: ghFiles.env, GITHUB_PATH: ghFiles.path, GITHUB_OUTPUT: ghFiles.output, GITHUB_STEP_SUMMARY: ghFiles.summary });
|
|
171
|
+
return scrubbedEnv(extra);
|
|
172
|
+
}
|
|
173
|
+
function absorbGhFiles(ghFiles, env) {
|
|
174
|
+
try {
|
|
175
|
+
const lines = fs.readFileSync(ghFiles.env, 'utf8').split('\n'); fs.writeFileSync(ghFiles.env, '');
|
|
176
|
+
for (let i = 0; i < lines.length; i++) {
|
|
177
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)<<(.+)$/.exec(lines[i]);
|
|
178
|
+
if (m) { const buf = []; i++; while (i < lines.length && lines[i] !== m[2]) buf.push(lines[i++]); env[m[1]] = buf.join('\n'); continue; }
|
|
179
|
+
const kv = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(lines[i]); if (kv) env[kv[1]] = kv[2];
|
|
180
|
+
}
|
|
181
|
+
const paths = fs.readFileSync(ghFiles.path, 'utf8').split('\n').filter(Boolean); fs.writeFileSync(ghFiles.path, '');
|
|
182
|
+
if (paths.length) env.PATH = paths.join(path.delimiter) + path.delimiter + (env.PATH || process.env.PATH);
|
|
183
|
+
} catch { /* files are best effort */ }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function runJob(job, ctx) {
|
|
187
|
+
const t0 = Date.now(); const res = { id: job.id, name: job.name, source: job.source, status: 'pass', steps: [], reason: '', duration_ms: 0 };
|
|
188
|
+
const jobTimeout = (job.timeout_s || ctx.jobTimeoutS) * 1000;
|
|
189
|
+
const log = (m) => { res.notes = (res.notes || []).concat(m); };
|
|
190
|
+
const runnableSteps = job.steps.filter((s) => !s.noop);
|
|
191
|
+
if (!runnableSteps.length) { res.status = 'pass'; res.reason = 'nothing to run'; res.duration_ms = Date.now() - t0; return res; }
|
|
192
|
+
const needExec = runnableSteps.some((s) => !s.skip);
|
|
193
|
+
let svc = null; let release = null;
|
|
194
|
+
try {
|
|
195
|
+
if (needExec) {
|
|
196
|
+
release = await acquireSlot(job.id, ctx.deadline);
|
|
197
|
+
if (!release) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted waiting for a machine slot'; return res; }
|
|
198
|
+
if (job.services && job.services.length) {
|
|
199
|
+
svc = await startServices(job, log);
|
|
200
|
+
if (svc.error) { res.status = 'deferred'; res.reason = `service containers: ${svc.error}`; return res; }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// forge#4040 (REG-5): the job timeout clock starts when the job starts executing, never while it waits for a slot.
|
|
204
|
+
const tExec = Date.now();
|
|
205
|
+
const ghFiles = { env: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.env`), path: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.path`), output: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.out`), summary: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.sum`) };
|
|
206
|
+
for (const f of Object.values(ghFiles)) fs.writeFileSync(f, '');
|
|
207
|
+
const carried = {}; let failedGroup = null;
|
|
208
|
+
for (const [i, step] of job.steps.entries()) {
|
|
209
|
+
if (step.noop) continue;
|
|
210
|
+
const sr = { name: step.name, command: step.command.length > 200 ? step.command.slice(0, 200) + '...' : step.command, scope: step.scope ? { mode: step.scope.mode, selector: step.scope.selector, selected: step.scope.selected, total: step.scope.total, widen_reason: step.scope.widen_reason } : undefined, status: 'pass', duration_ms: 0, cache: 'na' };
|
|
211
|
+
res.steps.push(sr);
|
|
212
|
+
if (step.skip) { sr.status = 'skipped'; sr.reason = step.skip; continue; }
|
|
213
|
+
if (failedGroup && step.group !== failedGroup) break;
|
|
214
|
+
if (Date.now() > ctx.deadline) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted'; return res; }
|
|
215
|
+
const s0 = Date.now();
|
|
216
|
+
if (step.builtin === 'portability') {
|
|
217
|
+
const f = checkPortability({ repo: ctx.repo, from: ctx.plan.merge_base, changed: ctx.plan.changed_files, tracked: ctx.tracked });
|
|
218
|
+
sr.duration_ms = Date.now() - s0;
|
|
219
|
+
if (f.length) { sr.status = 'fail'; res.tail = f.slice(0, 40); res.status = 'fail'; res.failed_step = step.name; failedGroup = step.group || null; if (!failedGroup) break; }
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
let key = '';
|
|
223
|
+
const cacheable = step.kind !== 'install' && !ctx.noResultCache;
|
|
224
|
+
if (cacheable) {
|
|
225
|
+
key = stepKey(step, job, ctx);
|
|
226
|
+
const f = path.join(cacheDir(ctx.o), key + '.json');
|
|
227
|
+
if (fs.existsSync(f)) { sr.cache = 'hit'; sr.duration_ms = 0; try { fs.utimesSync(f, new Date(), new Date()); } catch { /* ignore */ } continue; }
|
|
228
|
+
sr.cache = 'miss';
|
|
229
|
+
}
|
|
230
|
+
const argv = shellArgv(step.shell); if (!argv) { res.status = 'deferred'; res.reason = `shell ${step.shell} is not available locally`; return res; }
|
|
231
|
+
const scriptFile = path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}-${i}.sh`);
|
|
232
|
+
fs.writeFileSync(scriptFile, step.command + '\n');
|
|
233
|
+
const env = jobEnv(job, step, ctx, ghFiles); Object.assign(env, carried);
|
|
234
|
+
const cwd = path.resolve(ctx.repo, step.cwd || '.');
|
|
235
|
+
if (!insideRepo(ctx.repo, cwd)) { res.status = 'deferred'; res.reason = 'step working-directory escapes the repository'; return res; }
|
|
236
|
+
const tail = new Tail();
|
|
237
|
+
const left = Math.max(1000, Math.min((step.timeout_s || ctx.jobTimeoutS) * 1000, jobTimeout - (Date.now() - tExec), ctx.deadline - Date.now()));
|
|
238
|
+
const r = await exec([...argv, scriptFile], { cwd, env, timeoutMs: left, onData: (b) => tail.push(b) });
|
|
239
|
+
sr.duration_ms = Date.now() - s0;
|
|
240
|
+
if (job.source === 'github-actions') { const e2 = { ...env }; absorbGhFiles(ghFiles, e2); for (const k of Object.keys(e2)) if (e2[k] !== env[k]) carried[k] = e2[k]; }
|
|
241
|
+
if (r.code === 0) { if (key) { try { fs.mkdirSync(cacheDir(ctx.o), { recursive: true }); fs.writeFileSync(path.join(cacheDir(ctx.o), key + '.json'), JSON.stringify({ cmd: step.command.slice(0, 300), ts: Date.now() })); } catch { /* cache is best effort */ } } continue; }
|
|
242
|
+
if (r.error) tail.push(Buffer.from(`${r.error}\n`));
|
|
243
|
+
const text = tail.text(60);
|
|
244
|
+
if (r.timedOut) { if (Date.now() > ctx.deadline) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted (job killed)'; return res; } sr.status = 'timeout'; res.status = 'degraded'; res.reason = `timed out after ${Math.round(left / 1000)}s (a timeout proves nothing about the code)`; res.tail = text; res.failed_step = step.name; return res; }
|
|
245
|
+
if (step.kind === 'install') { res.status = 'deferred'; res.reason = `dependency install failed (offline?): ${step.command.slice(0, 60)}`; return res; }
|
|
246
|
+
if (r.code === 127 && text.some((l) => /command not found|not found$|No such file or directory/.test(l)) && !step.continue_on_error) {
|
|
247
|
+
const tool = (text.find((l) => /command not found/.test(l)) || '').replace(/^.*?([A-Za-z0-9._-]+): command not found.*$/, '$1');
|
|
248
|
+
if (/command not found/.test(text.join('\n'))) { res.status = 'deferred'; res.reason = `tool not installed locally: ${tool || 'unknown'}`; return res; }
|
|
249
|
+
}
|
|
250
|
+
if (step.continue_on_error) { sr.status = 'allowed-failure'; continue; }
|
|
251
|
+
// forge#4040 (REG-4): a failure under a tool-version drift (setup-node 18 vs host 26) cannot be attributed to the change.
|
|
252
|
+
if (job.version_drift && job.version_drift.length) { res.status = 'deferred'; res.reason = `version drift (${job.version_drift[0]}); step "${step.name}" failed locally (exit ${r.code}), CI proves it on the pinned toolchain`; res.tail = text; return res; }
|
|
253
|
+
sr.status = 'fail'; sr.exit = r.code; res.status = 'fail'; res.failed_step = step.name; res.exit = r.code; res.failed_command = step.command; res.tail = text;
|
|
254
|
+
failedGroup = step.group || null; if (!failedGroup) break;
|
|
255
|
+
}
|
|
256
|
+
return res;
|
|
257
|
+
} finally {
|
|
258
|
+
if (svc && svc.finish) svc.finish();
|
|
259
|
+
if (release) release();
|
|
260
|
+
res.duration_ms = Date.now() - t0;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---- scheduler ----
|
|
265
|
+
export async function runPlan(plan, o = {}) {
|
|
266
|
+
const t0 = Date.now();
|
|
267
|
+
const repo = path.resolve(plan.repo || o.repo || '.');
|
|
268
|
+
const requestedS = Number(process.env.FORGE_CI_LOCAL_BUDGET_SECONDS) || (o.budgetMinutes || plan.config.budget_minutes || Number(process.env.FORGE_CI_LOCAL_BUDGET_MINUTES) || 15) * 60;
|
|
269
|
+
// forge#4040 (REG-6): one invocation never exceeds the Bash tool cap (10 min); beyond the cap it ends as a partial run
|
|
270
|
+
// (DEGRADED) and a re-run resumes from the result cache.
|
|
271
|
+
const maxInvS = Number(process.env.FORGE_CI_LOCAL_MAX_INVOCATION_SECONDS) || 540;
|
|
272
|
+
const budgetS = Math.min(requestedS, maxInvS); const capped = requestedS > maxInvS;
|
|
273
|
+
const maxParallel = o.maxParallel || plan.config.max_parallel || Number(process.env.FORGE_CI_LOCAL_MAX_PARALLEL) || 4;
|
|
274
|
+
const tmpRoot = fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'forge-ci-local.'));
|
|
275
|
+
cleanups.add(() => { try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ } });
|
|
276
|
+
const ctx = {
|
|
277
|
+
plan, repo, o, tmp: tmpRoot, deadline: t0 + budgetS * 1000, jobTimeoutS: Number(process.env.FORGE_CI_LOCAL_JOB_TIMEOUT) || Math.min(900, budgetS),
|
|
278
|
+
tracked: runSync('git', ['-C', repo, 'ls-files', '--cached', '--others', '--exclude-standard', '-z']).out.split('\0').filter(Boolean),
|
|
279
|
+
noResultCache: process.env.FORGE_CI_LOCAL_NO_CACHE === '1',
|
|
280
|
+
};
|
|
281
|
+
const runnable = plan.jobs.filter((j) => !j.not_applicable && !j.deferred_reason);
|
|
282
|
+
const ids = new Set(runnable.map((j) => j.id));
|
|
283
|
+
const results = new Map(); const running = new Map();
|
|
284
|
+
const pending = new Set(runnable.map((j) => j.id));
|
|
285
|
+
const byId = new Map(runnable.map((j) => [j.id, j]));
|
|
286
|
+
while (pending.size || running.size) {
|
|
287
|
+
for (const id of [...pending]) {
|
|
288
|
+
const j = byId.get(id);
|
|
289
|
+
const needs = (j.needs || []).filter((n) => ids.has(n));
|
|
290
|
+
const blocked = needs.find((n) => results.has(n) && results.get(n).status !== 'pass');
|
|
291
|
+
if (blocked) { pending.delete(id); const b = results.get(blocked); const r = { id, name: j.name, source: j.source, status: b.status === 'fail' ? 'skipped' : 'deferred', reason: b.status === 'fail' ? `needs ${blocked}, which failed` : `needs ${blocked}, which is not proven locally`, steps: [], duration_ms: 0 }; results.set(id, r); printJob(r); continue; }
|
|
292
|
+
if (needs.some((n) => !results.has(n))) continue;
|
|
293
|
+
if (running.size >= maxParallel) break;
|
|
294
|
+
if (Date.now() > ctx.deadline) { pending.delete(id); results.set(id, { id, name: j.name, source: j.source, status: 'deferred', budget: true, reason: 'budget exhausted before the job started', steps: [], duration_ms: 0 }); continue; }
|
|
295
|
+
pending.delete(id);
|
|
296
|
+
const p = runJob(j, ctx).catch((e) => ({ id, name: j.name, source: j.source, status: 'deferred', reason: `runner error: ${e.message}`, steps: [], duration_ms: 0 })).then((r) => { results.set(id, r); running.delete(id); printJob(r); });
|
|
297
|
+
running.set(id, p);
|
|
298
|
+
}
|
|
299
|
+
if (running.size) await Promise.race(running.values()); else if (pending.size) await sleep(10);
|
|
300
|
+
}
|
|
301
|
+
// report
|
|
302
|
+
const all = [];
|
|
303
|
+
for (const j of plan.jobs) {
|
|
304
|
+
if (j.not_applicable) continue;
|
|
305
|
+
if (j.deferred_reason) { all.push({ id: j.id, name: j.name, status: 'deferred', reason: j.deferred_reason, steps: [], duration_ms: 0 }); continue; }
|
|
306
|
+
const r = results.get(j.id); if (r) all.push(r);
|
|
307
|
+
}
|
|
308
|
+
const run_ = all.filter((r) => r.status === 'pass' || r.status === 'fail' || r.status === 'degraded');
|
|
309
|
+
const failed = all.filter((r) => r.status === 'fail'); const defd = all.filter((r) => r.status === 'deferred'); const skipped = all.filter((r) => r.status === 'skipped');
|
|
310
|
+
for (const r of defd) console.log(`CI_LOCAL_DEFERRED: ${r.id} | ${r.name} | ${r.reason}`);
|
|
311
|
+
let checks = 0; let hits = 0; let narrowed = 0; let widened = 0; let selTotal = 0; let selSel = 0;
|
|
312
|
+
for (const r of all) for (const [n, s] of r.steps.entries()) {
|
|
313
|
+
if (!s.scope) continue;
|
|
314
|
+
if (s.cache === 'hit') hits++;
|
|
315
|
+
if (s.scope.mode === 'scoped' || s.scope.mode === 'skip') narrowed++;
|
|
316
|
+
if (s.scope.widen_reason) widened++;
|
|
317
|
+
if (typeof s.scope.selected === 'number' && typeof s.scope.total === 'number') { selSel += s.scope.selected; selTotal += s.scope.total; }
|
|
318
|
+
if (checks++ < 40) console.log(`CI_LOCAL_CHECK: job=${r.id} step=${n + 1} selector=${s.scope.selector || 'none'} selected=${s.scope.selected ?? ''} total=${s.scope.total ?? ''} cache=${s.cache} duration_ms=${s.duration_ms} widen=${(s.scope.widen_reason || '').replace(/\s+/g, '_') || 'none'}`);
|
|
319
|
+
}
|
|
320
|
+
const degraded = all.filter((r) => r.status === 'degraded');
|
|
321
|
+
const budgetHit = all.some((r) => r.budget) || (all.some((r) => r.status === 'deferred' && /budget exhausted/.test(r.reason || '')));
|
|
322
|
+
for (const r of degraded) console.log(`CI_LOCAL_DEGRADED: ${r.id} | ${r.name} | ${r.reason}`);
|
|
323
|
+
// forge#4040: FAIL only for a runnable job that really failed. A timeout or an exhausted budget leaves code unproven:
|
|
324
|
+
// DEGRADED (exit 2), never a pass and never a fail.
|
|
325
|
+
const degReason = degraded.length ? 'job_timeout' : budgetHit ? (capped ? 'budget_capped_at_invocation_limit' : 'budget_exhausted') : '';
|
|
326
|
+
const state = failed.length ? 'FAIL' : degReason ? 'DEGRADED' : 'PASS';
|
|
327
|
+
const dur = Date.now() - t0;
|
|
328
|
+
if (plan.no_applicable_jobs && !all.length) console.log('CI_LOCAL_NOTE: no_applicable_jobs (CI definitions exist but no job applies to this change)');
|
|
329
|
+
console.log(`CI_LOCAL_RESULT: ${state} jobs_run=${run_.length} jobs_deferred=${defd.length} jobs_failed=${failed.length} jobs_degraded=${degraded.length} jobs_skipped=${skipped.length} checks=${checks} cache_hits=${hits} narrowed=${narrowed} widened=${widened} selected=${selSel} total=${selTotal} duration_ms=${dur}${Date.now() > ctx.deadline ? ' budget_exhausted=true' : ''}${plan.no_applicable_jobs && !all.length ? ' no_applicable_jobs=true' : ''}${degReason && state === 'DEGRADED' ? ` reason=${degReason}` : ''}`);
|
|
330
|
+
if (o.resultFile) { try { fs.writeFileSync(o.resultFile, JSON.stringify({ state, mode: plan.config && plan.config.mode, jobs: all, plan_key: plan.plan_key, duration_ms: dur }, null, 2)); } catch { /* ignore */ } }
|
|
331
|
+
try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
332
|
+
return failed.length ? 1 : state === 'DEGRADED' ? 2 : 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function printJob(r) {
|
|
336
|
+
const secs = (r.duration_ms / 1000).toFixed(1);
|
|
337
|
+
const stepsRun = r.steps.filter((s) => s.status !== 'skipped').length;
|
|
338
|
+
const hit = r.steps.filter((s) => s.cache === 'hit').length;
|
|
339
|
+
console.log(`CI_LOCAL_JOB: ${r.status === 'pass' ? 'pass' : r.status} ${r.id} | ${r.name} | ${secs}s | steps=${stepsRun}/${r.steps.length}${hit ? ` cached=${hit}` : ''}${r.reason ? ` | ${r.reason}` : ''}`);
|
|
340
|
+
if (r.status === 'fail' || r.status === 'degraded') {
|
|
341
|
+
console.log(`${r.status === 'fail' ? 'CI_LOCAL_FAIL' : 'CI_LOCAL_DEGRADED_STEP'}: ${r.id} | step: ${r.failed_step || '?'}${r.exit !== undefined ? ` | exit ${r.exit}` : ''}`);
|
|
342
|
+
if (r.failed_command) console.log(`CI_LOCAL_FAIL_COMMAND: ${r.failed_command.split('\n')[0].slice(0, 300)}`);
|
|
343
|
+
for (const l of r.tail || []) console.log(` | ${l}`);
|
|
344
|
+
}
|
|
345
|
+
}
|