@biffo/cli 0.182.0 → 0.183.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.
@@ -29,12 +29,60 @@
29
29
  # customise does not belong in it — that is what the instance manifest's
30
30
  # three-way merge is for, and this deliberately has no such subtlety.
31
31
  #
32
+ # ## Rehearsal: why a distribution run is two phases
33
+ #
34
+ # Measured on 2026-07-29: **84 `chore(shared): sync template-shared files` PRs
35
+ # merged across 12 satellites in one day** — 7 rounds, where one would have done.
36
+ # That is 33.5% of the estate's entire merge volume for the day, and every one of
37
+ # those PRs is classified `toil` by the practices collector, which read 60.2%.
38
+ # Six of the seven rounds carried `scripts/verify.sh` **alone**: the gate was
39
+ # being iterated downstream, one estate-wide lap per defect found (root-only
40
+ # checks, `--no-cov` in a repo without pytest-cov, the pytest opt-in, `--list`
41
+ # disagreeing with the gate).
42
+ #
43
+ # The reason a lap cost 12 PRs rather than 1 is that this script used to be a
44
+ # single pass: for each repo in turn, stage -> push -> open a PR. A defect
45
+ # discovered while staging repo 7 left 6 PRs already open, so fixing it meant a
46
+ # whole new round rather than a correction.
47
+ #
48
+ # So: **rehearse every target before touching any of them.** Phase 1 stages the
49
+ # candidate files into each repo and runs that repo's own gate against them,
50
+ # locally, with no push and no PR. Phase 2 ships — and only if phase 1 was clean
51
+ # everywhere. All 14 satellite clones are already on disk under `--estate`, so
52
+ # proving the estate costs seconds; proving it through GitHub costs 12 PRs and 12
53
+ # CI runs per lap.
54
+ #
55
+ # **It is not enough to let the target repo's pre-push hook catch this**, which
56
+ # is what the push in phase 2 relies on. That hook is exactly the thing that was
57
+ # silently DEAD in every fresh worktree until #845, and `scripts/hook-audit.sh`
58
+ # exists because the failure was invisible. A repo whose hook is dead pushes
59
+ # happily and opens a PR carrying an unproven gate — the fail-open shape this
60
+ # whole file set exists to remove. Rehearsal runs the gate itself and reads its
61
+ # exit code, so it does not depend on the hook being armed.
62
+ #
32
63
  # Usage:
33
- # sh scripts/shared-sync.sh --check --estate ~/code # report drift, exit 1 if any
34
- # sh scripts/shared-sync.sh --estate ~/code # open a PR per drifted repo
64
+ # sh scripts/shared-sync.sh --check --estate ~/code # report drift, exit 1 if any
65
+ # sh scripts/shared-sync.sh --rehearse --estate ~/code # prove the candidates, ship nothing
66
+ # sh scripts/shared-sync.sh --estate ~/code # rehearse, then open a PR per repo
35
67
  # sh scripts/shared-sync.sh --estate ~/code --repo tabsii-crm
68
+ # sh scripts/shared-sync.sh --estate ~/code --no-rehearse # ship unproven, loudly
36
69
 
37
- set -uo pipefail
70
+ set -u
71
+ # `pipefail` is not POSIX, and this file is documented and invoked as
72
+ # `sh scripts/shared-sync.sh` -- including by scripts/practices-daily.sh.
73
+ #
74
+ # It carried a bare `set -uo pipefail` for as long as it only ever ran on the
75
+ # workstation, whose /bin/sh is dash 0.5.12, a version that happens to accept
76
+ # `-o pipefail`. The first time anything executed it elsewhere -- a test, on a
77
+ # CI runner with an older sh -- it died at this line with
78
+ # `set: Illegal option -o pipefail`, before parsing a single argument. Nothing
79
+ # had noticed, because nothing had ever run it anywhere else.
80
+ #
81
+ # That is the third instance of this class in the corpus: the same one-version
82
+ # difference in the same shell made js-dependency-audit.sh mangle every advisory
83
+ # payload while exiting 0 (#883). Enable it where it exists, carry on where it
84
+ # does not.
85
+ (set -o pipefail) 2>/dev/null && set -o pipefail || true
38
86
 
39
87
  TEMPLATE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
40
88
  MANIFEST="$TEMPLATE_ROOT/shared-files.json"
@@ -43,15 +91,24 @@ MANIFEST="$TEMPLATE_ROOT/shared-files.json"
43
91
  CHECK=""
44
92
  ESTATE=""
45
93
  ONLY=""
94
+ REHEARSE_ONLY=""
95
+ NO_REHEARSE=""
46
96
  while [ $# -gt 0 ]; do
47
97
  case "$1" in
48
98
  --check) CHECK=1; shift ;;
99
+ --rehearse) REHEARSE_ONLY=1; shift ;;
100
+ # In the open, per AGENTS.md section 7: a gate that can be skipped silently
101
+ # is not a gate. This prints the reason on every line it lets through, so a
102
+ # transcript shows what was shipped unproven.
103
+ --no-rehearse) NO_REHEARSE=1; shift ;;
49
104
  --estate) ESTATE="$2"; shift 2 ;;
50
105
  --repo) ONLY="$2"; shift 2 ;;
51
106
  *) echo "unknown argument: $1" >&2; exit 2 ;;
52
107
  esac
53
108
  done
54
109
  [ -n "$ESTATE" ] || { echo "--estate <dir> is required" >&2; exit 2; }
110
+ [ -n "$REHEARSE_ONLY" ] && [ -n "$NO_REHEARSE" ] && {
111
+ echo "--rehearse and --no-rehearse are opposites" >&2; exit 2; }
55
112
 
56
113
  FILES=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$MANIFEST','utf8')).files.join('\n'))")
57
114
  MARKERS=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$MANIFEST','utf8')).appliesTo.join(' '))")
@@ -59,6 +116,12 @@ MARKERS=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$MANIFEST'
59
116
  drifted=0
60
117
  synced=0
61
118
  current=0
119
+ failed=0
120
+
121
+ # Field separator for the two state files below. A literal tab in a `grep`
122
+ # pattern or a `${var%%...}` expansion is invisible in a diff and one editor
123
+ # away from becoming spaces.
124
+ TAB=$(printf '\t')
62
125
 
