actionspack 0.0.0 → 0.1.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/LICENSE +21 -0
- package/README.md +166 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +86 -0
- package/dist/commands-V9OOSAvq.mjs +1318 -0
- package/dist/index.d.mts +108 -0
- package/dist/index.mjs +2 -0
- package/package.json +62 -8
- package/index.js +0 -1
|
@@ -0,0 +1,1318 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify, styleText } from "node:util";
|
|
3
|
+
import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Evaluator, Lexer, Parser, data } from "@actions/expressions";
|
|
6
|
+
import { Binary, ContextAccess, FunctionCall, Grouping, IndexAccess, Literal, Logical, Star, Unary } from "@actions/expressions/ast";
|
|
7
|
+
import { truthy } from "@actions/expressions/result";
|
|
8
|
+
import { parse, stringify } from "yaml";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { ACTION_ROOT } from "@actions/workflow-parser/actions/action-constants";
|
|
14
|
+
import { JSONObjectReader } from "@actions/workflow-parser/templates/json-object-reader";
|
|
15
|
+
import { TemplateSchema } from "@actions/workflow-parser/templates/schema/index";
|
|
16
|
+
import { TemplateContext, TemplateValidationErrors } from "@actions/workflow-parser/templates/template-context";
|
|
17
|
+
import { readTemplate } from "@actions/workflow-parser/templates/template-reader";
|
|
18
|
+
import { isBasicExpression, isBoolean, isMapping, isNumber, isSequence, isString } from "@actions/workflow-parser/templates/tokens/type-guards";
|
|
19
|
+
import { NoOperationTraceWriter } from "@actions/workflow-parser/templates/trace-writer";
|
|
20
|
+
import { WORKFLOW_ROOT } from "@actions/workflow-parser/workflows/workflow-constants";
|
|
21
|
+
import { YamlObjectReader } from "@actions/workflow-parser/workflows/yaml-object-reader";
|
|
22
|
+
import { Buffer } from "node:buffer";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
//#region src/utils/expression.ts
|
|
25
|
+
const EXPRESSION_CONTEXTS = [
|
|
26
|
+
"env",
|
|
27
|
+
"github",
|
|
28
|
+
"inputs",
|
|
29
|
+
"job",
|
|
30
|
+
"matrix",
|
|
31
|
+
"needs",
|
|
32
|
+
"runner",
|
|
33
|
+
"secrets",
|
|
34
|
+
"steps",
|
|
35
|
+
"strategy",
|
|
36
|
+
"vars"
|
|
37
|
+
];
|
|
38
|
+
function parseExpression(expression) {
|
|
39
|
+
return new Parser(new Lexer(expression).lex().tokens, EXPRESSION_CONTEXTS, []).parse();
|
|
40
|
+
}
|
|
41
|
+
function expressionBody(value) {
|
|
42
|
+
if (!value.startsWith("${{") || !value.endsWith("}}")) return;
|
|
43
|
+
return value.slice(3, -2).trim();
|
|
44
|
+
}
|
|
45
|
+
function staticExpression(expr, values = {}) {
|
|
46
|
+
const evaluated = evaluateStaticExpression(expr, values);
|
|
47
|
+
if (evaluated) return evaluated;
|
|
48
|
+
if (expr instanceof Logical) return simplifyLogicalExpression(expr, values, (item) => printExpression(item, values)).static;
|
|
49
|
+
}
|
|
50
|
+
function simplifyLogicalExpression(expr, values, printExpression) {
|
|
51
|
+
const op = expr.operator.lexeme;
|
|
52
|
+
const pending = [];
|
|
53
|
+
let lastStatic;
|
|
54
|
+
for (const arg of expr.args) {
|
|
55
|
+
const staticArg = staticExpression(arg, values);
|
|
56
|
+
const text = staticArg?.text ?? printExpression(arg);
|
|
57
|
+
if (!staticArg) {
|
|
58
|
+
pending.push(text);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
lastStatic = staticArg;
|
|
62
|
+
if (pending.length === 0) {
|
|
63
|
+
if (op === "&&" && !staticArg.truthy) return {
|
|
64
|
+
static: staticArg,
|
|
65
|
+
text
|
|
66
|
+
};
|
|
67
|
+
if (op === "&&" && staticArg.truthy) continue;
|
|
68
|
+
if (op === "||" && staticArg.truthy) return {
|
|
69
|
+
static: staticArg,
|
|
70
|
+
text
|
|
71
|
+
};
|
|
72
|
+
if (op === "||" && !staticArg.truthy) continue;
|
|
73
|
+
}
|
|
74
|
+
pending.push(text);
|
|
75
|
+
}
|
|
76
|
+
if (pending.length === 0 && lastStatic) return {
|
|
77
|
+
static: lastStatic,
|
|
78
|
+
text: lastStatic.text
|
|
79
|
+
};
|
|
80
|
+
if (pending.length === 1) return { text: pending[0] };
|
|
81
|
+
return { text: pending.join(` ${op} `) };
|
|
82
|
+
}
|
|
83
|
+
function staticIfExpression(expression) {
|
|
84
|
+
try {
|
|
85
|
+
const value = staticExpression(parseExpression(expression));
|
|
86
|
+
if (value?.value === true) return true;
|
|
87
|
+
if (value?.value === false) return false;
|
|
88
|
+
if (value?.value === "") return "";
|
|
89
|
+
return;
|
|
90
|
+
} catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function printExpression(expr, values) {
|
|
95
|
+
const staticValue = staticExpression(expr, values);
|
|
96
|
+
if (staticValue) return staticValue.text;
|
|
97
|
+
if (expr instanceof Binary) return `${printExpression(expr.left, values)} ${expr.operator.lexeme} ${printExpression(expr.right, values)}`;
|
|
98
|
+
if (expr instanceof ContextAccess) return expr.name.lexeme;
|
|
99
|
+
if (expr instanceof FunctionCall) return printFunctionExpression(expr, values);
|
|
100
|
+
if (expr instanceof Grouping) return `(${printExpression(expr.group, values)})`;
|
|
101
|
+
if (expr instanceof IndexAccess) {
|
|
102
|
+
const replacement = replacementForIndexAccess(expr, values);
|
|
103
|
+
if (replacement !== void 0) return replacement;
|
|
104
|
+
return printIndexAccess(expr, values);
|
|
105
|
+
}
|
|
106
|
+
if (expr instanceof Literal) return expr.token.lexeme;
|
|
107
|
+
if (expr instanceof Logical) return simplifyLogicalExpression(expr, values, (item) => printExpression(item, values)).text;
|
|
108
|
+
if (expr instanceof Star) return "*";
|
|
109
|
+
if (expr instanceof Unary) return `${expr.operator.lexeme}${printExpression(expr.expr, values)}`;
|
|
110
|
+
throw new Error("Unsupported expression node");
|
|
111
|
+
}
|
|
112
|
+
function literalString(expr) {
|
|
113
|
+
if (typeof expr.token.value === "string") return expr.token.value;
|
|
114
|
+
const literal = expr.literal;
|
|
115
|
+
return typeof literal.value === "string" ? literal.value : "";
|
|
116
|
+
}
|
|
117
|
+
function valueForIndexAccess(expr, values) {
|
|
118
|
+
if (!(expr instanceof IndexAccess)) return;
|
|
119
|
+
return hasValueForIndexAccess(expr, values) ? values[indexAccessKey(expr)] : void 0;
|
|
120
|
+
}
|
|
121
|
+
function quoteExpressionString(value) {
|
|
122
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
123
|
+
}
|
|
124
|
+
function valueLiteral(value) {
|
|
125
|
+
if (value === void 0 || value === null) return "null";
|
|
126
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
127
|
+
if (typeof value === "number") return String(value);
|
|
128
|
+
if (typeof value === "string") return quoteExpressionString(value);
|
|
129
|
+
return `fromJson(${quoteExpressionString(JSON.stringify(value))})`;
|
|
130
|
+
}
|
|
131
|
+
function evaluateStaticExpression(expr, values) {
|
|
132
|
+
if (!canEvaluateExpression(expr, values)) return;
|
|
133
|
+
try {
|
|
134
|
+
const data = new Evaluator(expr, expressionContext(values)).evaluate();
|
|
135
|
+
return {
|
|
136
|
+
data,
|
|
137
|
+
text: dataLiteral(data),
|
|
138
|
+
truthy: truthy(data),
|
|
139
|
+
value: dataValue(data)
|
|
140
|
+
};
|
|
141
|
+
} catch {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function hasValueForIndexAccess(expr, values) {
|
|
146
|
+
const key = indexAccessKey(expr);
|
|
147
|
+
return key !== void 0 && Object.hasOwn(values, key);
|
|
148
|
+
}
|
|
149
|
+
function indexAccessKey(expr) {
|
|
150
|
+
if (!(expr.expr instanceof ContextAccess) || !(expr.index instanceof Literal)) return;
|
|
151
|
+
const root = expr.expr.name.lexeme;
|
|
152
|
+
if (root !== "inputs" && root !== "secrets") return;
|
|
153
|
+
const name = literalString(expr.index);
|
|
154
|
+
return name ? `${root}.${name}` : void 0;
|
|
155
|
+
}
|
|
156
|
+
function canEvaluateExpression(expr, values) {
|
|
157
|
+
if (expr instanceof Binary) return canEvaluateExpression(expr.left, values) && canEvaluateExpression(expr.right, values);
|
|
158
|
+
if (expr instanceof ContextAccess) return Object.hasOwn(values, expr.name.lexeme);
|
|
159
|
+
if (expr instanceof FunctionCall) return expr.args.every((arg) => canEvaluateExpression(arg, values));
|
|
160
|
+
if (expr instanceof Grouping) return canEvaluateExpression(expr.group, values);
|
|
161
|
+
if (expr instanceof IndexAccess) return hasValueForIndexAccess(expr, values) || canEvaluateExpression(expr.expr, values) && canEvaluateExpression(expr.index, values);
|
|
162
|
+
if (expr instanceof Literal) return true;
|
|
163
|
+
if (expr instanceof Logical) return expr.args.every((arg) => canEvaluateExpression(arg, values));
|
|
164
|
+
if (expr instanceof Star) return false;
|
|
165
|
+
if (expr instanceof Unary) return canEvaluateExpression(expr.expr, values);
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
function expressionContext(values) {
|
|
169
|
+
const roots = /* @__PURE__ */ new Map();
|
|
170
|
+
const pairs = [];
|
|
171
|
+
for (const [key, value] of Object.entries(values)) {
|
|
172
|
+
const [root, ...rest] = key.split(".");
|
|
173
|
+
if (!root) continue;
|
|
174
|
+
if (rest.length === 0) {
|
|
175
|
+
pairs.push({
|
|
176
|
+
key: root,
|
|
177
|
+
value: toExpressionData(value)
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const name = rest.join(".");
|
|
182
|
+
const entries = roots.get(root) ?? /* @__PURE__ */ new Map();
|
|
183
|
+
entries.set(name, value);
|
|
184
|
+
roots.set(root, entries);
|
|
185
|
+
}
|
|
186
|
+
for (const [root, entries] of roots) pairs.push({
|
|
187
|
+
key: root,
|
|
188
|
+
value: new data.Dictionary(...[...entries].map(([key, value]) => ({
|
|
189
|
+
key,
|
|
190
|
+
value: toExpressionData(value)
|
|
191
|
+
})))
|
|
192
|
+
});
|
|
193
|
+
return new data.Dictionary(...pairs);
|
|
194
|
+
}
|
|
195
|
+
function toExpressionData(value) {
|
|
196
|
+
if (value === void 0 || value === null) return new data.Null();
|
|
197
|
+
if (typeof value === "boolean") return new data.BooleanData(value);
|
|
198
|
+
if (typeof value === "number") return new data.NumberData(value);
|
|
199
|
+
if (typeof value === "string") return new data.StringData(value);
|
|
200
|
+
if (Array.isArray(value)) return new data.Array(...value.map((item) => toExpressionData(item)));
|
|
201
|
+
if (typeof value === "object") return new data.Dictionary(...Object.entries(value).map(([key, item]) => ({
|
|
202
|
+
key,
|
|
203
|
+
value: toExpressionData(item)
|
|
204
|
+
})));
|
|
205
|
+
return new data.Null();
|
|
206
|
+
}
|
|
207
|
+
function dataValue(value) {
|
|
208
|
+
if (value instanceof data.Array) return value.values().map((item) => dataValue(item));
|
|
209
|
+
if (value instanceof data.BooleanData) return value.value;
|
|
210
|
+
if (value instanceof data.Dictionary) return Object.fromEntries(value.pairs().map((pair) => [pair.key, dataValue(pair.value)]));
|
|
211
|
+
if (value instanceof data.Null) return null;
|
|
212
|
+
if (value instanceof data.NumberData) return value.value;
|
|
213
|
+
if (value instanceof data.StringData) return value.value;
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
function dataLiteral(value) {
|
|
217
|
+
return valueLiteral(dataValue(value));
|
|
218
|
+
}
|
|
219
|
+
function replacementForIndexAccess(expr, values) {
|
|
220
|
+
return hasValueForIndexAccess(expr, values) ? valueLiteral(valueForIndexAccess(expr, values)) : void 0;
|
|
221
|
+
}
|
|
222
|
+
function printIndexAccess(expr, values) {
|
|
223
|
+
const target = printExpression(expr.expr, values);
|
|
224
|
+
if (expr.index instanceof Literal) return expr.index.token.value === void 0 ? `${target}.${expr.index.token.lexeme}` : `${target}[${expr.index.token.lexeme}]`;
|
|
225
|
+
return `${target}[${printExpression(expr.index, values)}]`;
|
|
226
|
+
}
|
|
227
|
+
function printFunctionExpression(expr, values) {
|
|
228
|
+
const format = printFormatExpression(expr, values);
|
|
229
|
+
if (format) return format;
|
|
230
|
+
return `${expr.functionName.lexeme}(${expr.args.map((arg) => printExpression(arg, values)).join(", ")})`;
|
|
231
|
+
}
|
|
232
|
+
function printFormatExpression(expr, values) {
|
|
233
|
+
if (expr.functionName.lexeme.toLowerCase() !== "format") return;
|
|
234
|
+
const [formatArg, ...args] = expr.args;
|
|
235
|
+
if (!formatArg) return;
|
|
236
|
+
const format = staticExpression(formatArg, values);
|
|
237
|
+
if (typeof format?.value !== "string") return;
|
|
238
|
+
const simplified = simplifyFormatString(format.value, args.map((arg) => ({
|
|
239
|
+
static: staticExpression(arg, values),
|
|
240
|
+
text: printExpression(arg, values)
|
|
241
|
+
})));
|
|
242
|
+
if (!simplified) return;
|
|
243
|
+
if (simplified.args.length === 0) return quoteExpressionString(simplified.format);
|
|
244
|
+
return `format(${quoteExpressionString(simplified.format)}, ${simplified.args.join(", ")})`;
|
|
245
|
+
}
|
|
246
|
+
function simplifyFormatString(format, args) {
|
|
247
|
+
const nextArgs = [];
|
|
248
|
+
let nextFormat = "";
|
|
249
|
+
let index = 0;
|
|
250
|
+
while (index < format.length) {
|
|
251
|
+
const char = format[index];
|
|
252
|
+
const nextChar = format[index + 1];
|
|
253
|
+
if (char === "{" && nextChar === "{") {
|
|
254
|
+
nextFormat += "{{";
|
|
255
|
+
index += 2;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (char === "}" && nextChar === "}") {
|
|
259
|
+
nextFormat += "}}";
|
|
260
|
+
index += 2;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (char !== "{") {
|
|
264
|
+
nextFormat += char;
|
|
265
|
+
index += 1;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const end = format.indexOf("}", index + 1);
|
|
269
|
+
if (end === -1) return;
|
|
270
|
+
const rawIndex = /^\d+/u.exec(format.slice(index + 1, end))?.[0];
|
|
271
|
+
if (!rawIndex) return;
|
|
272
|
+
const arg = args[Number(rawIndex)];
|
|
273
|
+
if (!arg) return;
|
|
274
|
+
if (arg.static) nextFormat += escapeFormatLiteral(arg.static.data.coerceString());
|
|
275
|
+
else {
|
|
276
|
+
const nextIndex = nextArgs.push(arg.text) - 1;
|
|
277
|
+
nextFormat += `{${nextIndex}}`;
|
|
278
|
+
}
|
|
279
|
+
index = end + 1;
|
|
280
|
+
}
|
|
281
|
+
nextFormat = nextFormat.trim();
|
|
282
|
+
return {
|
|
283
|
+
args: nextArgs,
|
|
284
|
+
format: nextFormat
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function escapeFormatLiteral(value) {
|
|
288
|
+
return value.replaceAll("{", "{{").replaceAll("}", "}}");
|
|
289
|
+
}
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/utils/yaml.ts
|
|
292
|
+
function parseYamlMap(source, file) {
|
|
293
|
+
const value = parse(source);
|
|
294
|
+
if (!isRecord(value)) throw new TypeError(`${file} must contain a YAML mapping`);
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
297
|
+
function stringifyYaml(value) {
|
|
298
|
+
return stringify(value, {
|
|
299
|
+
lineWidth: 0,
|
|
300
|
+
minContentWidth: 0,
|
|
301
|
+
singleQuote: true
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
function stringifyWorkflowYaml(value) {
|
|
305
|
+
return formatWorkflowYaml(stringifyYaml(normalizeWorkflowYamlValue(value)));
|
|
306
|
+
}
|
|
307
|
+
function isRecord(value) {
|
|
308
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
309
|
+
}
|
|
310
|
+
function asRecord(value) {
|
|
311
|
+
return isRecord(value) ? value : void 0;
|
|
312
|
+
}
|
|
313
|
+
function asArray(value) {
|
|
314
|
+
return Array.isArray(value) ? value : [];
|
|
315
|
+
}
|
|
316
|
+
function formatWorkflowYaml(source) {
|
|
317
|
+
const lines = source.trimEnd().split("\n");
|
|
318
|
+
const output = [];
|
|
319
|
+
let stepsIndent;
|
|
320
|
+
let stepItemIndent;
|
|
321
|
+
for (const line of lines) {
|
|
322
|
+
const indent = leadingSpaces(line);
|
|
323
|
+
const trimmed = line.trim();
|
|
324
|
+
const isTopLevelKey = indent === 0 && /^[\w-]+:/u.test(line);
|
|
325
|
+
if (stepsIndent !== void 0 && trimmed && indent <= stepsIndent) {
|
|
326
|
+
pushBlankLine(output);
|
|
327
|
+
stepsIndent = void 0;
|
|
328
|
+
stepItemIndent = void 0;
|
|
329
|
+
}
|
|
330
|
+
if (isTopLevelKey && output.length > 0) pushBlankLine(output);
|
|
331
|
+
if (stepsIndent !== void 0 && stepItemIndent !== void 0 && indent === stepItemIndent && trimmed.startsWith("- ")) pushBlankLine(output);
|
|
332
|
+
output.push(line);
|
|
333
|
+
if (/^steps:\s*$/u.test(trimmed)) {
|
|
334
|
+
stepsIndent = indent;
|
|
335
|
+
stepItemIndent = void 0;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (stepsIndent !== void 0 && indent > stepsIndent && trimmed.startsWith("- ")) stepItemIndent ??= indent;
|
|
339
|
+
}
|
|
340
|
+
return `${output.join("\n")}\n`;
|
|
341
|
+
}
|
|
342
|
+
function leadingSpaces(value) {
|
|
343
|
+
return value.length - value.trimStart().length;
|
|
344
|
+
}
|
|
345
|
+
function pushBlankLine(lines) {
|
|
346
|
+
if (lines.length > 0 && lines.at(-1) !== "") lines.push("");
|
|
347
|
+
}
|
|
348
|
+
function normalizeWorkflowYamlValue(value) {
|
|
349
|
+
if (Array.isArray(value)) return value.map((item) => normalizeWorkflowYamlValue(item));
|
|
350
|
+
if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalizeWorkflowYamlValue(item)]));
|
|
351
|
+
if (value === "true") return true;
|
|
352
|
+
if (value === "false") return false;
|
|
353
|
+
return value;
|
|
354
|
+
}
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/optimizer.ts
|
|
357
|
+
function optimizeJob(job) {
|
|
358
|
+
const next = { ...job };
|
|
359
|
+
if (isEmptyNeeds(next.needs)) delete next.needs;
|
|
360
|
+
if (staticIfValue(next.if) === true) delete next.if;
|
|
361
|
+
return next;
|
|
362
|
+
}
|
|
363
|
+
function optimizeStep(stepValue) {
|
|
364
|
+
const step = asRecord(stepValue);
|
|
365
|
+
if (!step) return stepValue;
|
|
366
|
+
const ifValue = staticIfValue(step.if);
|
|
367
|
+
if (ifValue === false || ifValue === "") return;
|
|
368
|
+
if (ifValue === true) {
|
|
369
|
+
const next = { ...step };
|
|
370
|
+
delete next.if;
|
|
371
|
+
return next;
|
|
372
|
+
}
|
|
373
|
+
return stepValue;
|
|
374
|
+
}
|
|
375
|
+
function optimizeJobs(jobs) {
|
|
376
|
+
return Object.fromEntries(Object.entries(jobs).map(([jobId, jobValue]) => {
|
|
377
|
+
const job = asRecord(jobValue);
|
|
378
|
+
return [jobId, job ? optimizeJob(job) : jobValue];
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
function staticIfValue(value) {
|
|
382
|
+
if (value === true || value === false || value === "") return value;
|
|
383
|
+
if (typeof value !== "string") return;
|
|
384
|
+
const trimmed = value.trim();
|
|
385
|
+
return staticIfExpression(expressionBody(trimmed) ?? trimmed);
|
|
386
|
+
}
|
|
387
|
+
function isEmptyNeeds(value) {
|
|
388
|
+
return Array.isArray(value) && value.length === 0;
|
|
389
|
+
}
|
|
390
|
+
//#endregion
|
|
391
|
+
//#region src/utils/workflow-parser.ts
|
|
392
|
+
const workflowParserEntry = fileURLToPath(import.meta.resolve("@actions/workflow-parser"));
|
|
393
|
+
const workflowParserDist = path.dirname(workflowParserEntry);
|
|
394
|
+
let actionSchema;
|
|
395
|
+
let workflowSchema;
|
|
396
|
+
function parseWorkflowMap(source, file) {
|
|
397
|
+
return parseTemplateMap(WORKFLOW_ROOT, workflowSchemaForParser(), source, file);
|
|
398
|
+
}
|
|
399
|
+
function parseActionMap(source, file) {
|
|
400
|
+
return parseTemplateMap(ACTION_ROOT, actionSchemaForParser(), source, file);
|
|
401
|
+
}
|
|
402
|
+
function parseTemplateMap(root, schema, source, file) {
|
|
403
|
+
const result = parseTemplate(root, schema, source, file);
|
|
404
|
+
throwOnTemplateErrors(result, file);
|
|
405
|
+
if (!result.value) return {};
|
|
406
|
+
const value = templateTokenToValue(result.value);
|
|
407
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${file} must be a YAML mapping`);
|
|
408
|
+
return value;
|
|
409
|
+
}
|
|
410
|
+
function parseActionTemplate(source, file) {
|
|
411
|
+
return parseTemplate(ACTION_ROOT, actionSchemaForParser(), source, file);
|
|
412
|
+
}
|
|
413
|
+
function parseTemplate(root, schema, source, file) {
|
|
414
|
+
const context = new TemplateContext(new TemplateValidationErrors(), schema, new NoOperationTraceWriter());
|
|
415
|
+
const fileId = context.getFileId(file);
|
|
416
|
+
const reader = new YamlObjectReader(fileId, source);
|
|
417
|
+
if (reader.errors.length > 0) {
|
|
418
|
+
for (const error of reader.errors) context.error(fileId, error.message, error.range);
|
|
419
|
+
return {
|
|
420
|
+
context,
|
|
421
|
+
value: void 0
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
context,
|
|
426
|
+
value: readTemplate(context, root, reader, fileId)
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function throwOnTemplateErrors(result, file) {
|
|
430
|
+
const errors = result.context.errors.getErrors();
|
|
431
|
+
if (errors.length > 0) throw new Error(`${file}: ${errors.map((error) => error.message).join("\n")}`);
|
|
432
|
+
}
|
|
433
|
+
function templateTokenToValue(token) {
|
|
434
|
+
if (isMapping(token)) return Object.fromEntries([...token].map((pair) => [String(templateTokenToValue(pair.key)), templateTokenToValue(pair.value)]));
|
|
435
|
+
if (isSequence(token)) return [...token].map((item) => templateTokenToValue(item));
|
|
436
|
+
if (isString(token) || isNumber(token) || isBoolean(token)) return token.value;
|
|
437
|
+
if (isBasicExpression(token)) return token.toString();
|
|
438
|
+
return token.toJSON();
|
|
439
|
+
}
|
|
440
|
+
function workflowSchemaForParser() {
|
|
441
|
+
workflowSchema ??= loadSchema("workflow-v1.0.min.json");
|
|
442
|
+
return workflowSchema;
|
|
443
|
+
}
|
|
444
|
+
function actionSchemaForParser() {
|
|
445
|
+
actionSchema ??= loadSchema("action-v1.0.min.json");
|
|
446
|
+
return actionSchema;
|
|
447
|
+
}
|
|
448
|
+
function loadSchema(name) {
|
|
449
|
+
const file = path.join(workflowParserDist, name);
|
|
450
|
+
return TemplateSchema.load(new JSONObjectReader(void 0, readFileSync(file, "utf8")));
|
|
451
|
+
}
|
|
452
|
+
//#endregion
|
|
453
|
+
//#region src/utils/fs.ts
|
|
454
|
+
const LOCKFILE_PATH = ".github/workflow.lock.yml";
|
|
455
|
+
const DEFAULT_SOURCE_DIR = ".github/workflows/src";
|
|
456
|
+
const DEFAULT_OUTPUT_DIR = ".github/workflows";
|
|
457
|
+
function resolveCwd(cwd) {
|
|
458
|
+
return cwd ? path.resolve(cwd) : process.cwd();
|
|
459
|
+
}
|
|
460
|
+
async function fileExists(file) {
|
|
461
|
+
try {
|
|
462
|
+
await access(file);
|
|
463
|
+
return true;
|
|
464
|
+
} catch {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
async function readYamlFile(file) {
|
|
469
|
+
return parseYamlMap(await readFile(file, "utf8"), file);
|
|
470
|
+
}
|
|
471
|
+
async function readWorkflowFile(file) {
|
|
472
|
+
return parseWorkflowMap(await readFile(file, "utf8"), file);
|
|
473
|
+
}
|
|
474
|
+
async function writeYamlFile(file, value) {
|
|
475
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
476
|
+
await writeFile(file, stringifyYaml(value), "utf8");
|
|
477
|
+
}
|
|
478
|
+
async function discoverConfig(cwd, overrides = {}) {
|
|
479
|
+
const root = resolveCwd(cwd);
|
|
480
|
+
const configPath = path.join(root, "actionspack.yml");
|
|
481
|
+
let config;
|
|
482
|
+
if (await fileExists(configPath)) {
|
|
483
|
+
const rawConfig = await readYamlFile(configPath);
|
|
484
|
+
config = {
|
|
485
|
+
entries: (Array.isArray(rawConfig.entries) ? rawConfig.entries : []).map((entry) => {
|
|
486
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new TypeError("actionspack.yml entries must be mappings");
|
|
487
|
+
const source = Reflect.get(entry, "source");
|
|
488
|
+
const output = Reflect.get(entry, "output");
|
|
489
|
+
if (typeof source !== "string" || typeof output !== "string") throw new TypeError("actionspack.yml entries require source and output");
|
|
490
|
+
return {
|
|
491
|
+
source,
|
|
492
|
+
output
|
|
493
|
+
};
|
|
494
|
+
}),
|
|
495
|
+
external: normalizeStringList(rawConfig.external)
|
|
496
|
+
};
|
|
497
|
+
} else config = await discoverDefaultConfig(root);
|
|
498
|
+
return {
|
|
499
|
+
...config,
|
|
500
|
+
...normalizeConfigOverrides(overrides)
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
async function discoverDefaultConfig(root) {
|
|
504
|
+
const sourceDir = path.join(root, DEFAULT_SOURCE_DIR);
|
|
505
|
+
if (!await fileExists(sourceDir)) return {
|
|
506
|
+
entries: [],
|
|
507
|
+
external: []
|
|
508
|
+
};
|
|
509
|
+
return {
|
|
510
|
+
external: [],
|
|
511
|
+
entries: (await readdir(sourceDir)).filter((name) => name.endsWith(".yml") || name.endsWith(".yaml")).toSorted().map((name) => ({
|
|
512
|
+
source: path.posix.join(DEFAULT_SOURCE_DIR, name),
|
|
513
|
+
output: path.posix.join(DEFAULT_OUTPUT_DIR, name.replace(/\.ya?ml$/u, ".yml"))
|
|
514
|
+
}))
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
function normalizeConfigOverrides(overrides) {
|
|
518
|
+
return {
|
|
519
|
+
...overrides.entries ? { entries: overrides.entries } : {},
|
|
520
|
+
...overrides.external ? { external: overrides.external } : {}
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
function normalizeStringList(value) {
|
|
524
|
+
if (typeof value === "string") return [value];
|
|
525
|
+
if (!Array.isArray(value)) return [];
|
|
526
|
+
return value.filter((item) => typeof item === "string");
|
|
527
|
+
}
|
|
528
|
+
function readWorkflowEntry(root, entry) {
|
|
529
|
+
return readWorkflowFile(path.join(root, entry.source));
|
|
530
|
+
}
|
|
531
|
+
function emptyLockfile() {
|
|
532
|
+
return {
|
|
533
|
+
lockfileVersion: 1,
|
|
534
|
+
entries: {},
|
|
535
|
+
packages: {}
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
async function readLockfile(cwd) {
|
|
539
|
+
const root = resolveCwd(cwd);
|
|
540
|
+
const file = path.join(root, LOCKFILE_PATH);
|
|
541
|
+
if (!await fileExists(file)) return emptyLockfile();
|
|
542
|
+
const value = await readYamlFile(file);
|
|
543
|
+
return {
|
|
544
|
+
lockfileVersion: 1,
|
|
545
|
+
entries: normalizeRecord(value.entries),
|
|
546
|
+
packages: normalizeRecord(value.packages)
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
async function writeLockfile(cwd, lockfile) {
|
|
550
|
+
await writeYamlFile(path.join(resolveCwd(cwd), LOCKFILE_PATH), lockfile);
|
|
551
|
+
}
|
|
552
|
+
async function writeWorkflow(cwd, output, workflow) {
|
|
553
|
+
const file = path.join(resolveCwd(cwd), output);
|
|
554
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
555
|
+
await writeFile(file, stringifyWorkflowYaml(workflow), "utf8");
|
|
556
|
+
}
|
|
557
|
+
function normalizeRecord(value) {
|
|
558
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
559
|
+
return value;
|
|
560
|
+
}
|
|
561
|
+
//#endregion
|
|
562
|
+
//#region src/utils/github.ts
|
|
563
|
+
var HttpGitHubClient = class {
|
|
564
|
+
#cacheDir;
|
|
565
|
+
#token;
|
|
566
|
+
constructor(token = process.env.GITHUB_TOKEN, cacheDir = path.join(tmpdir(), "actionspack-github-cache")) {
|
|
567
|
+
this.#cacheDir = cacheDir;
|
|
568
|
+
this.#token = token;
|
|
569
|
+
}
|
|
570
|
+
async resolveRef(owner, repo, ref) {
|
|
571
|
+
if (/^[a-f0-9]{40}$/iu.test(ref)) return ref;
|
|
572
|
+
const candidates = [
|
|
573
|
+
`heads/${ref}`,
|
|
574
|
+
`tags/${ref}`,
|
|
575
|
+
ref
|
|
576
|
+
];
|
|
577
|
+
for (const candidate of candidates) {
|
|
578
|
+
const sha = (await this.#request(`/repos/${owner}/${repo}/git/ref/${candidate}`))?.object?.sha;
|
|
579
|
+
if (sha) return sha;
|
|
580
|
+
}
|
|
581
|
+
throw new Error(`Unable to resolve ${owner}/${repo}@${ref}`);
|
|
582
|
+
}
|
|
583
|
+
async readFile(owner, repo, ref, filePath) {
|
|
584
|
+
const cacheFile = this.#cacheFile(owner, repo, ref, filePath);
|
|
585
|
+
const cached = await readFile(cacheFile, "utf8").catch(() => void 0);
|
|
586
|
+
if (cached !== void 0) return cached;
|
|
587
|
+
const response = await this.#request(`/repos/${owner}/${repo}/contents/${encodeURIComponentPath(filePath)}?ref=${ref}`, true);
|
|
588
|
+
if (!response) return;
|
|
589
|
+
if (response.encoding !== "base64" || typeof response.content !== "string") throw new Error(`Unexpected GitHub content response for ${owner}/${repo}/${filePath}@${ref}`);
|
|
590
|
+
const content = Buffer.from(response.content, "base64").toString("utf8");
|
|
591
|
+
await mkdir(path.dirname(cacheFile), { recursive: true });
|
|
592
|
+
await writeFile(cacheFile, content, "utf8");
|
|
593
|
+
return content;
|
|
594
|
+
}
|
|
595
|
+
#cacheFile(owner, repo, ref, filePath) {
|
|
596
|
+
return path.join(this.#cacheDir, owner, repo, ref, ...filePath.split("/"));
|
|
597
|
+
}
|
|
598
|
+
async #request(pathname, allowNotFound = false) {
|
|
599
|
+
const headers = {
|
|
600
|
+
accept: "application/vnd.github+json",
|
|
601
|
+
"user-agent": "actionspack",
|
|
602
|
+
"x-github-api-version": "2022-11-28"
|
|
603
|
+
};
|
|
604
|
+
if (this.#token) headers.authorization = `Bearer ${this.#token}`;
|
|
605
|
+
const response = await fetch(`https://api.github.com${pathname}`, { headers });
|
|
606
|
+
if (response.status === 404 && allowNotFound) return;
|
|
607
|
+
if (!response.ok) throw new Error(`GitHub API request failed: ${response.status} ${response.statusText}`);
|
|
608
|
+
return await response.json();
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
function encodeURIComponentPath(filePath) {
|
|
612
|
+
return filePath.split("/").map(encodeURIComponent).join("/");
|
|
613
|
+
}
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/utils/ref.ts
|
|
616
|
+
const REMOTE_USES_RE = /^[\w.-]+\/[\w.-]+(?:\/[^@\s]+)?@[^@\s]+$/;
|
|
617
|
+
function packageKey(owner, repo, path) {
|
|
618
|
+
return path && path !== "." ? `github:${owner}/${repo}/${path}` : `github:${owner}/${repo}`;
|
|
619
|
+
}
|
|
620
|
+
function isRemoteUses(value) {
|
|
621
|
+
return typeof value === "string" && REMOTE_USES_RE.test(value);
|
|
622
|
+
}
|
|
623
|
+
function isPinnedRemoteUses(value) {
|
|
624
|
+
return isRemoteUses(value) && /@[a-f0-9]{40}$/iu.test(value);
|
|
625
|
+
}
|
|
626
|
+
function parseRemoteUses(value, kind = "action") {
|
|
627
|
+
const atIndex = value.lastIndexOf("@");
|
|
628
|
+
if (atIndex <= 0 || atIndex === value.length - 1) throw new Error(`Malformed remote reference: ${value}`);
|
|
629
|
+
const ref = value.slice(atIndex + 1);
|
|
630
|
+
const parts = value.slice(0, atIndex).split("/");
|
|
631
|
+
if (parts.length < 2 || !parts[0] || !parts[1]) throw new Error(`Malformed remote reference: ${value}`);
|
|
632
|
+
const owner = parts[0];
|
|
633
|
+
const repo = parts[1];
|
|
634
|
+
const path = parts.length > 2 ? parts.slice(2).join("/") : ".";
|
|
635
|
+
const inferredKind = path.startsWith(".github/workflows/") ? "reusable-workflow" : kind;
|
|
636
|
+
return {
|
|
637
|
+
owner,
|
|
638
|
+
repo,
|
|
639
|
+
path,
|
|
640
|
+
ref,
|
|
641
|
+
package: packageKey(owner, repo, path),
|
|
642
|
+
kind: inferredKind
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
function matchesPackageSelector(key, selector) {
|
|
646
|
+
const normalized = selector.startsWith("github:") ? selector : `github:${selector}`;
|
|
647
|
+
return key === normalized || key.startsWith(`${normalized}/`) || key.includes(`:${selector}/`);
|
|
648
|
+
}
|
|
649
|
+
function matchesExternalSelector(remote, selector) {
|
|
650
|
+
const value = selector.replace(/^github:/u, "");
|
|
651
|
+
const fullName = `${remote.owner}/${remote.repo}`;
|
|
652
|
+
const withPath = `${fullName}/${remote.path}`;
|
|
653
|
+
const packageSelector = `github:${value}`;
|
|
654
|
+
return remote.package === selector || remote.package === packageSelector || fullName === value || withPath === value || remote.package.startsWith(`${packageSelector}/`);
|
|
655
|
+
}
|
|
656
|
+
function pinnedUses(remote, resolved) {
|
|
657
|
+
return `${remote.path === "." ? `${remote.owner}/${remote.repo}` : `${remote.owner}/${remote.repo}/${remote.path}`}@${resolved}`;
|
|
658
|
+
}
|
|
659
|
+
//#endregion
|
|
660
|
+
//#region src/scan.ts
|
|
661
|
+
async function scan(options = {}) {
|
|
662
|
+
const cwd = resolveCwd(options.cwd);
|
|
663
|
+
const config = await discoverConfig(cwd, options);
|
|
664
|
+
const previous = await readLockfile(cwd);
|
|
665
|
+
const github = options.github ?? new HttpGitHubClient();
|
|
666
|
+
const lockfile = {
|
|
667
|
+
lockfileVersion: 1,
|
|
668
|
+
entries: {},
|
|
669
|
+
packages: { ...previous.packages }
|
|
670
|
+
};
|
|
671
|
+
const queue = [];
|
|
672
|
+
for (const entry of config.entries) {
|
|
673
|
+
const dependencies = collectWorkflowDependencies(await readWorkflowEntry(cwd, entry), entry.source);
|
|
674
|
+
lockfile.entries[entry.source] = {
|
|
675
|
+
output: entry.output,
|
|
676
|
+
dependencies: dependencies.map(toLockDependency)
|
|
677
|
+
};
|
|
678
|
+
queue.push(...dependencies);
|
|
679
|
+
}
|
|
680
|
+
await resolveQueue(lockfile, queue, previous, github, options.refreshPackages ?? /* @__PURE__ */ new Set(), {
|
|
681
|
+
external: config.external,
|
|
682
|
+
stderr: options.stderr,
|
|
683
|
+
stdout: options.stdout
|
|
684
|
+
});
|
|
685
|
+
lockfile.packages = prunePackages(lockfile);
|
|
686
|
+
await writeLockfile(cwd, lockfile);
|
|
687
|
+
return {
|
|
688
|
+
lockfile,
|
|
689
|
+
entries: config.entries
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
function collectWorkflowDependencies(workflow, source) {
|
|
693
|
+
const dependencies = [];
|
|
694
|
+
const jobs = asRecord(workflow.jobs);
|
|
695
|
+
if (!jobs) return dependencies;
|
|
696
|
+
for (const [jobId, jobValue] of Object.entries(jobs)) {
|
|
697
|
+
const job = asRecord(jobValue);
|
|
698
|
+
if (!job) continue;
|
|
699
|
+
if (typeof job.uses === "string") {
|
|
700
|
+
if (isRemoteUses(job.uses)) dependencies.push(toDependency(job.uses, "reusable-workflow", `${source}#jobs.${jobId}.uses`));
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
dependencies.push(...collectStepDependencies(asArray(job.steps), `${source}#jobs.${jobId}.steps`));
|
|
704
|
+
}
|
|
705
|
+
return uniqueDependencies(dependencies);
|
|
706
|
+
}
|
|
707
|
+
function collectStepDependencies(steps, foundAtPrefix) {
|
|
708
|
+
const dependencies = [];
|
|
709
|
+
for (const [index, stepValue] of steps.entries()) {
|
|
710
|
+
const step = asRecord(stepValue);
|
|
711
|
+
if (!step || typeof step.uses !== "string") continue;
|
|
712
|
+
if (isRemoteUses(step.uses)) dependencies.push(toDependency(step.uses, "action", `${foundAtPrefix}[${index}].uses`));
|
|
713
|
+
}
|
|
714
|
+
return uniqueDependencies(dependencies);
|
|
715
|
+
}
|
|
716
|
+
async function resolveQueue(lockfile, queue, previous, github, refreshPackages, context) {
|
|
717
|
+
const seen = /* @__PURE__ */ new Set();
|
|
718
|
+
const resolvedRefs = /* @__PURE__ */ new Map();
|
|
719
|
+
const warnings = /* @__PURE__ */ new Set();
|
|
720
|
+
while (queue.length > 0) {
|
|
721
|
+
const dependency = queue.shift();
|
|
722
|
+
const shouldRefresh = refreshPackages.has(dependency.package);
|
|
723
|
+
const previousPackage = previous.packages[dependency.package];
|
|
724
|
+
const resolved = previousPackage?.resolved && !shouldRefresh ? previousPackage.resolved : await resolveDependencyRef(dependency, github, context, resolvedRefs);
|
|
725
|
+
const seenKey = `${dependency.package}@${resolved}`;
|
|
726
|
+
if (seen.has(seenKey)) continue;
|
|
727
|
+
const scanned = await scanRemotePackage(dependency.remote, resolved, github, context, warnings);
|
|
728
|
+
lockfile.packages[dependency.package] = {
|
|
729
|
+
...scanned,
|
|
730
|
+
requested: dependency.requested,
|
|
731
|
+
resolved
|
|
732
|
+
};
|
|
733
|
+
seen.add(seenKey);
|
|
734
|
+
queue.push(...scanned.dependencies.map((item) => ({
|
|
735
|
+
...item,
|
|
736
|
+
remote: parseRemoteUsesFromDependency(item)
|
|
737
|
+
})));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
async function scanRemotePackage(remote, resolved, github, context, warnings) {
|
|
741
|
+
if (remote.kind === "reusable-workflow") {
|
|
742
|
+
if (isExternal(remote, context.external)) {
|
|
743
|
+
context.stdout?.write(`External ${remote.owner}/${remote.repo}/${remote.path}@${resolved}\n`);
|
|
744
|
+
return externalPackage(remote, "external-workflow");
|
|
745
|
+
}
|
|
746
|
+
const content = await github.readFile(remote.owner, remote.repo, resolved, remote.path);
|
|
747
|
+
if (!content) throw new Error(`Missing reusable workflow ${remote.owner}/${remote.repo}/${remote.path}@${resolved}`);
|
|
748
|
+
const dependencies = collectWorkflowDependencies(parseWorkflowMap(content, remote.path), `${remote.owner}/${remote.repo}/${remote.path}`);
|
|
749
|
+
return {
|
|
750
|
+
source: "github",
|
|
751
|
+
owner: remote.owner,
|
|
752
|
+
repo: remote.repo,
|
|
753
|
+
path: remote.path,
|
|
754
|
+
type: "reusable-workflow",
|
|
755
|
+
contentDigest: digest(content),
|
|
756
|
+
dependencies: dependencies.map(toLockDependency)
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
if (isExternal(remote, context.external)) {
|
|
760
|
+
context.stdout?.write(`External ${remote.owner}/${remote.repo}/${remote.path}@${resolved}\n`);
|
|
761
|
+
return externalPackage(remote, "external-action");
|
|
762
|
+
}
|
|
763
|
+
const metadata = await readActionMetadata(github, remote, resolved);
|
|
764
|
+
const runs = asRecord(parseActionMap(metadata.content, `${remote.path}/action.yml`).runs);
|
|
765
|
+
const using = typeof runs?.using === "string" ? runs.using.toLowerCase() : void 0;
|
|
766
|
+
if (using !== "composite") {
|
|
767
|
+
warnOnce(context, warnings, `Unsupported action type for ${remote.owner}/${remote.repo}/${remote.path}@${resolved}: ${using ?? "unknown"}; marking external`);
|
|
768
|
+
return {
|
|
769
|
+
...externalPackage(remote, "external-action"),
|
|
770
|
+
contentDigest: digest(metadata.content)
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
const dependencies = collectStepDependencies(asArray(runs?.steps), `${remote.owner}/${remote.repo}/${remote.path}#runs.steps`);
|
|
774
|
+
return {
|
|
775
|
+
source: "github",
|
|
776
|
+
owner: remote.owner,
|
|
777
|
+
repo: remote.repo,
|
|
778
|
+
path: remote.path,
|
|
779
|
+
type: "composite",
|
|
780
|
+
contentDigest: digest(metadata.content),
|
|
781
|
+
dependencies: dependencies.map(toLockDependency)
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function resolveDependencyRef(dependency, github, context, resolvedRefs) {
|
|
785
|
+
const key = `${dependency.package}@${dependency.requested}`;
|
|
786
|
+
const resolved = resolvedRefs.get(key);
|
|
787
|
+
if (resolved) return Promise.resolve(resolved);
|
|
788
|
+
context.stdout?.write(`Resolving ${dependency.remote.owner}/${dependency.remote.repo}${dependency.remote.path === "." ? "" : `/${dependency.remote.path}`}@${dependency.requested}\n`);
|
|
789
|
+
return github.resolveRef(dependency.remote.owner, dependency.remote.repo, dependency.requested).then((value) => {
|
|
790
|
+
resolvedRefs.set(key, value);
|
|
791
|
+
return value;
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
function warnOnce(context, warnings, message) {
|
|
795
|
+
if (warnings.has(message)) return;
|
|
796
|
+
warnings.add(message);
|
|
797
|
+
context.stderr?.write(`${styleText("yellow", `Warning: ${message}`)}\n`);
|
|
798
|
+
}
|
|
799
|
+
function isExternal(remote, selectors) {
|
|
800
|
+
return selectors.some((selector) => matchesExternalSelector(remote, selector));
|
|
801
|
+
}
|
|
802
|
+
function externalPackage(remote, type) {
|
|
803
|
+
return {
|
|
804
|
+
source: "github",
|
|
805
|
+
owner: remote.owner,
|
|
806
|
+
repo: remote.repo,
|
|
807
|
+
path: remote.path,
|
|
808
|
+
type,
|
|
809
|
+
external: true,
|
|
810
|
+
contentDigest: "external",
|
|
811
|
+
dependencies: []
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
async function readActionMetadata(github, remote, resolved) {
|
|
815
|
+
const base = remote.path === "." ? "" : `${remote.path.replace(/\/$/u, "")}/`;
|
|
816
|
+
for (const name of ["action.yml", "action.yaml"]) {
|
|
817
|
+
const metadataPath = `${base}${name}`;
|
|
818
|
+
const content = await github.readFile(remote.owner, remote.repo, resolved, metadataPath);
|
|
819
|
+
if (content) return {
|
|
820
|
+
path: metadataPath,
|
|
821
|
+
content
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
throw new Error(`Missing action metadata for ${remote.owner}/${remote.repo}/${remote.path}@${resolved}`);
|
|
825
|
+
}
|
|
826
|
+
function parseRemoteUsesFromDependency(dependency) {
|
|
827
|
+
const match = /^github:([^/]+)\/([^/]+)(?:\/(.+))?$/u.exec(dependency.package);
|
|
828
|
+
if (!match) throw new Error(`Unsupported package key: ${dependency.package}`);
|
|
829
|
+
const [, owner, repo, packagePath] = match;
|
|
830
|
+
const remotePath = packagePath ?? ".";
|
|
831
|
+
const kind = remotePath.startsWith(".github/workflows/") ? "reusable-workflow" : "action";
|
|
832
|
+
return {
|
|
833
|
+
owner,
|
|
834
|
+
repo,
|
|
835
|
+
path: remotePath,
|
|
836
|
+
ref: dependency.requested,
|
|
837
|
+
package: dependency.package,
|
|
838
|
+
kind
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
function toDependency(value, kind, foundAt) {
|
|
842
|
+
const remote = parseRemoteUses(value, kind);
|
|
843
|
+
return {
|
|
844
|
+
package: remote.package,
|
|
845
|
+
requested: remote.ref,
|
|
846
|
+
foundAt,
|
|
847
|
+
remote
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function toLockDependency(dependency) {
|
|
851
|
+
return {
|
|
852
|
+
package: dependency.package,
|
|
853
|
+
requested: dependency.requested,
|
|
854
|
+
...dependency.resolved ? { resolved: dependency.resolved } : {},
|
|
855
|
+
...dependency.foundAt ? { foundAt: dependency.foundAt } : {}
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
function uniqueDependencies(dependencies) {
|
|
859
|
+
const seen = /* @__PURE__ */ new Set();
|
|
860
|
+
return dependencies.filter((dependency) => {
|
|
861
|
+
const key = `${dependency.package}@${dependency.requested}`;
|
|
862
|
+
if (seen.has(key)) return false;
|
|
863
|
+
seen.add(key);
|
|
864
|
+
return true;
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
function prunePackages(lockfile) {
|
|
868
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
869
|
+
const visit = (dependency) => {
|
|
870
|
+
if (reachable.has(dependency.package)) return;
|
|
871
|
+
const item = lockfile.packages[dependency.package];
|
|
872
|
+
if (!item) return;
|
|
873
|
+
reachable.add(dependency.package);
|
|
874
|
+
item.dependencies.forEach(visit);
|
|
875
|
+
};
|
|
876
|
+
for (const entry of Object.values(lockfile.entries)) entry.dependencies.forEach(visit);
|
|
877
|
+
return Object.fromEntries([...reachable].toSorted().map((key) => [key, lockfile.packages[key]]));
|
|
878
|
+
}
|
|
879
|
+
function digest(content) {
|
|
880
|
+
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
881
|
+
}
|
|
882
|
+
//#endregion
|
|
883
|
+
//#region src/utils/substitute.ts
|
|
884
|
+
function substituteValue(value, values, key) {
|
|
885
|
+
if (typeof value === "string") {
|
|
886
|
+
const next = substituteString(value, values);
|
|
887
|
+
return key === "run" && typeof next === "string" ? next.trim() : next;
|
|
888
|
+
}
|
|
889
|
+
if (Array.isArray(value)) return value.map((item) => substituteValue(item, values));
|
|
890
|
+
if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteValue(item, values, key)]));
|
|
891
|
+
return value;
|
|
892
|
+
}
|
|
893
|
+
function substituteString(value, values) {
|
|
894
|
+
if (!value.includes("${{")) return value;
|
|
895
|
+
const expression = parseCompositeRunExpression(value);
|
|
896
|
+
if (!expression) return value;
|
|
897
|
+
try {
|
|
898
|
+
const expr = parseExpression(expression);
|
|
899
|
+
const replacement = directReplacement(expr, values);
|
|
900
|
+
if (replacement !== void 0) return replacement;
|
|
901
|
+
const simplified = printExpression(expr, values);
|
|
902
|
+
const staticValue = staticExpression(parseExpression(simplified), values);
|
|
903
|
+
return staticValue ? staticValue.value : `\${{ ${simplified} }}`;
|
|
904
|
+
} catch {
|
|
905
|
+
return value;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
function normalizeInputValue(value) {
|
|
909
|
+
if (value === void 0 || value === null) return "";
|
|
910
|
+
return value;
|
|
911
|
+
}
|
|
912
|
+
function parseCompositeRunExpression(value) {
|
|
913
|
+
const expression = expressionBody(value.trim());
|
|
914
|
+
if (expression !== void 0) return expression;
|
|
915
|
+
const result = parseActionTemplate(`runs:
|
|
916
|
+
using: composite
|
|
917
|
+
steps:
|
|
918
|
+
- run: ${JSON.stringify(value)}
|
|
919
|
+
`, "actionspack-substitute/action.yml");
|
|
920
|
+
if (!result.value) return;
|
|
921
|
+
const token = findToken(result.value, [
|
|
922
|
+
"runs",
|
|
923
|
+
"steps",
|
|
924
|
+
"0",
|
|
925
|
+
"run"
|
|
926
|
+
]);
|
|
927
|
+
return token && isBasicExpression(token) ? token.expression : void 0;
|
|
928
|
+
}
|
|
929
|
+
function findToken(token, path) {
|
|
930
|
+
if (path.length === 0) return token;
|
|
931
|
+
const [head, ...rest] = path;
|
|
932
|
+
if (isMapping(token)) {
|
|
933
|
+
const next = token.find(head);
|
|
934
|
+
return next ? findToken(next, rest) : void 0;
|
|
935
|
+
}
|
|
936
|
+
if (isSequence(token)) {
|
|
937
|
+
const index = Number(head);
|
|
938
|
+
return Number.isInteger(index) ? findToken(token.get(index), rest) : void 0;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
function directReplacement(expr, values) {
|
|
942
|
+
return valueForIndexAccess(expr, values);
|
|
943
|
+
}
|
|
944
|
+
//#endregion
|
|
945
|
+
//#region src/pack.ts
|
|
946
|
+
async function pack(options = {}) {
|
|
947
|
+
if ((await discoverConfig(resolveCwd(options.cwd), options)).entries.length === 0) throw new Error("No workflow source files found in .github/workflows/src");
|
|
948
|
+
return packScanned(await scan(options), options);
|
|
949
|
+
}
|
|
950
|
+
async function packScanned(scanResult, options = {}) {
|
|
951
|
+
const cwd = resolveCwd(options.cwd);
|
|
952
|
+
const github = options.github ?? new HttpGitHubClient();
|
|
953
|
+
for (const entry of scanResult.entries) {
|
|
954
|
+
const packed = await packWorkflow(await readWorkflowEntry(cwd, entry), scanResult.lockfile, github);
|
|
955
|
+
assertNoRemoteUses(packed, entry.output);
|
|
956
|
+
await writeWorkflow(cwd, entry.output, packed);
|
|
957
|
+
}
|
|
958
|
+
options.stdout?.write(`Packed ${scanResult.entries.length} workflow${scanResult.entries.length === 1 ? "" : "s"}\n`);
|
|
959
|
+
return scanResult;
|
|
960
|
+
}
|
|
961
|
+
async function verify(options = {}) {
|
|
962
|
+
const cwd = resolveCwd(options.cwd);
|
|
963
|
+
const config = await discoverConfig(cwd, options);
|
|
964
|
+
const lockfile = await readLockfile(cwd);
|
|
965
|
+
const github = options.github ?? new HttpGitHubClient();
|
|
966
|
+
for (const entry of config.entries) {
|
|
967
|
+
const expected = await packWorkflow(await readWorkflowEntry(cwd, entry), lockfile, github);
|
|
968
|
+
assertNoRemoteUses(expected, entry.output);
|
|
969
|
+
if (await readFile(path.join(cwd, entry.output), "utf8").catch(() => {
|
|
970
|
+
throw new Error(`Missing generated workflow: ${entry.output}`);
|
|
971
|
+
}) !== stringifyWorkflowYaml(expected)) throw new Error(`Generated workflow is stale: ${entry.output}`);
|
|
972
|
+
}
|
|
973
|
+
return {
|
|
974
|
+
lockfile,
|
|
975
|
+
entries: config.entries
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
async function packWorkflow(workflow, lockfile, github) {
|
|
979
|
+
const packed = substituteValue(structuredClone(workflow), {});
|
|
980
|
+
const jobs = asRecord(packed.jobs);
|
|
981
|
+
if (!jobs) return packed;
|
|
982
|
+
const nextJobs = {};
|
|
983
|
+
const needsReplacements = {};
|
|
984
|
+
for (const [jobId, jobValue] of Object.entries(jobs)) {
|
|
985
|
+
const job = asRecord(jobValue);
|
|
986
|
+
if (!job) {
|
|
987
|
+
nextJobs[jobId] = jobValue;
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
if (typeof job.uses === "string") {
|
|
991
|
+
if (!isRemoteUses(job.uses)) throw new Error(`Local reusable workflows are not transformed yet: jobs.${jobId}.uses`);
|
|
992
|
+
const remote = parseRemoteUses(job.uses, "reusable-workflow");
|
|
993
|
+
const item = requirePackage(lockfile, remote);
|
|
994
|
+
if (item.external) {
|
|
995
|
+
nextJobs[jobId] = {
|
|
996
|
+
...job,
|
|
997
|
+
uses: pinnedUses(remote, item.resolved)
|
|
998
|
+
};
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
const inlined = await inlineReusableWorkflow(jobId, job, lockfile, github);
|
|
1002
|
+
Object.assign(nextJobs, inlined.jobs);
|
|
1003
|
+
needsReplacements[jobId] = inlined.jobIds;
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
nextJobs[jobId] = await packJob(job, lockfile, github);
|
|
1007
|
+
}
|
|
1008
|
+
packed.jobs = optimizeJobs(rewritePackedNeeds(nextJobs, needsReplacements));
|
|
1009
|
+
return substituteValue(packed, {});
|
|
1010
|
+
}
|
|
1011
|
+
async function packJob(job, lockfile, github) {
|
|
1012
|
+
const next = { ...job };
|
|
1013
|
+
next.steps = await packSteps(asArray(job.steps), lockfile, github);
|
|
1014
|
+
return optimizeJob(next);
|
|
1015
|
+
}
|
|
1016
|
+
async function packSteps(steps, lockfile, github) {
|
|
1017
|
+
const packed = [];
|
|
1018
|
+
for (const stepValue of steps) {
|
|
1019
|
+
const step = asRecord(stepValue);
|
|
1020
|
+
if (!step || typeof step.uses !== "string") {
|
|
1021
|
+
const next = optimizeStep(substituteValue(stepValue, {}));
|
|
1022
|
+
if (next !== void 0) packed.push(next);
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
if (!isRemoteUses(step.uses)) {
|
|
1026
|
+
const next = optimizeStep(stepValue);
|
|
1027
|
+
if (next !== void 0) packed.push(next);
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
packed.push(...await inlineCompositeStep(step, lockfile, github));
|
|
1031
|
+
}
|
|
1032
|
+
return packed;
|
|
1033
|
+
}
|
|
1034
|
+
async function inlineCompositeStep(callerStep, lockfile, github) {
|
|
1035
|
+
const remote = parseRemoteUses(String(callerStep.uses), "action");
|
|
1036
|
+
const item = requirePackage(lockfile, remote);
|
|
1037
|
+
if (item.type !== "composite") {
|
|
1038
|
+
if (item.external) return [{
|
|
1039
|
+
...callerStep,
|
|
1040
|
+
uses: pinnedUses(remote, item.resolved)
|
|
1041
|
+
}];
|
|
1042
|
+
throw new Error(`Expected composite action for ${callerStep.uses}`);
|
|
1043
|
+
}
|
|
1044
|
+
const metadata = await readActionMetadata(github, item, item.resolved);
|
|
1045
|
+
const action = parseActionMap(metadata.content, metadata.path);
|
|
1046
|
+
if (asRecord(action.outputs) && typeof callerStep.id === "string") throw new Error(`Composite action outputs are not supported for step id ${callerStep.id}`);
|
|
1047
|
+
const values = collectActionInputValues(action, callerStep, String(callerStep.uses));
|
|
1048
|
+
return (await packSteps(substituteValue(asArray(asRecord(action.runs).steps), values), lockfile, github)).map((stepValue, index) => optimizeStep(applyCallerStepFields(stepValue, callerStep, index))).filter((stepValue) => stepValue !== void 0);
|
|
1049
|
+
}
|
|
1050
|
+
async function inlineReusableWorkflow(callerJobId, callerJob, lockfile, github) {
|
|
1051
|
+
const item = requirePackage(lockfile, parseRemoteUses(String(callerJob.uses), "reusable-workflow"));
|
|
1052
|
+
if (item.type !== "reusable-workflow") throw new Error(`Expected reusable workflow for ${callerJob.uses}`);
|
|
1053
|
+
if (callerJob.secrets === "inherit") throw new Error(`secrets: inherit is not supported for ${callerJobId}`);
|
|
1054
|
+
rejectUnsupportedReusableCallerKeys(callerJob, callerJobId);
|
|
1055
|
+
const content = await github.readFile(item.owner, item.repo, item.resolved, item.path);
|
|
1056
|
+
if (!content) throw new Error(`Missing reusable workflow ${item.owner}/${item.repo}/${item.path}@${item.resolved}`);
|
|
1057
|
+
const workflow = parseWorkflowMap(content, item.path);
|
|
1058
|
+
const call = workflowCallConfig(workflow);
|
|
1059
|
+
if (!call) throw new Error(`Reusable workflow ${String(callerJob.uses)} must use workflow_call`);
|
|
1060
|
+
if (asRecord(call.outputs)) throw new Error(`Reusable workflow outputs are not supported for ${callerJobId}`);
|
|
1061
|
+
const values = collectReusableValues(call, callerJob, callerJobId);
|
|
1062
|
+
const remoteJobs = asRecord(workflow.jobs);
|
|
1063
|
+
if (!remoteJobs) throw new Error(`Reusable workflow has no jobs: ${String(callerJob.uses)}`);
|
|
1064
|
+
const copied = {};
|
|
1065
|
+
const copiedJobIds = Object.keys(remoteJobs).map((remoteJobId) => `${callerJobId}-${remoteJobId}`);
|
|
1066
|
+
for (const [remoteJobId, remoteJobValue] of Object.entries(remoteJobs)) {
|
|
1067
|
+
const remoteJob = asRecord(remoteJobValue);
|
|
1068
|
+
if (!remoteJob) throw new Error(`Reusable workflow job must be a mapping: ${remoteJobId}`);
|
|
1069
|
+
if (remoteJob.uses) throw new Error(`Nested reusable workflows are not supported: ${callerJobId}-${remoteJobId}`);
|
|
1070
|
+
const transformed = await packJob(substituteValue(remoteJob, values), lockfile, github);
|
|
1071
|
+
transformed.needs = rewriteReusableNeeds(remoteJob.needs, callerJob.needs, callerJobId);
|
|
1072
|
+
copied[`${callerJobId}-${remoteJobId}`] = optimizeJob(transformed);
|
|
1073
|
+
}
|
|
1074
|
+
return {
|
|
1075
|
+
jobIds: copiedJobIds,
|
|
1076
|
+
jobs: copied
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
function collectActionInputValues(action, callerStep, uses) {
|
|
1080
|
+
const values = {};
|
|
1081
|
+
const supplied = asRecord(callerStep.with) ?? {};
|
|
1082
|
+
const inputs = asRecord(action.inputs) ?? {};
|
|
1083
|
+
for (const [name, inputValue] of Object.entries(inputs)) {
|
|
1084
|
+
const input = asRecord(inputValue) ?? {};
|
|
1085
|
+
const suppliedValue = supplied[name];
|
|
1086
|
+
const defaultValue = input.default;
|
|
1087
|
+
const required = input.required === true || input.required === "true";
|
|
1088
|
+
if (suppliedValue === void 0 && defaultValue === void 0 && required) throw new Error(`Missing required input ${name} for ${uses}`);
|
|
1089
|
+
values[`inputs.${name}`] = normalizeInputValue(suppliedValue ?? defaultValue);
|
|
1090
|
+
}
|
|
1091
|
+
for (const [name, value] of Object.entries(supplied)) values[`inputs.${name}`] = normalizeInputValue(value);
|
|
1092
|
+
return values;
|
|
1093
|
+
}
|
|
1094
|
+
function collectReusableValues(call, callerJob, jobId) {
|
|
1095
|
+
const values = {};
|
|
1096
|
+
const supplied = asRecord(callerJob.with) ?? {};
|
|
1097
|
+
const inputs = asRecord(call.inputs) ?? {};
|
|
1098
|
+
for (const [name, inputValue] of Object.entries(inputs)) {
|
|
1099
|
+
const input = asRecord(inputValue) ?? {};
|
|
1100
|
+
const suppliedValue = supplied[name];
|
|
1101
|
+
const defaultValue = input.default;
|
|
1102
|
+
const required = input.required === true || input.required === "true";
|
|
1103
|
+
if (suppliedValue === void 0 && defaultValue === void 0 && required) throw new Error(`Missing required workflow input ${name} for ${jobId}`);
|
|
1104
|
+
values[`inputs.${name}`] = normalizeInputValue(suppliedValue ?? defaultValue);
|
|
1105
|
+
}
|
|
1106
|
+
for (const [name, value] of Object.entries(supplied)) values[`inputs.${name}`] = normalizeInputValue(value);
|
|
1107
|
+
const secrets = asRecord(callerJob.secrets) ?? {};
|
|
1108
|
+
const declaredSecrets = asRecord(call.secrets) ?? {};
|
|
1109
|
+
for (const name of Object.keys(declaredSecrets)) {
|
|
1110
|
+
if (secrets[name] === void 0) {
|
|
1111
|
+
const declaration = asRecord(declaredSecrets[name]);
|
|
1112
|
+
if (declaration?.required === true || declaration?.required === "true") throw new Error(`Missing required workflow secret ${name} for ${jobId}`);
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
values[`secrets.${name}`] = normalizeInputValue(secrets[name]);
|
|
1116
|
+
}
|
|
1117
|
+
return values;
|
|
1118
|
+
}
|
|
1119
|
+
function applyCallerStepFields(stepValue, callerStep, index) {
|
|
1120
|
+
const step = asRecord(stepValue);
|
|
1121
|
+
if (!step) return stepValue;
|
|
1122
|
+
const next = { ...step };
|
|
1123
|
+
if (index === 0 && typeof callerStep.name === "string" && typeof next.name !== "string") next.name = callerStep.name;
|
|
1124
|
+
if (callerStep.if !== void 0) next.if = next.if === void 0 ? callerStep.if : `(${String(callerStep.if)}) && (${String(next.if)})`;
|
|
1125
|
+
if (asRecord(callerStep.env)) next.env = {
|
|
1126
|
+
...asRecord(callerStep.env),
|
|
1127
|
+
...asRecord(next.env)
|
|
1128
|
+
};
|
|
1129
|
+
return next;
|
|
1130
|
+
}
|
|
1131
|
+
function rewriteReusableNeeds(remoteNeeds, callerNeeds, callerJobId) {
|
|
1132
|
+
const rewrite = (value) => `${callerJobId}-${value}`;
|
|
1133
|
+
const rewrittenRemoteNeeds = typeof remoteNeeds === "string" ? rewrite(remoteNeeds) : Array.isArray(remoteNeeds) ? remoteNeeds.map((item) => rewrite(String(item))) : [];
|
|
1134
|
+
const needs = [...typeof callerNeeds === "string" ? [callerNeeds] : Array.isArray(callerNeeds) ? callerNeeds : [], ...rewrittenRemoteNeeds];
|
|
1135
|
+
return needs.length > 0 ? needs : void 0;
|
|
1136
|
+
}
|
|
1137
|
+
function rewritePackedNeeds(jobs, replacements) {
|
|
1138
|
+
if (Object.keys(replacements).length === 0) return jobs;
|
|
1139
|
+
return Object.fromEntries(Object.entries(jobs).map(([jobId, jobValue]) => {
|
|
1140
|
+
const job = asRecord(jobValue);
|
|
1141
|
+
if (!job) return [jobId, jobValue];
|
|
1142
|
+
return [jobId, optimizeJob({
|
|
1143
|
+
...job,
|
|
1144
|
+
needs: rewriteNeedsValue(job.needs, replacements)
|
|
1145
|
+
})];
|
|
1146
|
+
}));
|
|
1147
|
+
}
|
|
1148
|
+
function rewriteNeedsValue(value, replacements) {
|
|
1149
|
+
if (value === void 0) return;
|
|
1150
|
+
const originalIsString = typeof value === "string";
|
|
1151
|
+
const rewritten = (originalIsString ? [value] : Array.isArray(value) ? value.map(String) : []).flatMap((need) => replacements[need] ?? [need]);
|
|
1152
|
+
const unique = [...new Set(rewritten)];
|
|
1153
|
+
if (unique.length === 0) return;
|
|
1154
|
+
return originalIsString && unique.length === 1 ? unique[0] : unique;
|
|
1155
|
+
}
|
|
1156
|
+
function workflowCallConfig(workflow) {
|
|
1157
|
+
const on = Reflect.get(workflow, "on");
|
|
1158
|
+
if (on === "workflow_call") return {};
|
|
1159
|
+
if (Array.isArray(on) && on.includes("workflow_call")) return {};
|
|
1160
|
+
const onMap = asRecord(on);
|
|
1161
|
+
if (!onMap) return;
|
|
1162
|
+
const call = onMap.workflow_call;
|
|
1163
|
+
if (call === null) return {};
|
|
1164
|
+
return asRecord(call) ?? {};
|
|
1165
|
+
}
|
|
1166
|
+
function rejectUnsupportedReusableCallerKeys(callerJob, jobId) {
|
|
1167
|
+
const allowed = new Set([
|
|
1168
|
+
"uses",
|
|
1169
|
+
"with",
|
|
1170
|
+
"secrets",
|
|
1171
|
+
"needs",
|
|
1172
|
+
"if",
|
|
1173
|
+
"permissions",
|
|
1174
|
+
"name"
|
|
1175
|
+
]);
|
|
1176
|
+
for (const key of Object.keys(callerJob)) if (!allowed.has(key)) throw new Error(`Unsupported reusable workflow caller key jobs.${jobId}.${key}`);
|
|
1177
|
+
}
|
|
1178
|
+
function requirePackage(lockfile, remote) {
|
|
1179
|
+
const item = lockfile.packages[remote.package];
|
|
1180
|
+
if (!item) throw new Error(`Missing lockfile package for ${remote.package}`);
|
|
1181
|
+
return item;
|
|
1182
|
+
}
|
|
1183
|
+
function assertNoRemoteUses(value, file = "workflow") {
|
|
1184
|
+
const visit = (item, trail) => {
|
|
1185
|
+
if (Array.isArray(item)) {
|
|
1186
|
+
item.forEach((child, index) => visit(child, `${trail}[${index}]`));
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const record = asRecord(item);
|
|
1190
|
+
if (!record) return;
|
|
1191
|
+
if (isRemoteUses(record.uses) && !isPinnedRemoteUses(record.uses)) throw new Error(`Packed workflow contains remote action reference at ${file}${trail}.uses: ${record.uses}`);
|
|
1192
|
+
for (const [key, child] of Object.entries(record)) visit(child, `${trail}.${key}`);
|
|
1193
|
+
};
|
|
1194
|
+
visit(value, "");
|
|
1195
|
+
}
|
|
1196
|
+
//#endregion
|
|
1197
|
+
//#region src/commands.ts
|
|
1198
|
+
const execFileAsync = promisify(execFile);
|
|
1199
|
+
async function update(options = {}) {
|
|
1200
|
+
const cwd = resolveCwd(options.cwd);
|
|
1201
|
+
const refreshPackages = selectRefreshPackages(await readLockfile(cwd), options.packageName);
|
|
1202
|
+
const scanResult = await scan({
|
|
1203
|
+
...options,
|
|
1204
|
+
cwd,
|
|
1205
|
+
refreshPackages
|
|
1206
|
+
});
|
|
1207
|
+
if (!options.lockfileOnly) await packScanned(scanResult, options);
|
|
1208
|
+
}
|
|
1209
|
+
async function tree(options = {}) {
|
|
1210
|
+
const lockfile = await readLockfile(options.cwd);
|
|
1211
|
+
const lines = [];
|
|
1212
|
+
for (const [source, entry] of Object.entries(lockfile.entries)) {
|
|
1213
|
+
lines.push(source);
|
|
1214
|
+
appendDependencyTree(lines, lockfile, entry.dependencies, "");
|
|
1215
|
+
}
|
|
1216
|
+
const output = `${lines.join("\n")}\n`;
|
|
1217
|
+
options.stdout?.write(output);
|
|
1218
|
+
return output;
|
|
1219
|
+
}
|
|
1220
|
+
async function why(packageName, options = {}) {
|
|
1221
|
+
const lockfile = await readLockfile(options.cwd);
|
|
1222
|
+
const matches = new Set(Object.keys(lockfile.packages).filter((key) => matchesPackageSelector(key, packageName)));
|
|
1223
|
+
if (matches.size === 0) throw new Error(`Package is not in the lockfile: ${packageName}`);
|
|
1224
|
+
const paths = [];
|
|
1225
|
+
for (const [source, entry] of Object.entries(lockfile.entries)) for (const dependency of entry.dependencies) collectWhyPaths(lockfile, dependency, matches, [source], paths);
|
|
1226
|
+
const output = paths.length === 0 ? `${packageName} is not reachable\n` : `${packageName} is used by:\n\n${paths.map(formatPath).join("\n\n")}\n`;
|
|
1227
|
+
options.stdout?.write(output);
|
|
1228
|
+
return output;
|
|
1229
|
+
}
|
|
1230
|
+
async function diff(options = {}) {
|
|
1231
|
+
const cwd = resolveCwd(options.cwd);
|
|
1232
|
+
const current = await readLockfile(cwd);
|
|
1233
|
+
const result = diffLockfiles(await readHeadLockfile(cwd), current);
|
|
1234
|
+
if (options.json) {
|
|
1235
|
+
options.stdout?.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1236
|
+
return result;
|
|
1237
|
+
}
|
|
1238
|
+
const output = formatDiff(result);
|
|
1239
|
+
options.stdout?.write(output);
|
|
1240
|
+
return output;
|
|
1241
|
+
}
|
|
1242
|
+
function selectRefreshPackages(lockfile, selector) {
|
|
1243
|
+
const keys = Object.keys(lockfile.packages);
|
|
1244
|
+
if (!selector) return new Set(keys);
|
|
1245
|
+
const selected = keys.filter((key) => matchesPackageSelector(key, selector));
|
|
1246
|
+
if (selected.length === 0) throw new Error(`Package is not in the lockfile: ${selector}`);
|
|
1247
|
+
return new Set(selected);
|
|
1248
|
+
}
|
|
1249
|
+
function appendDependencyTree(lines, lockfile, dependencies, prefix) {
|
|
1250
|
+
dependencies.forEach((dependency, index) => {
|
|
1251
|
+
const last = index === dependencies.length - 1;
|
|
1252
|
+
const item = lockfile.packages[dependency.package];
|
|
1253
|
+
const label = item ? `${item.owner}/${item.repo}${item.path === "." ? "" : `/${item.path}`}@${item.requested} (${item.resolved})` : `${dependency.package}@${dependency.requested}`;
|
|
1254
|
+
lines.push(`${prefix}${last ? "└─" : "├─"} ${label}`);
|
|
1255
|
+
if (item) appendDependencyTree(lines, lockfile, item.dependencies, `${prefix}${last ? " " : "│ "}`);
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
function collectWhyPaths(lockfile, dependency, matches, current, paths) {
|
|
1259
|
+
const item = lockfile.packages[dependency.package];
|
|
1260
|
+
const label = item ? `${item.owner}/${item.repo}${item.path === "." ? "" : `/${item.path}`}` : dependency.package;
|
|
1261
|
+
const next = [...current, label];
|
|
1262
|
+
if (matches.has(dependency.package)) paths.push(next);
|
|
1263
|
+
item?.dependencies.forEach((child) => collectWhyPaths(lockfile, child, matches, next, paths));
|
|
1264
|
+
}
|
|
1265
|
+
function formatPath(path) {
|
|
1266
|
+
return path.map((item, index) => `${" ".repeat(index)}${index === 0 ? item : `└─ ${item}`}`).join("\n");
|
|
1267
|
+
}
|
|
1268
|
+
async function readHeadLockfile(cwd) {
|
|
1269
|
+
try {
|
|
1270
|
+
const { stdout } = await execFileAsync("git", ["show", `HEAD:${LOCKFILE_PATH}`], { cwd });
|
|
1271
|
+
const { parse } = await import("yaml");
|
|
1272
|
+
return parse(stdout) ?? {
|
|
1273
|
+
lockfileVersion: 1,
|
|
1274
|
+
entries: {},
|
|
1275
|
+
packages: {}
|
|
1276
|
+
};
|
|
1277
|
+
} catch {
|
|
1278
|
+
return {
|
|
1279
|
+
lockfileVersion: 1,
|
|
1280
|
+
entries: {},
|
|
1281
|
+
packages: {}
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
function diffLockfiles(previous, current) {
|
|
1286
|
+
const previousKeys = new Set(Object.keys(previous.packages));
|
|
1287
|
+
const currentKeys = new Set(Object.keys(current.packages));
|
|
1288
|
+
return {
|
|
1289
|
+
added: [...currentKeys].filter((key) => !previousKeys.has(key)).toSorted(),
|
|
1290
|
+
removed: [...previousKeys].filter((key) => !currentKeys.has(key)).toSorted(),
|
|
1291
|
+
changed: [...currentKeys].filter((key) => previousKeys.has(key)).flatMap((key) => {
|
|
1292
|
+
const oldPackage = previous.packages[key];
|
|
1293
|
+
const newPackage = current.packages[key];
|
|
1294
|
+
const dependencyChanged = stringifyYaml(oldPackage.dependencies) !== stringifyYaml(newPackage.dependencies);
|
|
1295
|
+
if (oldPackage.resolved === newPackage.resolved && !dependencyChanged) return [];
|
|
1296
|
+
return [{
|
|
1297
|
+
package: key,
|
|
1298
|
+
oldResolved: oldPackage.resolved,
|
|
1299
|
+
newResolved: newPackage.resolved,
|
|
1300
|
+
dependencyChanged
|
|
1301
|
+
}];
|
|
1302
|
+
}).toSorted((a, b) => a.package.localeCompare(b.package))
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
function formatDiff(result) {
|
|
1306
|
+
const lines = [];
|
|
1307
|
+
for (const item of result.changed) {
|
|
1308
|
+
lines.push(item.package, `old: ${item.oldResolved}`, `new: ${item.newResolved}`);
|
|
1309
|
+
if (item.dependencyChanged) lines.push("transitive changes: changed");
|
|
1310
|
+
lines.push("");
|
|
1311
|
+
}
|
|
1312
|
+
if (result.added.length > 0) lines.push("added:", ...result.added.map((item) => `- ${item}`), "");
|
|
1313
|
+
if (result.removed.length > 0) lines.push("removed:", ...result.removed.map((item) => `- ${item}`), "");
|
|
1314
|
+
if (lines.length === 0) return "No lockfile changes\n";
|
|
1315
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
1316
|
+
}
|
|
1317
|
+
//#endregion
|
|
1318
|
+
export { why as a, packWorkflow as c, collectWorkflowDependencies as d, scan as f, update as i, verify as l, diffLockfiles as n, assertNoRemoteUses as o, tree as r, pack as s, diff as t, collectStepDependencies as u };
|