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
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { analyzeJobs, diffSummaries, evaluateThresholds } from "../src/cost.js";
|
|
4
|
+
import { parseBoolean, parseJsonObject, parseList } from "../src/inputs.js";
|
|
5
|
+
import { renderReport } from "../src/report.js";
|
|
6
|
+
|
|
7
|
+
const VALUE_OPTIONS = new Set([
|
|
8
|
+
"current",
|
|
9
|
+
"baseline",
|
|
10
|
+
"rates",
|
|
11
|
+
"exclude",
|
|
12
|
+
"fail-on-increase-percent",
|
|
13
|
+
"fail-on-increase-usd",
|
|
14
|
+
"fail-on-total-usd"
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const BOOLEAN_OPTIONS = new Set([
|
|
18
|
+
"fail-on-unknown-runner",
|
|
19
|
+
"fail-on-ambiguous-runner-rate"
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
run();
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error(`error: ${error.message ?? error}`);
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function run() {
|
|
30
|
+
const argv = process.argv.slice(2);
|
|
31
|
+
if (argv.includes("--help")) {
|
|
32
|
+
printHelp();
|
|
33
|
+
process.exitCode = 0;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const args = parseArgs(argv);
|
|
38
|
+
|
|
39
|
+
if (!args.current) {
|
|
40
|
+
printHelp();
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const analysis = analyzeCliJobs(args);
|
|
46
|
+
const thresholdResult = evaluateCliThresholds(args, analysis);
|
|
47
|
+
const runnerRateWarnings = collectRunnerRateWarnings(analysis);
|
|
48
|
+
const report = renderCliReport({ args, analysis, thresholdResult, runnerRateWarnings });
|
|
49
|
+
|
|
50
|
+
process.stdout.write(report);
|
|
51
|
+
|
|
52
|
+
if (thresholdResult.conclusion === "fail") {
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function analyzeCliJobs(args) {
|
|
58
|
+
const currentJobs = readJobs(args.current);
|
|
59
|
+
const baselineJobs = args.baseline ? readJobs(args.baseline) : [];
|
|
60
|
+
const runnerRates = args.rates ? readRates(args.rates) : {};
|
|
61
|
+
const excludePatterns = parseList(args.exclude);
|
|
62
|
+
const current = analyzeJobs(currentJobs, { rateOverrides: runnerRates, excludePatterns });
|
|
63
|
+
const baseline = analyzeJobs(baselineJobs, { rateOverrides: runnerRates, excludePatterns });
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
current,
|
|
67
|
+
baseline,
|
|
68
|
+
diff: diffSummaries(current, baseline)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readRates(filePath) {
|
|
73
|
+
const content = readFileSync(filePath, "utf8");
|
|
74
|
+
if (content.trim() === "") {
|
|
75
|
+
throw new Error("runner-rates must be a valid JSON object: file is empty.");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return parseJsonObject(content, {}, "runner-rates");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function evaluateCliThresholds(args, analysis) {
|
|
82
|
+
const thresholdResult = evaluateThresholds(analysis.diff, {
|
|
83
|
+
maxIncreasePercent: asNonNegativeNumber(args["fail-on-increase-percent"], "fail-on-increase-percent"),
|
|
84
|
+
maxIncreaseUsd: asNonNegativeNumber(args["fail-on-increase-usd"], "fail-on-increase-usd"),
|
|
85
|
+
maxTotalUsd: asNonNegativeNumber(args["fail-on-total-usd"], "fail-on-total-usd")
|
|
86
|
+
});
|
|
87
|
+
const currentUnknownJobs = analysis.current.jobs.filter((job) => job.rate === null);
|
|
88
|
+
const currentAmbiguousRateJobs = analysis.current.jobs.filter((job) => job.rateWarning);
|
|
89
|
+
|
|
90
|
+
applyUnknownRunnerGate({ args, thresholdResult, currentUnknownJobs });
|
|
91
|
+
applyAmbiguousRunnerGate({ args, thresholdResult, currentAmbiguousRateJobs });
|
|
92
|
+
|
|
93
|
+
return thresholdResult;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function collectRunnerRateWarnings(analysis) {
|
|
97
|
+
const current = analysis.current;
|
|
98
|
+
const baseline = analysis.baseline;
|
|
99
|
+
const currentUnknownJobs = current.jobs.filter((job) => job.rate === null);
|
|
100
|
+
const baselineUnknownJobs = baseline.jobs.filter((job) => job.rate === null);
|
|
101
|
+
const currentAmbiguousRateJobs = current.jobs.filter((job) => job.rateWarning);
|
|
102
|
+
const baselineAmbiguousRateJobs = baseline.jobs.filter((job) => job.rateWarning);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
unknownJobs: [
|
|
106
|
+
...withRunLabel(currentUnknownJobs, "current"),
|
|
107
|
+
...withRunLabel(baselineUnknownJobs, "baseline")
|
|
108
|
+
],
|
|
109
|
+
ambiguousRateJobs: [
|
|
110
|
+
...withRunLabel(currentAmbiguousRateJobs, "current"),
|
|
111
|
+
...withRunLabel(baselineAmbiguousRateJobs, "baseline")
|
|
112
|
+
]
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function applyUnknownRunnerGate({ args, thresholdResult, currentUnknownJobs }) {
|
|
117
|
+
if (args["fail-on-unknown-runner"] && currentUnknownJobs.length > 0) {
|
|
118
|
+
thresholdResult.conclusion = "fail";
|
|
119
|
+
thresholdResult.failures.push(`${currentUnknownJobs.length} current job(s) used unknown runner rates.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function applyAmbiguousRunnerGate({ args, thresholdResult, currentAmbiguousRateJobs }) {
|
|
124
|
+
if (args["fail-on-ambiguous-runner-rate"] && currentAmbiguousRateJobs.length > 0) {
|
|
125
|
+
thresholdResult.conclusion = "fail";
|
|
126
|
+
thresholdResult.failures.push(`${currentAmbiguousRateJobs.length} current job(s) used ambiguous runner rates.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderCliReport({ args, analysis, thresholdResult, runnerRateWarnings }) {
|
|
131
|
+
return renderReport({
|
|
132
|
+
diff: analysis.diff,
|
|
133
|
+
currentRun: { id: "local-current", run_number: "local" },
|
|
134
|
+
baselineRun: args.baseline ? { id: "local-baseline", run_number: "local" } : null,
|
|
135
|
+
thresholdResult,
|
|
136
|
+
unknownJobs: runnerRateWarnings.unknownJobs,
|
|
137
|
+
ambiguousRateJobs: runnerRateWarnings.ambiguousRateJobs
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readJobs(filePath) {
|
|
142
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
143
|
+
if (Array.isArray(parsed)) {
|
|
144
|
+
return validateJobs(filePath, parsed);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (parsed && !Array.isArray(parsed) && typeof parsed === "object" && Array.isArray(parsed.jobs)) {
|
|
148
|
+
return validateJobs(filePath, parsed.jobs);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
throw new Error(`Expected ${filePath} to contain an array or an object with a jobs array.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function validateJobs(filePath, jobs) {
|
|
155
|
+
for (const [index, job] of jobs.entries()) {
|
|
156
|
+
if (!job || Array.isArray(job) || typeof job !== "object") {
|
|
157
|
+
throw new Error(`Expected ${filePath} jobs[${index}] to be an object.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return jobs;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function withRunLabel(jobs, runLabel) {
|
|
165
|
+
return jobs.map((job) => ({ ...job, runLabel }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseArgs(argv) {
|
|
169
|
+
const parsed = {};
|
|
170
|
+
|
|
171
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
172
|
+
const option = parseOption(argv, index);
|
|
173
|
+
parsed[option.key] = option.value;
|
|
174
|
+
index += option.consumedValue ? 1 : 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return parsed;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseOption(argv, index) {
|
|
181
|
+
const option = parseOptionToken(argv[index]);
|
|
182
|
+
|
|
183
|
+
const key = option.key;
|
|
184
|
+
if (key === "help") {
|
|
185
|
+
assertNoInlineValue(option);
|
|
186
|
+
return { key, value: true, consumedValue: false };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (BOOLEAN_OPTIONS.has(key)) {
|
|
190
|
+
if (option.hasInlineValue) {
|
|
191
|
+
return { key, value: parseBoolean(inlineOptionValue(option)), consumedValue: false };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { key, value: true, consumedValue: false };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
assertValueOption(key);
|
|
198
|
+
|
|
199
|
+
return option.hasInlineValue
|
|
200
|
+
? { key, value: inlineOptionValue(option), consumedValue: false }
|
|
201
|
+
: { key, value: readOptionValue(argv, index, key), consumedValue: true };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseOptionToken(arg) {
|
|
205
|
+
assertOption(arg);
|
|
206
|
+
const body = arg.slice(2);
|
|
207
|
+
const separatorIndex = body.indexOf("=");
|
|
208
|
+
|
|
209
|
+
return separatorIndex === -1
|
|
210
|
+
? { key: body, hasInlineValue: false }
|
|
211
|
+
: { key: body.slice(0, separatorIndex), value: body.slice(separatorIndex + 1), hasInlineValue: true };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function assertNoInlineValue(option) {
|
|
215
|
+
if (option.hasInlineValue) {
|
|
216
|
+
throw new Error(`--${option.key} does not accept a value.`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function inlineOptionValue(option) {
|
|
221
|
+
assertNonEmptyOptionValue(option.key, option.value);
|
|
222
|
+
return option.value;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function assertNonEmptyOptionValue(key, value) {
|
|
226
|
+
if (value === "") {
|
|
227
|
+
throw new Error(`--${key} requires a value.`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function assertOption(arg) {
|
|
232
|
+
if (!arg.startsWith("--")) {
|
|
233
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function assertValueOption(key) {
|
|
238
|
+
if (!VALUE_OPTIONS.has(key)) {
|
|
239
|
+
throw new Error(`Unknown option: --${key}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function readOptionValue(argv, index, key) {
|
|
244
|
+
const value = argv[index + 1];
|
|
245
|
+
if (value === undefined || value === "" || value.startsWith("--")) {
|
|
246
|
+
throw new Error(`--${key} requires a value.`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return value;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function asNumber(value) {
|
|
253
|
+
if (value === undefined || value === "") {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const parsed = Number(value);
|
|
258
|
+
if (!Number.isFinite(parsed)) {
|
|
259
|
+
throw new Error(`Expected a number, got ${value}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return parsed;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function asNonNegativeNumber(value, name) {
|
|
266
|
+
if (typeof value === "string" && value.trim() === "") {
|
|
267
|
+
throw new Error(`--${name} must be a non-negative number.`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const parsed = asNumber(value);
|
|
271
|
+
if (parsed !== undefined && parsed < 0) {
|
|
272
|
+
throw new Error(`--${name} must be a non-negative number.`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return parsed;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function printHelp() {
|
|
279
|
+
process.stdout.write(`ci-cost-diff
|
|
280
|
+
|
|
281
|
+
Usage:
|
|
282
|
+
ci-cost-diff --current current-jobs.json [--baseline baseline-jobs.json]
|
|
283
|
+
|
|
284
|
+
Options:
|
|
285
|
+
--help Show this help message.
|
|
286
|
+
--current FILE GitHub jobs JSON for the current run.
|
|
287
|
+
--baseline FILE GitHub jobs JSON for the baseline run.
|
|
288
|
+
--rates FILE JSON object with runner rate overrides.
|
|
289
|
+
--exclude PATTERN[,PATTERN] Job name patterns to exclude.
|
|
290
|
+
--fail-on-unknown-runner Exit 1 if a current job has an unknown runner rate.
|
|
291
|
+
--fail-on-ambiguous-runner-rate Exit 1 if a current job used an ambiguous runner rate.
|
|
292
|
+
Boolean flags also accept --flag=true|false.
|
|
293
|
+
--fail-on-increase-percent N Exit 1 if cost delta is above N percent.
|
|
294
|
+
--fail-on-increase-usd N Exit 1 if cost delta is above N USD.
|
|
295
|
+
--fail-on-total-usd N Exit 1 if current cost is above N USD.
|
|
296
|
+
`);
|
|
297
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
CI Cost Diff is split into a small calculation core and thin adapters around it.
|
|
4
|
+
|
|
5
|
+
```mermaid
|
|
6
|
+
flowchart TD
|
|
7
|
+
A["GitHub Action entrypoint"] --> B["GitHub API adapter"]
|
|
8
|
+
B --> C["Workflow run jobs"]
|
|
9
|
+
C --> D["Cost engine"]
|
|
10
|
+
E["Local CLI"] --> D
|
|
11
|
+
D --> F["Diff engine"]
|
|
12
|
+
F --> G["Markdown report"]
|
|
13
|
+
G --> H["Step summary"]
|
|
14
|
+
G --> I["Pull request comment"]
|
|
15
|
+
F --> J["Budget thresholds"]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Modules
|
|
19
|
+
|
|
20
|
+
| Path | Responsibility |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `src/action.js` | GitHub Action entrypoint, input parsing, run lookup, outputs, comments, failure handling. |
|
|
23
|
+
| `src/github.js` | Minimal GitHub REST API client for workflow runs, jobs, and issue comments. |
|
|
24
|
+
| `src/cost.js` | Duration rounding, job analysis, run summaries, cost diffs, budget threshold checks. |
|
|
25
|
+
| `src/rates.js` | Default GitHub-hosted runner rates, runner SKU inference, custom rate overrides. |
|
|
26
|
+
| `src/report.js` | Markdown report rendering and PR comment marker. |
|
|
27
|
+
| `src/inputs.js` | Dependency-free GitHub Action input parsing helpers. |
|
|
28
|
+
| `bin/ci-cost-diff.js` | Local CLI for comparing saved jobs JSON files. |
|
|
29
|
+
|
|
30
|
+
## Packaging And Release
|
|
31
|
+
|
|
32
|
+
The GitHub Action runs checked-in source directly through `action.yml` with `runs.using: node24` and `runs.main: src/action.js`; there is no build or bundled artifact. Package verification asserts the npm tarball contents, CLI executable mode, package metadata, lockfile metadata, action metadata, and release workflow guardrails before publishing.
|
|
33
|
+
|
|
34
|
+
The release workflow publishes from a checked-out tag with npm Trusted Publishing. It uses a GitHub-hosted runner, `permissions: id-token: write`, Node `24`, `package-manager-cache: false`, and `npm publish --provenance --ignore-scripts`. Once the npm package has a trusted publisher configured for `HupBaHa/cost-diff` and workflow `release.yml`, the workflow does not need `NPM_TOKEN` or another long-lived npm API key.
|
|
35
|
+
|
|
36
|
+
## Baseline Selection
|
|
37
|
+
|
|
38
|
+
The action:
|
|
39
|
+
|
|
40
|
+
1. Reads the current workflow run id from `run-id` or `GITHUB_RUN_ID`.
|
|
41
|
+
2. Fetches the current run to discover its workflow id.
|
|
42
|
+
3. Chooses the baseline branch from `baseline-branch`, pull request base branch, repository default branch, or current run branch.
|
|
43
|
+
4. Finds the latest older successful workflow run for the same workflow on the baseline branch, paging past current or newer runs until `lookback-runs` is exhausted, GitHub returns the final page, or GitHub's documented 1,000-result limit for filtered workflow-run searches is reached.
|
|
44
|
+
5. Fetches all job pages with `filter=all` by default so old rerun attempts are included.
|
|
45
|
+
6. Compares jobs from the current run with jobs from that baseline run.
|
|
46
|
+
|
|
47
|
+
## Job Matching
|
|
48
|
+
|
|
49
|
+
Jobs are aggregated by exact GitHub job name. This works well for matrix jobs because GitHub expands matrix values into the visible job name, for example `test (node 22, ubuntu-latest)`.
|
|
50
|
+
|
|
51
|
+
## Failure Model
|
|
52
|
+
|
|
53
|
+
The action fails only when configured budget thresholds are exceeded, `fail-on-unknown-runner` is enabled and the current run contains unknown runner rates, or `fail-on-ambiguous-runner-rate` is enabled and the current run contains ambiguous standard-rate fallbacks. Otherwise, unknown runners are shown in the report and counted as `$0.00`.
|
|
54
|
+
|
|
55
|
+
Pull request comments are best-effort. Update mode scopes comments with a collision-resistant marker, updates the newest same-scope comment from the authenticated token user, and deletes older duplicates. When token identity cannot be resolved, same-scope GitHub App marker comments are left unchanged instead of duplicated. When GitHub exposes a read-only token for fork pull requests, the action skips the comment and still writes the report to the step summary.
|
|
56
|
+
|
|
57
|
+
## Design Choices
|
|
58
|
+
|
|
59
|
+
- Dependency-free runtime keeps the action easy to audit and avoids supply-chain overhead.
|
|
60
|
+
- Source files run directly under the GitHub Action Node 24 runtime; there is no bundling step.
|
|
61
|
+
- The cost engine is pure JavaScript and testable without GitHub credentials.
|
|
62
|
+
- GitHub API access is limited to the data needed for reporting.
|
|
63
|
+
- GitHub API responses are validated against the expected REST shapes so malformed successful responses fail closed instead of undercounting.
|
|
64
|
+
- GitHub API path segments are encoded, and numeric path parameters such as run ids, issue numbers, and comment ids are validated before requests are built.
|
|
65
|
+
- GitHub pagination follows final pages and has a high safety guard to avoid malformed endless full-page responses. Baseline workflow-run lookup is capped at GitHub's documented filtered-search limit.
|
|
66
|
+
- Server-provided retry waits from `Retry-After` and `x-ratelimit-reset` are honored up to a bounded maximum; fallback exponential retry delays are also capped.
|
|
67
|
+
- The GitHub API base URL comes from `GITHUB_API_URL`, which keeps GitHub Enterprise Server deployments possible.
|
|
68
|
+
- `current-job-id` can receive `${{ job.check_run_id }}` from the workflow to exclude the reporting job; the action matches it against each job's `check_run_url`. On GitHub Enterprise Server, users may need `exclude-jobs` for custom reporting job names.
|
|
69
|
+
|
|
70
|
+
## Limitations
|
|
71
|
+
|
|
72
|
+
- The action estimates list-price compute cost, not actual account billing.
|
|
73
|
+
- Report field values and the final report are truncated for comment reliability, so extremely long job names or labels may be shortened in Markdown output.
|
|
74
|
+
- Larger runner labels are often custom and may require `runner-rates`.
|
|
75
|
+
- Custom-looking runner labels can be priced with a standard default unless `runner-rates` overrides are provided.
|
|
76
|
+
- Generic GPU labels such as `linux` plus `gpu` are not enough to infer a GitHub-hosted GPU runner because GitHub also supports `gpu` as a custom self-hosted label.
|
|
77
|
+
- Self-hosted runners created without default labels cannot be identified from generic OS-plus-architecture labels alone and are reported as unknown unless you provide a specific override.
|
|
78
|
+
- Baseline lookup cannot inspect beyond GitHub's 1,000-result cap for filtered workflow-run searches.
|
|
79
|
+
- A workflow must run on the baseline branch for a useful comparison.
|
|
80
|
+
- The reporting job should depend on the jobs being measured.
|
|
81
|
+
- GitHub does not provide an atomic issue-comment upsert, so concurrent first writers can briefly create duplicate comments before a cleanup pass removes older same-scope duplicates.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Rate Model
|
|
2
|
+
|
|
3
|
+
CI Cost Diff estimates GitHub Actions compute cost from workflow job data.
|
|
4
|
+
|
|
5
|
+
## Formula
|
|
6
|
+
|
|
7
|
+
For each completed job:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
rounded_minutes = max(1, ceil((completed_at - started_at) / 60 seconds))
|
|
11
|
+
job_cost = rounded_minutes * runner_rate_usd_per_minute
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The total run cost is the sum of all analyzed jobs. Jobs with missing, invalid, or negative time intervals are skipped; valid completed jobs are rounded up to at least one billable minute.
|
|
15
|
+
|
|
16
|
+
Percentage increases are reported as `n/a` when the baseline cost is zero. If `fail-on-increase-percent` is configured, any nonzero current cost fails against a zero-cost baseline. Budget thresholds must be finite and non-negative, and comparisons tolerate insignificant floating-point artifacts from decimal rates. Non-finite threshold inputs or derived budget values fail closed instead of letting budget gates pass silently.
|
|
17
|
+
|
|
18
|
+
## Default Rates
|
|
19
|
+
|
|
20
|
+
Default rates are based on GitHub Actions runner pricing from GitHub Docs, checked on 2026-06-07.
|
|
21
|
+
|
|
22
|
+
| SKU | Rate USD/min |
|
|
23
|
+
| --- | ---: |
|
|
24
|
+
| `actions_linux_slim` | 0.002 |
|
|
25
|
+
| `actions_linux` | 0.006 |
|
|
26
|
+
| `actions_linux_arm` | 0.005 |
|
|
27
|
+
| `actions_windows` | 0.010 |
|
|
28
|
+
| `actions_windows_arm` | 0.010 |
|
|
29
|
+
| `actions_macos` | 0.062 |
|
|
30
|
+
| `linux_2_core_advanced` | 0.006 |
|
|
31
|
+
| `linux_4_core` | 0.012 |
|
|
32
|
+
| `linux_8_core` | 0.022 |
|
|
33
|
+
| `linux_16_core` | 0.042 |
|
|
34
|
+
| `linux_32_core` | 0.082 |
|
|
35
|
+
| `linux_64_core` | 0.162 |
|
|
36
|
+
| `linux_96_core` | 0.252 |
|
|
37
|
+
| `windows_4_core` | 0.022 |
|
|
38
|
+
| `windows_8_core` | 0.042 |
|
|
39
|
+
| `windows_16_core` | 0.082 |
|
|
40
|
+
| `windows_32_core` | 0.162 |
|
|
41
|
+
| `windows_64_core` | 0.322 |
|
|
42
|
+
| `windows_96_core` | 0.552 |
|
|
43
|
+
| `macos_l` | 0.077 |
|
|
44
|
+
| `linux_2_core_arm` | 0.005 |
|
|
45
|
+
| `linux_4_core_arm` | 0.008 |
|
|
46
|
+
| `linux_8_core_arm` | 0.014 |
|
|
47
|
+
| `linux_16_core_arm` | 0.026 |
|
|
48
|
+
| `linux_32_core_arm` | 0.050 |
|
|
49
|
+
| `linux_64_core_arm` | 0.098 |
|
|
50
|
+
| `windows_2_core_arm` | 0.008 |
|
|
51
|
+
| `windows_4_core_arm` | 0.014 |
|
|
52
|
+
| `windows_8_core_arm` | 0.026 |
|
|
53
|
+
| `windows_16_core_arm` | 0.050 |
|
|
54
|
+
| `windows_32_core_arm` | 0.098 |
|
|
55
|
+
| `windows_64_core_arm` | 0.194 |
|
|
56
|
+
| `macos_xl` | 0.102 |
|
|
57
|
+
| `linux_4_core_gpu` | 0.052 |
|
|
58
|
+
| `windows_4_core_gpu` | 0.102 |
|
|
59
|
+
|
|
60
|
+
Sources:
|
|
61
|
+
|
|
62
|
+
- GitHub Docs: https://docs.github.com/en/billing/reference/actions-runner-pricing
|
|
63
|
+
- GitHub Docs: https://docs.github.com/en/billing/concepts/product-billing/github-actions
|
|
64
|
+
|
|
65
|
+
GitHub can change list prices or add runner SKUs. When default rates or SKU inference change, update `src/rates.js`, this table and checked date, and the rate tests together. Use `runner-rates` overrides for private pricing, internal showback rates, or runner labels that GitHub does not expose as a known billing SKU.
|
|
66
|
+
|
|
67
|
+
## What Is Not Modeled
|
|
68
|
+
|
|
69
|
+
The action does not model:
|
|
70
|
+
|
|
71
|
+
- Included monthly minutes by account plan.
|
|
72
|
+
- Public-repository standard-runner free usage.
|
|
73
|
+
- GitHub Pages or Dependabot free usage.
|
|
74
|
+
- Artifact, cache, or package storage billing.
|
|
75
|
+
- Private pricing, enterprise agreements, or discounts.
|
|
76
|
+
- Self-hosted runner infrastructure cost.
|
|
77
|
+
|
|
78
|
+
Self-hosted jobs are treated as `$0.00` because GitHub does not bill self-hosted runner minutes. You can still assign a rate with an exact self-hosted label, runner name, or runner group override if you want internal showback; broad class keys such as `linux` do not price self-hosted jobs. GitHub lets self-hosted runners omit default labels such as `self-hosted`, so bare generic OS labels such as `linux`, `windows`, or `macos`, generic OS-plus-architecture labels like `linux` and `x64`, `linux` and `arm`, or `windows` and `arm`, combined non-official ARM aliases such as `linux-arm`, `ubuntu-arm`, or `windows-arm`, actions-prefixed hosted-looking aliases such as `actions-ubuntu-arm`, combined OS-plus-GPU labels such as `linux-gpu`, `gpu-linux`, or `ubuntu-gpu`, and non-official Ubuntu-looking labels such as `ubuntu-cache` are treated as unknown unless a more specific override is provided. Split standard labels plus architecture labels, such as `ubuntu-latest` and `arm64`, are also treated as unknown; use official hosted ARM labels such as `ubuntu-24.04-arm` and `ubuntu-22.04-arm`. Split standard labels plus GPU labels, such as `ubuntu-latest` and `gpu`, are treated as unknown because `gpu` can be a custom self-hosted runner label. Generic OS-plus-GPU labels such as `linux` and `gpu` are treated as unknown for the same reason.
|
|
79
|
+
|
|
80
|
+
## Rerun Attempts
|
|
81
|
+
|
|
82
|
+
The GitHub jobs API defaults to the latest attempt for a workflow run. CI Cost Diff uses `filter=all` by default so old rerun attempts are included in the estimate. Set `job-filter: latest` if you only want to compare the latest attempt.
|
|
83
|
+
|
|
84
|
+
## Larger Runners
|
|
85
|
+
|
|
86
|
+
Known standard and larger-runner billing SKU labels, official workflow labels such as `ubuntu-slim`, `macos-26`, `windows-2025-vs2026`, `ubuntu-24.04-16core`, `windows-2022-16core`, `macos-15-large`, and `macos-26-xlarge`, runner names, and runner group names are matched by default after normalization. GitHub larger runners commonly use custom labels, and the jobs API does not always expose an unambiguous billing SKU. Exact actions-prefixed billing SKU labels such as `actions-linux-arm` or `actions-linux-slim` are priced, but actions-prefixed hosted-looking aliases such as `actions-ubuntu-arm` are not. Exact GPU billing SKU labels such as `linux-4-core-gpu` and `windows-4-core-gpu` are priced, but generic `linux`/`gpu`, `windows`/`gpu`, `ubuntu-latest`/`gpu`, `linux-gpu`, or `gpu-linux` label combinations require an explicit override. For accurate estimates in those cases, pass explicit overrides:
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
with:
|
|
90
|
+
runner-rates: |
|
|
91
|
+
{
|
|
92
|
+
"ubuntu-8-core": 0.022,
|
|
93
|
+
"macos-12-core": 0.077
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Override keys are normalized the same way as runner labels. Exact non-class runner labels, names, groups, and known billing SKUs take precedence over class keys. Class-level keys such as `linux`, `windows`, and `macos` apply only to jobs inferred to that standard GitHub-hosted runner SKU, or to explicit runner names/groups, and do not override exact larger-runner labels or generic OS-plus-architecture/GPU labels.
|
|
98
|
+
|
|
99
|
+
Generic split labels such as `linux` plus `8-core`, `windows` plus `8-core`, or `macos` plus `xlarge` are reported as ambiguous when they fall back to a standard default rate or broad class override. Add a specific override or use a known larger-runner label to avoid underpricing. Arbitrary `actions-*` custom labels remain distinct from non-`actions-*` labels; only known `actions-linux`, `actions-windows`, or `actions-macos` billing SKU aliases normalize to their SKU forms.
|
|
100
|
+
|
|
101
|
+
## Unknown Runners
|
|
102
|
+
|
|
103
|
+
Unknown runners are reported and counted as `$0.00` by default. Enable `fail-on-unknown-runner: true` to make current-run unknown rates block the check; baseline unknowns remain report warnings so they do not hide current cost regressions.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"jobs": [
|
|
3
|
+
{
|
|
4
|
+
"id": 201,
|
|
5
|
+
"name": "unit tests",
|
|
6
|
+
"labels": ["ubuntu-latest"],
|
|
7
|
+
"runner_name": "GitHub Actions 1",
|
|
8
|
+
"started_at": "2026-05-10T10:00:00Z",
|
|
9
|
+
"completed_at": "2026-05-10T10:01:40Z",
|
|
10
|
+
"conclusion": "success"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": 202,
|
|
14
|
+
"name": "browser tests",
|
|
15
|
+
"labels": ["windows-latest"],
|
|
16
|
+
"runner_name": "GitHub Actions 2",
|
|
17
|
+
"started_at": "2026-05-10T10:00:00Z",
|
|
18
|
+
"completed_at": "2026-05-10T10:03:20Z",
|
|
19
|
+
"conclusion": "success"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"jobs": [
|
|
3
|
+
{
|
|
4
|
+
"id": 101,
|
|
5
|
+
"name": "unit tests",
|
|
6
|
+
"labels": ["ubuntu-latest"],
|
|
7
|
+
"runner_name": "GitHub Actions 1",
|
|
8
|
+
"started_at": "2026-05-11T10:00:00Z",
|
|
9
|
+
"completed_at": "2026-05-11T10:02:05Z",
|
|
10
|
+
"conclusion": "success"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": 102,
|
|
14
|
+
"name": "browser tests",
|
|
15
|
+
"labels": ["windows-latest"],
|
|
16
|
+
"runner_name": "GitHub Actions 2",
|
|
17
|
+
"started_at": "2026-05-11T10:00:00Z",
|
|
18
|
+
"completed_at": "2026-05-11T10:04:15Z",
|
|
19
|
+
"conclusion": "success"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ci-cost-diff-action",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GitHub Action and CLI that estimates GitHub Actions job cost and reports CI cost deltas in pull requests.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "HupBaHa",
|
|
7
|
+
"homepage": "https://github.com/HupBaHa/cost-diff#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/HupBaHa/cost-diff.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/HupBaHa/cost-diff/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"ci-cost-diff": "bin/ci-cost-diff.js"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"check": "node scripts/check.js",
|
|
20
|
+
"lint": "eslint . --max-warnings 0",
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"pack:check": "node scripts/pack-check.js",
|
|
23
|
+
"smoke": "node ./bin/ci-cost-diff.js --current examples/current-jobs.json --baseline examples/baseline-jobs.json",
|
|
24
|
+
"verify": "node scripts/check.js && npm run lint && npm run test && npm run smoke && npm run pack:check"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"action.yml",
|
|
28
|
+
"bin/",
|
|
29
|
+
"docs/",
|
|
30
|
+
"examples/",
|
|
31
|
+
"src/",
|
|
32
|
+
"CHANGELOG.md",
|
|
33
|
+
"README.md",
|
|
34
|
+
"SECURITY.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"github-actions",
|
|
42
|
+
"finops",
|
|
43
|
+
"ci",
|
|
44
|
+
"cost",
|
|
45
|
+
"pull-request",
|
|
46
|
+
"devops"
|
|
47
|
+
],
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@eslint/js": "^9.39.4",
|
|
51
|
+
"eslint": "^9.39.4",
|
|
52
|
+
"globals": "^16.5.0"
|
|
53
|
+
}
|
|
54
|
+
}
|