ci-cost-diff-action 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/CHANGELOG.md +92 -0
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/SECURITY.md +25 -0
- package/action.yml +100 -0
- package/bin/ci-cost-diff.js +297 -0
- package/docs/ARCHITECTURE.md +81 -0
- package/docs/RATE_MODEL.md +103 -0
- package/examples/baseline-jobs.json +22 -0
- package/examples/current-jobs.json +22 -0
- package/package.json +54 -0
- package/src/action.js +533 -0
- package/src/comments.js +78 -0
- package/src/cost.js +603 -0
- package/src/github.js +670 -0
- package/src/inputs.js +187 -0
- package/src/jobs.js +40 -0
- package/src/rates.js +841 -0
- package/src/report.js +258 -0
package/src/rates.js
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolved runner billing information.
|
|
3
|
+
* @typedef {object} RateInfo
|
|
4
|
+
* @property {number|null} rate USD-per-minute rate, or null when unknown.
|
|
5
|
+
* @property {string} sku Billing SKU, normalized override key, or `unknown`.
|
|
6
|
+
* @property {"override"|"default"|"self-hosted"|"unknown"} source How the rate was resolved.
|
|
7
|
+
* @property {string} [warning] Warning for ambiguous default-rate fallback.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* User-supplied rate overrides keyed by runner label, runner name/group, or SKU.
|
|
12
|
+
* @typedef {Record<string, number|string>|Map<string, number|string>} RateOverrides
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default GitHub-hosted runner prices in USD per rounded billable minute.
|
|
17
|
+
* Keys are normalized billing SKUs and common aliases accepted by the resolver.
|
|
18
|
+
* @type {Readonly<Record<string, number>>}
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_RATES_USD_PER_MINUTE = Object.freeze({
|
|
21
|
+
actions_linux_slim: 0.002,
|
|
22
|
+
actions_linux: 0.006,
|
|
23
|
+
actions_linux_arm: 0.005,
|
|
24
|
+
actions_windows: 0.010,
|
|
25
|
+
actions_windows_arm: 0.010,
|
|
26
|
+
actions_macos: 0.062,
|
|
27
|
+
linux_slim: 0.002,
|
|
28
|
+
linux: 0.006,
|
|
29
|
+
linux_arm: 0.005,
|
|
30
|
+
windows: 0.010,
|
|
31
|
+
windows_arm: 0.010,
|
|
32
|
+
macos: 0.062,
|
|
33
|
+
linux_2_core_advanced: 0.006,
|
|
34
|
+
linux_4_core: 0.012,
|
|
35
|
+
linux_8_core: 0.022,
|
|
36
|
+
linux_16_core: 0.042,
|
|
37
|
+
linux_32_core: 0.082,
|
|
38
|
+
linux_64_core: 0.162,
|
|
39
|
+
linux_96_core: 0.252,
|
|
40
|
+
windows_4_core: 0.022,
|
|
41
|
+
windows_8_core: 0.042,
|
|
42
|
+
windows_16_core: 0.082,
|
|
43
|
+
windows_32_core: 0.162,
|
|
44
|
+
windows_64_core: 0.322,
|
|
45
|
+
windows_96_core: 0.552,
|
|
46
|
+
macos_l: 0.077,
|
|
47
|
+
linux_2_core_arm: 0.005,
|
|
48
|
+
linux_4_core_arm: 0.008,
|
|
49
|
+
linux_8_core_arm: 0.014,
|
|
50
|
+
linux_16_core_arm: 0.026,
|
|
51
|
+
linux_32_core_arm: 0.050,
|
|
52
|
+
linux_64_core_arm: 0.098,
|
|
53
|
+
windows_2_core_arm: 0.008,
|
|
54
|
+
windows_4_core_arm: 0.014,
|
|
55
|
+
windows_8_core_arm: 0.026,
|
|
56
|
+
windows_16_core_arm: 0.050,
|
|
57
|
+
windows_32_core_arm: 0.098,
|
|
58
|
+
windows_64_core_arm: 0.194,
|
|
59
|
+
macos_xl: 0.102,
|
|
60
|
+
linux_4_core_gpu: 0.052,
|
|
61
|
+
windows_4_core_gpu: 0.102
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const MACOS_LARGE_RUNNER_LABELS = new Set([
|
|
65
|
+
"macos_latest_large",
|
|
66
|
+
"macos_14_large",
|
|
67
|
+
"macos_15_large",
|
|
68
|
+
"macos_26_large"
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const MACOS_XLARGE_RUNNER_LABELS = new Set([
|
|
72
|
+
"macos_latest_xlarge",
|
|
73
|
+
"macos_13_xlarge",
|
|
74
|
+
"macos_14_xlarge",
|
|
75
|
+
"macos_15_xlarge",
|
|
76
|
+
"macos_26_xlarge"
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
const STANDARD_RUNNER_LABELS = new Set([
|
|
80
|
+
"ubuntu_latest",
|
|
81
|
+
"ubuntu_24_04",
|
|
82
|
+
"ubuntu_24_04_arm",
|
|
83
|
+
"ubuntu_22_04_arm",
|
|
84
|
+
"ubuntu_22_04",
|
|
85
|
+
"ubuntu_20_04",
|
|
86
|
+
"ubuntu_slim",
|
|
87
|
+
"windows_latest",
|
|
88
|
+
"windows_2025",
|
|
89
|
+
"windows_2025_vs2026",
|
|
90
|
+
"windows_2022",
|
|
91
|
+
"windows_2019",
|
|
92
|
+
"windows_11_arm",
|
|
93
|
+
"macos_latest",
|
|
94
|
+
"macos_26",
|
|
95
|
+
"macos_26_intel",
|
|
96
|
+
"macos_15",
|
|
97
|
+
"macos_15_intel",
|
|
98
|
+
"macos_14",
|
|
99
|
+
"macos_13",
|
|
100
|
+
...MACOS_LARGE_RUNNER_LABELS,
|
|
101
|
+
...MACOS_XLARGE_RUNNER_LABELS
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const STANDARD_SKUS = new Set([
|
|
105
|
+
"actions_linux_slim",
|
|
106
|
+
"actions_linux",
|
|
107
|
+
"actions_linux_arm",
|
|
108
|
+
"actions_windows",
|
|
109
|
+
"actions_windows_arm",
|
|
110
|
+
"actions_macos"
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const INTERNAL_SENTINEL_SKUS = new Set(["unknown", "self_hosted"]);
|
|
114
|
+
const AMBIGUOUS_RUNNER_LABEL_PATTERN = /(^|_)(custom|larger|large|xlarge|xl|gpu|slim)(_|$)|(^|_)[0-9]+_?core(_|$)|(^|_)core_?[0-9]+(_|$)/;
|
|
115
|
+
const X64_ARCHITECTURE_LABELS = new Set(["x64", "x86", "x86_64"]);
|
|
116
|
+
const ARM_ARCHITECTURE_LABELS = new Set(["arm", "arm64", "aarch64"]);
|
|
117
|
+
const ARCHITECTURE_LABELS = new Set([...X64_ARCHITECTURE_LABELS, ...ARM_ARCHITECTURE_LABELS]);
|
|
118
|
+
|
|
119
|
+
const DEFAULT_RATE_ALIASES = Object.freeze({
|
|
120
|
+
linux_slim: "actions_linux_slim",
|
|
121
|
+
linux: "actions_linux",
|
|
122
|
+
linux_arm: "actions_linux_arm",
|
|
123
|
+
windows: "actions_windows",
|
|
124
|
+
windows_arm: "actions_windows_arm",
|
|
125
|
+
macos: "actions_macos"
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const INFERRED_SKU_RULES = Object.freeze([
|
|
129
|
+
{ sku: "actions_linux_slim", matches: (labels) => labels.includes("ubuntu_slim") },
|
|
130
|
+
{ sku: "windows_4_core_gpu", matches: (labels) => anyWindowsGpuLabel(labels) },
|
|
131
|
+
{ sku: "linux_4_core_gpu", matches: (labels) => anyLinuxGpuLabel(labels) },
|
|
132
|
+
{ resolve: inferOfficialCoreRunnerSku },
|
|
133
|
+
{ sku: "macos_xl", matches: (labels) => labels.some(isOfficialMacosXLargeLabel) },
|
|
134
|
+
{ sku: "macos_l", matches: (labels) => labels.some(isOfficialMacosLargeLabel) },
|
|
135
|
+
{ sku: "actions_macos", matches: (labels) => anyMacosLabel(labels) },
|
|
136
|
+
{ sku: "actions_windows_arm", matches: (labels) => anyWindowsArmLabel(labels) },
|
|
137
|
+
{ sku: "actions_windows", matches: (labels) => anyWindowsLabel(labels) },
|
|
138
|
+
{ sku: "actions_linux_arm", matches: (labels) => anyLinuxLabelIncludes(labels, "arm") },
|
|
139
|
+
{ sku: "actions_linux", matches: (labels) => anyLinuxLabel(labels) }
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Normalizes a runner label, name, group, or SKU for lookup.
|
|
144
|
+
* @param {unknown} value Raw lookup key.
|
|
145
|
+
* @returns {string} Lowercase underscore-separated key.
|
|
146
|
+
*/
|
|
147
|
+
export function normalizeKey(value) {
|
|
148
|
+
const normalized = normalizeRawKey(value);
|
|
149
|
+
const actionsAlias = normalized.replace(/^actions_/, "");
|
|
150
|
+
|
|
151
|
+
return isKnownActionsAlias(normalized, actionsAlias)
|
|
152
|
+
? actionsAlias
|
|
153
|
+
: normalized;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isKnownActionsAlias(normalized, actionsAlias) {
|
|
157
|
+
return /^actions_(linux|windows|macos)(_|$)/.test(normalized)
|
|
158
|
+
&& (isKnownNonClassDefaultSku(actionsAlias) || Object.prototype.hasOwnProperty.call(DEFAULT_RATE_ALIASES, actionsAlias));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function normalizeRawKey(value) {
|
|
162
|
+
return String(value ?? "")
|
|
163
|
+
.trim()
|
|
164
|
+
.toLowerCase()
|
|
165
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
166
|
+
.replace(/^_+|_+$/g, "");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Converts user rate overrides to normalized numeric lookup entries.
|
|
171
|
+
* @param {RateOverrides} [overrides={}] Override object or map.
|
|
172
|
+
* @returns {Map<string, number>} Normalized rate lookup map.
|
|
173
|
+
*/
|
|
174
|
+
export function normalizeRateOverrides(overrides = {}) {
|
|
175
|
+
const normalized = new Map();
|
|
176
|
+
const entries = overrides instanceof Map ? overrides.entries() : Object.entries(overrides);
|
|
177
|
+
|
|
178
|
+
for (const [key, value] of entries) {
|
|
179
|
+
const rate = parseOverrideRate(key, value);
|
|
180
|
+
normalized.set(normalizeKey(key), rate);
|
|
181
|
+
setExactStandardSkuOverride(normalized, key, rate);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return normalized;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function setExactStandardSkuOverride(normalized, key, rate) {
|
|
188
|
+
const rawKey = normalizeRawKey(key);
|
|
189
|
+
if (STANDARD_SKUS.has(rawKey)) {
|
|
190
|
+
normalized.set(rawKey, rate);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseOverrideRate(key, value) {
|
|
195
|
+
const rate = numericOverrideValue(value);
|
|
196
|
+
if (!Number.isFinite(rate) || rate < 0) {
|
|
197
|
+
throw new Error(`Invalid runner rate for "${key}": expected a non-negative number.`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return rate;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function numericOverrideValue(value) {
|
|
204
|
+
if (typeof value === "number") {
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Infers the closest GitHub billing SKU from runner labels.
|
|
213
|
+
* @param {string[]} [labels=[]] Runner labels from the workflow job.
|
|
214
|
+
* @returns {string} Billing SKU, `self_hosted`, or `unknown`.
|
|
215
|
+
*/
|
|
216
|
+
export function inferBillingSku(labels = []) {
|
|
217
|
+
const rawLabels = labels.map(normalizeRawKey).filter(Boolean);
|
|
218
|
+
const normalizedLabels = labels.map(normalizeKey).filter(Boolean);
|
|
219
|
+
const labelSet = new Set(normalizedLabels);
|
|
220
|
+
|
|
221
|
+
if (labelSet.has("self_hosted")) {
|
|
222
|
+
return "self_hosted";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (unknownHostedLabelSet(rawLabels, normalizedLabels)) {
|
|
226
|
+
return "unknown";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return inferredSkuFromRules(normalizedLabels);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function unknownHostedLabelSet(rawLabels, normalizedLabels) {
|
|
233
|
+
return invalidActionsHostedLabels(rawLabels, normalizedLabels)
|
|
234
|
+
|| unofficialOsArchitectureLabels(rawLabels, normalizedLabels)
|
|
235
|
+
|| unofficialOsGpuLabels(normalizedLabels)
|
|
236
|
+
|| standardHostedGpuLabels(normalizedLabels)
|
|
237
|
+
|| genericOsModifierLabels(normalizedLabels)
|
|
238
|
+
|| standardHostedArchitectureConflict(normalizedLabels)
|
|
239
|
+
|| standardHostedLabelWithSeparateArchitecture(normalizedLabels);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function invalidActionsHostedLabels(rawLabels, normalizedLabels) {
|
|
243
|
+
return normalizedLabels.some((label, index) => rawLabels[index].startsWith("actions_")
|
|
244
|
+
&& labelHasOs(label)
|
|
245
|
+
&& !isKnownActionsBillingSku(rawLabels[index], label));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isKnownActionsBillingSku(rawLabel, normalizedLabel) {
|
|
249
|
+
return STANDARD_SKUS.has(rawLabel)
|
|
250
|
+
|| isKnownNonClassDefaultSku(rawLabel)
|
|
251
|
+
|| isKnownNonClassDefaultSku(normalizedLabel);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function standardHostedGpuLabels(labels) {
|
|
255
|
+
return hasLabelSignal(labels, "gpu") && labels.some(isKnownHostedLabel);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function genericOsModifierLabels(labels) {
|
|
259
|
+
return genericOsLabels(labels)
|
|
260
|
+
&& hasGenericOsModifier(labels)
|
|
261
|
+
&& !labels.some(isStandardUbuntuLabel)
|
|
262
|
+
&& !labels.some(isStandardWindowsLabel);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function unofficialOsArchitectureLabels(rawLabels, normalizedLabels) {
|
|
266
|
+
return normalizedLabels.some((label, index) => isKnownActionsBillingSku(rawLabels[index], label)
|
|
267
|
+
? false
|
|
268
|
+
: labelHasOsArchitecture(label) && !isKnownHostedLabel(label) && !isKnownNonClassDefaultSku(label));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function unofficialOsGpuLabels(normalizedLabels) {
|
|
272
|
+
return normalizedLabels.some((label) => labelHasOs(label)
|
|
273
|
+
&& labelHasPositiveSegment(label, "gpu")
|
|
274
|
+
&& !isKnownHostedLabel(label)
|
|
275
|
+
&& !isKnownNonClassDefaultSku(label));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function labelHasOsArchitecture(label) {
|
|
279
|
+
return labelHasOs(label) && labelSegments(label).some((segment) => ARCHITECTURE_LABELS.has(segment));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function labelHasOs(label) {
|
|
283
|
+
return labelSegments(label).some((segment) => ["linux", "ubuntu", "windows", "macos"].includes(segment));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function labelHasPositiveSegment(label, term) {
|
|
287
|
+
const segments = labelSegments(label);
|
|
288
|
+
return segments.some((segment, index) => segment === term && segments[index - 1] !== "not");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function isKnownNonClassDefaultSku(label) {
|
|
292
|
+
return typeof DEFAULT_RATES_USD_PER_MINUTE[label] === "number" && !isClassRateKey(label);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function genericOsLabels(labels) {
|
|
296
|
+
return labels.includes("linux") || labels.includes("windows") || labels.includes("macos");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function hasGenericOsModifier(labels) {
|
|
300
|
+
return labels.some((label) => ARCHITECTURE_LABELS.has(label)) || hasLabelSignal(labels, "gpu");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function standardHostedLabelWithSeparateArchitecture(labels) {
|
|
304
|
+
return labels.some((label) => ARCHITECTURE_LABELS.has(label))
|
|
305
|
+
&& labels.some(isStandardRunnerLabelWithoutArchitecture);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function standardHostedArchitectureConflict(labels) {
|
|
309
|
+
const architectures = new Set(labels.map(standaloneArchitecture).filter(Boolean));
|
|
310
|
+
return architectures.size > 0 && labels.some((label) => hostedLabelConflictsWithArchitectures(label, architectures));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function hostedLabelConflictsWithArchitectures(label, architectures) {
|
|
314
|
+
const architecture = hostedLabelArchitecture(label);
|
|
315
|
+
return architecture !== "" && !architectures.has(architecture);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function standaloneArchitecture(label) {
|
|
319
|
+
if (ARM_ARCHITECTURE_LABELS.has(label)) {
|
|
320
|
+
return "arm";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return X64_ARCHITECTURE_LABELS.has(label) ? "x64" : "";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function hostedLabelArchitecture(label) {
|
|
327
|
+
if (!isKnownHostedLabel(label)) {
|
|
328
|
+
return "";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (hasArmArchitecture(label)) {
|
|
332
|
+
return "arm";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return hasIntelArchitecture(label) || hasX64Architecture(label) ? "x64" : "";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function isKnownHostedLabel(label) {
|
|
339
|
+
return STANDARD_RUNNER_LABELS.has(label) || Boolean(officialCoreRunnerSku(label));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isStandardRunnerLabelWithoutArchitecture(label) {
|
|
343
|
+
return isKnownHostedLabel(label) && !hasArmArchitecture(label) && !hasIntelArchitecture(label) && !hasX64Architecture(label);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function hasIntelArchitecture(label) {
|
|
347
|
+
return labelSegments(label).includes("intel");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function hasX64Architecture(label) {
|
|
351
|
+
return labelSegments(label).some((segment) => X64_ARCHITECTURE_LABELS.has(segment));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function inferredSkuFromRules(labels) {
|
|
355
|
+
for (const rule of INFERRED_SKU_RULES) {
|
|
356
|
+
const sku = resolveInferredSkuRule(rule, labels);
|
|
357
|
+
if (sku) {
|
|
358
|
+
return sku;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return "unknown";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function resolveInferredSkuRule(rule, labels) {
|
|
366
|
+
if (rule.resolve) {
|
|
367
|
+
return rule.resolve(labels);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return rule.matches(labels) ? rule.sku : "";
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function inferOfficialCoreRunnerSku(labels) {
|
|
374
|
+
for (const label of labels) {
|
|
375
|
+
const sku = officialCoreRunnerSku(label);
|
|
376
|
+
if (sku) {
|
|
377
|
+
return sku;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return "";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function officialCoreRunnerSku(label) {
|
|
385
|
+
const coreCount = terminalCoreCount(label);
|
|
386
|
+
if (!coreCount) {
|
|
387
|
+
return "";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (isUbuntuVersionLabel(label)) {
|
|
391
|
+
return knownDefaultSku(linuxCoreSku(label, coreCount));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (isWindowsVersionLabel(label)) {
|
|
395
|
+
return knownDefaultSku(windowsCoreSku(label, coreCount));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return "";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function terminalCoreCount(label) {
|
|
402
|
+
const segments = labelSegments(label);
|
|
403
|
+
const terminalCore = /^([0-9]+)core$/.exec(segments.at(-1) ?? "");
|
|
404
|
+
if (terminalCore) {
|
|
405
|
+
return Number(terminalCore[1]);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return segments.at(-1) === "core" && /^[0-9]+$/.test(segments.at(-2) ?? "")
|
|
409
|
+
? Number(segments.at(-2))
|
|
410
|
+
: 0;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function isUbuntuVersionLabel(label) {
|
|
414
|
+
const [os, major, minor] = labelSegments(label);
|
|
415
|
+
return os === "ubuntu" && numericSegment(major) && numericSegment(minor);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function isWindowsVersionLabel(label) {
|
|
419
|
+
const [os, version] = labelSegments(label);
|
|
420
|
+
return os === "windows" && /^[0-9]{4}$/.test(version ?? "");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function linuxCoreSku(label, coreCount) {
|
|
424
|
+
if (hasArmArchitecture(label)) {
|
|
425
|
+
return `linux_${coreCount}_core_arm`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return coreCount === 2 ? "linux_2_core_advanced" : `linux_${coreCount}_core`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function windowsCoreSku(label, coreCount) {
|
|
432
|
+
return hasArmArchitecture(label)
|
|
433
|
+
? `windows_${coreCount}_core_arm`
|
|
434
|
+
: `windows_${coreCount}_core`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function hasArmArchitecture(label) {
|
|
438
|
+
const segments = labelSegments(label);
|
|
439
|
+
return segments.includes("arm") || segments.includes("arm64") || segments.includes("aarch64");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function knownDefaultSku(sku) {
|
|
443
|
+
return typeof DEFAULT_RATES_USD_PER_MINUTE[sku] === "number" ? sku : "";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function numericSegment(segment) {
|
|
447
|
+
return /^[0-9]+$/.test(segment ?? "");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function anyLabelIncludes(labels, term) {
|
|
451
|
+
return labels.some((label) => labelSegments(label).includes(term));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function anyLabelIncludesAll(labels, terms) {
|
|
455
|
+
return terms.every((term) => hasLabelSignal(labels, term));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function hasLabelSignal(labels, term) {
|
|
459
|
+
return isRunnerModifier(term) ? anyLabelHasModifier(labels, term) : anyLabelIncludes(labels, term);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function isRunnerModifier(term) {
|
|
463
|
+
return ["arm", "gpu", "large", "xlarge", "xl"].includes(term);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function anyLabelHasModifier(labels, term) {
|
|
467
|
+
return labels.some((label) => labelHasTerminalModifier(label, term));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function labelHasTerminalModifier(label, term) {
|
|
471
|
+
const segments = labelSegments(label);
|
|
472
|
+
return segments.at(-1) === term && segments.at(-2) !== "not";
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function labelSegments(label) {
|
|
476
|
+
return label.split("_").filter(Boolean);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function anyLinuxLabel(labels) {
|
|
480
|
+
return labels.some(isStandardUbuntuLabel) || anyAmbiguousLinuxLabel(labels);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function anyAmbiguousLinuxLabel(labels) {
|
|
484
|
+
return (anyLabelIncludes(labels, "linux") || anyLabelIncludes(labels, "ubuntu")) && hasAmbiguousRunnerSignal(labels);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function anyLinuxLabelIncludes(labels, term) {
|
|
488
|
+
return anyLabelIncludesAll(labels, [term, "ubuntu"]) || anyLabelIncludesAll(labels, [term, "linux"]);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function anyLinuxGpuLabel(labels) {
|
|
492
|
+
return labels.some(isStandardUbuntuLabel) && hasLabelSignal(labels, "gpu");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function isStandardUbuntuLabel(label) {
|
|
496
|
+
return STANDARD_RUNNER_LABELS.has(label) && labelSegments(label).includes("ubuntu");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function anyWindowsGpuLabel(labels) {
|
|
500
|
+
return labels.some(isStandardWindowsLabel) && hasLabelSignal(labels, "gpu");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function anyWindowsArmLabel(labels) {
|
|
504
|
+
return labels.some((label) => isStandardWindowsLabel(label) && hasArmArchitecture(label));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function anyWindowsLabel(labels) {
|
|
508
|
+
return labels.some(isStandardWindowsLabel) || anyAmbiguousOsLabel(labels, "windows");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function isStandardWindowsLabel(label) {
|
|
512
|
+
return STANDARD_RUNNER_LABELS.has(label) && labelSegments(label).includes("windows");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function anyMacosLabel(labels) {
|
|
516
|
+
return labels.some(isStandardMacosLabel) || anyAmbiguousOsLabel(labels, "macos");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function isStandardMacosLabel(label) {
|
|
520
|
+
return STANDARD_RUNNER_LABELS.has(label) && labelSegments(label).includes("macos");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function anyAmbiguousOsLabel(labels, os) {
|
|
524
|
+
return anyLabelIncludes(labels, os) && hasAmbiguousRunnerSignal(labels);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function isOfficialMacosLargeLabel(label) {
|
|
528
|
+
return MACOS_LARGE_RUNNER_LABELS.has(label);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function isOfficialMacosXLargeLabel(label) {
|
|
532
|
+
return MACOS_XLARGE_RUNNER_LABELS.has(label);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function hasAmbiguousRunnerSignal(labels) {
|
|
536
|
+
return labels.some(isAmbiguousRunnerSignalLabel);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function isAmbiguousRunnerSignalLabel(label) {
|
|
540
|
+
return AMBIGUOUS_RUNNER_LABEL_PATTERN.test(label)
|
|
541
|
+
&& !ARCHITECTURE_LABELS.has(label)
|
|
542
|
+
&& !hasNegativeTerminalModifier(label);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function hasNegativeTerminalModifier(label) {
|
|
546
|
+
const segments = labelSegments(label);
|
|
547
|
+
return segments.at(-2) === "not" && isRunnerModifier(segments.at(-1) ?? "");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Resolves the best available runner rate for a workflow job.
|
|
552
|
+
* @param {import("./cost.js").GitHubJob} job Workflow job to price.
|
|
553
|
+
* @param {Map<string, number>} [rateOverrides] Normalized override rates.
|
|
554
|
+
* @returns {RateInfo} Rate information used by cost analysis.
|
|
555
|
+
*/
|
|
556
|
+
export function resolveRunnerRate(job, rateOverrides = new Map()) {
|
|
557
|
+
const labels = Array.isArray(job.labels) ? job.labels : [];
|
|
558
|
+
const normalizedLabels = labels.map(normalizeKey).filter(Boolean);
|
|
559
|
+
const runnerKeys = runnerRateKeys(job, labels);
|
|
560
|
+
const inferredSku = inferBillingSku(labels);
|
|
561
|
+
|
|
562
|
+
return firstRateInfo([
|
|
563
|
+
resolveOverrideRate(rateOverrides, specificOverrideKeys(runnerKeys)),
|
|
564
|
+
resolveSelfHostedRate(inferredSku),
|
|
565
|
+
resolveHostedRunnerNameOverrideRate(rateOverrides, runnerKeys, normalizedLabels),
|
|
566
|
+
resolveRunnerDefaultRate(runnerKeys, normalizedLabels),
|
|
567
|
+
resolveInferredOverrideRate(rateOverrides, inferredSku, normalizedLabels),
|
|
568
|
+
resolveInferredDefaultRate(inferredSku, normalizedLabels),
|
|
569
|
+
resolveOverrideRate(rateOverrides, classOverrideKeys(runnerKeys))
|
|
570
|
+
]);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function firstRateInfo(candidates) {
|
|
574
|
+
return candidates.find(Boolean) ?? unknownRateInfo();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function runnerRateKeys(job, labels) {
|
|
578
|
+
return [
|
|
579
|
+
{ value: job.runner_name, source: "runner" },
|
|
580
|
+
{ value: job.runner_group_name, source: "group" },
|
|
581
|
+
...labels.map((value) => ({ value, source: "label" }))
|
|
582
|
+
].map(normalizeKeyEntry).filter((entry) => entry.key);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function normalizeKeyEntry(entry) {
|
|
586
|
+
return {
|
|
587
|
+
key: normalizeKey(entry.value),
|
|
588
|
+
rawKey: normalizeRawKey(entry.value),
|
|
589
|
+
source: entry.source
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function specificOverrideKeys(runnerKeys) {
|
|
594
|
+
return runnerKeys
|
|
595
|
+
.flatMap(specificOverrideKeysForEntry);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function specificOverrideKeysForEntry(entry) {
|
|
599
|
+
const keys = [];
|
|
600
|
+
if (STANDARD_SKUS.has(entry.rawKey)) {
|
|
601
|
+
keys.push(entry.rawKey);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (!isClassRateKey(entry.key)) {
|
|
605
|
+
keys.push(entry.key);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return keys;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function classOverrideKeys(runnerKeys) {
|
|
612
|
+
return runnerKeys
|
|
613
|
+
.filter((entry) => entry.source !== "label" && isClassRateKey(entry.key))
|
|
614
|
+
.map((entry) => entry.key);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function isClassRateKey(key) {
|
|
618
|
+
return Object.prototype.hasOwnProperty.call(DEFAULT_RATE_ALIASES, normalizeKey(key));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function resolveOverrideRate(rateOverrides, candidateKeys) {
|
|
622
|
+
for (const key of candidateKeys) {
|
|
623
|
+
if (rateOverrides.has(key)) {
|
|
624
|
+
return {
|
|
625
|
+
rate: rateOverrides.get(key),
|
|
626
|
+
sku: key,
|
|
627
|
+
source: "override"
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function resolveHostedRunnerNameOverrideRate(rateOverrides, runnerKeys, normalizedLabels) {
|
|
636
|
+
for (const entry of runnerKeys) {
|
|
637
|
+
const sku = hostedRunnerNameSku(entry);
|
|
638
|
+
const rateInfo = sku ? resolveOverrideRate(rateOverrides, inferredOverrideKeys(sku)) : null;
|
|
639
|
+
if (rateInfo) {
|
|
640
|
+
return withAmbiguousOverrideWarning(rateInfo, sku, normalizedLabels);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function hostedRunnerNameSku(entry) {
|
|
648
|
+
return entry.source === "label" ? "" : knownHostedLabelSku(entry.key);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function resolveInferredOverrideRate(rateOverrides, inferredSku, normalizedLabels) {
|
|
652
|
+
if (INTERNAL_SENTINEL_SKUS.has(inferredSku)) {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const rateInfo = resolveOverrideRate(rateOverrides, inferredOverrideKeys(inferredSku));
|
|
657
|
+
return rateInfo ? withAmbiguousOverrideWarning(rateInfo, inferredSku, normalizedLabels) : null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function inferredOverrideKeys(inferredSku) {
|
|
661
|
+
return [inferredSku, normalizeKey(inferredSku)].filter(Boolean);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function withAmbiguousOverrideWarning(rateInfo, inferredSku, normalizedLabels) {
|
|
665
|
+
const sku = DEFAULT_RATE_ALIASES[normalizeKey(inferredSku)] ?? inferredSku;
|
|
666
|
+
return {
|
|
667
|
+
...rateInfo,
|
|
668
|
+
warning: ambiguousStandardRunnerWarning(sku, normalizedLabels, rateInfo.source)
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function resolveSelfHostedRate(inferredSku) {
|
|
673
|
+
if (inferredSku === "self_hosted") {
|
|
674
|
+
return {
|
|
675
|
+
rate: 0,
|
|
676
|
+
sku: inferredSku,
|
|
677
|
+
source: "self-hosted"
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function resolveRunnerDefaultRate(runnerKeys, normalizedLabels) {
|
|
685
|
+
for (const entry of runnerKeys) {
|
|
686
|
+
const rateInfo = resolveDefaultRate(entry.key) ?? resolveHostedRunnerNameDefaultRate(entry);
|
|
687
|
+
if (rateInfo && shouldUseRunnerDefaultRate(entry, rateInfo, normalizedLabels)) {
|
|
688
|
+
return {
|
|
689
|
+
...rateInfo,
|
|
690
|
+
source: "default"
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function resolveHostedRunnerNameDefaultRate(entry) {
|
|
699
|
+
if (entry.source === "label") {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const sku = knownHostedLabelSku(entry.key);
|
|
704
|
+
return sku ? resolveDefaultRate(sku) : null;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function knownHostedLabelSku(label) {
|
|
708
|
+
if (!isKnownHostedLabel(label)) {
|
|
709
|
+
return "";
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return officialCoreRunnerSku(label) || standardHostedLabelSku(label);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function standardHostedLabelSku(label) {
|
|
716
|
+
if (isOfficialMacosXLargeLabel(label)) {
|
|
717
|
+
return "macos_xl";
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (isOfficialMacosLargeLabel(label)) {
|
|
721
|
+
return "macos_l";
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (isStandardMacosLabel(label)) {
|
|
725
|
+
return "actions_macos";
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (isStandardWindowsLabel(label)) {
|
|
729
|
+
return hasArmArchitecture(label) ? "actions_windows_arm" : "actions_windows";
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return isStandardUbuntuLabel(label) ? standardUbuntuLabelSku(label) : "";
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function standardUbuntuLabelSku(label) {
|
|
736
|
+
if (label === "ubuntu_slim") {
|
|
737
|
+
return "actions_linux_slim";
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return hasArmArchitecture(label) ? "actions_linux_arm" : "actions_linux";
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function shouldUseRunnerDefaultRate(entry, rateInfo, normalizedLabels) {
|
|
744
|
+
if (!isStandardDefaultAlias(entry.key, rateInfo.sku)) {
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (entry.source !== "label") {
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (entry.rawKey === rateInfo.sku || isExactSlimAlias(entry, rateInfo.sku)) {
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return entry.rawKey.startsWith("actions_")
|
|
757
|
+
&& !ambiguousStandardRunnerWarning(rateInfo.sku, normalizedLabels)
|
|
758
|
+
&& !genericOsArchitectureLabels(rateInfo.sku, normalizedLabels);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function isExactSlimAlias(entry, sku) {
|
|
762
|
+
return sku === "actions_linux_slim" && entry.key === "linux_slim";
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function isStandardDefaultAlias(key, sku) {
|
|
766
|
+
const normalizedKey = normalizeKey(key);
|
|
767
|
+
return STANDARD_SKUS.has(sku) && DEFAULT_RATE_ALIASES[normalizedKey] === sku;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function genericOsArchitectureLabels(sku, normalizedLabels) {
|
|
771
|
+
const os = standardSkuOs(sku);
|
|
772
|
+
return Boolean(os
|
|
773
|
+
&& normalizedLabels.includes(os)
|
|
774
|
+
&& normalizedLabels.some((label) => ARCHITECTURE_LABELS.has(label))
|
|
775
|
+
&& !normalizedLabels.some((label) => STANDARD_RUNNER_LABELS.has(label))
|
|
776
|
+
&& !hasAmbiguousRunnerSignal(normalizedLabels));
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function standardSkuOs(sku) {
|
|
780
|
+
if (sku.includes("linux")) {
|
|
781
|
+
return "linux";
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (sku.includes("windows")) {
|
|
785
|
+
return "windows";
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return sku.includes("macos") ? "macos" : "";
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function resolveInferredDefaultRate(inferredSku, normalizedLabels) {
|
|
792
|
+
const rateInfo = resolveDefaultRate(inferredSku);
|
|
793
|
+
if (rateInfo) {
|
|
794
|
+
return {
|
|
795
|
+
...rateInfo,
|
|
796
|
+
source: "default",
|
|
797
|
+
warning: ambiguousStandardRunnerWarning(rateInfo.sku, normalizedLabels, "default")
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function unknownRateInfo() {
|
|
805
|
+
return {
|
|
806
|
+
rate: null,
|
|
807
|
+
sku: "unknown",
|
|
808
|
+
source: "unknown"
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function resolveDefaultRate(key) {
|
|
813
|
+
const normalizedKey = normalizeKey(key);
|
|
814
|
+
const sku = DEFAULT_RATE_ALIASES[normalizedKey] ?? normalizedKey;
|
|
815
|
+
const rate = DEFAULT_RATES_USD_PER_MINUTE[sku] ?? DEFAULT_RATES_USD_PER_MINUTE[normalizedKey];
|
|
816
|
+
|
|
817
|
+
if (typeof rate !== "number") {
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return { rate, sku };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function ambiguousStandardRunnerWarning(sku, normalizedLabels, source = "default") {
|
|
825
|
+
if (!STANDARD_SKUS.has(sku)) {
|
|
826
|
+
return "";
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const nonStandardLabels = normalizedLabels.filter((label) => !STANDARD_RUNNER_LABELS.has(label));
|
|
830
|
+
if (nonStandardLabels.length === 0) {
|
|
831
|
+
return "";
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (nonStandardLabels.some(isAmbiguousRunnerSignalLabel)) {
|
|
835
|
+
return source === "override"
|
|
836
|
+
? `Runner labels look custom or larger-runner-like but were priced with a broad runner-rates override for ${sku}. Add a specific runner-rates override for accurate billing.`
|
|
837
|
+
: `Runner labels look custom or larger-runner-like but were priced as ${sku}. Add a runner-rates override for accurate billing.`;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return "";
|
|
841
|
+
}
|