@biffo/cli 0.296.13 → 0.296.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.296.13",
3
+ "version": "0.296.15",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,6 +43,7 @@
43
43
  # eval "$(sh scripts/pg-test-db.sh --export)" # export BIFFO_TEST_PG_DSN and TABSII_TEST_PG_DSN
44
44
  # sh scripts/pg-test-db.sh # print the DSN on stdout
45
45
  # sh scripts/pg-test-db.sh --recreate # force a rebuild
46
+ # sh scripts/pg-test-db.sh --reap # housekeeping only: reap and exit
46
47
  #
47
48
  # Only the DSN reaches stdout, so it is safe to capture; progress goes to stderr.
48
49
  #
@@ -164,10 +165,12 @@ CONTAINER="${BIFFO_PG_CONTAINER:-biffo-pg-test-$_checkout_suffix}"
164
165
 
165
166
  RECREATE=0
166
167
  EXPORT=0
168
+ REAP_ONLY=0
167
169
  for arg in "$@"; do
168
170
  case "$arg" in
169
171
  --recreate) RECREATE=1 ;;
170
172
  --export) EXPORT=1 ;;
173
+ --reap) REAP_ONLY=1 ;;
171
174
  -h | --help)
172
175
  sed -n '2,72p' "$0" | sed 's/^#\{1,2\} \{0,1\}//'
173
176
  exit 0
@@ -250,6 +253,7 @@ if [ "$BIFFO_PG_REAP_HOURS" -gt 0 ] 2>/dev/null && command -v docker >/dev/null
250
253
  say "cannot compute a reap cutoff on this date(1); skipping container reaping"
251
254
  else
252
255
  _reaped=0
256
+ _reaped_gone=0
253
257
  _considered=0
254
258
  # TWO filters, not one literal name (#1383). The label is what containers
255
259
  # created from here now carry; the name prefix keeps covering every one
@@ -272,14 +276,65 @@ if [ "$BIFFO_PG_REAP_HOURS" -gt 0 ] 2>/dev/null && command -v docker >/dev/null
272
276
  _made=$(docker inspect -f '{{.Created}}' "$_c" 2>/dev/null | cut -c1-19)
273
277
  [ -z "$_made" ] && continue
274
278
  _considered=$((_considered + 1))
279
+ # ── Ownership beats age, where ownership is knowable ─────────────────
280
+ #
281
+ # Age was only ever a PROXY. The container is keyed to a checkout
282
+ # (see `CONTAINER` above), so the honest question is not "is this old?"
283
+ # but "does the checkout that owns it still exist?" -- and once the
284
+ # worktree is deleted the answer is a fact, not an estimate. A container
285
+ # whose checkout is gone can never be reused by anything, so there is no
286
+ # 4-second-rebuild trade to weigh: it is pure garbage the moment the
287
+ # directory disappears.
288
+ #
289
+ # Measured 2026-08-22 on one workstation: 42 live containers, of which 19
290
+ # belonged to checkouts that no longer existed. Under the age rule alone
291
+ # those 19 each held a running Postgres and ~500MB for up to 24 more
292
+ # hours -- and #703's real complaint was never disk, it was that these
293
+ # compete for the same page cache and I/O as the lane being timed.
294
+ #
295
+ # The path is read from a LABEL SET AT CREATION, never derived from the
296
+ # container's name. Deriving it would mean hashing candidate paths to see
297
+ # which produces this suffix, and the `biffo-pg-test-` prefix is shared by
298
+ # every repo in the estate -- so a run in one repo, finding no matching
299
+ # worktree of its OWN, would confidently reap a container another repo's
300
+ # test lane was mid-run against. The label makes the claim self-describing
301
+ # and repo-independent.
302
+ #
303
+ # Containers created before this label existed report an empty value and
304
+ # fall through to the age rule below, exactly as `biffo.ephemeral=1`
305
+ # migrated in (#1383). Nothing is stranded; they simply age out once.
306
+ _owner=$(docker inspect -f '{{index .Config.Labels "biffo.checkout"}}' "$_c" 2>/dev/null)
307
+ if [ -n "$_owner" ] && [ "$_owner" != "<no value>" ] && [ ! -d "$_owner" ]; then
308
+ if docker rm -f -v "$_c" >/dev/null 2>&1; then
309
+ _reaped=$((_reaped + 1))
310
+ _reaped_gone=$((_reaped_gone + 1))
311
+ fi
312
+ continue
313
+ fi
275
314
  # Both are UTC ISO-8601 to the second, so a string compare IS a time