63
126
  applies() {
64
127
  # Instances are NOT in scope: they carry biffo.core.json and a
@@ -112,17 +175,35 @@ diff_files() {
112
175
  echo "$out"
113
176
  }
114
177
 
115
- sync_repo() {
178
+ repo_slug() {
179
+ git -C "$1" remote get-url origin | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##'
180
+ }
181
+
182
+ # The absolute path of a working tree's shared git directory, which is the same
183
+ # for a primary checkout and every worktree linked to it -- i.e. an identity for
184
+ # the REPOSITORY rather than for one of its working trees.
185
+ repo_dir() {
186
+ (cd "$1" 2>/dev/null && cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd)
187
+ }
188
+
189
+ TEMPLATE_REPO=$(repo_dir "$TEMPLATE_ROOT")
190
+ [ -n "$TEMPLATE_REPO" ] || { echo "$TEMPLATE_ROOT is not a git repository" >&2; exit 2; }
191
+
192
+ # Phase 1: put the candidate files in place and make the repo ready to run its
193
+ # own gate against them. Deliberately stops short of committing anything -- a
194
+ # staged worktree is a question ("would this land?"), and until phase 2 it has no
195
+ # commit, no push and no PR.
196
+ stage_repo() {
116
197
  d="$1"
117
198
  label="$2"
118
- slug=$(git -C "$d" remote get-url origin | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##')
119
- base=$(gh repo view "$slug" --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null)
120
- [ -n "$base" ] || { printf '%-26s \033[31mcannot resolve default branch\033[0m\n' "$label"; return 1; }
199
+ base="$3"
121
200
 
122
201
  git -C "$d" fetch origin --quiet || return 1
123
202
  wt="$d/.worktrees/shared-sync"
124
203
  git -C "$d" worktree remove --force "$wt" 2>/dev/null
125
- git -C "$d" branch -D chore/sync-shared 2>/dev/null
204
+ # `branch -D` reports on STDOUT, so a quiet run printed "Deleted branch
205
+ # chore/sync-shared" in the middle of the rehearsal table.
206
+ git -C "$d" branch -D chore/sync-shared >/dev/null 2>&1
126
207
  git -C "$d" worktree add -q "$wt" -b chore/sync-shared "origin/$base" || return 1
127
208
 
128
209
  for f in $FILES; do
@@ -161,9 +242,82 @@ sync_repo() {
161
242
  git -C "$wt" add -A
162
243
  if git -C "$wt" diff --cached --quiet; then
163
244
  git -C "$d" worktree remove --force "$wt" 2>/dev/null
164
- printf '%-26s \033[32mnothing to sync\033[0m\n' "$label"
245
+ return 2
246
+ fi
247
+ return 0
248
+ }
249
+
250
+ # Phase 1's actual question: with the candidate files in place, does this repo's
251
+ # gate still work HERE?
252
+ #
253
+ # This is the check the estate did not have. `--check` compares bytes, and
254
+ # `gate-coverage.sh --estate` measures repos as they ARE -- neither can say
255
+ # anything about a file that has not been distributed yet. Six of the seven
256
+ # rounds on 2026-07-29 were fixing defects that only exist in a repo that is not
257
+ # this one: a check list tuned to the template's layout, a pytest-cov flag a
258
+ # plugin repo rejects, a package.json that is not at the root.
259
+ rehearse_repo() {
260
+ wt="$1"
261
+
262
+ # `sh`, not `bash`: this is exactly how `.githooks/pre-push` invokes it
263
+ # (`exec sh scripts/verify.sh`), and /bin/sh is dash on every machine in this
264
+ # estate. A gate proven under one shell and run under another is not the same
265
+ # gate -- `js-dependency-audit.sh` reported INCONCLUSIVE on every invocation
266
+ # while exiting 0 for exactly that reason, because dash's `echo` interprets
267
+ # backslash escapes and bash's does not (#883).
268
+ _out=$( (cd "$wt" && sh scripts/verify.sh 2>&1) )
269
+ _rc=$?
270
+ _checks=$(printf '%s' "$_out" | sed -n 's/.*verify passed[^-]*- *//p' | head -1)
271
+
272
+ # `gate-coverage.sh` reads `--list` rather than running anything, so it is
273
+ # cheap, and it answers the question `verify.sh` cannot: the gate ran without
274
+ # error, but how much of THIS repo's CI did it mirror? A gate can exit 0
275
+ # having covered 1 kind in 8 -- that IS #855, and it is invisible in an exit
276
+ # code. Reported, not enforced: what coverage number should block a
277
+ # distribution is H5's call to make with numbers, not this script's to assume.
278
+ #
279
+ # Match its three verdicts, not just the fraction. Reading only `N/M` reported
280
+ # a bare `?` for tabsii-map, which has no ci.yml at all -- so the one repo
281
+ # where the coverage question does not apply looked like the one repo where the
282
+ # measurement had failed. "Not applicable" and "could not tell" are different
283
+ # answers, and this file exists because conflating them is expensive.
284
+ _cov=$( (cd "$wt" && sh scripts/gate-coverage.sh 2>&1) |
285
+ sed 's/\x1b\[[0-9;]*m//g' |
286
+ grep -oE '([0-9]+/[0-9]+|no CI to mirror|NO GATE)' | head -1)
287
+ [ -n "$_cov" ] || _cov='coverage unknown'
288
+ case "$_cov" in
289
+ */*) _cov="covers $_cov" ;;
290
+ esac
291
+
292
+ if [ "$_rc" -eq 0 ]; then
293
+ case "$_out" in
294
+ *"verify ran NOTHING"*)
295
+ # verify.sh has already established this is not the #855 bug: no
296
+ # `.github/workflows/ci.yml`, so this repo has no CI for the gate to
297
+ # mirror and no shift-left obligation. Reporting it as a pass would be
298
+ # the exact conflation the gate itself refuses to make, so it gets its
299
+ # own verdict.
300
+ printf '%s\t%s\n' NO-CI 'gate ran nothing; repo has no ci.yml' ;;
301
+ *)
302
+ printf '%s\t%s\n' PASS "$(printf '%s (%s)' "$_checks" "$_cov")" ;;
303
+ esac
165
304
  return 0
166
305
  fi
306
+ _why=$(printf '%s' "$_out" | grep -E 'verify failed|verify ran NOTHING' | head -1)
307
+ [ -n "$_why" ] || _why=$(printf '%s' "$_out" | tail -1)
308
+ printf '%s\t%s\n' FAIL "$(printf '%s' "$_why" | sed 's/\x1b\[[0-9;]*m//g')"
309
+ return 1
310
+ }
311
+
312
+ # Phase 2: commit the already-staged worktree, push it, open the PR. Reached only
313
+ # when phase 1 came back clean for EVERY repo in this run.
314
+ ship_repo() {
315
+ d="$1"
316
+ label="$2"
317
+ slug="$3"
318
+ base="$4"
319
+ wt="$d/.worktrees/shared-sync"
320
+
167
321
  git -C "$wt" -c commit.gpgsign=false commit -q --no-verify -m "chore(shared): sync template-shared files
168
322
 
169
323
  Distributed by biffo-template's scripts/shared-sync.sh. These files are held
@@ -212,6 +366,14 @@ Run \`sh scripts/gate-coverage.sh\` after merging to see this repo's gate measur
212
366
  return 0
213
367
  }
214
368
 
369
+ # The list of repos this run will touch, one `label<TAB>dir<TAB>slug<TAB>base`
370
+ # per line. Written in the survey pass and read twice afterwards, so both phases
371
+ # work from the same set: a rehearsal that proved a different list of repos than
372
+ # the one that ships is worth nothing.
373
+ TARGETS=$(mktemp)
374
+ VERDICTS=$(mktemp)
375
+ trap 'rm -f "$TARGETS" "$VERDICTS"' EXIT
376
+
215
377
  printf '\nshared-file sync - template -> repos core upgrade cannot reach\n\n'
216
378
  for d in "$ESTATE"/*/; do
217
379
  d="${d%/}"
@@ -220,7 +382,21 @@ for d in "$ESTATE"/*/; do
220
382
  # The template is the source, not a target. It matched only because it carries
221
383
  # scripts/verify.sh, and comparing it to itself through origin/<base> reported
222
384
  # it as missing every file whenever its own dev was ahead of the checkout.
223
- [ "$d" = "$TEMPLATE_ROOT" ] && continue
385
+ #
386
+ # Compare REPOSITORIES, not working-tree paths. `[ "$d" = "$TEMPLATE_ROOT" ]`
387
+ # held only when this script was run from the primary checkout -- and AGENTS.md
388
+ # section 1 mandates that all work happens in a worktree, where TEMPLATE_ROOT
389
+ # is `.worktrees/<name>` and the primary checkout beside it looks like just
390
+ # another satellite carrying scripts/verify.sh. It has no biffo.core.json and
391
+ # no sibling/plugin marker, so nothing else excluded it either.
392
+ #
393
+ # It never fired because a primary checkout is normally byte-identical to
394
+ # origin/dev, so `diff_files` reported `current` and skipped it. It fires the
395
+ # moment the candidate files differ from origin/dev -- which is the only
396
+ # situation this script is ever run in while iterating on a shared file. The
397
+ # first `--rehearse` from a worktree would have staged the template as a
398
+ # target of its own distribution and opened a sync PR against its own dev.
399
+ [ "$(repo_dir "$d")" = "$TEMPLATE_REPO" ] && continue
224
400
  [ -n "$ONLY" ] && [ "$label" != "$ONLY" ] && continue
225
401
  applies "$d" || continue
226
402
  delta=$(diff_files "$d")
@@ -232,14 +408,134 @@ for d in "$ESTATE"/*/; do
232
408
  drifted=$((drifted + 1))
233
409
  if [ -n "$CHECK" ]; then
234
410
  printf '%-26s \033[31mDRIFTED\033[0m%s\n' "$label" "$delta"
235
- else
236
- sync_repo "$d" "$label" && synced=$((synced + 1))
411
+ continue
237
412
  fi
413
+ slug=$(repo_slug "$d")
414
+ base=$(gh repo view "$slug" --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null)
415
+ if [ -z "$base" ]; then
416
+ printf '%-26s \033[31mcannot resolve default branch\033[0m\n' "$label"
417
+ failed=$((failed + 1))
418
+ continue
419
+ fi
420
+ printf '%s\t%s\t%s\t%s\n' "$label" "$d" "$slug" "$base" >> "$TARGETS"
421
+ printf '%-26s \033[33mdrifted\033[0m%s\n' "$label" "$delta"
238
422
  done
239
423
 
240
- printf '\n%s current, %s drifted\n' "$current" "$drifted"
241
- if [ -n "$CHECK" ] && [ "$drifted" -gt 0 ]; then
242
- printf '\033[31mShared files have drifted.\033[0m Run without --check to open sync PRs.\n\n'
243
- exit 1
424
+ if [ -n "$CHECK" ]; then
425
+ printf '\n%s current, %s drifted\n' "$current" "$drifted"
426
+ if [ "$drifted" -gt 0 ]; then
427
+ printf '\033[31mShared files have drifted.\033[0m Run without --check to open sync PRs.\n\n'
428
+ exit 1
429
+ fi
430
+ printf '\n'
431
+ exit 0
432
+ fi
433
+
434
+ if [ ! -s "$TARGETS" ]; then
435
+ printf '\n%s current, %s drifted\n\n' "$current" "$drifted"
436
+ [ "${failed:-0}" -gt 0 ] && exit 1
437
+ exit 0
438
+ fi
439
+
440
+ # ---- Phase 1: rehearse -------------------------------------------------------
441
+ #
442
+ # Every target, before any of them ships. The order matters and it is the whole
443
+ # point of the change: staging repo 7 and finding the gate broken there must not
444
+ # leave six PRs already open in repos 1-6.
445
+ if [ -n "$NO_REHEARSE" ]; then
446
+ printf '\n\033[31m--no-rehearse: shipping %s repos unproven.\033[0m ' "$(wc -l < "$TARGETS" | tr -d ' ')"
447
+ printf 'Nothing has run the gate against these candidates.\n'
448
+ else
449
+ printf '\nrehearsing %s repos - staging the candidates and running each gate\n\n' \
450
+ "$(wc -l < "$TARGETS" | tr -d ' ')"
451
+ rehearsal_failures=0
452
+ while IFS="$TAB" read -r label d slug base; do
453
+ # Read the status IMMEDIATELY. `if ! stage_repo ...` would have collapsed
454
+ # "could not stage" (1) and "nothing to sync" (2) into one branch, and
455
+ # reported a repo that was already current as a staging failure.
456
+ stage_repo "$d" "$label" "$base"
457
+ stage_rc=$?
458
+ if [ "$stage_rc" -eq 2 ]; then
459
+ printf '%-26s \033[32mnothing to sync\033[0m\n' "$label"
460
+ printf '%s%s%s%s%s\n' "$label" "$TAB" SKIP "$TAB" 'nothing to sync' >> "$VERDICTS"
461
+ continue
462
+ fi
463
+ if [ "$stage_rc" -ne 0 ]; then
464
+ printf '%-26s \033[31mCANNOT STAGE\033[0m - fetch or worktree failed\n' "$label"
465
+ printf '%s%s%s%s%s\n' "$label" "$TAB" FAIL "$TAB" 'could not stage' >> "$VERDICTS"
466
+ rehearsal_failures=$((rehearsal_failures + 1))
467
+ continue
468
+ fi
469
+ verdict_line=$(rehearse_repo "$d/.worktrees/shared-sync")
470
+ verdict=$(printf '%s' "$verdict_line" | cut -f1)
471
+ detail=$(printf '%s' "$verdict_line" | cut -f2-)
472
+ case "$verdict" in
473
+ PASS) printf '%-26s \033[32mPASS\033[0m %s\n' "$label" "$detail" ;;
474
+ NO-CI) printf '%-26s \033[90mNO-CI\033[0m %s\n' "$label" "$detail" ;;
475
+ *)
476
+ printf '%-26s \033[31mFAIL\033[0m %s\n' "$label" "$detail"
477
+ printf '%-26s staged tree left at %s\n' '' "$d/.worktrees/shared-sync"
478
+ rehearsal_failures=$((rehearsal_failures + 1)) ;;
479
+ esac
480
+ printf '%s%s%s%s%s\n' "$label" "$TAB" "$verdict" "$TAB" "$detail" >> "$VERDICTS"
481
+ done < "$TARGETS"
482
+
483
+ if [ "$rehearsal_failures" -gt 0 ]; then
484
+ printf '\n\033[31mrehearsal failed in %s repo(s) - NOTHING was pushed and no PR was opened.\033[0m\n' \
485
+ "$rehearsal_failures"
486
+ printf 'Fix the candidate files here in the template, then run this again. Each\n'
487
+ printf 'round that ships before it is proven costs one PR per satellite: there were\n'
488
+ printf '84 of them on 2026-07-29, in 7 rounds, and six of those rounds carried\n'
489
+ printf 'scripts/verify.sh alone.\n'
490
+ printf 'The failing repos keep their staged worktree so the gate can be run there;\n'
491
+ printf 'the clean ones were removed.\n\n'
492
+ while IFS="$TAB" read -r label verdict detail; do
493
+ [ "$verdict" = FAIL ] || continue
494
+ printf ' %-24s %s\n' "$label" "$detail"
495
+ done < "$VERDICTS"
496
+ printf '\n'
497
+ # Reap the worktrees of the repos that passed. They staged cleanly and are
498
+ # not evidence of anything; leaving 11 of them behind after a refusal is the
499
+ # orphan-worktree accumulation AGENTS.md section 1 exists to prevent.
500
+ while IFS="$TAB" read -r label d slug base; do
501
+ grep -q "^$label${TAB}FAIL${TAB}" "$VERDICTS" && continue
502
+ git -C "$d" worktree remove --force "$d/.worktrees/shared-sync" 2>/dev/null
503
+ git -C "$d" branch -D chore/sync-shared 2>/dev/null
504
+ done < "$TARGETS"
505
+ exit 1
506
+ fi
507
+ printf '\nrehearsal clean in every repo\n'
244
508
  fi
245
- printf '\n'
509
+
510
+ if [ -n "$REHEARSE_ONLY" ]; then
511
+ # Staged worktrees are left in place deliberately: --rehearse answers "would
512
+ # this land?", and the tree that answered it is the thing to go and look at.
513
+ printf '\n--rehearse: nothing pushed, no PRs opened. Staged trees are at\n'
514
+ printf '<repo>/.worktrees/shared-sync; re-run without --rehearse to ship.\n\n'
515
+ exit 0
516
+ fi
517
+
518
+ # ---- Phase 2: ship -----------------------------------------------------------
519
+ printf '\nopening PRs\n\n'
520
+ while IFS="$TAB" read -r label d slug base; do
521
+ grep -q "^$label${TAB}SKIP${TAB}" "$VERDICTS" 2>/dev/null && continue
522
+ # --no-rehearse skips phase 1 entirely, so nothing has staged these yet.
523
+ if [ -n "$NO_REHEARSE" ]; then
524
+ stage_repo "$d" "$label" "$base"
525
+ case $? in
526
+ 2) printf '%-26s \033[32mnothing to sync\033[0m\n' "$label"; continue ;;
527
+ 1) printf '%-26s \033[31mCANNOT STAGE\033[0m\n' "$label"; failed=$((failed + 1)); continue ;;
528
+ esac
529
+ fi
530
+ if ship_repo "$d" "$label" "$slug" "$base"; then
531
+ synced=$((synced + 1))
532
+ else
533
+ failed=$((failed + 1))
534
+ fi
535
+ done < "$TARGETS"
536
+
537
+ printf '\n%s current, %s drifted, %s PR(s) opened' "$current" "$drifted" "$synced"
538
+ [ "${failed:-0}" -gt 0 ] && printf ', \033[31m%s failed\033[0m' "$failed"
539
+ printf '\n\n'
540
+ [ "${failed:-0}" -gt 0 ] && exit 1
541
+ exit 0
@@ -29,12 +29,60 @@
29
29
  # customise does not belong in it — that is what the instance manifest's
