@biffo/cli 0.236.0 → 0.238.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/.githooks/pre-push +8 -5
- package/_skeletons/plugin-template/AGENTS.md +22 -4
- package/_skeletons/plugin-template/scripts/verify.sh +6 -2
- package/_skeletons/sibling-template/.githooks/pre-push +8 -5
- package/_skeletons/sibling-template/AGENTS.md +20 -2
- package/_skeletons/sibling-template/scripts/verify.sh +6 -2
- package/dist/index.js +65 -16
- package/package.json +7 -2
- package/_skeletons/sibling-template/scripts/branch-health.sh +0 -306
- package/_skeletons/sibling-template/scripts/claim.sh +0 -208
- package/_skeletons/sibling-template/scripts/hook-audit.sh +0 -145
- package/_skeletons/sibling-template/scripts/pg-test-db.sh +0 -275
- package/_skeletons/sibling-template/scripts/rewrite-scope-check.sh +0 -189
- /package/{_skeletons/plugin-template/scripts → scripts}/branch-health.sh +0 -0
- /package/{_skeletons/plugin-template/scripts → scripts}/claim.sh +0 -0
- /package/{_skeletons/plugin-template/scripts → scripts}/hook-audit.sh +0 -0
- /package/{_skeletons/plugin-template/scripts → scripts}/pg-test-db.sh +0 -0
- /package/{_skeletons/plugin-template/scripts → scripts}/rewrite-scope-check.sh +0 -0
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env sh
|
|
2
|
-
#
|
|
3
|
-
# Is anyone already working this issue? Ask git, not a label.
|
|
4
|
-
#
|
|
5
|
-
# ## Why this exists
|
|
6
|
-
#
|
|
7
|
-
# Several agent sessions run against this estate at once, and on 2026-08-03
|
|
8
|
-
# **four** of them collided in one morning:
|
|
9
|
-
#
|
|
10
|
-
# - #1165 — another session built AND MERGED a PR for it while this one was
|
|
11
|
-
# claiming it. Three minutes, start to merge.
|
|
12
|
-
# - #1174 — a live worktree on `fix/1174-…` existed; the issue was unlabelled.
|
|
13
|
-
# - #621, #956 — live worktrees, no labels. Labelled on their behalf.
|
|
14
|
-
# - #1188 — the reverse: a label with no work, while another session built it
|
|
15
|
-
# and opened a PR.
|
|
16
|
-
#
|
|
17
|
-
# Three of the four were "work exists, label does not". That is the shape this
|
|
18
|
-
# script is for.
|
|
19
|
-
#
|
|
20
|
-
# ## The rule it encodes
|
|
21
|
-
#
|
|
22
|
-
# **The `in-progress` label is a hand-maintained second copy of something git
|
|
23
|
-
# already knows.** A branch exists. A PR exists. Those are automatic — you
|
|
24
|
-
# cannot do the work without creating them — whereas the label is a separate
|
|
25
|
-
# action a human or agent has to remember, in a workflow that may never have
|
|
26
|
-
# been told to. Second copies of a decision drift; this estate says so about
|
|
27
|
-
# `_extract_detail`, about AGENTS.md, and about the commit-msg type list.
|
|
28
|
-
#
|
|
29
|
-
# So this checks FOUR signals and reports all of them, rather than trusting the
|
|
30
|
-
# one that is easiest to forget.
|
|
31
|
-
#
|
|
32
|
-
# ## What it cannot do
|
|
33
|
-
#
|
|
34
|
-
# Prevent a race. GitHub has no locking, two sessions can start in the same
|
|
35
|
-
# second, and #1165 went from branch to merged in three minutes — no protocol
|
|
36
|
-
# would have caught that. The goal is early, cheap detection, not exclusion.
|
|
37
|
-
# Every collision that morning was caught before duplicate work merged; the
|
|
38
|
-
# cost was minutes, not shipped rework.
|
|
39
|
-
#
|
|
40
|
-
# ## Usage
|
|
41
|
-
#
|
|
42
|
-
# sh scripts/claim.sh 1234 # check, and claim if free
|
|
43
|
-
# sh scripts/claim.sh 1234 --check # report only, change nothing
|
|
44
|
-
# sh scripts/claim.sh 1234 -R owner/repo
|
|
45
|
-
#
|
|
46
|
-
# 0 free — and claimed, unless --check
|
|
47
|
-
# 1 taken, or already closed — the reason is printed
|
|
48
|
-
# 2 cannot tell — issue unreadable, gh unauthenticated
|
|
49
|
-
#
|
|
50
|
-
# 2 is deliberately not 0, matching `wait-for-checks.sh` and `branch-health.sh`.
|
|
51
|
-
# A check that cannot see its input must not report "free".
|
|
52
|
-
#
|
|
53
|
-
# Requires `gh`, authenticated. Uses gh's embedded jq, so no jq binary needed.
|
|
54
|
-
|
|
55
|
-
set -u
|
|
56
|
-
|
|
57
|
-
ISSUE=""
|
|
58
|
-
REPO=""
|
|
59
|
-
CHECK_ONLY=""
|
|
60
|
-
|
|
61
|
-
usage() {
|
|
62
|
-
sed -n '2,58p' "$0" | sed 's/^# \{0,1\}//'
|
|
63
|
-
exit 2
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
while [ $# -gt 0 ]; do
|
|
67
|
-
case "$1" in
|
|
68
|
-
-R | --repo)
|
|
69
|
-
REPO="${2:-}"
|
|
70
|
-
shift 2
|
|
71
|
-
;;
|
|
72
|
-
--check) CHECK_ONLY=1; shift ;;
|
|
73
|
-
-h | --help) usage ;;
|
|
74
|
-
*)
|
|
75
|
-
ISSUE="$1"
|
|
76
|
-
shift
|
|
77
|
-
;;
|
|
78
|
-
esac
|
|
79
|
-
done
|
|
80
|
-
|
|
81
|
-
case "$ISSUE" in
|
|
82
|
-
'' | *[!0-9]*)
|
|
83
|
-
echo "claim: give an issue number, e.g. sh scripts/claim.sh 1234" >&2
|
|
84
|
-
exit 2
|
|
85
|
-
;;
|
|
86
|
-
esac
|
|
87
|
-
|
|
88
|
-
RED=$(printf '\033[31m')
|
|
89
|
-
GREEN=$(printf '\033[32m')
|
|
90
|
-
YELLOW=$(printf '\033[33m')
|
|
91
|
-
DIM=$(printf '\033[90m')
|
|
92
|
-
OFF=$(printf '\033[0m')
|
|
93
|
-
|
|
94
|
-
gh_issue() { if [ -n "$REPO" ]; then gh issue "$@" --repo "$REPO"; else gh issue "$@"; fi; }
|
|
95
|
-
gh_pr() { if [ -n "$REPO" ]; then gh pr "$@" --repo "$REPO"; else gh pr "$@"; fi; }
|
|
96
|
-
|
|
97
|
-
LABEL=in-progress
|
|
98
|
-
TAKEN=0
|
|
99
|
-
REASONS=""
|
|
100
|
-
|
|
101
|
-
note() { REASONS="${REASONS} $1\n"; TAKEN=1; }
|
|
102
|
-
|
|
103
|
-
# --- 0. Does the issue exist, and is it still open? --------------------------
|
|
104
|
-
|
|
105
|
-
meta=$(gh_issue view "$ISSUE" --json state,title,labels \
|
|
106
|
-
--jq '"\(.state)\t\(.title)\t\((.labels|map(.name)|join(",")))"' 2>/dev/null) || {
|
|
107
|
-
echo "${RED}claim: cannot read issue #$ISSUE${OFF} — wrong repo, or gh not authenticated." >&2
|
|
108
|
-
echo "${DIM} That is 'cannot tell', not 'free'.${OFF}" >&2
|
|
109
|
-
exit 2
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
state=$(printf '%s' "$meta" | cut -f1)
|
|
113
|
-
title=$(printf '%s' "$meta" | cut -f2)
|
|
114
|
-
labels=$(printf '%s' "$meta" | cut -f3)
|
|
115
|
-
|
|
116
|
-
echo "${DIM}#$ISSUE — $title${OFF}"
|
|
117
|
-
echo
|
|
118
|
-
|
|
119
|
-
if [ "$state" != "OPEN" ]; then
|
|
120
|
-
echo "${RED}Already $state.${OFF} Nothing to claim."
|
|
121
|
-
exit 1
|
|
122
|
-
fi
|
|
123
|
-
|
|
124
|
-
# --- 1. The label. Easiest to check, easiest to forget. ----------------------
|
|
125
|
-
|
|
126
|
-
case ",$labels," in
|
|
127
|
-
*",$LABEL,"*)
|
|
128
|
-
updated=$(gh_issue view "$ISSUE" --json updatedAt --jq .updatedAt 2>/dev/null)
|
|
129
|
-
note "${YELLOW}label${OFF} carries '$LABEL' (issue last updated $updated)"
|
|
130
|
-
;;
|
|
131
|
-
esac
|
|
132
|
-
|
|
133
|
-
# --- 2. An open PR that references it ----------------------------------------
|
|
134
|
-
#
|
|
135
|
-
# The strongest signal, because a PR cannot be opened without the work existing.
|
|
136
|
-
# Matches the issue number in the title or body as a whole number, so #118 does
|
|
137
|
-
# not match #1188.
|
|
138
|
-
|
|
139
|
-
open_prs=$(gh_pr list --state open --limit 100 --json number,title,body,headRefName \
|
|
140
|
-
--jq "[.[] | select(((.title + \" \" + .body) | test(\"(^|[^0-9])#$ISSUE([^0-9]|\$)\")) or (.headRefName | test(\"(^|[^0-9])$ISSUE([^0-9]|\$)\")))] | .[] | \"#\(.number) \(.headRefName)\"" 2>/dev/null)
|
|
141
|
-
|
|
142
|
-
if [ -n "$open_prs" ]; then
|
|
143
|
-
printf '%s\n' "$open_prs" | while IFS= read -r pr; do
|
|
144
|
-
[ -n "$pr" ] && echo " ${RED}open PR${OFF} $pr"
|
|
145
|
-
done
|
|
146
|
-
note "${RED}open PR${OFF} see above — someone has working code"
|
|
147
|
-
fi
|
|
148
|
-
|
|
149
|
-
# --- 3. A remote branch naming it --------------------------------------------
|
|
150
|
-
#
|
|
151
|
-
# Catches work that has been pushed but has no PR yet. Whole-number match again.
|
|
152
|
-
|
|
153
|
-
branches=$(git ls-remote --heads "${REPO:+https://github.com/$REPO.git}" 2>/dev/null |
|
|
154
|
-
sed 's|.*refs/heads/||' |
|
|
155
|
-
grep -E "(^|[^0-9])$ISSUE([^0-9]|$)" 2>/dev/null)
|
|
156
|
-
|
|
157
|
-
if [ -n "$branches" ]; then
|
|
158
|
-
printf '%s\n' "$branches" | while IFS= read -r b; do
|
|
159
|
-
[ -n "$b" ] && echo " ${RED}branch${OFF} $b"
|
|
160
|
-
done
|
|
161
|
-
note "${RED}branch${OFF} a remote branch names this issue"
|
|
162
|
-
fi
|
|
163
|
-
|
|
164
|
-
# --- 4. A recently merged PR that already closed it --------------------------
|
|
165
|
-
#
|
|
166
|
-
# Not "taken" — "possibly already done". #1165 was built and merged in three
|
|
167
|
-
# minutes; the only trace afterwards is a merged PR.
|
|
168
|
-
|
|
169
|
-
merged=$(gh_pr list --state merged --limit 30 --json number,title,body,mergedAt \
|
|
170
|
-
--jq "[.[] | select((.title + \" \" + .body) | test(\"(^|[^0-9])#$ISSUE([^0-9]|\$)\"))] | .[0] | select(. != null) | \"#\(.number) merged \(.mergedAt[0:16])\"" 2>/dev/null)
|
|
171
|
-
|
|
172
|
-
if [ -n "$merged" ]; then
|
|
173
|
-
echo " ${YELLOW}merged${OFF} $merged"
|
|
174
|
-
echo " ${DIM} the issue is still open, but work referencing it has landed —${OFF}"
|
|
175
|
-
echo " ${DIM} read it before rebuilding.${OFF}"
|
|
176
|
-
fi
|
|
177
|
-
|
|
178
|
-
# --- Verdict ------------------------------------------------------------------
|
|
179
|
-
|
|
180
|
-
echo
|
|
181
|
-
if [ "$TAKEN" -eq 1 ]; then
|
|
182
|
-
printf '%b' "${RED}Taken.${OFF} Signals:\n$REASONS"
|
|
183
|
-
echo
|
|
184
|
-
echo "${DIM}If you believe it is abandoned, check how old the work is and say so in a${OFF}"
|
|
185
|
-
echo "${DIM}comment before taking it. Never steal a fresh claim.${OFF}"
|
|
186
|
-
exit 1
|
|
187
|
-
fi
|
|
188
|
-
|
|
189
|
-
if [ -n "$CHECK_ONLY" ]; then
|
|
190
|
-
echo "${GREEN}Free.${OFF} ${DIM}(--check: nothing changed)${OFF}"
|
|
191
|
-
exit 0
|
|
192
|
-
fi
|
|
193
|
-
|
|
194
|
-
# Claim it. Label AND comment together: the label is what other sessions filter
|
|
195
|
-
# on, the comment is what dates it so a stale claim can be recognised later.
|
|
196
|
-
gh_issue edit "$ISSUE" --add-label "$LABEL" >/dev/null 2>&1 || {
|
|
197
|
-
echo "${RED}claim: could not apply the '$LABEL' label.${OFF}" >&2
|
|
198
|
-
echo "${DIM} Not claimed. Do not start work on the assumption that it worked.${OFF}" >&2
|
|
199
|
-
exit 2
|
|
200
|
-
}
|
|
201
|
-
gh_issue comment "$ISSUE" \
|
|
202
|
-
--body "Claimed at $(date -u +%FT%TZ) by \`$(git config user.name 2>/dev/null || echo agent)\`. Release it — remove the label — on merge, or if you stop." \
|
|
203
|
-
>/dev/null 2>&1
|
|
204
|
-
|
|
205
|
-
echo "${GREEN}Claimed.${OFF}"
|
|
206
|
-
echo "${DIM}Push your branch as soon as it exists: a claim is a reservation, the branch${OFF}"
|
|
207
|
-
echo "${DIM}is the evidence, and the window between them is where collisions happen.${OFF}"
|
|
208
|
-
exit 0
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
3
|
-
# Is a git hook actually going to execute here?
|
|
4
|
-
#
|
|
5
|
-
# ## Why this exists
|
|
6
|
-
#
|
|
7
|
-
# A configured hook that does not run is worse than no hook, because it is
|
|
8
|
-
# assumed to be protecting you. On 2026-07-29 the estate was in exactly that
|
|
9
|
-
# state: `core.hooksPath` pointed at `.husky/_`, a **gitignored** directory
|
|
10
|
-
# created only by `prepare: husky` on `pnpm install` — and git resolves that
|
|
11
|
-
# relative path against *each worktree's* root. Every fresh worktree therefore
|
|
12
|
-
# had no hooks, and git said nothing: no warning, no error, no output.
|
|
13
|
-
#
|
|
14
|
-
# AGENTS.md §1 mandates a fresh worktree per unit of work, so the required
|
|
15
|
-
# workflow disarmed its own gates. 6 of 32 working trees were armed. The pre-push
|
|
16
|
-
# pyright, the pre-commit lint-staged and commitlint had all been silently
|
|
17
|
-
# skipped in the other 26 for as long as anyone had been using worktrees.
|
|
18
|
-
#
|
|
19
|
-
# Nothing detected it because nothing looked. This is the thing that looks.
|
|
20
|
-
#
|
|
21
|
-
# ## Verdicts
|
|
22
|
-
#
|
|
23
|
-
# ARMED git will execute a hook here.
|
|
24
|
-
# DEAD core.hooksPath is set and its target is missing or carries no
|
|
25
|
-
# hooks. Git skips silently. **This is the state that lies to you**,
|
|
26
|
-
# and the only one that makes this script exit non-zero.
|
|
27
|
-
# NO-HOOKS no hooks configured at all. Honest, and visible in this report.
|
|
28
|
-
#
|
|
29
|
-
# Usage:
|
|
30
|
-
# sh scripts/hook-audit.sh # this repo and its worktrees
|
|
31
|
-
# sh scripts/hook-audit.sh --estate ~/code # every repo under a directory
|
|
32
|
-
# sh scripts/hook-audit.sh --quiet # verdict counts only
|
|
33
|
-
|
|
34
|
-
set -uo pipefail
|
|
35
|
-
|
|
36
|
-
ESTATE=""
|
|
37
|
-
QUIET=""
|
|
38
|
-
while [ $# -gt 0 ]; do
|
|
39
|
-
case "$1" in
|
|
40
|
-
--estate) ESTATE="$2"; shift 2 ;;
|
|
41
|
-
--quiet) QUIET=1; shift ;;
|
|
42
|
-
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
43
|
-
esac
|
|
44
|
-
done
|
|
45
|
-
|
|
46
|
-
armed=0
|
|
47
|
-
dead=0
|
|
48
|
-
nohooks=0
|
|
49
|
-
dead_list=""
|
|
50
|
-
|
|
51
|
-
# The three hooks this standard cares about. A hooksPath directory containing
|
|
52
|
-
# none of them is not armed for our purposes even if it holds something else.
|
|
53
|
-
WANTED='^(pre-commit|pre-push|commit-msg)$'
|
|
54
|
-
|
|
55
|
-
report() {
|
|
56
|
-
tree="$1"
|
|
57
|
-
label="$2"
|
|
58
|
-
hp=$(git -C "$tree" config core.hooksPath 2>/dev/null)
|
|
59
|
-
|
|
60
|
-
if [ -z "$hp" ]; then
|
|
61
|
-
# The default hooks directory is in the **common** git dir, which linked
|
|
62
|
-
# worktrees share. Reading "$tree/.git/hooks" is wrong for exactly the trees
|
|
63
|
-
# this audit exists to check: in a linked worktree `.git` is a *file*
|
|
64
|
-
# containing a gitdir pointer, so that path does not exist and every armed
|
|
65
|
-
# worktree was about to be reported NO-HOOKS. Ask git where it actually is.
|
|
66
|
-
#
|
|
67
|
-
# Git ships .sample files there that never execute, so counting the
|
|
68
|
-
# directory as armed merely for being non-empty would be exactly the false
|
|
69
|
-
# comfort this script exists to remove.
|
|
70
|
-
common=$(git -C "$tree" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
|
|
71
|
-
real=$(ls "${common:-$tree/.git}/hooks" 2>/dev/null | grep -vc '\.sample$' || true)
|
|
72
|
-
if [ "${real:-0}" -gt 0 ]; then
|
|
73
|
-
armed=$((armed + 1))
|
|
74
|
-
[ -n "$QUIET" ] || printf '%-56s %-14s \033[32mARMED\033[0m %s\n' "$label" "(default)" "$real hook(s) in .git/hooks"
|
|
75
|
-
else
|
|
76
|
-
nohooks=$((nohooks + 1))
|
|
77
|
-
[ -n "$QUIET" ] || printf '%-56s %-14s \033[33mNO-HOOKS\033[0m %s\n' "$label" "(default)" "no hooks configured"
|
|
78
|
-
fi
|
|
79
|
-
return
|
|
80
|
-
fi
|
|
81
|
-
|
|
82
|
-
# Relative hooksPath resolves against the working tree root — the whole bug.
|
|
83
|
-
case "$hp" in
|
|
84
|
-
/*) dir="$hp" ;;
|
|
85
|
-
*) dir="$tree/$hp" ;;
|
|
86
|
-
esac
|
|
87
|
-
|
|
88
|
-
present=$(ls "$dir" 2>/dev/null | grep -E "$WANTED" | tr '\n' ',' || true)
|
|
89
|
-
if [ -z "$present" ]; then
|
|
90
|
-
dead=$((dead + 1))
|
|
91
|
-
dead_list="$dead_list $label ($hp)
|
|
92
|
-
"
|
|
93
|
-
[ -n "$QUIET" ] || printf '%-56s %-14s \033[31mDEAD\033[0m %s\n' "$label" "$hp" "$hp missing or holds no hooks — git skips ALL hooks silently"
|
|
94
|
-
else
|
|
95
|
-
armed=$((armed + 1))
|
|
96
|
-
[ -n "$QUIET" ] || printf '%-56s %-14s \033[32mARMED\033[0m %s\n' "$label" "$hp" "${present%,}"
|
|
97
|
-
fi
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
walk_repo() {
|
|
101
|
-
root="${1%/}"
|
|
102
|
-
name="$2"
|
|
103
|
-
# Every working tree, not just the primary — the primary is usually the one
|
|
104
|
-
# that IS armed, which is how this went unnoticed for so long.
|
|
105
|
-
git -C "$root" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}' | while read -r t; do
|
|
106
|
-
[ -d "$t" ] || continue
|
|
107
|
-
if [ "$t" = "$root" ]; then echo "$t|$name"; else echo "$t|$name${t#$root}"; fi
|
|
108
|
-
done
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
[ -n "$QUIET" ] || printf '%-56s %-14s %-8s %s\n' "WORKING TREE" "hooksPath" "VERDICT" "detail"
|
|
112
|
-
|
|
113
|
-
if [ -n "$ESTATE" ]; then
|
|
114
|
-
targets=$(for d in "$ESTATE"/*/; do
|
|
115
|
-
[ -e "$d/.git" ] || continue
|
|
116
|
-
walk_repo "$d" "$(basename "${d%/}")"
|
|
117
|
-
done)
|
|
118
|
-
else
|
|
119
|
-
root=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "not a git repo" >&2; exit 2; }
|
|
120
|
-
# From inside a worktree, --show-toplevel gives the worktree; walk from the
|
|
121
|
-
# common repo so sibling worktrees are audited too.
|
|
122
|
-
common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
|
|
123
|
-
root=$(dirname "$common")
|
|
124
|
-
targets=$(walk_repo "$root" "$(basename "$root")")
|
|
125
|
-
fi
|
|
126
|
-
|
|
127
|
-
while IFS='|' read -r tree label; do
|
|
128
|
-
[ -n "$tree" ] || continue
|
|
129
|
-
report "$tree" "$label"
|
|
130
|
-
done <<EOF
|
|
131
|
-
$targets
|
|
132
|
-
EOF
|
|
133
|
-
|
|
134
|
-
total=$((armed + dead + nohooks))
|
|
135
|
-
printf '\n%s working trees — \033[32m%s armed\033[0m, \033[31m%s dead\033[0m, %s without hooks' "$total" "$armed" "$dead" "$nohooks"
|
|
136
|
-
[ "$total" -gt 0 ] && printf ' (%s%% armed)' "$((100 * armed / total))"
|
|
137
|
-
printf '\n'
|
|
138
|
-
|
|
139
|
-
if [ "$dead" -gt 0 ]; then
|
|
140
|
-
printf '\n\033[31mDEAD working trees — hooks are configured here and are NOT running:\033[0m\n%s' "$dead_list"
|
|
141
|
-
printf 'Every commit and push made in these is unguarded, and nothing says so.\n'
|
|
142
|
-
printf 'Fix: run `pnpm install` there, or move the repo to tracked .githooks/.\n'
|
|
143
|
-
exit 1
|
|
144
|
-
fi
|
|
145
|
-
exit 0
|
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env sh
|
|
2
|
-
#
|
|
3
|
-
# Give the Postgres-dependent test lane a database with a CURRENT schema, and
|
|
4
|
-
# print its DSN.
|
|
5
|
-
#
|
|
6
|
-
# ## Why this exists
|
|
7
|
-
#
|
|
8
|
-
# `scripts/verify.sh` grew a `pg-test` check (#1089) because on 2026-08-02 nine
|
|
9
|
-
# of thirteen locally-catchable failing CI steps across the estate were one
|
|
10
|
-
# repo's real-Postgres lane -- a required check with no local counterpart at all.
|
|
11
|
-
# But a gate can only run that lane against a database, and no repo documented
|
|
12
|
-
# how to get one: no compose file, no script, no DSN written down. The container
|
|
13
|
-
# that existed on the workstation had been created ad hoc in some earlier session
|
|
14
|
-
# and held a scatter of scratch databases. That undocumented setup WAS the
|
|
15
|
-
# fail-open, because a gate nobody can run is not a gate.
|
|
16
|
-
#
|
|
17
|
-
# ## Why freshness, not rebuild-every-time
|
|
18
|
-
#
|
|
19
|
-
# The expensive failure is not a slow rebuild, it is a STALE one. Measured on
|
|
20
|
-
# tabsii-platform while writing this: a database built about an hour earlier,
|
|
21
|
-
# before two PRs merged, produced **23 failures** in a module that had nothing to
|
|
22
|
-
# do with the change in hand. Rebuilt from the same tree it passed 336/336, and
|
|
23
|
-
# passed 336 again on an immediate re-run -- so the lane was genuinely
|
|
24
|
-
# re-runnable and every one of those failures was the old schema.
|
|
25
|
-
#
|
|
26
|
-
# That is the worst shape a local gate can have. Twenty-three red tests that are
|
|
27
|
-
# not your fault teach people the gate is unreliable, and an unreliable gate gets
|
|
28
|
-
# bypassed -- which H4 pre-registered as the condition refuting the whole
|
|
29
|
-
# local-gate programme. So the schema inputs are fingerprinted and a rebuild
|
|
30
|
-
# happens only when they actually changed: reuse ~0.3s, rebuild ~4s.
|
|
31
|
-
#
|
|
32
|
-
# ## Why it is generic
|
|
33
|
-
#
|
|
34
|
-
# It adapts to the repo rather than being told about it, for the same reason
|
|
35
|
-
# `verify.sh` does: forks drift, and a per-instance copy of this would drift from
|
|
36
|
-
# the DDL layout it is meant to build. Everything instance-specific is DERIVED --
|
|
37
|
-
# the schema directories from `db/imports/*/`, the engine image from whether the
|
|
38
|
-
# DDL asks for PostGIS, and the did-it-build threshold from the number of
|
|
39
|
-
# policies the DDL itself declares. Nothing here names a product.
|
|
40
|
-
#
|
|
41
|
-
# ## Usage
|
|
42
|
-
#
|
|
43
|
-
# eval "$(sh scripts/pg-test-db.sh --export)" # export BIFFO_TEST_PG_DSN
|
|
44
|
-
# sh scripts/pg-test-db.sh # print the DSN on stdout
|
|
45
|
-
# sh scripts/pg-test-db.sh --recreate # force a rebuild
|
|
46
|
-
#
|
|
47
|
-
# Only the DSN reaches stdout, so it is safe to capture; progress goes to stderr.
|
|
48
|
-
#
|
|
49
|
-
# Overridable: BIFFO_PG_HOST, BIFFO_PG_PORT, BIFFO_PG_USER, BIFFO_PG_PASSWORD,
|
|
50
|
-
# BIFFO_PG_DB, BIFFO_PG_CONTAINER, BIFFO_PG_IMAGE.
|
|
51
|
-
#
|
|
52
|
-
# ## Concurrency: derived, not coordinated
|
|
53
|
-
#
|
|
54
|
-
# The port, database name, and container name below default to values DERIVED
|
|
55
|
-
# from this checkout's own path, not fixed constants. A fixed port
|
|
56
|
-
# (`BIFFO_PG_PORT` used to default to `55432` everywhere) meant any two
|
|
57
|
-
# checkouts running this script at the same time -- two worktrees of one repo,
|
|
58
|
-
# or two entirely different repos on one machine -- attached to the SAME
|
|
59
|
-
# Postgres cluster and raced on cluster-wide catalogs like `pg_shdepend`
|
|
60
|
-
# (#1114). A fixed database name (`biffo_test`) meant they then shared one
|
|
61
|
-
# database on top of that (#1120).
|
|
62
|
-
#
|
|
63
|
-
# The fix is not a lock: a machine-wide `flock` around this script would make
|
|
64
|
-
# concurrent runs correct by serializing them, but that is coordination, and
|
|
65
|
-
# this estate's shared-mutable-state defects have twice been solved instead by
|
|
66
|
-
# making the value itself unique per user rather than making users take turns.
|
|
67
|
-
# Deriving from `$REPO_ROOT` gets that for free -- it is already different for
|
|
68
|
-
# every worktree and every repo -- and deterministically, so repeat runs
|
|
69
|
-
# against the SAME checkout still land on the same port/db/container and reuse
|
|
70
|
-
# the fingerprinted schema (see step 2 below) instead of rebuilding it under a
|
|
71
|
-
# fresh identity every time. `BIFFO_PG_PORT`, `BIFFO_PG_DB`, and
|
|
72
|
-
# `BIFFO_PG_CONTAINER` remain explicit overrides; only the *default* changed.
|
|
73
|
-
|
|
74
|
-
set -eu
|
|
75
|
-
|
|
76
|
-
REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
|
77
|
-
|
|
78
|
-
# sha256sum is already a dependency of this script (see `fingerprint` below),
|
|
79
|
-
# so reusing it here for a deterministic, cheap per-checkout key adds nothing
|
|
80
|
-
# new to install. 12 hex chars is ample to keep collisions between checkouts
|
|
81
|
-
# on one machine practically impossible while staying short enough to read in
|
|
82
|
-
# `docker ps` output and a psql prompt.
|
|
83
|
-
_checkout_key=$(printf '%s' "$REPO_ROOT" | sha256sum | cut -c1-12)
|
|
84
|
-
_checkout_suffix=$(printf '%s' "$_checkout_key" | cut -c1-8)
|
|
85
|
-
# IANA's dynamic/private port range (49152-65535, 16384 ports) mapped from the
|
|
86
|
-
# next 4 hex chars of the same hash -- an ephemeral port picked deterministically
|
|
87
|
-
# rather than asked of the OS, because a freshly-random port on every invocation
|
|
88
|
-
# would break the reuse this key is for (a second run against the same checkout
|
|
89
|
-
# has to land back on the same container to find its existing database).
|
|
90
|
-
_checkout_port=$((49152 + (0x$(printf '%s' "$_checkout_key" | cut -c9-12) % 16384)))
|
|
91
|
-
|
|
92
|
-
HOST="${BIFFO_PG_HOST:-localhost}"
|
|
93
|
-
PORT="${BIFFO_PG_PORT:-$_checkout_port}"
|
|
94
|
-
USER_="${BIFFO_PG_USER:-postgres}"
|
|
95
|
-
PASS="${BIFFO_PG_PASSWORD:-postgres}"
|
|
96
|
-
DB="${BIFFO_PG_DB:-biffo_test_$_checkout_suffix}"
|
|
97
|
-
# Keyed the same way as PORT and for the same reason: the container is where
|
|
98
|
-
# the port mapping actually lives (`docker run -p "$PORT:5432"`), so if the
|
|
99
|
-
# container name stayed fixed while the port became per-checkout, a second
|
|
100
|
-
# checkout would find the first checkout's container already occupying that
|
|
101
|
-
# name, "start" it rather than create its own, and then poll forever against a
|
|
102
|
-
# port that container was never bound to.
|
|
103
|
-
CONTAINER="${BIFFO_PG_CONTAINER:-biffo-pg-test-$_checkout_suffix}"
|
|
104
|
-
|
|
105
|
-
RECREATE=0
|
|
106
|
-
EXPORT=0
|
|
107
|
-
for arg in "$@"; do
|
|
108
|
-
case "$arg" in
|
|
109
|
-
--recreate) RECREATE=1 ;;
|
|
110
|
-
--export) EXPORT=1 ;;
|
|
111
|
-
-h | --help)
|
|
112
|
-
sed -n '2,72p' "$0" | sed 's/^#\{1,2\} \{0,1\}//'
|
|
113
|
-
exit 0
|
|
114
|
-
;;
|
|
115
|
-
*)
|
|
116
|
-
echo "unknown argument: $arg" >&2
|
|
117
|
-
exit 2
|
|
118
|
-
;;
|
|
119
|
-
esac
|
|
120
|
-
done
|
|
121
|
-
|
|
122
|
-
say() { echo "pg-test-db: $*" >&2; }
|
|
123
|
-
|
|
124
|
-
cd "$REPO_ROOT"
|
|
125
|
-
|
|
126
|
-
# --- what this repo's schema is made of --------------------------------------
|
|
127
|
-
#
|
|
128
|
-
# `db/imports/<name>/*.sql` is the Biffo DDL-import convention that the API's own
|
|
129
|
-
# `ddl_import.list_sql_files` reads at startup, so deriving from it means this
|
|
130
|
-
# script and the running app agree by construction rather than by someone
|
|
131
|
-
# remembering to update both.
|
|
132
|
-
DDL_FILES=$(find db/imports -mindepth 2 -maxdepth 2 -name '*.sql' 2>/dev/null | LC_ALL=C sort || true)
|
|
133
|
-
ALEMBIC_DIR=""
|
|
134
|
-
for _d in services/api .; do
|
|
135
|
-
[ -f "$_d/alembic.ini" ] && ALEMBIC_DIR="$_d" && break
|
|
136
|
-
done
|
|
137
|
-
|
|
138
|
-
if [ -z "$DDL_FILES" ] && [ -z "$ALEMBIC_DIR" ]; then
|
|
139
|
-
say "no db/imports/*/ DDL and no alembic.ini - this repo has no schema to build"
|
|
140
|
-
exit 1
|
|
141
|
-
fi
|
|
142
|
-
|
|
143
|
-
# PostGIS or plain, decided by what the DDL asks for. A plain `postgres` image
|
|
144
|
-
# fails on the first `CREATE EXTENSION postgis`, and picking the heavier image
|
|
145
|
-
# unconditionally would slow every repo that does not need it.
|
|
146
|
-
if [ -n "$DDL_FILES" ] && echo "$DDL_FILES" | xargs grep -liE 'EXTENSION[[:space:]]+(IF[[:space:]]+NOT[[:space:]]+EXISTS[[:space:]]+)?postgis' >/dev/null 2>&1; then
|
|
147
|
-
IMAGE="${BIFFO_PG_IMAGE:-postgis/postgis:16-3.4}"
|
|
148
|
-
else
|
|
149
|
-
IMAGE="${BIFFO_PG_IMAGE:-postgres:16}"
|
|
150
|
-
fi
|
|
151
|
-
|
|
152
|
-
export PGPASSWORD="$PASS"
|
|
153
|
-
psql_admin() { psql -q -h "$HOST" -p "$PORT" -U "$USER_" -d postgres "$@"; }
|
|
154
|
-
psql_db() { psql -q -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" "$@"; }
|
|
155
|
-
|
|
156
|
-
# --- 1. a reachable server ---------------------------------------------------
|
|
157
|
-
#
|
|
158
|
-
# Started here rather than assumed, because "docker run one yourself" is exactly
|
|
159
|
-
# the tribal knowledge this script replaces. An already-running server is reused.
|
|
160
|
-
if ! psql_admin -c 'SELECT 1' >/dev/null 2>&1; then
|
|
161
|
-
if ! command -v docker >/dev/null 2>&1; then
|
|
162
|
-
say "no Postgres at $HOST:$PORT and docker is not installed."
|
|
163
|
-
say "Start one and re-run, or set BIFFO_PG_HOST / BIFFO_PG_PORT."
|
|
164
|
-
exit 1
|
|
165
|
-
fi
|
|
166
|
-
if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
|
167
|
-
say "starting existing container $CONTAINER"
|
|
168
|
-
docker start "$CONTAINER" >/dev/null
|
|
169
|
-
else
|
|
170
|
-
say "creating container $CONTAINER ($IMAGE) on port $PORT"
|
|
171
|
-
docker run -d --name "$CONTAINER" \
|
|
172
|
-
-e POSTGRES_PASSWORD="$PASS" -p "$PORT:5432" "$IMAGE" >/dev/null
|
|
173
|
-
fi
|
|
174
|
-
# Polled, not slept: a cold image pull and a warm restart differ by an order of
|
|
175
|
-
# magnitude, and one fixed sleep is wrong for both.
|
|
176
|
-
_waited=0
|
|
177
|
-
until psql_admin -c 'SELECT 1' >/dev/null 2>&1; do
|
|
178
|
-
_waited=$((_waited + 1))
|
|
179
|
-
if [ "$_waited" -gt 90 ]; then
|
|
180
|
-
say "Postgres did not become ready in 90s"
|
|
181
|
-
exit 1
|
|
182
|
-
fi
|
|
183
|
-
sleep 1
|
|
184
|
-
done
|
|
185
|
-
say "Postgres ready after ${_waited}s"
|
|
186
|
-
fi
|
|
187
|
-
|
|
188
|
-
DSN="postgresql+asyncpg://$USER_:$PASS@$HOST:$PORT/$DB"
|
|
189
|
-
emit() {
|
|
190
|
-
if [ "$EXPORT" -eq 1 ]; then
|
|
191
|
-
echo "export BIFFO_TEST_PG_DSN='$DSN'"
|
|
192
|
-
else
|
|
193
|
-
echo "$DSN"
|
|
194
|
-
fi
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
# --- 2. is the existing schema current? --------------------------------------
|
|
198
|
-
#
|
|
199
|
-
# By CONTENT, not mtime: a branch switch changes content and leaves mtime
|
|
200
|
-
# anywhere. Stored inside the database, so it cannot outlive a drop or describe
|
|
201
|
-
# some other database.
|
|
202
|
-
fingerprint() {
|
|
203
|
-
{
|
|
204
|
-
[ -n "$ALEMBIC_DIR" ] && find "$ALEMBIC_DIR" -name '*.py' -path '*alembic*' -type f 2>/dev/null |
|
|
205
|
-
LC_ALL=C sort | xargs cat 2>/dev/null
|
|
206
|
-
[ -n "$DDL_FILES" ] && echo "$DDL_FILES" | xargs cat 2>/dev/null
|
|
207
|
-
} | sha256sum | cut -d' ' -f1
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
WANT=$(fingerprint)
|
|
211
|
-
HAVE=""
|
|
212
|
-
if [ "$RECREATE" -eq 0 ] &&
|
|
213
|
-
psql_admin -tAc "SELECT 1 FROM pg_database WHERE datname='$DB'" 2>/dev/null | grep -q 1; then
|
|
214
|
-
HAVE=$(psql -tAq -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" \
|
|
215
|
-
-c "SELECT value FROM biffo_pg_test_fingerprint LIMIT 1" 2>/dev/null || true)
|
|
216
|
-
fi
|
|
217
|
-
|
|
218
|
-
if [ -n "$HAVE" ] && [ "$HAVE" = "$WANT" ]; then
|
|
219
|
-
say "schema is current, reusing $DB"
|
|
220
|
-
emit
|
|
221
|
-
exit 0
|
|
222
|
-
fi
|
|
223
|
-
|
|
224
|
-
[ -n "$HAVE" ] && say "schema inputs changed - rebuilding rather than serving a stale schema"
|
|
225
|
-
|
|
226
|
-
# --- 3. rebuild the way the app and CI do ------------------------------------
|
|
227
|
-
say "rebuilding $DB"
|
|
228
|
-
psql_admin -c "DROP DATABASE IF EXISTS $DB WITH (FORCE)" >/dev/null
|
|
229
|
-
psql_admin -c "CREATE DATABASE $DB" >/dev/null
|
|
230
|
-
|
|
231
|
-
if [ -n "$ALEMBIC_DIR" ]; then
|
|
232
|
-
BIFFO_DATABASE_URL="$DSN" uv run --directory "$ALEMBIC_DIR" alembic upgrade head >/dev/null
|
|
233
|
-
say "alembic upgrade head"
|
|
234
|
-
fi
|
|
235
|
-
|
|
236
|
-
if [ -n "$DDL_FILES" ]; then
|
|
237
|
-
# ONE psql session, sorted by filename, mirroring the API's own DDL import.
|
|
238
|
-
# Session state an early module sets -- typically `SET search_path` in the
|
|
239
|
-
# first file -- has to survive into later ones, so a per-file connection would
|
|
240
|
-
# silently change the meaning of every unqualified name after it. LC_ALL=C
|
|
241
|
-
# keeps the shell's sort byte-ordered to match Python's.
|
|
242
|
-
# shellcheck disable=SC2046
|
|
243
|
-
psql -q -v ON_ERROR_STOP=1 -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" \
|
|
244
|
-
--single-transaction $(echo "$DDL_FILES" | sed 's/^/-f /' | tr '\n' ' ') >/dev/null
|
|
245
|
-
say "$(echo "$DDL_FILES" | wc -l | tr -d ' ') DDL modules applied"
|
|
246
|
-
fi
|
|
247
|
-
|
|
248
|
-
# --- 4. refuse to bless a half-built schema ----------------------------------
|
|
249
|
-
#
|
|
250
|
-
# The threshold is derived, not guessed: count the policies the DDL declares and
|
|
251
|
-
# require the database to hold at least half. Recording a fingerprint against a
|
|
252
|
-
# partial schema is worse than failing, because the NEXT run would trust it and
|
|
253
|
-
# every failure after that would look like the developer's own change.
|
|
254
|
-
if [ -n "$DDL_FILES" ]; then
|
|
255
|
-
_declared=$(echo "$DDL_FILES" | xargs grep -ciE '^[[:space:]]*CREATE[[:space:]]+POLICY' 2>/dev/null |
|
|
256
|
-
awk -F: '{s+=$NF} END {print s+0}')
|
|
257
|
-
if [ "${_declared:-0}" -gt 0 ]; then
|
|
258
|
-
_actual=$(psql -tAq -h "$HOST" -p "$PORT" -U "$USER_" -d "$DB" \
|
|
259
|
-
-c "SELECT count(*) FROM pg_policies" 2>/dev/null || echo 0)
|
|
260
|
-
if [ "${_actual:-0}" -lt $((_declared / 2)) ]; then
|
|
261
|
-
say "only ${_actual:-0} policies present against $_declared declared - the schema did not build."
|
|
262
|
-
say "Not recording a fingerprint; fix the DDL and re-run."
|
|
263
|
-
exit 1
|
|
264
|
-
fi
|
|
265
|
-
say "$_actual RLS policies ($_declared declared)"
|
|
266
|
-
fi
|
|
267
|
-
fi
|
|
268
|
-
|
|
269
|
-
psql_db \
|
|
270
|
-
-c "CREATE TABLE IF NOT EXISTS biffo_pg_test_fingerprint (value text primary key)" \
|
|
271
|
-
-c "TRUNCATE biffo_pg_test_fingerprint" \
|
|
272
|
-
-c "INSERT INTO biffo_pg_test_fingerprint (value) VALUES ('$WANT')" >/dev/null
|
|
273
|
-
|
|
274
|
-
say "ready"
|
|
275
|
-
emit
|