276
315
  # compare -- no epoch conversion, and portable across date(1) flavours.
277
316
  if awk -v a="$_made" -v b="$_reap_cutoff" 'BEGIN { exit !(a < b) }'; then
278
- docker rm -f "$_c" >/dev/null 2>&1 && _reaped=$((_reaped + 1))
317
+ # `-v` REMOVES THE CONTAINER'S ANONYMOUS VOLUME WITH IT.
318
+ #
319
+ # Without it every reap orphans a full Postgres data directory. Measured on one
320
+ # workstation 2026-08-20: 413 dangling volumes holding 104.8GB -- 95% of all local
321
+ # volume space -- against 11 live containers totalling 2.5MB. The containers were
322
+ # tidied and their data was not, so the leak grew by roughly a database per reap
323
+ # and nothing pointed at it.
324
+ #
325
+ # It is invisible by construction: `docker ps` looks clean, the reaper reports how
326
+ # many it removed, and the space is only findable with `docker volume ls -qf
327
+ # dangling=true`. The first symptom is a full disk somewhere unrelated.
328
+ docker rm -f -v "$_c" >/dev/null 2>&1 && _reaped=$((_reaped + 1))
279
329
  fi
280
330
  done
281
- [ "$_reaped" -gt 0 ] &&
282
- say "reaped $_reaped of $_considered container(s) unused for over ${BIFFO_PG_REAP_HOURS}h (set BIFFO_PG_REAP_HOURS=0 to disable)"
331
+ # Two reasons, counted apart. A single total would let the cheap, certain
332
+ # rule and the age guess read as one number, and the whole point of the
333
+ # ownership rule is that it is NOT a guess -- if it ever reaps something
334
+ # still wanted, that total must say so on its own.
335
+ if [ "$_reaped" -gt 0 ]; then
336
+ say "reaped $_reaped of $_considered container(s): $_reaped_gone whose checkout no longer exists, $((_reaped - _reaped_gone)) unused for over ${BIFFO_PG_REAP_HOURS}h (set BIFFO_PG_REAP_HOURS=0 to disable)"
337
+ fi
283
338
 
284
339
  # ── What the reaper can SEE but must not touch ──────────────────────────
285
340
  #
@@ -330,10 +385,31 @@ if [ "$BIFFO_PG_REAP_HOURS" -gt 0 ] 2>/dev/null && command -v docker >/dev/null
330
385
  say "NOT reaped -- Postgres containers over ${BIFFO_PG_REAP_HOURS}h old that this script did not create:"
331
386
  for _u in $_unclaimed; do say " $_u"; done
332
387
  say " A stale one costs test failures that belong to nobody (#1383). Remove by hand"
333
- say " (docker rm -f <name>), or start it with --label biffo.ephemeral=1 to have it reaped."
388
+ say " (docker rm -f -v <name> -- the -v matters, or its data volume is orphaned),"
389
+ say " or start it with --label biffo.ephemeral=1 to have it reaped."
334
390
  fi
335
391
  }
336
392
  fi
393
+
394
+ # `--reap` is housekeeping ONLY: reap, report, and stop before starting or
395
+ # touching a server.
396
+ #
397
+ # The reaper is otherwise LAZY -- it runs only when something else runs the
398
+ # lane, so the moment the fleet goes quiet nothing reclaims anything and the
399
+ # mess sits until the next test. That is the opposite of what is wanted: idle
400
+ # is exactly when reclaiming is free. This flag is the callable form, so a
401
+ # worktree teardown or a periodic sweep can collect without standing up a
402
+ # Postgres nobody asked for.
403
+ if [ "$REAP_ONLY" -eq 1 ]; then
404
+ exit 0
405
+ fi
406
+ fi
407
+
408
+ # Guard the case above: with reaping disabled there is nothing for `--reap` to
409
+ # do, and it must still not fall through into starting a server.
410
+ if [ "$REAP_ONLY" -eq 1 ]; then
411
+ say "reaping is disabled (BIFFO_PG_REAP_HOURS=0); nothing to do"
412
+ exit 0
337
413
  fi
338
414
 
339
415
  if ! psql_admin -c 'SELECT 1' >/dev/null 2>&1; then
@@ -352,7 +428,11 @@ if ! psql_admin -c 'SELECT 1' >/dev/null 2>&1; then
352
428
  # is still scanned, but it only ever described containers this script named;
