-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhoist.sh
More file actions
executable file
·2940 lines (2710 loc) · 126 KB
/
Copy pathhoist.sh
File metadata and controls
executable file
·2940 lines (2710 loc) · 126 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
HOIST_VERSION="1.9.2"
HOIST_REPO="KingPin/hoist"
DOCKER_BINARY="${DOCKER_BINARY:-$(which docker)}"
CACHE_LOCATION=/tmp
TAG=""
DRY_RUN=false
PARALLEL=1
PRUNE_IMAGES=true
LOG_FILE=""
GLOBAL_DISCORD_WEBHOOK=""
GLOBAL_SLACK_WEBHOOK=""
GLOBAL_GENERIC_WEBHOOK=""
GLOBAL_TELEGRAM_BOT_TOKEN=""
GLOBAL_TELEGRAM_CHAT_ID=""
GLOBAL_GOTIFY_URL=""
GLOBAL_NTFY_URL=""
GLOBAL_NTFY_TOKEN=""
GLOBAL_TEAMS_WEBHOOK=""
GLOBAL_MATRIX_HOMESERVER=""
GLOBAL_MATRIX_ROOM_ID=""
GLOBAL_MATRIX_TOKEN=""
HEALTHCHECKS_PING_URL=""
WEBHOOK_ROLLUP="false"
WEBHOOK_ROLLUP_CHANNELS="discord,slack,generic"
HEALTHCHECK_TIMEOUT=120
HEALTHCHECK_INTERVAL=2
ROLLBACK_DEFAULT="false"
MAINTENANCE_WINDOW=""
HOIST_HOSTNAME=""
VERBOSE=false
SUMMARY_LIST_NAMES=true
CURL_TIMEOUT="${CURL_TIMEOUT:-30}"
UPDATE_CHECK="${UPDATE_CHECK:-notify}"
# Self-update API check is cached this many seconds so cron-fired runs don't hit
# the GitHub API every time. --force-update-check bypasses it for one run.
UPDATE_CHECK_CACHE_TTL="${UPDATE_CHECK_CACHE_TTL:-21600}" # 6h
DO_SELF_UPDATE=false
FORCE=false
FORCE_UPDATE_CHECK=false
DO_LIST=false
DO_CRON=false
CRON_ACTION=""
CRON_SCHEDULE_FLAG=""
CRON_USER_FLAG=""
CRON_BACKEND_FLAG=""
CRON_SCOPE_FLAG=""
CRON_DOCKER_HOST_FLAG=""
ONLY_LIST=""
EXCLUDE_LIST=""
log() {
local msg="[$(date +%T)] $*"
echo "$msg"
[[ -n $LOG_FILE ]] && echo "$msg" >> "$LOG_FILE"
}
# _is_true <value> -> 0 if the value is an affirmative boolean, else 1.
# Case-insensitive; accepts true/1/yes/on. Used for user-supplied label and
# config gates so "True", "YES", "1" behave the same as "true".
_is_true() {
case "${1,,}" in
true|1|yes|on) return 0 ;;
*) return 1 ;;
esac
}
# _normalize_tag <tag> -> echoes the tag with a guaranteed leading dot (or
# empty for empty input). Labels are read as com.sumguy.hoist<TAG>.<key>.
_normalize_tag() {
local t="$1"
[[ -n $t && $t != .* ]] && t=".$t"
printf '%s' "$t"
}
_load_config() {
local cfg=""
if [[ -n $HOIST_CONFIG && -f $HOIST_CONFIG ]]; then cfg="$HOIST_CONFIG"
elif [[ -f "$(dirname "$0")/hoist.conf" ]]; then cfg="$(dirname "$0")/hoist.conf"
elif [[ -f /etc/hoist/hoist.conf ]]; then cfg=/etc/hoist/hoist.conf
fi
[[ -n $cfg ]] && { log "Loading config: $cfg"; source "$cfg"; }
}
# All startup side effects (config load, arg parsing, environment validation,
# trap install) live in _init so the script can be sourced by tests without
# executing anything. _init is invoked from the entry-point guard at the bottom.
_init() {
_load_config
while [[ "$1" != "" ]]; do
case "$1" in
--dry-run) DRY_RUN=true; VERBOSE=true ;;
--verbose) VERBOSE=true ;;
--tag=*) TAG="${1#*=}"
[[ -n $TAG ]] || { echo "Error: --tag requires a value" >&2; exit 2; } ;;
--tag) [[ -n ${2:-} && $2 != --* ]] || { echo "Error: --tag requires a value" >&2; exit 2; }
TAG="$2"; shift ;;
--parallel=*) PARALLEL="${1#*=}"
[[ $PARALLEL =~ ^[0-9]+$ ]] || { echo "Error: --parallel requires a non-negative integer" >&2; exit 2; } ;;
--parallel) [[ -n ${2:-} && $2 =~ ^[0-9]+$ ]] || { echo "Error: --parallel requires a non-negative integer" >&2; exit 2; }
PARALLEL="$2"; shift ;;
--update) DO_SELF_UPDATE=true ;;
--version) echo "hoist v${HOIST_VERSION}"; exit 0 ;;
--force) FORCE=true ;;
--force-update-check) FORCE_UPDATE_CHECK=true ;;
--list|--status) DO_LIST=true ;;
--only=*) ONLY_LIST="${1#*=}"
[[ -n $ONLY_LIST ]] || { echo "Error: --only requires a value" >&2; exit 2; } ;;
--only) [[ -n ${2:-} && $2 != --* ]] || { echo "Error: --only requires a value" >&2; exit 2; }
ONLY_LIST="$2"; shift ;;
--exclude=*) EXCLUDE_LIST="${1#*=}"
[[ -n $EXCLUDE_LIST ]] || { echo "Error: --exclude requires a value" >&2; exit 2; } ;;
--exclude) [[ -n ${2:-} && $2 != --* ]] || { echo "Error: --exclude requires a value" >&2; exit 2; }
EXCLUDE_LIST="$2"; shift ;;
--cron=*)
echo "Error: --cron does not take a value with '='; use: hoist --cron <action>" >&2
exit 2 ;;
--cron)
DO_CRON=true
if [[ -n "${2:-}" && "$2" != --* ]]; then
CRON_ACTION="$2"
shift
fi ;;
--schedule=*) CRON_SCHEDULE_FLAG="${1#*=}"
[[ -n $CRON_SCHEDULE_FLAG ]] || { echo "Error: --schedule requires a value" >&2; exit 2; } ;;
--schedule) [[ -n ${2:-} && $2 != --* ]] || { echo "Error: --schedule requires a value" >&2; exit 2; }
CRON_SCHEDULE_FLAG="$2"; shift ;;
--user=*) CRON_USER_FLAG="${1#*=}"
[[ -n $CRON_USER_FLAG ]] || { echo "Error: --user requires a value" >&2; exit 2; } ;;
--user) [[ -n ${2:-} && $2 != --* ]] || { echo "Error: --user requires a value" >&2; exit 2; }
CRON_USER_FLAG="$2"; shift ;;
--backend=*) CRON_BACKEND_FLAG="${1#*=}"
[[ -n $CRON_BACKEND_FLAG ]] || { echo "Error: --backend requires a value" >&2; exit 2; } ;;
--backend) [[ -n ${2:-} && $2 != --* ]] || { echo "Error: --backend requires a value" >&2; exit 2; }
CRON_BACKEND_FLAG="$2"; shift ;;
--scope=*) CRON_SCOPE_FLAG="${1#*=}"
[[ -n $CRON_SCOPE_FLAG ]] || { echo "Error: --scope requires a value" >&2; exit 2; } ;;
--scope) [[ -n ${2:-} && $2 != --* ]] || { echo "Error: --scope requires a value" >&2; exit 2; }
CRON_SCOPE_FLAG="$2"; shift ;;
--docker-host=*) CRON_DOCKER_HOST_FLAG="${1#*=}"
[[ -n $CRON_DOCKER_HOST_FLAG ]] || { echo "Error: --docker-host requires a value" >&2; exit 2; } ;;
--docker-host) [[ -n ${2:-} && $2 != --* ]] || { echo "Error: --docker-host requires a value" >&2; exit 2; }
CRON_DOCKER_HOST_FLAG="$2"; shift ;;
-h|--help|-\?)
cat <<EOF
hoist v${HOIST_VERSION} — auto-update or notify on Docker containers via labels
Usage: hoist [options]
Options:
--tag <value> Use a label subset (e.g. --tag nightly reads
com.sumguy.hoist.nightly.* labels)
--dry-run Show what would be pulled/updated without making
changes or sending notifications (implies --verbose)
--verbose Log skipped containers (no hoist labels)
--parallel <N> Process containers concurrently with N workers
--list, --status Print a table of running containers and their label
config, then exit (no pulls or updates)
--only <names> Comma-separated container names to include (others skipped)
--exclude <names> Comma-separated container names to exclude
--update Self-update hoist to the latest GitHub release
--force With --update, reinstall even if already up to date
--force-update-check Ignore the cached self-update check and query the
GitHub API now (the check is otherwise cached for 6h)
--version Print version and exit
-h, --help, -? Show this help and exit
Scheduling:
--cron [action] Manage hoist's scheduled run. Bare --cron opens an
interactive menu. Actions:
install Install a cron entry or systemd timer
remove Remove the hoist-managed schedule
print Print the file(s) hoist would install
status Show what's currently installed
--schedule <expr> Schedule for --cron install/print. Presets:
30min, hourly, 6hourly, daily, weekly
or a raw cron / OnCalendar expression.
--scope <scope> Install scope for --cron install: user | system.
user: systemd --user units, no sudo needed.
system: /etc/cron.d or /etc/systemd/system/ (default for root).
--user <name> User to run hoist as (--cron install, system scope). Default: root.
--backend <name> Force backend: cron | systemd. Default: auto-detect.
cron backend is only available with --scope system.
--docker-host <uri> Pin DOCKER_HOST in the generated user unit
(--cron install, --scope user only). Overrides
auto-detect. Use this for rootless docker on a
non-default socket, or in non-interactive automation
where DOCKER_HOST isn't set in the calling shell.
Config file (sourced before CLI flag parsing):
\$HOIST_CONFIG, ./hoist.conf, or /etc/hoist/hoist.conf
Repo: https://github.com/${HOIST_REPO}
EOF
exit 0 ;;
*) echo "Error: unknown option: $1" >&2
echo "Run 'hoist --help' for usage." >&2
exit 2 ;;
esac
shift
done
# Normalize TAG: labels are read as com.sumguy.hoist<TAG>.<key>, so a non-empty
# TAG must carry its leading dot. Accepts both `--tag nightly` (CLI) and
# `TAG=nightly` (config / Ansible).
TAG="$(_normalize_tag "$TAG")"
# Bash 4.3+ check runs after arg parse so --version/--help still work on macOS
# system bash 3.2 — users need a way to diagnose what they have installed.
# 4.3 is required for `wait -n` used by the parallel worker pool.
if [[ -z ${BASH_VERSINFO+x} ]] || (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then
echo "Error: hoist requires bash 4.3+ (current: ${BASH_VERSION:-unknown})." >&2
echo " macOS: brew install bash, then ensure the Homebrew bash is first in PATH" >&2
echo " (e.g. /opt/homebrew/bin or /usr/local/bin), or invoke hoist with that bash explicitly." >&2
echo " Verify with: bash --version" >&2
exit 1
fi
# On INT/TERM tear down the workers we spawned and their docker/curl children,
# but never the whole process group — `kill 0` would also signal shell siblings
# such as the cron parent. pkill -P reaches each worker's descendants (best
# effort; absent on minimal hosts). Exit with 128+signum so the caller observes
# the real signal (130 for INT, 143 for TERM) instead of a flat code.
_on_signal() {
local signum="$1" _jobs _j
trap - INT TERM # disarm so a second signal can't re-enter the handler
_jobs=$(jobs -p)
if [[ -n "$_jobs" ]]; then
for _j in $_jobs; do
command -v pkill >/dev/null 2>&1 && pkill -TERM -P "$_j" 2>/dev/null
kill "$_j" 2>/dev/null
done
fi
exit $((128 + signum))
}
trap '_on_signal 2' INT
trap '_on_signal 15' TERM
if [[ $DO_CRON != true ]]; then
[[ -x "$DOCKER_BINARY" ]] || { echo "Error: docker binary not found: ${DOCKER_BINARY:-docker}"; exit 1; }
# jq is mandatory for label inspection on every non-cron run; fail early with
# an actionable message rather than mid-run with a cryptic parse error.
command -v jq >/dev/null 2>&1 || { echo "Error: jq is required but was not found in PATH. Install jq and retry." >&2; exit 1; }
fi
# Tolerate whitespace in the rollup channel list (e.g. "discord, slack"): both
# the membership test and the dispatch loop match on exact comma-delimited
# tokens, so strip spaces/tabs once here.
WEBHOOK_ROLLUP_CHANNELS="${WEBHOOK_ROLLUP_CHANNELS//[[:space:]]/}"
for _wh_var in GLOBAL_DISCORD_WEBHOOK GLOBAL_SLACK_WEBHOOK GLOBAL_GENERIC_WEBHOOK \
GLOBAL_GOTIFY_URL GLOBAL_NTFY_URL GLOBAL_TEAMS_WEBHOOK \
GLOBAL_MATRIX_HOMESERVER HEALTHCHECKS_PING_URL; do
_wh_val="${!_wh_var}"
if [[ -n "$_wh_val" ]]; then
[[ "$_wh_val" =~ ^https?:// ]] || { echo "Error: $_wh_var is not a valid http(s) URL: $_wh_val"; exit 1; }
fi
done
_validate_maintenance_window "$MAINTENANCE_WINDOW" || exit 2
# Ensure CACHE_LOCATION exists, and lock it to 0700 when it's a directory we
# create. The bare default (/tmp) is shared across every user on the host, and
# our state-file names are fixed (hoist.lock, hoist-self-update-check.cache, …).
# So whoever runs hoist first — often root — ends up owning those files, and a
# later run as a different user hits "Permission denied" opening them (the run
# lock then aborts the whole run). Namespace the shared default into a per-user
# private dir so runs by different users on the same host never collide. A
# custom CACHE_LOCATION is taken as-is (the caller owns partitioning it).
if [[ "$CACHE_LOCATION" == "/tmp" ]]; then
CACHE_LOCATION="/tmp/hoist-${EUID}"
mkdir -p "$CACHE_LOCATION" 2>/dev/null \
|| { echo "Error: cannot create CACHE_LOCATION: $CACHE_LOCATION" >&2; exit 1; }
# /tmp is world-writable, so another user could pre-create this dir to read
# our state. If it exists but isn't ours, refuse rather than write into it.
[[ -O "$CACHE_LOCATION" ]] \
|| { echo "Error: CACHE_LOCATION $CACHE_LOCATION exists but is not owned by the current user" >&2; exit 1; }
chmod 0700 "$CACHE_LOCATION" 2>/dev/null || true
else
mkdir -p "$CACHE_LOCATION" 2>/dev/null \
|| { echo "Error: cannot create CACHE_LOCATION: $CACHE_LOCATION" >&2; exit 1; }
[[ -O "$CACHE_LOCATION" ]] && chmod 0700 "$CACHE_LOCATION" 2>/dev/null || true
fi
log "TAG=${TAG} | DRY_RUN=${DRY_RUN} | PARALLEL=${PARALLEL} | VERBOSE=${VERBOSE}"
}
setup_environment() {
# Exported so external child processes (user script.update / script.notify hooks,
# docker, curl) see them. Bash functions are inherited automatically by `&`-spawned
# subshells in the same process, so no `export -f` is needed for the parallel pool.
export DOCKER_BINARY CACHE_LOCATION TAG DRY_RUN VERBOSE CURL_TIMEOUT
export PRUNE_IMAGES LOG_FILE MAINTENANCE_WINDOW
export GLOBAL_DISCORD_WEBHOOK GLOBAL_SLACK_WEBHOOK GLOBAL_GENERIC_WEBHOOK
export GLOBAL_TELEGRAM_BOT_TOKEN GLOBAL_TELEGRAM_CHAT_ID
export GLOBAL_GOTIFY_URL GLOBAL_NTFY_URL GLOBAL_NTFY_TOKEN GLOBAL_TEAMS_WEBHOOK
export GLOBAL_MATRIX_HOMESERVER GLOBAL_MATRIX_ROOM_ID GLOBAL_MATRIX_TOKEN
export HEALTHCHECKS_PING_URL WEBHOOK_ROLLUP WEBHOOK_ROLLUP_CHANNELS
export HEALTHCHECK_TIMEOUT HEALTHCHECK_INTERVAL ROLLBACK_DEFAULT
}
# State files live in a predictably-named CACHE_LOCATION. The default is now a
# per-user private dir (/tmp/hoist-$EUID, 0700), but a custom CACHE_LOCATION may
# still sit on a shared/sticky path — and an attacker who can guess our filename
# could pre-plant a symlink so our write lands on a victim file. On a sticky dir
# we usually can't delete another user's symlink, so the safe move is to refuse
# to write through it rather than follow it. Returns 1 (caller skips the write)
# when the target is a symlink.
_state_path_safe() {
if [[ -L "$1" ]]; then
log "Refusing to write state file through a symlink: $1"
return 1
fi
return 0
}
# _validate_maintenance_window <window> -> 0 if empty or well-formed,
# otherwise echoes an error and returns 1. Format is HH:MM-HH:MM (24h).
_validate_maintenance_window() {
local w="$1"
[[ -z "$w" ]] && return 0
[[ "$w" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]$ ]] && return 0
echo "Error: MAINTENANCE_WINDOW must be HH:MM-HH:MM (24h), got: $w" >&2
return 1
}
# _in_maintenance_window <window "HH:MM-HH:MM"> <now "HHMM"> -> 0 if now falls
# inside the window, 1 if outside. Pure (no clock reads). Start is inclusive,
# end exclusive; a start greater than end means the window spans midnight.
# Forces base-10 so zero-padded times (e.g. 0900) never hit octal arithmetic.
_in_maintenance_window() {
local window="$1" now="$2" start end
start=${window%-*}; start=${start//:/}
end=${window#*-}; end=${end//:/}
start=$((10#$start)); end=$((10#$end)); now=$((10#$now))
if (( start <= end )); then
(( now >= start && now < end ))
else
(( now >= start || now < end )) # spans midnight
fi
}
check_maintenance_window() {
[[ -z "$MAINTENANCE_WINDOW" ]] && return 0
if [[ "$DRY_RUN" == true ]]; then
log "Maintenance window check bypassed (dry-run)"
return 0
fi
if _in_maintenance_window "$MAINTENANCE_WINDOW" "$(date +%H%M)"; then
log "Within maintenance window ($MAINTENANCE_WINDOW), proceeding."
else
log "Outside maintenance window ($MAINTENANCE_WINDOW), skipping."
# Ping success so Healthchecks.io sees an intentional no-op run rather than
# a missed check (the early exit is before the normal end-of-run ping).
_hc_ping
exit 0
fi
}
# Serialize docker-compose operations per project directory. In --parallel mode
# two sibling containers that share a compose project would otherwise run
# `compose pull` / `compose up` against the same project set concurrently, which
# races on the project's container list (and on the rollback re-tag). An
# exclusive per-project lock, keyed on a hash of the absolute workdir, makes each
# pull/up atomic with respect to its siblings. Different projects still run fully
# in parallel. Prefers flock; falls back to an mkdir spinlock when flock is
# absent. The lock blocks (siblings queue) rather than failing.
_with_project_lock() {
local workdir="$1"; shift
local key lock rc
key=$(cksum <<< "$workdir"); key=${key%% *}
lock="${CACHE_LOCATION}/hoist-project-${key}.lock"
# Refuse a planted symlink: the exec-redirect below (and the mkdir fallback)
# would otherwise truncate/write through the link target on a shared /tmp.
_state_path_safe "$lock" || return 1
if command -v flock >/dev/null 2>&1; then
local fd
exec {fd}>"$lock" || { log "Error: cannot open project lock $lock"; return 1; }
flock "$fd"
# The flock is held against the open file description, not this process,
# and bash sets no close-on-exec on {var}> fds — so the docker/compose
# child run below would otherwise inherit $fd and keep the lock held even
# after this worker dies. A Ctrl+C mid-`docker compose pull` then orphans
# that pull (reparented to init) holding the inherited fd, wedging every
# later run that touches this project on `flock` forever. Close $fd for
# the command only ({fd}>&-): this worker keeps its own copy open so the
# lock stays held for the pull/up, but the docker child cannot pin it —
# an orphaned pull no longer survives its worker. (Same fd-inheritance
# hazard the run lock handles via its childless holder process.)
"$@" {fd}>&-; rc=$?
exec {fd}>&-
return "$rc"
fi
local lockdir="${lock}.d" holder
while ! mkdir "$lockdir" 2>/dev/null; do
holder=""
[[ -f "$lockdir/pid" ]] && holder=$(cat "$lockdir/pid" 2>/dev/null)
if [[ -n $holder ]] && ! kill -0 "$holder" 2>/dev/null; then
rm -rf "$lockdir"; continue
fi
sleep 0.2
done
echo "$$" > "$lockdir/pid"
"$@"; rc=$?
rm -rf "$lockdir"
return "$rc"
}
# The cd runs inside a subshell so the working-directory change never leaks
# into the caller (which processes many containers across different workdirs).
_compose_exec() {
local workdir="$1" service="$2"; shift 2
( cd "$workdir" || { log "Error: cannot cd to compose workdir: $workdir"; exit 1; }
"${DOCKER_BINARY}" compose "$@" "$service" )
}
compose_pull_wrapper() {
[[ "$1" == /* ]] || { log "Error: compose workdir is not an absolute path: $1"; return 1; }
# Under --parallel, sibling containers' compose-pull progress bars interleave
# into unreadable garbage. Suppress Docker's progress UI with --quiet there;
# hoist's own per-container "Pulling image..." log lines still show activity.
# Serial runs keep the verbose progress output.
if (( PARALLEL > 1 )); then
_with_project_lock "$1" _compose_exec "$1" "$2" pull --quiet
else
_with_project_lock "$1" _compose_exec "$1" "$2" pull
fi
}
compose_up_wrapper() {
[[ "$1" == /* ]] || { log "Error: compose workdir is not an absolute path: $1"; return 1; }
_with_project_lock "$1" _compose_exec "$1" "$2" up -d --always-recreate-deps
}
# Take an exclusive run lock so two hoist runs never process the same fleet
# concurrently (double pulls, racing compose up, clobbered run-result state).
# Prefers flock (kernel lock, auto-released when the fd closes on exit); falls
# back to an atomic mkdir lock with stale-PID detection when flock is absent.
# A held lock is not an error — hoist exits 0 so overlapping cron ticks are
# harmless.
_acquire_run_lock() {
local lock="${CACHE_LOCATION}/hoist.lock"
# A symlink at our lock path is hostile/broken, not a held lock: the
# exec-redirect below would truncate the link target on a shared /tmp.
# Refuse to run (exit 1) — distinct from the clean exit 0 when the lock is
# legitimately held by another run.
_state_path_safe "$lock" || exit 1
if command -v flock >/dev/null 2>&1; then
exec {_LOCK_FD}>"$lock" || { log "Error: cannot open run lock $lock"; exit 1; }
if ! flock -n "$_LOCK_FD"; then
log "Another hoist run holds the lock ($lock); exiting."
exit 0
fi
# flock is held against the open file description, not this process, and
# bash does NOT set close-on-exec on {var}> fds — so every docker/compose
# child we exec and every &-spawned worker inherits _LOCK_FD and keeps the
# lock held until its own copy closes. A Ctrl+C mid-`docker compose pull`
# can orphan a docker subprocess that keeps the inherited fd open, wedging
# every later run with "Another hoist run holds the lock". Hand the held fd
# to one childless holder process, then close our copy so nothing we exec
# or fork afterwards can pin the lock. `disown` keeps the holder out of the
# job table (so the parallel pool's `wait`/`wait -n` ignore it); the EXIT
# trap — which also runs after _on_signal's `exit` on INT/TERM — kills the
# holder, releasing the lock the instant hoist truly exits.
( exec sleep 2147483647 ) &
_LOCK_HOLDER=$!
disown
exec {_LOCK_FD}>&-
trap '[[ -n "${_LOCK_HOLDER:-}" ]] && kill "$_LOCK_HOLDER" 2>/dev/null' EXIT
return 0
fi
# flock unavailable: atomic mkdir lock. mkdir fails if the dir exists, which
# is our mutex; a recorded PID that is no longer alive means a crashed run.
local lockdir="${lock}.d"
while ! mkdir "$lockdir" 2>/dev/null; do
local holder=""
[[ -f "$lockdir/pid" ]] && holder=$(cat "$lockdir/pid" 2>/dev/null)
if [[ -n $holder ]] && ! kill -0 "$holder" 2>/dev/null; then
log "Removing stale run lock (pid $holder no longer running)"
rm -rf "$lockdir"
continue
fi
log "Another hoist run holds the lock ($lockdir, pid ${holder:-unknown}); exiting."
exit 0
done
echo "$$" > "$lockdir/pid"
_RUN_LOCKDIR="$lockdir"
# EXIT also fires after the INT/TERM handler runs `exit 130`, so this one
# trap covers normal and signalled termination. Background workers run in
# subshells that reset traps, so they won't remove the lock.
trap '[[ -n "${_RUN_LOCKDIR:-}" ]] && rm -rf "$_RUN_LOCKDIR"' EXIT
}
validate_webhook_url() {
[[ "$1" =~ ^https?:// ]] || { log "Error: invalid webhook URL (must start with http:// or https://): $1"; return 1; }
}
validate_script_path() {
local path="$1"
[[ -z "$path" ]] && return 0
[[ "$path" == /* ]] || { log "SECURITY: script path must be absolute: $path"; return 1; }
[[ -x "$path" ]] || { log "SECURITY: script path is not executable: $path"; return 1; }
}
# _semver_compare <a> <b> -> echoes -1 / 0 / 1 for a<b, a==b, a>b.
# Numeric base-10 comparison (no octal traps on zero-padded fields) and
# pre-release aware: with equal numeric triples a release outranks a
# pre-release (1.0.0 > 1.0.0-rc.1), per semver §11.
_semver_compare() {
local a_full="$1" b_full="$2"
local IFS=.
local -a a=(${a_full%%-*}) b=(${b_full%%-*})
local i av bv
for i in 0 1 2; do
av=${a[i]:-0}; bv=${b[i]:-0}
av=${av%%[!0-9]*}; bv=${bv%%[!0-9]*} # drop any trailing non-digits
av=$((10#${av:-0})); bv=$((10#${bv:-0}))
((av > bv)) && { echo 1; return; }
((av < bv)) && { echo -1; return; }
done
local pa="" pb=""
[[ $a_full == *-* ]] && pa=${a_full#*-}
[[ $b_full == *-* ]] && pb=${b_full#*-}
if [[ -z $pa && -n $pb ]]; then echo 1; return; fi # release > pre-release
if [[ -n $pa && -z $pb ]]; then echo -1; return; fi # pre-release < release
echo 0
}
# 0 (true) if a > b
_semver_gt() { [[ $(_semver_compare "$1" "$2") == 1 ]]; }
# 0 (true) if a == b (numeric triple + pre-release rank)
_semver_eq() { [[ $(_semver_compare "$1" "$2") == 0 ]]; }
# _semver_satisfies <constraint> <version> -> 0 if version satisfies constraint
# Supports: ^X.Y.Z (same major), ~X.Y.Z (same major+minor), >=X, <=X, >X, <X,
# =X, X (exact), comma-separated conjunctions (every part must hold), and an
# optional leading v/V on both the constraint and the candidate version.
_semver_satisfies() {
local constraint="$1" version="$2"
[[ -z $version ]] && return 0 # no version label = can't enforce, fail-open
version="${version#[vV]}"
# Comma-separated parts form a conjunction: every part must be satisfied
# (e.g. ">=0.1.2,<0.2" pins the 0.1.x line).
if [[ $constraint == *,* ]]; then
local part
local IFS=,
for part in $constraint; do
part="${part#"${part%%[![:space:]]*}"}" # ltrim
part="${part%"${part##*[![:space:]]}"}" # rtrim
[[ -z $part ]] && continue
_semver_satisfies "$part" "$version" || return 1
done
return 0
fi
local op base
# Patterns are single-quoted so bash does not tilde-expand '~' (which would
# turn the arm into $HOME and silently never match).
case "$constraint" in
'^'*) op="^"; base="${constraint#\^}" ;;
'~'*) op="~"; base="${constraint#\~}" ;;
">="*) op=">="; base="${constraint#>=}" ;;
"<="*) op="<="; base="${constraint#<=}" ;;
">"*) op=">"; base="${constraint#>}" ;;
"<"*) op="<"; base="${constraint#<}" ;;
"="*) op="="; base="${constraint#=}" ;;
*) op="="; base="$constraint" ;;
esac
base="${base#[vV]}"
local IFS=.
local -a c=(${base%%-*}) v=(${version%%-*})
# Arms quoted so '~' (and '^') are matched literally, not tilde-expanded.
case "$op" in
'^') [[ ${v[0]:-0} == "${c[0]:-0}" ]] || return 1
_semver_gt "$version" "$base" || _semver_eq "$version" "$base" ;;
'~') [[ ${v[0]:-0} == "${c[0]:-0}" && ${v[1]:-0} == "${c[1]:-0}" ]] || return 1
_semver_gt "$version" "$base" || _semver_eq "$version" "$base" ;;
">=") _semver_gt "$version" "$base" || _semver_eq "$version" "$base" ;;
"<=") ! _semver_gt "$version" "$base" ;;
">") _semver_gt "$version" "$base" ;;
"<") _semver_gt "$base" "$version" ;;
"=") _semver_eq "$version" "$base" ;;
esac
}
# _parse_to_epoch <iso_str> -> echoes seconds-since-epoch, or returns 1.
# Portable across GNU date (-d), Homebrew gdate, and BSD date (-j -f) for
# common ISO formats.
_parse_to_epoch() {
local input="$1" out
if out=$(date -d "$input" +%s 2>/dev/null); then echo "$out"; return 0; fi
if command -v gdate >/dev/null 2>&1 && out=$(gdate -d "$input" +%s 2>/dev/null); then
echo "$out"; return 0
fi
local fmt
for fmt in '%Y-%m-%dT%H:%M:%SZ' '%Y-%m-%dT%H:%M:%S' '%Y-%m-%d %H:%M:%S' '%Y-%m-%d'; do
if out=$(date -j -f "$fmt" "$input" +%s 2>/dev/null); then
echo "$out"; return 0
fi
done
return 1
}
# _check_pause_until <iso_str> -> 0 if past the pause time (proceed), 1 if still paused
_check_pause_until() {
local target now
target=$(_parse_to_epoch "$1") || {
log "Warning: pause_until value '$1' is not parseable; ignoring"
return 0
}
now=$(date +%s)
(( now >= target ))
}
# _wait_for_healthy <container> <timeout_seconds>
# 0 = container reached healthy / running
# 1 = unhealthy, exited, or timed out
_wait_for_healthy() {
local container="$1" timeout="$2"
local interval="${HEALTHCHECK_INTERVAL:-2}"
if ! [[ $timeout =~ ^[1-9][0-9]*$ ]]; then
log "$container: healthcheck timeout '$timeout' is not a positive integer; using default 120"
timeout=120
fi
if ! [[ $interval =~ ^[1-9][0-9]*$ ]]; then
log "Warning: HEALTHCHECK_INTERVAL '$interval' is not a positive integer; using default 2"
interval=2
fi
# Detect whether the image defines a HEALTHCHECK at all. Without one,
# this function can only wait for `running`/`exited` — not real health.
local has_health
has_health=$("${DOCKER_BINARY}" inspect --format \
'{{if .State.Health}}yes{{else}}no{{end}}' "$container" 2>/dev/null) || return 1
if [[ $has_health != yes ]]; then
log "$container: healthcheck.wait set but image defines no HEALTHCHECK — falling back to State.Status (running/exited only)"
fi
local deadline=$(( $(date +%s) + timeout ))
local status
while (( $(date +%s) < deadline )); do
status=$("${DOCKER_BINARY}" inspect --format \
'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \
"$container" 2>/dev/null) || return 1
case "$status" in
healthy|running) return 0 ;;
unhealthy|exited|dead) return 1 ;;
starting|created|restarting|paused) ;;
*) ;;
esac
sleep "$interval"
done
return 1
}
# _rollback_container <container> <workdir> <service> <image_name> <old_image_sha>
# Re-aliases the previous image SHA back onto the original tag and re-runs compose up.
# Requires the old image to still be present locally (PRUNE_IMAGES may have removed it).
_rollback_container() {
local container="$1" workdir="$2" service="$3" image_name="$4" old_sha="$5"
if [[ -z "$old_sha" ]]; then
log "$container: rollback skipped — old image SHA unknown"
return 1
fi
if ! "${DOCKER_BINARY}" image inspect "$old_sha" >/dev/null 2>&1; then
log "$container: rollback failed — old image $old_sha no longer present locally (was it pruned?)"
return 1
fi
if [[ ! -d $workdir ]]; then
log "$container: rollback failed — compose workdir '$workdir' is missing"
return 1
fi
log "$container: rolling back to $old_sha"
# The re-tag + compose-up is one critical section: it assumes the tag it sets
# is the image compose reads back. Run it under the per-project lock so a
# sibling worker pulling/recreating the same project can't re-tag the image
# in between (the rollback re-tag race the project lock subsumes).
_with_project_lock "$workdir" _rollback_retag_up \
"$container" "$workdir" "$service" "$image_name" "$old_sha"
}
# Critical section of a rollback: re-alias the old SHA onto the tag, re-run
# compose up without pulling, and undo the re-tag if the up fails. Always run
# via _with_project_lock so it is atomic against sibling compose operations.
_rollback_retag_up() {
local container="$1" workdir="$2" service="$3" image_name="$4" old_sha="$5"
# Remember what the tag currently points at so a failed rollback can be
# restored rather than leaving the tag aliased to an image that was never
# brought up.
local prev_target
prev_target=$("${DOCKER_BINARY}" image inspect --format '{{.Id}}' "$image_name" 2>/dev/null)
if ! "${DOCKER_BINARY}" tag "$old_sha" "$image_name"; then
log "$container: rollback failed — could not re-tag $old_sha as $image_name"
return 1
fi
# Re-run compose up without pulling — picks up the now-aliased image.
# '--pull never' is the supported flag ('--no-pull' does not exist and made
# this path silently fall back to pulling). stderr is captured into the log.
local up_err
if ! up_err=$( cd "$workdir" && "${DOCKER_BINARY}" compose up -d --pull never "$service" 2>&1 ); then
log "$container: rollback failed — compose up did not succeed: ${up_err}"
# Undo the re-tag so the tag still reflects what is actually running.
if [[ -n $prev_target ]]; then
"${DOCKER_BINARY}" tag "$prev_target" "$image_name" >/dev/null 2>&1 \
|| log "$container: warning — could not restore tag $image_name to $prev_target"
fi
return 1
fi
log "$container: rollback complete"
return 0
}
_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
echo "Error: neither sha256sum nor shasum available" >&2
return 1
fi
}
_iso_ts() {
# %3N is GNU-only; BSD date drops or echoes the literal token, varying by libc.
# Validate the result against a strict ISO-8601 millisecond pattern; otherwise
# downgrade to second precision so webhook payloads stay parseable.
local ts
ts=$(date -u +'%FT%T.%3NZ' 2>/dev/null)
if [[ $ts =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$ ]]; then
printf '%s' "$ts"
else
date -u +'%FT%TZ'
fi
}
_hoist_hostname() {
if [[ -n $HOIST_HOSTNAME ]]; then
printf '%s' "$HOIST_HOSTNAME"
elif command -v hostname >/dev/null 2>&1; then
hostname -s 2>/dev/null || hostname
elif [[ -n ${HOSTNAME:-} ]]; then
printf '%s' "$HOSTNAME"
elif [[ -r /etc/hostname ]]; then
tr -d '\n' < /etc/hostname
else
printf 'unknown'
fi
}
_self_update_notify() {
local new_ver="$1" release_url="$2"
local payload
if [[ -n $GLOBAL_DISCORD_WEBHOOK ]]; then
payload=$(jq -n \
--arg title "Hoist update available: v${new_ver}" \
--arg url "$release_url" \
--arg cur "$HOIST_VERSION" \
--arg footer "Powered by Hoist • $(_hoist_hostname)" \
'{"embeds":[{"title":$title,
"description":("Current: v" + $cur + "\nRun `hoist --update` to upgrade"),
"url":$url,"color":16776960,
"footer":{"text":$footer}}],
"username":"Hoist"}') || {
[[ $VERBOSE == true ]] && log "Warning: failed to build Discord payload for self-update notify"
}
[[ -n $payload ]] && _send_webhook "$GLOBAL_DISCORD_WEBHOOK" "$payload" \
-L -H "Content-Type: application/json" 2>/dev/null || \
{ [[ $VERBOSE == true ]] && log "Warning: Discord self-update notification failed"; }
fi
if [[ -n $GLOBAL_SLACK_WEBHOOK ]]; then
payload=$(jq -n \
--arg t "Hoist v${new_ver} available (current: v${HOIST_VERSION}). Run --update to upgrade. ${release_url}" \
'{"text":$t}') || {
[[ $VERBOSE == true ]] && log "Warning: failed to build Slack payload for self-update notify"
}
[[ -n $payload ]] && _send_webhook "$GLOBAL_SLACK_WEBHOOK" "$payload" \
-L -H "Content-Type: application/json" 2>/dev/null || \
{ [[ $VERBOSE == true ]] && log "Warning: Slack self-update notification failed"; }
fi
if [[ -n $GLOBAL_GENERIC_WEBHOOK ]]; then
payload=$(jq -n \
--arg type "self_update_available" \
--arg cur "$HOIST_VERSION" \
--arg new "$new_ver" \
--arg url "$release_url" \
--arg ts "$(_iso_ts)" \
'{"type":$type,"current_version":$cur,"new_version":$new,"release_url":$url,"timestamp":$ts}') || {
[[ $VERBOSE == true ]] && log "Warning: failed to build generic payload for self-update notify"
}
[[ -n $payload ]] && _send_webhook "$GLOBAL_GENERIC_WEBHOOK" "$payload" \
-L -H "Content-Type: application/json" 2>/dev/null || \
{ [[ $VERBOSE == true ]] && log "Warning: generic self-update notification failed"; }
fi
}
_self_update_apply() {
local new_ver="$1" asset_url="$2" sha256_url="$3" silent="${4:-false}"
local script_path
script_path=$(realpath "$0" 2>/dev/null || readlink -f "$0" 2>/dev/null)
[[ -z $script_path ]] && script_path=$(cd "$(dirname "$0")" && echo "$(pwd)/$(basename "$0")")
[[ -f $script_path ]] || {
log "Error: cannot resolve script path for self-update: $script_path"
return 1
}
[[ -w $script_path ]] || { log "Error: $script_path is not writable — cannot self-update"; return 1; }
local tmp_script tmp_sha256 script_dir
script_dir=$(dirname "$script_path")
# Create the replacement in the script's own directory so the final mv is a
# same-filesystem atomic rename, not a cross-fs copy into the live inode
# (which a concurrent reader could observe half-written).
tmp_script=$(mktemp "${script_dir}/.hoist-update-XXXXXX") || {
log "Error: cannot create temp file in ${script_dir} for update download"
return 1
}
tmp_sha256=$(mktemp /tmp/hoist-update-XXXXXX.sha256) || {
log "Error: cannot create temp file for SHA256"
rm -f "$tmp_script"
return 1
}
# mktemp already creates these 0600; restate it explicitly so a downloaded
# replacement script is never group/world-readable before the atomic mv,
# regardless of platform mktemp quirks.
chmod 0600 "$tmp_script" "$tmp_sha256" 2>/dev/null || true
_suu_cleanup() { rm -f "$tmp_script" "$tmp_sha256"; }
[[ $silent == false ]] && log "Downloading hoist.sh v${new_ver}..."
curl -fsSL --proto '=https' --max-time 60 --connect-timeout 10 \
-H "User-Agent: Hoist/${HOIST_VERSION}" \
-o "$tmp_script" "$asset_url" || {
log "Error: failed to download hoist v${new_ver} from ${asset_url}"
_suu_cleanup; return 1
}
[[ $silent == false ]] && log "Downloading SHA256 checksum..."
curl -fsSL --proto '=https' --max-time 10 --connect-timeout 5 \
-H "User-Agent: Hoist/${HOIST_VERSION}" \
-o "$tmp_sha256" "$sha256_url" || {
log "Error: failed to download SHA256 for hoist v${new_ver} from ${sha256_url}"
_suu_cleanup; return 1
}
local expected_hash actual_hash
expected_hash=$(awk '{print $1}' "$tmp_sha256")
[[ $expected_hash =~ ^[0-9a-f]{64}$ ]] || {
log "Error: malformed SHA256 file (got: '${expected_hash}')"
_suu_cleanup; return 1
}
actual_hash=$(_sha256 "$tmp_script") || {
log "Error: cannot compute SHA256 (no sha256sum or shasum available)"
_suu_cleanup; return 1
}
if [[ $expected_hash != "$actual_hash" ]]; then
log "Error: SHA256 mismatch — aborting update (expected: ${expected_hash}, got: ${actual_hash})"
_suu_cleanup; return 1
fi
# Preserve original file mode and ownership so the replaced script remains
# readable/executable for non-root users (mktemp creates 0600, which would
# otherwise lock everyone but the updater out).
local orig_mode orig_owner
orig_mode=$(stat -c '%a' "$script_path" 2>/dev/null)
[[ -z $orig_mode ]] && orig_mode=$(stat -f '%Lp' "$script_path" 2>/dev/null)
[[ -z $orig_mode ]] && orig_mode=755
orig_owner=$(stat -c '%u:%g' "$script_path" 2>/dev/null)
[[ -z $orig_owner ]] && orig_owner=$(stat -f '%u:%g' "$script_path" 2>/dev/null)
chmod "$orig_mode" "$tmp_script" || chmod +x "$tmp_script"
[[ -n $orig_owner ]] && chown "$orig_owner" "$tmp_script" 2>/dev/null
mv "$tmp_script" "$script_path" || { log "Error: mv failed — update aborted"; _suu_cleanup; return 1; }
log "Updated to v${new_ver}. Restart hoist to use the new version."
rm -f "$tmp_sha256" "${CACHE_LOCATION}/hoist-self-v${new_ver}.notified"
}
_self_update_check() {
local interactive="${1:-false}"
local force="${2:-false}"
local timeout_arg=5
[[ $interactive == true ]] && timeout_arg=15
local _check_cache="${CACHE_LOCATION}/hoist-self-update-check.cache"
# Automated runs honour a cached check so a cron/timer-fired hoist doesn't
# hit the GitHub API on every invocation. --force-update-check bypasses it
# for one run; interactive --update always queries live.
if [[ $interactive == false && $FORCE_UPDATE_CHECK != true && -r $_check_cache ]]; then
local _last_check _now
_last_check=$(awk 'NR==1{print $1}' "$_check_cache" 2>/dev/null)
_now=$(date +%s)
if [[ $_last_check =~ ^[0-9]+$ ]] && (( _now - _last_check < UPDATE_CHECK_CACHE_TTL )); then
[[ $VERBOSE == true ]] && log "Self-update check skipped (checked $(( (_now - _last_check) / 60 ))m ago; cache TTL $(( UPDATE_CHECK_CACHE_TTL / 60 ))m)"
return 0
fi
fi
local api_response _api_raw _http_code
_api_raw=$(curl -sSL --proto '=https' -w '\n%{http_code}' \
--max-time "$timeout_arg" --connect-timeout 5 \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-H "User-Agent: Hoist/${HOIST_VERSION}" \
"https://api.github.com/repos/${HOIST_REPO}/releases/latest" 2>/dev/null)
_http_code="${_api_raw##*$'\n'}"
api_response="${_api_raw%$'\n'*}"
if [[ $_http_code == 000 || -z $_http_code ]]; then
if [[ $interactive == true ]]; then log "Error: could not reach GitHub API (network failure or timeout)"; exit 1; fi
[[ $VERBOSE == true ]] && log "Self-update check failed (network), skipping"
return 0
fi
if [[ $_http_code == 404 ]]; then
if [[ $interactive == true ]]; then
log "Error: no releases published yet for ${HOIST_REPO} — see https://github.com/${HOIST_REPO}/releases"
exit 1
fi
[[ $VERBOSE == true ]] && log "Self-update: no releases published for ${HOIST_REPO}, skipping"
return 0
fi
if [[ $_http_code != 200 ]]; then
if [[ $interactive == true ]]; then log "Error: GitHub API returned HTTP ${_http_code}"; exit 1; fi
[[ $VERBOSE == true ]] && log "Self-update: GitHub API returned HTTP ${_http_code}, skipping"
return 0
fi
local latest_tag latest_version release_url asset_url sha256_url release_body
latest_tag=$(jq -r '.tag_name // empty' <<< "$api_response")
[[ -z $latest_tag ]] && {
if [[ $interactive == true ]]; then log "Error: malformed API response"; exit 1; fi
return 0
}
latest_version="${latest_tag#v}"
# Record this successful check so the next automated run can skip the API.
# A live interactive check also refreshes the window.
if _state_path_safe "$_check_cache"; then
if ! ( umask 177 && printf '%s %s\n' "$(date +%s)" "$latest_version" > "$_check_cache" ); then
[[ $VERBOSE == true ]] && log "Warning: could not write update-check cache ${_check_cache}"
fi
fi
release_url=$(jq -r '.html_url // empty' <<< "$api_response")
[[ -z $release_url ]] && release_url="https://github.com/${HOIST_REPO}/releases/tag/v${latest_version}"
asset_url=$(jq -r '.assets[] | select(.name == "hoist.sh") | .browser_download_url' <<< "$api_response")
sha256_url=$(jq -r '.assets[] | select(.name == "hoist.sh.sha256") | .browser_download_url' <<< "$api_response")
release_body=$(jq -r '.body // empty' <<< "$api_response")
if [[ -z $asset_url || -z $sha256_url ]]; then
if [[ $interactive == true ]]; then
log "Error: release v${latest_version} is missing hoist.sh or hoist.sh.sha256 assets"
exit 1
fi
[[ $VERBOSE == true ]] && log "Update v${latest_version} skipped — release assets not found"
return 0
fi
if ! _semver_gt "$latest_version" "$HOIST_VERSION"; then
if [[ $interactive == true && $force == true ]]; then
log "Already at v${HOIST_VERSION} — reinstalling latest (forced)"
elif [[ $interactive == true ]]; then
log "Already up to date (v${HOIST_VERSION}) — use --force to reinstall"; exit 0
else
[[ $VERBOSE == true ]] && log "hoist is up to date (v${HOIST_VERSION})"
return 0
fi
fi
local sentinel="${CACHE_LOCATION}/hoist-self-v${latest_version}.notified"
# Sentinel suppresses repeat notifications on automated runs; --update (interactive) always proceeds
if [[ $interactive == false && -f $sentinel ]]; then
[[ $VERBOSE == true ]] && log "Update notification already sent for v${latest_version}"
return 0
fi
log "hoist v${latest_version} available (current: v${HOIST_VERSION}) — run with --update to upgrade"
log " Release: ${release_url}"
if [[ $interactive == false ]]; then
_self_update_notify "$latest_version" "$release_url"
if _state_path_safe "$sentinel"; then
( umask 177 && printf '%s' "$latest_version" > "$sentinel" ) || \
log "Warning: could not write update sentinel ${sentinel} — notifications may repeat"
fi
if [[ $UPDATE_CHECK == "update" ]]; then
log "UPDATE_CHECK=update — applying automatically"
_self_update_apply "$latest_version" "$asset_url" "$sha256_url" true || \
log "Auto-update to v${latest_version} failed — continuing container management"
fi
return 0
fi
if [[ -n $release_body ]]; then
printf '\n%s\n\n' "$(sed 's/^/ /' <<< "$release_body" | head -30)"
fi