deploy: release-server never hangs on Bitwarden + narrates its steps (#1233)
A promote sat 14 silent minutes inside `bw get item` today (stale session/network — bw has no timeout of its own and its stderr was discarded), violating the verb's own never-a-hang promise. The signing-key read now runs bw under perl's alarm (30s): a fast empty read (locked vault) still falls through to the interactive unlock; a genuine stall kills bw and prints the fix (bw sync / LCHAT_RELEASE_KEY). The seed stays in the pipe, never on disk. And the slow steps say what they are: key read, tool build (the cold go-build minutes), feed fetch, sign+upload, the stable rolling-binary refresh.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+25
−4
deploy/release-server.sh+25 −4
diff --git a/deploy/release-server.sh b/deploy/release-server.sh| index 58ca4ec..e90fc80 100755 |
| --- a/deploy/release-server.sh |
| +++ b/deploy/release-server.sh |
| @@ -83,16 +83,32 @@ case "${1:-}" in publish|promote) |
| && command -v bw >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then |
| # The item has no password field — the seed rides a hidden custom field, |
| # picked out as the only >40-char value (matches the header's incantation). |
| - bw_key() { bw get item "$BW_ITEM_SIGNING_KEY" 2>/dev/null \ |
| + # bw's API calls carry no timeout of their own and can stall for good on a |
| + # bad connection or stale session (observed 2026-07-21: a promote sat 14 |
| + # silent minutes inside `bw get`), so bw runs under perl's alarm — the |
| + # never-a-hang promise, enforced. The seed stays in the pipe, never on |
| + # disk. bw_stalled distinguishes a killed 30s read from a fast empty one |
| + # (vault locked), which still falls through to the unlock prompt below. |
| + bw_key() { perl -e 'alarm 30; exec @ARGV' bw get item "$BW_ITEM_SIGNING_KEY" 2>/dev/null \ |
| | jq -r '.fields[]? | select((.value|length)>40).value' | head -1; } |
| + bw_stalled() { # $1 = start epoch; empty result + ≥30s elapsed = the watchdog fired |
| + [ $(( $(date +%s) - $1 )) -ge 30 ] || return 1 |
| + echo "release-server: the Bitwarden CLI stalled for 30s reading '$BW_ITEM_SIGNING_KEY' — run 'bw sync' and retry (or export LCHAT_RELEASE_KEY)" >&2 |
| + } |
| + echo "release-server: reading the signing key from Bitwarden…" >&2 |
| + _t0="$(date +%s)" |
| LCHAT_RELEASE_KEY="$(bw_key)" |
| if [ -z "$LCHAT_RELEASE_KEY" ]; then |
| - # Empty usually means the vault is locked (or this shell has no session): |
| - # unlock — prompts for the master password on the tty — then read again. |
| + bw_stalled "$_t0" && exit 1 |
| + # A fast empty read means the vault is locked (or this shell has no |
| + # session): unlock — prompts for the master password on the tty — then |
| + # read again. |
| echo "release-server: unlocking Bitwarden to read the signing key…" >&2 |
| BW_SESSION="$(bw unlock --raw)" && export BW_SESSION || { |
| echo "release-server: Bitwarden unlock failed — unlock it (or set LCHAT_RELEASE_KEY), then retry" >&2; exit 1; } |
| + _t0="$(date +%s)" |
| LCHAT_RELEASE_KEY="$(bw_key)" |
| + [ -n "$LCHAT_RELEASE_KEY" ] || { bw_stalled "$_t0" && exit 1 || true; } |
| fi |
| [ -n "$LCHAT_RELEASE_KEY" ] || { |
| echo "release-server: couldn't read '$BW_ITEM_SIGNING_KEY' from Bitwarden" >&2; exit 1; } |
| @@ -126,7 +142,9 @@ esac |
| # can't drift on the format. Build a local tool from this checkout (only the |
| # feed-touching commands need it). |
| TOOL="$TMP/lchat-tool" |
| -case "${1:-}" in publish|promote) go build -o "$TOOL" . ;; esac |
| +case "${1:-}" in publish|promote) |
| + echo "release-server: building the signing tool (go build — a cold build takes a few minutes)…" >&2 |
| + go build -o "$TOOL" . ;; esac |
| # fetch_payload: current feed payload JSON (verified), or a fresh skeleton. |
| fetch_payload() { |
| @@ -155,6 +173,7 @@ mirror_upload() { |
| # With LCHAT_RELEASE_KEY in the environment, KEY is empty and the tool reads |
| # the seed straight from the env — no --key, no file. |
| sign_and_upload() { |
| + echo "release-server: signing + uploading the feed…" >&2 |
| if [ -n "$KEY" ]; then |
| "$TOOL" release sign --key "$KEY" --in "$1" --out "$TMP/manifest.json" |
| else |
| @@ -283,6 +302,7 @@ promote) |
| case "$CHAN" in edge|beta|stable) ;; *) |
| echo "usage: release-server.sh promote <ver> edge|beta|stable" >&2; exit 1;; esac |
| + echo "promote: fetching the current feed…" >&2 |
| fetch_payload > "$TMP/payload.json" |
| # Prefer the entry already on a channel (usually promoting edge -> beta -> |
| # stable); otherwise rebuild it from the server-<ver> release's own assets |
| @@ -319,6 +339,7 @@ promote) |
| # Stable also refreshes the rolling release's "latest stable" binaries — the |
| # fixed, JSON-free URLs deploy/install.sh downloads. |
| if [ "$CHAN" = stable ]; then |
| + echo "promote: refreshing the rolling latest-stable binaries (8 asset copies)…" >&2 |
| mkdir -p "$TMP/stable" |
| for A in "$BIN" "$BIN.sha256" "$BIN_ARM" "$BIN_ARM.sha256" \ |
| "$BIN_MAC" "$BIN_MAC.sha256" "$BIN_MAC_X64" "$BIN_MAC_X64.sha256"; do |