Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 15s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 10s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
~104 MB on top of ~85 MB, almost all of it the AppImage. That is what the product being complete costs (rule 23): a self-hoster gets a working app for their machine from the server holding their notes, with no account on a forge that is private. The AppImage is not optional within that — it is the only bundle that can replace itself in place, so a server without one cannot serve in-app updates to anybody. `packaging/fetch-clients.sh` replaces the inline fetch and writes the fixed names and sidecars `client_dist.py` reads. It never fails: a platform with nothing published means the server advertises nothing for it and the UI hides that download, and eight fetches must not become eight ways to redden a green lane. THE VERSION IS FETCHED, NOT DERIVED, and this is the part that would have been wrong the easy way. The obvious shortcut is `version.sh display desktop` in the image job — it has the checkout. But 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. `client_dist.py`'s size check could not catch it, because size IS measured from the real file — it would sail through and lie about the version alone. So `write-manifest.sh` now publishes `thoughtsync-desktop.json` beside `latest.json`, from the same two values in the same breath, and only size/sha256 are measured at bake time. Which needed the prune's keep-list, or the sidecar would have been uploaded and deleted again in the same run — a fixed name is self-limiting, which is exactly why that list exists. `version_code` is NOT uniformly an integer, and coercing it was a leftover from the days when Android was the only platform. Android's must stay a JSON number: `ClientRelease` in core declares it `i64` and a string fails to deserialize on every phone in the field. The desktop's is Tauri's semver key `1.0.<minutes>` — the value its updater actually compares — and `int()` would have rejected every desktop sidecar CI writes. The table now says which is which, and tests pin both directions. Also retires the comment above the fetch step, which claimed the APK came from "always the rolling dev release" and mentioned `:<version>` images. M314 step 3 made the channel conditional in the code directly below it, and step 6 removed version-shaped image tags entirely. Verified against the live dev channel before pushing: the Android half resolves and exits 0, the desktop half degrades with a warning because the sidecar does not exist yet, and all five constructed bundle filenames return 200.
162 lines
6.9 KiB
Bash
Executable File
162 lines
6.9 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}"
|
|
BASE="$SERVER/$REPO/releases/download/$channel"
|
|
|
|
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"
|
|
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"
|