eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env sh
2# Publish server releases + move the channel feed (edge / beta / stable).
3#
4# The feed model (docs/server-releases.md): every build gets a GitHub release
5# `server-<ver>` on the public BUILDS repo (machine artifacts, split from the
6# human releases repo 2026-07-22) carrying the linux binary; one rolling
7# release tagged `server` on the RELEASES repo carries manifest.json — the
8# Ed25519-signed feed saying which version each channel points at. Boxes only ever read the
9# manifest; promotion is a manifest edit, no rebuild.
10#
11# ./deploy/release-server.sh publish [notes...] # build HEAD -> release server-<sha> -> point edge at it
12# ./deploy/release-server.sh promote <ver> <chan> # point beta/stable at an already-published version
13# ./deploy/release-server.sh ship [ver] # run the box updater until every instance is current
14#
15# `ship` is the manual-deploy button: publish moves the feed, ship makes the
16# box catch up NOW instead of on the hourly timer — it loops `lchat update run`
17# until every un-pinned instance is current on its channel (absorbing GitHub's
18# stale-CDN window on a fresh manifest); each pass restarts the updated
19# instances' bot sidecars in lock-step (deploy/systemd/lchat-bot@.service).
20# Needs LCHAT_BOX (e.g. <box-ip>; the operator user + WireGuard tunnel come
21# from ~/.ssh/config — root SSH is bootstrap-only, and the box commands run
22# via `sudo -n` through deploy/sudoers-operators. Environment specifics live
23# in CLAUDE.local.md, never here). `publish ... && ship` is a full deploy.
24#
25# Needs: gh (authed, write access to $LCHAT_RELEASES_REPO AND $LCHAT_BUILDS_REPO), jq, go, and the
26# signing key. Both publish AND promote re-sign the feed, so both need the key.
27#
28# The easy path — nothing to set up: with the Bitwarden CLI (`bw`) installed,
29# just run the command and it pulls the seed from Bitwarden itself (unlocking
30# interactively if the vault is locked). One unlock covers a publish && promote
31# chain; the seed stays in the env only, never on disk.
32#
33# ./deploy/release-server.sh publish "notes"
34# ./deploy/release-server.sh promote <ver> stable
35#
36# (The Bitwarden item — "Eleven Messenger server release signing key" — has no
37# password field; the seed rides a hidden custom field, picked out as the only
38# >40-char value. Legacy copy in 1Password.)
39#
40# Override the auto-pull by providing the key yourself: the seed VALUE in
41# $LCHAT_RELEASE_KEY (env, never touches disk), $LCHAT_RELEASE_KEY_FILE as a
42# file path, or `-` to read it from stdin (parked in this run's tmpdir, wiped
43# on exit). Any of these set → Bitwarden is not consulted.
44#
45# Optional S3 mirror: set LCHAT_RELEASE_MIRROR_BUCKET (needs the aws CLI) and
46# every feed artifact is also copied to s3://<bucket>/releases/… — GitHub's
47# asset CDN serves freshly-clobbered manifests stale (and new binaries 404)
48# for minutes after a publish, and it's the fleet's only update path, so the
49# mirror is a second boring home for the same signed bytes. Updaters fall back
50# to it via LCHAT_RELEASE_MIRROR_URL (docs/server-releases.md § The S3 mirror);
51# the feed's Ed25519 signature + sha256s make the mirror availability-only,
52# never trusted. Unset, this script is unchanged.
53set -eu
54
55# The Bitwarden item names this script reads — the vault contract.
56# shellcheck source=deploy/vault-items.sh
57. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/vault-items.sh"
58# shellcheck source=deploy/bw-lib.sh
59. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/bw-lib.sh"
60
61REPO="${LCHAT_RELEASES_REPO:-elevenmessenger/releases}"
62# Per-commit server-<sha> build artifacts live in their own public repo so the
63# RELEASES repo's page stays human (Mac app, the rolling feed, enterprise) —
64# these are dev builds, not product releases (Paul, 2026-07-22). Public
65# because the fleet and self-hosters download tokenless. Builds published
66# before the split stay on $REPO forever — promote resolves per version, so
67# old pins and rollbacks never break.
68BUILDS_REPO="${LCHAT_BUILDS_REPO:-elevenmessenger/builds}"
69KEY="${LCHAT_RELEASE_KEY_FILE:-}"
70MIRROR_BUCKET="${LCHAT_RELEASE_MIRROR_BUCKET:-}"
71BIN=lchat-linux-amd64
72BIN_ARM=lchat-linux-arm64
73# The darwin builds exist for the website's `lchat try` download (Macs hit
74# "exec format error" on a linux binary) — the fleet and the channel manifest
75# stay linux-only; these just ride the releases as extra assets.
76BIN_MAC=lchat-darwin-arm64
77BIN_MAC_X64=lchat-darwin-amd64
78
79TMP="$(mktemp -d)"
80trap 'rm -rf "$TMP"' EXIT
81
82# ship only drives the box over ssh — no signing, no feed writes — so the key
83# and the gh/jq toolchain are required only where the feed is touched
84# (publish/promote, checked below).
85case "${1:-}" in publish|promote)
86 # No key provided any other way? If the Bitwarden CLI is present, pull the
87 # signing seed from there — bw is the standing store for operator secrets, so
88 # neither publish NOR promote should need a hand-exported key (both re-sign the
89 # feed, so both need it). Unlocks interactively if the vault is locked; the
90 # seed lands in the env only, never on disk (same property as the explicit
91 # LCHAT_RELEASE_KEY flow). No bw and no key set → the original error below.
92 if [ -z "${LCHAT_RELEASE_KEY:-}" ] && [ -z "$KEY" ] \
93 && command -v bw >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then
94 # The item has no password field — the seed rides a hidden custom field,
95 # picked out as the only >40-char value (matches the header's incantation).
96 # bw's API calls carry no timeout of their own and can stall for good on a
97 # bad connection or stale session (observed 2026-07-21: a promote sat 14
98 # silent minutes inside `bw get`), so bw runs under perl's alarm — the
99 # never-a-hang promise, enforced. The seed stays in the pipe, never on
100 # disk. bw_stalled distinguishes a killed 30s read from a fast empty one
101 # (vault locked), which still falls through to the unlock prompt below.
102 bw_key() { bw_run 30 get item "$BW_ITEM_SIGNING_KEY" 2>/dev/null \
103 | jq -r '.fields[]? | select((.value|length)>40).value' | head -1; }
104 bw_stalled() { # $1 = start epoch; empty result + ≥30s elapsed = the watchdog fired
105 [ $(( $(date +%s) - $1 )) -ge 30 ] || return 1
106 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
107 }
108 echo "release-server: reading the signing key from Bitwarden…" >&2
109 _t0="$(date +%s)"
110 LCHAT_RELEASE_KEY="$(bw_key)"
111 if [ -z "$LCHAT_RELEASE_KEY" ]; then
112 bw_stalled "$_t0" && exit 1
113 # A fast empty read means the vault is locked (or this shell has no
114 # session): unlock — prompts for the master password on the tty — then
115 # read again.
116 echo "release-server: unlocking Bitwarden to read the signing key…" >&2
117 # Our prompt, never bw's; watchdogged — the whole policy lives in
118 # bw-lib.sh (set -e: die with a hint, not silently).
119 BW_SESSION="$(bw_unlock_session)" && export BW_SESSION || {
120 echo "release-server: set LCHAT_RELEASE_KEY (or export BW_SESSION) and retry" >&2; exit 1; }
121 _t0="$(date +%s)"
122 LCHAT_RELEASE_KEY="$(bw_key)"
123 [ -n "$LCHAT_RELEASE_KEY" ] || { bw_stalled "$_t0" && exit 1 || true; }
124 fi
125 [ -n "$LCHAT_RELEASE_KEY" ] || {
126 echo "release-server: couldn't read '$BW_ITEM_SIGNING_KEY' from Bitwarden" >&2; exit 1; }
127 export LCHAT_RELEASE_KEY # the signing tool (a child process) reads it from the env
128 echo "release-server: signing key sourced from Bitwarden" >&2
129 fi
130 if [ -n "${LCHAT_RELEASE_KEY:-}" ]; then
131 # The seed itself is in the environment — the preferred flow: it goes
132 # secret manager -> shell -> the signing tool's memory and never touches
133 # disk. No key file anywhere below (release sign reads the env directly).
134 KEY=""
135 elif [ "$KEY" = "-" ]; then
136 # Key arrives on stdin: park it in this run's private tmpdir (0700 from
137 # mktemp -d, wiped by the trap) so nothing persists past the run.
138 cat > "$TMP/signing.key"
139 [ -s "$TMP/signing.key" ] || {
140 echo "release-server: LCHAT_RELEASE_KEY_FILE=- but nothing arrived on stdin" >&2; exit 1; }
141 KEY="$TMP/signing.key"
142 else
143 [ -n "$KEY" ] && [ -f "$KEY" ] || {
144 echo "release-server: set LCHAT_RELEASE_KEY to the base64 seed — or install the Bitwarden CLI 'bw' to pull it automatically (LCHAT_RELEASE_KEY_FILE to a key file, or - for stdin, also work)" >&2; exit 1; }
145 fi
146 command -v jq >/dev/null || { echo "release-server: jq is required" >&2; exit 1; }
147 command -v gh >/dev/null || { echo "release-server: gh is required" >&2; exit 1; }
148 [ -z "$MIRROR_BUCKET" ] || command -v aws >/dev/null || {
149 echo "release-server: LCHAT_RELEASE_MIRROR_BUCKET is set but the aws CLI is missing" >&2; exit 1; }
150 ;;
151esac
152
153# The signing/manifest code lives in the binary itself, so builder and box
154# can't drift on the format. Build a local tool from this checkout (only the
155# feed-touching commands need it).
156TOOL="$TMP/lchat-tool"
157case "${1:-}" in publish|promote)
158 echo "release-server: building the signing tool (go build — a cold build takes a few minutes)…" >&2
159 go build -o "$TOOL" . ;; esac
160
161# fetch_payload: current feed payload JSON (verified), or a fresh skeleton.
162fetch_payload() {
163 if gh release download server -R "$REPO" -p manifest.json -O "$TMP/manifest.json" 2>/dev/null; then
164 "$TOOL" release payload --in "$TMP/manifest.json"
165 else
166 echo '{"schema":1,"channels":{}}'
167 fi
168}
169
170# mirror_upload <file> <key>: copy an artifact into the S3 mirror (no-op when
171# LCHAT_RELEASE_MIRROR_BUCKET is unset). Best-effort by design: by the time
172# this runs the GitHub half has already landed, so a failed mirror write warns
173# instead of aborting a half-done publish — a lagging mirror is harmless
174# (updaters use whichever verified feed is fresher).
175mirror_upload() {
176 [ -n "$MIRROR_BUCKET" ] || return 0
177 if aws s3 cp "$1" "s3://$MIRROR_BUCKET/releases/$2" >/dev/null; then
178 echo "mirrored $2 -> s3://$MIRROR_BUCKET/releases/$2"
179 else
180 echo "release-server: WARNING: mirror upload of $2 failed — the S3 mirror lags until the next publish/promote" >&2
181 fi
182}
183
184# sign_and_upload: sign $1 (payload json) and clobber the rolling feed asset.
185# With LCHAT_RELEASE_KEY in the environment, KEY is empty and the tool reads
186# the seed straight from the env — no --key, no file.
187sign_and_upload() {
188 echo "release-server: signing + uploading the feed…" >&2
189 if [ -n "$KEY" ]; then
190 "$TOOL" release sign --key "$KEY" --in "$1" --out "$TMP/manifest.json"
191 else
192 "$TOOL" release sign --in "$1" --out "$TMP/manifest.json"
193 fi
194 gh release view server -R "$REPO" >/dev/null 2>&1 || \
195 gh release create server -R "$REPO" --title "Server release feed" --latest=false \
196 --notes "Rolling release: manifest.json is the channel feed instances update from. See the messenger repo's docs/server-releases.md."
197 gh release upload server "$TMP/manifest.json" -R "$REPO" --clobber >/dev/null
198 echo "release-server: manifest.json clobbered on the rolling release" >&2
199 "$TOOL" release verify --in "$TMP/manifest.json"
200 # Both publish and promote come through here, so the mirror's feed tracks
201 # every channel move, not just new builds.
202 mirror_upload "$TMP/manifest.json" manifest.json
203 refresh_release_page "$1"
204}
205
206# refresh_release_page: keep the releases repo human-readable. The per-version
207# server-<sha> releases are mechanical build artifacts (created --prerelease,
208# below, so they wear the grey badge and stay out of the way); the rolling
209# `server` release is the HUMAN surface, so on every publish/promote its notes
210# become a current-stack table: the channel pointers plus the latest Mac app.
211# Also asserts the repo invariant the enterprise release broke on 2026-07-19:
212# GitHub's "Latest" belongs to the MAC releases — Sparkle's baked-in SUFeedURL
213# is releases/latest/download/appcast.xml, so a Latest without appcast.xml
214# silently 404s every Mac's update check. Any release minted OUTSIDE this
215# script must pass --latest=false (or --prerelease); this check turns that
216# mistake from silent breakage into a loud warning on the next release op.
217refresh_release_page() {
218 echo "release-server: refreshing the releases-page stack table…" >&2
219 LATEST_TAG="$(gh api "repos/$REPO/releases/latest" -q .tag_name 2>/dev/null || true)"
220 if [ -n "$LATEST_TAG" ] && ! gh release view "$LATEST_TAG" -R "$REPO" --json assets \
221 -q '.assets[].name' 2>/dev/null | grep -qx "appcast.xml"; then
222 echo "release-server: ⚠️ '$LATEST_TAG' holds GitHub's Latest badge but has no appcast.xml —" >&2
223 echo " Mac auto-update (releases/latest/download/appcast.xml) is 404ing RIGHT NOW." >&2
224 echo " Fix: gh release edit <newest-mac-tag> -R $REPO --latest" >&2
225 fi
226 TABLE="$(jq -r '
227 "Current stack (refreshed on every publish/promote):\n\n" +
228 "| Piece | Version |\n|---|---|\n" +
229 ([.channels | to_entries[] | "| Server (" + .key + ") | " + .value.version + " |"] | join("\n"))
230 ' "$1" 2>/dev/null || true)"
231 MAC_TAG="$(gh release list -R "$REPO" --limit 40 --json tagName -q \
232 '[.[].tagName | select(startswith("v"))][0]' 2>/dev/null || true)"
233 [ -n "$MAC_TAG" ] && TABLE="$TABLE
234| Mac app | $MAC_TAG |"
235 TABLE="$TABLE
236| Services (connect/push/home) | ride the server version |
237
238manifest.json on this release is the signed channel feed instances update from — docs/server-releases.md."
239 [ -n "$TABLE" ] && gh release edit server -R "$REPO" --notes "$TABLE" >/dev/null 2>&1 || true
240}
241
242cmd="${1:-}"; shift || true
243case "$cmd" in
244publish)
245 # A release must correspond to landed main code — same rule as deploys.
246 [ -z "$(git status --porcelain)" ] || { echo "release-server: working tree is dirty — release from clean main" >&2; exit 1; }
247 VER="$(git rev-parse --short HEAD)"
248 NOTES="${*:-$(git log -1 --pretty=%s)}"
249
250 OUT="$TMP/$BIN" ./deploy/build-linux.sh "$VER"
251 ARCH=arm64 OUT="$TMP/$BIN_ARM" ./deploy/build-linux.sh "$VER"
252 OS=darwin ARCH=arm64 OUT="$TMP/$BIN_MAC" ./deploy/build-linux.sh "$VER"
253 OS=darwin ARCH=amd64 OUT="$TMP/$BIN_MAC_X64" ./deploy/build-linux.sh "$VER"
254 SHA="$(shasum -a 256 "$TMP/$BIN" | cut -d' ' -f1)"
255 SIZE="$(wc -c < "$TMP/$BIN" | tr -d ' ')"
256 SHA_ARM="$(shasum -a 256 "$TMP/$BIN_ARM" | cut -d' ' -f1)"
257 SIZE_ARM="$(wc -c < "$TMP/$BIN_ARM" | tr -d ' ')"
258 echo "$SHA $BIN" > "$TMP/$BIN.sha256"
259 echo "$SHA_ARM $BIN_ARM" > "$TMP/$BIN_ARM.sha256"
260 for M in "$BIN_MAC" "$BIN_MAC_X64"; do
261 echo "$(shasum -a 256 "$TMP/$M" | cut -d' ' -f1) $M" > "$TMP/$M.sha256"
262 done
263
264 # Sibling-service binaries (connect broker, push relay, home server) ride the
265 # release too, with their sha256s pinned INSIDE the signed manifest entry —
266 # that's what lets the box's root update pass install them with no root SSH
267 # (updaterservices.go). name:builddir pairs; assets are lchat-<name>-linux-<arch>.
268 SERVICES="connect:broker push:push home:home"
269 SVC_JSON="{}"
270 SVC_ASSETS=""
271 for SPEC in $SERVICES; do
272 SNAME="${SPEC%%:*}"; SDIR="${SPEC##*:}"
273 SB="lchat-$SNAME-linux-amd64"; SBA="lchat-$SNAME-linux-arm64"
274 GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o "$TMP/$SB" "./$SDIR"
275 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o "$TMP/$SBA" "./$SDIR"
276 echo "$(shasum -a 256 "$TMP/$SB" | cut -d' ' -f1) $SB" > "$TMP/$SB.sha256"
277 echo "$(shasum -a 256 "$TMP/$SBA" | cut -d' ' -f1) $SBA" > "$TMP/$SBA.sha256"
278 echo "built $VER (service $SNAME, amd64+arm64)"
279 SVC_JSON="$(printf '%s' "$SVC_JSON" | jq --arg n "$SNAME" \
280 --arg url "https://github.com/$BUILDS_REPO/releases/download/server-$VER/$SB" \
281 --arg sha "$(cut -d' ' -f1 < "$TMP/$SB.sha256")" \
282 --argjson size "$(wc -c < "$TMP/$SB" | tr -d ' ')" \
283 --arg aurl "https://github.com/$BUILDS_REPO/releases/download/server-$VER/$SBA" \
284 --arg asha "$(cut -d' ' -f1 < "$TMP/$SBA.sha256")" \
285 --argjson asize "$(wc -c < "$TMP/$SBA" | tr -d ' ')" \
286 '.[$n] = {url:$url, sha256:$sha, size:$size, arm64_url:$aurl, arm64_sha256:$asha, arm64_size:$asize}')"
287 SVC_ASSETS="$SVC_ASSETS $SB $SB.sha256 $SBA $SBA.sha256"
288 done
289
290 if gh release view "server-$VER" -R "$BUILDS_REPO" >/dev/null 2>&1; then
291 echo "release-server: server-$VER already exists on $BUILDS_REPO (promote it, or land a new commit)" >&2; exit 1
292 fi
293 # --latest=false: the repo's "latest" pointer belongs to the MAC app —
294 # Sparkle's baked-in SUFeedURL is releases/latest/download/appcast.xml, and
295 # GitHub hands "latest" to the newest plain release by default, so every
296 # server publish was silently 404ing every Mac's update check (found
297 # 2026-07-14: the feed pointed at server-414bdeb0, no appcast there).
298 # --prerelease: build artifacts, not the headline — they wear the grey badge
299 # so the releases page reads as "the things" (Mac app, the rolling feed)
300 # instead of a wall of shas. Prereleases also can never take "Latest"
301 # (which belongs to the Mac releases — see refresh_release_page).
302 echo "release-server: creating server-$VER + uploading $((8 + $(echo $SVC_ASSETS | wc -w | tr -d ' '))) assets…" >&2
303 gh release create "server-$VER" -R "$BUILDS_REPO" --title "Server $VER" --notes "$NOTES" \
304 --prerelease --latest=false \
305 "$TMP/$BIN" "$TMP/$BIN.sha256" "$TMP/$BIN_ARM" "$TMP/$BIN_ARM.sha256" \
306 "$TMP/$BIN_MAC" "$TMP/$BIN_MAC.sha256" "$TMP/$BIN_MAC_X64" "$TMP/$BIN_MAC_X64.sha256"
307 # One call for the service binaries — a call per asset printed ten anonymous
308 # "Successfully uploaded 1 asset" lines and paid ten round trips.
309 SVC_PATHS=""
310 for A in $SVC_ASSETS; do SVC_PATHS="$SVC_PATHS $TMP/$A"; done
311 # shellcheck disable=SC2086 # word-splitting the paths is the point
312 gh release upload "server-$VER" $SVC_PATHS -R "$BUILDS_REPO"
313 # Binaries mirror before the manifest that points at them, so the mirror
314 # never serves a feed whose mirror-side binaries don't exist yet. Keys copy
315 # the GitHub layout (server-<ver>/<asset>) — that's what the updater's
316 # mirror fallback derives from a primary binary URL.
317 for A in "$BIN" "$BIN.sha256" "$BIN_ARM" "$BIN_ARM.sha256" \
318 "$BIN_MAC" "$BIN_MAC.sha256" "$BIN_MAC_X64" "$BIN_MAC_X64.sha256" \
319 $SVC_ASSETS; do
320 mirror_upload "$TMP/$A" "server-$VER/$A"
321 done
322
323 # The human version (deploy/SERVER_SEMVER) rides along as `semver` — the
324 # sha stays the artifact id. It's what major pinning (update_major)
325 # evaluates on the box; a missing file publishes without one, which HOLDS
326 # any major-pinned instance (fail closed), so keep the file current.
327 SEMVER="$(tr -d '[:space:]' < deploy/SERVER_SEMVER 2>/dev/null || true)"
328 [ -n "$SEMVER" ] || echo "release-server: note: no deploy/SERVER_SEMVER — publishing without a semver (major-pinned instances will hold this release)" >&2
329 fetch_payload | jq \
330 --arg ver "$VER" --arg sha "$SHA" --argjson size "$SIZE" --arg notes "$NOTES" \
331 --arg sha_arm "$SHA_ARM" --argjson size_arm "$SIZE_ARM" \
332 --arg url "https://github.com/$BUILDS_REPO/releases/download/server-$VER/$BIN" \
333 --arg url_arm "https://github.com/$BUILDS_REPO/releases/download/server-$VER/$BIN_ARM" \
334 --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg semver "$SEMVER" \
335 --argjson svc "$SVC_JSON" \
336 '.channels.edge = ({version:$ver, url:$url, sha256:$sha, size:$size,
337 arm64_url:$url_arm, arm64_sha256:$sha_arm, arm64_size:$size_arm,
338 notes:$notes, published_at:$now, services:$svc}
339 + (if $semver == "" then {} else {semver:$semver} end))' \
340 > "$TMP/payload.json"
341 sign_and_upload "$TMP/payload.json"
342 # The installer rides the rolling release too, so its URL is always current.
343 if [ -f deploy/install.sh ]; then
344 gh release upload server deploy/install.sh -R "$REPO" --clobber
345 fi
346 # The self-hosting guide is the public downloads repo's README (this source
347 # repo is private, so strangers can't read docs/ here) — synced on publish.
348 if [ -f docs/self-hosting.md ]; then
349 README_SHA="$(gh api "repos/$REPO/contents/README.md" --jq .sha 2>/dev/null || true)"
350 gh api -X PUT "repos/$REPO/contents/README.md" \
351 -f message="sync self-hosting guide (messenger @$VER)" \
352 -f content="$(base64 < docs/self-hosting.md | tr -d '\n')" \
353 ${README_SHA:+-f sha="$README_SHA"} >/dev/null
354 echo "synced self-hosting guide -> $REPO README"
355 fi
356 echo "published server-$VER and pointed edge at it"
357 ;;
358
359promote)
360 VER="${1:-}"; CHAN="${2:-}"
361 case "$CHAN" in edge|beta|stable) ;; *)
362 echo "usage: release-server.sh promote <ver> edge|beta|stable" >&2; exit 1;; esac
363
364 echo "promote: fetching the current feed…" >&2
365 fetch_payload > "$TMP/payload.json"
366 # Which repo holds this version's build: builds (post-split) or releases
367 # (published before the split — those releases stay put so nothing breaks).
368 VREPO="$BUILDS_REPO"
369 gh release view "server-$VER" -R "$VREPO" >/dev/null 2>&1 || VREPO="$REPO"
370 # Prefer the entry already on a channel (usually promoting edge -> beta ->
371 # stable); otherwise rebuild it from the server-<ver> release's own assets
372 # (arm64 fields only when that release has an arm64 build — pre-arm64
373 # releases stay amd64-only).
374 ENTRY="$(jq --arg ver "$VER" '[.channels[] | select(.version == $ver)][0] // empty' "$TMP/payload.json")"
375 if [ -z "$ENTRY" ]; then
376 # A rebuilt entry can't recover the semver the build was published with —
377 # major-pinned instances hold semver-less releases, so say so.
378 echo "release-server: note: rebuilding the $VER entry from release assets — it will carry no semver (major-pinned instances will hold it)" >&2
379 gh release download "server-$VER" -R "$VREPO" -p "$BIN.sha256" -O "$TMP/$BIN.sha256" || {
380 echo "release-server: no server-$VER release on $VREPO" >&2; exit 1; }
381 SHA="$(cut -d' ' -f1 < "$TMP/$BIN.sha256")"
382 SIZE="$(gh release view "server-$VER" -R "$VREPO" --json assets \
383 --jq ".assets[] | select(.name == \"$BIN\") | .size")"
384 NOTES="$(gh release view "server-$VER" -R "$VREPO" --json body --jq .body)"
385 ENTRY="$(jq -n --arg ver "$VER" --arg sha "$SHA" --argjson size "${SIZE:-0}" --arg notes "$NOTES" \
386 --arg url "https://github.com/$VREPO/releases/download/server-$VER/$BIN" \
387 --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
388 '{version:$ver, url:$url, sha256:$sha, size:$size, notes:$notes, published_at:$now}')"
389 if gh release download "server-$VER" -R "$VREPO" -p "$BIN_ARM.sha256" -O "$TMP/$BIN_ARM.sha256" 2>/dev/null; then
390 SHA_ARM="$(cut -d' ' -f1 < "$TMP/$BIN_ARM.sha256")"
391 SIZE_ARM="$(gh release view "server-$VER" -R "$VREPO" --json assets \
392 --jq ".assets[] | select(.name == \"$BIN_ARM\") | .size")"
393 ENTRY="$(printf '%s' "$ENTRY" | jq --arg sha "$SHA_ARM" --argjson size "${SIZE_ARM:-0}" \
394 --arg url "https://github.com/$VREPO/releases/download/server-$VER/$BIN_ARM" \
395 '. + {arm64_url:$url, arm64_sha256:$sha, arm64_size:$size}')"
396 fi
397 fi
398 jq --arg chan "$CHAN" --argjson entry "$ENTRY" '.channels[$chan] = $entry' \
399 "$TMP/payload.json" > "$TMP/payload2.json"
400 sign_and_upload "$TMP/payload2.json"
401
402 # Stable also refreshes the rolling release's "latest stable" binaries — the
403 # fixed, JSON-free URLs deploy/install.sh downloads.
404 if [ "$CHAN" = stable ]; then
405 mkdir -p "$TMP/stable"
406 # Download what exists, name it, then push everything in ONE upload call —
407 # a call per asset printed eight anonymous "Successfully uploaded 1 asset"
408 # lines (gh never says which), the papercut promote kept after publish
409 # lost it.
410 ROLLING=""
411 for A in "$BIN" "$BIN.sha256" "$BIN_ARM" "$BIN_ARM.sha256" \
412 "$BIN_MAC" "$BIN_MAC.sha256" "$BIN_MAC_X64" "$BIN_MAC_X64.sha256"; do
413 if gh release download "server-$VER" -R "$VREPO" -p "$A" -O "$TMP/stable/$A" 2>/dev/null; then
414 ROLLING="$ROLLING $TMP/stable/$A"
415 echo "promote: $A" >&2
416 else
417 echo "promote: $A — server-$VER has none; rolling copy not refreshed" >&2
418 fi
419 done
420 if [ -n "$ROLLING" ]; then
421 echo "promote: refreshing $(echo "$ROLLING" | wc -w | tr -d ' ') rolling latest-stable assets (one upload)…" >&2
422 # shellcheck disable=SC2086 # word-splitting the paths is the point
423 gh release upload server $ROLLING -R "$REPO" --clobber >/dev/null
424 fi
425 fi
426 echo "promoted $VER to $CHAN"
427 ;;
428
429ship)
430 # --expedite: pass --ignore-bake through to the box updater — the deliberate
431 # operator override of the supply-chain bake window (docs/server-releases.md),
432 # for shipping an incident fix the moment it's promoted. The hold is the
433 # default for a reason; expediting is a stated decision, never routine.
434 EXPEDITE=""
435 if [ "${1:-}" = "--expedite" ]; then EXPEDITE="--ignore-bake"; shift; fi
436 VER="${1:-$(git rev-parse --short HEAD)}"
437 BOX="${LCHAT_BOX:-}"
438 [ -n "$BOX" ] || { echo "release-server: set LCHAT_BOX (e.g. <box-ip>; user comes from ssh config) to ship" >&2; exit 1; }
439 echo "ship: updating instances on $BOX until current (feed target: $VER${EXPEDITE:+, expedited past the bake window})"
440 DEADLINE=$(( $(date +%s) + 600 ))
441 while :; do
442 ssh "$BOX" sudo -n /opt/lchat/lchat update run $EXPEDITE || true
443 STATUS="$(ssh "$BOX" sudo -n /opt/lchat/lchat update status || true)"
444 # Done when no un-pinned instance with a known channel head is behind, and
445 # at least one row proves a feed landed at all. Pinned rows are someone's
446 # canary: reported, never waited on. Held rows (a beta/stable head still
447 # inside its supply-chain bake window) are likewise reported, not waited
448 # on — the hold is measured in days and ship's deadline in minutes.
449 BEHIND="$(printf '%s\n' "$STATUS" | awk 'NR>1 && $3!="pinned" && $5!="-" && $0 !~ /\(current\)/ && $0 !~ /\(held/ {print $1}')"
450 if [ -z "$BEHIND" ] && printf '%s\n' "$STATUS" | grep -q "(current)"; then
451 printf '%s\n' "$STATUS"
452 printf '%s\n' "$STATUS" | awk 'NR>1 && $3=="pinned" {print "note: " $1 " is pinned (a canary) - left alone"}'
453 printf '%s\n' "$STATUS" | awk 'NR>1 && /\(held/ {print "note: " $1 " is holding its channel head (safety hold) - not waited on"}'
454 if printf '%s\n' "$STATUS" | grep -q "$VER (current)"; then
455 echo "shipped $VER - every un-pinned instance is current"
456 else
457 echo "shipped - every un-pinned instance is current on its channel (none rides a channel pointing at $VER yet)"
458 fi
459 exit 0
460 fi
461 # Nothing behind and nothing current can still be a clean stop: every
462 # remaining row is inside its safety hold (e.g. the whole fleet rides
463 # stable right after a promote). They install when the window elapses.
464 if [ -z "$BEHIND" ] && printf '%s\n' "$STATUS" | grep -q "(held"; then
465 printf '%s\n' "$STATUS"
466 echo "shipped the feed - every remaining instance is holding its channel head inside the safety window (installs when it elapses; per-host override: lchat deploy <host> --version <ver>)"
467 exit 0
468 fi
469 if [ "$(date +%s)" -ge "$DEADLINE" ]; then
470 printf '%s\n' "$STATUS"
471 echo "ship: gave up after 10m - still behind: $BEHIND (GitHub CDN can serve a fresh manifest stale; retry in a few minutes)" >&2
472 exit 1
473 fi
474 echo "ship: waiting for the feed/instances (behind: ${BEHIND:-feed})..."
475 sleep 30
476 done
477 ;;
478
479*)
480 echo "usage: release-server.sh publish [notes...] | promote <ver> edge|beta|stable | ship [ver]" >&2
481 exit 1
482 ;;
483esac
484