353
429
  # anything started under another name was outside the reaper's denominator
354
430
  # entirely. A label travels with the container whatever it is called.
431
+ # `biffo.checkout` is what makes the container's owner knowable after the
432
+ # fact. The reaper above uses it to remove a container the moment its
433
+ # checkout is deleted, rather than waiting out a 24-hour proxy.
355
434
  docker run -d --name "$CONTAINER" --label biffo.ephemeral=1 \
435
+ --label "biffo.checkout=$REPO_ROOT" \
356
436
  -e POSTGRES_PASSWORD="$PASS" -p "$PORT:5432" "$IMAGE" >/dev/null
357
437
  fi
358
438
  # Polled, not slept: a cold image pull and a warm restart differ by an order of
@@ -98,10 +98,37 @@
98
98
  # anything keep waiting on the checks as before. An unreadable field
99
99
  # else (old gh, missing scope) must never become a verdict.
100
100
  #
101
+ # ## A wait that outlives its caller is worse than no wait
102
+ #
103
+ # This script's timeout says how long IT will wait. It said nothing about how
104
+ # long its CALLER has left, and that gap loses whole sessions.
105
+ #
106
+ # Measured 2026-08-22, biffo-fleet Foreman `7d362ba7`, which runs under
107
+ # `timeout 3300`. It pushed a commit at 05:37:03 and started this script at
108
+ # 05:37:04 with about five minutes of its 55-minute budget left. CI was
109
+ # genuinely in flight and would have concluded at 05:48:18 — a correct ~11
110
+ # minute wait. The session was SIGKILLed at 05:42:45, this script returned
111
+ # **137**, and the turn was lost mid-flight: no verdict, no cost record, the
112
+ # tick reporting `cost=UNKNOWN tokens=UNKNOWN`. Nothing was wrong with the
113
+ # checks or with the waiting; the wait could not fit in the time left and
114
+ # neither side knew it. It was the eleventh such kill in three days.
115
+ #
116
+ # So a caller that knows when it dies can say so:
117
+ #
118
+ # WAIT_FOR_CHECKS_DEADLINE absolute unix epoch the CALLER dies at
119
+ # WAIT_FOR_CHECKS_MARGIN seconds to leave it to react (default 60)
120
+ #
121
+ # The effective deadline becomes the EARLIER of its own timeout and that bound,
122
+ # and if not even one poll fits it exits 2 immediately — before any API call —
123
+ # rather than starting a wait it cannot finish. Both remain exit 2, "cannot
124
+ # tell", never a pass: "ran out of time" and "checks are green" must never be
125
+ # the same answer. Unset, nothing changes.
126
+ #
101
127
  # ## Usage
102
128
  #
103
129
  # sh scripts/wait-for-checks.sh <pr-number> [-R owner/repo]
104
130
  # [--timeout SECONDS] [--interval SECONDS]
131
+ # [--deadline EPOCH]
105
132
  #
106
133
  # Requires `gh`, authenticated. Uses gh's embedded jq, so no jq binary is needed.
107
134
 
@@ -111,6 +138,8 @@ PR=""
111
138
  REPO=""
112
139
  TIMEOUT="${WAIT_FOR_CHECKS_TIMEOUT:-1800}"
113
140
  INTERVAL="${WAIT_FOR_CHECKS_INTERVAL:-30}"
141
+ SESSION_DEADLINE="${WAIT_FOR_CHECKS_DEADLINE:-}"
142
+ MARGIN="${WAIT_FOR_CHECKS_MARGIN:-60}"
114
143
 
115
144
  usage() {
116
145
  # Print the whole header block, however long it grows: from line 2 up to the
@@ -134,6 +163,10 @@ while [ $# -gt 0 ]; do
134
163
  INTERVAL="${2:-}"
135
164
  shift 2
136
165
  ;;
166
+ --deadline)
167
+ SESSION_DEADLINE="${2:-}"
168
+ shift 2
169
+ ;;
137
170
  -h | --help) usage ;;
138
171
  *)
139
172
  PR="$1"
@@ -152,6 +185,24 @@ GREEN=$(printf '\033[32m')
152
185
  DIM=$(printf '\033[90m')
153
186
  OFF=$(printf '\033[0m')
154
187
 
