@secureci/cli 0.5.1 → 0.7.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 +74 -15
- package/dist/advisories.js +603 -0
- package/dist/cli.js +263 -38
- package/dist/config.js +23 -1
- package/dist/init.js +12 -5
- package/dist/parser/allow-comments.js +113 -0
- package/dist/parser/dangerous-triggers.js +163 -0
- package/dist/parser/injection.js +281 -0
- package/dist/parser/permissions.js +145 -0
- package/dist/parser/pipeline-contract-parser.js +70 -0
- package/dist/parser/pipeline-contract-validator.js +18 -4
- package/dist/parser/validator.js +61 -1
- package/dist/pin.js +311 -0
- package/dist/report.js +9 -2
- package/dist/runner/action-fetcher.js +100 -4
- package/dist/runner/builtin-actions.js +13 -3
- package/dist/runner/dry-run.js +57 -12
- package/dist/runner/executor.js +368 -254
- package/dist/runner/expression.js +106 -8
- package/dist/runner/job-plan.js +58 -1
- package/dist/runner/job-runtime.js +3 -3
- package/dist/runner/node-action.js +11 -3
- package/dist/runner/node-binary.js +108 -0
- package/dist/runner/scheduler.js +207 -0
- package/dist/runner/steps.js +54 -2
- package/dist/utils/env-file.js +185 -0
- package/dist/utils/logger.js +36 -7
- package/dist/version.js +56 -0
- package/package.json +3 -1
package/dist/pin.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.findUsesSites = findUsesSites;
|
|
37
|
+
exports.isPinAllowed = isPinAllowed;
|
|
38
|
+
exports.pinContent = pinContent;
|
|
39
|
+
exports.localPinTargets = localPinTargets;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const YAML = __importStar(require("yaml"));
|
|
43
|
+
const repository_1 = require("./utils/repository");
|
|
44
|
+
const FULL_SHA = /^[0-9a-f]{40}$/i;
|
|
45
|
+
/**
|
|
46
|
+
* Every `uses:` a runner reads: `jobs.<id>.uses` (reusable workflows),
|
|
47
|
+
* `jobs.<id>.steps[*].uses` and a composite action's `runs.steps[*].uses`.
|
|
48
|
+
* Read from the YAML syntax tree, so `uses:` inside a script or an input
|
|
49
|
+
* isn't mistaken for one, with source offsets for in-place edits.
|
|
50
|
+
*/
|
|
51
|
+
function findUsesSites(content) {
|
|
52
|
+
const doc = parseYaml(content);
|
|
53
|
+
const root = doc.contents;
|
|
54
|
+
if (!YAML.isMap(root))
|
|
55
|
+
return [];
|
|
56
|
+
const nodes = [];
|
|
57
|
+
const usesOf = (node) => {
|
|
58
|
+
if (!YAML.isMap(node))
|
|
59
|
+
return;
|
|
60
|
+
const uses = node.get('uses', true);
|
|
61
|
+
if (YAML.isScalar(uses))
|
|
62
|
+
nodes.push(uses);
|
|
63
|
+
};
|
|
64
|
+
const stepsOf = (node) => {
|
|
65
|
+
if (!YAML.isMap(node))
|
|
66
|
+
return;
|
|
67
|
+
const steps = node.get('steps', true);
|
|
68
|
+
if (YAML.isSeq(steps))
|
|
69
|
+
steps.items.forEach(usesOf);
|
|
70
|
+
};
|
|
71
|
+
const jobs = root.get('jobs', true);
|
|
72
|
+
if (YAML.isMap(jobs)) {
|
|
73
|
+
for (const pair of jobs.items) {
|
|
74
|
+
usesOf(pair.value);
|
|
75
|
+
stepsOf(pair.value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
stepsOf(root.get('runs', true));
|
|
79
|
+
return nodes
|
|
80
|
+
.filter((node) => node.range && typeof node.value === 'string')
|
|
81
|
+
.map((node) => {
|
|
82
|
+
const [start, end] = node.range;
|
|
83
|
+
return {
|
|
84
|
+
line: content.slice(0, start).split('\n').length,
|
|
85
|
+
uses: node.value,
|
|
86
|
+
start,
|
|
87
|
+
end,
|
|
88
|
+
style: scalarStyle(node.type)
|
|
89
|
+
};
|
|
90
|
+
})
|
|
91
|
+
.sort((a, b) => a.start - b.start);
|
|
92
|
+
}
|
|
93
|
+
function parseYaml(content) {
|
|
94
|
+
const doc = YAML.parseDocument(content);
|
|
95
|
+
if (doc.errors.length > 0) {
|
|
96
|
+
throw new Error(`File is not valid YAML: ${doc.errors[0].message}`);
|
|
97
|
+
}
|
|
98
|
+
return doc;
|
|
99
|
+
}
|
|
100
|
+
function scalarStyle(type) {
|
|
101
|
+
if (type === 'QUOTE_SINGLE')
|
|
102
|
+
return 'single';
|
|
103
|
+
if (type === 'QUOTE_DOUBLE')
|
|
104
|
+
return 'double';
|
|
105
|
+
if (type === 'BLOCK_LITERAL' || type === 'BLOCK_FOLDED')
|
|
106
|
+
return 'block';
|
|
107
|
+
return 'plain';
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Whether a contract's `pins.allow` exempts a reference from pinning: an
|
|
111
|
+
* owner, or the exact action name, case-insensitively for owner and repo.
|
|
112
|
+
* This mirrors the contract's own pins check, so `pin` never leaves a
|
|
113
|
+
* reference on a tag that `validate --contract` would then reject.
|
|
114
|
+
*/
|
|
115
|
+
function isPinAllowed(uses, allow) {
|
|
116
|
+
const at = uses.lastIndexOf('@');
|
|
117
|
+
const name = normalizeName(at === -1 ? uses : uses.slice(0, at));
|
|
118
|
+
const owner = name.split('/')[0];
|
|
119
|
+
const allowed = new Set(allow.map(normalizeName));
|
|
120
|
+
return allowed.has(name) || allowed.has(owner);
|
|
121
|
+
}
|
|
122
|
+
function normalizeName(name) {
|
|
123
|
+
const parts = name.split('/');
|
|
124
|
+
return [
|
|
125
|
+
...parts.slice(0, 2).map((p) => p.toLowerCase()),
|
|
126
|
+
...parts.slice(2)
|
|
127
|
+
].join('/');
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Pin every remote `uses:` reference in a workflow or action file to the
|
|
131
|
+
* commit SHA `resolve` returns, keeping the tag in a trailing comment
|
|
132
|
+
* (`owner/repo@<sha> # v4`) so Dependabot and Renovate can still update
|
|
133
|
+
* it. The text is edited in place (only the value and its comment change),
|
|
134
|
+
* never re-serialised, so formatting and other comments survive.
|
|
135
|
+
*
|
|
136
|
+
* Left alone: local (`./`) and `docker://` references, references already
|
|
137
|
+
* pinned to a full SHA, and those `allow` exempts. A reference that can't
|
|
138
|
+
* be resolved is reported in `unresolved` and not changed.
|
|
139
|
+
*/
|
|
140
|
+
async function pinContent(content, resolve, options = {}) {
|
|
141
|
+
const sites = findUsesSites(content);
|
|
142
|
+
const result = {
|
|
143
|
+
content,
|
|
144
|
+
changes: [],
|
|
145
|
+
unresolved: [],
|
|
146
|
+
allowed: []
|
|
147
|
+
};
|
|
148
|
+
const lineCounts = new Map();
|
|
149
|
+
sites.forEach((s) => lineCounts.set(s.line, (lineCounts.get(s.line) ?? 0) + 1));
|
|
150
|
+
const resolved = new Map();
|
|
151
|
+
const edits = [];
|
|
152
|
+
for (const site of sites) {
|
|
153
|
+
const { uses, line } = site;
|
|
154
|
+
if (/^(\.\.?\/|docker:\/\/)/.test(uses))
|
|
155
|
+
continue;
|
|
156
|
+
const at = uses.lastIndexOf('@');
|
|
157
|
+
const name = at === -1 ? '' : uses.slice(0, at);
|
|
158
|
+
const ref = uses.slice(at + 1);
|
|
159
|
+
const parts = name.split('/');
|
|
160
|
+
if (at === -1 || !ref || parts.length < 2 || parts.some((p) => !p)) {
|
|
161
|
+
result.unresolved.push({
|
|
162
|
+
line,
|
|
163
|
+
uses,
|
|
164
|
+
reason: 'is not an owner/repo@ref reference'
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (FULL_SHA.test(ref))
|
|
169
|
+
continue;
|
|
170
|
+
if (isPinAllowed(uses, options.allow ?? [])) {
|
|
171
|
+
result.allowed.push({ line, uses });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (site.style === 'block') {
|
|
175
|
+
result.unresolved.push({
|
|
176
|
+
line,
|
|
177
|
+
uses,
|
|
178
|
+
reason: 'is written as a block scalar; pin it by hand'
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!resolved.has(uses))
|
|
183
|
+
resolved.set(uses, resolve(uses));
|
|
184
|
+
const sha = (await resolved.get(uses))?.toLowerCase();
|
|
185
|
+
if (!sha || !FULL_SHA.test(sha)) {
|
|
186
|
+
result.unresolved.push({
|
|
187
|
+
line,
|
|
188
|
+
uses,
|
|
189
|
+
reason: 'could not be resolved to a commit'
|
|
190
|
+
});
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const pinned = `${name}@${sha}`;
|
|
194
|
+
const quote = site.style === 'single' ? "'" : site.style === 'double' ? '"' : '';
|
|
195
|
+
edits.push({
|
|
196
|
+
start: site.start,
|
|
197
|
+
end: site.end,
|
|
198
|
+
text: `${quote}${pinned}${quote}`
|
|
199
|
+
});
|
|
200
|
+
// Several references on one line (a flow sequence) can't each have a
|
|
201
|
+
// trailing comment, so none is written rather than a wrong one.
|
|
202
|
+
if (lineCounts.get(line) === 1) {
|
|
203
|
+
edits.push(commentEdit(content, site.end, ref));
|
|
204
|
+
}
|
|
205
|
+
result.changes.push({ line, uses, ref, sha, pinned });
|
|
206
|
+
}
|
|
207
|
+
result.content = edits
|
|
208
|
+
.sort((a, b) => b.start - a.start)
|
|
209
|
+
.reduce((text, e) => text.slice(0, e.start) + e.text + text.slice(e.end), content);
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The edit that leaves `# <ref>` as the comment of the line a value ends
|
|
214
|
+
* on: an old version comment is replaced, any other comment kept after
|
|
215
|
+
* the ref, and with no comment one is added at the end of the line.
|
|
216
|
+
*/
|
|
217
|
+
function commentEdit(content, valueEnd, ref) {
|
|
218
|
+
let lineEnd = content.indexOf('\n', valueEnd);
|
|
219
|
+
if (lineEnd === -1)
|
|
220
|
+
lineEnd = content.length;
|
|
221
|
+
if (content[lineEnd - 1] === '\r')
|
|
222
|
+
lineEnd--;
|
|
223
|
+
const tail = content.slice(valueEnd, lineEnd);
|
|
224
|
+
const hash = commentStart(tail);
|
|
225
|
+
if (hash === -1) {
|
|
226
|
+
const at = valueEnd + tail.trimEnd().length;
|
|
227
|
+
return { start: at, end: at, text: ` # ${ref}` };
|
|
228
|
+
}
|
|
229
|
+
const body = tail.slice(hash + 1).trim();
|
|
230
|
+
return {
|
|
231
|
+
start: valueEnd + hash,
|
|
232
|
+
end: lineEnd,
|
|
233
|
+
text: `# ${ref}${commentRest(body, ref)}`
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** Where a YAML comment starts in the rest of a line, or -1. */
|
|
237
|
+
function commentStart(tail) {
|
|
238
|
+
let quote;
|
|
239
|
+
for (let i = 0; i < tail.length; i++) {
|
|
240
|
+
const c = tail[i];
|
|
241
|
+
if (quote) {
|
|
242
|
+
if (c === '\\' && quote === '"')
|
|
243
|
+
i++;
|
|
244
|
+
else if (c === quote)
|
|
245
|
+
quote = undefined;
|
|
246
|
+
}
|
|
247
|
+
else if (c === "'" || c === '"') {
|
|
248
|
+
quote = c;
|
|
249
|
+
}
|
|
250
|
+
else if (c === '#' && i > 0 && /\s/.test(tail[i - 1])) {
|
|
251
|
+
return i;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return -1;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* What to keep of an existing comment after the new `# <ref>`: the text
|
|
258
|
+
* after an old version (`v3`, `3.2.1`, `tag=v3`, `pin @v3`, or the old
|
|
259
|
+
* ref itself), else the whole comment.
|
|
260
|
+
*/
|
|
261
|
+
function commentRest(body, ref) {
|
|
262
|
+
if (!body)
|
|
263
|
+
return '';
|
|
264
|
+
const version = body.match(/^(?:tag\s*=\s*|pin\s*@?\s*)?@?v?\d[\w.+-]*(.*)$/i);
|
|
265
|
+
const rest = version
|
|
266
|
+
? version[1]
|
|
267
|
+
: body === ref || body.startsWith(`${ref} `)
|
|
268
|
+
? body.slice(ref.length)
|
|
269
|
+
: undefined;
|
|
270
|
+
if (rest === undefined)
|
|
271
|
+
return ` - ${body}`;
|
|
272
|
+
return rest.trim() ? ` ${rest.trim()}` : '';
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The given files plus the local composite actions (`./path`) and local
|
|
276
|
+
* reusable workflows they use, recursively, so a whole repository is
|
|
277
|
+
* pinned in one go. `./` resolves against the repository root, as on
|
|
278
|
+
* GitHub. Files are listed once; one that can't be read is kept (for the
|
|
279
|
+
* caller to report) but not followed.
|
|
280
|
+
*/
|
|
281
|
+
function localPinTargets(files) {
|
|
282
|
+
const seen = new Set(files.map((f) => path.resolve(f)));
|
|
283
|
+
const result = [...files];
|
|
284
|
+
const follow = (file, root) => {
|
|
285
|
+
let sites;
|
|
286
|
+
try {
|
|
287
|
+
sites = findUsesSites(fs.readFileSync(file, 'utf8'));
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
for (const { uses } of sites) {
|
|
293
|
+
if (!uses.startsWith('./') && !uses.startsWith('../'))
|
|
294
|
+
continue;
|
|
295
|
+
const target = path.resolve(root, uses);
|
|
296
|
+
const local = [
|
|
297
|
+
target,
|
|
298
|
+
...['action.yml', 'action.yaml'].map((n) => path.join(target, n))
|
|
299
|
+
].find((c) => fs.existsSync(c) && fs.statSync(c).isFile());
|
|
300
|
+
if (!local || seen.has(local))
|
|
301
|
+
continue;
|
|
302
|
+
seen.add(local);
|
|
303
|
+
result.push(local);
|
|
304
|
+
follow(local, root);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
for (const file of files)
|
|
308
|
+
follow(file, (0, repository_1.repositoryRoot)(file));
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
//# sourceMappingURL=pin.js.map
|
package/dist/report.js
CHANGED
|
@@ -4,6 +4,8 @@ exports.formatReport = formatReport;
|
|
|
4
4
|
exports.formatValidation = formatValidation;
|
|
5
5
|
/** A SARIF rule id per error, so code-scanning can group findings. */
|
|
6
6
|
function ruleIdFor(error) {
|
|
7
|
+
if (error.rule)
|
|
8
|
+
return error.rule;
|
|
7
9
|
if (/^required\[|^forbidden\[/.test(error.path))
|
|
8
10
|
return 'secureci/contract';
|
|
9
11
|
if (/\.if$|\bif:/.test(error.path))
|
|
@@ -13,7 +15,11 @@ function ruleIdFor(error) {
|
|
|
13
15
|
const RULES = {
|
|
14
16
|
'secureci/validation': 'Workflow or action structure',
|
|
15
17
|
'secureci/contract': 'Pipeline contract',
|
|
16
|
-
'secureci/expression': 'Expression / if: condition'
|
|
18
|
+
'secureci/expression': 'Expression / if: condition',
|
|
19
|
+
'secureci/injection': 'Untrusted input in a shell or script',
|
|
20
|
+
'secureci/permissions': 'GITHUB_TOKEN permissions',
|
|
21
|
+
'secureci/dangerous-trigger': 'Privileged trigger with untrusted code',
|
|
22
|
+
'secureci/vulnerable-action': 'Action with a known security advisory'
|
|
17
23
|
};
|
|
18
24
|
function jsonForFile(entry) {
|
|
19
25
|
return {
|
|
@@ -22,7 +28,8 @@ function jsonForFile(entry) {
|
|
|
22
28
|
errors: entry.result.errors.map((e) => ({
|
|
23
29
|
path: e.path,
|
|
24
30
|
message: e.message,
|
|
25
|
-
severity: e.severity
|
|
31
|
+
severity: e.severity,
|
|
32
|
+
rule: ruleIdFor(e)
|
|
26
33
|
}))
|
|
27
34
|
};
|
|
28
35
|
}
|
|
@@ -37,10 +37,16 @@ exports.parseActionReference = parseActionReference;
|
|
|
37
37
|
exports.getActionCacheDir = getActionCacheDir;
|
|
38
38
|
exports.githubToken = githubToken;
|
|
39
39
|
exports._resetGithubTokenForTests = _resetGithubTokenForTests;
|
|
40
|
+
exports.serverUrl = serverUrl;
|
|
41
|
+
exports.apiUrl = apiUrl;
|
|
40
42
|
exports.lsRemoteSha = lsRemoteSha;
|
|
43
|
+
exports.parseLsRemoteTags = parseLsRemoteTags;
|
|
44
|
+
exports.lsRemoteTags = lsRemoteTags;
|
|
45
|
+
exports.getText = getText;
|
|
41
46
|
exports.fetchAction = fetchAction;
|
|
42
47
|
exports.fetchRepoFile = fetchRepoFile;
|
|
43
48
|
exports.clearActionCache = clearActionCache;
|
|
49
|
+
exports.cachedActionSha = cachedActionSha;
|
|
44
50
|
exports.resolveActionSha = resolveActionSha;
|
|
45
51
|
exports.resolveRepoIdentity = resolveRepoIdentity;
|
|
46
52
|
exports.lsRemoteReachable = lsRemoteReachable;
|
|
@@ -110,9 +116,14 @@ function githubToken() {
|
|
|
110
116
|
function _resetGithubTokenForTests() {
|
|
111
117
|
discoveredToken = undefined;
|
|
112
118
|
}
|
|
119
|
+
/** The GitHub server ($GITHUB_SERVER_URL, default https://github.com). */
|
|
113
120
|
function serverUrl() {
|
|
114
121
|
return (process.env.GITHUB_SERVER_URL || 'https://github.com').replace(/\/+$/, '');
|
|
115
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* The GitHub REST API base: $GITHUB_API_URL, else api.github.com, or
|
|
125
|
+
* `<server>/api/v3` for GitHub Enterprise Server.
|
|
126
|
+
*/
|
|
116
127
|
function apiUrl() {
|
|
117
128
|
if (process.env.GITHUB_API_URL) {
|
|
118
129
|
return process.env.GITHUB_API_URL.replace(/\/+$/, '');
|
|
@@ -155,7 +166,51 @@ async function lsRemoteSha(url, ref) {
|
|
|
155
166
|
refs.get(`refs/tags/${ref}`) ??
|
|
156
167
|
refs.get(`refs/heads/${ref}`));
|
|
157
168
|
}
|
|
158
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* Tag name -> commit SHA from `git ls-remote --tags` output. An annotated
|
|
171
|
+
* tag's peeled entry (`refs/tags/x^{}`) wins, so every SHA is a commit.
|
|
172
|
+
*/
|
|
173
|
+
function parseLsRemoteTags(output) {
|
|
174
|
+
const tags = new Map();
|
|
175
|
+
const peeled = new Set();
|
|
176
|
+
for (const line of output.split('\n')) {
|
|
177
|
+
const [sha, ref] = line.trim().split(/\s+/);
|
|
178
|
+
if (!sha || !FULL_SHA.test(sha) || !ref?.startsWith('refs/tags/'))
|
|
179
|
+
continue;
|
|
180
|
+
let name = ref.slice('refs/tags/'.length);
|
|
181
|
+
const isPeeled = name.endsWith('^{}');
|
|
182
|
+
if (isPeeled)
|
|
183
|
+
name = name.slice(0, -3);
|
|
184
|
+
if (isPeeled || !peeled.has(name))
|
|
185
|
+
tags.set(name, sha.toLowerCase());
|
|
186
|
+
if (isPeeled)
|
|
187
|
+
peeled.add(name);
|
|
188
|
+
}
|
|
189
|
+
return tags;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Every tag of the repository at `url` and the commit it points at, via
|
|
193
|
+
* anonymous `git ls-remote --tags` (no API rate limit), or undefined if
|
|
194
|
+
* the repository can't be read.
|
|
195
|
+
*/
|
|
196
|
+
async function lsRemoteTags(url) {
|
|
197
|
+
try {
|
|
198
|
+
const { stdout } = await execFileAsync('git', ['ls-remote', '--tags', url], {
|
|
199
|
+
encoding: 'utf8',
|
|
200
|
+
timeout: 15000,
|
|
201
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
202
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
203
|
+
});
|
|
204
|
+
return parseLsRemoteTags(stdout);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* GET a small text response (API lookups). Rejects on a non-200 status
|
|
212
|
+
* with an error carrying `status`.
|
|
213
|
+
*/
|
|
159
214
|
function getText(url, headers, timeoutMs) {
|
|
160
215
|
return new Promise((resolve, reject) => {
|
|
161
216
|
const client = url.startsWith('http://') ? http : https;
|
|
@@ -278,7 +333,27 @@ function writeRefIndex(dir, refs) {
|
|
|
278
333
|
* picked up. If the ref can't be resolved (offline, rate-limited), the
|
|
279
334
|
* last commit cached for it is used. Returns the repository root.
|
|
280
335
|
*/
|
|
281
|
-
|
|
336
|
+
const inflight = new Map();
|
|
337
|
+
/**
|
|
338
|
+
* fetchRepositoryOnce, shared by concurrent callers asking for the same
|
|
339
|
+
* repository and ref (jobs running in parallel), so it is downloaded and
|
|
340
|
+
* written into the cache once.
|
|
341
|
+
*/
|
|
342
|
+
function fetchRepository(ref, uses, options) {
|
|
343
|
+
const key = JSON.stringify([
|
|
344
|
+
repoCacheDir(ref),
|
|
345
|
+
ref.ref,
|
|
346
|
+
!!options.offline,
|
|
347
|
+
options.token === undefined ? 'discovered' : options.token
|
|
348
|
+
]);
|
|
349
|
+
let pending = inflight.get(key);
|
|
350
|
+
if (!pending) {
|
|
351
|
+
pending = fetchRepositoryOnce(ref, uses, options).finally(() => inflight.delete(key));
|
|
352
|
+
inflight.set(key, pending);
|
|
353
|
+
}
|
|
354
|
+
return pending;
|
|
355
|
+
}
|
|
356
|
+
async function fetchRepositoryOnce(ref, uses, options) {
|
|
282
357
|
const dir = repoCacheDir(ref);
|
|
283
358
|
const refs = readRefIndex(dir);
|
|
284
359
|
const cachedSha = FULL_SHA.test(ref.ref)
|
|
@@ -334,8 +409,12 @@ async function fetchRepository(ref, uses, options) {
|
|
|
334
409
|
const [top] = fs.readdirSync(extractDir);
|
|
335
410
|
if (!top)
|
|
336
411
|
throw new Error('No files extracted from tarball');
|
|
337
|
-
|
|
338
|
-
|
|
412
|
+
// A commit's content never changes: keep a copy another job (asking
|
|
413
|
+
// by another ref) put there meanwhile, as it may be in use.
|
|
414
|
+
if (!sha || !fs.existsSync(target)) {
|
|
415
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
416
|
+
fs.renameSync(path.join(extractDir, top), target);
|
|
417
|
+
}
|
|
339
418
|
if (sha) {
|
|
340
419
|
writeRefIndex(dir, { ...readRefIndex(dir), [ref.ref]: sha });
|
|
341
420
|
}
|
|
@@ -410,6 +489,23 @@ function clearActionCache() {
|
|
|
410
489
|
logger_1.logger.info('Action cache cleared');
|
|
411
490
|
}
|
|
412
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* The commit a ref resolved to when it was last downloaded into the action
|
|
494
|
+
* cache, without touching the network (for offline use), or undefined when
|
|
495
|
+
* that commit isn't cached. An already-pinned SHA is returned as is. For a
|
|
496
|
+
* moving ref (`@main`) this is the last commit seen, not necessarily the
|
|
497
|
+
* current one.
|
|
498
|
+
*/
|
|
499
|
+
function cachedActionSha(uses) {
|
|
500
|
+
const ref = parseActionReference(uses);
|
|
501
|
+
if (!ref)
|
|
502
|
+
return undefined;
|
|
503
|
+
if (FULL_SHA.test(ref.ref))
|
|
504
|
+
return ref.ref.toLowerCase();
|
|
505
|
+
const dir = repoCacheDir(ref);
|
|
506
|
+
const sha = readRefIndex(dir)[ref.ref];
|
|
507
|
+
return sha && fs.existsSync(path.join(dir, sha)) ? sha : undefined;
|
|
508
|
+
}
|
|
413
509
|
/**
|
|
414
510
|
* Resolve a `uses:` action reference to its current commit SHA, for the
|
|
415
511
|
* inventory and lockfile. Returns undefined if the ref can't be resolved
|
|
@@ -435,14 +435,24 @@ async function saveCache(fsys, scope, key, paths) {
|
|
|
435
435
|
warning: 'Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.'
|
|
436
436
|
};
|
|
437
437
|
}
|
|
438
|
-
|
|
439
|
-
|
|
438
|
+
// Unique per save: jobs running at once may save the same key.
|
|
439
|
+
const staging = `${dir}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
440
440
|
for (const file of files) {
|
|
441
441
|
copyFile(file.host, path.join(staging, 'files', ...cachedLocation(fsys, file.logical).split('/')));
|
|
442
442
|
}
|
|
443
443
|
fs.writeFileSync(path.join(staging, 'meta.json'), JSON.stringify({ key, version, created: Date.now() }));
|
|
444
444
|
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
445
|
-
|
|
445
|
+
try {
|
|
446
|
+
fs.renameSync(staging, dir);
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
450
|
+
// Another job saved this key meanwhile; cache entries are immutable.
|
|
451
|
+
if (fs.existsSync(path.join(dir, 'meta.json'))) {
|
|
452
|
+
return ok(`Cache for key "${key}" already exists; not saving`);
|
|
453
|
+
}
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
446
456
|
return ok(`Cache saved with key: ${key} (${files.length} file(s))`);
|
|
447
457
|
}
|
|
448
458
|
/** `actions/cache`, `actions/cache/restore` and `actions/cache/save`. */
|
package/dist/runner/dry-run.js
CHANGED
|
@@ -4,11 +4,15 @@ exports.dryRun = dryRun;
|
|
|
4
4
|
const workflow_parser_1 = require("../parser/workflow-parser");
|
|
5
5
|
const logger_1 = require("../utils/logger");
|
|
6
6
|
const validator_1 = require("../parser/validator");
|
|
7
|
+
const expression_1 = require("./expression");
|
|
8
|
+
const job_plan_1 = require("./job-plan");
|
|
9
|
+
// A matrix expression reading a context a dry run doesn't have.
|
|
10
|
+
const RUN_TIME_MATRIX = /\$\{\{[^}]*\b(?:needs|inputs|vars|github|secrets)\b/;
|
|
7
11
|
/**
|
|
8
12
|
* Perform a dry run of a workflow or action file
|
|
9
13
|
* Shows what would be executed without actually running anything
|
|
10
14
|
*/
|
|
11
|
-
function dryRun(filePath) {
|
|
15
|
+
function dryRun(filePath, options = {}) {
|
|
12
16
|
try {
|
|
13
17
|
const parsed = (0, workflow_parser_1.parseFile)(filePath);
|
|
14
18
|
// A file a real run would reject fails the dry run too.
|
|
@@ -23,7 +27,7 @@ function dryRun(filePath) {
|
|
|
23
27
|
return { success: false, type: parsed.type };
|
|
24
28
|
}
|
|
25
29
|
if (parsed.type === 'workflow') {
|
|
26
|
-
return dryRunWorkflow(parsed);
|
|
30
|
+
return dryRunWorkflow(parsed, options);
|
|
27
31
|
}
|
|
28
32
|
else {
|
|
29
33
|
return dryRunAction(parsed);
|
|
@@ -37,22 +41,56 @@ function dryRun(filePath) {
|
|
|
37
41
|
};
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
|
-
function dryRunWorkflow(parsed) {
|
|
44
|
+
function dryRunWorkflow(parsed, options) {
|
|
41
45
|
const workflow = parsed.file;
|
|
46
|
+
const filter = options.matrix ?? {};
|
|
47
|
+
const definedKeys = new Set();
|
|
48
|
+
let runTimeMatrix = false;
|
|
49
|
+
let unmatched = false;
|
|
42
50
|
logger_1.logger.info(`Workflow: ${workflow.name}`);
|
|
43
51
|
logger_1.logger.info(`Trigger: ${JSON.stringify(workflow.on)}`);
|
|
44
|
-
const
|
|
52
|
+
const selectedJobs = Object.entries(workflow.jobs).filter(([jobId]) => !options.job || jobId === options.job);
|
|
53
|
+
const jobs = selectedJobs.flatMap(([jobId, job]) => {
|
|
54
|
+
// Matrix combinations, as far as they can be known without running.
|
|
55
|
+
let matrix;
|
|
56
|
+
const rawMatrix = job.strategy?.matrix;
|
|
57
|
+
const atRunTime = rawMatrix !== undefined &&
|
|
58
|
+
RUN_TIME_MATRIX.test(JSON.stringify(rawMatrix));
|
|
59
|
+
if (atRunTime) {
|
|
60
|
+
runTimeMatrix = true;
|
|
61
|
+
}
|
|
62
|
+
else if (rawMatrix !== undefined) {
|
|
63
|
+
const combinations = (0, job_plan_1.expandMatrix)(rawMatrix, (0, expression_1.createContext)());
|
|
64
|
+
combinations.forEach((c) => Object.keys(c).forEach((k) => definedKeys.add(k)));
|
|
65
|
+
const { keys, matches } = (0, job_plan_1.filterMatrix)(combinations, filter);
|
|
66
|
+
if (keys.length > 0 && matches.length === 0) {
|
|
67
|
+
logger_1.logger.warn((0, job_plan_1.noMatrixMatchMessage)(jobId, filter, combinations));
|
|
68
|
+
unmatched = true;
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
matrix = matches.map((m) => (0, job_plan_1.matrixLabel)(m.values));
|
|
72
|
+
}
|
|
45
73
|
logger_1.logger.step(`Job: ${jobId} (${job.name || jobId})`);
|
|
74
|
+
if (atRunTime) {
|
|
75
|
+
logger_1.logger.info(' Matrix: resolved at run time');
|
|
76
|
+
}
|
|
77
|
+
else if (matrix) {
|
|
78
|
+
logger_1.logger.info(` Matrix (${matrix.length} combination(s)):`);
|
|
79
|
+
matrix.forEach((label) => logger_1.logger.info(` ${label}`));
|
|
80
|
+
}
|
|
46
81
|
// Handle reusable workflow calls
|
|
47
82
|
if (job.uses) {
|
|
48
83
|
logger_1.logger.info(` Uses workflow: ${job.uses}`);
|
|
49
84
|
if (job.with) {
|
|
50
85
|
logger_1.logger.info(` With inputs: ${JSON.stringify(job.with)}`);
|
|
51
86
|
}
|
|
52
|
-
return
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
name: jobId,
|
|
90
|
+
steps: [`Calls reusable workflow: ${job.uses}`],
|
|
91
|
+
...(matrix ? { matrix } : {})
|
|
92
|
+
}
|
|
93
|
+
];
|
|
56
94
|
}
|
|
57
95
|
// Handle regular jobs
|
|
58
96
|
if (job['runs-on']) {
|
|
@@ -74,11 +112,18 @@ function dryRunWorkflow(parsed) {
|
|
|
74
112
|
logger_1.logger.info(stepDesc);
|
|
75
113
|
return stepDesc;
|
|
76
114
|
});
|
|
77
|
-
return {
|
|
78
|
-
name: jobId,
|
|
79
|
-
steps
|
|
80
|
-
};
|
|
115
|
+
return [{ name: jobId, steps, ...(matrix ? { matrix } : {}) }];
|
|
81
116
|
});
|
|
117
|
+
// A key might belong to a matrix only known at run time.
|
|
118
|
+
if (!runTimeMatrix) {
|
|
119
|
+
for (const key of (0, job_plan_1.unknownMatrixKeys)(filter, definedKeys)) {
|
|
120
|
+
logger_1.logger.warn(`--matrix ${key}: no matrix job defines this key; it was ignored`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (unmatched) {
|
|
124
|
+
logger_1.logger.error('Dry run failed: a --matrix filter matched no combination');
|
|
125
|
+
return { success: false, type: 'workflow', jobs };
|
|
126
|
+
}
|
|
82
127
|
logger_1.logger.success('Dry run completed successfully');
|
|
83
128
|
return {
|
|
84
129
|
success: true,
|