@biffo/cli 0.174.2 → 0.176.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Distribute the template's shared files to the repos `biffo core upgrade`
|
|
4
|
+
# cannot reach, and make drift visible when it happens.
|
|
5
|
+
#
|
|
6
|
+
# ## Why this exists
|
|
7
|
+
#
|
|
8
|
+
# Instances carry `biffo.core.json` and a `core-manifest.json`, so the CLI
|
|
9
|
+
# three-way-merges template-owned paths into them. **Sibling apps and plugin
|
|
10
|
+
# repos are separate repositories with neither.** The documented channel to them
|
|
11
|
+
# was "vendor it into the skeleton, plus a one-time manual copy-in for existing
|
|
12
|
+
# ones", which is not a mechanism: the skeleton only helps repos created
|
|
13
|
+
# afterwards, and nothing ever prompts the copy-in.
|
|
14
|
+
#
|
|
15
|
+
# The cost, twice over:
|
|
16
|
+
#
|
|
17
|
+
# - AGENTS.md drifted 68 lines behind in tabsii, missing the very workflow
|
|
18
|
+
# guardrails the template had already written (#559).
|
|
19
|
+
# - Eight repos ran a local gate two versions old. `tabsii-crm` checked ONE
|
|
20
|
+
# thing in eight on a 700-line change and printed `verify passed` (#855).
|
|
21
|
+
#
|
|
22
|
+
# Both were found by a human noticing, months and hours late respectively. This
|
|
23
|
+
# turns that into a command that reports, and a `--check` that fails.
|
|
24
|
+
#
|
|
25
|
+
# ## What it is not
|
|
26
|
+
#
|
|
27
|
+
# A **one-way overwrite**, not a merge. `shared-files.json` may only list files
|
|
28
|
+
# every sibling and plugin should hold verbatim. Anything a repo is expected to
|
|
29
|
+
# customise does not belong in it — that is what the instance manifest's
|
|
30
|
+
# three-way merge is for, and this deliberately has no such subtlety.
|
|
31
|
+
#
|
|
32
|
+
# 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
|
|
35
|
+
# sh scripts/shared-sync.sh --estate ~/code --repo tabsii-crm
|
|
36
|
+
|
|
37
|
+
set -uo pipefail
|
|
38
|
+
|
|
39
|
+
TEMPLATE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
40
|
+
MANIFEST="$TEMPLATE_ROOT/shared-files.json"
|
|
41
|
+
[ -f "$MANIFEST" ] || { echo "no shared-files.json beside $0" >&2; exit 2; }
|
|
42
|
+
|
|
43
|
+
CHECK=""
|
|
44
|
+
ESTATE=""
|
|
45
|
+
ONLY=""
|
|
46
|
+
while [ $# -gt 0 ]; do
|
|
47
|
+
case "$1" in
|
|
48
|
+
--check) CHECK=1; shift ;;
|
|
49
|
+
--estate) ESTATE="$2"; shift 2 ;;
|
|
50
|
+
--repo) ONLY="$2"; shift 2 ;;
|
|
51
|
+
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
52
|
+
esac
|
|
53
|
+
done
|
|
54
|
+
[ -n "$ESTATE" ] || { echo "--estate <dir> is required" >&2; exit 2; }
|
|
55
|
+
|
|
56
|
+
FILES=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$MANIFEST','utf8')).files.join('\n'))")
|
|
57
|
+
MARKERS=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$MANIFEST','utf8')).appliesTo.join(' '))")
|
|
58
|
+
|
|
59
|
+
drifted=0
|
|
60
|
+
synced=0
|
|
61
|
+
current=0
|
|
62
|
+
|
|
63
|
+
applies() {
|
|
64
|
+
# Instances are NOT in scope: they carry biffo.core.json and a
|
|
65
|
+
# core-manifest.json, so `biffo core upgrade` three-way-merges these paths
|
|
66
|
+
# into them. Two mechanisms writing the same files would fight, and the
|
|
67
|
+
# core-ownership guard would refuse this script's commit anyway -- correctly,
|
|
68
|
+
# since these are template-owned paths in an instance.
|
|
69
|
+
[ -f "$1/biffo.core.json" ] && return 1
|
|
70
|
+
for m in $MARKERS; do [ -f "$1/$m" ] && return 0; done
|
|
71
|
+
# Also: any repo already carrying the gate. The runner repos have neither
|
|
72
|
+
# marker but did receive verify.sh, and a mechanism that distributes a file
|
|
73
|
+
# once and then stops tracking it is the drift this script exists to end --
|
|
74
|
+
# it would have recreated the exact hole in the exact repos nobody watches.
|
|
75
|
+
[ -f "$1/scripts/verify.sh" ] && return 0
|
|
76
|
+
return 1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Which of the shared files differ in this repo. Missing counts as drifted: a
|
|
80
|
+
# repo that never received a file is exactly as unprotected as one holding a
|
|
81
|
+
# stale copy, and reporting them differently invites triaging only the second.
|
|
82
|
+
diff_files() {
|
|
83
|
+
d="$1"
|
|
84
|
+
out=""
|
|
85
|
+
# Compare against the REPO's integration branch, not the local working copy.
|
|
86
|
+
#
|
|
87
|
+
# The first version read "$d/$f" straight off disk, so a clone that had not
|
|
88
|
+
# been pulled reported DRIFTED for twelve repos that were entirely current --
|
|
89
|
+
# right after their sync PRs merged. The question is "is this repository
|
|
90
|
+
# current", not "is my laptop current", and a drift detector that fires on a
|
|
91
|
+
# stale checkout is one you learn to ignore.
|
|
92
|
+
git -C "$d" fetch origin --quiet 2>/dev/null
|
|
93
|
+
# `dev` first, per AGENTS.md section 2: it is the integration branch in every
|
|
94
|
+
# Biffo repo. origin/HEAD is NOT a substitute -- it points at `main` in
|
|
95
|
+
# several clones, and `main` is a stale release branch that legitimately does
|
|
96
|
+
# not carry these files, so resolving through it reported three repos as
|
|
97
|
+
# missing everything.
|
|
98
|
+
if git -C "$d" rev-parse --verify --quiet origin/dev >/dev/null 2>&1; then
|
|
99
|
+
base=dev
|
|
100
|
+
else
|
|
101
|
+
base=$(git -C "$d" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
|
|
102
|
+
[ -n "$base" ] || base=$(git -C "$d" rev-parse --abbrev-ref HEAD 2>/dev/null)
|
|
103
|
+
fi
|
|
104
|
+
for f in $FILES; do
|
|
105
|
+
remote=$(git -C "$d" show "origin/$base:$f" 2>/dev/null)
|
|
106
|
+
if [ -z "$remote" ]; then
|
|
107
|
+
out="$out $f(missing)"
|
|
108
|
+
elif [ "$remote" != "$(cat "$TEMPLATE_ROOT/$f")" ]; then
|
|
109
|
+
out="$out $f"
|
|
110
|
+
fi
|
|
111
|
+
done
|
|
112
|
+
echo "$out"
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
sync_repo() {
|
|
116
|
+
d="$1"
|
|
117
|
+
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; }
|
|
121
|
+
|
|
122
|
+
git -C "$d" fetch origin --quiet || return 1
|
|
123
|
+
wt="$d/.worktrees/shared-sync"
|
|
124
|
+
git -C "$d" worktree remove --force "$wt" 2>/dev/null
|
|
125
|
+
git -C "$d" branch -D chore/sync-shared 2>/dev/null
|
|
126
|
+
git -C "$d" worktree add -q "$wt" -b chore/sync-shared "origin/$base" || return 1
|
|
127
|
+
|
|
128
|
+
for f in $FILES; do
|
|
129
|
+
mkdir -p "$wt/$(dirname "$f")"
|
|
130
|
+
cp "$TEMPLATE_ROOT/$f" "$wt/$f"
|
|
131
|
+
chmod +x "$wt/$f" 2>/dev/null
|
|
132
|
+
done
|
|
133
|
+
|
|
134
|
+
# Stamp the version these files CAME FROM, so a repo can say which template
|
|
135
|
+
# its gate is, without a template clone or a network call (#869, H5 gap 1).
|
|
136
|
+
#
|
|
137
|
+
# Read from $TEMPLATE_ROOT, never from $wt. H5 pre-registered this as the most
|
|
138
|
+
# likely way to make the whole thing meaningless: a stamp generated from the
|
|
139
|
+
# RECEIVING repo always matches itself and reports perfect health forever --
|
|
140
|
+
# the shape of every instrument defect found on 2026-07-29. The test asserts
|
|
141
|
+
# the two differ.
|
|
142
|
+
_tv=$(git -C "$TEMPLATE_ROOT" describe --tags --match 'core-v*' --abbrev=0 2>/dev/null || echo unknown)
|
|
143
|
+
printf '%s\n' "$_tv" > "$wt/.biffo-shared-version"
|
|
144
|
+
|
|
145
|
+
# Install the JS/Python deps the gate will now check, or its own pre-push
|
|
146
|
+
# correctly refuses this push -- and reaching for BIFFO_SKIP_VERIFY to ship a
|
|
147
|
+
# gate would be the counter-metric H4 pre-registered as refuting itself.
|
|
148
|
+
# The ROOT package too, not only the nested ones. The first version matched
|
|
149
|
+
# `--dir` paths only, so a repo whose package.json is at the root -- tabsii-map
|
|
150
|
+
# -- got no install and the gate refused the push with `tsc: not found`. It
|
|
151
|
+
# then reported GATE REFUSED, which was true and useless: the gate was right,
|
|
152
|
+
# the installer had skipped the one layout it could not see.
|
|
153
|
+
[ -f "$wt/package.json" ] && (cd "$wt" && pnpm install --frozen-lockfile >/dev/null 2>&1 || true)
|
|
154
|
+
[ -f "$wt/pyproject.toml" ] && (cd "$wt" && uv sync --all-groups >/dev/null 2>&1 || true)
|
|
155
|
+
for p in $( (cd "$wt" && sh scripts/verify.sh --list 2>/dev/null) | grep -oE '\-\-dir(ectory)? \./[A-Za-z0-9_./-]+' | awk '{print $2}' | sort -u); do
|
|
156
|
+
(cd "$wt/$p" 2>/dev/null && { pnpm install --frozen-lockfile >/dev/null 2>&1 ||
|
|
157
|
+
pnpm install --frozen-lockfile --ignore-workspace >/dev/null 2>&1 ||
|
|
158
|
+
uv sync --all-groups >/dev/null 2>&1; }) || true
|
|
159
|
+
done
|
|
160
|
+
|
|
161
|
+
git -C "$wt" add -A
|
|
162
|
+
if git -C "$wt" diff --cached --quiet; then
|
|
163
|
+
git -C "$d" worktree remove --force "$wt" 2>/dev/null
|
|
164
|
+
printf '%-26s \033[32mnothing to sync\033[0m\n' "$label"
|
|
165
|
+
return 0
|
|
166
|
+
fi
|
|
167
|
+
git -C "$wt" -c commit.gpgsign=false commit -q --no-verify -m "chore(shared): sync template-shared files
|
|
168
|
+
|
|
169
|
+
Distributed by biffo-template's scripts/shared-sync.sh. These files are held
|
|
170
|
+
verbatim from the template; see shared-files.json there for the list and why
|
|
171
|
+
this mechanism exists.
|
|
172
|
+
|
|
173
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" >/dev/null 2>&1
|
|
174
|
+
|
|
175
|
+
# --force-with-lease: this branch is regenerated from origin/<base> on every
|
|
176
|
+
# run, so a previous sync's branch is legitimately replaced -- but the lease
|
|
177
|
+
# still refuses if someone else has pushed to it.
|
|
178
|
+
#
|
|
179
|
+
# The output is CAPTURED and classified rather than discarded. The first
|
|
180
|
+
# version printed "PUSH REFUSED (run the gate there and look)" for every
|
|
181
|
+
# failure, and the first real cause was a plain non-fast-forward against a
|
|
182
|
+
# previous run's branch. The gate was green in that repo. A diagnostic that
|
|
183
|
+
# names the wrong cause sends you to read a passing log, which is the same
|
|
184
|
+
# class of defect as everything else this gate exists to catch.
|
|
185
|
+
push_out=$(git -C "$wt" push --force-with-lease -u origin HEAD 2>&1)
|
|
186
|
+
push_rc=$?
|
|
187
|
+
if [ "$push_rc" -ne 0 ]; then
|
|
188
|
+
case "$push_out" in
|
|
189
|
+
*"verify failed"*|*"verify ran NOTHING"*)
|
|
190
|
+
printf '%-26s \033[31mGATE REFUSED THE PUSH\033[0m - run scripts/verify.sh there\n' "$label" ;;
|
|
191
|
+
*"stale info"*|*"non-fast-forward"*)
|
|
192
|
+
printf '%-26s \033[31mbranch diverged\033[0m - someone else pushed to chore/sync-shared\n' "$label" ;;
|
|
193
|
+
*)
|
|
194
|
+
printf '%-26s \033[31mpush failed\033[0m: %s\n' "$label" "$(echo "$push_out" | tail -1)" ;;
|
|
195
|
+
esac
|
|
196
|
+
return 1
|
|
197
|
+
fi
|
|
198
|
+
url=$(gh pr create --repo "$slug" --base "$base" --head chore/sync-shared \
|
|
199
|
+
--title "chore(shared): sync template-shared files" \
|
|
200
|
+
--body "Distributed by \`biffo-template\`'s \`scripts/shared-sync.sh\`.
|
|
201
|
+
|
|
202
|
+
Sibling and plugin repos are separate repositories with no \`core-manifest.json\`, so \`biffo core upgrade\` cannot reach them. The documented channel was \"vendor into the skeleton plus a one-time manual copy-in\", which only ever helped repos created afterwards — and is why this repo was running a local gate two versions old.
|
|
203
|
+
|
|
204
|
+
Files synced verbatim from the template (see \`shared-files.json\` there):
|
|
205
|
+
|
|
206
|
+
$(for f in $FILES; do echo "- \`$f\`"; done)
|
|
207
|
+
|
|
208
|
+
Run \`sh scripts/gate-coverage.sh\` after merging to see this repo's gate measured against its own CI.
|
|
209
|
+
|
|
210
|
+
🤖 Generated with [Claude Code](https://claude.com/claude-code)" 2>&1 | tail -1)
|
|
211
|
+
printf '%-26s %s\n' "$label" "$url"
|
|
212
|
+
return 0
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
printf '\nshared-file sync - template -> repos core upgrade cannot reach\n\n'
|
|
216
|
+
for d in "$ESTATE"/*/; do
|
|
217
|
+
d="${d%/}"
|
|
218
|
+
label=$(basename "$d")
|
|
219
|
+
[ -e "$d/.git" ] || continue
|
|
220
|
+
# The template is the source, not a target. It matched only because it carries
|
|
221
|
+
# scripts/verify.sh, and comparing it to itself through origin/<base> reported
|
|
222
|
+
# it as missing every file whenever its own dev was ahead of the checkout.
|
|
223
|
+
[ "$d" = "$TEMPLATE_ROOT" ] && continue
|
|
224
|
+
[ -n "$ONLY" ] && [ "$label" != "$ONLY" ] && continue
|
|
225
|
+
applies "$d" || continue
|
|
226
|
+
delta=$(diff_files "$d")
|
|
227
|
+
if [ -z "$delta" ]; then
|
|
228
|
+
current=$((current + 1))
|
|
229
|
+
printf '%-26s \033[32mcurrent\033[0m\n' "$label"
|
|
230
|
+
continue
|
|
231
|
+
fi
|
|
232
|
+
drifted=$((drifted + 1))
|
|
233
|
+
if [ -n "$CHECK" ]; then
|
|
234
|
+
printf '%-26s \033[31mDRIFTED\033[0m%s\n' "$label" "$delta"
|
|
235
|
+
else
|
|
236
|
+
sync_repo "$d" "$label" && synced=$((synced + 1))
|
|
237
|
+
fi
|
|
238
|
+
done
|
|
239
|
+
|
|
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
|
|
244
|
+
fi
|
|
245
|
+
printf '\n'
|
|
@@ -74,8 +74,50 @@ FAILED=""
|
|
|
74
74
|
PASSED=""
|
|
75
75
|
SKIPPED=""
|
|
76
76
|
|
|
77
|
+
# pytest is included where the suite is FAST ENOUGH TO PAY, measured rather than
|
|
78
|
+
# opted into (#869, H5 gap 4).
|
|
79
|
+
#
|
|
80
|
+
# The old rule was a blanket exclusion with a manual opt-in nobody ever issued,
|
|
81
|
+
# so the fastest suites in the estate were the ones not being run:
|
|
82
|
+
#
|
|
83
|
+
# tabsii-marketplace 1.7s tabsii-geo 2.1s tabsii-intake 2.5s tabsii-crm 2.7s
|
|
84
|
+
# biffo-template 51.2s biffo-platform 57.4s tabsii-platform 85.6s
|
|
85
|
+
#
|
|
86
|
+
# The exclusion was right for the three repos it was written against and wrong
|
|
87
|
+
# for the four it was applied to. The arithmetic: ~2.5s on every push against a
|
|
88
|
+
# ~14 min sibling CI round trip to discover and confirm a Python test failure --
|
|
89
|
+
# break-even at one catch per 336 pushes, against an observed rate of roughly
|
|
90
|
+
# one per 165.
|
|
91
|
+
#
|
|
92
|
+
# BIFFO_VERIFY_PYTEST overrides in BOTH directions: 1 forces it in, 0 forces it
|
|
93
|
+
# out. An override that only forces on would leave no way to escape a suite that
|
|
94
|
+
# has quietly grown past the threshold.
|
|
95
|
+
PYTEST_BUDGET_SECONDS="${BIFFO_VERIFY_PYTEST_BUDGET:-15}"
|
|
77
96
|
PYTEST="${BIFFO_VERIFY_PYTEST:-}"
|
|
78
97
|
|
|
98
|
+
# Cached per directory, because timing the suite to decide whether to run the
|
|
99
|
+
# suite would cost exactly what it is trying to save. The cache lives with the
|
|
100
|
+
# repo, not in $HOME, so it cannot leak a fast verdict from one repo to another.
|
|
101
|
+
pytest_is_fast() {
|
|
102
|
+
_d="$1"
|
|
103
|
+
_cache="$_d/.pytest-duration"
|
|
104
|
+
if [ -f "$_cache" ]; then
|
|
105
|
+
_secs=$(cat "$_cache" 2>/dev/null)
|
|
106
|
+
else
|
|
107
|
+
# First run in a repo: time it once, then decide from then on. A timeout
|
|
108
|
+
# means "too slow", which is the correct verdict rather than a hang.
|
|
109
|
+
_start=$(date +%s)
|
|
110
|
+
if [ "$_d" = "." ]; then
|
|
111
|
+
timeout "$((PYTEST_BUDGET_SECONDS * 4))" uv run pytest -q --no-cov >/dev/null 2>&1 || true
|
|
112
|
+
else
|
|
113
|
+
timeout "$((PYTEST_BUDGET_SECONDS * 4))" uv run --directory "$_d" pytest -q --no-cov >/dev/null 2>&1 || true
|
|
114
|
+
fi
|
|
115
|
+
_secs=$(($(date +%s) - _start))
|
|
116
|
+
echo "$_secs" > "$_cache" 2>/dev/null || true
|
|
117
|
+
fi
|
|
118
|
+
[ "${_secs:-9999}" -le "$PYTEST_BUDGET_SECONDS" ]
|
|
119
|
+
}
|
|
120
|
+
|
|
79
121
|
# Does THIS repo's CI run a check of this kind?
|
|
80
122
|
#
|
|
81
123
|
# ## Why every check is gated on this (#861)
|
|
@@ -200,7 +242,14 @@ skip() {
|
|
|
200
242
|
printf ' \033[90m-- %-16s n/a - %s\033[0m\n' "$1" "$2"
|
|
201
243
|
}
|
|
202
244
|
|
|
203
|
-
[ -
|
|
245
|
+
if [ -z "$LIST" ]; then
|
|
246
|
+
# State which template this gate came from. A gate two versions old is the
|
|
247
|
+
# condition that let tabsii-crm print `verify passed` on a 700-line change
|
|
248
|
+
# while running one check, and nothing in the repo said so (#869, H5 gap 1).
|
|
249
|
+
_stamp=""
|
|
250
|
+
[ -f .biffo-shared-version ] && _stamp=" (template $(cat .biffo-shared-version))"
|
|
251
|
+
printf '\nverify - the checks CI runs, before the push%s\n\n' "$_stamp"
|
|
252
|
+
fi
|
|
204
253
|
|
|
205
254
|
# Python first: ruff is near-instant, so the cheapest feedback on the largest
|
|
206
255
|
# single class of failure comes back immediately.
|
|
@@ -232,20 +281,24 @@ if [ -n "$PY_DIRS" ]; then
|
|
|
232
281
|
[ -z "$bandit_paths" ] && [ -d src ] && bandit_paths="src"
|
|
233
282
|
# shellcheck disable=SC2086
|
|
234
283
|
[ -n "$bandit_paths" ] && ci_has "bandit" && run_check "bandit$suffix" uv run bandit -r $bandit_paths -ll -q
|
|
235
|
-
if [
|
|
236
|
-
|
|
284
|
+
if [ "$PYTEST" = "0" ]; then
|
|
285
|
+
skip "pytest$suffix" "excluded by BIFFO_VERIFY_PYTEST=0"
|
|
286
|
+
elif [ -n "$PYTEST" ] || { [ -z "$LIST" ] && pytest_is_fast "."; }; then
|
|
287
|
+
ci_has "pytest" && run_check "pytest$suffix" uv run pytest -q --no-cov
|
|
237
288
|
else
|
|
238
|
-
skip "pytest$suffix" "
|
|
289
|
+
skip "pytest$suffix" "suite is slower than ${PYTEST_BUDGET_SECONDS}s - CI keeps it"
|
|
239
290
|
fi
|
|
240
291
|
else
|
|
241
292
|
ci_has "ruff check" && run_check "ruff-check$suffix" uv run --directory "$d" ruff check .
|
|
242
293
|
ci_has "ruff format" && run_check "ruff-format$suffix" uv run --directory "$d" ruff format --check .
|
|
243
294
|
ci_has "pyright" && run_check "pyright$suffix" uv run --directory "$d" pyright
|
|
244
295
|
ci_has "bandit" && run_check "bandit$suffix" uv run --directory "$d" bandit -r src -ll -q
|
|
245
|
-
if [
|
|
246
|
-
|
|
296
|
+
if [ "$PYTEST" = "0" ]; then
|
|
297
|
+
skip "pytest$suffix" "excluded by BIFFO_VERIFY_PYTEST=0"
|
|
298
|
+
elif [ -n "$PYTEST" ] || { [ -z "$LIST" ] && pytest_is_fast "$d"; }; then
|
|
299
|
+
ci_has "pytest" && run_check "pytest$suffix" uv run --directory "$d" pytest -q --no-cov
|
|
247
300
|
else
|
|
248
|
-
skip "pytest$suffix" "
|
|
301
|
+
skip "pytest$suffix" "suite is slower than ${PYTEST_BUDGET_SECONDS}s - CI keeps it"
|
|
249
302
|
fi
|
|
250
303
|
fi
|
|
251
304
|
done
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Distribute the template's shared files to the repos `biffo core upgrade`
|
|
4
|
+
# cannot reach, and make drift visible when it happens.
|
|
5
|
+
#
|
|
6
|
+
# ## Why this exists
|
|
7
|
+
#
|
|
8
|
+
# Instances carry `biffo.core.json` and a `core-manifest.json`, so the CLI
|
|
9
|
+
# three-way-merges template-owned paths into them. **Sibling apps and plugin
|
|
10
|
+
# repos are separate repositories with neither.** The documented channel to them
|
|
11
|
+
# was "vendor it into the skeleton, plus a one-time manual copy-in for existing
|
|
12
|
+
# ones", which is not a mechanism: the skeleton only helps repos created
|
|
13
|
+
# afterwards, and nothing ever prompts the copy-in.
|
|
14
|
+
#
|
|
15
|
+
# The cost, twice over:
|
|
16
|
+
#
|
|
17
|
+
# - AGENTS.md drifted 68 lines behind in tabsii, missing the very workflow
|
|
18
|
+
# guardrails the template had already written (#559).
|
|
19
|
+
# - Eight repos ran a local gate two versions old. `tabsii-crm` checked ONE
|
|
20
|
+
# thing in eight on a 700-line change and printed `verify passed` (#855).
|
|
21
|
+
#
|
|
22
|
+
# Both were found by a human noticing, months and hours late respectively. This
|
|
23
|
+
# turns that into a command that reports, and a `--check` that fails.
|
|
24
|
+
#
|
|
25
|
+
# ## What it is not
|
|
26
|
+
#
|
|
27
|
+
# A **one-way overwrite**, not a merge. `shared-files.json` may only list files
|
|
28
|
+
# every sibling and plugin should hold verbatim. Anything a repo is expected to
|
|
29
|
+
# customise does not belong in it — that is what the instance manifest's
|
|
30
|
+
# three-way merge is for, and this deliberately has no such subtlety.
|
|
31
|
+
#
|
|
32
|
+
# 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
|
|
35
|
+
# sh scripts/shared-sync.sh --estate ~/code --repo tabsii-crm
|
|
36
|
+
|
|
37
|
+
set -uo pipefail
|
|
38
|
+
|
|
39
|
+
TEMPLATE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
40
|
+
MANIFEST="$TEMPLATE_ROOT/shared-files.json"
|
|
41
|
+
[ -f "$MANIFEST" ] || { echo "no shared-files.json beside $0" >&2; exit 2; }
|
|
42
|
+
|
|
43
|
+
CHECK=""
|
|
44
|
+
ESTATE=""
|
|
45
|
+
ONLY=""
|
|
46
|
+
while [ $# -gt 0 ]; do
|
|
47
|
+
case "$1" in
|
|
48
|
+
--check) CHECK=1; shift ;;
|
|
49
|
+
--estate) ESTATE="$2"; shift 2 ;;
|
|
50
|
+
--repo) ONLY="$2"; shift 2 ;;
|
|
51
|
+
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
52
|
+
esac
|
|
53
|
+
done
|
|
54
|
+
[ -n "$ESTATE" ] || { echo "--estate <dir> is required" >&2; exit 2; }
|
|
55
|
+
|
|
56
|
+
FILES=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$MANIFEST','utf8')).files.join('\n'))")
|
|
57
|
+
MARKERS=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$MANIFEST','utf8')).appliesTo.join(' '))")
|
|
58
|
+
|
|
59
|
+
drifted=0
|
|
60
|
+
synced=0
|
|
61
|
+
current=0
|
|
62
|
+
|
|
63
|
+
applies() {
|
|
64
|
+
# Instances are NOT in scope: they carry biffo.core.json and a
|
|
65
|
+
# core-manifest.json, so `biffo core upgrade` three-way-merges these paths
|
|
66
|
+
# into them. Two mechanisms writing the same files would fight, and the
|
|
67
|
+
# core-ownership guard would refuse this script's commit anyway -- correctly,
|
|
68
|
+
# since these are template-owned paths in an instance.
|
|
69
|
+
[ -f "$1/biffo.core.json" ] && return 1
|
|
70
|
+
for m in $MARKERS; do [ -f "$1/$m" ] && return 0; done
|
|
71
|
+
# Also: any repo already carrying the gate. The runner repos have neither
|
|
72
|
+
# marker but did receive verify.sh, and a mechanism that distributes a file
|
|
73
|
+
# once and then stops tracking it is the drift this script exists to end --
|
|
74
|
+
# it would have recreated the exact hole in the exact repos nobody watches.
|
|
75
|
+
[ -f "$1/scripts/verify.sh" ] && return 0
|
|
76
|
+
return 1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Which of the shared files differ in this repo. Missing counts as drifted: a
|
|
80
|
+
# repo that never received a file is exactly as unprotected as one holding a
|
|
81
|
+
# stale copy, and reporting them differently invites triaging only the second.
|
|
82
|
+
diff_files() {
|
|
83
|
+
d="$1"
|
|
84
|
+
out=""
|
|
85
|
+
# Compare against the REPO's integration branch, not the local working copy.
|
|
86
|
+
#
|
|
87
|
+
# The first version read "$d/$f" straight off disk, so a clone that had not
|
|
88
|
+
# been pulled reported DRIFTED for twelve repos that were entirely current --
|
|
89
|
+
# right after their sync PRs merged. The question is "is this repository
|
|
90
|
+
# current", not "is my laptop current", and a drift detector that fires on a
|
|
91
|
+
# stale checkout is one you learn to ignore.
|
|
92
|
+
git -C "$d" fetch origin --quiet 2>/dev/null
|
|
93
|
+
# `dev` first, per AGENTS.md section 2: it is the integration branch in every
|
|
94
|
+
# Biffo repo. origin/HEAD is NOT a substitute -- it points at `main` in
|
|
95
|
+
# several clones, and `main` is a stale release branch that legitimately does
|
|
96
|
+
# not carry these files, so resolving through it reported three repos as
|
|
97
|
+
# missing everything.
|
|
98
|
+
if git -C "$d" rev-parse --verify --quiet origin/dev >/dev/null 2>&1; then
|
|
99
|
+
base=dev
|
|
100
|
+
else
|
|
101
|
+
base=$(git -C "$d" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
|
|
102
|
+
[ -n "$base" ] || base=$(git -C "$d" rev-parse --abbrev-ref HEAD 2>/dev/null)
|
|
103
|
+
fi
|
|
104
|
+
for f in $FILES; do
|
|
105
|
+
remote=$(git -C "$d" show "origin/$base:$f" 2>/dev/null)
|
|
106
|
+
if [ -z "$remote" ]; then
|
|
107
|
+
out="$out $f(missing)"
|
|
108
|
+
elif [ "$remote" != "$(cat "$TEMPLATE_ROOT/$f")" ]; then
|
|
109
|
+
out="$out $f"
|
|
110
|
+
fi
|
|
111
|
+
done
|
|
112
|
+
echo "$out"
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
sync_repo() {
|
|
116
|
+
d="$1"
|
|
117
|
+
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; }
|
|
121
|
+
|
|
122
|
+
git -C "$d" fetch origin --quiet || return 1
|
|
123
|
+
wt="$d/.worktrees/shared-sync"
|
|
124
|
+
git -C "$d" worktree remove --force "$wt" 2>/dev/null
|
|
125
|
+
git -C "$d" branch -D chore/sync-shared 2>/dev/null
|
|
126
|
+
git -C "$d" worktree add -q "$wt" -b chore/sync-shared "origin/$base" || return 1
|
|
127
|
+
|
|
128
|
+
for f in $FILES; do
|
|
129
|
+
mkdir -p "$wt/$(dirname "$f")"
|
|
130
|
+
cp "$TEMPLATE_ROOT/$f" "$wt/$f"
|
|
131
|
+
chmod +x "$wt/$f" 2>/dev/null
|
|
132
|
+
done
|
|
133
|
+
|
|
134
|
+
# Stamp the version these files CAME FROM, so a repo can say which template
|
|
135
|
+
# its gate is, without a template clone or a network call (#869, H5 gap 1).
|
|
136
|
+
#
|
|
137
|
+
# Read from $TEMPLATE_ROOT, never from $wt. H5 pre-registered this as the most
|
|
138
|
+
# likely way to make the whole thing meaningless: a stamp generated from the
|
|
139
|
+
# RECEIVING repo always matches itself and reports perfect health forever --
|
|
140
|
+
# the shape of every instrument defect found on 2026-07-29. The test asserts
|
|
141
|
+
# the two differ.
|
|
142
|
+
_tv=$(git -C "$TEMPLATE_ROOT" describe --tags --match 'core-v*' --abbrev=0 2>/dev/null || echo unknown)
|
|
143
|
+
printf '%s\n' "$_tv" > "$wt/.biffo-shared-version"
|
|
144
|
+
|
|
145
|
+
# Install the JS/Python deps the gate will now check, or its own pre-push
|
|
146
|
+
# correctly refuses this push -- and reaching for BIFFO_SKIP_VERIFY to ship a
|
|
147
|
+
# gate would be the counter-metric H4 pre-registered as refuting itself.
|
|
148
|
+
# The ROOT package too, not only the nested ones. The first version matched
|
|
149
|
+
# `--dir` paths only, so a repo whose package.json is at the root -- tabsii-map
|
|
150
|
+
# -- got no install and the gate refused the push with `tsc: not found`. It
|
|
151
|
+
# then reported GATE REFUSED, which was true and useless: the gate was right,
|
|
152
|
+
# the installer had skipped the one layout it could not see.
|
|
153
|
+
[ -f "$wt/package.json" ] && (cd "$wt" && pnpm install --frozen-lockfile >/dev/null 2>&1 || true)
|
|
154
|
+
[ -f "$wt/pyproject.toml" ] && (cd "$wt" && uv sync --all-groups >/dev/null 2>&1 || true)
|
|
155
|
+
for p in $( (cd "$wt" && sh scripts/verify.sh --list 2>/dev/null) | grep -oE '\-\-dir(ectory)? \./[A-Za-z0-9_./-]+' | awk '{print $2}' | sort -u); do
|
|
156
|
+
(cd "$wt/$p" 2>/dev/null && { pnpm install --frozen-lockfile >/dev/null 2>&1 ||
|
|
157
|
+
pnpm install --frozen-lockfile --ignore-workspace >/dev/null 2>&1 ||
|
|
158
|
+
uv sync --all-groups >/dev/null 2>&1; }) || true
|
|
159
|
+
done
|
|
160
|
+
|
|
161
|
+
git -C "$wt" add -A
|
|
162
|
+
if git -C "$wt" diff --cached --quiet; then
|
|
163
|
+
git -C "$d" worktree remove --force "$wt" 2>/dev/null
|
|
164
|
+
printf '%-26s \033[32mnothing to sync\033[0m\n' "$label"
|
|
165
|
+
return 0
|
|
166
|
+
fi
|
|
167
|
+
git -C "$wt" -c commit.gpgsign=false commit -q --no-verify -m "chore(shared): sync template-shared files
|
|
168
|
+
|
|
169
|
+
Distributed by biffo-template's scripts/shared-sync.sh. These files are held
|
|
170
|
+
verbatim from the template; see shared-files.json there for the list and why
|
|
171
|
+
this mechanism exists.
|
|
172
|
+
|
|
173
|
+
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" >/dev/null 2>&1
|
|
174
|
+
|
|
175
|
+
# --force-with-lease: this branch is regenerated from origin/<base> on every
|
|
176
|
+
# run, so a previous sync's branch is legitimately replaced -- but the lease
|
|
177
|
+
# still refuses if someone else has pushed to it.
|
|
178
|
+
#
|
|
179
|
+
# The output is CAPTURED and classified rather than discarded. The first
|
|
180
|
+
# version printed "PUSH REFUSED (run the gate there and look)" for every
|
|
181
|
+
# failure, and the first real cause was a plain non-fast-forward against a
|
|
182
|
+
# previous run's branch. The gate was green in that repo. A diagnostic that
|
|
183
|
+
# names the wrong cause sends you to read a passing log, which is the same
|
|
184
|
+
# class of defect as everything else this gate exists to catch.
|
|
185
|
+
push_out=$(git -C "$wt" push --force-with-lease -u origin HEAD 2>&1)
|
|
186
|
+
push_rc=$?
|
|
187
|
+
if [ "$push_rc" -ne 0 ]; then
|
|
188
|
+
case "$push_out" in
|
|
189
|
+
*"verify failed"*|*"verify ran NOTHING"*)
|
|
190
|
+
printf '%-26s \033[31mGATE REFUSED THE PUSH\033[0m - run scripts/verify.sh there\n' "$label" ;;
|
|
191
|
+
*"stale info"*|*"non-fast-forward"*)
|
|
192
|
+
printf '%-26s \033[31mbranch diverged\033[0m - someone else pushed to chore/sync-shared\n' "$label" ;;
|
|
193
|
+
*)
|
|
194
|
+
printf '%-26s \033[31mpush failed\033[0m: %s\n' "$label" "$(echo "$push_out" | tail -1)" ;;
|
|
195
|
+
esac
|
|
196
|
+
return 1
|
|
197
|
+
fi
|
|
198
|
+
url=$(gh pr create --repo "$slug" --base "$base" --head chore/sync-shared \
|
|
199
|
+
--title "chore(shared): sync template-shared files" \
|
|
200
|
+
--body "Distributed by \`biffo-template\`'s \`scripts/shared-sync.sh\`.
|
|
201
|
+
|
|
202
|
+
Sibling and plugin repos are separate repositories with no \`core-manifest.json\`, so \`biffo core upgrade\` cannot reach them. The documented channel was \"vendor into the skeleton plus a one-time manual copy-in\", which only ever helped repos created afterwards — and is why this repo was running a local gate two versions old.
|
|
203
|
+
|
|
204
|
+
Files synced verbatim from the template (see \`shared-files.json\` there):
|
|
205
|
+
|
|
206
|
+
$(for f in $FILES; do echo "- \`$f\`"; done)
|
|
207
|
+
|
|
208
|
+
Run \`sh scripts/gate-coverage.sh\` after merging to see this repo's gate measured against its own CI.
|
|
209
|
+
|
|
210
|
+
🤖 Generated with [Claude Code](https://claude.com/claude-code)" 2>&1 | tail -1)
|
|
211
|
+
printf '%-26s %s\n' "$label" "$url"
|
|
212
|
+
return 0
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
printf '\nshared-file sync - template -> repos core upgrade cannot reach\n\n'
|
|
216
|
+
for d in "$ESTATE"/*/; do
|
|
217
|
+
d="${d%/}"
|
|
218
|
+
label=$(basename "$d")
|
|
219
|
+
[ -e "$d/.git" ] || continue
|
|
220
|
+
# The template is the source, not a target. It matched only because it carries
|
|
221
|
+
# scripts/verify.sh, and comparing it to itself through origin/<base> reported
|
|
222
|
+
# it as missing every file whenever its own dev was ahead of the checkout.
|
|
223
|
+
[ "$d" = "$TEMPLATE_ROOT" ] && continue
|
|
224
|
+
[ -n "$ONLY" ] && [ "$label" != "$ONLY" ] && continue
|
|
225
|
+
applies "$d" || continue
|
|
226
|
+
delta=$(diff_files "$d")
|
|
227
|
+
if [ -z "$delta" ]; then
|
|
228
|
+
current=$((current + 1))
|
|
229
|
+
printf '%-26s \033[32mcurrent\033[0m\n' "$label"
|
|
230
|
+
continue
|
|
231
|
+
fi
|
|
232
|
+
drifted=$((drifted + 1))
|
|
233
|
+
if [ -n "$CHECK" ]; then
|
|
234
|
+
printf '%-26s \033[31mDRIFTED\033[0m%s\n' "$label" "$delta"
|
|
235
|
+
else
|
|
236
|
+
sync_repo "$d" "$label" && synced=$((synced + 1))
|
|
237
|
+
fi
|
|
238
|
+
done
|
|
239
|
+
|
|
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
|
|
244
|
+
fi
|
|
245
|
+
printf '\n'
|
|
@@ -74,8 +74,50 @@ FAILED=""
|
|
|
74
74
|
PASSED=""
|
|
75
75
|
SKIPPED=""
|
|
76
76
|
|
|
77
|
+
# pytest is included where the suite is FAST ENOUGH TO PAY, measured rather than
|
|
78
|
+
# opted into (#869, H5 gap 4).
|
|
79
|
+
#
|
|
80
|
+
# The old rule was a blanket exclusion with a manual opt-in nobody ever issued,
|
|
81
|
+
# so the fastest suites in the estate were the ones not being run:
|
|
82
|
+
#
|
|
83
|
+
# tabsii-marketplace 1.7s tabsii-geo 2.1s tabsii-intake 2.5s tabsii-crm 2.7s
|
|
84
|
+
# biffo-template 51.2s biffo-platform 57.4s tabsii-platform 85.6s
|
|
85
|
+
#
|
|
86
|
+
# The exclusion was right for the three repos it was written against and wrong
|
|
87
|
+
# for the four it was applied to. The arithmetic: ~2.5s on every push against a
|
|
88
|
+
# ~14 min sibling CI round trip to discover and confirm a Python test failure --
|
|
89
|
+
# break-even at one catch per 336 pushes, against an observed rate of roughly
|
|
90
|
+
# one per 165.
|
|
91
|
+
#
|
|
92
|
+
# BIFFO_VERIFY_PYTEST overrides in BOTH directions: 1 forces it in, 0 forces it
|
|
93
|
+
# out. An override that only forces on would leave no way to escape a suite that
|
|
94
|
+
# has quietly grown past the threshold.
|
|
95
|
+
PYTEST_BUDGET_SECONDS="${BIFFO_VERIFY_PYTEST_BUDGET:-15}"
|
|
77
96
|
PYTEST="${BIFFO_VERIFY_PYTEST:-}"
|
|
78
97
|
|
|
98
|
+
# Cached per directory, because timing the suite to decide whether to run the
|
|
99
|
+
# suite would cost exactly what it is trying to save. The cache lives with the
|
|
100
|
+
# repo, not in $HOME, so it cannot leak a fast verdict from one repo to another.
|
|
101
|
+
pytest_is_fast() {
|
|
102
|
+
_d="$1"
|
|
103
|
+
_cache="$_d/.pytest-duration"
|
|
104
|
+
if [ -f "$_cache" ]; then
|
|
105
|
+
_secs=$(cat "$_cache" 2>/dev/null)
|
|
106
|
+
else
|
|
107
|
+
# First run in a repo: time it once, then decide from then on. A timeout
|
|
108
|
+
# means "too slow", which is the correct verdict rather than a hang.
|
|
109
|
+
_start=$(date +%s)
|
|
110
|
+
if [ "$_d" = "." ]; then
|
|
111
|
+
timeout "$((PYTEST_BUDGET_SECONDS * 4))" uv run pytest -q --no-cov >/dev/null 2>&1 || true
|
|
112
|
+
else
|
|
113
|
+
timeout "$((PYTEST_BUDGET_SECONDS * 4))" uv run --directory "$_d" pytest -q --no-cov >/dev/null 2>&1 || true
|
|
114
|
+
fi
|
|
115
|
+
_secs=$(($(date +%s) - _start))
|
|
116
|
+
echo "$_secs" > "$_cache" 2>/dev/null || true
|
|
117
|
+
fi
|
|
118
|
+
[ "${_secs:-9999}" -le "$PYTEST_BUDGET_SECONDS" ]
|
|
119
|
+
}
|
|
120
|
+
|
|
79
121
|
# Does THIS repo's CI run a check of this kind?
|
|
80
122
|
#
|
|
81
123
|
# ## Why every check is gated on this (#861)
|
|
@@ -200,7 +242,14 @@ skip() {
|
|
|
200
242
|
printf ' \033[90m-- %-16s n/a - %s\033[0m\n' "$1" "$2"
|
|
201
243
|
}
|
|
202
244
|
|
|
203
|
-
[ -
|
|
245
|
+
if [ -z "$LIST" ]; then
|
|
246
|
+
# State which template this gate came from. A gate two versions old is the
|
|
247
|
+
# condition that let tabsii-crm print `verify passed` on a 700-line change
|
|
248
|
+
# while running one check, and nothing in the repo said so (#869, H5 gap 1).
|
|
249
|
+
_stamp=""
|
|
250
|
+
[ -f .biffo-shared-version ] && _stamp=" (template $(cat .biffo-shared-version))"
|
|
251
|
+
printf '\nverify - the checks CI runs, before the push%s\n\n' "$_stamp"
|
|
252
|
+
fi
|
|
204
253
|
|
|
205
254
|
# Python first: ruff is near-instant, so the cheapest feedback on the largest
|
|
206
255
|
# single class of failure comes back immediately.
|
|
@@ -232,20 +281,24 @@ if [ -n "$PY_DIRS" ]; then
|
|
|
232
281
|
[ -z "$bandit_paths" ] && [ -d src ] && bandit_paths="src"
|
|
233
282
|
# shellcheck disable=SC2086
|
|
234
283
|
[ -n "$bandit_paths" ] && ci_has "bandit" && run_check "bandit$suffix" uv run bandit -r $bandit_paths -ll -q
|
|
235
|
-
if [
|
|
236
|
-
|
|
284
|
+
if [ "$PYTEST" = "0" ]; then
|
|
285
|
+
skip "pytest$suffix" "excluded by BIFFO_VERIFY_PYTEST=0"
|
|
286
|
+
elif [ -n "$PYTEST" ] || { [ -z "$LIST" ] && pytest_is_fast "."; }; then
|
|
287
|
+
ci_has "pytest" && run_check "pytest$suffix" uv run pytest -q --no-cov
|
|
237
288
|
else
|
|
238
|
-
skip "pytest$suffix" "
|
|
289
|
+
skip "pytest$suffix" "suite is slower than ${PYTEST_BUDGET_SECONDS}s - CI keeps it"
|
|
239
290
|
fi
|
|
240
291
|
else
|
|
241
292
|
ci_has "ruff check" && run_check "ruff-check$suffix" uv run --directory "$d" ruff check .
|
|
242
293
|
ci_has "ruff format" && run_check "ruff-format$suffix" uv run --directory "$d" ruff format --check .
|
|
243
294
|
ci_has "pyright" && run_check "pyright$suffix" uv run --directory "$d" pyright
|
|
244
295
|
ci_has "bandit" && run_check "bandit$suffix" uv run --directory "$d" bandit -r src -ll -q
|
|
245
|
-
if [
|
|
246
|
-
|
|
296
|
+
if [ "$PYTEST" = "0" ]; then
|
|
297
|
+
skip "pytest$suffix" "excluded by BIFFO_VERIFY_PYTEST=0"
|
|
298
|
+
elif [ -n "$PYTEST" ] || { [ -z "$LIST" ] && pytest_is_fast "$d"; }; then
|
|
299
|
+
ci_has "pytest" && run_check "pytest$suffix" uv run --directory "$d" pytest -q --no-cov
|
|
247
300
|
else
|
|
248
|
-
skip "pytest$suffix" "
|
|
301
|
+
skip "pytest$suffix" "suite is slower than ${PYTEST_BUDGET_SECONDS}s - CI keeps it"
|
|
249
302
|
fi
|
|
250
303
|
fi
|
|
251
304
|
done
|