Files
thoughtsync/packaging/fetch-clients.sh
T
bvandeusenandClaude Opus 5 72968897ab
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m13s
channels: the dev channel publishes on dev-rolling, so its tag stops shadowing the branch
The rolling dev release lived on a tag named `dev`, beside the branch
named `dev`. Once a clone had fetched tags, `git push origin dev` failed
with "src refspec dev matches more than one" (Scribe #2184, note #3042),
and every session had to know to spell out refs/heads/dev.

The channel is still `dev` everywhere a person sees it: the app's
setting, `install.sh --channel dev`, the stored pref. Only the release
tag moves, to `dev-rolling`, matching roundtable-android. `stable` has no
branch to collide with and keeps its name.

- packaging/channel-tag.sh is the one channel -> tag mapping CI reads:
  the publish steps in android.yml and desktop.yml, the manifest job,
  fetch-clients.sh and guard-forward.sh. guard-forward exits 2 on an
  unmapped channel instead of fetching an empty URL and passing.
- update.rs and install.sh carry their own copy because neither can run
  it; update.rs gains a test that no channel feed is named like a branch.
- tests/test_channel_tag.py runs the script: no tag is a branch name,
  dev is exactly dev-rolling, an unknown channel fails with no output.
- publish-release.sh titles the release "ThoughtSync dev (rolling)", so
  the tag name does not leak into what people read.

TEMPORARY bridge: desktop apps installed before this have
.../download/dev/latest.json compiled in. The dev manifest job sets
BRIDGE_TAG=dev, and write-manifest.sh writes the same latest.json to
the old `dev` release. Its URLs name dev-rolling assets, so those apps
update once into a build that reads the new tag. The bridge, and the old
release and tag, are removed once installed apps have crossed over.
Until then the push still needs the explicit refspec, as
ci-requirements.md now says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
2026-09-10 18:34:11 -04:00

170 lines
7.5 KiB
Bash
Executable File

#!/usr/bin/env sh
#
# Collect every client this image should hand out, into one directory.
#
# fetch-clients.sh <dev|stable> <destdir>
#
# The server serves clients from `DATA_DIR/client/` or from the copy baked into the
# image (`client_dist.py`). This is what fills the second one. It runs in CI, right
# before `docker build`, and writes the FIXED filenames that module looks for.
#
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A `:dev` image serves dev clients;
# `:latest` serves stable ones. Passed in rather than derived here, because the
# caller is the thing that knows which image it is building.
#
# NEVER FAILS. A platform with nothing published means the server advertises
# nothing for it and the UI hides that download — a supported state, and the only
# one available before a platform's first build has ever published. Turning eight
# fetches into eight ways to redden an otherwise fine lane would be strictly worse
# than shipping an image that offers four clients instead of five.
#
# WHY THE VERSION IS FETCHED AND NOT DERIVED. The obvious shortcut is to run
# `version.sh display desktop` here — this job has the checkout, after all. It is
# wrong: this commit may not be the commit the channel is serving. A push touching
# only `src/` does not rebuild the desktop, so the channel still holds an older
# build, and a locally-derived version would describe those bytes with this
# commit's number. The size check in `client_dist.py` would not catch it, because
# the size IS measured from the real file — it would sail through and lie about the
# version only. So the version comes from the channel, beside the bytes it
# describes, and only `size`/`sha256` are measured here.
set -eu
channel="${1:?usage: fetch-clients.sh <dev|stable> <destdir>}"
dest="${2:?usage: fetch-clients.sh <dev|stable> <destdir>}"
case "$channel" in dev|stable) : ;; *)
echo "fetch-clients.sh: unknown channel '$channel'" >&2; exit 2 ;;
esac
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}"
# The channel's release tag, not its name — `dev` lives on `dev-rolling`
# (packaging/channel-tag.sh, Scribe #2184).
tag="$(sh "$(dirname "$0")/channel-tag.sh" "$channel")"
BASE="$SERVER/$REPO/releases/download/$tag"
mkdir -p "$dest"
# Authenticated when we have a token — these releases are private (issue 2091), so
# on this instance we always do. Anonymous still works against a public fork.
fetch() {
if [ -n "${GITHUB_TOKEN:-}" ]; then
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "$2" "$1"
else
curl -fsSL -o "$2" "$1"
fi
}
# One field out of a small flat JSON object. `grep`/`sed` rather than a parser
# because this runs in the CI image's busybox sh and adding a jq dependency to buy
# one string is not a trade worth making. The sidecars are written by us and are
# one level deep.
field() {
grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"?[^,\"}]+\"?" "$1" 2>/dev/null \
| head -1 | sed -E 's/.*:[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/'
}
bytes() { wc -c < "$1" | tr -d ' '; }
digest() { sha256sum "$1" | cut -d' ' -f1; }
# The sidecar shape `client_dist.py` reads. `size` and `sha256` are measured from
# the file that actually landed, so a truncated download cannot be described as a
# whole one.
sidecar() {
_file="$1"; _out="$2"; _name="$3"; _code="$4"
# `version_code` is QUOTED here, and that is not a slip. This function only ever
# writes DESKTOP sidecars, whose ordering key is Tauri's `1.0.<minutes>` — which
# unquoted is not valid JSON at all, so every sidecar this wrote would fail to
# parse and the server would advertise nothing. Android's sidecar is a different
# file, copied verbatim from its lane, and keeps its integer.
printf '{\n "version_name": "%s",\n "version_code": "%s",\n "size": %s,\n "sha256": "%s"\n}\n' \
"$_name" "$_code" "$(bytes "$_file")" "$(digest "$_file")" > "$_out"
}
echo "==> Collecting the $channel clients"
# --- Android -----------------------------------------------------------------
#
# Its sidecar is published whole by the Android lane — an APK keeps its version in
# a binary AXML manifest, so the values are recorded where they were already known.
# Copied verbatim rather than rebuilt here.
if fetch "$BASE/thoughtsync.apk" "$dest/thoughtsync.apk" &&
fetch "$BASE/thoughtsync-android.json" "$dest/thoughtsync-android.json"; then
echo " android $(field "$dest/thoughtsync-android.json" version_name)"
else
# Both or neither. Half a pair is worse than none: the server would read a
# sidecar describing an APK that is not there, or an APK it cannot state a
# version for.
echo "::warning::No Android client on the $channel channel — this image ships without one."
rm -f "$dest/thoughtsync.apk" "$dest/thoughtsync-android.json"
fi
# --- desktop -----------------------------------------------------------------
#
# One sidecar on the channel carries the version PAIR for all four bundles, because
# they are one build: `version_name` is what a person reads, `version_code` is the
# ordering key, and the key is also what the bundle filenames are stamped with.
# Written by `write-manifest.sh`, which is the step that speaks for what the channel
# serves.
bake_desktop() {
desk="$dest/.desktop-release.json"
if ! fetch "$BASE/thoughtsync-desktop.json" "$desk"; then
echo "::warning::No desktop release on the $channel channel — this image ships without desktop clients."
rm -f "$desk"
return 0
fi
name="$(field "$desk" version_name)"
key="$(field "$desk" version_code)"
rm -f "$desk"
if [ -z "$name" ] || [ -z "$key" ]; then
echo "::warning::The $channel desktop sidecar named no version — skipping desktop clients."
return 0
fi
echo " desktop $name (key $key)"
# Bundle filenames are stamped with the ORDERING KEY — what Tauri puts in them,
# and what `write-manifest.sh` already selects on. Constructed rather than
# discovered from the release's asset list: one shape, no JSON walk, and a name
# that does not resolve is caught by the fetch failing rather than by matching
# the wrong file.
#
# `<platform id>|<published name>|<name on disk>`
for row in \
"linux-deb|ThoughtSync_${key}_amd64.deb|thoughtsync.deb" \
"linux-pacman|thoughtsync-${key}-1-x86_64.pkg.tar.zst|thoughtsync.pkg.tar.zst" \
"linux-appimage|ThoughtSync_${key}_amd64.AppImage|thoughtsync.AppImage" \
"windows|ThoughtSync_${key}_x64-setup.exe|thoughtsync-setup.exe"
do
id="${row%%|*}"; rest="${row#*|}"
remote="${rest%%|*}"; local_name="${rest#*|}"
if ! fetch "$BASE/$remote" "$dest/$local_name"; then
echo "::warning::$channel has no $remote — this image ships without the $id client."
rm -f "$dest/$local_name"
continue
fi
# The AppImage is the only bundle that replaces itself in place, so the updater
# verifies a signature before it does. Without one it is not servable as an
# update, and `client_dist.py` treats it as absent rather than offering it
# unverifiable — so drop the bundle too rather than baking 95 MB nothing can use.
if [ "$id" = "linux-appimage" ]; then
if ! fetch "$BASE/$remote.sig" "$dest/$local_name.sig"; then
echo "::warning::$remote has no signature on $channel — dropping the AppImage."
rm -f "$dest/$local_name" "$dest/$local_name.sig"
continue
fi
fi
sidecar "$dest/$local_name" "$dest/thoughtsync-$id.json" "$name" "$key"
echo " $id $(bytes "$dest/$local_name") bytes"
done
}
bake_desktop
echo "==> Baked in:"
ls -l "$dest"