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,101 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// circleci.mjs - .circleci/config.yml -> normalised jobs. Workflow job lists, branch filters, requires ordering,
|
|
5
|
+
// commands/executors with simple parameters, docker service containers (host network). Contexts (secrets), orbs,
|
|
6
|
+
// approvals, macOS executors off macOS, add_ssh_keys and deploy-named jobs are deferred-to-ci.
|
|
7
|
+
|
|
8
|
+
import { Unsupported, branchMatches, jobId, deferred, notApplicable, secretLikeVar, DEPLOY_NAME, hostClass, parseDuration } from '../lib.mjs';
|
|
9
|
+
import { stepsFromScript, loadDoc, parseSafe, baseJob } from './common.mjs';
|
|
10
|
+
|
|
11
|
+
export const id = 'circleci';
|
|
12
|
+
export const DEF_RE = /^\.circleci\/config\.ya?ml$/;
|
|
13
|
+
const NOOP = new Set(['checkout', 'restore_cache', 'save_cache', 'persist_to_workspace', 'attach_workspace', 'store_artifacts', 'store_test_results', 'setup_remote_docker']);
|
|
14
|
+
|
|
15
|
+
function subst(text, params) {
|
|
16
|
+
return String(text).replace(/<<\s*parameters\.([A-Za-z0-9_-]+)\s*>>/g, (_, k) => { if (!(k in params)) throw new Unsupported(`parameter ${k}`); return String(params[k]); })
|
|
17
|
+
.replace(/<<\s*(pipeline|[^>\s]+)[^>]*>>/g, (m) => { throw new Unsupported(`template ${m.slice(0, 40)}`); });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function expand(steps, doc, params, depth, out) {
|
|
21
|
+
if (depth > 4) throw new Unsupported('command nesting');
|
|
22
|
+
for (const st of steps || []) {
|
|
23
|
+
if (typeof st === 'string') { if (NOOP.has(st)) continue; if (doc.commands && doc.commands[st]) { expand(doc.commands[st].steps, doc, defaultsOf(doc.commands[st], {}), depth + 1, out); continue; } throw new Unsupported(`step ${st}`); }
|
|
24
|
+
const [k] = Object.keys(st); const v = st[k];
|
|
25
|
+
if (NOOP.has(k)) continue;
|
|
26
|
+
if (k === 'run') {
|
|
27
|
+
const o = typeof v === 'string' ? { command: v } : v;
|
|
28
|
+
if (o.when === 'on_fail' || o.when === 'never') continue;
|
|
29
|
+
out.push({ name: o.name || 'run', command: subst(o.command, params), env: Object.fromEntries(Object.entries(o.environment || {}).map(([a, b]) => [a, subst(b, params)])), wd: o.working_directory ? subst(o.working_directory, params) : '.' });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (doc.commands && doc.commands[k]) { expand(doc.commands[k].steps, doc, defaultsOf(doc.commands[k], v || {}), depth + 1, out); continue; }
|
|
33
|
+
throw new Unsupported(`step ${k}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function defaultsOf(def, args) { const p = {}; for (const [k, d] of Object.entries(def.parameters || {})) p[k] = k in args ? args[k] : d && d.default; for (const [k, v] of Object.entries(args)) p[k] = v; return p; }
|
|
37
|
+
|
|
38
|
+
const DEFAULT_PORTS = { postgres: 5432, mysql: 3306, mariadb: 3306, redis: 6379, mongo: 27017, rabbitmq: 5672, memcached: 11211, elasticsearch: 9200 };
|
|
39
|
+
export function declaredPorts(d) {
|
|
40
|
+
if (Array.isArray(d.ports) && d.ports.length) return d.ports.map((p) => (/^\d+$/.test(String(p)) ? `${p}:${p}` : String(p)));
|
|
41
|
+
const base = String(d.image || '').split('/').pop().split(':')[0].replace(/^cimg-/, '');
|
|
42
|
+
const hit = DEFAULT_PORTS[base] || DEFAULT_PORTS[String(d.image || '').split('/').pop().split(':')[0]];
|
|
43
|
+
return hit ? [`${hit}:${hit}`] : [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function plan(a) {
|
|
47
|
+
const out = []; const warnings = []; const used = [];
|
|
48
|
+
for (const file of a.defFiles(DEF_RE)) {
|
|
49
|
+
const doc = loadDoc(a, file, parseSafe);
|
|
50
|
+
if (doc === undefined) { out.push(deferred(jobId('circleci', 'config'), 'circleci', file, 'config YAML unreadable')); continue; }
|
|
51
|
+
if (!doc || !doc.jobs) continue;
|
|
52
|
+
used.push(file);
|
|
53
|
+
if (doc.orbs) warnings.push(`${file}: orbs declared; jobs/commands from orbs are deferred-to-ci`);
|
|
54
|
+
const entries = [];
|
|
55
|
+
const wfs = Object.entries(doc.workflows || {}).filter(([k]) => k !== 'version');
|
|
56
|
+
if (!wfs.length && doc.jobs.build) entries.push({ job: 'build', name: 'build', cfg: {} });
|
|
57
|
+
for (const [wn, wf] of wfs) {
|
|
58
|
+
if (wf.when !== undefined || wf.unless !== undefined) { out.push(deferred(jobId('circleci', wn), 'circleci', wn, 'workflow when/unless conditions are not evaluated')); continue; }
|
|
59
|
+
for (const item of wf.jobs || []) {
|
|
60
|
+
const [jn, cfg] = typeof item === 'string' ? [item, {}] : [Object.keys(item)[0], Object.values(item)[0] || {}];
|
|
61
|
+
entries.push({ job: jn, name: cfg.name || jn, cfg, wf: wn });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const nameToId = {}; for (const e of entries) nameToId[e.name] = jobId('circleci', e.wf || '', e.name);
|
|
65
|
+
for (const e of entries) {
|
|
66
|
+
const jid = nameToId[e.name]; const jobDef = doc.jobs[e.job];
|
|
67
|
+
const def = (r) => out.push(deferred(jid, 'circleci', e.name, r));
|
|
68
|
+
try {
|
|
69
|
+
if (!jobDef) { def(`job ${e.job} defined by an orb or missing`); continue; }
|
|
70
|
+
const f = e.cfg.filters && e.cfg.filters.branches;
|
|
71
|
+
if (f && ((f.only && !branchMatches(a.headBranch || '', [].concat(f.only))) || (f.ignore && branchMatches(a.headBranch || '', [].concat(f.ignore))))) { out.push(notApplicable(jid, 'circleci', e.name, 'branch filter excludes this branch')); continue; }
|
|
72
|
+
if (e.cfg.context) { def('uses a context (secrets)'); continue; }
|
|
73
|
+
if (e.cfg.type === 'approval') { def('approval gate'); continue; }
|
|
74
|
+
if (e.cfg.matrix) { def('matrix job'); continue; }
|
|
75
|
+
if (DEPLOY_NAME.test(e.job)) { def('deploy/publish job'); continue; }
|
|
76
|
+
let exec = jobDef; if (jobDef.executor) { const ex = typeof jobDef.executor === 'string' ? jobDef.executor : jobDef.executor.name; if (!doc.executors || !doc.executors[ex]) { def(`executor ${ex} is not defined in this file`); continue; } exec = { ...doc.executors[ex], ...jobDef }; }
|
|
77
|
+
if (exec.macos && hostClass() !== 'darwin') { def('macOS executor'); continue; }
|
|
78
|
+
if (exec.machine && exec.machine.image && /windows/i.test(exec.machine.image)) { def('Windows executor'); continue; }
|
|
79
|
+
const params = defaultsOf(jobDef, Object.fromEntries(Object.entries(e.cfg).filter(([k]) => !['requires', 'filters', 'context', 'name', 'type', 'matrix'].includes(k))));
|
|
80
|
+
const steps = []; expand(jobDef.steps, doc, params, 0, steps);
|
|
81
|
+
if (jobDef.steps.some((s) => s && typeof s === 'object' && Object.keys(s)[0] === 'add_ssh_keys')) { def('add_ssh_keys'); continue; }
|
|
82
|
+
const services = [];
|
|
83
|
+
// forge#4040 (SEC-6): secondary containers never use host networking. Each runs on a per-job docker network and only
|
|
84
|
+
// its declared ports (a `ports:` list, else the image's well-known default) are published on 127.0.0.1.
|
|
85
|
+
if (Array.isArray(exec.docker) && exec.docker.length > 1) for (const d of exec.docker.slice(1)) services.push({ name: String(d.name || d.image).replace(/[^A-Za-z0-9_.-]/g, '-'), image: d.image, env: d.environment || {}, ports: declaredPorts(d), options: '' });
|
|
86
|
+
{ const blind = services.filter((sv) => !sv.ports.length); if (blind.length) { def(`unknown service ports: ${blind.map((sv) => sv.image).join(', ')} (no ports: declared and no well-known default; unreachable without host networking)`); continue; } }
|
|
87
|
+
const env = Object.fromEntries(Object.entries({ ...(exec.environment || {}), ...(exec.docker && exec.docker[0] ? exec.docker[0].environment || {} : {}) }).map(([k, v]) => [k, subst(v, params)]));
|
|
88
|
+
const defined = new Set(Object.keys(env));
|
|
89
|
+
const all = steps.map((s) => s.command).join('\n');
|
|
90
|
+
const sv = secretLikeVar(all, defined);
|
|
91
|
+
if (sv) { def(`references credential-like variable $${sv}`); continue; }
|
|
92
|
+
Object.assign(env, { CIRCLE_BRANCH: a.headBranch || '', CIRCLE_SHA1: a.headSha, CIRCLE_WORKING_DIRECTORY: a.repo, CIRCLE_JOB: e.job });
|
|
93
|
+
const built = []; let defer = '';
|
|
94
|
+
for (const s of steps) { const r = stepsFromScript(s.command, { name: s.name, cwd: s.wd, env: s.env, repo: a.repo }); if (r.defer) defer = defer || r.defer; built.push(...r.steps); }
|
|
95
|
+
if (defer) { def(defer); continue; }
|
|
96
|
+
out.push(baseJob({ id: jid, source: 'circleci', name: e.name, env, steps: built, services, needs: (e.cfg.requires || []).map((r) => nameToId[r]).filter(Boolean), timeout_s: parseDuration(jobDef.no_output_timeout) }));
|
|
97
|
+
} catch (err) { if (err instanceof Unsupported) { def(`unsupported construct: ${err.message}`); continue; } throw err; }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { jobs: out, warnings, used };
|
|
101
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// common.mjs - what every source adapter shares: turning a script into classified steps, and the rule that a step
|
|
5
|
+
// which installs system packages, deploys, or talks to a remote service is never run locally.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { commandExists, shellSplit, parseYaml, Degraded } from '../lib.mjs';
|
|
10
|
+
import { splitBlock } from '../scoping.mjs';
|
|
11
|
+
|
|
12
|
+
const SYSTEM = /^(sudo|apt|apt-get|aptitude|yum|dnf|apk|brew|choco|snap|zypper|pacman|port|winget|scoop|systemctl|service)(\s|$)/;
|
|
13
|
+
const REMOTE = /^(docker\s+(push|login)|podman\s+push|kubectl|helm|terraform\s+(apply|destroy)|pulumi|aws|gcloud|az|gsutil|doctl|flyctl|fly\s+deploy|vercel|netlify|wrangler|heroku|firebase|npm\s+(publish|login|whoami|token)|yarn\s+npm\s+publish|pnpm\s+publish|twine\s+upload|cargo\s+(publish|login)|gem\s+push|git\s+push|git\s+remote\s+add|gh(\s|$)|curl\s+.*\|\s*(ba)?sh|ssh(\s|$)|scp(\s|$)|rsync\s+.*:|sentry-cli|codecov|slack)/;
|
|
14
|
+
const INSTALL = /^(npm\s+(ci|install|i)|pnpm\s+(install|i)|yarn(\s+install)?|bun\s+install|pip3?\s+install|python3?\s+-m\s+pip\s+install|poetry\s+install|uv\s+(sync|pip\s+install)|pipenv\s+install|bundle\s+install|composer\s+install|go\s+mod\s+(download|tidy)|cargo\s+fetch|mvn\s+.*dependency:|gradle(w)?\s+dependencies|dotnet\s+restore)(\s|$)/;
|
|
15
|
+
const SHELL_BUILTIN = new Set(['cd', 'echo', 'test', '[', '[[', 'export', 'set', 'true', 'false', 'exit', 'printf', 'source', '.', 'read', 'eval', 'exec', 'trap', 'unset', 'command', 'type', ':', 'if', 'for', 'while', 'case', 'until', 'function', 'local', 'declare', 'shift', 'wait', 'return', 'mkdir', 'rm', 'cp', 'mv', 'cat', 'ls', 'pwd', 'sleep', 'grep', 'sed', 'awk', 'find', 'sort', 'head', 'tail', 'wc', 'tr', 'cut', 'xargs', 'diff', 'touch', 'chmod', 'env', 'bash', 'sh', 'git', 'tee', 'basename', 'dirname', 'date', 'uname', 'which', 'tar', 'unzip', 'jq', 'yq', 'make', 'node', 'npm', 'npx']);
|
|
16
|
+
|
|
17
|
+
// Classify one command line. Returns { kind, defer?, noop?, note? }.
|
|
18
|
+
// Every command segment of a line: split on `&&`, `||`, `;`, `|`, `&`, newlines, `$(`, backticks and grouping, then drop
|
|
19
|
+
// leading env assignments and control words, so `cd x && aws s3 sync` is judged on `aws`, not on `cd`. A quoted separator
|
|
20
|
+
// over-splits, which only ever defers more (the safe direction).
|
|
21
|
+
const LEAD_WORD = /^(?:(?:then|do|else|elif|if|while|until|time|exec|command|env|nohup|!)\s+)+/;
|
|
22
|
+
function segments(cmd) {
|
|
23
|
+
return cmd.split(/&&|\|\||[;|&\n`(){}]|\$\(/).map((p) => p.trim().replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '').replace(LEAD_WORD, '').replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '')).filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function classifyCommand(cmd, repo, cwdRel) {
|
|
27
|
+
const first = cmd.trim().replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '');
|
|
28
|
+
const head3 = (t) => t.split(/\s+/).slice(0, 3).join(' ');
|
|
29
|
+
for (const seg of segments(cmd)) {
|
|
30
|
+
if (SYSTEM.test(seg)) return { kind: 'system', defer: `system-level command: ${head3(seg)}` };
|
|
31
|
+
if (REMOTE.test(seg)) return { kind: 'remote', defer: `remote-mutating or service command: ${head3(seg)}` };
|
|
32
|
+
}
|
|
33
|
+
// `curl ... | sh` spans a pipe, so it is matched on the whole line as well as per segment.
|
|
34
|
+
if (/\b(curl|wget)\b[^\n]*\|\s*(ba)?sh\b/.test(cmd)) return { kind: 'remote', defer: `remote-mutating or service command: ${head3(first)}` };
|
|
35
|
+
if (INSTALL.test(first)) {
|
|
36
|
+
if (/^(npm|pnpm|yarn|bun)\b/.test(first)) {
|
|
37
|
+
const nm = path.join(repo, cwdRel || '.', 'node_modules');
|
|
38
|
+
return fs.existsSync(nm) ? { kind: 'install', noop: true, note: 'node_modules present' } : { kind: 'install', note: 'dependency install (offline failure defers the job)' };
|
|
39
|
+
}
|
|
40
|
+
if (/^(go|cargo|mvn|gradle|dotnet)/.test(first)) return { kind: 'install', noop: true, note: 'the build tool fetches dependencies on demand' };
|
|
41
|
+
return { kind: 'install', noop: true, note: 'python/ruby/php dependency install is not run on the host; assumes a provisioned environment' };
|
|
42
|
+
}
|
|
43
|
+
return { kind: 'check' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Missing local tool for a simple command: the job is deferred, never failed for an absent binary.
|
|
47
|
+
export function missingTool(cmd) {
|
|
48
|
+
const toks = shellSplit(cmd);
|
|
49
|
+
if (!toks) return '';
|
|
50
|
+
const i = toks.findIndex((t) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(t));
|
|
51
|
+
const t = i >= 0 ? toks[i] : '';
|
|
52
|
+
if (!t || t.includes('/') || SHELL_BUILTIN.has(t) || t.startsWith('.')) return '';
|
|
53
|
+
return commandExists(t) ? '' : t;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Build normalised step objects for one `run` script. Lines are split only when provably independent.
|
|
57
|
+
export function stepsFromScript(script, o) {
|
|
58
|
+
const parts = splitBlock(script);
|
|
59
|
+
const out = []; let defer = '';
|
|
60
|
+
parts.forEach((cmd, idx) => {
|
|
61
|
+
const c = classifyCommand(cmd, o.repo, o.cwd);
|
|
62
|
+
if (c.defer && !defer) defer = c.defer;
|
|
63
|
+
// A multi-line block is also scanned line by line for deferrable commands.
|
|
64
|
+
if (parts.length === 1 && cmd.includes('\n')) for (const l of cmd.split('\n')) { const lc = classifyCommand(l, o.repo, o.cwd); if (lc.defer && !defer) defer = lc.defer; }
|
|
65
|
+
if (c.noop) { out.push({ name: `${o.name || 'run'}`, command: cmd, cwd: o.cwd, env: o.env || {}, shell: o.shell, kind: 'install', noop: true, note: c.note }); return; }
|
|
66
|
+
out.push({
|
|
67
|
+
name: parts.length > 1 ? `${o.name || 'run'} [${idx + 1}/${parts.length}]` : (o.name || 'run'),
|
|
68
|
+
group: parts.length > 1 ? (o.name || 'run') : undefined,
|
|
69
|
+
command: cmd, cwd: o.cwd, env: o.env || {}, shell: o.shell, kind: c.kind, note: c.note,
|
|
70
|
+
continue_on_error: !!o.continue_on_error, timeout_s: o.timeout_s,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
return { steps: out, defer };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Resolve the shell invocation (argv minus the script file) for a CI `shell:` value; null when it cannot run here.
|
|
77
|
+
export function shellArgv(shell) {
|
|
78
|
+
const s = (shell || '').trim();
|
|
79
|
+
if (!s) return ['bash', '-e'];
|
|
80
|
+
if (s === 'bash') return ['bash', '--noprofile', '--norc', '-eo', 'pipefail'];
|
|
81
|
+
if (s === 'sh') return ['sh', '-e'];
|
|
82
|
+
if (/^bash\b/.test(s)) return s.replace(/\s*\{0\}\s*$/, '').split(/\s+/);
|
|
83
|
+
if (/^sh\b/.test(s)) return s.replace(/\s*\{0\}\s*$/, '').split(/\s+/);
|
|
84
|
+
if (s === 'python') return ['python3'];
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function deepMerge(a, b) {
|
|
89
|
+
if (b === undefined) return a;
|
|
90
|
+
if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) {
|
|
91
|
+
const out = { ...a }; for (const [k, v] of Object.entries(b)) out[k] = k in a ? deepMerge(a[k], v) : v; return out;
|
|
92
|
+
}
|
|
93
|
+
return b;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Trusted base definition merged with the PR's own edits (PR wins per key; a PR cannot delete a base job).
|
|
97
|
+
export function loadDoc(a, file, parse) {
|
|
98
|
+
const { base, head } = a.readDefs(file);
|
|
99
|
+
const b = base != null ? parse(base) : null; const h = head != null ? parse(head) : null;
|
|
100
|
+
if (b === undefined || h === undefined) return undefined;
|
|
101
|
+
if (!b) return h; if (!h) return b;
|
|
102
|
+
return deepMerge(b, h);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseSafe(text) {
|
|
106
|
+
// parseYaml throws Degraded when yq is absent (propagate) and Error on malformed YAML (undefined = unreadable).
|
|
107
|
+
try { return parseYaml(text); } catch (e) { if (e instanceof Degraded) throw e; return undefined; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Env and checkout conventions for jobs derived from non-GitHub CI systems.
|
|
111
|
+
export function baseJob(o) {
|
|
112
|
+
return { id: o.id, source: o.source, name: o.name, cwd: '.', env: o.env || {}, services: o.services || [], steps: o.steps || [], needs: o.needs || [], timeout_s: o.timeout_s, deferred_reason: null, warnings: o.warnings || [], matrix: o.matrix };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function globList(v) { return [].concat(v || []).filter((x) => typeof x === 'string'); }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// explicit.mjs - commands the operator declared in forge.yaml -> verification.ci_local.extra_commands.
|
|
5
|
+
// Always merged into the plan. verification.commands is NOT repeated here: validate V2 already runs it as the fast pre-step.
|
|
6
|
+
|
|
7
|
+
import { jobId } from '../lib.mjs';
|
|
8
|
+
import { baseJob, stepsFromScript } from './common.mjs';
|
|
9
|
+
|
|
10
|
+
export const id = 'explicit';
|
|
11
|
+
|
|
12
|
+
export function plan(a) {
|
|
13
|
+
const jobs = [];
|
|
14
|
+
(a.config.extra_commands || []).forEach((c, i) => {
|
|
15
|
+
const cmd = typeof c === 'string' ? c : c && c.command; if (!cmd) return;
|
|
16
|
+
const name = typeof c === 'object' && c.name ? String(c.name) : cmd.split('\n')[0].slice(0, 60);
|
|
17
|
+
const r = stepsFromScript(String(cmd), { name, cwd: '.', env: {}, repo: a.repo });
|
|
18
|
+
jobs.push(baseJob({ id: jobId('explicit', String(i + 1)), source: 'explicit', name, steps: r.steps }));
|
|
19
|
+
});
|
|
20
|
+
return { jobs, warnings: [], used: [] };
|
|
21
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// github-actions.mjs - GitHub Actions workflows -> normalised jobs.
|
|
5
|
+
// Reads workflows from the base ref (trusted) plus the PR's own edits, applies on.pull_request/push path filters,
|
|
6
|
+
// dorny/paths-filter outputs and job `if:` conditions, and defers anything that cannot be proven locally.
|
|
7
|
+
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
Unsupported, Degraded, parseYaml, branchMatches, matchesFilter, evalExpr, interpolate, condTrue, hostClass, jobId, deferred, notApplicable,
|
|
11
|
+
SECRET_REF, DEPLOY_NAME, commandExists, run, majorOf, str,
|
|
12
|
+
} from '../lib.mjs';
|
|
13
|
+
import { stepsFromScript, shellArgv } from './common.mjs';
|
|
14
|
+
|
|
15
|
+
export const id = 'github-actions';
|
|
16
|
+
export const DEF_RE = /^\.github\/workflows\/[^/]+\.ya?ml$/;
|
|
17
|
+
|
|
18
|
+
const NOOP_ACTIONS = /^(actions\/checkout|actions\/cache(\/(restore|save))?|actions\/upload-artifact|step-security\/harden-runner|Swatinem\/rust-cache)(@|$)/;
|
|
19
|
+
const SETUP = {
|
|
20
|
+
'actions/setup-node': { tool: 'node', key: 'node-version', local: () => process.version },
|
|
21
|
+
'actions/setup-python': { tool: 'python3', key: 'python-version', local: () => run('python3', ['--version']).out.trim() },
|
|
22
|
+
'actions/setup-go': { tool: 'go', key: 'go-version', local: () => run('go', ['version']).out.trim() },
|
|
23
|
+
'actions/setup-java': { tool: 'java', key: 'java-version', local: () => { const r = run('java', ['-version']); return (r.err + r.out).split('\n')[0]; } },
|
|
24
|
+
'actions/setup-dotnet': { tool: 'dotnet', key: 'dotnet-version', local: () => run('dotnet', ['--version']).out.trim() },
|
|
25
|
+
'ruby/setup-ruby': { tool: 'ruby', key: 'ruby-version', local: () => run('ruby', ['--version']).out.trim() },
|
|
26
|
+
'oven-sh/setup-bun': { tool: 'bun', key: 'bun-version', local: () => run('bun', ['--version']).out.trim() },
|
|
27
|
+
'astral-sh/setup-uv': { tool: 'uv', key: 'version', local: () => run('uv', ['--version']).out.trim() },
|
|
28
|
+
'pnpm/action-setup': { tool: 'pnpm', key: 'version', local: () => run('pnpm', ['--version']).out.trim() },
|
|
29
|
+
'dtolnay/rust-toolchain': { tool: 'cargo', key: 'toolchain', local: () => run('cargo', ['--version']).out.trim() },
|
|
30
|
+
'shivammathur/setup-php': { tool: 'php', key: 'php-version', local: () => run('php', ['--version']).out.split('\n')[0] },
|
|
31
|
+
'denoland/setup-deno': { tool: 'deno', key: 'deno-version', local: () => run('deno', ['--version']).out.split('\n')[0] },
|
|
32
|
+
};
|
|
33
|
+
const SHELLCHECK_ACTIONS = /^(ludeeus\/action-shellcheck|reviewdog\/action-shellcheck)(@|$)/;
|
|
34
|
+
|
|
35
|
+
function events(on) {
|
|
36
|
+
if (on === undefined || on === null) return {};
|
|
37
|
+
if (typeof on === 'string') return { [on]: {} };
|
|
38
|
+
if (Array.isArray(on)) return Object.fromEntries(on.map((e) => [e, {}]));
|
|
39
|
+
return Object.fromEntries(Object.entries(on).map(([k, v]) => [k, v && typeof v === 'object' ? v : {}]));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pathsOk(cfg, changed) {
|
|
43
|
+
if (!changed.length) return true;
|
|
44
|
+
if (Array.isArray(cfg.paths)) return changed.some((f) => matchesFilter(f, cfg.paths));
|
|
45
|
+
if (Array.isArray(cfg['paths-ignore'])) return changed.some((f) => !matchesFilter(f, cfg['paths-ignore']));
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function branchOk(cfg, branch) {
|
|
50
|
+
if (Array.isArray(cfg.branches)) return branchMatches(branch, cfg.branches);
|
|
51
|
+
if (Array.isArray(cfg['branches-ignore'])) return !branchMatches(branch, cfg['branches-ignore']);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Does this workflow gate a pull request into the base branch? Returns '' when yes, else the reason it does not.
|
|
56
|
+
function gateReason(ev, a) {
|
|
57
|
+
const names = Object.keys(ev);
|
|
58
|
+
if (ev.pull_request) {
|
|
59
|
+
const c = ev.pull_request;
|
|
60
|
+
if (!branchOk(c, a.baseBranch)) return `pull_request branches filter excludes base ${a.baseBranch}`;
|
|
61
|
+
if (!pathsOk(c, a.changed)) return 'pull_request path filter selects none of the changed files';
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
if (ev.push) {
|
|
65
|
+
const c = ev.push;
|
|
66
|
+
if (Array.isArray(c.tags) && !Array.isArray(c.branches) && !Array.isArray(c['branches-ignore'])) return 'push is tag-only';
|
|
67
|
+
if (!branchOk(c, a.headBranch || '')) return `push branches filter excludes ${a.headBranch}`;
|
|
68
|
+
if (!pathsOk(c, a.changed)) return 'push path filter selects none of the changed files';
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
return `not a pull-request gate (events: ${names.join(', ') || 'none'})`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function mergeDocs(baseText, headText) {
|
|
75
|
+
const b = baseText != null ? safe(baseText) : null; const h = headText != null ? safe(headText) : null;
|
|
76
|
+
if (b === undefined || h === undefined) return undefined;
|
|
77
|
+
if (!b) return h; if (!h) return b;
|
|
78
|
+
return { ...h, jobs: { ...(b.jobs || {}), ...(h.jobs || {}) } };
|
|
79
|
+
}
|
|
80
|
+
function safe(t) { try { return parseYaml(t); } catch (e) { if (e instanceof Degraded) throw e; return undefined; } }
|
|
81
|
+
|
|
82
|
+
function topo(jobs) {
|
|
83
|
+
const ids = Object.keys(jobs); const done = new Set(); const out = [];
|
|
84
|
+
const needsOf = (j) => [].concat(jobs[j].needs || []).filter((n) => typeof n === 'string');
|
|
85
|
+
let guard = ids.length + 2;
|
|
86
|
+
while (out.length < ids.length && guard-- > 0) for (const j of ids) if (!done.has(j) && needsOf(j).every((n) => done.has(n) || !(n in jobs))) { done.add(j); out.push(j); }
|
|
87
|
+
for (const j of ids) if (!done.has(j)) out.push(j); // cycles: processed last, deferred by missing needs
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function expandMatrix(m, ctx) {
|
|
92
|
+
if (m === undefined || m === null) return [{}];
|
|
93
|
+
if (typeof m === 'string') throw new Unsupported('dynamic matrix');
|
|
94
|
+
const base = {}; for (const [k, v] of Object.entries(m)) if (!['include', 'exclude'].includes(k)) { let val = v; if (typeof val === 'string') val = interpolate(val, ctx); if (!Array.isArray(val)) throw new Unsupported('dynamic matrix axis ' + k); base[k] = val; }
|
|
95
|
+
let combos = [{}];
|
|
96
|
+
for (const [k, vals] of Object.entries(base)) combos = combos.flatMap((c) => vals.map((v) => ({ ...c, [k]: v })));
|
|
97
|
+
if (!Object.keys(base).length) combos = [];
|
|
98
|
+
const ex = Array.isArray(m.exclude) ? m.exclude : [];
|
|
99
|
+
combos = combos.filter((c) => !ex.some((e) => Object.entries(e).every(([k, v]) => c[k] === v)));
|
|
100
|
+
// GitHub include semantics (forge#4040): an entry extends every combo whose ORIGINAL axis values all match it (an entry
|
|
101
|
+
// naming no original axis matches every combo); an entry that matches no combo is added as a new combo. Original axis
|
|
102
|
+
// values are never overwritten; extra keys are.
|
|
103
|
+
for (const inc of Array.isArray(m.include) ? m.include : []) {
|
|
104
|
+
const keys = Object.keys(inc).filter((k) => k in base);
|
|
105
|
+
const targets = Object.keys(base).length ? combos.filter((c) => keys.every((k) => c[k] === inc[k])) : [];
|
|
106
|
+
if (targets.length) { for (const t of targets) for (const [k, v] of Object.entries(inc)) if (!(k in base)) t[k] = v; }
|
|
107
|
+
else combos.push({ ...inc });
|
|
108
|
+
}
|
|
109
|
+
return combos.length ? combos : [{}];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function osClass(runsOn) {
|
|
113
|
+
const labels = [].concat(runsOn && runsOn.labels ? runsOn.labels : runsOn).filter((x) => typeof x === 'string').map((x) => x.toLowerCase());
|
|
114
|
+
if (!labels.length) return { reason: 'runs-on missing' };
|
|
115
|
+
if (labels.includes('self-hosted') || (runsOn && runsOn.group)) return { reason: 'self-hosted runner' };
|
|
116
|
+
for (const l of labels) { if (/windows/.test(l)) return { os: 'win32', label: l }; if (/macos|mac-?os/.test(l)) return { os: 'darwin', label: l }; if (/ubuntu|linux|debian|arm64|x64/.test(l)) return { os: 'linux', label: l }; }
|
|
117
|
+
return { reason: `unknown runner label ${labels.join(',')}` };
|
|
118
|
+
}
|
|
119
|
+
function hostRuns(os) { const h = hostClass(); return os === 'linux' ? h === 'linux' || h === 'darwin' : os === h; }
|
|
120
|
+
|
|
121
|
+
// forge#4040 (SEC-5, FIX-2): no expression whose value could carry attacker-influenced text is ever substituted into script
|
|
122
|
+
// text. A `github.*` or `env.*` expression (env values are themselves expanded from github.* at plan time, so
|
|
123
|
+
// `${{ env.X }}` is the indirect form of the same injection) is evaluated into an environment VALUE and the script gets a
|
|
124
|
+
// quoted shell variable reference. Any other expression is substituted literally only when its value is inert
|
|
125
|
+
// ([A-Za-z0-9_./:@+,=-]); otherwise it goes through the environment too.
|
|
126
|
+
const INERT = /^[A-Za-z0-9_.\/:@+,=-]*$/;
|
|
127
|
+
function quoteState(before) {
|
|
128
|
+
let q = '';
|
|
129
|
+
for (let i = 0; i < before.length; i++) {
|
|
130
|
+
const c = before[i];
|
|
131
|
+
if (q === "'") { if (c === "'") q = ''; continue; }
|
|
132
|
+
if (c === '\\') { i++; continue; }
|
|
133
|
+
if (q === '"') { if (c === '"') q = ''; continue; }
|
|
134
|
+
if (c === '"' || c === "'") q = c;
|
|
135
|
+
}
|
|
136
|
+
return q;
|
|
137
|
+
}
|
|
138
|
+
export function interpolateRunScript(text, ctx, env, shell) {
|
|
139
|
+
if (typeof text !== 'string' || !text.includes('${{')) return text;
|
|
140
|
+
const posix = !shell || /^\s*(bash|sh)(\s|$)/.test(String(shell));
|
|
141
|
+
let n = 0;
|
|
142
|
+
return text.replace(/\$\{\{([\s\S]*?)\}\}/g, (m, e, off, whole) => {
|
|
143
|
+
const v = str(evalExpr(e, ctx));
|
|
144
|
+
const tainted = /(^|[^A-Za-z0-9_.])(github|env)\s*(\.|\[)/.test(e);
|
|
145
|
+
if (!tainted && INERT.test(v)) return v;
|
|
146
|
+
if (!posix) throw new Unsupported('expression with a non-inert value in a non-POSIX shell script');
|
|
147
|
+
const name = `FORGE_CI_EXPR_${++n}`; env[name] = v;
|
|
148
|
+
const q = quoteState(whole.slice(0, off));
|
|
149
|
+
return q === '"' ? `\${${name}}` : q === "'" ? `'"\${${name}}"'` : `"\${${name}}"`;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function versionNote(action, step, ctx) {
|
|
154
|
+
const spec = SETUP[action]; if (!spec) return '';
|
|
155
|
+
const want = interpolate(String((step.with || {})[spec.key] ?? ''), ctx);
|
|
156
|
+
if (!want || /lts|latest|stable|nightly|[<>~^*]|file/i.test(want) || /^\W*$/.test(want)) return '';
|
|
157
|
+
const w = majorOf(want); if (w === null) return '';
|
|
158
|
+
const l = majorOf(spec.local() || '');
|
|
159
|
+
return l !== null && l !== w ? `${action} wants ${spec.key} ${want}; local is ${String(spec.local()).slice(0, 40)} (major differs)` : '';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function shellcheckStep(action, step, ctx, a) {
|
|
163
|
+
const w = step.with || {};
|
|
164
|
+
const isLudeeus = action.startsWith('ludeeus');
|
|
165
|
+
const dir = String(interpolate(String(isLudeeus ? (w.scandir || '.') : (w.path || '.')), ctx)).replace(/^\.\//, '') || '.';
|
|
166
|
+
const ign = String(w.ignore_paths || w.exclude || '').split(/\s+/).filter(Boolean).map((p) => p.replace(/^\.\//, '').replace(/\/$/, ''));
|
|
167
|
+
const ignNames = String(w.ignore_names || '').split(/\s+/).filter(Boolean);
|
|
168
|
+
const extra = String(w.additional_files || '').split(/\s+/).filter(Boolean);
|
|
169
|
+
const sev = isLudeeus ? w.severity : null;
|
|
170
|
+
const files = a.tracked.filter((f) => /\.(sh|bash|ksh)$/.test(f) && (dir === '.' || f === dir || f.startsWith(dir.replace(/\/$/, '') + '/')) && !ign.some((p) => f === p || f.startsWith(p + '/')) && !ignNames.includes(path.posix.basename(f)));
|
|
171
|
+
for (const f of extra) if (a.tracked.includes(f) && !files.includes(f)) files.push(f);
|
|
172
|
+
const flags = [];
|
|
173
|
+
if (sev) flags.push('-S', String(sev));
|
|
174
|
+
if (!isLudeeus && w.shellcheck_flags !== '') flags.push(...String(w.shellcheck_flags ?? '--external-sources').split(/\s+/).filter(Boolean));
|
|
175
|
+
if (isLudeeus && w.shellcheck_opts) flags.push(...String(w.shellcheck_opts).split(/\s+/).filter(Boolean));
|
|
176
|
+
if (!commandExists('shellcheck')) return { missing: 'shellcheck' };
|
|
177
|
+
if (!files.length) return { noop: true, note: `no shell files under ${dir}` };
|
|
178
|
+
return { command: ['shellcheck', ...flags, ...files.sort()].map((t) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(t) ? t : `'${t.replace(/'/g, "'\\''")}'`)).join(' ') };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// dorny/paths-filter: evaluate each filter against the changed files; returns { name: 'true'|'false', changes: json }.
|
|
182
|
+
function pathsFilterOutputs(step, ctx, a) {
|
|
183
|
+
const w = step.with || {};
|
|
184
|
+
let text = w.filters;
|
|
185
|
+
if (typeof text !== 'string') throw new Unsupported('paths-filter without inline filters');
|
|
186
|
+
text = interpolate(text, ctx);
|
|
187
|
+
if (!/\n/.test(text) && /\.ya?ml$/.test(text.trim())) {
|
|
188
|
+
const f = text.trim().replace(/^\.\//, '');
|
|
189
|
+
const body = a.readDefs(f); const t = body.head != null ? body.head : body.base;
|
|
190
|
+
if (t == null) throw new Unsupported('paths-filter file ' + f);
|
|
191
|
+
text = t;
|
|
192
|
+
}
|
|
193
|
+
const doc = parseYaml(text);
|
|
194
|
+
if (!doc || typeof doc !== 'object') throw new Unsupported('paths-filter filters unreadable');
|
|
195
|
+
const out = {}; const hit = [];
|
|
196
|
+
const flat = (v) => (typeof v === 'string' ? [v] : Array.isArray(v) ? v.flatMap(flat) : v && typeof v === 'object' ? Object.values(v).flatMap(flat) : []);
|
|
197
|
+
for (const [name, spec] of Object.entries(doc)) {
|
|
198
|
+
const pats = flat(spec);
|
|
199
|
+
const ok = a.changed.some((f) => matchesFilter(f, pats));
|
|
200
|
+
out[name] = ok ? 'true' : 'false'; if (ok) hit.push(name);
|
|
201
|
+
}
|
|
202
|
+
out.changes = JSON.stringify(hit);
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseServices(svcs, ctx) {
|
|
207
|
+
const out = [];
|
|
208
|
+
for (const [name, s] of Object.entries(svcs || {})) {
|
|
209
|
+
if (!s || typeof s.image !== 'string') throw new Unsupported('service without image');
|
|
210
|
+
if (s.credentials) throw new Unsupported('service credentials');
|
|
211
|
+
const env = {}; for (const [k, v] of Object.entries(s.env || {})) env[k] = String(interpolate(String(v), ctx));
|
|
212
|
+
out.push({ name, image: String(interpolate(s.image, ctx)), env, ports: (s.ports || []).map(String), options: s.options ? String(interpolate(String(s.options), ctx)) : '' });
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function plan(a) {
|
|
218
|
+
const jobsOut = []; const warnings = []; const used = [];
|
|
219
|
+
const files = a.defFiles(DEF_RE);
|
|
220
|
+
for (const file of files) {
|
|
221
|
+
const { base, head } = a.readDefs(file);
|
|
222
|
+
const doc = mergeDocs(base, head);
|
|
223
|
+
const wfName = path.posix.basename(file).replace(/\.ya?ml$/, '');
|
|
224
|
+
if (doc === undefined) { warnings.push(`${file}: unreadable YAML; its jobs are deferred-to-ci`); jobsOut.push(deferred(jobId('gha', wfName), 'github-actions', file, 'workflow YAML unreadable')); continue; }
|
|
225
|
+
if (!doc || typeof doc !== 'object' || !doc.jobs) continue;
|
|
226
|
+
used.push(file);
|
|
227
|
+
const ev = events(doc.on !== undefined ? doc.on : doc.true);
|
|
228
|
+
if (ev.pull_request_target) { jobsOut.push(deferred(jobId('gha', wfName), 'github-actions', doc.name || file, 'pull_request_target workflow runs with base-branch secrets')); continue; }
|
|
229
|
+
const why = gateReason(ev, a);
|
|
230
|
+
if (why) { jobsOut.push(notApplicable(jobId('gha', wfName), 'github-actions', doc.name || file, why)); continue; }
|
|
231
|
+
planWorkflow(file, wfName, doc, a, jobsOut, warnings);
|
|
232
|
+
}
|
|
233
|
+
return { jobs: jobsOut, warnings, used };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function planWorkflow(file, wfName, doc, a, out, warnings) {
|
|
237
|
+
const wfEnv = doc.env || {};
|
|
238
|
+
const base = { github: { event_name: 'pull_request', base_ref: a.baseBranch, head_ref: a.headBranch || '', ref: `refs/heads/${a.headBranch || ''}`, ref_name: a.headBranch || '', ref_type: 'branch', sha: a.headSha, repository: a.repository || '', workspace: a.repo, event: { pull_request: { base: { sha: a.baseSha, ref: a.baseBranch, repo: { full_name: a.repository || '' } }, head: { sha: a.headSha, ref: a.headBranch || '', repo: { fork: false, full_name: a.repository || '' } }, draft: false, labels: [] } } }, runner: { os: { linux: 'Linux', darwin: 'macOS', win32: 'Windows' }[hostClass()], arch: process.arch === 'arm64' ? 'ARM64' : 'X64', temp: process.env.TMPDIR || '/tmp' } };
|
|
239
|
+
const jobs = doc.jobs; const results = {}; // jobKey -> { status, outputs }
|
|
240
|
+
const planOne = (key) => {
|
|
241
|
+
const job = jobs[key] || {};
|
|
242
|
+
const jid = jobId('gha', wfName, key);
|
|
243
|
+
let name = key;
|
|
244
|
+
const needs = [].concat(job.needs || []).filter((n) => typeof n === 'string');
|
|
245
|
+
const baseCtx = { ...base, env: {}, needs: {}, steps: undefined };
|
|
246
|
+
for (const n of needs) if (results[n] && results[n].outputs) baseCtx.needs[n] = { outputs: results[n].outputs, result: results[n].status === 'applicable' ? 'success' : 'skipped' };
|
|
247
|
+
try { if (typeof job.name === 'string') name = String(interpolate(job.name, { ...baseCtx, matrix: undefined })) || key; } catch { name = job.name; }
|
|
248
|
+
const def = (reason, extra) => { out.push(deferred(jid, 'github-actions', name, reason, { needs: needs.map((n) => jobId('gha', wfName, n)), ...extra })); results[key] = { status: 'deferred', outputs: null }; };
|
|
249
|
+
const na = (reason) => { out.push(notApplicable(jid, 'github-actions', name, reason)); results[key] = { status: 'skipped', outputs: {} }; };
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
// needs: a deferred or missing dependency means the job cannot be proven here
|
|
253
|
+
const badNeed = needs.find((n) => !results[n] || results[n].status === 'deferred');
|
|
254
|
+
const skipNeed = needs.find((n) => results[n] && results[n].status === 'skipped');
|
|
255
|
+
if (badNeed && !(typeof job.if === 'string' && /always\(\)/.test(job.if))) return def(`needs ${badNeed}, which is not run locally`);
|
|
256
|
+
if (skipNeed && !(typeof job.if === 'string' && /always\(\)/.test(job.if))) return na(`needs ${skipNeed}, which was skipped`);
|
|
257
|
+
if (job.uses) return def('reusable workflow job');
|
|
258
|
+
if (job.container) return def('job runs in a container');
|
|
259
|
+
if (job.environment) return def('environment-protected job (deploy)');
|
|
260
|
+
if (job.if !== undefined && !condTrue(job.if, { ...baseCtx, env: wfEnv })) return na(`job if: ${String(job.if).trim().slice(0, 80)} is false`);
|
|
261
|
+
if (DEPLOY_NAME.test(key) || DEPLOY_NAME.test(String(job.name || ''))) return def('deploy/publish job');
|
|
262
|
+
const scanJob = JSON.stringify({ ...job, steps: (job.steps || []).map((s) => (s && s.uses && (NOOP_ACTIONS.test(s.uses) || SETUP[String(s.uses).split('@')[0]]) ? { ...s, with: undefined } : s)) });
|
|
263
|
+
if (SECRET_REF.test(scanJob) || SECRET_REF.test(JSON.stringify(wfEnv))) return def('references secrets');
|
|
264
|
+
if (job.permissions && job.permissions['id-token'] === 'write') return def('requests an OIDC token');
|
|
265
|
+
|
|
266
|
+
const matrixCtx = { ...baseCtx, env: wfEnv };
|
|
267
|
+
const combos = expandMatrix(job.strategy && job.strategy.matrix, matrixCtx);
|
|
268
|
+
const variants = []; let chosen = null;
|
|
269
|
+
for (const combo of combos) {
|
|
270
|
+
const cctx = { ...baseCtx, matrix: combo, env: wfEnv };
|
|
271
|
+
let ro = job['runs-on']; if (typeof ro === 'string') ro = interpolate(ro, cctx); else if (Array.isArray(ro)) ro = ro.map((x) => interpolate(x, cctx));
|
|
272
|
+
const c = osClass(ro);
|
|
273
|
+
const tag = Object.keys(combo).length ? Object.entries(combo).map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : v}`).join(',') : '';
|
|
274
|
+
if (c.reason) { variants.push(`${tag || 'default'}: ${c.reason}`); continue; }
|
|
275
|
+
if (!hostRuns(c.os)) { variants.push(`${tag || c.label}: ${c.label} (host is ${hostClass()})`); continue; }
|
|
276
|
+
if (chosen) { variants.push(`${tag || c.label}: matrix variant not run locally`); continue; }
|
|
277
|
+
chosen = { combo, ctx: cctx, tag };
|
|
278
|
+
}
|
|
279
|
+
if (!chosen && combos.length === 1) return def(variants[0].replace(/^[^:]*: /, ''));
|
|
280
|
+
if (variants.length) out.push(deferred(jobId(jid, 'variants'), 'github-actions', name, `${variants.length} variant(s) not run on this host: ${variants.slice(0, 3).join('; ')}${variants.length > 3 ? '; ...' : ''}`, { variants: variants.length }));
|
|
281
|
+
if (!chosen) { results[key] = { status: 'deferred', outputs: null }; return; }
|
|
282
|
+
const built = buildJob(file, wfName, key, jid, name, job, doc, chosen, needs, wfEnv, a, warnings);
|
|
283
|
+
if (built.defer) return def(built.defer);
|
|
284
|
+
results[key] = { status: 'applicable', outputs: built.outputs };
|
|
285
|
+
out.push(built.job);
|
|
286
|
+
} catch (e) {
|
|
287
|
+
if (e instanceof Unsupported) return def(`unsupported construct: ${e.message}`);
|
|
288
|
+
throw e;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
for (const key of topo(jobs)) planOne(key);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function buildJob(file, wfName, key, jid, name, job, doc, chosen, needs, wfEnv, a, warnings) {
|
|
295
|
+
const ctx = chosen.ctx;
|
|
296
|
+
const jobEnvRaw = { ...wfEnv, ...(job.env || {}) };
|
|
297
|
+
const env = {};
|
|
298
|
+
for (const [k, v] of Object.entries(jobEnvRaw)) env[k] = String(interpolate(String(v), ctx));
|
|
299
|
+
ctx.env = env;
|
|
300
|
+
const defaults = { ...(doc.defaults && doc.defaults.run), ...(job.defaults && job.defaults.run) };
|
|
301
|
+
const steps = []; const notes = []; const stepsCtx = {}; ctx.steps = stepsCtx;
|
|
302
|
+
const toolWarn = [];
|
|
303
|
+
let outputs = {};
|
|
304
|
+
for (const [i, st] of (job.steps || []).entries()) {
|
|
305
|
+
if (!st) continue;
|
|
306
|
+
if (st.if !== undefined && !condTrue(st.if, ctx)) continue;
|
|
307
|
+
const label = st.name ? String(interpolate(String(st.name), ctx)) : (st.uses ? String(st.uses) : `step ${i + 1}`);
|
|
308
|
+
const senv = {}; for (const [k, v] of Object.entries(st.env || {})) senv[k] = String(interpolate(String(v), ctx));
|
|
309
|
+
if (st.uses) {
|
|
310
|
+
const action = String(st.uses).split('@')[0];
|
|
311
|
+
if (NOOP_ACTIONS.test(String(st.uses))) continue;
|
|
312
|
+
if (SETUP[action]) {
|
|
313
|
+
if (!commandExists(SETUP[action].tool)) return { defer: `toolchain not installed locally: ${SETUP[action].tool} (${action})` };
|
|
314
|
+
const n = versionNote(action, st, ctx); if (n) toolWarn.push(n);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (/^dorny\/paths-filter$/.test(action)) { stepsCtx[st.id || `step${i}`] = { outputs: pathsFilterOutputs(st, ctx, a) }; continue; }
|
|
318
|
+
if (SHELLCHECK_ACTIONS.test(String(st.uses))) {
|
|
319
|
+
const sc = shellcheckStep(action, st, ctx, a);
|
|
320
|
+
if (sc.missing) return { defer: `tool not installed: ${sc.missing}` };
|
|
321
|
+
if (sc.noop) continue;
|
|
322
|
+
steps.push({ name: label, command: sc.command, cwd: '.', env: { ...senv }, shell: 'bash', kind: 'check' });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
return { defer: `unknown action ${st.uses}` };
|
|
326
|
+
}
|
|
327
|
+
if (typeof st.run === 'string') {
|
|
328
|
+
const script = interpolateRunScript(st.run, { ...ctx, env: { ...ctx.env, ...senv } }, senv, st.shell || defaults.shell);
|
|
329
|
+
const cwd = st['working-directory'] || defaults['working-directory'] || '.';
|
|
330
|
+
const shell = st.shell || defaults.shell;
|
|
331
|
+
if (!shellArgv(shell)) return { defer: `shell ${shell} is not available locally` };
|
|
332
|
+
const r = stepsFromScript(script, { name: label, cwd: String(interpolate(String(cwd), ctx)), env: senv, shell, repo: a.repo, continue_on_error: st['continue-on-error'] === true, timeout_s: st['timeout-minutes'] ? Number(st['timeout-minutes']) * 60 : undefined });
|
|
333
|
+
if (r.defer) return { defer: r.defer };
|
|
334
|
+
steps.push(...r.steps);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
return { defer: `unsupported step shape at index ${i}` };
|
|
338
|
+
}
|
|
339
|
+
// job outputs resolved from dorny steps (the `changes` job pattern)
|
|
340
|
+
for (const [k, v] of Object.entries(job.outputs || {})) { try { outputs[k] = String(interpolate(String(v), ctx)); } catch { /* left unresolved: needs.X.outputs.k is Unsupported downstream */ } }
|
|
341
|
+
if (!steps.length && !Object.keys(outputs).length && !(job.steps || []).length) return { defer: 'job has no steps' };
|
|
342
|
+
let services = [];
|
|
343
|
+
if (job.services) services = parseServices(job.services, ctx);
|
|
344
|
+
warnings.push(...toolWarn.map((w) => `${jid}: ${w}`));
|
|
345
|
+
const timeout_s = job['timeout-minutes'] ? Number(job['timeout-minutes']) * 60 : undefined;
|
|
346
|
+
const j = { id: jid, source: 'github-actions', name, workflow: file, cwd: '.', env, services, steps, needs: needs.map((n) => jobId('gha', wfName, n)), timeout_s, matrix: chosen.tag || undefined, deferred_reason: null, warnings: toolWarn, version_drift: toolWarn.length ? toolWarn : undefined, resolves_outputs: Object.keys(outputs).length > 0 && !steps.length };
|
|
347
|
+
if (!steps.length) { j.not_applicable = Object.keys(outputs).length ? 'resolved locally (path-filter outputs only)' : 'no runnable steps'; }
|
|
348
|
+
return { job: j, outputs };
|
|
349
|
+
}
|