eleven / messenger Public

server: linux/arm64 through the release pipeline (#431)

* server: linux/arm64 through the release pipeline

Self-hosters on ARM (cheap VPSes, Raspberry Pi) had no binary and no
auto-updates — the pipeline was amd64-only. Backward-compatible: the flat
url/sha256 manifest fields stay amd64 (every fleet updater already reads
them); arm64 rides in additive optional fields old binaries ignore
(verified against the pre-arm64 release binary).

- release.go: releaseEntry arm64_url/arm64_sha256/arm64_size + forArch();
  validateManifest applies the same https/hex rules when they're present
- updater.go: downloads resolve via forArch(runtime.GOARCH) — an arm64 box
  facing a pre-arm64 release skips it (never installs the wrong arch)
- build-linux.sh: ARCH env (default amd64 unchanged)
- release-server.sh: publish builds+uploads both arches and writes both
  into the edge entry; promote carries or reconstructs the arm64 fields;
  promoting to STABLE also clobbers latest-stable binaries onto the rolling
  'server' release (fixed JSON-free URLs for the upcoming installer), and
  publish uploads deploy/install.sh to the rolling release when present

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server: pin updaterArch in the test feed fixture (deterministic on arm64 dev machines)

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 98a9fd3bed6ffbbe25f487bc637d3f7db9d46f4f parent d174815
6 files changed, +228 −20
  • deploy/build-linux.sh +7 −5
  • deploy/release-server.sh +39 −3
  • release.go +33 −4
  • release_test.go +43 −0
  • updater.go +16 −5
  • updater_test.go +90 −3
diff --git a/deploy/build-linux.sh b/deploy/build-linux.sh
index 7d0f4ff..7bf40e5 100755
--- a/deploy/build-linux.sh
+++ b/deploy/build-linux.sh
@@ -1,10 +1,11 @@
#!/usr/bin/env sh
-# Build the linux/amd64 lchat binary for the router box, stamped with a build id
+# Build the linux lchat binary for the router box, stamped with a build id
# (the git short sha, or $1) so `lchat stage`/`deploy --version` can identify and
# health-check it. Prints the build id and the output path.
#
-# ./deploy/build-linux.sh # id = git short sha
+# ./deploy/build-linux.sh # amd64, id = git short sha
# ./deploy/build-linux.sh test-scroll # id = test-scroll (e.g. a canary label)
+# ARCH=arm64 ./deploy/build-linux.sh # linux/arm64 (self-hosters on ARM)
# OUT=/tmp/lchat ./deploy/build-linux.sh
set -eu
@@ -13,9 +14,10 @@ if [ -z "$VER" ]; then
echo "build-linux: could not determine a build id (pass one as \$1)" >&2
exit 1
fi
-OUT="${OUT:-lchat-linux-amd64}"
+ARCH="${ARCH:-amd64}"
+OUT="${OUT:-lchat-linux-$ARCH}"
-GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
+GOOS=linux GOARCH="$ARCH" CGO_ENABLED=0 \
go build -ldflags "-X main.buildVersion=${VER}" -o "$OUT" .
-echo "built ${VER} -> ${OUT}"
+echo "built ${VER} (${ARCH}) -> ${OUT}"
diff --git a/deploy/release-server.sh b/deploy/release-server.sh
index a5bea62..bcc55d6 100755
--- a/deploy/release-server.sh
+++ b/deploy/release-server.sh
@@ -18,6 +18,7 @@ set -eu
REPO="${LCHAT_RELEASES_REPO:-elevenmessenger/releases}"
KEY="${LCHAT_RELEASE_KEY_FILE:-}"
BIN=lchat-linux-amd64
+BIN_ARM=lchat-linux-arm64
[ -n "$KEY" ] && [ -f "$KEY" ] || {
echo "release-server: set LCHAT_RELEASE_KEY_FILE to the signing key file" >&2; exit 1; }
@@ -60,23 +61,35 @@ publish)
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"
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"
if gh release view "server-$VER" -R "$REPO" >/dev/null 2>&1; then
echo "release-server: server-$VER already exists on $REPO (promote it, or land a new commit)" >&2; exit 1
fi
gh release create "server-$VER" -R "$REPO" --title "Server $VER" --notes "$NOTES" \
- "$TMP/$BIN" "$TMP/$BIN.sha256"
+ "$TMP/$BIN" "$TMP/$BIN.sha256" "$TMP/$BIN_ARM" "$TMP/$BIN_ARM.sha256"
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/$REPO/releases/download/server-$VER/$BIN" \
+ --arg url_arm "https://github.com/$REPO/releases/download/server-$VER/$BIN_ARM" \
--arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
- '.channels.edge = {version:$ver, url:$url, sha256:$sha, size:$size, notes:$notes, published_at:$now}' \
+ '.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}' \
> "$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
echo "published server-$VER and pointed edge at it"
;;
@@ -87,7 +100,9 @@ promote)
fetch_payload > "$TMP/payload.json"
# Prefer the entry already on a channel (usually promoting edge -> beta ->
- # stable); otherwise rebuild it from the server-<ver> release's own assets.
+ # stable); otherwise rebuild it from the server-<ver> 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
gh release download "server-$VER" -R "$REPO" -p "$BIN.sha256" -O "$TMP/$BIN.sha256" || {
@@ -100,10 +115,31 @@ promote)
--arg url "https://github.com/$REPO/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 "$REPO" -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 "$REPO" --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/$REPO/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"
+ for A in "$BIN" "$BIN.sha256" "$BIN_ARM" "$BIN_ARM.sha256"; do
+ if gh release download "server-$VER" -R "$REPO" -p "$A" -O "$TMP/stable/$A" 2>/dev/null; then
+ gh release upload server "$TMP/stable/$A" -R "$REPO" --clobber
+ else
+ echo "note: server-$VER has no $A — rolling copy not refreshed" >&2
+ fi
+ done
+ fi
echo "promoted $VER to $CHAN"
;;
diff --git a/release.go b/release.go
index 208627d..581be52 100644
--- a/release.go
+++ b/release.go
@@ -62,14 +62,34 @@ var releaseManifestURL = envOr("LCHAT_UPDATE_MANIFEST_URL",
"https://github.com/elevenmessenger/releases/releases/download/server/manifest.json")
type releaseEntry struct {
- Version string `json:"version"`
- URL string `json:"url"`
- SHA256 string `json:"sha256"`
- Size int64 `json:"size,omitempty"`
+ Version string `json:"version"`
+ // The flat fields are the amd64 binary — kept flat (not per-arch keyed) so
+ // manifests stay readable by every updater already in the fleet.
+ URL string `json:"url"`
+ SHA256 string `json:"sha256"`
+ Size int64 `json:"size,omitempty"`
+ // Optional arm64 build of the same version; absent on releases published
+ // before arm64 support (an arm64 box then skips the version, never
+ // installs the wrong arch).
+ ARM64URL string `json:"arm64_url,omitempty"`
+ ARM64SHA256 string `json:"arm64_sha256,omitempty"`
+ ARM64Size int64 `json:"arm64_size,omitempty"`
Notes string `json:"notes,omitempty"`
PublishedAt string `json:"published_at,omitempty"`
}
+// forArch resolves the binary for a GOARCH: amd64 is the flat fields, arm64
+// the optional ones (ok=false when a release has no build for that arch).
+func (e releaseEntry) forArch(goarch string) (url, sha string, ok bool) {
+ switch goarch {
+ case "amd64":
+ return e.URL, e.SHA256, true
+ case "arm64":
+ return e.ARM64URL, e.ARM64SHA256, e.ARM64URL != ""
+ }
+ return "", "", false
+}
+
type releaseManifest struct {
Schema int `json:"schema"`
GeneratedAt time.Time `json:"generated_at"`
@@ -114,6 +134,15 @@ func validateManifest(m *releaseManifest) error {
if raw, err := hex.DecodeString(e.SHA256); err != nil || len(raw) != sha256.Size {
return fmt.Errorf("channel %s: sha256 must be %d hex chars", ch, sha256.Size*2)
}
+ // The arm64 build is optional, but when present it obeys the same rules.
+ if e.ARM64URL != "" || e.ARM64SHA256 != "" {
+ if !strings.HasPrefix(e.ARM64URL, "https://") && !isLoopbackURL(e.ARM64URL) {
+ return fmt.Errorf("channel %s: arm64 URL must be https (%q)", ch, e.ARM64URL)
+ }
+ if raw, err := hex.DecodeString(e.ARM64SHA256); err != nil || len(raw) != sha256.Size {
+ return fmt.Errorf("channel %s: arm64 sha256 must be %d hex chars", ch, sha256.Size*2)
+ }
+ }
}
return nil
}
diff --git a/release_test.go b/release_test.go
index b7d5e3c..b2e249a 100644
--- a/release_test.go
+++ b/release_test.go
@@ -110,6 +110,49 @@ func TestValidateManifestRejectsBadEntries(t *testing.T) {
}
}
+func TestReleaseEntryForArch(t *testing.T) {
+ both := releaseEntry{
+ URL: "https://x/amd64", SHA256: strings.Repeat("aa", 32),
+ ARM64URL: "https://x/arm64", ARM64SHA256: strings.Repeat("bb", 32),
+ }
+ if u, s, ok := both.forArch("amd64"); !ok || u != "https://x/amd64" || s != strings.Repeat("aa", 32) {
+ t.Fatalf("amd64 = %q %q %v", u, s, ok)
+ }
+ if u, s, ok := both.forArch("arm64"); !ok || u != "https://x/arm64" || s != strings.Repeat("bb", 32) {
+ t.Fatalf("arm64 = %q %q %v", u, s, ok)
+ }
+ // A pre-arm64 entry: amd64 resolves, arm64 says "no build".
+ amdOnly := releaseEntry{URL: "https://x/amd64", SHA256: strings.Repeat("aa", 32)}
+ if _, _, ok := amdOnly.forArch("arm64"); ok {
+ t.Fatal("arm64 resolved on an amd64-only entry")
+ }
+ if _, _, ok := amdOnly.forArch("riscv64"); ok {
+ t.Fatal("unknown arch resolved")
+ }
+}
+
+func TestValidateManifestChecksARM64Fields(t *testing.T) {
+ m := testManifest()
+ e := m.Channels["edge"]
+ e.ARM64URL = "http://insecure.example/lchat"
+ e.ARM64SHA256 = strings.Repeat("cd", 32)
+ m.Channels["edge"] = e
+ if err := validateManifest(m); err == nil {
+ t.Fatal("http arm64 URL accepted")
+ }
+ e.ARM64URL = "https://ok.example/lchat"
+ e.ARM64SHA256 = "short"
+ m.Channels["edge"] = e
+ if err := validateManifest(m); err == nil {
+ t.Fatal("bad arm64 sha accepted")
+ }
+ e.ARM64SHA256 = strings.Repeat("cd", 32)
+ m.Channels["edge"] = e
+ if err := validateManifest(m); err != nil {
+ t.Fatalf("good arm64 entry rejected: %v", err)
+ }
+}
+
func TestReadReleaseKeyRoundTrip(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
path := filepath.Join(t.TempDir(), "key.b64")
diff --git a/updater.go b/updater.go
index 8bcc39a..c15766b 100644
--- a/updater.go
+++ b/updater.go
@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "runtime"
"strings"
"time"
)
@@ -114,10 +115,20 @@ func fetchUpdatePrefs(socket string) (updatePrefs, error) {
return p, nil
}
-// downloadReleaseBinary fetches a release into the incoming dir and refuses
-// anything whose sha256 doesn't match the signed manifest entry. Returns the
-// temp path; the caller stages it and removes the temp.
+// updaterArch is the architecture this box runs — a var so tests can exercise
+// the arm64 resolution without faking runtime.GOARCH.
+var updaterArch = runtime.GOARCH
+
+// downloadReleaseBinary fetches this box's arch of a release into the incoming
+// dir and refuses anything whose sha256 doesn't match the signed manifest
+// entry. Returns the temp path; the caller stages it and removes the temp.
func downloadReleaseBinary(e releaseEntry) (string, error) {
+ url, sha, ok := e.forArch(updaterArch)
+ if !ok {
+ // An arm64 box facing a pre-arm64 release: skip, never install the
+ // wrong arch. The next release publishes both and the box catches up.
+ return "", fmt.Errorf("release %s has no %s build — skipping", e.Version, updaterArch)
+ }
if err := os.MkdirAll(incomingDir(), 0o755); err != nil {
return "", err
}
@@ -131,7 +142,7 @@ func downloadReleaseBinary(e releaseEntry) (string, error) {
return "", err
}
client := &http.Client{Timeout: 10 * time.Minute}
- resp, err := client.Get(e.URL)
+ resp, err := client.Get(url)
if err != nil {
return fail(err)
}
@@ -143,7 +154,7 @@ func downloadReleaseBinary(e releaseEntry) (string, error) {
if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil {
return fail(err)
}
- if got := hex.EncodeToString(h.Sum(nil)); got != e.SHA256 {
+ if got := hex.EncodeToString(h.Sum(nil)); got != sha {
return fail(fmt.Errorf("download %s: sha256 mismatch (refusing to stage)", e.Version))
}
return f.Name(), nil
diff --git a/updater_test.go b/updater_test.go
index 14edd4a..aa01bdb 100644
--- a/updater_test.go
+++ b/updater_test.go
@@ -82,6 +82,12 @@ func startFeed(t *testing.T, binaries map[string]string) (*httptest.Server, func
ver := strings.TrimPrefix(r.URL.Path, "/bin/")
w.Write([]byte(binaries[ver]))
})
+ // Each version's arm64 build is distinct bytes, so a test can prove the
+ // right arch was staged.
+ mux.HandleFunc("/arm/", func(w http.ResponseWriter, r *http.Request) {
+ ver := strings.TrimPrefix(r.URL.Path, "/arm/")
+ w.Write([]byte(binaries[ver] + "@arm64"))
+ })
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
@@ -90,14 +96,24 @@ func startFeed(t *testing.T, binaries map[string]string) (*httptest.Server, func
releaseManifestURL = ts.URL + "/manifest.json"
t.Cleanup(func() { releasePubKeyB64, releaseManifestURL = oldKey, oldURL })
+ // Pin the box arch so feed tests are deterministic on any dev machine
+ // (an arm64 Mac would otherwise resolve the arm64 variant everywhere);
+ // arch-specific tests override AFTER calling startFeed.
+ prevArch := updaterArch
+ updaterArch = "amd64"
+ t.Cleanup(func() { updaterArch = prevArch })
+
setChannels := func(channels map[string]string) {
m := &releaseManifest{Channels: map[string]releaseEntry{}}
for ch, ver := range channels {
sum := sha256.Sum256([]byte(binaries[ver]))
+ armSum := sha256.Sum256([]byte(binaries[ver] + "@arm64"))
m.Channels[ch] = releaseEntry{
- Version: ver,
- URL: ts.URL + "/bin/" + ver,
- SHA256: hex.EncodeToString(sum[:]),
+ Version: ver,
+ URL: ts.URL + "/bin/" + ver,
+ SHA256: hex.EncodeToString(sum[:]),
+ ARM64URL: ts.URL + "/arm/" + ver,
+ ARM64SHA256: hex.EncodeToString(armSum[:]),
}
}
w, err := signReleaseManifest(priv, m)
@@ -308,6 +324,77 @@ func TestUpdatePassDryRunTouchesNothing(t *testing.T) {
}
}
+func TestUpdatePassStagesTheRightArch(t *testing.T) {
+ withTempOpt(t)
+ stubSystemctl(t)
+
+ _, setChannels := startFeed(t, map[string]string{"new1234": "binary-content"})
+ setChannels(map[string]string{"stable": "new1234"})
+ // After startFeed (which pins amd64): this box is arm64.
+ updaterArch = "arm64"
+
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
+ seedInstance(t, reg, "h1.example.com", s1, "old1111")
+
+ if err := updatePass(reg, "lchat@", false); err != nil {
+ t.Fatal(err)
+ }
+ staged, err := os.ReadFile(versionBinPath("new1234"))
+ if err != nil || string(staged) != "binary-content@arm64" {
+ t.Fatalf("arm64 box staged %q, %v — must be the arm64 build", staged, err)
+ }
+ if v, _ := liveVersion("h1.example.com"); v != "new1234" {
+ t.Fatalf("arm64 apply didn't land: live = %q", v)
+ }
+}
+
+func TestUpdatePassSkipsArchlessRelease(t *testing.T) {
+ withTempOpt(t)
+ calls := stubSystemctl(t)
+ prevArch := updaterArch
+ updaterArch = "arm64"
+ t.Cleanup(func() { updaterArch = prevArch })
+
+ // A pre-arm64 manifest: flat amd64 fields only. An arm64 box must skip it
+ // rather than install the wrong arch.
+ pub, priv, _ := ed25519.GenerateKey(rand.Reader)
+ feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ wrapper, _ := signReleaseManifest(priv, &releaseManifest{Channels: map[string]releaseEntry{
+ "stable": {Version: "new1234", URL: "https://example.com/lchat", SHA256: strings.Repeat("ab", 32)},
+ }})
+ w.Write(wrapper)
+ }))
+ defer feed.Close()
+ oldKey, oldURL := releasePubKeyB64, releaseManifestURL
+ releasePubKeyB64 = base64.StdEncoding.EncodeToString(pub)
+ releaseManifestURL = feed.URL
+ t.Cleanup(func() { releasePubKeyB64, releaseManifestURL = oldKey, oldURL })
+
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "")
+ seedInstance(t, reg, "h1.example.com", s1, "old1111")
+
+ if err := updatePass(reg, "lchat@", false); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := liveVersion("h1.example.com"); v != "old1111" {
+ t.Fatalf("arm64 box moved to an amd64-only release: %q", v)
+ }
+ if _, err := os.Stat(versionBinPath("new1234")); !os.IsNotExist(err) {
+ t.Fatal("amd64 binary staged on an arm64 box")
+ }
+ if len(*calls) != 0 {
+ t.Fatalf("restarts on a skipped release: %v", *calls)
+ }
+}
+
func TestUpdatePassSkipsPinnedInstance(t *testing.T) {
withTempOpt(t)
calls := stubSystemctl(t)