30
30
  # three-way merge is for, and this deliberately has no such subtlety.
31
31
  #
32
+ # ## Rehearsal: why a distribution run is two phases
33
+ #
34
+ # Measured on 2026-07-29: **84 `chore(shared): sync template-shared files` PRs
35
+ # merged across 12 satellites in one day** — 7 rounds, where one would have done.
36
+ # That is 33.5% of the estate's entire merge volume for the day, and every one of
37
+ # those PRs is classified `toil` by the practices collector, which read 60.2%.
38
+ # Six of the seven rounds carried `scripts/verify.sh` **alone**: the gate was
39
+ # being iterated downstream, one estate-wide lap per defect found (root-only
40
+ # checks, `--no-cov` in a repo without pytest-cov, the pytest opt-in, `--list`
41
+ # disagreeing with the gate).
42
+ #
43
+ # The reason a lap cost 12 PRs rather than 1 is that this script used to be a
44
+ # single pass: for each repo in turn, stage -> push -> open a PR. A defect
45
+ # discovered while staging repo 7 left 6 PRs already open, so fixing it meant a
46
+ # whole new round rather than a correction.
47
+ #
48
+ # So: **rehearse every target before touching any of them.** Phase 1 stages the
49
+ # candidate files into each repo and runs that repo's own gate against them,
50
+ # locally, with no push and no PR. Phase 2 ships — and only if phase 1 was clean
51
+ # everywhere. All 14 satellite clones are already on disk under `--estate`, so
52
+ # proving the estate costs seconds; proving it through GitHub costs 12 PRs and 12
53
+ # CI runs per lap.
54
+ #
55
+ # **It is not enough to let the target repo's pre-push hook catch this**, which
56
+ # is what the push in phase 2 relies on. That hook is exactly the thing that was
57
+ # silently DEAD in every fresh worktree until #845, and `scripts/hook-audit.sh`
58
+ # exists because the failure was invisible. A repo whose hook is dead pushes
59
+ # happily and opens a PR carrying an unproven gate — the fail-open shape this
60
+ # whole file set exists to remove. Rehearsal runs the gate itself and reads its
61
+ # exit code, so it does not depend on the hook being armed.
62
+ #
32
63
  # Usage:
