cce workspace root: member list, lockfile and dependency pins
git clone https://git.lucas.co/cce.git
bump-revs.sh (16K)
1 #!/bin/sh
2 # Bump the pinned revs that let a single crate be installed on its own.
3 #
4 # Each app declares its workspace-internal dependencies as git dependencies on
5 # GitHub (the crates' origin), pinned to an exact rev, while the workspace root patches those
6 # sources back to the local crates. That split is what lets one app be cloned
7 # and built alone -- and it is also exactly why the pins rot silently: inside
8 # this workspace the [patch] block always wins, so a stale rev never fails a
9 # build here. It surfaces only as a standalone build of an app quietly
10 # compiling an old copy of the toolkit. Nothing warns you. Hence this script.
11 #
12 # Run it after pushing a shared crate (cce-ui, cce-window-manager).
13 #
14 # bump-revs.sh [--dry-run] [--commit] [dep...]
15 #
16 # With no dep named, every dependency that appears in a git pin is considered.
17 #
18 # WHAT IT PINS TO: the head of the dependency's branch on its `origin` remote
19 # (GitHub, since 2026-09-20 -- the bare repos under ~/git are gone) -- not the
20 # work tree's HEAD. A rev that exists only in a work tree is fetchable by
21 # nobody, so pinning it would write manifests that resolve on this machine and
22 # nowhere else. If the dependency's work tree has uncommitted changes or
23 # commits not yet on origin, that is reported and the run fails rather than
24 # pinning something stale; push it first (the post-commit hook normally has).
25 # An origin ahead of the work tree is fine and is pinned as-is -- it is what
26 # others can actually fetch.
27 #
28 # The pins name GitHub directly (since 2026-09-21; before that git.lucas.co,
29 # which only mirrors GitHub hourly, so a fresh pin was unfetchable for up to an
30 # hour), so a rev pinned right after a push resolves at once.
31 #
32 # A manifest that should have changed but did not fails the run. Silently
33 # skipping is what let 21 repos sit unpushed for a day; the same rule applies
34 # here.
35 #
36 # THE LOCKFILE MOVES WITH THE PIN. A crate that commits its Cargo.lock gets it
37 # re-resolved against the new pin and committed alongside it. Inside the
38 # workspace cargo never reads a member's own lock (the root lock and the
39 # [patch] block win), so a stale one rots exactly as a stale pin does, and
40 # surfaces only as a standalone build rewriting it silently. Until 2026-09-25
41 # this script touched Cargo.toml only, and all ten tracked locks with git deps
42 # had drifted -- two still resolving cce-ui as a PATH dependency. The refresh
43 # is `cargo metadata` in a copy of the crate outside the workspace: a minimal
44 # update, the one a standalone build would make, moving nothing from
45 # crates.io that the new pin does not require. A crate whose lock does not
46 # resolve fails the run before anything is committed.
47 #
48 # A PIN MAY CARRY EXTRA KEYS AFTER `rev` (features, optional, ...); the bump
49 # rewrites the rev alone and keeps them. Until 2026-10-01 the pattern demanded
50 # nothing after the rev, so cce-notes' and cce-grid's `features = [...]` pins
51 # of cce-ui and cce-ui's `optional = true` pin of cce-vault were invisible:
52 # not bumped, not reported, because the drift check only looked at manifests
53 # the pattern had already matched. Now every `git = "https://github.com/
54 # lsgalante/..."` line the pattern does not match is reported, and one naming
55 # a dependency being bumped fails the run before anything is written.
56 #
57 # A REPIN TOUCHES ONLY THE REPIN. A crate whose Cargo.toml or Cargo.lock
58 # already has uncommitted changes blocks the run before anything is written,
59 # and --commit commits exactly those two paths, leaving anything else staged
60 # where it was. Until 2026-10-01 it ran `git add` then a bare `git commit`, so
61 # whatever another session had staged, or half-edited in the manifest, went
62 # out in the "Repin" commit and was pushed with it. A commit that fails now
63 # fails the run.
64
65 set -eu
66
67 ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
68 DRY=""
69 COMMIT=""
70 WANT=""
71
72 for arg in "$@"; do
73 case "$arg" in
74 --dry-run) DRY=1 ;;
75 --commit) COMMIT=1 ;;
76 -h|--help) sed -n '2,/^$/p' "$0" | sed '/^$/d; s/^# \{0,1\}//'; exit 0 ;;
77 -*) echo "unknown option: $arg" >&2; exit 2 ;;
78 *) WANT="$WANT $arg" ;;
79 esac
80 done
81
82 [ -f "$ROOT/Cargo.toml" ] || { echo "no Cargo.toml beside $0" >&2; exit 1; }
83 grep -q '^\[workspace\]' "$ROOT/Cargo.toml" || {
84 echo "$ROOT/Cargo.toml is not a workspace root" >&2; exit 1; }
85
86 say() { printf '%s\n' "$*"; }
87
88 # Scratch space for the whole run, removed however the run ends -- an early
89 # `exit 1` included, which used to leave a predictable /tmp/bump-revs.$$
90 # behind.
91 SCRATCH=$(mktemp -d)
92 trap 'rm -rf "$SCRATCH"' EXIT
93
94 # Every git pin in the workspace, as "crate dep rev". A pin is
95 # dep = { git = "https://github.com/lsgalante/dep.git", rev = "<sha>"[, more] }
96 # -- the git URL and rev first, then any other keys (features, optional, ...),
97 # which the bump leaves exactly as they are.
98 PIN_RE='^\([a-z0-9-]*\) = { git = "https://github\.com/lsgalante/\1\.git", rev = "\([0-9a-f]\{40\}\)"\(, [^}]*\)\{0,1\} }$'
99 pins() {
100 for m in "$ROOT"/*/Cargo.toml; do
101 crate=$(basename "$(dirname "$m")")
102 sed -n "s|$PIN_RE|$crate \\1 \\2|p" "$m"
103 done
104 }
105
106 # Every line that points a dependency at one of our GitHub repos but is not a
107 # pin in the shape above, as "crate:line: text". These are what used to vanish
108 # silently -- a pin the pattern cannot see is a pin nobody bumps.
109 strays() {
110 for m in "$ROOT"/*/Cargo.toml; do
111 crate=$(basename "$(dirname "$m")")
112 grep -n 'git = "https://github\.com/lsgalante/' "$m" \
113 | grep -v '^[0-9]*:[[:space:]]*#' \
114 | grep -v "^[0-9]*:$(printf '%s' "$PIN_RE" | sed 's/^\^//')" \
115 | sed "s|^|$crate:|"
116 done
117 }
118
119 ALL_PINS=$(pins)
120 STRAYS=$(strays)
121 if [ -n "$STRAYS" ]; then
122 say "!! not in pin shape -- this script cannot see or bump these:"
123 printf '%s\n' "$STRAYS" | sed 's/^/ /'
124 say " put each in the shape above (extra keys after rev are fine)"
125 fi
126 [ -n "$ALL_PINS" ] || [ -n "$STRAYS" ] || { say "no git pins found under $ROOT"; exit 0; }
127
128 DEPS=$(printf '%s\n' "$ALL_PINS" | awk '{print $2}' | sort -u)
129 if [ -n "$WANT" ]; then
130 for w in $WANT; do
131 printf '%s\n' "$DEPS" | grep -qx "$w" || {
132 echo "$w is not pinned by any crate here" >&2; exit 1; }
133 done
134 DEPS=$(printf '%s' "$WANT" | tr ' ' '\n' | sed '/^$/d')
135 fi
136
137 # A stray naming a dependency this run bumps is a dependent that would be
138 # left behind, so it stops the run; with no dependency named, every one of
139 # our repos counts. Strays naming other dependencies were reported above.
140 if [ -n "$STRAYS" ]; then
141 if [ -n "$WANT" ]; then
142 hit=""
143 for dep in $DEPS; do
144 printf '%s\n' "$STRAYS" | grep -q "lsgalante/$dep\(\.git\)\{0,1\}\"" && hit="$hit $dep"
145 done
146 else
147 hit=" every dependency"
148 fi
149 if [ -n "$hit" ]; then
150 say ""
151 say "blocked: a pin above is out of shape, bumping$hit would leave it behind"
152 say "-- nothing written"
153 exit 1
154 fi
155 fi
156
157 [ -n "$DRY" ] && say "DRY RUN -- nothing will be written"
158
159 # Resolve each dependency to the rev that others can actually fetch, refusing
160 # to pin anything that is only local. Collected first, so a blocked dependency
161 # stops the run before any manifest is touched.
162 TARGETS=""
163 BLOCKED=""
164 for dep in $DEPS; do
165 wt="$ROOT/$dep"
166 # -e, not -d: a checkout made with `git worktree add` has a .git FILE.
167 [ -e "$wt/.git" ] || { say "!! $dep: no work tree at $wt"; BLOCKED="$BLOCKED $dep"; continue; }
168 branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD) || {
169 say "!! $dep: detached HEAD -- check out its branch first"; BLOCKED="$BLOCKED $dep"; continue; }
170 url=$(git -C "$wt" remote get-url origin 2>/dev/null) || {
171 say "!! $dep: no origin remote"; BLOCKED="$BLOCKED $dep"; continue; }
172 # Asked of the remote itself, not a possibly stale remote-tracking ref:
173 # what others can fetch is what origin has right now.
174 new=$(git -C "$wt" ls-remote --quiet "$url" "refs/heads/$branch" 2>/dev/null | cut -f1)
175 [ -n "$new" ] || {
176 say "!! $dep: origin ($url) has no branch $branch -- push it first"; BLOCKED="$BLOCKED $dep"; continue; }
177
178 if [ -n "$(git -C "$wt" status --porcelain --untracked-files=no)" ]; then
179 say "!! $dep: uncommitted changes -- commit and push before pinning"
180 BLOCKED="$BLOCKED $dep"; continue
181 fi
182 wt_head=$(git -C "$wt" rev-parse HEAD)
183 if [ "$wt_head" != "$new" ]; then
184 # Make sure the local history knows the remote rev before comparing;
185 # a fetch is cheap and the ancestor test is meaningless without it.
186 git -C "$wt" fetch --quiet "$url" "refs/heads/$branch" 2>/dev/null || true
187 if git -C "$wt" merge-base --is-ancestor "$new" "$wt_head" 2>/dev/null; then
188 ahead=$(git -C "$wt" rev-list --count "$new..$wt_head")
189 say "!! $dep: work tree is $ahead commit(s) ahead of origin/$branch"
190 say " push it first, or the pin misses that work:"
191 say " git -C $wt push origin $branch"
192 BLOCKED="$BLOCKED $dep"; continue
193 elif ! git -C "$wt" merge-base --is-ancestor "$wt_head" "$new" 2>/dev/null; then
194 say "!! $dep: work tree and origin/$branch have diverged -- reconcile first"
195 BLOCKED="$BLOCKED $dep"; continue
196 fi
197 fi
198 TARGETS="$TARGETS $dep=$new"
199 done
200
201 if [ -n "$BLOCKED" ]; then
202 say ""
203 say "blocked:$BLOCKED -- nothing written"
204 # One blocked name per line: grep reads each line as its own pattern.
205 # (`tr -d ' '` used to glue two blocked names into one pattern that
206 # matched nothing, so the hint suggested re-running with them included.)
207 rest=$(printf '%s\n' "$DEPS" \
208 | grep -vxF "$(printf '%s' "$BLOCKED" | tr ' ' '\n' | sed '/^$/d')" \
209 | tr '\n' ' ' | sed 's/ $//')
210 if [ -n "$rest" ]; then
211 say "name the other dependencies explicitly to bump them anyway, e.g."
212 say " $(basename "$0") $rest"
213 fi
214 exit 1
215 fi
216
217 # A crate this run would repin must not already have uncommitted changes in
218 # its manifest or lock: the repin would be committed on top of them -- often
219 # another session's work in progress -- and pushed with it, and the lock
220 # refresh would resolve against them too. Checked before anything is written.
221 DIRTY=""
222 for t in $TARGETS; do
223 dep=${t%%=*}
224 new=${t#*=}
225 for crate in $(printf '%s\n' "$ALL_PINS" | awk -v d="$dep" -v n="$new" '$2 == d && $3 != n { print $1 }'); do
226 case " $DIRTY " in *" $crate "*) continue ;; esac
227 if [ -n "$(git -C "$ROOT/$crate" status --porcelain --untracked-files=no -- Cargo.toml Cargo.lock 2>&1)" ]; then
228 say "!! $crate: Cargo.toml or Cargo.lock has uncommitted changes -- commit or stash them first"
229 DIRTY="$DIRTY $crate"
230 fi
231 done
232 done
233 if [ -n "$DIRTY" ]; then
234 say ""
235 say "blocked: would repin on top of uncommitted changes in$DIRTY -- nothing written"
236 exit 1
237 fi
238
239 # Apply. A crate already at the target rev is left alone and reported as such,
240 # so the output distinguishes "nothing to do" from "did nothing".
241 CHANGED=""
242 UNCHANGED=0
243 for t in $TARGETS; do
244 dep=${t%%=*}
245 new=${t#*=}
246 say "$dep -> $(printf '%.8s' "$new")"
247 printf '%s\n' "$ALL_PINS" | while read -r crate d old; do
248 [ "$d" = "$dep" ] || continue
249 [ "$old" = "$new" ] && { echo "SAME $crate"; continue; }
250 echo "EDIT $crate $old"
251 done > "$SCRATCH/plan"
252
253 while read -r verb crate old; do
254 case "$verb" in
255 SAME) UNCHANGED=$((UNCHANGED + 1)) ;;
256 EDIT)
257 m="$ROOT/$crate/Cargo.toml"
258 say " $crate"
259 if [ -z "$DRY" ]; then
260 # The rev alone is rewritten; keys after it are kept.
261 head="$dep = { git = \"https://github\\.com/lsgalante/$dep\\.git\", rev = \""
262 sed -i "s|^\($head\)$old\"|\1$new\"|" "$m"
263 grep -q "^$head$new\"" "$m" || {
264 say "!! $crate: manifest did not change -- pin format drifted?"; exit 1; }
265 else
266 printf ' would: %s %s -> %s\n' "$crate" "$(printf '%.8s' "$old")" "$(printf '%.8s' "$new")"
267 fi
268 CHANGED="$CHANGED $crate"
269 ;;
270 esac
271 done < "$SCRATCH/plan"
272 done
273
274 CHANGED=$(printf '%s' "$CHANGED" | tr ' ' '\n' | sed '/^$/d' | sort -u)
275 COUNT=$(printf '%s' "$CHANGED" | grep -c . || true)
276
277 # Whether a crate commits its Cargo.lock. An untracked (or absent) lock has
278 # nothing published to go stale, so it is left to whoever builds there.
279 lock_tracked() {
280 git -C "$ROOT/$1" ls-files --error-unmatch Cargo.lock >/dev/null 2>&1
281 }
282
283 # Re-resolve one crate's Cargo.lock against its edited manifest, the way a
284 # standalone clone would: the committed tree plus the work tree's manifest and
285 # lock, in a directory outside the workspace (inside it cargo would find the
286 # root and ignore this lock entirely).
287 WORK="$SCRATCH/locks"
288 refresh_lock() {
289 crate=$1
290 mkdir -p "$WORK"
291 case "$WORK" in "$ROOT"/*)
292 say "!! temp dir $WORK is inside the workspace -- set TMPDIR elsewhere"; return 1 ;;
293 esac
294 copy="$WORK/$crate"
295 rm -rf "$copy" && mkdir -p "$copy"
296 git -C "$ROOT/$crate" archive HEAD | tar -x -C "$copy"
297 cp "$ROOT/$crate/Cargo.toml" "$ROOT/$crate/Cargo.lock" "$copy/"
298 if ! ( cd "$copy" && cargo metadata --quiet --format-version 1 >/dev/null 2>"$WORK/$crate.err" ); then
299 say "!! $crate: Cargo.lock does not resolve standalone:"
300 tail -n 5 "$WORK/$crate.err" | sed 's/^/ /'
301 return 1
302 fi
303 if cmp -s "$copy/Cargo.lock" "$ROOT/$crate/Cargo.lock"; then
304 say " $crate (already current)"
305 else
306 cp "$copy/Cargo.lock" "$ROOT/$crate/Cargo.lock"
307 say " $crate"
308 fi
309 }
310
311 if [ "$COUNT" != 0 ]; then
312 say ""
313 if [ -n "$DRY" ]; then
314 for crate in $CHANGED; do
315 lock_tracked "$crate" && say " would refresh: $crate/Cargo.lock"
316 done
317 else
318 say "refreshing lockfiles:"
319 LOCK_FAILED=""
320 for crate in $CHANGED; do
321 lock_tracked "$crate" || continue
322 refresh_lock "$crate" || LOCK_FAILED="$LOCK_FAILED $crate"
323 done
324 if [ -n "$LOCK_FAILED" ]; then
325 say ""
326 say "lock refresh failed:$LOCK_FAILED -- nothing committed; the"
327 say "manifests are edited in place, so fix the resolve and re-run"
328 exit 1
329 fi
330 fi
331 fi
332
333 say ""
334 if [ "$COUNT" = 0 ]; then
335 say "every pin already current ($UNCHANGED) -- nothing to do"
336 exit 0
337 fi
338 if [ -n "$DRY" ]; then
339 repinned="would be repinned"
340 else
341 repinned="repinned"
342 fi
343 if [ "$UNCHANGED" -gt 0 ]; then
344 say "$COUNT crate(s) $repinned, $UNCHANGED already current"
345 else
346 say "$COUNT crate(s) $repinned"
347 fi
348
349 if [ -n "$DRY" ]; then
350 say "re-run without --dry-run to write them"
351 elif [ -n "$COMMIT" ]; then
352 say ""
353 say "committing:"
354 FAILED=""
355 for crate in $CHANGED; do
356 files="Cargo.toml"
357 lock_tracked "$crate" && files="$files Cargo.lock"
358 # The paths after `--` are the whole commit: anything else already
359 # staged in the crate stays staged and out of it. A failure is
360 # counted, not swallowed -- `( ... ) && say` here once let a failed
361 # commit pass under set -e and still print "committed".
362 if git -C "$ROOT/$crate" commit --quiet -m "Repin workspace dependencies to their published revs
363
364 The pinned revs only affect builds outside this workspace, where an app is
365 cloned on its own, so a stale pin never fails a build here. Bumped by
366 bump-revs.sh after the dependency was pushed; Cargo.lock (when committed)
367 is re-resolved to match." -- $files; then
368 say " $crate"
369 else
370 say "!! $crate: commit failed"
371 FAILED="$FAILED $crate"
372 fi
373 done
374 if [ -n "$FAILED" ]; then
375 say ""
376 say "not committed:$FAILED -- their manifests are edited in place; commit them by hand"
377 exit 1
378 fi
379 say ""
380 say "committed -- each crate's post-commit hook pushes it to origin (GitHub);"
381 say "a crate without the hook still needs: git -C <crate> push origin <branch>"
382 else
383 say "review, then commit in each crate (or re-run with --commit)"
384 fi