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,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
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// gitlab-ci.mjs - .gitlab-ci.yml (+ local includes) -> normalised jobs. Merge-request pipeline semantics:
|
|
5
|
+
// rules/only/except with changes: path filters, stages and needs ordering, extends, variables. Services, triggers,
|
|
6
|
+
// environments, manual jobs and credential-like variables are deferred-to-ci.
|
|
7
|
+
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { Unsupported, matchesFilter, branchMatches, jobId, deferred, notApplicable, parseDuration, secretLikeVar, DEPLOY_NAME } from '../lib.mjs';
|
|
10
|
+
import { stepsFromScript, loadDoc, parseSafe, deepMerge, baseJob, globList } from './common.mjs';
|
|
11
|
+
|
|
12
|
+
export const id = 'gitlab-ci';
|
|
13
|
+
export const DEF_RE = /^\.gitlab-ci\.ya?ml$/;
|
|
14
|
+
const RESERVED = new Set(['stages', 'variables', 'default', 'include', 'workflow', 'image', 'services', 'before_script', 'after_script', 'cache', 'artifacts', 'pages', 'spec', 'inputs']);
|
|
15
|
+
|
|
16
|
+
// --- minimal GitLab rules:if evaluator ($VAR, strings, ==, !=, =~, !~, &&, ||, !, parens, null) ---
|
|
17
|
+
function evalRule(src, vars) {
|
|
18
|
+
const toks = []; let i = 0;
|
|
19
|
+
while (i < src.length) {
|
|
20
|
+
const c = src[i];
|
|
21
|
+
if (/\s/.test(c)) { i++; continue; }
|
|
22
|
+
if (c === '$') { const m = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/.exec(src.slice(i)); if (!m) throw new Unsupported('rule syntax'); toks.push({ t: 'var', v: m[1] }); i += m[0].length; continue; }
|
|
23
|
+
if (c === '"' || c === "'") { const j = src.indexOf(c, i + 1); if (j < 0) throw new Unsupported('rule string'); toks.push({ t: 'str', v: src.slice(i + 1, j) }); i = j + 1; continue; }
|
|
24
|
+
if (c === '/') { const m = /^\/((?:\\.|[^/\\])*)\/([a-z]*)/.exec(src.slice(i)); if (!m) throw new Unsupported('rule regex'); toks.push({ t: 're', v: new RegExp(m[1], m[2].replace(/[^imsu]/g, '')) }); i += m[0].length; continue; }
|
|
25
|
+
const two = src.slice(i, i + 2);
|
|
26
|
+
if (['==', '!=', '=~', '!~', '&&', '||'].includes(two)) { toks.push({ t: 'op', v: two }); i += 2; continue; }
|
|
27
|
+
if ('()!'.includes(c)) { toks.push({ t: 'op', v: c }); i++; continue; }
|
|
28
|
+
if (/^null\b/.test(src.slice(i))) { toks.push({ t: 'null' }); i += 4; continue; }
|
|
29
|
+
throw new Unsupported('rule token ' + c);
|
|
30
|
+
}
|
|
31
|
+
let p = 0;
|
|
32
|
+
const val = (t) => (t.t === 'var' ? (t.v in vars ? vars[t.v] : (() => { throw new Unsupported('variable $' + t.v); })()) : t.t === 'null' ? null : t.t === 'str' ? t.v : t.t === 're' ? t.v : (() => { throw new Unsupported('rule operand'); })());
|
|
33
|
+
function prim() {
|
|
34
|
+
const t = toks[p++]; if (!t) throw new Unsupported('rule end');
|
|
35
|
+
if (t.t === 'op' && t.v === '(') { const v = or(); if (!(toks[p] && toks[p].v === ')')) throw new Unsupported('rule )'); p++; return v; }
|
|
36
|
+
if (t.t === 'op' && t.v === '!') return !prim();
|
|
37
|
+
const l = val(t); const n = toks[p];
|
|
38
|
+
if (n && n.t === 'op' && ['==', '!=', '=~', '!~'].includes(n.v)) {
|
|
39
|
+
p++; const r = val(toks[p++]);
|
|
40
|
+
if (n.v === '==') return (l === null ? null : l) === (r === null ? null : r);
|
|
41
|
+
if (n.v === '!=') return l !== r;
|
|
42
|
+
const hit = l !== null && r instanceof RegExp && r.test(String(l)); return n.v === '=~' ? hit : !hit;
|
|
43
|
+
}
|
|
44
|
+
return l !== null && l !== '';
|
|
45
|
+
}
|
|
46
|
+
function and() { let l = prim(); while (toks[p] && toks[p].v === '&&') { p++; const r = prim(); l = l && r; } return l; }
|
|
47
|
+
function or() { let l = and(); while (toks[p] && toks[p].v === '||') { p++; const r = and(); l = l || r; } return l; }
|
|
48
|
+
const v = or(); if (p < toks.length) throw new Unsupported('rule trailing'); return !!v;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function refMatches(ref, a) {
|
|
52
|
+
if (typeof ref !== 'string') return false;
|
|
53
|
+
if (ref === 'merge_requests' || ref === 'branches' || ref === 'pushes' || ref === 'external_pull_requests') return true;
|
|
54
|
+
if (['tags', 'schedules', 'api', 'triggers', 'web', 'pipelines', 'chat'].includes(ref)) return false;
|
|
55
|
+
const m = /^\/(.*)\/([a-z]*)$/.exec(ref);
|
|
56
|
+
return m ? new RegExp(m[1], m[2]).test(a.headBranch || '') : branchMatches(a.headBranch || '', [ref]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveExtends(name, jobs, depth = 0) {
|
|
60
|
+
const j = jobs[name]; if (!j || typeof j !== 'object') throw new Unsupported(`extends target ${name}`);
|
|
61
|
+
if (depth > 6) throw new Unsupported('extends depth');
|
|
62
|
+
let merged = {};
|
|
63
|
+
for (const e of [].concat(j.extends || [])) merged = deepMerge(merged, resolveExtends(e, jobs, depth + 1));
|
|
64
|
+
const own = { ...j }; delete own.extends;
|
|
65
|
+
return deepMerge(merged, own);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function loadWithIncludes(a, file, seen = new Set(), depth = 0) {
|
|
69
|
+
const doc = loadDoc(a, file, parseSafe);
|
|
70
|
+
if (doc === undefined) return undefined;
|
|
71
|
+
if (!doc || typeof doc !== 'object') return doc;
|
|
72
|
+
let merged = {};
|
|
73
|
+
const warn = [];
|
|
74
|
+
for (const inc of [].concat(doc.include || [])) {
|
|
75
|
+
const loc = typeof inc === 'string' ? inc : inc && inc.local;
|
|
76
|
+
if (!loc || /^https?:/.test(loc)) { warn.push(`include not resolved: ${typeof inc === 'string' ? inc : JSON.stringify(inc).slice(0, 60)}`); continue; }
|
|
77
|
+
const f = loc.replace(/^\//, '');
|
|
78
|
+
if (seen.has(f) || depth > 3) continue; seen.add(f);
|
|
79
|
+
const sub = loadWithIncludes(a, f, seen, depth + 1);
|
|
80
|
+
if (sub && typeof sub === 'object') { warn.push(...(sub.__warnings || [])); delete sub.__warnings; merged = deepMerge(merged, sub); }
|
|
81
|
+
}
|
|
82
|
+
const out = deepMerge(merged, doc); delete out.include; out.__warnings = warn; return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function plan(a) {
|
|
86
|
+
const out = []; const warnings = []; const used = [];
|
|
87
|
+
for (const file of a.defFiles(DEF_RE)) {
|
|
88
|
+
const doc = loadWithIncludes(a, file);
|
|
89
|
+
if (doc === undefined) { out.push(deferred(jobId('gitlab', 'pipeline'), 'gitlab-ci', file, 'pipeline YAML unreadable')); continue; }
|
|
90
|
+
if (!doc || typeof doc !== 'object') continue;
|
|
91
|
+
used.push(file);
|
|
92
|
+
for (const w of doc.__warnings || []) warnings.push(`${file}: ${w} (jobs from it are not in the plan)`);
|
|
93
|
+
const stages = Array.isArray(doc.stages) ? doc.stages : ['.pre', 'build', 'test', 'deploy', '.post'];
|
|
94
|
+
const gvars = vars(doc.variables); const defaults = doc.default || {};
|
|
95
|
+
const defined = new Set([...Object.keys(gvars)]);
|
|
96
|
+
const names = Object.keys(doc).filter((k) => !RESERVED.has(k) && !k.startsWith('.') && !k.startsWith('__') && doc[k] && typeof doc[k] === 'object' && !Array.isArray(doc[k]));
|
|
97
|
+
const results = {}; const byStage = {};
|
|
98
|
+
for (const name of names) {
|
|
99
|
+
const jid = jobId('gitlab', name);
|
|
100
|
+
let job;
|
|
101
|
+
try { job = doc[name].extends ? resolveExtends(name, doc) : doc[name]; } catch (e) { if (e instanceof Unsupported) { out.push(deferred(jid, 'gitlab-ci', name, e.message)); results[name] = 'deferred'; continue; } throw e; }
|
|
102
|
+
if (!job.script && !job.trigger && !job.run) continue;
|
|
103
|
+
const stage = job.stage || 'test';
|
|
104
|
+
const env = { ...gvars, ...vars(job.variables) };
|
|
105
|
+
const ctxVars = { CI_PIPELINE_SOURCE: 'merge_request_event', CI_MERGE_REQUEST_TARGET_BRANCH_NAME: a.baseBranch, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: a.headBranch || '', CI_COMMIT_BRANCH: null, CI_COMMIT_TAG: null, CI_COMMIT_REF_NAME: a.headBranch || '', CI_COMMIT_SHA: a.headSha, ...env };
|
|
106
|
+
const def = (r) => { out.push(deferred(jid, 'gitlab-ci', name, r)); results[name] = 'deferred'; };
|
|
107
|
+
const na = (r) => { out.push(notApplicable(jid, 'gitlab-ci', name, r)); results[name] = 'skipped'; };
|
|
108
|
+
try {
|
|
109
|
+
const gate = gating(job, ctxVars, a);
|
|
110
|
+
if (gate.na) { na(gate.na); continue; }
|
|
111
|
+
if (gate.defer) { def(gate.defer); continue; }
|
|
112
|
+
if (job.trigger) { def('trigger (child/multi-project pipeline)'); continue; }
|
|
113
|
+
if (job.environment) { def('deploy environment'); continue; }
|
|
114
|
+
if (job.secrets || job.id_tokens) { def('uses CI secrets / OIDC tokens'); continue; }
|
|
115
|
+
if (DEPLOY_NAME.test(name) || stage === 'deploy') { def('deploy/publish job'); continue; }
|
|
116
|
+
const services = job.services || defaults.services;
|
|
117
|
+
if (services && services.length) { def('service containers need docker network aliases (not reproduced on the host)'); continue; }
|
|
118
|
+
const lines = [].concat(defaults.before_script || [], job.before_script || [], job.script || []);
|
|
119
|
+
if (lines.some((l) => typeof l !== 'string')) { def('script uses !reference or nested lists'); continue; }
|
|
120
|
+
const sv = secretLikeVar(lines.join('\n'), defined) || secretLikeVar(JSON.stringify(env), defined);
|
|
121
|
+
if (/CI_JOB_TOKEN|CI_REGISTRY_PASSWORD/.test(lines.join('\n'))) { def('uses CI_JOB_TOKEN / registry credentials'); continue; }
|
|
122
|
+
if (sv) { def(`references credential-like variable $${sv}`); continue; }
|
|
123
|
+
const warn = []; const image = job.image || defaults.image;
|
|
124
|
+
if (image) warn.push(`image ${typeof image === 'string' ? image : image.name} ignored; running with the host toolchain`);
|
|
125
|
+
let matrix; let variants = 0;
|
|
126
|
+
if (job.parallel && typeof job.parallel === 'object' && job.parallel.matrix) {
|
|
127
|
+
const combos = job.parallel.matrix.flatMap((m) => Object.entries(m).reduce((acc, [k, v]) => acc.flatMap((c) => [].concat(v).map((x) => ({ ...c, [k]: String(x) }))), [{}]));
|
|
128
|
+
Object.assign(env, combos[0] || {}); matrix = Object.entries(combos[0] || {}).map(([k, v]) => `${k}=${v}`).join(','); variants = combos.length - 1;
|
|
129
|
+
}
|
|
130
|
+
Object.assign(env, { CI_PROJECT_DIR: a.repo, CI_COMMIT_SHA: a.headSha, CI_COMMIT_REF_NAME: a.headBranch || '', CI_MERGE_REQUEST_TARGET_BRANCH_NAME: a.baseBranch, CI_PIPELINE_SOURCE: 'merge_request_event', CI_JOB_NAME: name });
|
|
131
|
+
const r = stepsFromScript(lines.join('\n'), { name, cwd: '.', env: {}, repo: a.repo, continue_on_error: job.allow_failure === true, timeout_s: parseDuration(job.timeout) });
|
|
132
|
+
if (r.defer) { def(r.defer); continue; }
|
|
133
|
+
const needs = job.needs !== undefined ? [].concat(job.needs).map((n) => (typeof n === 'string' ? n : n && n.job)).filter(Boolean).map((n) => jobId('gitlab', n)) : null;
|
|
134
|
+
const j = baseJob({ id: jid, source: 'gitlab-ci', name, env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, String(v)])), steps: r.steps, warnings: warn, matrix, needs: needs || [], timeout_s: parseDuration(job.timeout) });
|
|
135
|
+
j._stage = stage; j._explicitNeeds = needs !== null;
|
|
136
|
+
(byStage[stage] = byStage[stage] || []).push(j);
|
|
137
|
+
results[name] = 'applicable'; out.push(j);
|
|
138
|
+
if (variants) out.push(deferred(jobId(jid, 'variants'), 'gitlab-ci', name, `${variants} parallel:matrix variant(s) not run locally`));
|
|
139
|
+
} catch (e) { if (e instanceof Unsupported) { def(`unsupported construct: ${e.message}`); continue; } throw e; }
|
|
140
|
+
}
|
|
141
|
+
// stage ordering: a job without explicit needs waits for every runnable job of earlier stages
|
|
142
|
+
for (const jobs of Object.values(byStage)) for (const j of jobs) {
|
|
143
|
+
if (j._explicitNeeds) { delete j._stage; delete j._explicitNeeds; continue; }
|
|
144
|
+
const idx = stages.indexOf(j._stage);
|
|
145
|
+
j.needs = Object.entries(byStage).filter(([s]) => stages.indexOf(s) < idx && stages.indexOf(s) >= 0).flatMap(([, js]) => js.map((x) => x.id));
|
|
146
|
+
delete j._stage; delete j._explicitNeeds;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { jobs: out, warnings, used };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function vars(v) { const o = {}; for (const [k, x] of Object.entries(v || {})) o[k] = x && typeof x === 'object' ? String(x.value ?? '') : String(x ?? ''); return o; }
|
|
153
|
+
|
|
154
|
+
function changesOk(list, a) { const pats = globList(list && list.paths ? list.paths : list); return !a.changed.length || a.changed.some((f) => matchesFilter(f, pats)); }
|
|
155
|
+
|
|
156
|
+
// -> { na } | { defer } | {} (job is selected)
|
|
157
|
+
function gating(job, v, a) {
|
|
158
|
+
if (job.when === 'manual' || job.when === 'delayed') return { defer: `when: ${job.when}` };
|
|
159
|
+
if (job.when === 'never') return { na: 'when: never' };
|
|
160
|
+
if (Array.isArray(job.rules)) {
|
|
161
|
+
for (const r of job.rules) {
|
|
162
|
+
if (r.if !== undefined && !evalRule(String(r.if), v)) continue;
|
|
163
|
+
if (r.changes !== undefined && !changesOk(r.changes, a)) continue;
|
|
164
|
+
if (r.exists !== undefined) throw new Unsupported('rules:exists');
|
|
165
|
+
if (r.when === 'never') return { na: 'matched a when: never rule' };
|
|
166
|
+
if (r.when === 'manual' || r.when === 'delayed') return { defer: `rule when: ${r.when}` };
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
return { na: 'no rule selects this merge-request pipeline' };
|
|
170
|
+
}
|
|
171
|
+
if (job.only !== undefined) {
|
|
172
|
+
const o = Array.isArray(job.only) ? { refs: job.only } : job.only;
|
|
173
|
+
const refs = globList(o.refs || (Array.isArray(job.only) ? job.only : []));
|
|
174
|
+
if (refs.length && !refs.some((x) => refMatches(x, a))) return { na: 'only: refs do not select this pipeline' };
|
|
175
|
+
if (o.changes && !changesOk(o.changes, a)) return { na: 'only: changes selects none of the changed files' };
|
|
176
|
+
}
|
|
177
|
+
if (job.except !== undefined) {
|
|
178
|
+
const o = Array.isArray(job.except) ? { refs: job.except } : job.except;
|
|
179
|
+
const refs = globList(o.refs || (Array.isArray(job.except) ? job.except : []));
|
|
180
|
+
if (refs.some((x) => refMatches(x, a))) return { na: 'except: refs exclude this pipeline' };
|
|
181
|
+
if (o.changes && changesOk(o.changes, a)) return { na: 'except: changes excludes this pipeline' };
|
|
182
|
+
}
|
|
183
|
+
return {};
|
|
184
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// portability.mjs - deterministic cross-platform checks that always run, because macOS/Windows CI jobs are deferred:
|
|
5
|
+
// Windows-invalid filename characters and reserved names, case-insensitive path collisions, CRLF in shell scripts,
|
|
6
|
+
// bash 3.2 constructs and GNU-only tool flags (added lines only), plus the repo's own check-shell-portability.sh
|
|
7
|
+
// when present. Findings are `PORTABILITY | <rule> | <file>[:line] | <detail>` lines.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { git, jobId } from '../lib.mjs';
|
|
12
|
+
import { baseJob } from './common.mjs';
|
|
13
|
+
|
|
14
|
+
export const id = 'portability';
|
|
15
|
+
const WIN_BAD = /[<>:"|?*\u0000-\u001f]/;
|
|
16
|
+
const WIN_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
|
|
17
|
+
const SH_EXT = /\.(sh|bash)$/;
|
|
18
|
+
|
|
19
|
+
const BASH32 = [
|
|
20
|
+
[/\b(mapfile|readarray)\b/, 'bash-4 builtin mapfile/readarray'],
|
|
21
|
+
[/\b(declare|local|typeset)\s+-[A-Za-z]*A\b/, 'bash-4 associative array'],
|
|
22
|
+
[/\$\{[A-Za-z_][A-Za-z0-9_]*(,,|\^\^)\}|\$\{[A-Za-z_][A-Za-z0-9_]*[,^]\}/, 'bash-4 case modification expansion'],
|
|
23
|
+
[/(^|[^|])\|&/, 'bash-4 |& redirection'],
|
|
24
|
+
[/&>>/, 'bash-4 &>> redirection'],
|
|
25
|
+
[/\bcoproc\b/, 'bash-4 coproc'],
|
|
26
|
+
[/\bwait\s+-n\b/, 'bash-4.3 wait -n'],
|
|
27
|
+
[/\[\[\s+-v\s/, 'bash-4.2 [[ -v ]]'],
|
|
28
|
+
[/\b(local|declare)\s+-n\b/, 'bash-4.3 namerefs'],
|
|
29
|
+
[/\$\{[A-Za-z_][A-Za-z0-9_]*@[AEPQUuLa]\}/, 'bash-4.4/5 parameter transformation'],
|
|
30
|
+
[/\bread\s+(-[A-Za-z]*i\b)/, 'bash-4 read -i'],
|
|
31
|
+
[/\$(EPOCHSECONDS|EPOCHREALTIME|SRANDOM|BASHPID)\b/, 'bash-4/5 special variable'],
|
|
32
|
+
[/;;&|;&(\s|$)/, 'bash-4 case fallthrough'],
|
|
33
|
+
[/\bshopt\s+-s\s+(globstar|lastpipe|inherit_errexit)\b/, 'bash-4 shopt option'],
|
|
34
|
+
];
|
|
35
|
+
const GNU = [
|
|
36
|
+
[/\bgrep\s+(-[A-Za-z]*P\b|--perl-regexp)/, 'grep -P (PCRE) is GNU-only'],
|
|
37
|
+
[/\bsed\s+-i\s+(?!['"])/, 'sed -i without a backup suffix argument is GNU-only'],
|
|
38
|
+
[/\bsed\s+(-[A-Za-z]*r\b|--regexp-extended)/, 'sed -r is GNU-only (use -E)'],
|
|
39
|
+
[/\bdate\s+(-d\b|--date)/, 'date -d is GNU-only'],
|
|
40
|
+
[/\breadlink\s+-f\b/, 'readlink -f is GNU-only'],
|
|
41
|
+
[/\bstat\s+-c\b/, 'stat -c is GNU-only'],
|
|
42
|
+
[/\bxargs\s+(-r\b|--no-run-if-empty)/, 'xargs -r is GNU-only'],
|
|
43
|
+
[/\bfind\b.*\s-printf\b/, 'find -printf is GNU-only'],
|
|
44
|
+
[/\bsort\s+(-[A-Za-z]*V\b|--version-sort)/, 'sort -V is not on older BSD sort'],
|
|
45
|
+
[/\bhead\s+-n\s*-[0-9]/, 'head -n -N is GNU-only'],
|
|
46
|
+
[/\bls\s+--color\b/, 'ls --color is GNU-only'],
|
|
47
|
+
[/\bcp\s+--parents\b/, 'cp --parents is GNU-only'],
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export function plan(a) {
|
|
51
|
+
if (!a.changed.length) return { jobs: [], warnings: [], used: [] };
|
|
52
|
+
const steps = [{ name: 'portability checks (filenames, case collisions, CRLF, bash 3.2, GNU-only flags)', command: '(built-in portability checks)', cwd: '.', env: {}, kind: 'check', builtin: 'portability' }];
|
|
53
|
+
if (a.changed.some((f) => /^scripts\/.*\.sh$/.test(f)) && fs.existsSync(path.join(a.repo, 'scripts/check-shell-portability.sh')) && fs.existsSync(path.join(a.repo, 'scripts'))) {
|
|
54
|
+
steps.push({ name: 'repo check-shell-portability.sh', command: 'bash scripts/check-shell-portability.sh scripts', cwd: '.', env: {}, kind: 'check', prescoped: true });
|
|
55
|
+
}
|
|
56
|
+
return { jobs: [baseJob({ id: jobId('portability', 'builtin'), source: 'portability', name: 'portability checks', steps })], warnings: [], used: [] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function addedLines(repo, from, f) {
|
|
60
|
+
const tracked = git(repo, ['ls-files', '--error-unmatch', '--', f]).code === 0;
|
|
61
|
+
if (!tracked) { try { return fs.readFileSync(path.join(repo, f), 'utf8').split('\n').map((t, i) => ({ n: i + 1, t })); } catch { return []; } }
|
|
62
|
+
const d = git(repo, ['diff', '-U0', '--no-color', '--no-ext-diff', from, '--', f]).out;
|
|
63
|
+
const out = []; let n = 0;
|
|
64
|
+
for (const l of d.split('\n')) {
|
|
65
|
+
const h = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(l);
|
|
66
|
+
if (h) { n = Number(h[1]); continue; }
|
|
67
|
+
if (l.startsWith('+') && !l.startsWith('+++')) { out.push({ n, t: l.slice(1) }); n++; }
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Run the built-in checks; returns an array of finding strings.
|
|
73
|
+
export function checkPortability({ repo, from, changed, tracked }) {
|
|
74
|
+
const findings = [];
|
|
75
|
+
const lower = new Map();
|
|
76
|
+
for (const f of tracked) { const k = f.toLowerCase(); lower.set(k, (lower.get(k) || []).concat(f)); }
|
|
77
|
+
for (const f of changed) {
|
|
78
|
+
for (const seg of f.split('/')) {
|
|
79
|
+
if (WIN_BAD.test(seg)) { findings.push(`PORTABILITY | windows-filename | ${f} | invalid character in "${seg}"`); break; }
|
|
80
|
+
if (/[. ]$/.test(seg)) { findings.push(`PORTABILITY | windows-filename | ${f} | segment "${seg}" ends with a dot or space`); break; }
|
|
81
|
+
if (WIN_RESERVED.test(seg)) { findings.push(`PORTABILITY | windows-filename | ${f} | reserved device name "${seg}"`); break; }
|
|
82
|
+
}
|
|
83
|
+
if (f.length > 240) findings.push(`PORTABILITY | windows-path-length | ${f} | path longer than 240 characters`);
|
|
84
|
+
const same = lower.get(f.toLowerCase()) || [];
|
|
85
|
+
if (same.length > 1) findings.push(`PORTABILITY | case-collision | ${f} | collides with ${same.filter((x) => x !== f).join(', ')} on case-insensitive filesystems`);
|
|
86
|
+
const abs = path.join(repo, f);
|
|
87
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
|
|
88
|
+
let head = ''; try { head = fs.readFileSync(abs, 'utf8').slice(0, 200); } catch { continue; }
|
|
89
|
+
const isShell = SH_EXT.test(f) || /^#!.*\b(ba)?sh\b/.test(head);
|
|
90
|
+
if (!isShell && !/\.bats$/.test(f)) continue;
|
|
91
|
+
const lines = addedLines(repo, from, f);
|
|
92
|
+
for (const { n, t } of lines) {
|
|
93
|
+
if (/\r$/.test(t)) { findings.push(`PORTABILITY | crlf | ${f}:${n} | CRLF line ending in a shell script`); break; }
|
|
94
|
+
}
|
|
95
|
+
if (/\.bats$/.test(f)) continue;
|
|
96
|
+
for (const { n, t } of lines) {
|
|
97
|
+
const s = t.replace(/\r$/, '');
|
|
98
|
+
if (/^\s*#/.test(s) || /allowlist:/.test(s)) continue;
|
|
99
|
+
for (const [re, why] of BASH32) if (re.test(s)) findings.push(`PORTABILITY | bash-3.2 | ${f}:${n} | ${why}`);
|
|
100
|
+
for (const [re, why] of GNU) if (re.test(s)) findings.push(`PORTABILITY | gnu-only | ${f}:${n} | ${why}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return findings;
|
|
104
|
+
}
|