33
- # sh scripts/shared-sync.sh --check --estate ~/code # report drift, exit 1 if any
34
- # sh scripts/shared-sync.sh --estate ~/code # open a PR per drifted repo
64
+ # sh scripts/shared-sync.sh --check --estate ~/code # report drift, exit 1 if any
65
+ # sh scripts/shared-sync.sh --rehearse --estate ~/code # prove the candidates, ship nothing
66
+ # sh scripts/shared-sync.sh --estate ~/code # rehearse, then open a PR per repo
35
67
  # sh scripts/shared-sync.sh --estate ~/code --repo tabsii-crm
68
+ # sh scripts/shared-sync.sh --estate ~/code --no-rehearse # ship unproven, loudly
36
69
 
37
- set -uo pipefail
70
+ set -u
71
+ # `pipefail` is not POSIX, and this file is documented and invoked as
72
+ # `sh scripts/shared-sync.sh` -- including by scripts/practices-daily.sh.
73
+ #
74
+ # It carried a bare `set -uo pipefail` for as long as it only ever ran on the
75
+ # workstation, whose /bin/sh is dash 0.5.12, a version that happens to accept
76
+ # `-o pipefail`. The first time anything executed it elsewhere -- a test, on a
77
+ # CI runner with an older sh -- it died at this line with
78
+ # `set: Illegal option -o pipefail`, before parsing a single argument. Nothing
79
+ # had noticed, because nothing had ever run it anywhere else.
80
+ #
81
+ # That is the third instance of this class in the corpus: the same one-version
82
+ # difference in the same shell made js-dependency-audit.sh mangle every advisory
83
+ # payload while exiting 0 (#883). Enable it where it exists, carry on where it
84
+ # does not.
85
+ (set -o pipefail) 2>/dev/null && set -o pipefail || true
38
86
 
