@biffo/cli 0.235.0 → 0.236.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_skeletons/plugin-template/AGENTS.md +7 -2
- package/_skeletons/plugin-template/scripts/biffo.sh +16 -0
- package/_skeletons/sibling-template/AGENTS.md +7 -2
- package/_skeletons/sibling-template/scripts/biffo.sh +16 -0
- package/dist/index.js +5 -0
- package/package.json +1 -1
- package/_skeletons/plugin-template/scripts/wait-for-checks.sh +0 -238
- package/_skeletons/sibling-template/scripts/wait-for-checks.sh +0 -238
|
@@ -96,12 +96,17 @@ it deliberately and say so in a comment; never steal a fresh one.
|
|
|
96
96
|
|
|
97
97
|
- Get CI green and confirm it: `gh pr checks <N>`. A green local run is not
|
|
98
98
|
sufficient — verify the actual PR checks.
|
|
99
|
-
- **Wait with `
|
|
99
|
+
- **Wait with the CLI's `wait-for-checks`, not a hand-rolled loop:**
|
|
100
100
|
|
|
101
101
|
```bash
|
|
102
|
-
sh scripts/wait-for-checks
|
|
102
|
+
sh scripts/biffo.sh wait-for-checks <N> # 0 green · 1 failed · 2 cannot tell
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
This repo no longer carries its own copy of the script. `scripts/biffo.sh`
|
|
106
|
+
resolves the version-pinned Biffo CLI from `.biffo-shared-version` and runs
|
|
107
|
+
the canonical copy that ships inside the package, so there is one script
|
|
108
|
+
rather than one per repo (#1109).
|
|
109
|
+
|
|
105
110
|
Do not write your own `until … grep -c pending … done`. That polls for the
|
|
106
111
|
**absence** of pending checks, so the empty window right after
|
|
107
112
|
`gh pr update-branch` — superseded runs dropped, new ones not yet registered —
|
|
@@ -77,6 +77,22 @@ if [ -f biffo.core.json ]; then
|
|
|
77
77
|
exec npx --yes "@biffo/cli@$version" "$@"
|
|
78
78
|
fi
|
|
79
79
|
|
|
80
|
+
# Nothing above matched, so this must be the template — the only repo that
|
|
81
|
+
# carries `cli/`. A satellite that reaches here has no pin, which since #1109
|
|
82
|
+
# means it cannot run guards at all; before this branch it fell through and
|
|
83
|
+
# exec'd a `tsx` that does not exist, exiting 127 with no explanation.
|
|
84
|
+
#
|
|
85
|
+
# `biffo sibling create` now stamps the pin at birth, so this is reachable
|
|
86
|
+
# mainly by a repo scaffolded before that, or by a plugin repo (`biffo plugin
|
|
87
|
+
# create` scaffolds INTO an existing repo, so it has no standalone repo root to
|
|
88
|
+
# stamp). `shared-sync.sh` writes the pin on its first run, which is the fix.
|
|
89
|
+
if [ ! -d cli ]; then
|
|
90
|
+
echo "biffo.sh: no biffo.core.json, no .biffo-shared-version, and no cli/ here." >&2
|
|
91
|
+
echo " If this is a satellite, run shared-sync from the template to stamp it:" >&2
|
|
92
|
+
echo " sh scripts/shared-sync.sh --estate <path-to-your-repos>" >&2
|
|
93
|
+
exit 2
|
|
94
|
+
fi
|
|
95
|
+
|
|
80
96
|
# NOT `pnpm --filter @biffo/cli exec tsx ...`: `pnpm exec` normalises every
|
|
81
97
|
# non-zero exit to 1. Verified — the CLI exits 2 and pnpm reports 1.
|
|
82
98
|
#
|
|
@@ -96,12 +96,17 @@ it deliberately and say so in a comment; never steal a fresh one.
|
|
|
96
96
|
|
|
97
97
|
- Get CI green and confirm it: `gh pr checks <N>`. A green local run is not
|
|
98
98
|
sufficient — verify the actual PR checks.
|
|
99
|
-
- **Wait with `
|
|
99
|
+
- **Wait with the CLI's `wait-for-checks`, not a hand-rolled loop:**
|
|
100
100
|
|
|
101
101
|
```bash
|
|
102
|
-
sh scripts/wait-for-checks
|
|
102
|
+
sh scripts/biffo.sh wait-for-checks <N> # 0 green · 1 failed · 2 cannot tell
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
This repo no longer carries its own copy of the script. `scripts/biffo.sh`
|
|
106
|
+
resolves the version-pinned Biffo CLI from `.biffo-shared-version` and runs
|
|
107
|
+
the canonical copy that ships inside the package, so there is one script
|
|
108
|
+
rather than one per repo (#1109).
|
|
109
|
+
|
|
105
110
|
Do not write your own `until … grep -c pending … done`. That polls for the
|
|
106
111
|
**absence** of pending checks, so the empty window right after
|
|
107
112
|
`gh pr update-branch` — superseded runs dropped, new ones not yet registered —
|
|
@@ -77,6 +77,22 @@ if [ -f biffo.core.json ]; then
|
|
|
77
77
|
exec npx --yes "@biffo/cli@$version" "$@"
|
|
78
78
|
fi
|
|
79
79
|
|
|
80
|
+
# Nothing above matched, so this must be the template — the only repo that
|
|
81
|
+
# carries `cli/`. A satellite that reaches here has no pin, which since #1109
|
|
82
|
+
# means it cannot run guards at all; before this branch it fell through and
|
|
83
|
+
# exec'd a `tsx` that does not exist, exiting 127 with no explanation.
|
|
84
|
+
#
|
|
85
|
+
# `biffo sibling create` now stamps the pin at birth, so this is reachable
|
|
86
|
+
# mainly by a repo scaffolded before that, or by a plugin repo (`biffo plugin
|
|
87
|
+
# create` scaffolds INTO an existing repo, so it has no standalone repo root to
|
|
88
|
+
# stamp). `shared-sync.sh` writes the pin on its first run, which is the fix.
|
|
89
|
+
if [ ! -d cli ]; then
|
|
90
|
+
echo "biffo.sh: no biffo.core.json, no .biffo-shared-version, and no cli/ here." >&2
|
|
91
|
+
echo " If this is a satellite, run shared-sync from the template to stamp it:" >&2
|
|
92
|
+
echo " sh scripts/shared-sync.sh --estate <path-to-your-repos>" >&2
|
|
93
|
+
exit 2
|
|
94
|
+
fi
|
|
95
|
+
|
|
80
96
|
# NOT `pnpm --filter @biffo/cli exec tsx ...`: `pnpm exec` normalises every
|
|
81
97
|
# non-zero exit to 1. Verified — the CLI exits 2 and pnpm reports 1.
|
|
82
98
|
#
|
package/dist/index.js
CHANGED
|
@@ -6589,6 +6589,11 @@ function writeSiblingTemplate(templateRoot, targetDir, config, context) {
|
|
|
6589
6589
|
2
|
|
6590
6590
|
) + "\n"
|
|
6591
6591
|
);
|
|
6592
|
+
writeFileSync7(
|
|
6593
|
+
join21(targetDir, ".biffo-shared-version"),
|
|
6594
|
+
`core-v${context.templateVersion.replace(/^core-v/, "")}
|
|
6595
|
+
`
|
|
6596
|
+
);
|
|
6592
6597
|
const envPath = join21(targetDir, "apps", "frontend", ".env.example");
|
|
6593
6598
|
try {
|
|
6594
6599
|
const path = basePathFor(context.pathPrefix);
|
package/package.json
CHANGED
|
@@ -1,238 +0,0 @@
|
|
|
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
|
|
@@ -1,238 +0,0 @@
|
|
|
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
|