@biffo/cli 0.219.0 → 0.220.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.
@@ -60,6 +60,19 @@ This file is distributed by the template and kept in step by
60
60
 
61
61
  - Get CI green and confirm it: `gh pr checks <N>`. A green local run is not
62
62
  sufficient — verify the actual PR checks.
63
+ - **Wait with `scripts/wait-for-checks.sh`, not a hand-rolled loop:**
64
+
65
+ ```bash
66
+ sh scripts/wait-for-checks.sh <N> # 0 green · 1 failed · 2 cannot tell
67
+ ```
68
+
69
+ Do not write your own `until … grep -c pending … done`. That polls for the
70
+ **absence** of pending checks, so the empty window right after
71
+ `gh pr update-branch` — superseded runs dropped, new ones not yet registered —
72
+ reads as "all green" and merges a PR whose CI has not started. The script
73
+ waits on a **positive** signal instead, and its **exit 2 means "cannot tell",
74
+ which is never a pass**.
75
+
63
76
  - **Squash-merge, delete the branch, remove the worktree:**
64
77
  `gh pr merge <N> --squash --delete-branch`.
65
78
 
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Wait for a pull request's checks to finish, without mistaking "not started"
4
+ # for "all green".
5
+ #
6
+ # ## Why this exists
7
+ #
8
+ # Every session hand-rolls this loop, and the natural formulation is wrong in
9
+ # the dangerous direction:
10
+ #
11
+ # until [ "$(gh pr checks "$N" | grep -c pending)" = 0 ]; do sleep 30; done
12
+ #
13
+ # That polls for the **absence** of pending work, so a transient empty set reads
14
+ # as completion. Immediately after `gh pr update-branch` GitHub drops the
15
+ # superseded check runs before registering the new ones — for a few seconds
16
+ # there are **zero** checks, `pending` is 0, and the loop exits on a PR whose CI
17
+ # has not started. The caller then merges. Observed on 2026-08-02 while clearing
18
+ # a 13-repo queue; the same session also wrote an `until` whose `|| &&`
19
+ # precedence never terminated and burned a full 10-minute timeout.
20
+ #
21
+ # That is the estate's dominant failure shape — a gate passing because it cannot
22
+ # run — reproduced inside the agent's own tooling, where no CI guard can see it.
23
+ #
24
+ # ## The rule this encodes
25
+ #
26
+ # **Wait on a positive signal, never on the absence of a negative.** Two ways to
27
+ # get one, strongest first:
28
+ #
29
+ # 1. **Branch protection's required contexts.** If the base branch is protected,
30
+ # those names are exactly the checks that MUST report, so "every required
31
+ # context has concluded" is a direct answer rather than an inference. This is
32
+ # the only condition that cannot be satisfied by an empty or half-registered
33
+ # set.
34
+ # 2. **Stability, when protection is unreadable.** Some repos are unprotected
35
+ # (both plugin repos were until 2026-07-27) and a token may lack the scope to
36
+ # read protection. Then: at least one check present, all concluded, and the
37
+ # same count seen on two consecutive polls — so a fast check concluding while
38
+ # slower ones are still registering does not end the wait early.
39
+ #
40
+ # ## Exit codes, and why 2 exists
41
+ #
42
+ # 0 every required/observed check concluded, none failed
43
+ # 1 a check failed — the names are printed
44
+ # 2 cannot determine: timed out, no checks ever appeared, PR unreadable
45
+ #
46
+ # 2 is distinct from 1 on purpose, and neither is 0. A timeout is not a pass,
47
+ # and a caller that treats "cannot tell" as "green" has rebuilt the defect this
48
+ # script exists to prevent. `ci-wiring-audit.sh` uses the same 2-means-cannot-run
49
+ # convention.
50
+ #
51
+ # `cancelled` is reported separately rather than as a failure: on this estate's
52
+ # self-hosted runners a cancelled job is usually spot reclamation or a
53
+ # `cancel-in-progress` concurrency group, not the code. It still exits 1 —
54
+ # something must be re-run — but the message says which, so nobody debugs a
55
+ # phantom.
56
+ #
57
+ # ## Usage
58
+ #
59
+ # sh scripts/wait-for-checks.sh <pr-number> [-R owner/repo]
60
+ # [--timeout SECONDS] [--interval SECONDS]
61
+ #
62
+ # Requires `gh`, authenticated. Uses gh's embedded jq, so no jq binary is needed.
63
+
64
+ set -uo pipefail
65
+
66
+ PR=""
67
+ REPO=""
68
+ TIMEOUT="${WAIT_FOR_CHECKS_TIMEOUT:-1800}"
69
+ INTERVAL="${WAIT_FOR_CHECKS_INTERVAL:-30}"
70
+
71
+ usage() {
72
+ sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//'
73
+ exit 2
74
+ }
75
+
76
+ while [ $# -gt 0 ]; do
77
+ case "$1" in
78
+ -R | --repo)
79
+ REPO="${2:-}"
80
+ shift 2
81
+ ;;
82
+ --timeout)
83
+ TIMEOUT="${2:-}"
84
+ shift 2
85
+ ;;
86
+ --interval)
87
+ INTERVAL="${2:-}"
88
+ shift 2
89
+ ;;
90
+ -h | --help) usage ;;
91
+ *)
92
+ PR="$1"
93
+ shift
94
+ ;;
95
+ esac
96
+ done
97
+
98
+ [ -n "$PR" ] || {
99
+ echo "wait-for-checks: no PR number given" >&2
100
+ usage
101
+ }
102
+
103
+ RED=$(printf '\033[31m')
104
+ GREEN=$(printf '\033[32m')
105
+ DIM=$(printf '\033[90m')
106
+ OFF=$(printf '\033[0m')
107
+
108
+ gh_pr() {
109
+ if [ -n "$REPO" ]; then gh pr "$@" --repo "$REPO"; else gh pr "$@"; fi
110
+ }
111
+
112
+ gh_api() {
113
+ gh api "$@" 2>/dev/null
114
+ }
115
+
116
+ # --- What is this PR, and is there anything to wait for? ----------------------
117
+
118
+ meta=$(gh_pr view "$PR" --json state,baseRefName --jq '"\(.state)\t\(.baseRefName)"') || {
119
+ echo "${RED}wait-for-checks: cannot read PR $PR${OFF}" >&2
120
+ exit 2
121
+ }
122
+ state=${meta%% *}
123
+ base=${meta##* }
124
+
125
+ case "$state" in
126
+ MERGED | CLOSED)
127
+ echo "${DIM}PR $PR is $state — nothing to wait for.${OFF}"
128
+ exit 0
129
+ ;;
130
+ esac
131
+
132
+ # --- Signal 1: the checks branch protection says MUST report ------------------
133
+
134
+ owner_repo="$REPO"
135
+ [ -n "$owner_repo" ] || owner_repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null)
136
+
137
+ required=""
138
+ if [ -n "$owner_repo" ]; then
139
+ required=$(gh_api "repos/$owner_repo/branches/$base/protection" \
140
+ --jq '.required_status_checks.contexts[]?' | sort -u)
141
+ fi
142
+
143
+ if [ -n "$required" ]; then
144
+ echo "${DIM}Waiting on $(echo "$required" | wc -l | tr -d ' ') required check(s) on $base.${OFF}"
145
+ else
146
+ # Not an error. An unprotected branch is a real configuration, and a token
147
+ # without the scope to read protection is common. Say which mode is in use, so
148
+ # a weaker guarantee is never mistaken for the strong one.
149
+ echo "${DIM}No readable branch protection on $base — falling back to stability.${OFF}"
150
+ fi
151
+
152
+ # --- Poll ---------------------------------------------------------------------
153
+
154
+ deadline=$(($(date +%s) + TIMEOUT))
155
+ prev_count=-1
156
+ rollup=""
157
+
158
+ while :; do
159
+ rollup=$(gh_pr view "$PR" --json statusCheckRollup --jq '
160
+ [ .statusCheckRollup[]?
161
+ | { name: (.name // .context),
162
+ state: (.conclusion // .state // (if .status == "COMPLETED" then "" else null end))
163
+ }
164
+ ] | .[] | "\(.name)\t\(.state // "")"') || rollup=""
165
+
166
+ count=0
167
+ [ -n "$rollup" ] && count=$(printf '%s\n' "$rollup" | grep -c .)
168
+
169
+ # Every check that has reported a terminal state.
170
+ concluded=$(printf '%s\n' "$rollup" | awk -F'\t' 'NF && $2 != "" && $2 != "PENDING" && $2 != "IN_PROGRESS" && $2 != "QUEUED" && $2 != "WAITING" { print $1 }')
171
+
172
+ done_waiting=0
173
+
174
+ if [ -n "$required" ]; then
175
+ # Strong condition: every required context is present AND concluded.
176
+ missing=""
177
+ while IFS= read -r ctx; do
178
+ [ -n "$ctx" ] || continue
179
+ printf '%s\n' "$concluded" | grep -Fxq "$ctx" || missing="$missing $ctx"
180
+ done <<EOF
181
+ $required
182
+ EOF
183
+ [ -z "$missing" ] && done_waiting=1
184
+ else
185
+ # Fallback: at least one check, all concluded, and the set has stopped
186
+ # growing. The count check is what stops a fast Secret Scan concluding alone
187
+ # while five slower jobs are still being registered.
188
+ if [ "$count" -gt 0 ]; then
189
+ n_concluded=$(printf '%s\n' "$concluded" | grep -c .)
190
+ if [ "$n_concluded" = "$count" ] && [ "$count" = "$prev_count" ]; then
191
+ done_waiting=1
192
+ fi
193
+ fi
194
+ fi
195
+
196
+ [ "$done_waiting" = "1" ] && break
197
+
198
+ prev_count=$count
199
+
200
+ now=$(date +%s)
201
+ if [ "$now" -ge "$deadline" ]; then
202
+ echo "${RED}wait-for-checks: timed out after ${TIMEOUT}s.${OFF}" >&2
203
+ if [ "$count" = "0" ]; then
204
+ # The exact case the naive loop gets wrong, so name it explicitly.
205
+ echo "No checks ever appeared on PR $PR. That is 'cannot tell', not 'green'." >&2
206
+ else
207
+ echo "Still unfinished:" >&2
208
+ printf '%s\n' "$rollup" | awk -F'\t' 'NF && ($2 == "" || $2 == "PENDING" || $2 == "IN_PROGRESS" || $2 == "QUEUED" || $2 == "WAITING") { print " " $1 }' >&2
209
+ fi
210
+ exit 2
211
+ fi
212
+
213
+ sleep "$INTERVAL"
214
+ done
215
+
216
+ # --- Report -------------------------------------------------------------------
217
+
218
+ failed=$(printf '%s\n' "$rollup" | awk -F'\t' 'NF && ($2 == "FAILURE" || $2 == "TIMED_OUT" || $2 == "ACTION_REQUIRED" || $2 == "STARTUP_FAILURE" || $2 == "ERROR") { print " " $1 " (" $2 ")" }')
219
+ cancelled=$(printf '%s\n' "$rollup" | awk -F'\t' 'NF && $2 == "CANCELLED" { print " " $1 }')
220
+
221
+ if [ -n "$cancelled" ]; then
222
+ echo "${RED}Cancelled:${OFF}"
223
+ printf '%s\n' "$cancelled"
224
+ echo "${DIM}A cancelled check is usually infrastructure (spot reclamation, or a" >&2
225
+ echo "cancel-in-progress concurrency group), not your code. Re-run it rather" >&2
226
+ echo "than debugging a phantom.${OFF}" >&2
227
+ fi
228
+
229
+ if [ -n "$failed" ]; then
230
+ echo "${RED}Failed:${OFF}"
231
+ printf '%s\n' "$failed"
232
+ exit 1
233
+ fi
234
+
235
+ [ -n "$cancelled" ] && exit 1
236
+
237
+ echo "${GREEN}All checks concluded, none failed.${OFF}"
238
+ exit 0
@@ -60,6 +60,19 @@ This file is distributed by the template and kept in step by
60
60
 
61
61
  - Get CI green and confirm it: `gh pr checks <N>`. A green local run is not
62
62
  sufficient — verify the actual PR checks.
63
+ - **Wait with `scripts/wait-for-checks.sh`, not a hand-rolled loop:**
64
+
65
+ ```bash
66
+ sh scripts/wait-for-checks.sh <N> # 0 green · 1 failed · 2 cannot tell
67
+ ```
68
+
69
+ Do not write your own `until … grep -c pending … done`. That polls for the
70
+ **absence** of pending checks, so the empty window right after
71
+ `gh pr update-branch` — superseded runs dropped, new ones not yet registered —
72
+ reads as "all green" and merges a PR whose CI has not started. The script
73
+ waits on a **positive** signal instead, and its **exit 2 means "cannot tell",
74
+ which is never a pass**.
75
+
63
76
  - **Squash-merge, delete the branch, remove the worktree:**
64
77
  `gh pr merge <N> --squash --delete-branch`.
65
78
 
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Wait for a pull request's checks to finish, without mistaking "not started"
4
+ # for "all green".
5
+ #
6
+ # ## Why this exists
7
+ #
8
+ # Every session hand-rolls this loop, and the natural formulation is wrong in
9
+ # the dangerous direction:
10
+ #
11
+ # until [ "$(gh pr checks "$N" | grep -c pending)" = 0 ]; do sleep 30; done
12
+ #
13
+ # That polls for the **absence** of pending work, so a transient empty set reads
14
+ # as completion. Immediately after `gh pr update-branch` GitHub drops the
15
+ # superseded check runs before registering the new ones — for a few seconds
16
+ # there are **zero** checks, `pending` is 0, and the loop exits on a PR whose CI
17
+ # has not started. The caller then merges. Observed on 2026-08-02 while clearing
18
+ # a 13-repo queue; the same session also wrote an `until` whose `|| &&`
19
+ # precedence never terminated and burned a full 10-minute timeout.
20
+ #
21
+ # That is the estate's dominant failure shape — a gate passing because it cannot
22
+ # run — reproduced inside the agent's own tooling, where no CI guard can see it.
23
+ #
24
+ # ## The rule this encodes
25
+ #
26
+ # **Wait on a positive signal, never on the absence of a negative.** Two ways to
27
+ # get one, strongest first:
28
+ #
29
+ # 1. **Branch protection's required contexts.** If the base branch is protected,
30
+ # those names are exactly the checks that MUST report, so "every required
31
+ # context has concluded" is a direct answer rather than an inference. This is
32
+ # the only condition that cannot be satisfied by an empty or half-registered
33
+ # set.
34
+ # 2. **Stability, when protection is unreadable.** Some repos are unprotected
35
+ # (both plugin repos were until 2026-07-27) and a token may lack the scope to
36
+ # read protection. Then: at least one check present, all concluded, and the
37
+ # same count seen on two consecutive polls — so a fast check concluding while
38
+ # slower ones are still registering does not end the wait early.
39
+ #
40
+ # ## Exit codes, and why 2 exists
41
+ #
42
+ # 0 every required/observed check concluded, none failed
43
+ # 1 a check failed — the names are printed
44
+ # 2 cannot determine: timed out, no checks ever appeared, PR unreadable
45
+ #
46
+ # 2 is distinct from 1 on purpose, and neither is 0. A timeout is not a pass,
47
+ # and a caller that treats "cannot tell" as "green" has rebuilt the defect this
48
+ # script exists to prevent. `ci-wiring-audit.sh` uses the same 2-means-cannot-run
49
+ # convention.
50
+ #
51
+ # `cancelled` is reported separately rather than as a failure: on this estate's
52
+ # self-hosted runners a cancelled job is usually spot reclamation or a
53
+ # `cancel-in-progress` concurrency group, not the code. It still exits 1 —
54
+ # something must be re-run — but the message says which, so nobody debugs a
55
+ # phantom.
56
+ #
57
+ # ## Usage
58
+ #
59
+ # sh scripts/wait-for-checks.sh <pr-number> [-R owner/repo]
60
+ # [--timeout SECONDS] [--interval SECONDS]
61
+ #
62
+ # Requires `gh`, authenticated. Uses gh's embedded jq, so no jq binary is needed.
63
+
64
+ set -uo pipefail
65
+
66
+ PR=""
67
+ REPO=""
68
+ TIMEOUT="${WAIT_FOR_CHECKS_TIMEOUT:-1800}"
69
+ INTERVAL="${WAIT_FOR_CHECKS_INTERVAL:-30}"
70
+
71
+ usage() {
72
+ sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//'
73
+ exit 2
74
+ }
75
+
76
+ while [ $# -gt 0 ]; do
77
+ case "$1" in
78
+ -R | --repo)
79
+ REPO="${2:-}"
80
+ shift 2
81
+ ;;
82
+ --timeout)
83
+ TIMEOUT="${2:-}"
84
+ shift 2
85
+ ;;
86
+ --interval)
87
+ INTERVAL="${2:-}"
88
+ shift 2
89
+ ;;
90
+ -h | --help) usage ;;
91
+ *)
92
+ PR="$1"
93
+ shift
94
+ ;;
95
+ esac
96
+ done
97
+
98
+ [ -n "$PR" ] || {
99
+ echo "wait-for-checks: no PR number given" >&2
100
+ usage
101
+ }
102
+
103
+ RED=$(printf '\033[31m')
104
+ GREEN=$(printf '\033[32m')
105
+ DIM=$(printf '\033[90m')
106
+ OFF=$(printf '\033[0m')
107
+
108
+ gh_pr() {
109
+ if [ -n "$REPO" ]; then gh pr "$@" --repo "$REPO"; else gh pr "$@"; fi
110
+ }
111
+
112
+ gh_api() {
113
+ gh api "$@" 2>/dev/null
114
+ }
115
+
116
+ # --- What is this PR, and is there anything to wait for? ----------------------
117
+
118
+ meta=$(gh_pr view "$PR" --json state,baseRefName --jq '"\(.state)\t\(.baseRefName)"') || {
119
+ echo "${RED}wait-for-checks: cannot read PR $PR${OFF}" >&2
120
+ exit 2
121
+ }
122
+ state=${meta%% *}
123
+ base=${meta##* }
124
+
125
+ case "$state" in
126
+ MERGED | CLOSED)
127
+ echo "${DIM}PR $PR is $state — nothing to wait for.${OFF}"
128
+ exit 0
129
+ ;;
130
+ esac
131
+
132
+ # --- Signal 1: the checks branch protection says MUST report ------------------
133
+
134
+ owner_repo="$REPO"
135
+ [ -n "$owner_repo" ] || owner_repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null)
136
+
137
+ required=""
138
+ if [ -n "$owner_repo" ]; then
139
+ required=$(gh_api "repos/$owner_repo/branches/$base/protection" \
140
+ --jq '.required_status_checks.contexts[]?' | sort -u)
141
+ fi
142
+
143
+ if [ -n "$required" ]; then
144
+ echo "${DIM}Waiting on $(echo "$required" | wc -l | tr -d ' ') required check(s) on $base.${OFF}"
145
+ else
146
+ # Not an error. An unprotected branch is a real configuration, and a token
147
+ # without the scope to read protection is common. Say which mode is in use, so
148
+ # a weaker guarantee is never mistaken for the strong one.
149
+ echo "${DIM}No readable branch protection on $base — falling back to stability.${OFF}"
150
+ fi
151
+
152
+ # --- Poll ---------------------------------------------------------------------
153
+
154
+ deadline=$(($(date +%s) + TIMEOUT))
155
+ prev_count=-1
156
+ rollup=""
157
+
158
+ while :; do
159
+ rollup=$(gh_pr view "$PR" --json statusCheckRollup --jq '
160
+ [ .statusCheckRollup[]?
161
+ | { name: (.name // .context),
162
+ state: (.conclusion // .state // (if .status == "COMPLETED" then "" else null end))
163
+ }
164
+ ] | .[] | "\(.name)\t\(.state // "")"') || rollup=""
165
+
166
+ count=0
167
+ [ -n "$rollup" ] && count=$(printf '%s\n' "$rollup" | grep -c .)
168
+
169
+ # Every check that has reported a terminal state.
170
+ concluded=$(printf '%s\n' "$rollup" | awk -F'\t' 'NF && $2 != "" && $2 != "PENDING" && $2 != "IN_PROGRESS" && $2 != "QUEUED" && $2 != "WAITING" { print $1 }')
171
+
172
+ done_waiting=0
173
+
174
+ if [ -n "$required" ]; then
175
+ # Strong condition: every required context is present AND concluded.
176
+ missing=""
177
+ while IFS= read -r ctx; do
178
+ [ -n "$ctx" ] || continue
179
+ printf '%s\n' "$concluded" | grep -Fxq "$ctx" || missing="$missing $ctx"
180
+ done <<EOF
181
+ $required
182
+ EOF
183
+ [ -z "$missing" ] && done_waiting=1
184
+ else
185
+ # Fallback: at least one check, all concluded, and the set has stopped
186
+ # growing. The count check is what stops a fast Secret Scan concluding alone
187
+ # while five slower jobs are still being registered.
188
+ if [ "$count" -gt 0 ]; then
189
+ n_concluded=$(printf '%s\n' "$concluded" | grep -c .)
190
+ if [ "$n_concluded" = "$count" ] && [ "$count" = "$prev_count" ]; then
191
+ done_waiting=1
192
+ fi
193
+ fi
194
+ fi
195
+
196
+ [ "$done_waiting" = "1" ] && break
197
+
198
+ prev_count=$count
199
+
200
+ now=$(date +%s)
201
+ if [ "$now" -ge "$deadline" ]; then
202
+ echo "${RED}wait-for-checks: timed out after ${TIMEOUT}s.${OFF}" >&2
203
+ if [ "$count" = "0" ]; then
204
+ # The exact case the naive loop gets wrong, so name it explicitly.
205
+ echo "No checks ever appeared on PR $PR. That is 'cannot tell', not 'green'." >&2
206
+ else
207
+ echo "Still unfinished:" >&2
208
+ printf '%s\n' "$rollup" | awk -F'\t' 'NF && ($2 == "" || $2 == "PENDING" || $2 == "IN_PROGRESS" || $2 == "QUEUED" || $2 == "WAITING") { print " " $1 }' >&2
209
+ fi
210
+ exit 2
211
+ fi
212
+
213
+ sleep "$INTERVAL"
214
+ done
215
+
216
+ # --- Report -------------------------------------------------------------------
217
+
218
+ failed=$(printf '%s\n' "$rollup" | awk -F'\t' 'NF && ($2 == "FAILURE" || $2 == "TIMED_OUT" || $2 == "ACTION_REQUIRED" || $2 == "STARTUP_FAILURE" || $2 == "ERROR") { print " " $1 " (" $2 ")" }')
219
+ cancelled=$(printf '%s\n' "$rollup" | awk -F'\t' 'NF && $2 == "CANCELLED" { print " " $1 }')
220
+
221
+ if [ -n "$cancelled" ]; then
222
+ echo "${RED}Cancelled:${OFF}"
223
+ printf '%s\n' "$cancelled"
224
+ echo "${DIM}A cancelled check is usually infrastructure (spot reclamation, or a" >&2
225
+ echo "cancel-in-progress concurrency group), not your code. Re-run it rather" >&2
226
+ echo "than debugging a phantom.${OFF}" >&2
227
+ fi
228
+
229
+ if [ -n "$failed" ]; then
230
+ echo "${RED}Failed:${OFF}"
231
+ printf '%s\n' "$failed"
232
+ exit 1
233
+ fi
234
+
235
+ [ -n "$cancelled" ] && exit 1
236
+
237
+ echo "${GREEN}All checks concluded, none failed.${OFF}"
238
+ exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.219.0",
3
+ "version": "0.220.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",