39
87
  TEMPLATE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
40
88
  MANIFEST="$TEMPLATE_ROOT/shared-files.json"
@@ -43,15 +91,24 @@ MANIFEST="$TEMPLATE_ROOT/shared-files.json"
43
91
  CHECK=""
44
92
  ESTATE=""
45
93
  ONLY=""
94
+ REHEARSE_ONLY=""
95
+ NO_REHEARSE=""
46
96
  while [ $# -gt 0 ]; do
47
97
  case "$1" in
48
98
  --check) CHECK=1; shift ;;
99
+ --rehearse) REHEARSE_ONLY=1; shift ;;
100
+ # In the open, per AGENTS.md section 7: a gate that can be skipped silently
101
+ # is not a gate. This prints the reason on every line it lets through, so a
102
+ # transcript shows what was shipped unproven.
103
+ --no-rehearse) NO_REHEARSE=1; shift ;;
49
104
  --estate) ESTATE="$2"; shift 2 ;;
50
105
  --repo) ONLY="$2"; shift 2 ;;
51
106
  *) echo "unknown argument: $1" >&2; exit 2 ;;
52
107
  esac
53
108
  done
54
109
  [ -n "$ESTATE" ] || { echo "--estate <dir> is required" >&2; exit 2; }
110
+ [ -n "$REHEARSE_ONLY" ] && [ -n "$NO_REHEARSE" ] && {
111
+ echo "--rehearse and --no-rehearse are opposites" >&2; exit 2; }
55
112
 
56
113
  FILES=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$MANIFEST','utf8')).files.join('\n'))")
57
114
  MARKERS=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$MANIFEST','utf8')).appliesTo.join(' '))")
@@ -59,6 +116,12 @@ MARKERS=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$MANIFEST'
59
116
  drifted=0
60
117
  synced=0
61
118
  current=0
119
+ failed=0
120
+
121
+ # Field separator for the two state files below. A literal tab in a `grep`
122
+ # pattern or a `${var%%...}` expansion is invisible in a diff and one editor
123
+ # away from becoming spaces.
124
+ TAB=$(printf '\t')
62
125
 
