forgedock 1.18.0 → 1.20.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/README.md +2 -0
- 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 +187 -10
- package/bin/runner.mjs +89 -13
- 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/orchestrate/phase-4-execution.md +1 -1
- package/commands/review-pr-agents/protocols.md +19 -0
- package/commands/review-pr-staging.md +4 -4
- package/commands/review-pr.md +31 -20
- package/commands/work-on/build/architect.md +54 -1
- package/commands/work-on/build/implement.md +28 -1
- package/commands/work-on/build/validate.md +321 -29
- package/commands/work-on/build.md +184 -17
- package/commands/work-on/close.md +9 -4
- package/commands/work-on/investigate.md +7 -0
- package/commands/work-on/remediate.md +43 -17
- package/commands/work-on/review.md +43 -22
- package/commands/work-on.md +4 -4
- package/docs/CONFIG.md +93 -2
- package/docs/OUTCOME-CODES.md +5 -1
- package/docs/RELEASE-NOTES.md +15 -0
- package/docs/STATS.md +11 -4
- package/docs/TELEMETRY.md +5 -2
- package/docs/WORK-ON-RUNTIME.md +29 -3
- 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/architect-resume-match.test.sh +12 -3
- package/scripts/build-knowledge-index.mjs +8 -5
- package/scripts/builder-resume-match.test.sh +12 -3
- package/scripts/calibration.mjs +5 -2
- 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 +4 -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 +379 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +451 -0
- package/scripts/ci-local/scoping.mjs +531 -0
- package/scripts/ci-local.sh +98 -0
- package/scripts/ci-local.test.sh +1108 -0
- package/scripts/classify-finding.sh +33 -7
- 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/file-deferred-followups.sh +91 -30
- package/scripts/file-deferred-followups.test.sh +101 -8
- 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 +3 -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 +294 -53
- package/scripts/review-finalize.test.sh +347 -9
- 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/review-roster.sh +18 -5
- package/scripts/review-roster.test.sh +57 -1
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/selfreview-plan.sh +86 -0
- package/scripts/selfreview-plan.test.sh +90 -0
- package/scripts/spawn-depth-check.sh +5 -4
- package/scripts/spawn-depth-check.test.sh +3 -0
- package/scripts/telemetry.test.sh +10 -0
- package/scripts/transition-label.sh +26 -0
- package/scripts/trusted-comments.test.sh +2 -1
- package/scripts/verify-phase-trail.test.sh +14 -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,67 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// task-runners.mjs - repos with no CI definition that gates the change: derive the check set from the repo's own
|
|
5
|
+
// task runners and tool configuration (package.json scripts, Makefile, justfile, Taskfile, tox/nox, pre-commit,
|
|
6
|
+
// pyproject tools, Cargo, Go, Maven, Gradle). Only lint/test/check style tasks; never build/deploy/publish.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { jobId } from '../lib.mjs';
|
|
11
|
+
import { parseSafe, baseJob } from './common.mjs';
|
|
12
|
+
import { stepsFromScript } from './common.mjs';
|
|
13
|
+
|
|
14
|
+
export const id = 'task-runners';
|
|
15
|
+
const NODE_SCRIPTS = ['lint', 'test', 'typecheck', 'type-check', 'check', 'tsc', 'format:check', 'test:unit'];
|
|
16
|
+
const TASK_NAMES = ['test', 'lint', 'check', 'ci', 'verify'];
|
|
17
|
+
|
|
18
|
+
const read = (a, f) => { try { return fs.readFileSync(path.join(a.repo, f), 'utf8'); } catch { return null; } };
|
|
19
|
+
const has = (a, f) => fs.existsSync(path.join(a.repo, f));
|
|
20
|
+
|
|
21
|
+
function mk(a, kind, name, command, extra = {}) {
|
|
22
|
+
const r = stepsFromScript(command, { name, cwd: '.', env: {}, repo: a.repo });
|
|
23
|
+
const steps = [...(extra.pre || []), ...r.steps];
|
|
24
|
+
return baseJob({ id: jobId('task', kind, name), source: 'task-runners', name: `${kind}: ${name}`, steps, warnings: [] });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function plan(a) {
|
|
28
|
+
const jobs = []; const used = [];
|
|
29
|
+
const add = (j) => jobs.push(j);
|
|
30
|
+
|
|
31
|
+
const pjText = read(a, 'package.json');
|
|
32
|
+
if (pjText) {
|
|
33
|
+
let pj = null; try { pj = JSON.parse(pjText); } catch { /* malformed */ }
|
|
34
|
+
const pm = has(a, 'pnpm-lock.yaml') ? 'pnpm' : has(a, 'yarn.lock') ? 'yarn' : (has(a, 'bun.lock') || has(a, 'bun.lockb')) ? 'bun' : 'npm';
|
|
35
|
+
if (pj && pj.scripts) {
|
|
36
|
+
used.push('package.json');
|
|
37
|
+
const install = !has(a, 'node_modules') ? [{ name: 'install dependencies', command: pm === 'npm' ? (has(a, 'package-lock.json') ? 'npm ci' : 'npm install') : `${pm} install`, cwd: '.', env: {}, kind: 'install' }] : [];
|
|
38
|
+
for (const s of NODE_SCRIPTS) if (typeof pj.scripts[s] === 'string' && !/^\s*echo\b.*(no test|not specified)/i.test(pj.scripts[s]) && !/Error: no test specified/.test(pj.scripts[s])) add(mk(a, pm, s, s === 'test' && pm === 'npm' ? 'npm test' : `${pm} run ${s}`, { pre: install }));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const mf = read(a, 'Makefile') || read(a, 'makefile') || read(a, 'GNUmakefile');
|
|
42
|
+
if (mf) { used.push('Makefile'); for (const t of TASK_NAMES) if (new RegExp(`^${t}\\s*:`, 'm').test(mf)) add(mk(a, 'make', t, `make ${t}`)); }
|
|
43
|
+
const jf = read(a, 'justfile') || read(a, 'Justfile');
|
|
44
|
+
if (jf) { used.push('justfile'); for (const t of TASK_NAMES) if (new RegExp(`^${t}\\b[^\\n:]*:`, 'm').test(jf)) add(mk(a, 'just', t, `just ${t}`)); }
|
|
45
|
+
const tf = read(a, 'Taskfile.yml') || read(a, 'Taskfile.yaml');
|
|
46
|
+
if (tf) { const d = parseSafe(tf); used.push('Taskfile'); if (d && d.tasks) for (const t of TASK_NAMES) if (d.tasks[t]) add(mk(a, 'task', t, `task ${t}`)); }
|
|
47
|
+
if (has(a, 'tox.ini')) { used.push('tox.ini'); add(mk(a, 'python', 'tox', 'tox -q')); }
|
|
48
|
+
if (has(a, 'noxfile.py')) { used.push('noxfile.py'); add(mk(a, 'python', 'nox', 'nox')); }
|
|
49
|
+
const py = read(a, 'pyproject.toml');
|
|
50
|
+
if (py) {
|
|
51
|
+
used.push('pyproject.toml');
|
|
52
|
+
if (/^\[tool\.ruff/m.test(py)) add(mk(a, 'python', 'ruff', 'ruff check .'));
|
|
53
|
+
if (/^\[tool\.black/m.test(py)) add(mk(a, 'python', 'black', 'black --check .'));
|
|
54
|
+
if (/^\[tool\.pytest/m.test(py) || has(a, 'pytest.ini')) add(mk(a, 'python', 'pytest', 'pytest'));
|
|
55
|
+
if (/^\[tool\.mypy/m.test(py)) add(mk(a, 'python', 'mypy', 'mypy .'));
|
|
56
|
+
} else if (has(a, 'pytest.ini')) add(mk(a, 'python', 'pytest', 'pytest'));
|
|
57
|
+
if (has(a, '.pre-commit-config.yaml')) {
|
|
58
|
+
used.push('.pre-commit-config.yaml');
|
|
59
|
+
const files = a.changed.filter((f) => has(a, f));
|
|
60
|
+
if (files.length) { const j = mk(a, 'pre-commit', 'run', 'pre-commit run --files ' + files.map((f) => `'${f.replace(/'/g, "'\\''")}'`).join(' ')); j.steps.forEach((s) => { s.prescoped = true; }); add(j); }
|
|
61
|
+
}
|
|
62
|
+
if (has(a, 'Cargo.toml')) { used.push('Cargo.toml'); add(mk(a, 'cargo', 'test', 'cargo test')); }
|
|
63
|
+
if (has(a, 'go.mod')) { used.push('go.mod'); add(mk(a, 'go', 'vet', 'go vet ./...')); add(mk(a, 'go', 'test', 'go test ./...')); }
|
|
64
|
+
if (has(a, 'pom.xml')) { used.push('pom.xml'); add(mk(a, 'maven', 'test', 'mvn -q -B test')); }
|
|
65
|
+
if (has(a, 'build.gradle') || has(a, 'build.gradle.kts')) { used.push('gradle'); add(mk(a, 'gradle', 'test', has(a, 'gradlew') ? './gradlew test' : 'gradle test')); }
|
|
66
|
+
return { jobs, warnings: [], used };
|
|
67
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// cli.mjs - node entry behind scripts/ci-local.sh. Subcommands: plan, run.
|
|
5
|
+
// Exit codes: 0 pass, 1 a runnable job failed, 2 degraded or usage error (the gate could not run; never a pass).
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { Degraded } from './lib.mjs';
|
|
9
|
+
import { buildPlan, summarizePlan } from './plan.mjs';
|
|
10
|
+
|
|
11
|
+
function parseArgs(argv) {
|
|
12
|
+
const o = { _: [] };
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
const t = argv[i];
|
|
15
|
+
const take = () => { if (i + 1 >= argv.length) throw new Error(`${t} needs a value`); return argv[++i]; };
|
|
16
|
+
if (t === '--repo') o.repo = take(); else if (t === '--base') o.base = take(); else if (t === '--base-branch') o.baseBranch = take();
|
|
17
|
+
else if (t === '--head-branch') o.headBranch = take(); else if (t === '--files') o.files = take().split(/[\n ]+/).filter(Boolean);
|
|
18
|
+
else if (t === '--files-from') o.files = fs.readFileSync(take(), 'utf8').split('\n').filter(Boolean);
|
|
19
|
+
else if (t === '--out') o.out = take(); else if (t === '--plan') o.planFile = take(); else if (t === '--no-cache') o.noCache = true;
|
|
20
|
+
else if (t === '--budget-minutes') o.budgetMinutes = Number(take()); else if (t === '--max-parallel') o.maxParallel = Number(take());
|
|
21
|
+
else if (t === '--json') o.json = true; else if (t === '--result-file') o.resultFile = take(); else if (t === '--iteration') o.iteration = Number(take());
|
|
22
|
+
else if (t === '--cache-dir') o.cacheDir = take(); else if (t === '--result-cache-dir') o.resultCacheDir = take();
|
|
23
|
+
else if (t.startsWith('-')) throw new Error(`unknown option ${t}`); else o._.push(t);
|
|
24
|
+
}
|
|
25
|
+
return o;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
const [sub, ...rest] = process.argv.slice(2);
|
|
30
|
+
const o = parseArgs(rest);
|
|
31
|
+
if (!['plan', 'run'].includes(sub)) { console.error('usage: ci-local.sh plan|run --repo PATH --base REF [--files "a b"] [--out FILE]'); return 2; }
|
|
32
|
+
if (!o.repo) { console.error('ci-local: --repo is required'); return 2; }
|
|
33
|
+
let plan;
|
|
34
|
+
if (sub === 'run' && o.planFile) plan = JSON.parse(fs.readFileSync(o.planFile, 'utf8'));
|
|
35
|
+
else plan = buildPlan(o);
|
|
36
|
+
if (sub === 'plan') {
|
|
37
|
+
if (o.out) fs.writeFileSync(o.out, JSON.stringify(plan, null, 2));
|
|
38
|
+
if (o.json) console.log(JSON.stringify(plan, null, 2)); else for (const l of summarizePlan(plan)) console.log(l);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
const { runPlan } = await import('./run.mjs');
|
|
42
|
+
return runPlan(plan, o);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// process.exitCode (not process.exit) so a large stdout pipe is flushed before the process ends.
|
|
46
|
+
main().then((c) => { process.exitCode = c; }, (e) => {
|
|
47
|
+
const msg = String((e && e.message) || e).split('\n')[0];
|
|
48
|
+
if (!(e instanceof Degraded)) console.error(`ci-local: ${msg}`);
|
|
49
|
+
console.log(`CI_LOCAL_RESULT: DEGRADED reason=${msg}`); process.exitCode = 2;
|
|
50
|
+
});
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// lib.mjs - shared helpers for scripts/ci-local (process, git, yaml, glob, expression, shell tokenizer).
|
|
5
|
+
// No dependencies beyond Node and the `yq` / `git` binaries. Nothing here ever talks to GitHub.
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
export class Unsupported extends Error {}
|
|
14
|
+
export class Degraded extends Error {}
|
|
15
|
+
|
|
16
|
+
export const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
17
|
+
|
|
18
|
+
export function run(cmd, args, opts = {}) {
|
|
19
|
+
const t0 = process.env.FORGE_CI_LOCAL_DEBUG ? Date.now() : 0;
|
|
20
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: opts.timeout || 60000, cwd: opts.cwd, input: opts.input, env: opts.env || process.env });
|
|
21
|
+
if (t0 && Date.now() - t0 > 40) process.stderr.write(`[ci-local debug] ${Date.now() - t0}ms ${cmd} ${args.slice(0, 6).join(' ').slice(0, 100)}\n`);
|
|
22
|
+
return { code: r.status === null ? 124 : r.status, out: r.stdout || '', err: r.stderr || '' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const git = (repo, args, opts = {}) => run('git', ['-C', repo, ...args], opts);
|
|
26
|
+
|
|
27
|
+
export function commandExists(name) {
|
|
28
|
+
const r = run('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', 'sh', name], { timeout: 5000 });
|
|
29
|
+
return r.code === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Memoized per process: stepKey runs for every step (twice: cost ordering and execution), and an unmemoized
|
|
33
|
+
// `<tool> --version` is a synchronous spawn that blocks the scheduler and burns the budget (#4068 review CONC-1).
|
|
34
|
+
const TOOL_VERSIONS = new Map();
|
|
35
|
+
export function toolVersion(name) {
|
|
36
|
+
if (TOOL_VERSIONS.has(name)) return TOOL_VERSIONS.get(name);
|
|
37
|
+
let v = '';
|
|
38
|
+
if (/^[A-Za-z0-9._+-]+$/.test(name) && commandExists(name)) {
|
|
39
|
+
const r = run(name, ['--version'], { timeout: 5000 });
|
|
40
|
+
v = (r.out || r.err).split('\n')[0].slice(0, 120);
|
|
41
|
+
}
|
|
42
|
+
TOOL_VERSIONS.set(name, v);
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hostClass() {
|
|
47
|
+
return { linux: 'linux', darwin: 'darwin', win32: 'win32' }[process.platform] || process.platform;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- YAML via yq (mikefarah v4 first, kislyuk-style fallback) ----
|
|
51
|
+
let yqMode = null;
|
|
52
|
+
export function yqAvailable() {
|
|
53
|
+
if (yqMode !== null) return yqMode !== '';
|
|
54
|
+
if (process.env.FORGE_CI_LOCAL_NO_YQ === '1') { yqMode = ''; return false; }
|
|
55
|
+
if (!commandExists('yq')) { yqMode = ''; return false; }
|
|
56
|
+
const ok = run('yq', ['-o=json', '.', '-'], { input: 'a: 1\n', timeout: 10000 });
|
|
57
|
+
if (ok.code === 0 && /"a":\s*1/.test(ok.out)) { yqMode = 'mikefarah'; return true; }
|
|
58
|
+
const alt = run('yq', ['.', '-'], { input: 'a: 1\n', timeout: 10000 });
|
|
59
|
+
if (alt.code === 0 && /"a":\s*1/.test(alt.out)) { yqMode = 'kislyuk'; return true; }
|
|
60
|
+
yqMode = ''; return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseYaml(text) {
|
|
64
|
+
if (!yqAvailable()) throw new Degraded('yq not found (mikefarah yq v4 is required to read CI definitions)');
|
|
65
|
+
const args = yqMode === 'mikefarah' ? ['-o=json', '-I=0', '.', '-'] : ['.', '-'];
|
|
66
|
+
const r = run('yq', args, { input: text, timeout: 30000 });
|
|
67
|
+
if (r.code !== 0) throw new Error('yaml parse error: ' + r.err.split('\n')[0]);
|
|
68
|
+
// Multi-document streams print one JSON value per document; keep the first document.
|
|
69
|
+
const first = r.out.trim();
|
|
70
|
+
if (first === '') return null;
|
|
71
|
+
try { return JSON.parse(first); } catch (e) {
|
|
72
|
+
const docs = []; let depth = 0, start = 0, inStr = false, esc = false;
|
|
73
|
+
for (let i = 0; i < first.length; i++) {
|
|
74
|
+
const c = first[i];
|
|
75
|
+
if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; }
|
|
76
|
+
if (c === '"') inStr = true; else if (c === '{' || c === '[') { if (depth === 0) start = i; depth++; } else if (c === '}' || c === ']') { depth--; if (depth === 0) docs.push(first.slice(start, i + 1)); }
|
|
77
|
+
}
|
|
78
|
+
if (!docs.length) throw new Error('yaml parse error: unreadable output');
|
|
79
|
+
return JSON.parse(docs[0]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const safeParseYaml = (text) => { try { return parseYaml(text); } catch (e) { if (e instanceof Degraded) throw e; return undefined; } };
|
|
84
|
+
|
|
85
|
+
// ---- repo state ----
|
|
86
|
+
export function resolveRef(repo, ref) {
|
|
87
|
+
const r = git(repo, ['rev-parse', '--verify', '--quiet', ref + '^{commit}']);
|
|
88
|
+
return r.code === 0 ? r.out.trim() : '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readAtRef(repo, ref, file) {
|
|
92
|
+
const r = git(repo, ['show', `${ref}:${file}`]);
|
|
93
|
+
return r.code === 0 ? r.out : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function listAtRef(repo, ref) {
|
|
97
|
+
const r = git(repo, ['ls-tree', '-r', '--name-only', ref]);
|
|
98
|
+
return r.code === 0 ? r.out.split('\n').filter(Boolean) : [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function trackedFiles(repo) {
|
|
102
|
+
const r = git(repo, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
|
|
103
|
+
return r.out.split('\0').filter(Boolean);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Files this change touches: working tree (staged + unstaged) vs the merge base, plus untracked.
|
|
107
|
+
export function changedFiles(repo, baseRef) {
|
|
108
|
+
const set = new Set();
|
|
109
|
+
const mb = baseRef ? git(repo, ['merge-base', baseRef, 'HEAD']) : { code: 1, out: '' };
|
|
110
|
+
const from = mb.code === 0 ? mb.out.trim() : (baseRef && resolveRef(repo, baseRef)) || 'HEAD';
|
|
111
|
+
for (const args of [['diff', '--name-only', '--no-renames', from], ['ls-files', '--others', '--exclude-standard']]) {
|
|
112
|
+
for (const f of git(repo, args).out.split('\n')) if (f) set.add(f);
|
|
113
|
+
}
|
|
114
|
+
return { files: [...set].sort(), from };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function headSha(repo) { return git(repo, ['rev-parse', 'HEAD']).out.trim(); }
|
|
118
|
+
|
|
119
|
+
// A fingerprint of the exact tree state (index + unstaged diff + untracked contents).
|
|
120
|
+
export function treeFingerprint(repo) {
|
|
121
|
+
const h = createHash('sha256');
|
|
122
|
+
h.update(git(repo, ['ls-files', '-s']).out);
|
|
123
|
+
h.update(git(repo, ['diff', '--no-ext-diff', '--binary']).out);
|
|
124
|
+
for (const f of git(repo, ['ls-files', '--others', '--exclude-standard', '-z']).out.split('\0').filter(Boolean)) {
|
|
125
|
+
h.update(f); try { h.update(fs.readFileSync(path.join(repo, f))); } catch { /* vanished */ }
|
|
126
|
+
}
|
|
127
|
+
return h.digest('hex');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function fileHash(repo, f) {
|
|
131
|
+
try { return sha256(fs.readFileSync(path.join(repo, f))); } catch { return 'missing'; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- globs (GitHub path-filter semantics: * stops at /, ** crosses it) ----
|
|
135
|
+
export function globToRegex(glob) {
|
|
136
|
+
let re = '';
|
|
137
|
+
for (let i = 0; i < glob.length; i++) {
|
|
138
|
+
const c = glob[i];
|
|
139
|
+
if (c === '*') {
|
|
140
|
+
if (glob[i + 1] === '*') {
|
|
141
|
+
i++;
|
|
142
|
+
if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
|
|
143
|
+
} else re += '[^/]*';
|
|
144
|
+
} else if (c === '?') re += '[^/]';
|
|
145
|
+
else if (c === '[') {
|
|
146
|
+
const j = glob.indexOf(']', i + 1);
|
|
147
|
+
if (j > i + 1) { re += '[' + glob.slice(i + 1, j).replace(/^!/, '^').replace(/\\/g, '\\\\') + ']'; i = j; } else re += '\\[';
|
|
148
|
+
} else re += c.replace(/[.+^${}()|\\]/g, '\\$&');
|
|
149
|
+
}
|
|
150
|
+
return new RegExp('^' + re + '$');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Ordered include/exclude list ('!' prefix excludes): the last matching pattern decides.
|
|
154
|
+
export function matchesFilter(file, patterns) {
|
|
155
|
+
let hit = false;
|
|
156
|
+
for (const p of patterns) {
|
|
157
|
+
if (typeof p !== 'string') continue;
|
|
158
|
+
const neg = p.startsWith('!');
|
|
159
|
+
const re = globToRegex((neg ? p.slice(1) : p).replace(/^\.?\//, ''));
|
|
160
|
+
if (re.test(file)) hit = !neg;
|
|
161
|
+
}
|
|
162
|
+
return hit;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function branchMatches(branch, patterns) {
|
|
166
|
+
let hit = false;
|
|
167
|
+
for (const p of patterns) {
|
|
168
|
+
if (typeof p !== 'string') continue;
|
|
169
|
+
const neg = p.startsWith('!');
|
|
170
|
+
if (globToRegex(neg ? p.slice(1) : p).test(branch)) hit = !neg;
|
|
171
|
+
}
|
|
172
|
+
return hit;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---- GitHub-style expressions ----
|
|
176
|
+
function truthy(v) { return !(v === false || v === 0 || v === '' || v === null || v === undefined || Number.isNaN(v)); }
|
|
177
|
+
function num(v) { if (v === null || v === undefined) return 0; if (typeof v === 'boolean') return v ? 1 : 0; if (typeof v === 'number') return v; if (v === '') return 0; const n = Number(v); return n; }
|
|
178
|
+
function looseEq(a, b) {
|
|
179
|
+
if (typeof a === 'string' && typeof b === 'string') return a.toLowerCase() === b.toLowerCase();
|
|
180
|
+
if ((a === null || a === undefined) && (b === null || b === undefined)) return true;
|
|
181
|
+
if (typeof a === typeof b) return a === b;
|
|
182
|
+
return num(a) === num(b);
|
|
183
|
+
}
|
|
184
|
+
export const str = (v) => (v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
185
|
+
|
|
186
|
+
function tokenize(src) {
|
|
187
|
+
const toks = []; let i = 0;
|
|
188
|
+
while (i < src.length) {
|
|
189
|
+
const c = src[i];
|
|
190
|
+
if (/\s/.test(c)) { i++; continue; }
|
|
191
|
+
if (c === "'") { let j = i + 1, s = ''; while (j < src.length) { if (src[j] === "'") { if (src[j + 1] === "'") { s += "'"; j += 2; continue; } break; } s += src[j++]; } if (j >= src.length) throw new Unsupported('unterminated string'); toks.push({ t: 'str', v: s }); i = j + 1; continue; }
|
|
192
|
+
if (/[0-9]/.test(c)) { const m = /^-?[0-9]+(\.[0-9]+)?/.exec(src.slice(i)); toks.push({ t: 'num', v: Number(m[0]) }); i += m[0].length; continue; }
|
|
193
|
+
if (/[A-Za-z_]/.test(c)) { const m = /^[A-Za-z_][A-Za-z0-9_-]*/.exec(src.slice(i)); toks.push({ t: 'id', v: m[0] }); i += m[0].length; continue; }
|
|
194
|
+
const two = src.slice(i, i + 2);
|
|
195
|
+
if (['==', '!=', '<=', '>=', '&&', '||'].includes(two)) { toks.push({ t: 'op', v: two }); i += 2; continue; }
|
|
196
|
+
if ('!<>().,[]*'.includes(c)) { toks.push({ t: 'op', v: c }); i++; continue; }
|
|
197
|
+
throw new Unsupported('unexpected character ' + c);
|
|
198
|
+
}
|
|
199
|
+
return toks;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const FUNCS = {
|
|
203
|
+
contains: (a, b) => (Array.isArray(a) ? a.some((x) => looseEq(x, b)) : str(a).toLowerCase().includes(str(b).toLowerCase())),
|
|
204
|
+
startsWith: (a, b) => str(a).toLowerCase().startsWith(str(b).toLowerCase()),
|
|
205
|
+
endsWith: (a, b) => str(a).toLowerCase().endsWith(str(b).toLowerCase()),
|
|
206
|
+
format: (f, ...a) => str(f).replace(/\{(\d+)\}/g, (_, n) => str(a[Number(n)])).replace(/\{\{/g, '{').replace(/\}\}/g, '}'),
|
|
207
|
+
join: (a, sep = ',') => (Array.isArray(a) ? a.map(str).join(str(sep)) : str(a)),
|
|
208
|
+
toJSON: (v) => JSON.stringify(v),
|
|
209
|
+
success: () => true, always: () => true, failure: () => false, cancelled: () => false,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Safe built-in names the evaluator may read from a context; anything else is Unsupported (never guessed).
|
|
213
|
+
const GITHUB_KEYS = new Set(['event_name', 'base_ref', 'head_ref', 'ref', 'ref_name', 'ref_type', 'sha', 'repository', 'workspace', 'event', 'server_url', 'repository_owner']);
|
|
214
|
+
|
|
215
|
+
const EVENT_PATHS = new Set(['pull_request', 'pull_request.base', 'pull_request.base.sha', 'pull_request.base.ref', 'pull_request.head', 'pull_request.head.sha', 'pull_request.head.ref', 'pull_request.draft', 'pull_request.labels', 'pull_request.labels.*.name', 'pull_request.head.repo', 'pull_request.head.repo.fork', 'pull_request.head.repo.full_name', 'pull_request.base.repo', 'pull_request.base.repo.full_name', 'before']);
|
|
216
|
+
|
|
217
|
+
export function evalExpr(src, ctx) {
|
|
218
|
+
const toks = tokenize(src); let p = 0;
|
|
219
|
+
const peek = () => toks[p]; const eat = (v) => { const t = toks[p]; if (t && t.t === 'op' && t.v === v) { p++; return true; } return false; };
|
|
220
|
+
function primary() {
|
|
221
|
+
const t = toks[p++];
|
|
222
|
+
if (!t) throw new Unsupported('unexpected end');
|
|
223
|
+
if (t.t === 'str' || t.t === 'num') return t.v;
|
|
224
|
+
if (t.t === 'op' && t.v === '(') { const v = or(); if (!eat(')')) throw new Unsupported('missing )'); return v; }
|
|
225
|
+
if (t.t === 'op' && t.v === '!') return !truthy(primary());
|
|
226
|
+
if (t.t === 'id') {
|
|
227
|
+
if (t.v === 'true') return true; if (t.v === 'false') return false; if (t.v === 'null') return null;
|
|
228
|
+
if (eat('(')) {
|
|
229
|
+
const args = []; if (!eat(')')) { do { args.push(or()); } while (eat(',')); if (!eat(')')) throw new Unsupported('missing )'); }
|
|
230
|
+
const fn = FUNCS[t.v];
|
|
231
|
+
if (!fn) throw new Unsupported('function ' + t.v + '()');
|
|
232
|
+
return fn(...args);
|
|
233
|
+
}
|
|
234
|
+
const segs = [t.v];
|
|
235
|
+
for (;;) {
|
|
236
|
+
if (eat('.')) { const n = toks[p++]; if (!n || n.t === 'op' && n.v !== '*') throw new Unsupported('bad member access'); if (n.t === 'op') { segs.push('*'); continue; } segs.push(String(n.v)); }
|
|
237
|
+
else if (peek() && peek().t === 'op' && peek().v === '[') { p++; const k = or(); if (!eat(']')) throw new Unsupported('missing ]'); segs.push(str(k)); }
|
|
238
|
+
else break;
|
|
239
|
+
}
|
|
240
|
+
return lookup(segs, ctx);
|
|
241
|
+
}
|
|
242
|
+
throw new Unsupported('unexpected token');
|
|
243
|
+
}
|
|
244
|
+
function cmp() { let l = primary(); while (peek() && peek().t === 'op' && ['<', '>', '<=', '>='].includes(peek().v)) { const o = toks[p++].v; const r = primary(); const a = num(l), b = num(r); l = o === '<' ? a < b : o === '>' ? a > b : o === '<=' ? a <= b : a >= b; } return l; }
|
|
245
|
+
function eq() { let l = cmp(); while (peek() && peek().t === 'op' && (peek().v === '==' || peek().v === '!=')) { const o = toks[p++].v; const r = cmp(); l = o === '==' ? looseEq(l, r) : !looseEq(l, r); } return l; }
|
|
246
|
+
function and() { let l = eq(); while (peek() && peek().t === 'op' && peek().v === '&&') { p++; const r = eq(); l = truthy(l) ? r : l; } return l; }
|
|
247
|
+
function or() { let l = and(); while (peek() && peek().t === 'op' && peek().v === '||') { p++; const r = and(); l = truthy(l) ? l : r; } return l; }
|
|
248
|
+
const v = or();
|
|
249
|
+
if (p < toks.length) throw new Unsupported('trailing tokens');
|
|
250
|
+
return v;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function lookup(segs, ctx) {
|
|
254
|
+
const root = segs[0];
|
|
255
|
+
if (ctx.__roots && root in ctx.__roots) {
|
|
256
|
+
let cur = ctx.__roots[root];
|
|
257
|
+
for (let i = 1; i < segs.length; i++) { if (cur === null || typeof cur !== 'object' || !(segs[i] in cur)) throw new Unsupported(segs.slice(0, i + 1).join('.')); cur = cur[segs[i]]; }
|
|
258
|
+
return cur === undefined ? null : cur;
|
|
259
|
+
}
|
|
260
|
+
if (root === 'github') {
|
|
261
|
+
if (!GITHUB_KEYS.has(segs[1])) throw new Unsupported('github.' + segs[1]);
|
|
262
|
+
if (segs[1] === 'event' && !EVENT_PATHS.has(segs.slice(2).join('.'))) throw new Unsupported('github.event.' + segs.slice(2).join('.'));
|
|
263
|
+
if (segs[1] === 'repository' && !ctx.github.repository) throw new Unsupported('github.repository');
|
|
264
|
+
} else if (root === 'runner') {
|
|
265
|
+
if (!['os', 'arch', 'temp'].includes(segs[1])) throw new Unsupported('runner.' + segs[1]);
|
|
266
|
+
} else if (root === 'matrix' || root === 'env' || root === 'needs' || root === 'steps') {
|
|
267
|
+
if (ctx[root] === undefined) throw new Unsupported(root + ' context');
|
|
268
|
+
} else throw new Unsupported(root + ' context');
|
|
269
|
+
let cur = ctx[root];
|
|
270
|
+
for (let i = 1; i < segs.length; i++) {
|
|
271
|
+
if (segs[i] === '*') {
|
|
272
|
+
if (cur === null || typeof cur !== 'object') return [];
|
|
273
|
+
const rest = segs.slice(i + 1); const items = Array.isArray(cur) ? cur : Object.values(cur);
|
|
274
|
+
return items.map((el) => { let c = el; for (const k of rest) { if (c === null || typeof c !== 'object') return null; c = c[k]; } return c === undefined ? null : c; });
|
|
275
|
+
}
|
|
276
|
+
if (cur === null || cur === undefined || typeof cur !== 'object') return null;
|
|
277
|
+
if (root === 'steps' && i === 1 && !(segs[i] in cur)) throw new Unsupported('steps.' + segs[i] + ' (not resolved locally)');
|
|
278
|
+
if (root === 'steps' && i === 2 && segs[2] === 'outputs' && !('outputs' in cur)) throw new Unsupported('step outputs');
|
|
279
|
+
if (root === 'needs' && i === 1 && !(segs[i] in cur)) throw new Unsupported('needs.' + segs[i]);
|
|
280
|
+
if (root === 'needs' && i === 2 && !['outputs', 'result'].includes(segs[2])) throw new Unsupported('needs.' + segs[1] + '.' + segs[2]);
|
|
281
|
+
if (root === 'needs' && i === 3 && !(segs[3] in cur)) throw new Unsupported('needs output ' + segs[3]);
|
|
282
|
+
if (root === 'steps' && i === 3 && !(segs[3] in cur)) throw new Unsupported('steps output ' + segs[3]);
|
|
283
|
+
cur = cur[segs[i]];
|
|
284
|
+
}
|
|
285
|
+
return cur === undefined ? null : cur;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Replace every ${{ expr }} in a string. A string that is exactly one expression keeps the raw value type.
|
|
289
|
+
export function interpolate(text, ctx) {
|
|
290
|
+
if (typeof text !== 'string' || !text.includes('${{')) return text;
|
|
291
|
+
const only = /^\s*\$\{\{([\s\S]*?)\}\}\s*$/.exec(text);
|
|
292
|
+
if (only && !only[1].includes('}}')) return evalExpr(only[1], ctx);
|
|
293
|
+
return text.replace(/\$\{\{([\s\S]*?)\}\}/g, (_, e) => str(evalExpr(e, ctx)));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function condTrue(cond, ctx) {
|
|
297
|
+
if (cond === undefined || cond === null || cond === true) return true;
|
|
298
|
+
if (cond === false) return false;
|
|
299
|
+
let c = String(cond).trim();
|
|
300
|
+
const m = /^\$\{\{([\s\S]*)\}\}$/.exec(c);
|
|
301
|
+
if (m) c = m[1].trim();
|
|
302
|
+
return truthy(evalExpr(c, ctx));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---- shell tokenizer: returns tokens for a single simple command, or null when the line is not simple ----
|
|
306
|
+
export function shellSplit(cmd) {
|
|
307
|
+
const toks = []; let cur = null, i = 0;
|
|
308
|
+
const push = () => { if (cur !== null) { toks.push(cur); cur = null; } };
|
|
309
|
+
while (i < cmd.length) {
|
|
310
|
+
const c = cmd[i];
|
|
311
|
+
if (c === '\\') { if (i + 1 >= cmd.length) return null; if (cmd[i + 1] === '\n') { i += 2; continue; } cur = (cur || '') + cmd[i + 1]; i += 2; continue; }
|
|
312
|
+
if (c === "'") { const j = cmd.indexOf("'", i + 1); if (j < 0) return null; cur = (cur || '') + cmd.slice(i + 1, j); i = j + 1; continue; }
|
|
313
|
+
if (c === '"') { let j = i + 1, s = ''; while (j < cmd.length && cmd[j] !== '"') { if (cmd[j] === '\\' && '"\\$`'.includes(cmd[j + 1] || '')) { s += cmd[j + 1]; j += 2; continue; } if (cmd[j] === '$' || cmd[j] === '`') return null; s += cmd[j++]; } if (j >= cmd.length) return null; cur = (cur || '') + s; i = j + 1; continue; }
|
|
314
|
+
if (/\s/.test(c)) { push(); i++; continue; }
|
|
315
|
+
if ('|&;<>(){}`$'.includes(c) || (c === '#' && cur === null)) return null;
|
|
316
|
+
cur = (cur || '') + c; i++;
|
|
317
|
+
}
|
|
318
|
+
push();
|
|
319
|
+
return toks;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function shellQuote(s) {
|
|
323
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(s) ? s : "'" + s.replace(/'/g, "'\\''") + "'";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- environment hygiene: CI jobs run in a runner-like environment ----
|
|
327
|
+
// An allowlist, not a blacklist: remote CI starts from a clean environment, so ambient operator state (agent
|
|
328
|
+
// credentials, install-root overrides, git hook variables such as GIT_DIR/GIT_INDEX_FILE, FORGE_*/CLAUDE_* settings)
|
|
329
|
+
// must not reach the jobs. Toolchain variables are passed through; credential-looking names are always dropped.
|
|
330
|
+
const ENV_EXACT = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LANGUAGE', 'TERM', 'TMPDIR', 'TMP', 'TEMP', 'TZ', 'COLORTERM', 'NO_COLOR', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'DOCKER_HOST', 'JAVA_HOME', 'GOPATH', 'GOROOT', 'GOFLAGS', 'GOCACHE', 'GOMODCACHE', 'GOPROXY', 'GOTOOLCHAIN', 'VIRTUAL_ENV', 'PYTHONPATH', 'PYTHONDONTWRITEBYTECODE', 'RUSTFLAGS', 'RUSTUP_HOME', 'SDKMAN_DIR', 'PNPM_HOME', 'BUN_INSTALL', 'NVM_DIR', 'NVM_BIN', 'NVM_INC', 'RBENV_ROOT', 'PYENV_ROOT', 'ASDF_DIR', 'ASDF_DATA_DIR', 'MISE_DATA_DIR', 'HOMEBREW_PREFIX', 'ComSpec', 'SystemRoot', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PROGRAMFILES', 'MSYSTEM']);
|
|
331
|
+
const ENV_PREFIX = ['LC_', 'XDG_', 'CARGO_', 'MAVEN_', 'M2_', 'GRADLE_', 'DOTNET_', 'npm_config_', 'NPM_CONFIG_', 'YARN_', 'UV_', 'POETRY_', 'PIP_CACHE', 'FORGE_CI_LOCAL_'];
|
|
332
|
+
// Substring tokens are unambiguous; short ones (PASS, PAT, KEY, IDENT, CRED, SESSION, COOKIE) match only as whole
|
|
333
|
+
// name segments (or a trailing _KEY) so benign names such as FORGE_CI_LOCAL_PASSTHROUGH or PATH are kept.
|
|
334
|
+
export const SECRET_NAME = /(TOKEN|SECRET|PASSWORD|PASSWD|AUTH|CREDENTIAL|PRIVATE|API_?KEY|ACCESS_?KEY|SESSION|COOKIE)|(^|[^A-Za-z0-9])(PASS|PAT|IDENT|CRED|CREDS|KEY)([^A-Za-z0-9]|$)|_KEY$/i;
|
|
335
|
+
// URL-embedded credentials (scheme://user:pass@host) in proxy/index/registry variables: the userinfo is stripped, the
|
|
336
|
+
// rest of the value is kept so the job still reaches the same host.
|
|
337
|
+
const URL_USERINFO = /([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^\/?#\s@]*@/g;
|
|
338
|
+
export function stripUserinfo(v) { return typeof v === 'string' ? v.replace(URL_USERINFO, '$1') : v; }
|
|
339
|
+
export function scrubbedEnv(extra = {}) {
|
|
340
|
+
const pass = new Set((process.env.FORGE_CI_LOCAL_PASSTHROUGH || '').split(',').map((x) => x.trim()).filter(Boolean));
|
|
341
|
+
const env = {};
|
|
342
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
343
|
+
if (SECRET_NAME.test(k)) continue;
|
|
344
|
+
if (ENV_EXACT.has(k) || pass.has(k) || ENV_PREFIX.some((p) => k.startsWith(p))) env[k] = stripUserinfo(v);
|
|
345
|
+
}
|
|
346
|
+
env.CI = 'true'; env.FORGE_CI_LOCAL = '1'; env.FORGE_TELEMETRY = 'off';
|
|
347
|
+
return Object.assign(env, extra);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function homeForgeDir() {
|
|
351
|
+
return process.env.FORGE_CI_LOCAL_HOME || path.join(process.env.HOME || os.homedir(), '.forge');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function majorOf(v) { const m = /(\d+)/.exec(String(v)); return m ? Number(m[1]) : null; }
|
|
355
|
+
|
|
356
|
+
export function dedupe(a) { return [...new Set(a)]; }
|
|
357
|
+
|
|
358
|
+
export function jobId(...parts) { return parts.filter((x) => x !== '' && x !== undefined && x !== null).join(':').replace(/[^A-Za-z0-9_.:@/-]+/g, '-'); }
|
|
359
|
+
|
|
360
|
+
// "Deferred" and "skipped" shapes shared by every adapter.
|
|
361
|
+
export function deferred(id, source, name, reason, extra = {}) { return { id, source, name, deferred_reason: reason, steps: [], services: [], env: {}, cwd: '.', ...extra }; }
|
|
362
|
+
export function notApplicable(id, source, name, reason) { return { id, source, name, not_applicable: reason, steps: [], services: [], env: {}, cwd: '.' }; }
|
|
363
|
+
|
|
364
|
+
export const SECRET_REF = /(secrets\.|\$\{?(?:\{\{ *)?secrets|CI_JOB_TOKEN|\bsecrets:)/;
|
|
365
|
+
export const DEPLOY_NAME = /(^|[^a-z])(deploy|deployment|publish|release|promote|rollout)([^a-z]|$)/i;
|
|
366
|
+
|
|
367
|
+
// A $VAR / ${VAR} reference whose name looks like a credential and is not defined in the CI file itself.
|
|
368
|
+
export function secretLikeVar(text, defined = new Set()) {
|
|
369
|
+
const re = /\$\{?([A-Za-z][A-Za-z0-9_]*(?:TOKEN|PASSWORD|PASSWD|SECRET|API_?KEY|PRIVATE_?KEY|CREDENTIALS?))\}?/g;
|
|
370
|
+
let m; while ((m = re.exec(text))) if (!defined.has(m[1])) return m[1];
|
|
371
|
+
return '';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function parseDuration(v) {
|
|
375
|
+
if (typeof v === 'number') return v * 60;
|
|
376
|
+
const m = /^(?:(\d+)\s*h(?:ours?)?)?\s*(?:(\d+)\s*m(?:in(?:utes?)?)?)?\s*(?:(\d+)\s*s(?:ec(?:onds?)?)?)?$/i.exec(String(v || '').trim());
|
|
377
|
+
if (!m || (!m[1] && !m[2] && !m[3])) return undefined;
|
|
378
|
+
return (Number(m[1] || 0) * 3600) + (Number(m[2] || 0) * 60) + Number(m[3] || 0);
|
|
379
|
+
}
|