188
+ # --- Bound the wait by the CALLER's life, not only by our own timeout ----------
189
+ #
190
+ # A non-numeric or empty bound is IGNORED rather than read as zero: an unreadable
191
+ # value must never become "no time left", which would turn a caller's typo into a
192
+ # script that refuses to wait for anything.
193
+ deadline=$(($(date +%s) + TIMEOUT))
194
+ bounded_by_caller=0
195
+ case "$SESSION_DEADLINE" in
196
+ '' | *[!0-9]*) : ;;
197
+ *)
198
+ caller_limit=$((SESSION_DEADLINE - MARGIN))
199
+ if [ "$caller_limit" -lt "$deadline" ]; then
200
+ deadline=$caller_limit
201
+ bounded_by_caller=1
202
+ fi
203
+ ;;
204
+ esac
205
+
155
206
  gh_pr() {
156
207
  if [ -n "$REPO" ]; then gh pr "$@" --repo "$REPO"; else gh pr "$@"; fi
157
208
  }
@@ -176,6 +227,33 @@ case "$state" in
176
227
  ;;
177
228
  esac
178
229
 
230
+ # Not even one poll fits in what the CALLER has left. Refuse BEFORE the polling
231
+ # starts: being killed mid-wait costs the caller its whole turn, while exiting now
232
+ # leaves it time to record what it already knows.
233
+ #
234
+ # TWO PLACEMENT RULES, both learned by getting them wrong (cli/src/lib/
235
+ # wait-for-checks.test.ts caught both):
236
+ #
237
+ # 1. ONLY when a caller bound is actually in force. Keyed on the effective
238
+ # deadline alone, `--timeout 0` -- a deliberate, tested "one pass then
239
+ # report" -- started refusing to run at all. A caller's own short timeout is
240
+ # its own business; this bound is about the caller's LIFE, not its patience.
241
+ #
242
+ # 2. AFTER the MERGED/CLOSED fast path. Placed before it, an already-merged PR
243
+ # with no time left returned "cannot tell" instead of the exit 0 it had
244
+ # already earned. There is nothing to wait for there, so there is nothing to
245
+ # refuse. One state read is a cost worth paying to answer correctly.
246
+ if [ "$bounded_by_caller" = "1" ] && [ "$deadline" -le "$(( $(date +%s) + INTERVAL ))" ]; then
247
+ left=$(( deadline - $(date +%s) ))
248
+ [ "$left" -lt 0 ] && left=0
249
+ echo "${RED}wait-for-checks: not enough time left to wait for PR $PR.${OFF}" >&2
250
+ echo "The caller dies in ${left}s (margin ${MARGIN}s) and one poll takes ${INTERVAL}s," >&2
251
+ echo "so this would be killed mid-wait, losing the turn without a verdict." >&2
252
+ echo "Re-run with more time, or raise the caller's budget." >&2
253
+ echo "Not a failure and not a pass: this is 'cannot tell'." >&2
254
+ exit 2
255
+ fi
256
+
179
257
  # --- Signal 1: the checks branch protection says MUST report ------------------
180
258
 
181
259
  owner_repo="$REPO"
@@ -198,7 +276,7 @@ fi
198
276
 
199
277
  # --- Poll ---------------------------------------------------------------------
200
278
 
201
- deadline=$(($(date +%s) + TIMEOUT))
279
+ # `deadline` and `bounded_by_caller` were settled above, before any API call.
202
280
  prev_count=-1
203
281
  rollup=""
204
282
 
@@ -298,7 +376,15 @@ EOF
298
376
 
299
377
  now=$(date +%s)
300
378
  if [ "$now" -ge "$deadline" ]; then
301
- echo "${RED}wait-for-checks: timed out after ${TIMEOUT}s.${OFF}" >&2
379
+ if [ "$bounded_by_caller" = "1" ]; then
380
+ # Distinct wording on purpose: "the caller ran out" sends you to its budget,
381
+ # "we ran out" sends you to CI. Same exit code, different fix.
382
+ echo "${RED}wait-for-checks: stopped early — the caller's deadline arrived.${OFF}" >&2
383
+ echo "Checks had not concluded. Waiting longer would have been killed" >&2
384
+ echo "mid-wait instead of returning this. Raise the caller's budget." >&2
385
+ else
386
+ echo "${RED}wait-for-checks: timed out after ${TIMEOUT}s.${OFF}" >&2
387
+ fi
302
388
  if [ "$count" = "0" ]; then
303
389
  # The exact case the naive loop gets wrong, so name it explicitly.
304
390
  echo "No checks ever appeared on PR $PR. That is 'cannot tell', not 'green'." >&2