#!/usr/bin/env sh # Publish server releases + move the channel feed (edge / beta / stable). # # The feed model (docs/server-releases.md): every build gets a GitHub release # `server-` on the public BUILDS repo (machine artifacts, split from the # human releases repo 2026-07-22) carrying the linux binary; one rolling # release tagged `server` on the RELEASES repo carries manifest.json — the # Ed25519-signed feed saying which version each channel points at. Boxes only ever read the # manifest; promotion is a manifest edit, no rebuild. # # ./deploy/release-server.sh publish [notes...] # build HEAD -> release server- -> point edge at it # ./deploy/release-server.sh promote # point beta/stable at an already-published version # ./deploy/release-server.sh ship [ver] # run the box updater until every instance is current # # `ship` is the manual-deploy button: publish moves the feed, ship makes the # box catch up NOW instead of on the hourly timer — it loops `lchat update run` # until every un-pinned instance is current on its channel (absorbing GitHub's # stale-CDN window on a fresh manifest); each pass restarts the updated # instances' bot sidecars in lock-step (deploy/systemd/lchat-bot@.service). # Needs LCHAT_BOX (e.g. ; the operator user + WireGuard tunnel come # from ~/.ssh/config — root SSH is bootstrap-only, and the box commands run # via `sudo -n` through deploy/sudoers-operators. Environment specifics live # in CLAUDE.local.md, never here). `publish ... && ship` is a full deploy. # # Needs: gh (authed, write access to $LCHAT_RELEASES_REPO AND $LCHAT_BUILDS_REPO), jq, go, and the # signing key. Both publish AND promote re-sign the feed, so both need the key. # # The easy path — nothing to set up: with the Bitwarden CLI (`bw`) installed, # just run the command and it pulls the seed from Bitwarden itself (unlocking # interactively if the vault is locked). One unlock covers a publish && promote # chain; the seed stays in the env only, never on disk. # # ./deploy/release-server.sh publish "notes" # ./deploy/release-server.sh promote stable # # (The Bitwarden item — "Eleven Messenger server release signing key" — has no # password field; the seed rides a hidden custom field, picked out as the only # >40-char value. Legacy copy in 1Password.) # # Override the auto-pull by providing the key yourself: the seed VALUE in # $LCHAT_RELEASE_KEY (env, never touches disk), $LCHAT_RELEASE_KEY_FILE as a # file path, or `-` to read it from stdin (parked in this run's tmpdir, wiped # on exit). Any of these set → Bitwarden is not consulted. # # Optional S3 mirror: set LCHAT_RELEASE_MIRROR_BUCKET (needs the aws CLI) and # every feed artifact is also copied to s3:///releases/… — GitHub's # asset CDN serves freshly-clobbered manifests stale (and new binaries 404) # for minutes after a publish, and it's the fleet's only update path, so the # mirror is a second boring home for the same signed bytes. Updaters fall back # to it via LCHAT_RELEASE_MIRROR_URL (docs/server-releases.md § The S3 mirror); # the feed's Ed25519 signature + sha256s make the mirror availability-only, # never trusted. Unset, this script is unchanged. set -eu # The Bitwarden item names this script reads — the vault contract. # shellcheck source=deploy/vault-items.sh . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/vault-items.sh" # shellcheck source=deploy/bw-lib.sh . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/bw-lib.sh" REPO="${LCHAT_RELEASES_REPO:-elevenmessenger/releases}" # Per-commit server- build artifacts live in their own public repo so the # RELEASES repo's page stays human (Mac app, the rolling feed, enterprise) — # these are dev builds, not product releases (Paul, 2026-07-22). Public # because the fleet and self-hosters download tokenless. Builds published # before the split stay on $REPO forever — promote resolves per version, so # old pins and rollbacks never break. BUILDS_REPO="${LCHAT_BUILDS_REPO:-elevenmessenger/builds}" KEY="${LCHAT_RELEASE_KEY_FILE:-}" MIRROR_BUCKET="${LCHAT_RELEASE_MIRROR_BUCKET:-}" BIN=lchat-linux-amd64 BIN_ARM=lchat-linux-arm64 # The darwin builds exist for the website's `lchat try` download (Macs hit # "exec format error" on a linux binary) — the fleet and the channel manifest # stay linux-only; these just ride the releases as extra assets. BIN_MAC=lchat-darwin-arm64 BIN_MAC_X64=lchat-darwin-amd64 TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT # ship only drives the box over ssh — no signing, no feed writes — so the key # and the gh/jq toolchain are required only where the feed is touched # (publish/promote, checked below). case "${1:-}" in publish|promote) # No key provided any other way? If the Bitwarden CLI is present, pull the # signing seed from there — bw is the standing store for operator secrets, so # neither publish NOR promote should need a hand-exported key (both re-sign the # feed, so both need it). Unlocks interactively if the vault is locked; the # seed lands in the env only, never on disk (same property as the explicit # LCHAT_RELEASE_KEY flow). No bw and no key set → the original error below. if [ -z "${LCHAT_RELEASE_KEY:-}" ] && [ -z "$KEY" ] \ && command -v bw >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then # The item has no password field — the seed rides a hidden custom field, # picked out as the only >40-char value (matches the header's incantation). # bw's API calls carry no timeout of their own and can stall for good on a # bad connection or stale session (observed 2026-07-21: a promote sat 14 # silent minutes inside `bw get`), so bw runs under perl's alarm — the # never-a-hang promise, enforced. The seed stays in the pipe, never on # disk. bw_stalled distinguishes a killed 30s read from a fast empty one # (vault locked), which still falls through to the unlock prompt below. bw_key() { bw_run 30 get item "$BW_ITEM_SIGNING_KEY" 2>/dev/null \ | jq -r '.fields[]? | select((.value|length)>40).value' | head -1; } bw_stalled() { # $1 = start epoch; empty result + ≥30s elapsed = the watchdog fired [ $(( $(date +%s) - $1 )) -ge 30 ] || return 1 echo "release-server: the Bitwarden CLI stalled for 30s reading '$BW_ITEM_SIGNING_KEY' — run 'bw sync' and retry (or export LCHAT_RELEASE_KEY)" >&2 } echo "release-server: reading the signing key from Bitwarden…" >&2 _t0="$(date +%s)" LCHAT_RELEASE_KEY="$(bw_key)" if [ -z "$LCHAT_RELEASE_KEY" ]; then bw_stalled "$_t0" && exit 1 # A fast empty read means the vault is locked (or this shell has no # session): unlock — prompts for the master password on the tty — then # read again. echo "release-server: unlocking Bitwarden to read the signing key…" >&2 # Our prompt, never bw's; watchdogged — the whole policy lives in # bw-lib.sh (set -e: die with a hint, not silently). BW_SESSION="$(bw_unlock_session)" && export BW_SESSION || { echo "release-server: set LCHAT_RELEASE_KEY (or export BW_SESSION) and retry" >&2; exit 1; } _t0="$(date +%s)" LCHAT_RELEASE_KEY="$(bw_key)" [ -n "$LCHAT_RELEASE_KEY" ] || { bw_stalled "$_t0" && exit 1 || true; } fi [ -n "$LCHAT_RELEASE_KEY" ] || { echo "release-server: couldn't read '$BW_ITEM_SIGNING_KEY' from Bitwarden" >&2; exit 1; } export LCHAT_RELEASE_KEY # the signing tool (a child process) reads it from the env echo "release-server: signing key sourced from Bitwarden" >&2 fi if [ -n "${LCHAT_RELEASE_KEY:-}" ]; then # The seed itself is in the environment — the preferred flow: it goes # secret manager -> shell -> the signing tool's memory and never touches # disk. No key file anywhere below (release sign reads the env directly). KEY="" elif [ "$KEY" = "-" ]; then # Key arrives on stdin: park it in this run's private tmpdir (0700 from # mktemp -d, wiped by the trap) so nothing persists past the run. cat > "$TMP/signing.key" [ -s "$TMP/signing.key" ] || { echo "release-server: LCHAT_RELEASE_KEY_FILE=- but nothing arrived on stdin" >&2; exit 1; } KEY="$TMP/signing.key" else [ -n "$KEY" ] && [ -f "$KEY" ] || { 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; } fi command -v jq >/dev/null || { echo "release-server: jq is required" >&2; exit 1; } command -v gh >/dev/null || { echo "release-server: gh is required" >&2; exit 1; } [ -z "$MIRROR_BUCKET" ] || command -v aws >/dev/null || { echo "release-server: LCHAT_RELEASE_MIRROR_BUCKET is set but the aws CLI is missing" >&2; exit 1; } ;; esac # The signing/manifest code lives in the binary itself, so builder and box # can't drift on the format. Build a local tool from this checkout (only the # feed-touching commands need it). TOOL="$TMP/lchat-tool" case "${1:-}" in publish|promote) echo "release-server: building the signing tool (go build — a cold build takes a few minutes)…" >&2 go build -o "$TOOL" . ;; esac # fetch_payload: current feed payload JSON (verified), or a fresh skeleton. fetch_payload() { if gh release download server -R "$REPO" -p manifest.json -O "$TMP/manifest.json" 2>/dev/null; then "$TOOL" release payload --in "$TMP/manifest.json" else echo '{"schema":1,"channels":{}}' fi } # mirror_upload : copy an artifact into the S3 mirror (no-op when # LCHAT_RELEASE_MIRROR_BUCKET is unset). Best-effort by design: by the time # this runs the GitHub half has already landed, so a failed mirror write warns # instead of aborting a half-done publish — a lagging mirror is harmless # (updaters use whichever verified feed is fresher). mirror_upload() { [ -n "$MIRROR_BUCKET" ] || return 0 if aws s3 cp "$1" "s3://$MIRROR_BUCKET/releases/$2" >/dev/null; then echo "mirrored $2 -> s3://$MIRROR_BUCKET/releases/$2" else echo "release-server: WARNING: mirror upload of $2 failed — the S3 mirror lags until the next publish/promote" >&2 fi } # sign_and_upload: sign $1 (payload json) and clobber the rolling feed asset. # With LCHAT_RELEASE_KEY in the environment, KEY is empty and the tool reads # the seed straight from the env — no --key, no file. sign_and_upload() { echo "release-server: signing + uploading the feed…" >&2 if [ -n "$KEY" ]; then "$TOOL" release sign --key "$KEY" --in "$1" --out "$TMP/manifest.json" else "$TOOL" release sign --in "$1" --out "$TMP/manifest.json" fi gh release view server -R "$REPO" >/dev/null 2>&1 || \ gh release create server -R "$REPO" --title "Server release feed" --latest=false \ --notes "Rolling release: manifest.json is the channel feed instances update from. See the messenger repo's docs/server-releases.md." gh release upload server "$TMP/manifest.json" -R "$REPO" --clobber >/dev/null echo "release-server: manifest.json clobbered on the rolling release" >&2 "$TOOL" release verify --in "$TMP/manifest.json" # Both publish and promote come through here, so the mirror's feed tracks # every channel move, not just new builds. mirror_upload "$TMP/manifest.json" manifest.json refresh_release_page "$1" } # refresh_release_page: keep the releases repo human-readable. The per-version # server- releases are mechanical build artifacts (created --prerelease, # below, so they wear the grey badge and stay out of the way); the rolling # `server` release is the HUMAN surface, so on every publish/promote its notes # become a current-stack table: the channel pointers plus the latest Mac app. # Also asserts the repo invariant the enterprise release broke on 2026-07-19: # GitHub's "Latest" belongs to the MAC releases — Sparkle's baked-in SUFeedURL # is releases/latest/download/appcast.xml, so a Latest without appcast.xml # silently 404s every Mac's update check. Any release minted OUTSIDE this # script must pass --latest=false (or --prerelease); this check turns that # mistake from silent breakage into a loud warning on the next release op. refresh_release_page() { echo "release-server: refreshing the releases-page stack table…" >&2 LATEST_TAG="$(gh api "repos/$REPO/releases/latest" -q .tag_name 2>/dev/null || true)" if [ -n "$LATEST_TAG" ] && ! gh release view "$LATEST_TAG" -R "$REPO" --json assets \ -q '.assets[].name' 2>/dev/null | grep -qx "appcast.xml"; then echo "release-server: ⚠️ '$LATEST_TAG' holds GitHub's Latest badge but has no appcast.xml —" >&2 echo " Mac auto-update (releases/latest/download/appcast.xml) is 404ing RIGHT NOW." >&2 echo " Fix: gh release edit -R $REPO --latest" >&2 fi TABLE="$(jq -r ' "Current stack (refreshed on every publish/promote):\n\n" + "| Piece | Version |\n|---|---|\n" + ([.channels | to_entries[] | "| Server (" + .key + ") | " + .value.version + " |"] | join("\n")) ' "$1" 2>/dev/null || true)" MAC_TAG="$(gh release list -R "$REPO" --limit 40 --json tagName -q \ '[.[].tagName | select(startswith("v"))][0]' 2>/dev/null || true)" [ -n "$MAC_TAG" ] && TABLE="$TABLE | Mac app | $MAC_TAG |" TABLE="$TABLE | Services (connect/push/home) | ride the server version | manifest.json on this release is the signed channel feed instances update from — docs/server-releases.md." [ -n "$TABLE" ] && gh release edit server -R "$REPO" --notes "$TABLE" >/dev/null 2>&1 || true } cmd="${1:-}"; shift || true case "$cmd" in publish) # A release must correspond to landed main code — same rule as deploys. [ -z "$(git status --porcelain)" ] || { echo "release-server: working tree is dirty — release from clean main" >&2; exit 1; } VER="$(git rev-parse --short HEAD)" NOTES="${*:-$(git log -1 --pretty=%s)}" OUT="$TMP/$BIN" ./deploy/build-linux.sh "$VER" ARCH=arm64 OUT="$TMP/$BIN_ARM" ./deploy/build-linux.sh "$VER" OS=darwin ARCH=arm64 OUT="$TMP/$BIN_MAC" ./deploy/build-linux.sh "$VER" OS=darwin ARCH=amd64 OUT="$TMP/$BIN_MAC_X64" ./deploy/build-linux.sh "$VER" SHA="$(shasum -a 256 "$TMP/$BIN" | cut -d' ' -f1)" SIZE="$(wc -c < "$TMP/$BIN" | tr -d ' ')" SHA_ARM="$(shasum -a 256 "$TMP/$BIN_ARM" | cut -d' ' -f1)" SIZE_ARM="$(wc -c < "$TMP/$BIN_ARM" | tr -d ' ')" echo "$SHA $BIN" > "$TMP/$BIN.sha256" echo "$SHA_ARM $BIN_ARM" > "$TMP/$BIN_ARM.sha256" for M in "$BIN_MAC" "$BIN_MAC_X64"; do echo "$(shasum -a 256 "$TMP/$M" | cut -d' ' -f1) $M" > "$TMP/$M.sha256" done # Sibling-service binaries (connect broker, push relay, home server) ride the # release too, with their sha256s pinned INSIDE the signed manifest entry — # that's what lets the box's root update pass install them with no root SSH # (updaterservices.go). name:builddir pairs; assets are lchat--linux-. SERVICES="connect:broker push:push home:home" SVC_JSON="{}" SVC_ASSETS="" for SPEC in $SERVICES; do SNAME="${SPEC%%:*}"; SDIR="${SPEC##*:}" SB="lchat-$SNAME-linux-amd64"; SBA="lchat-$SNAME-linux-arm64" GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o "$TMP/$SB" "./$SDIR" GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o "$TMP/$SBA" "./$SDIR" echo "$(shasum -a 256 "$TMP/$SB" | cut -d' ' -f1) $SB" > "$TMP/$SB.sha256" echo "$(shasum -a 256 "$TMP/$SBA" | cut -d' ' -f1) $SBA" > "$TMP/$SBA.sha256" echo "built $VER (service $SNAME, amd64+arm64)" SVC_JSON="$(printf '%s' "$SVC_JSON" | jq --arg n "$SNAME" \ --arg url "https://github.com/$BUILDS_REPO/releases/download/server-$VER/$SB" \ --arg sha "$(cut -d' ' -f1 < "$TMP/$SB.sha256")" \ --argjson size "$(wc -c < "$TMP/$SB" | tr -d ' ')" \ --arg aurl "https://github.com/$BUILDS_REPO/releases/download/server-$VER/$SBA" \ --arg asha "$(cut -d' ' -f1 < "$TMP/$SBA.sha256")" \ --argjson asize "$(wc -c < "$TMP/$SBA" | tr -d ' ')" \ '.[$n] = {url:$url, sha256:$sha, size:$size, arm64_url:$aurl, arm64_sha256:$asha, arm64_size:$asize}')" SVC_ASSETS="$SVC_ASSETS $SB $SB.sha256 $SBA $SBA.sha256" done if gh release view "server-$VER" -R "$BUILDS_REPO" >/dev/null 2>&1; then echo "release-server: server-$VER already exists on $BUILDS_REPO (promote it, or land a new commit)" >&2; exit 1 fi # --latest=false: the repo's "latest" pointer belongs to the MAC app — # Sparkle's baked-in SUFeedURL is releases/latest/download/appcast.xml, and # GitHub hands "latest" to the newest plain release by default, so every # server publish was silently 404ing every Mac's update check (found # 2026-07-14: the feed pointed at server-414bdeb0, no appcast there). # --prerelease: build artifacts, not the headline — they wear the grey badge # so the releases page reads as "the things" (Mac app, the rolling feed) # instead of a wall of shas. Prereleases also can never take "Latest" # (which belongs to the Mac releases — see refresh_release_page). echo "release-server: creating server-$VER + uploading $((8 + $(echo $SVC_ASSETS | wc -w | tr -d ' '))) assets…" >&2 gh release create "server-$VER" -R "$BUILDS_REPO" --title "Server $VER" --notes "$NOTES" \ --prerelease --latest=false \ "$TMP/$BIN" "$TMP/$BIN.sha256" "$TMP/$BIN_ARM" "$TMP/$BIN_ARM.sha256" \ "$TMP/$BIN_MAC" "$TMP/$BIN_MAC.sha256" "$TMP/$BIN_MAC_X64" "$TMP/$BIN_MAC_X64.sha256" # One call for the service binaries — a call per asset printed ten anonymous # "Successfully uploaded 1 asset" lines and paid ten round trips. SVC_PATHS="" for A in $SVC_ASSETS; do SVC_PATHS="$SVC_PATHS $TMP/$A"; done # shellcheck disable=SC2086 # word-splitting the paths is the point gh release upload "server-$VER" $SVC_PATHS -R "$BUILDS_REPO" # Binaries mirror before the manifest that points at them, so the mirror # never serves a feed whose mirror-side binaries don't exist yet. Keys copy # the GitHub layout (server-/) — that's what the updater's # mirror fallback derives from a primary binary URL. for A in "$BIN" "$BIN.sha256" "$BIN_ARM" "$BIN_ARM.sha256" \ "$BIN_MAC" "$BIN_MAC.sha256" "$BIN_MAC_X64" "$BIN_MAC_X64.sha256" \ $SVC_ASSETS; do mirror_upload "$TMP/$A" "server-$VER/$A" done # The human version (deploy/SERVER_SEMVER) rides along as `semver` — the # sha stays the artifact id. It's what major pinning (update_major) # evaluates on the box; a missing file publishes without one, which HOLDS # any major-pinned instance (fail closed), so keep the file current. SEMVER="$(tr -d '[:space:]' < deploy/SERVER_SEMVER 2>/dev/null || true)" [ -n "$SEMVER" ] || echo "release-server: note: no deploy/SERVER_SEMVER — publishing without a semver (major-pinned instances will hold this release)" >&2 fetch_payload | jq \ --arg ver "$VER" --arg sha "$SHA" --argjson size "$SIZE" --arg notes "$NOTES" \ --arg sha_arm "$SHA_ARM" --argjson size_arm "$SIZE_ARM" \ --arg url "https://github.com/$BUILDS_REPO/releases/download/server-$VER/$BIN" \ --arg url_arm "https://github.com/$BUILDS_REPO/releases/download/server-$VER/$BIN_ARM" \ --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg semver "$SEMVER" \ --argjson svc "$SVC_JSON" \ '.channels.edge = ({version:$ver, url:$url, sha256:$sha, size:$size, arm64_url:$url_arm, arm64_sha256:$sha_arm, arm64_size:$size_arm, notes:$notes, published_at:$now, services:$svc} + (if $semver == "" then {} else {semver:$semver} end))' \ > "$TMP/payload.json" sign_and_upload "$TMP/payload.json" # The installer rides the rolling release too, so its URL is always current. if [ -f deploy/install.sh ]; then gh release upload server deploy/install.sh -R "$REPO" --clobber fi # The self-hosting guide is the public downloads repo's README (this source # repo is private, so strangers can't read docs/ here) — synced on publish. if [ -f docs/self-hosting.md ]; then README_SHA="$(gh api "repos/$REPO/contents/README.md" --jq .sha 2>/dev/null || true)" gh api -X PUT "repos/$REPO/contents/README.md" \ -f message="sync self-hosting guide (messenger @$VER)" \ -f content="$(base64 < docs/self-hosting.md | tr -d '\n')" \ ${README_SHA:+-f sha="$README_SHA"} >/dev/null echo "synced self-hosting guide -> $REPO README" fi echo "published server-$VER and pointed edge at it" ;; promote) VER="${1:-}"; CHAN="${2:-}" case "$CHAN" in edge|beta|stable) ;; *) echo "usage: release-server.sh promote edge|beta|stable" >&2; exit 1;; esac echo "promote: fetching the current feed…" >&2 fetch_payload > "$TMP/payload.json" # Which repo holds this version's build: builds (post-split) or releases # (published before the split — those releases stay put so nothing breaks). VREPO="$BUILDS_REPO" gh release view "server-$VER" -R "$VREPO" >/dev/null 2>&1 || VREPO="$REPO" # Prefer the entry already on a channel (usually promoting edge -> beta -> # stable); otherwise rebuild it from the server- release's own assets # (arm64 fields only when that release has an arm64 build — pre-arm64 # releases stay amd64-only). ENTRY="$(jq --arg ver "$VER" '[.channels[] | select(.version == $ver)][0] // empty' "$TMP/payload.json")" if [ -z "$ENTRY" ]; then # A rebuilt entry can't recover the semver the build was published with — # major-pinned instances hold semver-less releases, so say so. echo "release-server: note: rebuilding the $VER entry from release assets — it will carry no semver (major-pinned instances will hold it)" >&2 gh release download "server-$VER" -R "$VREPO" -p "$BIN.sha256" -O "$TMP/$BIN.sha256" || { echo "release-server: no server-$VER release on $VREPO" >&2; exit 1; } SHA="$(cut -d' ' -f1 < "$TMP/$BIN.sha256")" SIZE="$(gh release view "server-$VER" -R "$VREPO" --json assets \ --jq ".assets[] | select(.name == \"$BIN\") | .size")" NOTES="$(gh release view "server-$VER" -R "$VREPO" --json body --jq .body)" ENTRY="$(jq -n --arg ver "$VER" --arg sha "$SHA" --argjson size "${SIZE:-0}" --arg notes "$NOTES" \ --arg url "https://github.com/$VREPO/releases/download/server-$VER/$BIN" \ --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ '{version:$ver, url:$url, sha256:$sha, size:$size, notes:$notes, published_at:$now}')" if gh release download "server-$VER" -R "$VREPO" -p "$BIN_ARM.sha256" -O "$TMP/$BIN_ARM.sha256" 2>/dev/null; then SHA_ARM="$(cut -d' ' -f1 < "$TMP/$BIN_ARM.sha256")" SIZE_ARM="$(gh release view "server-$VER" -R "$VREPO" --json assets \ --jq ".assets[] | select(.name == \"$BIN_ARM\") | .size")" ENTRY="$(printf '%s' "$ENTRY" | jq --arg sha "$SHA_ARM" --argjson size "${SIZE_ARM:-0}" \ --arg url "https://github.com/$VREPO/releases/download/server-$VER/$BIN_ARM" \ '. + {arm64_url:$url, arm64_sha256:$sha, arm64_size:$size}')" fi fi jq --arg chan "$CHAN" --argjson entry "$ENTRY" '.channels[$chan] = $entry' \ "$TMP/payload.json" > "$TMP/payload2.json" sign_and_upload "$TMP/payload2.json" # Stable also refreshes the rolling release's "latest stable" binaries — the # fixed, JSON-free URLs deploy/install.sh downloads. if [ "$CHAN" = stable ]; then mkdir -p "$TMP/stable" # Download what exists, name it, then push everything in ONE upload call — # a call per asset printed eight anonymous "Successfully uploaded 1 asset" # lines (gh never says which), the papercut promote kept after publish # lost it. ROLLING="" for A in "$BIN" "$BIN.sha256" "$BIN_ARM" "$BIN_ARM.sha256" \ "$BIN_MAC" "$BIN_MAC.sha256" "$BIN_MAC_X64" "$BIN_MAC_X64.sha256"; do if gh release download "server-$VER" -R "$VREPO" -p "$A" -O "$TMP/stable/$A" 2>/dev/null; then ROLLING="$ROLLING $TMP/stable/$A" echo "promote: $A" >&2 else echo "promote: $A — server-$VER has none; rolling copy not refreshed" >&2 fi done if [ -n "$ROLLING" ]; then echo "promote: refreshing $(echo "$ROLLING" | wc -w | tr -d ' ') rolling latest-stable assets (one upload)…" >&2 # shellcheck disable=SC2086 # word-splitting the paths is the point gh release upload server $ROLLING -R "$REPO" --clobber >/dev/null fi fi echo "promoted $VER to $CHAN" ;; ship) # --expedite: pass --ignore-bake through to the box updater — the deliberate # operator override of the supply-chain bake window (docs/server-releases.md), # for shipping an incident fix the moment it's promoted. The hold is the # default for a reason; expediting is a stated decision, never routine. EXPEDITE="" if [ "${1:-}" = "--expedite" ]; then EXPEDITE="--ignore-bake"; shift; fi VER="${1:-$(git rev-parse --short HEAD)}" BOX="${LCHAT_BOX:-}" [ -n "$BOX" ] || { echo "release-server: set LCHAT_BOX (e.g. ; user comes from ssh config) to ship" >&2; exit 1; } echo "ship: updating instances on $BOX until current (feed target: $VER${EXPEDITE:+, expedited past the bake window})" DEADLINE=$(( $(date +%s) + 600 )) while :; do ssh "$BOX" sudo -n /opt/lchat/lchat update run $EXPEDITE || true STATUS="$(ssh "$BOX" sudo -n /opt/lchat/lchat update status || true)" # Done when no un-pinned instance with a known channel head is behind, and # at least one row proves a feed landed at all. Pinned rows are someone's # canary: reported, never waited on. Held rows (a beta/stable head still # inside its supply-chain bake window) are likewise reported, not waited # on — the hold is measured in days and ship's deadline in minutes. BEHIND="$(printf '%s\n' "$STATUS" | awk 'NR>1 && $3!="pinned" && $5!="-" && $0 !~ /\(current\)/ && $0 !~ /\(held/ {print $1}')" if [ -z "$BEHIND" ] && printf '%s\n' "$STATUS" | grep -q "(current)"; then printf '%s\n' "$STATUS" printf '%s\n' "$STATUS" | awk 'NR>1 && $3=="pinned" {print "note: " $1 " is pinned (a canary) - left alone"}' printf '%s\n' "$STATUS" | awk 'NR>1 && /\(held/ {print "note: " $1 " is holding its channel head (safety hold) - not waited on"}' if printf '%s\n' "$STATUS" | grep -q "$VER (current)"; then echo "shipped $VER - every un-pinned instance is current" else echo "shipped - every un-pinned instance is current on its channel (none rides a channel pointing at $VER yet)" fi exit 0 fi # Nothing behind and nothing current can still be a clean stop: every # remaining row is inside its safety hold (e.g. the whole fleet rides # stable right after a promote). They install when the window elapses. if [ -z "$BEHIND" ] && printf '%s\n' "$STATUS" | grep -q "(held"; then printf '%s\n' "$STATUS" 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 --version )" exit 0 fi if [ "$(date +%s)" -ge "$DEADLINE" ]; then printf '%s\n' "$STATUS" echo "ship: gave up after 10m - still behind: $BEHIND (GitHub CDN can serve a fresh manifest stale; retry in a few minutes)" >&2 exit 1 fi echo "ship: waiting for the feed/instances (behind: ${BEHIND:-feed})..." sleep 30 done ;; *) echo "usage: release-server.sh publish [notes...] | promote edge|beta|stable | ship [ver]" >&2 exit 1 ;; esac