@biffo/cli 0.241.1 → 0.242.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/_skeletons/plugin-template/AGENTS.md +14 -0
- package/_skeletons/sibling-template/AGENTS.md +14 -0
- package/dist/index.js +16 -2
- package/package.json +5 -2
- package/scripts/practices-corpus.mjs +172 -0
- package/scripts/practices-metrics.mjs +2682 -0
- package/scripts/runner-drop-forensics.mjs +344 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Was this red check a fleet fault, or did a gate actually reject the change?
|
|
4
|
+
* (#1021)
|
|
5
|
+
*
|
|
6
|
+
* ## The problem this closes
|
|
7
|
+
*
|
|
8
|
+
* A self-hosted job dies mid-run and GitHub reports a plain red check. The job
|
|
9
|
+
* log is a **404** by the time anyone looks, because the runner that was
|
|
10
|
+
* uploading it no longer exists. The only surviving trace is a check-run
|
|
11
|
+
* annotation saying the runner "lost communication with the server" — which is
|
|
12
|
+
* equally consistent with a reclaimed spot instance, a starved one, and a
|
|
13
|
+
* network fault. Every occurrence passes on a plain re-run, so the honest
|
|
14
|
+
* reading of a red branch and the lazy one are indistinguishable, and #982
|
|
15
|
+
* showed the estate had been counting these as broken code for months.
|
|
16
|
+
*
|
|
17
|
+
* `isRunnerKill` (in `practices-metrics.mjs`, imported below rather than
|
|
18
|
+
* re-implemented) already answers *"did a runner die?"* from the run's own step
|
|
19
|
+
* conclusions. It cannot answer *"why?"* — and "why" is what decides whether
|
|
20
|
+
* anyone should be looking at the code at all.
|
|
21
|
+
*
|
|
22
|
+
* ## The join nobody had written
|
|
23
|
+
*
|
|
24
|
+
* Two facts make the "why" recoverable without retaining a single job log:
|
|
25
|
+
*
|
|
26
|
+
* 1. **`runner_name` on a self-hosted job is the EC2 instance ID** — literally
|
|
27
|
+
* `i-0b26948129cfd56f3`. The philips-labs module names runners after the
|
|
28
|
+
* instance, so GitHub's own API hands you the fleet's primary key.
|
|
29
|
+
* 2. **CloudTrail already records every spot reclamation for 90 days**, as an
|
|
30
|
+
* `AwsServiceEvent` named `BidEvictedEvent` whose
|
|
31
|
+
* `serviceEventDetails.instanceIdSet` lists the instances AWS took back.
|
|
32
|
+
* Nothing had to be enabled; management events are on by default.
|
|
33
|
+
*
|
|
34
|
+
* So the evidence was never actually missing. What was missing was the join.
|
|
35
|
+
* Measured across both fleets and all twelve repos under measurement,
|
|
36
|
+
* 2026-07-07 to 2026-08-03: **22 of 22 runner-killed jobs matched a spot
|
|
37
|
+
* eviction, and none were unexplained.** The cause is reclamation — not
|
|
38
|
+
* starvation, not network — which is why the fleet lever (spot allocation
|
|
39
|
+
* strategy) is the one that moves this number and log retention is not.
|
|
40
|
+
*
|
|
41
|
+
* ## Why the match is time-bounded and not just an ID lookup
|
|
42
|
+
*
|
|
43
|
+
* `biffo-runners` runs **pooled** runners (`enable_ephemeral_runners = false`,
|
|
44
|
+
* forced by a 4KB SSM limit on repo-level JIT configs), so one instance serves
|
|
45
|
+
* several jobs over its life. An eviction proves that instance died once; it
|
|
46
|
+
* does not tell you which of its jobs it was running at the time. Matching on
|
|
47
|
+
* ID alone would blame a reclamation for an unrelated earlier job on the same
|
|
48
|
+
* host that failed honestly.
|
|
49
|
+
*
|
|
50
|
+
* So an eviction counts only when it lands inside the job's own window. All 22
|
|
51
|
+
* observed matches did, comfortably — but the margins differ sharply by fleet,
|
|
52
|
+
* and the difference is worth knowing when reading output from this tool:
|
|
53
|
+
*
|
|
54
|
+
* - `biffo` (pooled, registration-token): the job ends **4–14 seconds** after
|
|
55
|
+
* the eviction.
|
|
56
|
+
* - `tabsii` (ephemeral, JIT): the job hangs for **~9–10 minutes** before
|
|
57
|
+
* GitHub gives up on the vanished runner.
|
|
58
|
+
*
|
|
59
|
+
* {@link MATCH_GRACE_MS} exists because of that 4-second floor.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
// @ts-check
|
|
63
|
+
import { execFileSync } from 'node:child_process'
|
|
64
|
+
import { isRunnerKill } from './practices-metrics.mjs'
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How far outside a job's own start/finish window an eviction may fall and
|
|
68
|
+
* still be counted as the thing that killed it.
|
|
69
|
+
*
|
|
70
|
+
* Zero tolerance is tempting and wrong. The tightest real margin observed was
|
|
71
|
+
* **4 seconds** (`i-0bfec05c84f2a7030`, `biffo-plugin-idea-scout`), so a few
|
|
72
|
+
* seconds of skew between GitHub's clock and CloudTrail's would push a genuine
|
|
73
|
+
* reclamation outside the window.
|
|
74
|
+
*
|
|
75
|
+
* The failure directions are not symmetric, which is what sets the value. Too
|
|
76
|
+
* tight and a reclaimed runner is reported `fleet-fault-unexplained`, sending
|
|
77
|
+
* someone to hunt instance logs that do not exist for a cause already known —
|
|
78
|
+
* the exact wild goose chase this module was written to end. Too loose and,
|
|
79
|
+
* on the pooled fleet only, a reclamation could be credited to a neighbouring
|
|
80
|
+
* job that failed honestly. A minute is far wider than any plausible clock
|
|
81
|
+
* skew and far narrower than the gap between consecutive jobs on one host.
|
|
82
|
+
*/
|
|
83
|
+
export const MATCH_GRACE_MS = 60_000
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Verdicts, in the order a reader should care about them.
|
|
87
|
+
*
|
|
88
|
+
* `FLEET_FAULT_UNEXPLAINED` is the interesting one and the reason this returns
|
|
89
|
+
* three values rather than a boolean. It means a runner demonstrably died and
|
|
90
|
+
* AWS did **not** reclaim it — starvation, a network fault, or the runner
|
|
91
|
+
* process being killed. That is the only case where retaining instance-level
|
|
92
|
+
* logs would buy anything, so it is also the trigger for reopening #1021's
|
|
93
|
+
* first checkbox. The corpus currently holds zero of them; if that changes,
|
|
94
|
+
* the conclusion above has an expiry date.
|
|
95
|
+
*/
|
|
96
|
+
export const VERDICT = {
|
|
97
|
+
/** A gate ran and rejected the change. Look at the code. */
|
|
98
|
+
REAL_FAILURE: 'real-failure',
|
|
99
|
+
/** AWS reclaimed the spot instance mid-job. Nothing to fix in the repo. */
|
|
100
|
+
SPOT_RECLAIMED: 'spot-reclaimed',
|
|
101
|
+
/** The runner died and no eviction explains it. This one needs a human. */
|
|
102
|
+
FLEET_FAULT_UNEXPLAINED: 'fleet-fault-unexplained',
|
|
103
|
+
/** Ran on a GitHub-hosted runner, so there is no fleet to correlate against. */
|
|
104
|
+
NOT_SELF_HOSTED: 'not-self-hosted',
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* EC2 instance IDs are `i-` plus 8 or 17 hex digits. A GitHub-hosted job
|
|
109
|
+
* reports something like `GitHub Actions 1000017440` instead, which must not be
|
|
110
|
+
* silently treated as an unmatched instance — "we could not see the input" is
|
|
111
|
+
* not "there was nothing there".
|
|
112
|
+
*
|
|
113
|
+
* @param {string | null | undefined} name a job's `runner_name`
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
export function isInstanceId(name) {
|
|
117
|
+
return typeof name === 'string' && /^i-[0-9a-f]{8}([0-9a-f]{9})?$/.test(name)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Index CloudTrail's `lookup-events` output by the instances each event killed.
|
|
122
|
+
*
|
|
123
|
+
* The instance IDs are buried two layers deep: `CloudTrailEvent` is a JSON
|
|
124
|
+
* **string** holding the real record, and the IDs live in
|
|
125
|
+
* `serviceEventDetails.instanceIdSet`. The flat `Resources` array that the
|
|
126
|
+
* top-level event carries is empty for this event type, which is the obvious
|
|
127
|
+
* place to look and the wrong one.
|
|
128
|
+
*
|
|
129
|
+
* One event can name several instances — AWS reclaims a whole pool at once, and
|
|
130
|
+
* the largest single event observed took twelve runners in the same second.
|
|
131
|
+
*
|
|
132
|
+
* @param {Array<Record<string, any>>} events the `Events` array from
|
|
133
|
+
* `aws cloudtrail lookup-events`
|
|
134
|
+
* @returns {Map<string, string>} instance ID to ISO-8601 eviction time
|
|
135
|
+
*/
|
|
136
|
+
export function parseEvictions(events) {
|
|
137
|
+
const byInstance = new Map()
|
|
138
|
+
for (const event of events ?? []) {
|
|
139
|
+
let record
|
|
140
|
+
try {
|
|
141
|
+
record = JSON.parse(event?.CloudTrailEvent ?? '{}')
|
|
142
|
+
} catch {
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
const at = record?.eventTime
|
|
146
|
+
if (!at) continue
|
|
147
|
+
for (const id of record?.serviceEventDetails?.instanceIdSet ?? []) {
|
|
148
|
+
// Keep the earliest sighting: an instance is reclaimed once, and a
|
|
149
|
+
// duplicate page of results must not move the timestamp.
|
|
150
|
+
const seen = byInstance.get(id)
|
|
151
|
+
if (!seen || Date.parse(at) < Date.parse(seen)) byInstance.set(id, at)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return byInstance
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Did `evictedAt` fall inside this job's run, allowing for {@link MATCH_GRACE_MS}?
|
|
159
|
+
*
|
|
160
|
+
* A job still running reports `completed_at: null`; treat that as "the window
|
|
161
|
+
* is still open" rather than as a zero-length window that matches nothing.
|
|
162
|
+
*
|
|
163
|
+
* @param {{ started_at?: string | null, completed_at?: string | null }} job
|
|
164
|
+
* @param {string} evictedAt
|
|
165
|
+
* @returns {boolean}
|
|
166
|
+
*/
|
|
167
|
+
export function evictionKilledJob(job, evictedAt) {
|
|
168
|
+
const evicted = Date.parse(evictedAt)
|
|
169
|
+
if (Number.isNaN(evicted)) return false
|
|
170
|
+
const started = Date.parse(job?.started_at ?? '')
|
|
171
|
+
if (Number.isNaN(started)) return false
|
|
172
|
+
const completedRaw = Date.parse(job?.completed_at ?? '')
|
|
173
|
+
const completed = Number.isNaN(completedRaw) ? Infinity : completedRaw + MATCH_GRACE_MS
|
|
174
|
+
return evicted >= started - MATCH_GRACE_MS && evicted <= completed
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Adjudicate one failed run against the fleet's eviction record.
|
|
179
|
+
*
|
|
180
|
+
* Order matters: {@link isRunnerKill} is consulted **first**, so a run where a
|
|
181
|
+
* gate genuinely failed stays a real failure even if some unrelated instance
|
|
182
|
+
* happened to be reclaimed in the same window. A reclamation is only ever an
|
|
183
|
+
* explanation for a death that the run's own steps already evidence.
|
|
184
|
+
*
|
|
185
|
+
* @param {Array<Record<string, any>>} jobs the run's `jobs` array
|
|
186
|
+
* @param {Map<string, string>} evictions from {@link parseEvictions}
|
|
187
|
+
* @returns {{ verdict: string, jobs: Array<{ name: string, instance: string | null, verdict: string, evictedAt: string | null }> }}
|
|
188
|
+
*/
|
|
189
|
+
export function adjudicateRun(jobs, evictions) {
|
|
190
|
+
if (!isRunnerKill(jobs)) return { verdict: VERDICT.REAL_FAILURE, jobs: [] }
|
|
191
|
+
|
|
192
|
+
const lost = (jobs ?? []).filter((job) => job.conclusion === 'failure')
|
|
193
|
+
const detail = lost.map((job) => {
|
|
194
|
+
const instance = job.runner_name ?? null
|
|
195
|
+
if (!isInstanceId(instance)) {
|
|
196
|
+
return { name: job.name, instance, verdict: VERDICT.NOT_SELF_HOSTED, evictedAt: null }
|
|
197
|
+
}
|
|
198
|
+
const evictedAt = evictions.get(instance) ?? null
|
|
199
|
+
const reclaimed = evictedAt !== null && evictionKilledJob(job, evictedAt)
|
|
200
|
+
return {
|
|
201
|
+
name: job.name,
|
|
202
|
+
instance,
|
|
203
|
+
verdict: reclaimed ? VERDICT.SPOT_RECLAIMED : VERDICT.FLEET_FAULT_UNEXPLAINED,
|
|
204
|
+
evictedAt: reclaimed ? evictedAt : null,
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// The run is only "explained" when every job that died has an eviction behind
|
|
209
|
+
// it. One unexplained death is the whole point of the tool, so it dominates —
|
|
210
|
+
// reporting the run as reclaimed because the *other* three jobs were would
|
|
211
|
+
// bury exactly the case that needs a person.
|
|
212
|
+
const explained = detail.every((d) => d.verdict === VERDICT.SPOT_RECLAIMED)
|
|
213
|
+
return {
|
|
214
|
+
verdict: explained ? VERDICT.SPOT_RECLAIMED : VERDICT.FLEET_FAULT_UNEXPLAINED,
|
|
215
|
+
jobs: detail,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Roll a set of adjudicated runs into the counts a human wants to read.
|
|
221
|
+
*
|
|
222
|
+
* @param {Array<{ verdict: string }>} runs
|
|
223
|
+
* @returns {Record<string, number>}
|
|
224
|
+
*/
|
|
225
|
+
export function summarise(runs) {
|
|
226
|
+
const counts = {
|
|
227
|
+
[VERDICT.REAL_FAILURE]: 0,
|
|
228
|
+
[VERDICT.SPOT_RECLAIMED]: 0,
|
|
229
|
+
[VERDICT.FLEET_FAULT_UNEXPLAINED]: 0,
|
|
230
|
+
[VERDICT.NOT_SELF_HOSTED]: 0,
|
|
231
|
+
}
|
|
232
|
+
for (const run of runs ?? []) counts[run.verdict] = (counts[run.verdict] ?? 0) + 1
|
|
233
|
+
return counts
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// I/O. Everything above is pure so the CLI's vitest suite can exercise it
|
|
238
|
+
// without a network; everything below is the thin shell that feeds it.
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/** @param {string[]} args */
|
|
242
|
+
function gh(args) {
|
|
243
|
+
return JSON.parse(execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fetch the fleet's eviction record.
|
|
248
|
+
*
|
|
249
|
+
* `lookup-events` pages at 50 regardless of `--max-results`, so this leans on
|
|
250
|
+
* the AWS CLI's own `--no-paginate`-free default, which follows `NextToken` to
|
|
251
|
+
* exhaustion. Asking for 1000 in one call silently returns 50 and looks like a
|
|
252
|
+
* quiet fleet — the first draft of this reported `tabsii` at 50 evictions when
|
|
253
|
+
* the true figure was 141.
|
|
254
|
+
*
|
|
255
|
+
* @param {{ profile: string, region: string, since: string }} opts
|
|
256
|
+
*/
|
|
257
|
+
function fetchEvictions({ profile, region, since }) {
|
|
258
|
+
const out = execFileSync(
|
|
259
|
+
'aws',
|
|
260
|
+
[
|
|
261
|
+
'cloudtrail',
|
|
262
|
+
'lookup-events',
|
|
263
|
+
'--profile',
|
|
264
|
+
profile,
|
|
265
|
+
'--region',
|
|
266
|
+
region,
|
|
267
|
+
'--lookup-attributes',
|
|
268
|
+
'AttributeKey=EventName,AttributeValue=BidEvictedEvent',
|
|
269
|
+
'--start-time',
|
|
270
|
+
since,
|
|
271
|
+
'--output',
|
|
272
|
+
'json',
|
|
273
|
+
'--no-cli-pager',
|
|
274
|
+
],
|
|
275
|
+
{ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 },
|
|
276
|
+
)
|
|
277
|
+
return parseEvictions(JSON.parse(out).Events ?? [])
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** @param {string[]} argv */
|
|
281
|
+
function parseArgs(argv) {
|
|
282
|
+
const args = { repo: null, run: null, profile: 'default', region: 'us-east-2', since: null }
|
|
283
|
+
for (let i = 0; i < argv.length; i++) {
|
|
284
|
+
const next = () => argv[++i]
|
|
285
|
+
if (argv[i] === '--repo') args.repo = next()
|
|
286
|
+
else if (argv[i] === '--run') args.run = next()
|
|
287
|
+
else if (argv[i] === '--profile') args.profile = next()
|
|
288
|
+
else if (argv[i] === '--region') args.region = next()
|
|
289
|
+
else if (argv[i] === '--since') args.since = next()
|
|
290
|
+
}
|
|
291
|
+
return args
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function main() {
|
|
295
|
+
const args = parseArgs(process.argv.slice(2))
|
|
296
|
+
if (!args.repo || !args.run) {
|
|
297
|
+
console.error(
|
|
298
|
+
'usage: runner-drop-forensics.mjs --repo <owner/name> --run <run-id> [--profile <aws-profile>] [--region <region>] [--since <date>]\n\n' +
|
|
299
|
+
'Decides whether a red check was a fleet fault or a real failure, by joining the\n' +
|
|
300
|
+
"run's self-hosted jobs to CloudTrail's record of spot reclamations.",
|
|
301
|
+
)
|
|
302
|
+
process.exit(2)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const jobs = gh([
|
|
306
|
+
'api',
|
|
307
|
+
`repos/${args.repo}/actions/runs/${args.run}/jobs?per_page=100`,
|
|
308
|
+
'--paginate',
|
|
309
|
+
'--slurp',
|
|
310
|
+
]).flatMap((/** @type {any} */ page) => page.jobs ?? [])
|
|
311
|
+
|
|
312
|
+
// Default the lookback to a day before the run started. CloudTrail keeps 90
|
|
313
|
+
// days of management events, so a wider window costs only latency, but there
|
|
314
|
+
// is no reason to page through three months to explain this morning.
|
|
315
|
+
const started = jobs.map((/** @type {any} */ j) => j.started_at).filter(Boolean).sort()[0]
|
|
316
|
+
const since =
|
|
317
|
+
args.since ?? new Date(Date.parse(started ?? new Date().toISOString()) - 86_400_000).toISOString()
|
|
318
|
+
|
|
319
|
+
const evictions = fetchEvictions({ profile: args.profile, region: args.region, since })
|
|
320
|
+
const result = adjudicateRun(jobs, evictions)
|
|
321
|
+
|
|
322
|
+
console.log(`run ${args.run} in ${args.repo}: ${result.verdict}`)
|
|
323
|
+
for (const job of result.jobs) {
|
|
324
|
+
const because = job.evictedAt ? ` (AWS reclaimed it at ${job.evictedAt})` : ''
|
|
325
|
+
console.log(` ${job.verdict.padEnd(24)} ${job.instance ?? '—'} ${job.name}${because}`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The estate's three-valued contract, and the mapping is not arbitrary:
|
|
329
|
+
// 0 the drop is explained and a plain re-run is justified; 1 a gate rejected
|
|
330
|
+
// the change, so go and read the code; 2 CANNOT TELL — a runner demonstrably
|
|
331
|
+
// died and nothing accounts for it.
|
|
332
|
+
//
|
|
333
|
+
// `fleet-fault-unexplained` earns 2 rather than a bespoke code precisely
|
|
334
|
+
// because **2 is never a pass** here as everywhere else. "The runner died for
|
|
335
|
+
// reasons unknown" must not read as "safe to re-run and move on"; that is the
|
|
336
|
+
// fail-open this whole tool exists to close.
|
|
337
|
+
if (result.verdict === VERDICT.REAL_FAILURE) process.exit(1)
|
|
338
|
+
if (result.verdict === VERDICT.SPOT_RECLAIMED) process.exit(0)
|
|
339
|
+
process.exit(2)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (process.argv[1] && process.argv[1].endsWith('runner-drop-forensics.mjs')) {
|
|
343
|
+
main()
|
|
344
|
+
}
|