63
126
  applies() {
64
127
  # Instances are NOT in scope: they carry biffo.core.json and a
@@ -112,17 +175,35 @@ diff_files() {
112
175
  echo "$out"
113
176
  }
114
177
 
115
- sync_repo() {
178
+ repo_slug() {
179
+ git -C "$1" remote get-url origin | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##'
180
+ }
181
+
182
+ # The absolute path of a working tree's shared git directory, which is the same
183
+ # for a primary checkout and every worktree linked to it -- i.e. an identity for
184
+ # the REPOSITORY rather than for one of its working trees.
185
+ repo_dir() {
186
+ (cd "$1" 2>/dev/null && cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd)
187
+ }
188
+
189
+ TEMPLATE_REPO=$(repo_dir "$TEMPLATE_ROOT")
190
+ [ -n "$TEMPLATE_REPO" ] || { echo "$TEMPLATE_ROOT is not a git repository" >&2; exit 2; }
191
+
192
+ # Phase 1: put the candidate files in place and make the repo ready to run its
193
+ # own gate against them. Deliberately stops short of committing anything -- a
194
+ # staged worktree is a question ("would this land?"), and until phase 2 it has no
195
+ # commit, no push and no PR.
196
+ stage_repo() {
116
197
  d="$1"
117
198
  label="$2"
118
- slug=$(git -C "$d" remote get-url origin | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##')
119
- base=$(gh repo view "$slug" --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null)
120
- [ -n "$base" ] || { printf '%-26s \033[31mcannot resolve default branch\033[0m\n' "$label"; return 1; }
199
+ base="$3"
121
200
 
122
201
  git -C "$d" fetch origin --quiet || return 1
123
202
  wt="$d/.worktrees/shared-sync"
124
203
  git -C "$d" worktree remove --force "$wt" 2>/dev/null
125
- git -C "$d" branch -D chore/sync-shared 2>/dev/null
204
+ # `branch -D` reports on STDOUT, so a quiet run printed "Deleted branch
205
+ # chore/sync-shared" in the middle of the rehearsal table.
206
+ git -C "$d" branch -D chore/sync-shared >/dev/null 2>&1
126
207
  git -C "$d" worktree add -q "$wt" -b chore/sync-shared "origin/$base" || return 1
127
208
 
128
209
  for f in $FILES; do
@@ -161,9 +242,82 @@ sync_repo() {
161
242
  git -C "$wt" add -A
162
243
  if git -C "$wt" diff --cached --quiet; then
163
244
  git -C "$d" worktree remove --force "$wt" 2>/dev/null
164
- printf '%-26s \033[32mnothing to sync\033[0m\n' "$label"
245
+ return 2
246
+ fi
247
+ return 0
248
+ }
249
+
250
+ # Phase 1's actual question: with the candidate files in place, does this repo's
251
+ # gate still work HERE?
252
+ #
253
+ # This is the check the estate did not have. `--check` compares bytes, and
254
+ # `gate-coverage.sh --estate` measures repos as they ARE -- neither can say
255
+ # anything about a file that has not been distributed yet. Six of the seven
256
+ # rounds on 2026-07-29 were fixing defects that only exist in a repo that is not
257
+ # this one: a check list tuned to the template's layout, a pytest-cov flag a
258
+ # plugin repo rejects, a package.json that is not at the root.
259
+ rehearse_repo() {
260
+ wt="$1"
261
+
262
+ # `sh`, not `bash`: this is exactly how `.githooks/pre-push` invokes it
263
+ # (`exec sh scripts/verify.sh`), and /bin/sh is dash on every machine in this
264
+ # estate. A gate proven under one shell and run under another is not the same
265
+ # gate -- `js-dependency-audit.sh` reported INCONCLUSIVE on every invocation
266
+ # while exiting 0 for exactly that reason, because dash's `echo` interprets
267
+ # backslash escapes and bash's does not (#883).
268
+ _out=$( (cd "$wt" && sh scripts/verify.sh 2>&1) )
269
+ _rc=$?
270
+ _checks=$(printf '%s' "$_out" | sed -n 's/.*verify passed[^-]*- *//p' | head -1)
271
+
272
+ # `gate-coverage.sh` reads `--list` rather than running anything, so it is
273
+ # cheap, and it answers the question `verify.sh` cannot: the gate ran without
274
+ # error, but how much of THIS repo's CI did it mirror? A gate can exit 0
275
+ # having covered 1 kind in 8 -- that IS #855, and it is invisible in an exit
276
+ # code. Reported, not enforced: what coverage number should block a
277
+ # distribution is H5's call to make with numbers, not this script's to assume.
278
+ #
279
+ # Match its three verdicts, not just the fraction. Reading only `N/M` reported
280
+ # a bare `?` for tabsii-map, which has no ci.yml at all -- so the one repo
281
+ # where the coverage question does not apply looked like the one repo where the
282
+ # measurement had failed. "Not applicable" and "could not tell" are different
283
+ # answers, and this file exists because conflating them is expensive.
284
+ _cov=$( (cd "$wt" && sh scripts/gate-coverage.sh 2>&1) |
285
+ sed 's/\x1b\[[0-9;]*m//g' |
286
+ grep -oE '([0-9]+/[0-9]+|no CI to mirror|NO GATE)' | head -1)
287
+ [ -n "$_cov" ] || _cov='coverage unknown'
288
+ case "$_cov" in
289
+ */*) _cov="covers $_cov" ;;
290
+ esac
291
+
292
+ if [ "$_rc" -eq 0 ]; then
293
+ case "$_out" in
294
+ *"verify ran NOTHING"*)
295
+ # verify.sh has already established this is not the #855 bug: no
296
+ # `.github/workflows/ci.yml`, so this repo has no CI for the gate to
297
+ # mirror and no shift-left obligation. Reporting it as a pass would be
298
+ # the exact conflation the gate itself refuses to make, so it gets its
299
+ # own verdict.
300
+ printf '%s\t%s\n' NO-CI 'gate ran nothing; repo has no ci.yml' ;;
301
+ *)
302
+ printf '%s\t%s\n' PASS "$(printf '%s (%s)' "$_checks" "$_cov")" ;;
303
+ esac
165
304
  return 0
166
305
  fi
306
+ _why=$(printf '%s' "$_out" | grep -E 'verify failed|verify ran NOTHING' | head -1)
307
+ [ -n "$_why" ] || _why=$(printf '%s' "$_out" | tail -1)
308
+ printf '%s\t%s\n' FAIL "$(printf '%s' "$_why" | sed 's/\x1b\[[0-9;]*m//g')"
309
+ return 1
310
+ }
311
+
312
+ # Phase 2: commit the already-staged worktree, push it, open the PR. Reached only
313
+ # when phase 1 came back clean for EVERY repo in this run.
314
+ ship_repo() {
315
+ d="$1"
316
+ label="$2"
317
+ slug="$3"
318
+ base="$4"
319
+ wt="$d/.worktrees/shared-sync"
320
+
167
321
  git -C "$wt" -c commit.gpgsign=false commit -q --no-verify -m "chore(shared): sync template-shared files
168
322
 
169
323
  Distributed by biffo-template's scripts/shared-sync.sh. These files are held
@@ -212,6 +366,14 @@ Run \`sh scripts/gate-coverage.sh\` after merging to see this repo's gate measur
212
366
  return 0
213
367
  }
214
368
 
369
+ # The list of repos this run will touch, one `label<TAB>dir<TAB>slug<TAB>base`
370
+ # per line. Written in the survey pass and read twice afterwards, so both phases
371
+ # work from the same set: a rehearsal that proved a different list of repos than
372
+ # the one that ships is worth nothing.
373
+ TARGETS=$(mktemp)
374
+ VERDICTS=$(mktemp)
375
+ trap 'rm -f "$TARGETS" "$VERDICTS"' EXIT
376
+
215
377
  printf '\nshared-file sync - template -> repos core upgrade cannot reach\n\n'
216
378
  for d in "$ESTATE"/*/; do
217
379
  d="${d%/}"
@@ -220,7 +382,21 @@ for d in "$ESTATE"/*/; do
220
382
  # The template is the source, not a target. It matched only because it carries
221
383
  # scripts/verify.sh, and comparing it to itself through origin/<base> reported
222
384
  # it as missing every file whenever its own dev was ahead of the checkout.
223
- [ "$d" = "$TEMPLATE_ROOT" ] && continue
385
+ #
386
+ # Compare REPOSITORIES, not working-tree paths. `[ "$d" = "$TEMPLATE_ROOT" ]`
387
+ # held only when this script was run from the primary checkout -- and AGENTS.md
388
+ # section 1 mandates that all work happens in a worktree, where TEMPLATE_ROOT
389
+ # is `.worktrees/<name>` and the primary checkout beside it looks like just
390
+ # another satellite carrying scripts/verify.sh. It has no biffo.core.json and
391
+ # no sibling/plugin marker, so nothing else excluded it either.
392
+ #
393
+ # It never fired because a primary checkout is normally byte-identical to
394
+ # origin/dev, so `diff_files` reported `current` and skipped it. It fires the
395
+ # moment the candidate files differ from origin/dev -- which is the only
396
+ # situation this script is ever run in while iterating on a shared file. The
397
+ # first `--rehearse` from a worktree would have staged the template as a
398
+ # target of its own distribution and opened a sync PR against its own dev.
399
+ [ "$(repo_dir "$d")" = "$TEMPLATE_REPO" ] && continue
224
400
  [ -n "$ONLY" ] && [ "$label" != "$ONLY" ] && continue
225
401
  applies "$d" || continue
226
402
  delta=$(diff_files "$d")
@@ -232,14 +408,134 @@ for d in "$ESTATE"/*/; do
232
408
  drifted=$((drifted + 1))
233
409
  if [ -n "$CHECK" ]; then
234
410
  printf '%-26s \033[31mDRIFTED\033[0m%s\n' "$label" "$delta"
235
- else
236
- sync_repo "$d" "$label" && synced=$((synced + 1))
411
+ continue
237
412
  fi
413
+ slug=$(repo_slug "$d")
414
+ base=$(gh repo view "$slug" --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null)
415
+ if [ -z "$base" ]; then
416
+ printf '%-26s \033[31mcannot resolve default branch\033[0m\n' "$label"
417
+ failed=$((failed + 1))
418
+ continue
419
+ fi
420
+ printf '%s\t%s\t%s\t%s\n' "$label" "$d" "$slug" "$base" >> "$TARGETS"
421
+ printf '%-26s \033[33mdrifted\033[0m%s\n' "$label" "$delta"
238
422
  done
239
423
 
240
- printf '\n%s current, %s drifted\n' "$current" "$drifted"
241
- if [ -n "$CHECK" ] && [ "$drifted" -gt 0 ]; then
242
- printf '\033[31mShared files have drifted.\033[0m Run without --check to open sync PRs.\n\n'
243
- exit 1
424
+ if [ -n "$CHECK" ]; then
425
+ printf '\n%s current, %s drifted\n' "$current" "$drifted"
426
+ if [ "$drifted" -gt 0 ]; then
427
+ printf '\033[31mShared files have drifted.\033[0m Run without --check to open sync PRs.\n\n'
428
+ exit 1
429
+ fi
430
+ printf '\n'
431
+ exit 0
432
+ fi
433
+
434
+ if [ ! -s "$TARGETS" ]; then
435
+ printf '\n%s current, %s drifted\n\n' "$current" "$drifted"
436
+ [ "${failed:-0}" -gt 0 ] && exit 1
437
+ exit 0
438
+ fi
439
+
440
+ # ---- Phase 1: rehearse -------------------------------------------------------
441
+ #
442
+ # Every target, before any of them ships. The order matters and it is the whole
443
+ # point of the change: staging repo 7 and finding the gate broken there must not
444
+ # leave six PRs already open in repos 1-6.
445
+ if [ -n "$NO_REHEARSE" ]; then
446
+ printf '\n\033[31m--no-rehearse: shipping %s repos unproven.\033[0m ' "$(wc -l < "$TARGETS" | tr -d ' ')"
447
+ printf 'Nothing has run the gate against these candidates.\n'
448
+ else
449
+ printf '\nrehearsing %s repos - staging the candidates and running each gate\n\n' \
450
+ "$(wc -l < "$TARGETS" | tr -d ' ')"
451
+ rehearsal_failures=0
452
+ while IFS="$TAB" read -r label d slug base; do
453
+ # Read the status IMMEDIATELY. `if ! stage_repo ...` would have collapsed
454
+ # "could not stage" (1) and "nothing to sync" (2) into one branch, and
455
+ # reported a repo that was already current as a staging failure.
456
+ stage_repo "$d" "$label" "$base"
457
+ stage_rc=$?
458
+ if [ "$stage_rc" -eq 2 ]; then
459
+ printf '%-26s \033[32mnothing to sync\033[0m\n' "$label"
460
+ printf '%s%s%s%s%s\n' "$label" "$TAB" SKIP "$TAB" 'nothing to sync' >> "$VERDICTS"
461
+ continue
462
+ fi
463
+ if [ "$stage_rc" -ne 0 ]; then
464
+ printf '%-26s \033[31mCANNOT STAGE\033[0m - fetch or worktree failed\n' "$label"
465
+ printf '%s%s%s%s%s\n' "$label" "$TAB" FAIL "$TAB" 'could not stage' >> "$VERDICTS"
466
+ rehearsal_failures=$((rehearsal_failures + 1))
467
+ continue
468
+ fi
469
+ verdict_line=$(rehearse_repo "$d/.worktrees/shared-sync")
470
+ verdict=$(printf '%s' "$verdict_line" | cut -f1)
471
+ detail=$(printf '%s' "$verdict_line" | cut -f2-)
472
+ case "$verdict" in
473
+ PASS) printf '%-26s \033[32mPASS\033[0m %s\n' "$label" "$detail" ;;
474
+ NO-CI) printf '%-26s \033[90mNO-CI\033[0m %s\n' "$label" "$detail" ;;
475
+ *)
476
+ printf '%-26s \033[31mFAIL\033[0m %s\n' "$label" "$detail"
477
+ printf '%-26s staged tree left at %s\n' '' "$d/.worktrees/shared-sync"
478
+ rehearsal_failures=$((rehearsal_failures + 1)) ;;
479
+ esac
480
+ printf '%s%s%s%s%s\n' "$label" "$TAB" "$verdict" "$TAB" "$detail" >> "$VERDICTS"
481
+ done < "$TARGETS"
482
+
483
+ if [ "$rehearsal_failures" -gt 0 ]; then
484
+ printf '\n\033[31mrehearsal failed in %s repo(s) - NOTHING was pushed and no PR was opened.\033[0m\n' \
485
+ "$rehearsal_failures"
486
+ printf 'Fix the candidate files here in the template, then run this again. Each\n'
487
+ printf 'round that ships before it is proven costs one PR per satellite: there were\n'
488
+ printf '84 of them on 2026-07-29, in 7 rounds, and six of those rounds carried\n'
489
+ printf 'scripts/verify.sh alone.\n'
490
+ printf 'The failing repos keep their staged worktree so the gate can be run there;\n'
491
+ printf 'the clean ones were removed.\n\n'
492
+ while IFS="$TAB" read -r label verdict detail; do
493
+ [ "$verdict" = FAIL ] || continue
494
+ printf ' %-24s %s\n' "$label" "$detail"
495
+ done < "$VERDICTS"
496
+ printf '\n'
497
+ # Reap the worktrees of the repos that passed. They staged cleanly and are
498
+ # not evidence of anything; leaving 11 of them behind after a refusal is the
499
+ # orphan-worktree accumulation AGENTS.md section 1 exists to prevent.
500
+ while IFS="$TAB" read -r label d slug base; do
501
+ grep -q "^$label${TAB}FAIL${TAB}" "$VERDICTS" && continue
502
+ git -C "$d" worktree remove --force "$d/.worktrees/shared-sync" 2>/dev/null
503
+ git -C "$d" branch -D chore/sync-shared 2>/dev/null
504
+ done < "$TARGETS"
505
+ exit 1
506
+ fi
507
+ printf '\nrehearsal clean in every repo\n'
244
508
  fi
245
- printf '\n'
509
+
510
+ if [ -n "$REHEARSE_ONLY" ]; then
511
+ # Staged worktrees are left in place deliberately: --rehearse answers "would
512
+ # this land?", and the tree that answered it is the thing to go and look at.
513
+ printf '\n--rehearse: nothing pushed, no PRs opened. Staged trees are at\n'
514
+ printf '<repo>/.worktrees/shared-sync; re-run without --rehearse to ship.\n\n'
515
+ exit 0
516
+ fi
517
+
518
+ # ---- Phase 2: ship -----------------------------------------------------------
519
+ printf '\nopening PRs\n\n'
520
+ while IFS="$TAB" read -r label d slug base; do
521
+ grep -q "^$label${TAB}SKIP${TAB}" "$VERDICTS" 2>/dev/null && continue
522
+ # --no-rehearse skips phase 1 entirely, so nothing has staged these yet.
523
+ if [ -n "$NO_REHEARSE" ]; then
524
+ stage_repo "$d" "$label" "$base"
525
+ case $? in
526
+ 2) printf '%-26s \033[32mnothing to sync\033[0m\n' "$label"; continue ;;
527
+ 1) printf '%-26s \033[31mCANNOT STAGE\033[0m\n' "$label"; failed=$((failed + 1)); continue ;;
528
+ esac
529
+ fi
530
+ if ship_repo "$d" "$label" "$slug" "$base"; then
531
+ synced=$((synced + 1))
532
+ else
533
+ failed=$((failed + 1))
534
+ fi
535
+ done < "$TARGETS"
536
+
537
+ printf '\n%s current, %s drifted, %s PR(s) opened' "$current" "$drifted" "$synced"
538
+ [ "${failed:-0}" -gt 0 ] && printf ', \033[31m%s failed\033[0m' "$failed"
539
+ printf '\n\n'
540
+ [ "${failed:-0}" -gt 0 ] && exit 1
541
+ exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.182.0",
3
+ "version": "0.183.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",