@biffo/cli 0.241.0 → 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.
@@ -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
+ }
package/scripts/verify.sh CHANGED
@@ -720,7 +720,7 @@ else
720
720
  fi
721
721
 
722
722
  # Terraform, wherever this repo keeps it: modules/ in the template and
723
- # instances, infra/ and modules/ in siblings.
723
+ # instances, infra/ and modules/ in siblings, terraform/ in the runner fleets.
724
724
  if [ -n "$LIST" ] || command -v terraform >/dev/null 2>&1; then
725
725
  # Scope must match this repo's CI, not exceed it. The template and instances
726
726
  # deliberately fmt-check modules/ ONLY: infra/environments/ is user-owned, and
@@ -731,11 +731,46 @@ if [ -n "$LIST" ] || command -v terraform >/dev/null 2>&1; then
731
731
  tf_dirs=""
732
732
  [ -d modules ] && tf_dirs="$tf_dirs modules/"
733
733
  [ -f biffo.sibling.json ] && [ -d infra ] && tf_dirs="$tf_dirs infra/"
734
+ # terraform/ is the whole of a runner fleet (#1239). Both fleets kept every
735
+ # .tf file there, which is neither of the two directories above, so `tf_dirs`
736
+ # came out empty and this gate printed `no terraform in this repo` -- in the
737
+ # two repos that are nothing BUT terraform. Their CI does check it
738
+ # (`terraform fmt -check -recursive terraform/`), so the gap was local only:
739
+ # the gate that exists to catch this before the push was the one thing not
740
+ # catching it. No repo in the estate holds both terraform/ and modules/, so
741
+ # adding it cannot widen scope anywhere that was already covered.
742
+ [ -d terraform ] && tf_dirs="$tf_dirs terraform/"
734
743
  if [ -n "$tf_dirs" ]; then
735
744
  # shellcheck disable=SC2086
736
745
  ci_has "terraform fmt" && run_check terraform-fmt terraform fmt -check -recursive $tf_dirs
737
746
  else
738
- skip terraform-fmt "no terraform in this repo"
747
+ # Distinguish "this repo has no terraform" from "this repo has terraform
748
+ # somewhere I do not look". The old message asserted the first and was
749
+ # printed for the second, which is the difference between a considered skip
750
+ # and a blind spot wearing its clothes -- the same shape as a branch audit
751
+ # dropping the repos it could not read (#1145) and reporting the remainder
752
+ # as the whole.
753
+ # Pruned rather than filtered, and NOT capped: `| head -20 | wc -l` would
754
+ # silently report 20 for a repo with 200, and a count that stops counting is
755
+ # the denominator defect this estate keeps re-learning.
756
+ _tf_stray=$(find . \
757
+ \( -name .git -o -name .worktrees -o -name .terraform -o -name node_modules \) -prune \
758
+ -o -name '*.tf' -print 2>/dev/null | wc -l | tr -d ' ')
759
+ if [ "${_tf_stray:-0}" -gt 0 ] && [ -z "$LIST" ]; then
760
+ # A WARN, not a skip and not a failure -- exactly the posture pg-test
761
+ # takes above for "the repo HAS the thing and the gate is blind to it".
762
+ # Not a failure because the right scope depends on what this repo's CI
763
+ # covers, which this gate cannot decide for a layout nobody has declared.
764
+ NOT_RUN="$NOT_RUN terraform-fmt"
765
+ printf ' \033[33mWARN\033[0m %-16s NOT RUN - %s .tf file(s) present, none in a directory this gate checks\n' \
766
+ "terraform-fmt" "$_tf_stray"
767
+ printf ' \033[33m%s\033[0m\n' \
768
+ "it looks in modules/, infra/ (siblings) and terraform/ - this repo uses none of them"
769
+ printf ' \033[90m%s\033[0m\n' \
770
+ "add the directory to the tf_dirs block in scripts/verify.sh (biffo-template#1239)"
771
+ else
772
+ skip terraform-fmt "no .tf files in modules/, infra/ or terraform/"
773
+ fi
739
774
  fi
740
775
  else
741
776
  skip terraform-fmt "terraform not installed"