| 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. |
| 53 | set -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 | |
| 61 | REPO="${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. |
| 68 | BUILDS_REPO="${LCHAT_BUILDS_REPO:-elevenmessenger/builds}" |
| 69 | KEY="${LCHAT_RELEASE_KEY_FILE:-}" |
| 70 | MIRROR_BUCKET="${LCHAT_RELEASE_MIRROR_BUCKET:-}" |
| 71 | BIN=lchat-linux-amd64 |
| 72 | BIN_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. |
| 76 | BIN_MAC=lchat-darwin-arm64 |
| 77 | BIN_MAC_X64=lchat-darwin-amd64 |
| 78 | |
| 79 | TMP="$(mktemp -d)" |
| 80 | trap '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). |
| 85 | case "${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 | ;; |
| 151 | esac |
| 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). |
| 156 | TOOL="$TMP/lchat-tool" |
| 157 | case "${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. |
| 162 | fetch_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). |
| 175 | mirror_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. |
| 187 | sign_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. |
| 217 | refresh_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 | |
| 238 | manifest.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 | |
| 242 | cmd="${1:-}"; shift || true |
| 243 | case "$cmd" in |
| 244 | publish) |
| 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 | |
| 359 | promote) |
| 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 | |
| 429 | ship) |
| 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 | ;; |
| 483 | esac |
| 484 | |