Skip to content

Commit 0b2421d

Browse files
authored
Keep shdeps release bootstrap local (#22)
Avoid doing foreground GitHub release freshness checks while sourcing `install.sh --bootstrap` for an existing release install. Bootstrap is on callers' startup path, and a transient GitHub 504 could delay commands like `dot update` before they had a chance to render their own UI. This keeps release installs local by default during bootstrap, while still allowing `SHDEPS_FORCE=1` to perform an immediate release refresh before the wrapper is sourced. Source checkout installs keep their existing bootstrap self-update behavior. The old Bash release-tag comparator and stale-refresh helper are removed now that normal release freshness is no longer owned by the sourceable installer bootstrap path. Testing: - `bash -n install.sh tests/shell/install-sh-test tests/shell/installer-flow-test` - `shellcheck install.sh tests/shell/install-sh-test tests/shell/installer-flow-test` - `tests/shell/install-sh-test` - `tests/shell/installer-flow-test` - `cargo test --locked` - `cargo build --locked` - `SHDEPS_RUST_CLI=target/debug/shdeps tests/shell/shdeps-wrapper-test` - `tests/shell/release-scripts-test` - `cargo clippy --locked --all-targets -- -D warnings` - `RUSTDOCFLAGS='-D missing-docs' cargo doc --locked --no-deps` - `tests/shell/lua-api-test` - `tests/shell/lua-bootstrap-test` - `/bin/bash scripts/smoke-install-bash32.sh` - verified the curl-wrapper bootstrap probe against the patched installer returned in 0s with no curl call on an affected release install - `git diff --check` Not run locally: `scripts/package-release.sh x86_64-unknown-linux-musl linux-x86_64-musl` and `scripts/smoke-release.sh linux-x86_64-musl`, because the local workstation does not have `rustup` or the `x86_64-unknown-linux-musl` Rust target installed; CI installs that target before running the package smoke.
1 parent 1948408 commit 0b2421d

3 files changed

Lines changed: 48 additions & 275 deletions

File tree

install.sh

Lines changed: 18 additions & 174 deletions
Original file line numberDiff line numberDiff line change
@@ -257,144 +257,6 @@ _json_string() {
257257
sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "$file" | head -n 1
258258
}
259259

260-
_installed_release_tag() {
261-
local dir="$1"
262-
263-
_json_string "$dir/.shdeps-install.json" "tag"
264-
}
265-
266-
_release_tag_newer() {
267-
local latest="$1" current="$2"
268-
269-
[[ -n "$latest" ]] || return 1
270-
# When no current tag exists we cannot compute "newer than"; let the
271-
# caller decide whether to install. (The bootstrap refresh path already
272-
# short-circuits this case; this branch is here for direct callers.)
273-
[[ -n "$current" ]] || return 0
274-
[[ "$(_natural_tag_cmp "$latest" "$current")" == "gt" ]]
275-
}
276-
277-
# Natural-order tag comparison shared with the Rust self-update path.
278-
#
279-
# Stable lexical comparison of release tags works for shdeps' own
280-
# `YYYYMMDD-HHMMSS-<hash>` format because the timestamp prefix is
281-
# zero-padded. It silently breaks down for any tag scheme with a numeric
282-
# component that grows past 9 (e.g., `v1.10` vs `v1.9`, where lexical
283-
# comparison incorrectly orders `v1.10` before `v1.9`). The Rust
284-
# implementation in `src/self_update.rs::compare_tags` already does a
285-
# natural sort that treats numeric runs by trimmed length and then by
286-
# bytes, with numeric runs sorting after text runs. Mirror that here so
287-
# bootstrap-time and runtime self-update agree on which release is newer
288-
# for every possible tag scheme.
289-
#
290-
# Echoes one of `lt`, `eq`, `gt` to stdout. Stays Bash 3.2-compatible
291-
# because `install.sh` runs from the curl-pipe path on macOS's stock
292-
# /bin/bash.
293-
_natural_tag_cmp() {
294-
local left="$1" right="$2"
295-
local left_pos=0 right_pos=0
296-
local left_len=${#left} right_len=${#right}
297-
local lc rc l_run r_run l_digit r_digit l_trim r_trim
298-
299-
while [[ $left_pos -lt $left_len || $right_pos -lt $right_len ]]; do
300-
if [[ $left_pos -ge $left_len ]]; then
301-
echo "lt"
302-
return
303-
fi
304-
if [[ $right_pos -ge $right_len ]]; then
305-
echo "gt"
306-
return
307-
fi
308-
309-
# Determine whether the next run on each side is numeric or textual.
310-
lc="${left:$left_pos:1}"
311-
rc="${right:$right_pos:1}"
312-
case "$lc" in [0-9]) l_digit=1 ;; *) l_digit=0 ;; esac
313-
case "$rc" in [0-9]) r_digit=1 ;; *) r_digit=0 ;; esac
314-
315-
# Extract the maximal same-class run from each side.
316-
l_run=""
317-
if [[ $l_digit -eq 1 ]]; then
318-
while [[ $left_pos -lt $left_len ]]; do
319-
case "${left:$left_pos:1}" in [0-9]) ;; *) break ;; esac
320-
l_run="$l_run${left:$left_pos:1}"
321-
left_pos=$((left_pos + 1))
322-
done
323-
else
324-
while [[ $left_pos -lt $left_len ]]; do
325-
case "${left:$left_pos:1}" in [0-9]) break ;; esac
326-
l_run="$l_run${left:$left_pos:1}"
327-
left_pos=$((left_pos + 1))
328-
done
329-
fi
330-
r_run=""
331-
if [[ $r_digit -eq 1 ]]; then
332-
while [[ $right_pos -lt $right_len ]]; do
333-
case "${right:$right_pos:1}" in [0-9]) ;; *) break ;; esac
334-
r_run="$r_run${right:$right_pos:1}"
335-
right_pos=$((right_pos + 1))
336-
done
337-
else
338-
while [[ $right_pos -lt $right_len ]]; do
339-
case "${right:$right_pos:1}" in [0-9]) break ;; esac
340-
r_run="$r_run${right:$right_pos:1}"
341-
right_pos=$((right_pos + 1))
342-
done
343-
fi
344-
345-
# Cross-class: numeric runs sort AFTER text runs so `v10` > `vbeta`.
346-
if [[ $l_digit -ne $r_digit ]]; then
347-
if [[ $l_digit -eq 1 ]]; then echo "gt"; else echo "lt"; fi
348-
return
349-
fi
350-
351-
if [[ $l_digit -eq 1 ]]; then
352-
# Numeric: trim leading zeros, compare by length first (so 10 > 9
353-
# without ever fitting into a fixed-width integer), then by bytes
354-
# for the same-length case.
355-
l_trim="${l_run#"${l_run%%[!0]*}"}"
356-
r_trim="${r_run#"${r_run%%[!0]*}"}"
357-
[[ -z "$l_trim" ]] && l_trim="0"
358-
[[ -z "$r_trim" ]] && r_trim="0"
359-
if [[ ${#l_trim} -lt ${#r_trim} ]]; then
360-
echo "lt"
361-
return
362-
fi
363-
if [[ ${#l_trim} -gt ${#r_trim} ]]; then
364-
echo "gt"
365-
return
366-
fi
367-
# `<` / `>` inside `[[ ]]` is lexicographic; on equal-length pure
368-
# digit strings that matches numeric ordering exactly. Use it
369-
# instead of `(( ))` so the comparison stays correct for numbers
370-
# that exceed bash's integer width. shellcheck SC2071 misreads
371-
# the intent so disable it inline rather than reshape the code.
372-
# shellcheck disable=SC2071
373-
if [[ "$l_trim" < "$r_trim" ]]; then
374-
echo "lt"
375-
return
376-
fi
377-
# shellcheck disable=SC2071
378-
if [[ "$l_trim" > "$r_trim" ]]; then
379-
echo "gt"
380-
return
381-
fi
382-
else
383-
# shellcheck disable=SC2071
384-
if [[ "$l_run" < "$r_run" ]]; then
385-
echo "lt"
386-
return
387-
fi
388-
# shellcheck disable=SC2071
389-
if [[ "$l_run" > "$r_run" ]]; then
390-
echo "gt"
391-
return
392-
fi
393-
fi
394-
done
395-
echo "eq"
396-
}
397-
398260
_asset_url() {
399261
local file="$1" name="$2"
400262

@@ -693,34 +555,6 @@ _cleanup_installed_state_for_source_checkout() {
693555
fi
694556
}
695557

696-
_refresh_release_install_if_stale() {
697-
local current latest repo
698-
699-
_uses_default_repo_slug || return 0
700-
_is_release_install_dir "$SHDEPS_DIR" || return 0
701-
702-
repo=$(_repo_slug)
703-
current=$(_installed_release_tag "$SHDEPS_DIR")
704-
# When the local install metadata is missing a `tag` field, we have no
705-
# comparison baseline. Treating "no current tag" as "always stale" used
706-
# to fire a `_install_release` call on every shell startup, hammering
707-
# GitHub for an install that was probably fine. A missing tag indicates
708-
# either a fresh install (which `_install_release` ran moments ago) or
709-
# a corrupted state file (which the user repairs by re-running the
710-
# installer explicitly). Either way, the background refresh should be
711-
# a no-op rather than a silent re-download loop.
712-
[[ -n "$current" ]] || return 0
713-
latest=$(_latest_release_tag "$repo") || return 0
714-
715-
# Bootstrap self-update has to be conservative but useful. Release tags are
716-
# timestamp-prefixed (`YYYYMMDD-HHMMSS-<hash>`), so lexical ordering is enough
717-
# to avoid replacing a newer local archive with an older or deleted GitHub
718-
# "latest" release while still advancing normal fleet installs promptly.
719-
if _release_tag_newer "$latest" "$current"; then
720-
_install_release >/dev/null 2>&1 || true
721-
fi
722-
}
723-
724558
_install_release() {
725559
local platform repo api_url token tmp json tag archive checksum
726560
local archive_url checksum_url archive_api_url checksum_api_url bundle
@@ -1030,7 +864,9 @@ _install() {
1030864
# ---------------------------------------------------------------------------
1031865
# Designed to be sourced: `. /path/to/install.sh --bootstrap`
1032866
#
1033-
# Finds shdeps.sh, sources it, symlinks the CLI, and runs self-update.
867+
# Finds shdeps.sh, sources it, and symlinks the CLI. Source checkouts still
868+
# self-update during bootstrap; release installs stay local unless forced so
869+
# callers are not blocked on GitHub before they can render their own UI.
1034870
# Clients set env vars (SHDEPS_CONF_DIR, SHDEPS_HOOKS_DIR, etc.) before
1035871
# sourcing.
1036872
#
@@ -1040,7 +876,7 @@ _bootstrap() {
1040876
# Idempotent — skip if already bootstrapped
1041877
declare -f shdeps_update &>/dev/null && return 0
1042878

1043-
local _bs_lib="" _bs_dir=""
879+
local _bs_lib="" _bs_dir="" _bs_release_install=0
1044880
local _dev_dir="${SHDEPS_GIT_DEV_DIR:-$HOME/git}"
1045881

1046882
# Find shdeps.sh: installed-tree env hint → env override → dev clone →
@@ -1051,8 +887,8 @@ _bootstrap() {
1051887
if _bootstrap_lib_is_installed_tree; then
1052888
if _uses_default_repo_slug && [[ -d "$SHDEPS_DIR/.git" ]]; then
1053889
_install_release >/dev/null 2>&1 || true
1054-
else
1055-
_refresh_release_install_if_stale
890+
elif [[ "${SHDEPS_FORCE:-0}" == 1 ]] && _is_release_install_dir "$SHDEPS_DIR"; then
891+
_install_release >/dev/null 2>&1 || true
1056892
fi
1057893
_bs_lib="$SHDEPS_DIR/shdeps.sh"
1058894
_bs_dir="$SHDEPS_DIR"
@@ -1070,8 +906,8 @@ _bootstrap() {
1070906
# this opportunistic: failed downloads, dirty checkouts, or unsupported
1071907
# platforms fall through to the source path so bootstrap still converges.
1072908
_install_release >/dev/null 2>&1 || true
1073-
else
1074-
_refresh_release_install_if_stale
909+
elif [[ "${SHDEPS_FORCE:-0}" == 1 ]] && _is_release_install_dir "$SHDEPS_DIR"; then
910+
_install_release >/dev/null 2>&1 || true
1075911
fi
1076912
_bs_lib="$SHDEPS_DIR/shdeps.sh"
1077913
_bs_dir="$SHDEPS_DIR"
@@ -1086,6 +922,10 @@ _bootstrap() {
1086922
fi
1087923
fi
1088924

925+
if [[ -n "$_bs_dir" ]] && _is_release_install_dir "$_bs_dir"; then
926+
_bs_release_install=1
927+
fi
928+
1089929
# Bootstrap may discover a dev checkout that has not gone through install.sh
1090930
# yet. Create the root binary link first so sourcing the Rust wrapper and
1091931
# linking the CLI both target this checkout instead of an older PATH command.
@@ -1097,8 +937,12 @@ _bootstrap() {
1097937
# shellcheck source=/dev/null
1098938
. "$_bs_lib" || return 1
1099939

1100-
# Pull latest shdeps (skips dirty clones / active development)
1101-
if [[ -n "$_bs_dir" ]]; then
940+
# Pull source checkouts during bootstrap, but keep release installs local.
941+
# Release freshness checks can involve GitHub redirects or API calls; doing
942+
# that before the caller has rendered any UI caused slow/noisy `dot update`
943+
# startup during transient GitHub 504s. `SHDEPS_FORCE=1` still refreshes
944+
# release installs above, which keeps explicit "check now" behavior.
945+
if [[ -n "$_bs_dir" && "$_bs_release_install" -eq 0 ]]; then
1102946
# Bootstrap is sourced into callers, so stdout belongs to the caller's
1103947
# script. Self-update is opportunistic here and ignored on failure; keep it
1104948
# quiet for the same reason so status chatter cannot pollute command

tests/shell/install-sh-test

Lines changed: 0 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -80,103 +80,8 @@ export SHDEPS_INSTALL_SH_NO_DISPATCH
8080
source "$_repo_root/install.sh"
8181
unset SHDEPS_INSTALL_SH_NO_DISPATCH
8282

83-
echo "=== install.sh: _natural_tag_cmp ==="
84-
85-
# Date-prefixed YYYYMMDD-HHMMSS-hash tags: lexical and natural agree.
86-
_assert_eq "natural: same tag is eq" "eq" \
87-
"$(_natural_tag_cmp '20260101-000000-abc' '20260101-000000-abc')"
88-
_assert_eq "natural: newer date prefix is gt" "gt" \
89-
"$(_natural_tag_cmp '20260102-000000-abc' '20260101-000000-abc')"
90-
_assert_eq "natural: older date prefix is lt" "lt" \
91-
"$(_natural_tag_cmp '20260100-000000-abc' '20260101-000000-abc')"
92-
93-
# Multi-digit numeric components: lexical would order `v1.10` BEFORE
94-
# `v1.9` (wrong); natural order gives `v1.10` > `v1.9`. This is the
95-
# parity gap with the Rust `compare_tags` implementation.
96-
_assert_eq "natural: v1.10 is gt v1.9 (lexical would say lt)" "gt" \
97-
"$(_natural_tag_cmp 'v1.10' 'v1.9')"
98-
_assert_eq "natural: v1.9 is lt v1.10" "lt" \
99-
"$(_natural_tag_cmp 'v1.9' 'v1.10')"
100-
_assert_eq "natural: v2.0 is gt v1.99" "gt" \
101-
"$(_natural_tag_cmp 'v2.0' 'v1.99')"
102-
103-
# Leading zeros: 010 and 10 are equal numerically.
104-
_assert_eq "natural: leading-zero numeric equals" "eq" \
105-
"$(_natural_tag_cmp 'v010' 'v10')"
106-
107-
# `v10` parses as Text("v"), Number("10") and `vbeta` parses as
108-
# Text("vbeta"); the first comparison is Text("v") vs Text("vbeta")
109-
# which resolves lexicographically (shorter prefix loses), so
110-
# `v10` < `vbeta`. This is the documented Rust behavior — the
111-
# cross-class "Number > Text" rule only fires when the two tags differ
112-
# AT a Number-vs-Text boundary that is reached on both sides
113-
# simultaneously (i.e., earlier parts compared equal).
114-
_assert_eq "natural: differing first text runs decide before later parts" "lt" \
115-
"$(_natural_tag_cmp 'v10' 'vbeta')"
116-
# Cross-class first runs: digit-first vs letter-first. This is the
117-
# canonical case where the "Number > Text" rule applies because the
118-
# parts compared at position 0 are different classes.
119-
_assert_eq "natural: digit-first beats letter-first (Number > Text)" "gt" \
120-
"$(_natural_tag_cmp '1foo' 'afoo')"
121-
122-
# Tag with extra suffix is greater than the prefix.
123-
_assert_eq "natural: longer-with-suffix is gt prefix" "gt" \
124-
"$(_natural_tag_cmp 'v1.0.1' 'v1.0')"
125-
126-
echo "=== install.sh: _release_tag_newer ==="
127-
128-
_assert_true "newer tag returns success" \
129-
_release_tag_newer "20260101-000000-abc" "20260100-000000-abc"
130-
_assert_false "same tag returns failure" \
131-
_release_tag_newer "20260101-000000-abc" "20260101-000000-abc"
132-
_assert_false "older latest returns failure" \
133-
_release_tag_newer "20260100-000000-abc" "20260101-000000-abc"
134-
_assert_false "empty latest returns failure" \
135-
_release_tag_newer "" "20260101-000000-abc"
136-
_assert_true "newer tag with multi-digit minor returns success" \
137-
_release_tag_newer "v1.10" "v1.9"
138-
139-
echo "=== install.sh: _refresh_release_install_if_stale guards ==="
140-
141-
# When the local install metadata is missing the `tag` field,
142-
# `_refresh_release_install_if_stale` must NOT trigger a re-install on
143-
# every shell startup. The pre-fix behavior fired `_install_release` on
144-
# every call because `_release_tag_newer "$latest" ""` returned true; the
145-
# guard inside the function short-circuits before the network step.
146-
_install_release_called=0
147-
_install_release() {
148-
# Stub: record invocation so the test can assert it never ran.
149-
_install_release_called=$((_install_release_called + 1))
150-
return 0
151-
}
152-
_latest_release_tag() {
153-
# Stub: pretend GitHub published a clearly-newer tag so any guard
154-
# failure would manifest as a recorded `_install_release` call.
155-
printf '20990101-000000-future\n'
156-
}
157-
_uses_default_repo_slug() { return 0; }
158-
_repo_slug() { printf 'owner/repo\n'; }
159-
16083
_fixture_dir=$(mktemp -d)
16184
trap 'rm -rf "$_fixture_dir"' EXIT
162-
SHDEPS_DIR="$_fixture_dir/release-install"
163-
mkdir -p "$SHDEPS_DIR"
164-
touch "$SHDEPS_DIR/shdeps"
165-
# A `.shdeps-install.json` with only `method` (no `tag`) — mirrors the
166-
# corrupted-or-old state where the bug used to loop the download.
167-
printf '{"method": "release"}\n' >"$SHDEPS_DIR/.shdeps-install.json"
168-
169-
_install_release_called=0
170-
_refresh_release_install_if_stale
171-
_assert_eq "missing tag does not fire install" "0" "$_install_release_called"
172-
173-
# Sanity check: with a valid older `tag`, the function DOES fire the
174-
# install. This proves the guard is the only thing preventing the call.
175-
printf '{"method": "release", "tag": "20260101-000000-abc"}\n' \
176-
>"$SHDEPS_DIR/.shdeps-install.json"
177-
_install_release_called=0
178-
_refresh_release_install_if_stale
179-
_assert_eq "stale tag fires install" "1" "$_install_release_called"
18085

18186
echo "=== install.sh: _curl_config_quote ==="
18287

0 commit comments

Comments
 (0)