forked from Start9Labs/start-technologies
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmanage-release.sh
More file actions
executable file
·1772 lines (1645 loc) · 76.2 KB
/
Copy pathmanage-release.sh
File metadata and controls
executable file
·1772 lines (1645 loc) · 76.2 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
#
# manage-release.sh — drive a monorepo product through its release steps.
#
# Usage: ./scripts/manage-release.sh <subcommand> <project>
#
# See usage() for the subcommands. The <project> is one of the monorepo's
# releasable products; its version is read from that product's canonical
# manifest (Cargo.toml for the Rust products, package.json for the SDK) and its
# git tag / GitHub release is <project>/v<version> (the slash namespaces each
# product's tags; releases before July 2026 used <project>_v<version>).
# mapfile, inherit_errexit, and safe empty-array expansion under `set -u` all
# need bash >= 4.4; macOS's /bin/bash is 3.2, so fail fast before half-running.
if [ -z "${BASH_VERSINFO:-}" ] || [ "${BASH_VERSINFO[0]}" -lt 4 ] \
|| { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -lt 4 ]; }; then
>&2 echo "manage-release.sh requires bash >= 4.4 (macOS: brew install bash)"
exit 1
fi
set -euo pipefail
# Without this, a failure inside $(release_body) is silently swallowed and the
# release is created with broken notes.
shopt -s inherit_errexit
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO="Start9Labs/start-technologies"
# Registries are scoped per project (<PROJECT>_SOURCE/TARGET_REGISTRY); the OS
# and StartWRT promote source -> target. The OS chain: CI indexes images into
# alpha; alpha -> beta is promoted manually, out of band; the full `release`
# promotes the source (beta) -> target (production). Override either per run.
STARTOS_SOURCE_REGISTRY="${STARTOS_SOURCE_REGISTRY:-https://beta-registry.start9.com}"
STARTOS_TARGET_REGISTRY="${STARTOS_TARGET_REGISTRY:-https://registry.start9.com}"
S3_BUCKET="s3://startos-images"
S3_CDN="https://startos-images.nyc3.cdn.digitaloceanspaces.com"
START9_GPG_KEY="2D63C217"
SDK_NPM_PACKAGE="@start9labs/start-sdk"
# The changelog link sits inside the notes' Highlights section rather than at a
# fixed position, so place_changelog_link finds it by this prefix.
CHANGELOG_LINK_PREFIX="**[Full changelog"
# The S3 origin, deliberately NOT the `*.cdn.*` host that apt/start9*.list point
# clients at. Do not "harmonize" the two. A promotion has to see the suite as it
# is right now: a cached InRelease is still a validly signed InRelease, so every
# signature and hash check below would pass while quietly promoting whatever
# build the edge happened to be holding. Signatures prove authenticity, not
# freshness. End users keep the CDN — apt is built to tolerate a stale mirror.
APT_BASE_URL="${APT_BASE_URL:-https://start9-debs.nyc3.digitaloceanspaces.com}"
# Belt and braces for any intermediary between here and the origin.
APT_NO_CACHE=(-H 'Cache-Control: no-cache' -H 'Pragma: no-cache')
APT_SUITE="stable"
# CI publishes every master build into `alpha` (.github/workflows/apt-publish-alpha.yml).
# A deb release promotes from there rather than rebuilding trust from a CI run,
# so the bytes testers have been running are the bytes that reach stable — the
# same source -> target promotion the OS and StartWRT releases use.
APT_ALPHA_SUITE="alpha"
APT_COMPONENT="main"
# StartWRT publishes flashable images to its own registry pair + S3 bucket. The
# chain mirrors the OS, minus the alpha tier: the CI deploy job (start-wrt.yaml)
# uploads a build's images to S3 AND registers + indexes them into the source
# (beta) registry, where beta routers (UCI `startwrt.system.registry` pointed at
# it) soak the version; the full `release` promotes source -> target
# (production). `register` is the manual fallback for CI's register/index steps
# (same commands; keep them in sync — see root AGENTS.md "Coupled changes"). It
# ships two gzipped images: an sdcard image (fresh install -> the registry `img`
# slot) and a sysupgrade image (OTA update -> the `squashfs` slot; see
# cmd_register for the hardlink trick that maps the .img.gz names onto those
# slots). Override either registry per run.
STARTWRT_SOURCE_REGISTRY="${STARTWRT_SOURCE_REGISTRY:-https://startwrt-beta-registry.start9.com}"
STARTWRT_TARGET_REGISTRY="${STARTWRT_TARGET_REGISTRY:-https://startwrt-registry.start9.com}"
STARTWRT_S3_BUCKET="s3://startwrt-images"
STARTWRT_S3_CDN="https://startwrt-images.nyc3.cdn.digitaloceanspaces.com"
STARTWRT_PLATFORM="spacemit,k1-x"
# The CI artifact (start-wrt.yaml `image` job) holding both images.
STARTWRT_BUILD_ARTIFACT="startwrt-openwrt-image"
# Compat floor for `registry os version add`: the oldest installed version
# allowed to upgrade to this one. An explicit beta floor (not a `^` caret) keeps
# beta prerelease tags in range. The upper bound (<=$VERSION) is added in
# cmd_register. Mirrored in start-wrt.yaml's register step.
STARTWRT_COMPAT_FLOOR="${STARTWRT_COMPAT_FLOOR:->=0.1.0-beta.1}"
# Every OS image platform. Most ship an iso + squashfs; raspberrypi ships a
# flashable img + squashfs (no iso). See os_image_exts.
OS_PLATFORMS="x86_64 x86_64-nonfree x86_64-nvidia aarch64 aarch64-nonfree aarch64-nvidia raspberrypi riscv64 riscv64-nonfree"
CLI_TRIPLES="x86_64-unknown-linux-musl x86_64-apple-darwin aarch64-unknown-linux-musl aarch64-apple-darwin riscv64gc-unknown-linux-musl"
DEB_ARCHES="x86_64 aarch64 riscv64"
PROJECTS="start-os start-cli start-tunnel start-registry start-sdk start-wrt"
# --- Project metadata ---
project_kind() {
case "$1" in
start-os) echo os ;;
start-cli) echo cli ;;
start-tunnel | start-registry) echo deb ;;
start-sdk) echo npm ;;
start-wrt) echo wrt ;;
*) return 1 ;;
esac
}
derive_version() {
local project=$1 version
if [ "$(project_kind "$project")" = npm ]; then
jq -r .version "$REPO_ROOT/projects/$project/package.json"
return
fi
# StartOS versions carry a revision segment (0.4.0.1) that SemVer, and so Cargo, cannot
# express; root package.json holds it and projects/start-os/Cargo.toml carries only a
# `-rev.N` label (kept honest by cmd_pre_check). Mirrors build/env/version.sh.
if [ "$(project_kind "$project")" = os ]; then
jq -r .version "$REPO_ROOT/package.json"
return
fi
# start-wrt has no top-level crate; its canonical version lives in the ctrl
# crate manifest (mirrors the top CHANGELOG.md entry and start-wrt.yaml's
# "Determine version" step).
local toml="$REPO_ROOT/projects/$project/Cargo.toml"
if [ "$project" = start-wrt ]; then
toml="$REPO_ROOT/projects/start-wrt/backend/ctrl/Cargo.toml"
fi
version=$(grep -m1 'VERSION_BUMP' "$toml" 2>/dev/null | sed -E 's/.*version *= *"([^"]+)".*/\1/' || true)
if [ -z "$version" ]; then
version=$(sed -nE '/^\[package\]/,/^\[/{s/^version *= *"([^"]+)".*/\1/p}' "$toml" | head -1)
fi
echo "$version"
}
changelog_path() { echo "$REPO_ROOT/projects/$1/CHANGELOG.md"; }
notes_path() { echo "$REPO_ROOT/projects/$1/release-notes/${VERSION}.md"; }
pre_update_notes_path() {
local notes
notes=$(notes_path "$1")
echo "${notes%.md}.pre-update.md"
}
# CHANGELOG_REF is what the link resolves against — the tag for a release, and
# the built commit for a CI registration, whose tag does not exist yet.
changelog_link() {
echo "${CHANGELOG_LINK_PREFIX} for v${VERSION}](https://github.com/${REPO}/blob/${CHANGELOG_REF}/projects/${PROJECT}/CHANGELOG.md)** — every change in this release."
}
curated_notes() {
local notes pre_update
notes=$(notes_path "$PROJECT")
if [ ! -f "$notes" ]; then
>&2 echo "No release notes at ${notes#"$REPO_ROOT/"} — write them before releasing ${PROJECT} v${VERSION}."
return 1
fi
pre_update=$(pre_update_notes_path "$PROJECT")
{
if [ -s "$pre_update" ]; then
cat "$pre_update"
printf '\n\n'
fi
cat "$notes"
} | place_changelog_link
}
# Put the changelog link at the end of stdin's Highlights section, dropping any
# copy already there. Notes with no Highlights section take it at the end.
place_changelog_link() {
awk -v link="$(changelog_link)" -v prefix="$CHANGELOG_LINK_PREFIX" '
index($0, prefix) == 1 { dropped = 1; next }
dropped { dropped = 0; if ($0 == "") next }
/^## / {
if (in_highlights) { print link; print ""; placed = 1; in_highlights = 0 }
if (tolower($0) ~ /^## highlights/) in_highlights = 1
}
{ print }
END { if (!placed) { if (NR) print ""; print link } }
'
}
project_display_name() {
case "$1" in
start-os) echo "StartOS" ;;
start-wrt) echo "StartWRT" ;;
start-tunnel) echo "StartTunnel" ;;
start-sdk) echo "Start SDK" ;;
*) echo "$1" ;;
esac
}
cli_asset_name() {
case "$1" in
x86_64-unknown-linux-musl) echo x86_64-linux ;;
aarch64-unknown-linux-musl) echo aarch64-linux ;;
riscv64gc-unknown-linux-musl) echo riscv64-linux ;;
x86_64-apple-darwin) echo x86_64-macos ;;
aarch64-apple-darwin) echo aarch64-macos ;;
*) return 1 ;;
esac
}
deb_arch() {
case "$1" in
x86_64) echo amd64 ;;
aarch64) echo arm64 ;;
riscv64) echo riscv64 ;;
*) return 1 ;;
esac
}
# One row of the release notes' download table, in the order they are offered:
# hardware | image | platform. A reader knows what they own, not which platform
# tuple it is, so the hardware column leads and names the Start9 product where
# there is one. generated_sections fails on a platform with no row here, so a new
# image variant cannot ship undescribed.
OS_DOWNLOAD_ROWS=(
"**Server One**, and most other Intel and AMD desktops, laptops, and mini PCs|x86_64 (AMD64), standard|x86_64-nonfree"
"**Server Pure**, and other hardware that runs without proprietary firmware|x86_64 (AMD64), slim|x86_64"
"An Intel or AMD server with an NVIDIA GPU|x86_64 (AMD64), NVIDIA|x86_64-nvidia"
"ARM64 servers and single-board computers|aarch64 (ARM64), standard|aarch64-nonfree"
"ARM64 hardware that runs without proprietary firmware|aarch64 (ARM64), slim|aarch64"
"**NVIDIA DGX Spark**, and other ARM64 servers with an NVIDIA GPU|aarch64 (ARM64), NVIDIA|aarch64-nvidia"
"**Raspberry Pi 4** — flashed to a microSD card, not a USB drive|Raspberry Pi|raspberrypi"
"RISC-V servers and boards|RISC-V (RVA23), standard|riscv64-nonfree"
"RISC-V hardware that runs without proprietary firmware|RISC-V (RVA23), slim|riscv64"
)
# One row of the StartWRT download table: hardware | image | asset slot |
# tooltip. The K1 is the only platform it builds for, so the rows differ by slot
# rather than by hardware — img is the fresh-install sdcard image, squashfs the
# sysupgrade payload. Mirrors OS_DOWNLOAD_ROWS.
WRT_DOWNLOAD_ROWS=(
"**Start9 router**, and other BananaPi BPI-F3 boards (SpaceMiT K1, RISC-V)|microSD card — fresh install or reflash|img|gzip-compressed — balenaEtcher flashes it without unpacking"
"A router already running StartWRT|Sysupgrade — the payload an in-app update fetches for itself|squashfs|"
)
# The image extensions a platform ships: squashfs everywhere, plus iso (most) or
# a flashable img (raspberrypi).
os_image_exts() {
case "$1" in
raspberrypi) echo "squashfs img" ;;
*) echo "squashfs iso" ;;
esac
}
# --- Helpers ---
# Load a registry's OS index into $_INDEX_JSON, refetching only when asked for a
# different registry than the one held — a release reads a dozen asset URLs off
# it and each fetch is a round trip. Call it once at the top of a scope that
# loops over assets: asset_url runs inside $(...), so a fetch it does itself is
# discarded with that subshell, while one the caller did is inherited.
_INDEX_REGISTRY=""
_INDEX_JSON=""
load_registry_index() {
if [ "$1" != "$_INDEX_REGISTRY" ]; then
_INDEX_JSON=$(start-cli --registry="$1" registry os index 2>/dev/null || echo '{}')
_INDEX_REGISTRY=$1
fi
}
# The published URL of an indexed asset: <registry> <slot> <platform>. Empty if
# that registry carries no such asset for $VERSION.
#
# This — never a URL rebuilt from the bucket layout plus a local filename — is
# what a download link must point at. The published basename carries the build's
# commit hash (startos-0.4.0-514af0c_x86_64.iso), which nothing local
# reproduces, so a reconstructed URL 403s.
asset_url() {
load_registry_index "$1"
jq -r --arg v "$VERSION" --arg s "$2" --arg p "$3" \
'.versions[$v][$s][$p].urls[0] // empty' <<< "$_INDEX_JSON"
}
# CI registers a build before its notes are written, so set them on the source
# registry from the working tree before promoting. The compat range is whatever
# that registration used; `version add` upserts the entry.
refresh_registry_notes() {
local registry=$1 range
load_registry_index "$registry"
range=$(jq -r --arg v "$VERSION" '.versions[$v].sourceVersion // empty' <<< "$_INDEX_JSON")
if [ -z "$range" ]; then
>&2 echo " ✗ ${registry} has no ${VERSION} entry to carry release notes"
return 1
fi
echo "Setting ${VERSION} release notes on ${registry}..."
start-cli --registry="$registry" registry os version add \
"$VERSION" "v${VERSION}" "$(curated_notes)" "$range"
_INDEX_REGISTRY=""
}
# The signed blake3 commitment of an indexed asset, as hex (b3sum's output
# format): <registry> <slot> <platform>. Empty if the asset is not indexed.
asset_commitment_b3() {
load_registry_index "$1"
local b64
b64=$(jq -r --arg v "$VERSION" --arg s "$2" --arg p "$3" \
'.versions[$v][$s][$p].commitment.hash // empty' <<< "$_INDEX_JSON")
[ -n "$b64" ] || return 0
# The index stores the hash unpadded-base64; base64 -d wants the padding.
case $((${#b64} % 4)) in
2) b64="${b64}==" ;;
3) b64="${b64}=" ;;
esac
printf '%s' "$b64" | base64 -d | od -An -v -tx1 | tr -d ' \n'
}
parse_run_id() {
local val="$1"
if [[ "$val" =~ /actions/runs/([0-9]+) ]]; then
echo "${BASH_REMATCH[1]}"
else
echo "$val"
fi
}
# The commit the tag will point at ($COMMIT, default HEAD), as a full sha.
tag_commit_sha() { (cd "$REPO_ROOT" && git rev-parse --verify "${COMMIT:-HEAD}^{commit}"); }
# Local staging dir: keeps the flat _v separator (the tag's / would nest dirs).
release_dir() { echo "$HOME/Downloads/${PROJECT}_v${VERSION}"; }
# Marker recording which commit the release dir's GHA artifacts were built from.
gha_commit_file() { echo "$(release_dir)/.gha-commit"; }
ensure_release_dir() {
local dir
dir=$(release_dir)
if [ "${CLEAN:-}" = "1" ]; then
rm -rf "$dir"
fi
mkdir -p "$dir"
cd "$dir"
}
enter_release_dir() {
local dir
dir=$(release_dir)
if [ ! -d "$dir" ]; then
>&2 echo "Release directory $dir does not exist. Run 'pull-gha' or 'pull' first."
exit 1
fi
cd "$dir"
}
# List the CLI binaries in the (current) release dir, one per line.
cli_binaries() {
local f
for f in start-cli_*; do
case "$f" in *.asc | *.deb) continue ;; esac
[ -f "$f" ] && echo "$f"
done
}
# List the .deb packages in the (current) release dir, one per line.
deb_files() {
local f
for f in *.deb; do [ -f "$f" ] && echo "$f"; done
}
# Compress every raw .img in the release dir beside itself.
#
# The registry keeps indexing the image bare: an installer streams it and the
# signed commitment is over the raw bytes. But a raw card image is mostly
# zeroes, and the release notes hand it to a human on a browser — so the notes
# link the .gz, and it is hashed and signed alongside the bare image.
#
# Idempotent: skips a .gz already newer than its source, and stages through a
# temp file so an interrupted run can't leave a truncated one looking current.
# A no-op outside the os kind — nothing else ships a raw .img.
ensure_img_gz() {
local img
for img in *.img; do
[ -f "$img" ] || continue
if [ -f "${img}.gz" ] && [ ! "$img" -nt "${img}.gz" ]; then
continue
fi
>&2 echo " compressing ${img} -> ${img}.gz"
gzip -c "$img" > "${img}.gz.tmp"
mv -f "${img}.gz.tmp" "${img}.gz"
done
}
# List every file the project ships (for signing / checksums), one per line.
release_files() {
local f
case "$KIND" in
os) for f in *.iso *.img *.img.gz *.squashfs; do [ -f "$f" ] && echo "$f"; done ;;
cli) cli_binaries; deb_files ;;
deb) deb_files ;;
wrt) for f in *-sdcard.img.gz *-sysupgrade.img.gz; do [ -f "$f" ] && echo "$f"; done ;;
esac
}
resolve_gh_user() {
GH_USER=${GH_USER:-$(gh api user -q .login 2>/dev/null || true)}
if [ "${GH_USER,,}" = start9 ]; then
>&2 echo "Error: GitHub user '$GH_USER' is reserved for Start9 signatures"
exit 1
fi
GH_GPG_KEY=$(git -C "$REPO_ROOT" config user.signingkey 2>/dev/null || true)
case "$(git -C "$REPO_ROOT" config gpg.format 2>/dev/null)" in
'' | openpgp) ;;
*) GH_GPG_KEY= ;;
esac
[ -z "$GH_GPG_KEY" ] || gpg --list-secret-keys "$GH_GPG_KEY" >/dev/null 2>&1 || GH_GPG_KEY=
}
require_kind() {
local ok
for ok in "$@"; do
[ "$KIND" = "$ok" ] && return 0
done
>&2 echo "Subcommand '$SUBCOMMAND' does not apply to $PROJECT (kind: $KIND)."
exit 2
}
# --- Deb helpers (shared by the deb and cli kinds) ---
# Download this project's per-arch debs from a GitHub Actions run into the cwd.
# Download this project's debs for the commit being tagged from the alpha suite.
#
# Selection is by the Git-Hash control field (written by debian/build.sh), which
# dpkg-scanpackages carries into the Packages index — so the build is identified
# and verified before a byte is downloaded. Version alone would not do: every
# master build of a version publishes under the same Version, and the apt pool
# holds exactly one deb per package/arch, so `alpha` always carries the newest
# master build of that version and nothing older.
#
# A mismatch therefore means master moved past the commit being tagged. That is
# a real stop, not a nuisance: releasing then would ship bytes no one soaked.
# Cut the release from an up-to-date master, or fall back to `pull-gha` for a
# repair.
# Verify the alpha suite's signed Release and return a verified Packages file
# for one architecture. Alpha is signed with the CI key precisely so its
# consumers can verify it, and a releaser promoting into stable is one: reading
# the index over plain HTTPS would let anyone able to write the bucket (without
# holding the signing key) get bytes stable-signed by the releaser.
# macOS ships shasum, not coreutils' sha256sum — same as checksum_block, which
# has carried this fallback all along. Prints the bare hash.
sha256_of() {
if command -v sha256sum > /dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
else
shasum -a 256 "$1" | cut -d' ' -f1
fi
}
verify_alpha_release() {
local keyring="$1" tmp="$2"
curl -fsSL "${APT_NO_CACHE[@]}" "${APT_BASE_URL}/dists/${APT_ALPHA_SUITE}/InRelease" -o "$tmp/InRelease"
if ! gpg --no-default-keyring --keyring "$keyring" --batch --yes \
--output "$tmp/Release" --decrypt "$tmp/InRelease" 2> "$tmp/gpg.err"; then
>&2 echo " ✗ the ${APT_ALPHA_SUITE} InRelease is not signed by the key in ${keyring}"
>&2 sed 's/^/ /' "$tmp/gpg.err"
return 1
fi
# No freshness bound is enforced here, deliberately. A signature proves
# authenticity and not recency, so replaying older signed metadata is
# possible for anyone who can write the bucket — but the defence against
# promoting the wrong build is that the operator is told which commit is
# being tagged (resolve_alpha_commit prints it, and a replay shows up as an
# unexpectedly old hash), not metadata that expires on a timer unrelated to
# this project's release cadence.
}
verify_alpha_packages() {
local tmp="$1" darch="$2" rel_path idx_sha got
rel_path="${APT_COMPONENT}/binary-${darch}/Packages"
idx_sha=$(awk -v p="$rel_path" '
/^SHA256:/ { in_sha = 1; next }
/^[^ ]/ { in_sha = 0 }
in_sha && $3 == p { print $1; exit }
' "$tmp/Release")
if [ -z "$idx_sha" ]; then
>&2 echo " ✗ the signed Release does not list ${rel_path}"
return 1
fi
curl -fsSL "${APT_NO_CACHE[@]}" "${APT_BASE_URL}/dists/${APT_ALPHA_SUITE}/${rel_path}" -o "$tmp/Packages.${darch}"
got=$(sha256_of "$tmp/Packages.${darch}")
if [ "$got" != "$idx_sha" ]; then
>&2 echo " ✗ ${rel_path} does not match the hash the signed Release commits to"
return 1
fi
}
# Print "<git-hash>\t<sha256>\t<filename>" for this project's deb in a verified
# Packages file. Emitted at the stanza boundary rather than on a particular
# field: the index guarantees no order, and Git-Hash follows Filename in practice.
alpha_stanza() {
local entry
entry=$(awk -v pkg="$PROJECT" -v ver="$VERSION" '
function flush() {
if (p == pkg && index(v, ver) > 0 && f != "") print h "\t" s "\t" f
p = ""; v = ""; h = ""; s = ""; f = ""
}
/^$/ { flush(); next }
/^Package:/ { p = $2 }
/^Version:/ { v = $2 }
/^Git-Hash:/ { h = $2 }
/^SHA256:/ { s = $2 }
/^Filename:/ { f = $2 }
END { flush() }
' "$1")
# First line taken in the shell rather than piped through `head -1`, which
# would close the pipe early and take SIGPIPE under `pipefail`.
echo "${entry%%$'\n'*}"
}
# Verify the alpha suite and collect this project's debs for every architecture.
# Populates ALPHA_COMMIT / ALPHA_SHAS / ALPHA_FILES and leaves the verified
# metadata in ALPHA_TMP, which the caller removes.
#
# One function so every consumer gets the same guarantees: `alpha-commit` used to
# read a single arch and accept a missing Git-Hash, so it could print a commit
# that `pull-alpha` then refused.
alpha_collect() {
local keyring arch darch stanza h
keyring="$REPO_ROOT/apt/start9-alpha.gpg"
if [ ! -f "$keyring" ]; then
>&2 echo "Cannot verify the ${APT_ALPHA_SUITE} suite: ${keyring} is missing."
return 1
fi
ALPHA_TMP=$(mktemp -d)
declare -gA ALPHA_SHAS=() ALPHA_FILES=()
ALPHA_COMMIT=""
verify_alpha_release "$keyring" "$ALPHA_TMP" || return 1
for arch in $DEB_ARCHES; do
darch=$(deb_arch "$arch")
verify_alpha_packages "$ALPHA_TMP" "$darch" || return 1
stanza=$(alpha_stanza "$ALPHA_TMP/Packages.${darch}")
if [ -z "$stanza" ]; then
>&2 echo " ✗ ${darch}: no ${PROJECT} ${VERSION} deb in the ${APT_ALPHA_SUITE} suite"
return 1
fi
h=${stanza%%$'\t'*}
stanza=${stanza#*$'\t'}
ALPHA_SHAS[$arch]=${stanza%%$'\t'*}
ALPHA_FILES[$arch]=${stanza#*$'\t'}
if [ -z "$h" ]; then
>&2 echo " ✗ ${arch}: this deb predates the Git-Hash control field — use 'pull-gha'"
return 1
fi
# Every arch must come from one build: a half-published suite would
# otherwise ship a release assembled from two different commits.
if [ -z "$ALPHA_COMMIT" ]; then
ALPHA_COMMIT=$h
elif [ "$h" != "$ALPHA_COMMIT" ]; then
>&2 echo " ✗ ${APT_ALPHA_SUITE} holds different commits per architecture (${ALPHA_COMMIT} vs ${h})."
>&2 echo " Wait for the build to finish publishing every arch, or use 'pull-gha'."
return 1
fi
done
}
# Download the collected debs, each verified against the hash the signed index
# commits to, and stage them only once every one has passed.
# Decide which commit the tag points at. The tag is a claim that a commit
# produced the artifact, so when we are promoting, alpha's build is what decides
# it — this adopts that commit rather than making the operator notice a mismatch
# and re-run by hand.
#
# Each product's workflow is path-filtered, so master routinely advances without
# producing a new build of that product: insisting on HEAD would demand a build
# that will never exist. Adoption is confined to a commit already in this
# branch's history, and an explicit COMMIT is never second-guessed.
resolve_alpha_commit() {
local alpha_hash="$1" tag_sha behind
tag_sha=$(tag_commit_sha)
[ "$alpha_hash" != "$tag_sha" ] || return 0
if [ -n "${COMMIT:-}" ]; then
>&2 echo " ✗ ${APT_ALPHA_SUITE} holds a build of ${alpha_hash}, but COMMIT=${COMMIT} is ${tag_sha}."
>&2 echo " The tag has to point at the commit that produced the artifacts. Drop COMMIT"
>&2 echo " to take alpha's, or use 'pull-gha' with the run that built ${tag_sha}."
return 1
fi
if ! (cd "$REPO_ROOT" && git rev-parse --verify --quiet "${alpha_hash}^{commit}" > /dev/null); then
>&2 echo " ✗ ${APT_ALPHA_SUITE} holds a build of ${alpha_hash}, which is not in this repository."
>&2 echo " git fetch origin, then re-run."
return 1
fi
if ! (cd "$REPO_ROOT" && git merge-base --is-ancestor "$alpha_hash" HEAD); then
>&2 echo " ✗ ${APT_ALPHA_SUITE} holds a build of ${alpha_hash}, which is not an ancestor of HEAD."
>&2 echo " That build did not come from the branch you are releasing. Check out the"
>&2 echo " branch that contains it, or use 'pull-gha'."
return 1
fi
behind=$(cd "$REPO_ROOT" && git rev-list --count "${alpha_hash}..HEAD")
COMMIT="$alpha_hash"
echo " Tagging ${alpha_hash}, the commit alpha built — HEAD is ${behind} commit(s) further on."
echo " (${PROJECT}'s workflow is path-filtered, so master advances without rebuilding it.)"
echo " To work from that tree: git checkout ${alpha_hash}"
}
# cmd_pre_check validates, and the release body is composed from, the *working
# tree* — but an adopted commit can be behind it. Where the notes and changelog
# are identical the distinction is immaterial, so the common case stays
# frictionless; where it is not, the release would publish and link files the
# tag does not point at, so stop and ask for the checkout.
assert_metadata_matches_adopted() {
local adopted head file
adopted=$(tag_commit_sha)
head=$(cd "$REPO_ROOT" && git rev-parse --verify HEAD)
[ "$adopted" != "$head" ] || return 0
for file in "$(changelog_path "$PROJECT")" "$(notes_path "$PROJECT")" "$(pre_update_notes_path "$PROJECT")"; do
(cd "$REPO_ROOT" && git diff --quiet "$adopted" HEAD -- "$file") && continue
>&2 echo " ✗ ${file#"$REPO_ROOT/"} differs between HEAD and the"
>&2 echo " commit being tagged (${adopted}). The release is composed from"
>&2 echo " the working tree, so it would not match the tag."
>&2 echo
>&2 echo " git checkout ${adopted}"
>&2 echo " ./scripts/manage-release.sh release ${PROJECT}"
return 1
done
}
# Clear every payload this staging path produces, both halves. Clearing only the
# debs left the reverse partial set possible: binaries from a failed cli download
# sitting beside a fresh marker, or a previous attempt's debs beside new
# binaries. The marker is written by the caller before either half is fetched.
clear_alpha_staging() {
rm -f ./*.deb
[ "$KIND" != cli ] || rm -f ./start-cli_*
}
promote_alpha_debs() {
local want arch darch base got
want=$(tag_commit_sha)
echo "Promoting ${PROJECT} debs from the ${APT_ALPHA_SUITE} suite (commit ${want})..."
for arch in $DEB_ARCHES; do
darch=$(deb_arch "$arch")
base=$(basename "${ALPHA_FILES[$arch]}")
echo " ${arch}: ${base}"
curl -fsSL "${APT_NO_CACHE[@]}" "${APT_BASE_URL}/${ALPHA_FILES[$arch]}" -o "$ALPHA_TMP/$base"
# The pool key is stable across builds, so without this an alpha
# republish between the index read and this download would swap the
# bytes after the commit check had already passed.
got=$(sha256_of "$ALPHA_TMP/$base")
if [ "$got" != "${ALPHA_SHAS[$arch]}" ]; then
>&2 echo " ✗ ${darch}: ${base} does not match the hash the signed index commits to"
>&2 echo " (the suite was republished mid-promotion, or the object was tampered with)"
return 1
fi
done
mv "$ALPHA_TMP"/*.deb .
}
pull_gha_debs() {
local arch
for arch in $DEB_ARCHES; do
echo " ${PROJECT}_${arch}.deb"
gh run download -R "$REPO" "$RUN_ID" -n "${PROJECT}_${arch}.deb" -D "$(pwd)"
done
}
# Download this project's released debs from the apt repository into the cwd.
pull_apt_debs() {
local arch darch idx filename
for arch in $DEB_ARCHES; do
darch=$(deb_arch "$arch")
idx="${APT_BASE_URL}/dists/${APT_SUITE}/${APT_COMPONENT}/binary-${darch}/Packages"
filename=$(curl -fsSL "${APT_NO_CACHE[@]}" "$idx" 2>/dev/null | awk -v pkg="$PROJECT" -v ver="$VERSION" '
/^$/ { p=""; v="" }
/^Package:/ { p=$2 }
/^Version:/ { v=$2 }
/^Filename:/ { if (p==pkg && index(v, ver) > 0) print $2 }
' | head -1)
if [ -n "$filename" ]; then
echo " ${arch}: ${filename}"
curl -fsSL "${APT_NO_CACHE[@]}" "${APT_BASE_URL}/${filename}" -o "$(basename "$filename")"
else
>&2 echo " ! no ${PROJECT} ${arch} deb for ${VERSION} in apt repo"
fi
done
}
# Publish the debs in the cwd to the apt repository and the GitHub release.
publish_debs() {
local files file
mapfile -t files < <(deb_files)
if [ ${#files[@]} -eq 0 ]; then
>&2 echo "No .deb files in $(release_dir)"
return 1
fi
echo "Publishing ${PROJECT} debs to the apt repository..."
"$REPO_ROOT/debian/publish.sh" "${files[@]}"
echo "Uploading ${PROJECT} debs to GitHub release ${TAG}..."
for file in "${files[@]}"; do
gh release upload -R "$REPO" "$TAG" "$file" --clobber
done
}
# --- Subcommands ---
# Report a failed "already released" guard. With FORCE=1 it's tolerated (returns
# success) so an idempotent step can be re-run — S3 put -P, gh release --clobber,
# registry re-index, apt re-publish all overwrite in place. Non-idempotent steps
# (npm publish, which can't republish a version) must NOT use this.
release_guard() {
if [ "${FORCE:-}" = 1 ]; then
>&2 echo " ! ${1} (forced)"
return 0
fi
>&2 echo " ✗ ${1}"
return 1
}
cmd_pre_check() {
local errors=0
echo "Pre-checking ${PROJECT} v${VERSION} (tag ${TAG})..."
# 1. The TOP changelog heading must be this prospective version explicitly
# (never `## [Unreleased]`) — see root AGENTS.md changelog rule. Testing the
# first `## ` heading (not the whole file) rejects a stale `## [Unreleased]`
# sitting above the version heading, which would also drop its entries from
# the generated release notes (changelog_section reads from the heading down).
local changelog ver_re first_heading
changelog=$(changelog_path "$PROJECT")
ver_re=${VERSION//./\\.}
if [ ! -f "$changelog" ]; then
>&2 echo " ✗ no CHANGELOG.md at $changelog"
errors=1
else
first_heading=$(grep -m1 -E '^## ' "$changelog")
if printf '%s\n' "$first_heading" | grep -qE "^##[[:space:]]+\[?${ver_re}(]| |\$)"; then
echo " ✓ top changelog heading is ${VERSION}"
else
>&2 echo " ✗ top CHANGELOG.md heading must be ${VERSION} (found: ${first_heading:-none}); a bare '## [Unreleased]' top heading is not allowed — see root AGENTS.md"
errors=1
fi
fi
local notes
notes=$(notes_path "$PROJECT")
if [ -s "$notes" ]; then
echo " ✓ release notes at ${notes#"$REPO_ROOT/"}"
else
>&2 echo " ✗ no release notes at ${notes#"$REPO_ROOT/"} — write this release's notes (lede, '## Highlights', optional '## Important'); put pre-update warnings in $(basename "$(pre_update_notes_path "$PROJECT")")"
errors=1
fi
# 1b. StartOS install/update docs pin the GitHub release link to the version
# being shipped — a repo-wide releases/latest resolves to whichever product
# released most recently (e.g. StartTunnel), not to StartOS. Enforce the bump
# like the changelog: fail if any doc still says releases/latest, links to no
# ${TAG} release, or pins a stale version. See root AGENTS.md "Coupled changes".
if [ "$KIND" = os ]; then
local docs_src total good
docs_src="$REPO_ROOT/projects/start-os/docs/src"
if grep -rqF "releases/latest" "$docs_src" 2>/dev/null; then
>&2 echo " ✗ start-os docs still link to releases/latest — pin to https://github.com/${REPO}/releases/tag/${TAG}"
errors=1
fi
# `|| true`: zero grep matches must reach the ✗ report below, not trip
# errexit via pipefail on the assignment.
total=$(grep -rohF "${REPO}/releases/tag/" "$docs_src" 2>/dev/null | wc -l | tr -d ' ' || true)
good=$(grep -rohF "${REPO}/releases/tag/${TAG}" "$docs_src" 2>/dev/null | wc -l | tr -d ' ' || true)
if [ "$good" -eq 0 ]; then
>&2 echo " ✗ start-os docs link to no ${TAG} release — pin to https://github.com/${REPO}/releases/tag/${TAG}"
errors=1
elif [ "$total" -ne "$good" ]; then
>&2 echo " ✗ start-os docs pin a stale release link (expected releases/tag/${TAG}):"
grep -rnF "${REPO}/releases/tag/" "$docs_src" 2>/dev/null | grep -vF "releases/tag/${TAG}" | sed 's/^/ /' >&2
errors=1
else
echo " ✓ start-os docs pin the release link to ${TAG}"
fi
# The crate manifest can't hold the OS version, so it carries a label
# (0.4.0.1 -> 0.4.0-rev.1). Nothing reads it; a stale one just misleads.
local expect_label crate_label
case "$VERSION" in
*.*.*.*) expect_label="${VERSION%.*}-rev.${VERSION##*.}" ;;
*) expect_label="$VERSION" ;;
esac
crate_label=$(sed -nE '/^\[package\]/,/^\[/{s/^version *= *"([^"]+)".*/\1/p}' \
"$REPO_ROOT/projects/start-os/Cargo.toml" | head -1)
if [ "$crate_label" = "$expect_label" ]; then
echo " ✓ start-os crate label is ${expect_label}"
else
>&2 echo " ✗ projects/start-os/Cargo.toml version must be ${expect_label} for OS ${VERSION} (found: ${crate_label:-none})"
errors=1
fi
fi
# 1c. The `s9pk init-package` template pins the SDK version authors scaffold
# against; hold it at the version being released. The template ships no
# package-lock.json — `init-package` runs `npm install`, so each scaffold
# generates its own lock; a committed template lock is dead weight that only
# rots out of sync with the pin. See start-sdk AGENTS.md and root AGENTS.md
# "Coupled changes".
if [ "$PROJECT" = start-sdk ]; then
local tmpl tmpl_pin
tmpl="$REPO_ROOT/projects/start-sdk/docs/package-template"
tmpl_pin=$(jq -r '.dependencies["@start9labs/start-sdk"] // ""' "$tmpl/package.json" 2>/dev/null)
if [ "$tmpl_pin" != "$VERSION" ]; then
>&2 echo " ✗ package-template pins @start9labs/start-sdk@${tmpl_pin:-<none>} — bump to ${VERSION} (make -C projects/start-sdk sync-template)"
errors=1
else
echo " ✓ package-template pins @start9labs/start-sdk@${VERSION}"
fi
if [ -e "$tmpl/package-lock.json" ]; then
>&2 echo " ✗ package-template must not commit package-lock.json (init-package regenerates it per scaffold; a committed one only rots out of sync with the SDK pin) — remove it"
errors=1
else
echo " ✓ package-template ships no package-lock.json"
fi
fi
# 2. Git tag must not already exist on the remote (idempotent: FORCE re-tags).
if git ls-remote --tags origin "refs/tags/${TAG}" 2>/dev/null | grep -q .; then
release_guard "tag ${TAG} already exists on origin" || errors=1
else
echo " ✓ tag ${TAG} is free"
fi
# 3. This release's own output must not already exist. For os/cli/deb that's
# the GitHub release (the os images themselves are published to S3 + indexed
# by CI, so the registry is expected to already carry them). For npm it's the
# published package version.
case "$KIND" in
os | cli | deb | wrt)
if gh release view -R "$REPO" "$TAG" >/dev/null 2>&1; then
release_guard "GitHub release ${TAG} already exists" || errors=1
else
echo " ✓ GitHub release ${TAG} does not exist"
fi
;;
npm)
# npm can't republish a version, so this is never forceable.
if [ -n "$(npm view "${SDK_NPM_PACKAGE}@${VERSION}" version 2>/dev/null || true)" ]; then
>&2 echo " ✗ ${SDK_NPM_PACKAGE}@${VERSION} already published to npm (cannot republish)"
errors=1
else
echo " ✓ ${SDK_NPM_PACKAGE}@${VERSION} not yet on npm"
fi
;;
esac
# 4. Preconditions for the release steps: everything the pipeline needs must
# already be in place, so a release doesn't fail halfway through.
case "$KIND" in
os)
# `release` pulls the images from the source registry and promotes
# them into prod, so every expected asset must already be in source.
local missing platform ext
load_registry_index "$STARTOS_SOURCE_REGISTRY"
if ! jq -e --arg v "$VERSION" '.versions[$v]' <<< "$_INDEX_JSON" >/dev/null 2>&1; then
>&2 echo " ✗ OS ${VERSION} not in source registry ${STARTOS_SOURCE_REGISTRY} — promote it there first"
errors=1
else
missing=""
for platform in $OS_PLATFORMS; do
for ext in $(os_image_exts "$platform"); do
[ -n "$(asset_url "$STARTOS_SOURCE_REGISTRY" "$ext" "$platform")" ] \
|| missing="${missing} ${platform}.${ext}"
done
done
if [ -n "$missing" ]; then
>&2 echo " ✗ source registry is missing OS assets:${missing}"
errors=1
else
echo " ✓ source registry has all ${VERSION} images"
fi
fi
# `release` promotes into prod; it shouldn't already be there.
if start-cli --registry="$STARTOS_TARGET_REGISTRY" registry os index 2>/dev/null \
| jq -e ".versions[\"$VERSION\"]" >/dev/null 2>&1; then
release_guard "OS ${VERSION} already in production registry ${STARTOS_TARGET_REGISTRY}" || errors=1
else
echo " ✓ not yet in production registry"
fi
# promoting re-signs registry commitments with the developer key;
# start-cli reads id.key.pem (auto-migrating a legacy developer.key.pem).
if [ -f "$HOME/.startos/id.key.pem" ] || [ -f "$HOME/.startos/developer.key.pem" ]; then
echo " ✓ developer key present"
else
>&2 echo " ✗ ~/.startos/id.key.pem missing (needed to promote to the registry)"
errors=1
fi
;;
npm)
if npm whoami >/dev/null 2>&1; then
echo " ✓ npm authenticated ($(npm whoami 2>/dev/null))"
else
>&2 echo " ✗ not logged in to npm (run: npm login)"
errors=1
fi
;;
wrt)
# `release` pulls the images from the source (beta) registry and
# promotes them into production, so both assets must already be
# registered there (the CI deploy does that; `register` is the
# manual fallback).
local slot wrt_missing
load_registry_index "$STARTWRT_SOURCE_REGISTRY"
if ! jq -e --arg v "$VERSION" '.versions[$v]' <<< "$_INDEX_JSON" >/dev/null 2>&1; then
>&2 echo " ✗ StartWRT ${VERSION} not in source registry ${STARTWRT_SOURCE_REGISTRY} — run the start-wrt deploy workflow (or 'register') first"
errors=1
else
wrt_missing=""
for slot in img squashfs; do
[ -n "$(asset_url "$STARTWRT_SOURCE_REGISTRY" "$slot" "$STARTWRT_PLATFORM")" ] \
|| wrt_missing="${wrt_missing} ${slot}"
done
if [ -n "$wrt_missing" ]; then
>&2 echo " ✗ source registry is missing StartWRT assets:${wrt_missing}"
errors=1
else
echo " ✓ source registry has both ${VERSION} images"
fi
fi
# `release` promotes into production; it shouldn't already be there.
if start-cli --registry="$STARTWRT_TARGET_REGISTRY" registry os index 2>/dev/null \
| jq -e ".versions[\"$VERSION\"]" >/dev/null 2>&1; then
release_guard "StartWRT ${VERSION} already in production registry ${STARTWRT_TARGET_REGISTRY}" || errors=1
else
echo " ✓ not yet in production registry"
fi
# promoting re-signs registry commitments with the developer key;
# start-cli reads id.key.pem (auto-migrating a legacy developer.key.pem).
if [ -f "$HOME/.startos/id.key.pem" ] || [ -f "$HOME/.startos/developer.key.pem" ]; then
echo " ✓ developer key present"
else
>&2 echo " ✗ ~/.startos/id.key.pem missing (needed to promote to the registry)"
errors=1
fi
;;
esac
# gh is needed by every release to create the GitHub release (plus the asset
# upload, sign, and apt Release signature for os/cli/deb/wrt).
if gh auth status >/dev/null 2>&1; then
echo " ✓ gh authenticated"
else
>&2 echo " ✗ gh not authenticated (run: gh auth login)"
errors=1
fi
# os/cli/deb/wrt also sign their artifacts with the Start9 org key, and
# render checksum blocks into the release notes (see checksum_block).
if [ "$KIND" != npm ]; then
if gpg --list-secret-keys "$START9_GPG_KEY" >/dev/null 2>&1; then
echo " ✓ Start9 signing key ${START9_GPG_KEY} present"
else
>&2 echo " ✗ Start9 GPG secret key ${START9_GPG_KEY} not in keyring (needed to sign)"
errors=1
fi
if command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1; then
echo " ✓ sha-256 tool available"
else
>&2 echo " ✗ neither sha256sum nor shasum installed (needed for release-notes checksums)"
errors=1
fi
if command -v b3sum >/dev/null 2>&1; then
echo " ✓ b3sum available"
else
>&2 echo " ✗ b3sum not installed (needed for release-notes checksums; brew/cargo install b3sum)"
errors=1
fi
fi
# cli/deb publish debs to the apt repo and os pushes the compressed images
# (push-gz) — all three upload to S3, which needs s3cmd + credentials.
if [ "$KIND" = cli ] || [ "$KIND" = deb ] || [ "$KIND" = os ]; then
if command -v s3cmd >/dev/null 2>&1; then
echo " ✓ s3cmd available"
else
>&2 echo " ✗ s3cmd not installed (needed to upload to S3/apt)"
errors=1
fi
if [ -f "$HOME/.s3cfg" ] || { [ -n "${S3_ACCESS_KEY:-}" ] && [ -n "${S3_SECRET_KEY:-}" ]; }; then
echo " ✓ s3 credentials configured"
else
>&2 echo " ! no ~/.s3cfg and S3_ACCESS_KEY/S3_SECRET_KEY unset — S3 upload may fail"
fi
fi
if [ "$errors" -ne 0 ]; then
>&2 echo "Pre-check failed."
exit 1
fi
echo "Pre-check passed."
}
# Resolve RUN_ID (prompting when unset), assert the run built the commit being
# tagged, and stage the release dir. Sets RUN_ID and RUN_SHA for the caller.
resolve_gha_run() {
if [ -z "${RUN_ID:-}" ]; then
read -rp "RUN_ID (GitHub Actions run for ${PROJECT}): " RUN_ID
fi