image: bake every client in, not just the phone
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
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.
This commit is contained in:
+24
-36
@@ -323,53 +323,41 @@ jobs:
|
|||||||
docker system prune -af || true
|
docker system prune -af || true
|
||||||
docker builder prune --keep-storage 5g -f || true
|
docker builder prune --keep-storage 5g -f || true
|
||||||
|
|
||||||
# Bake the Android client in, on EVERY image build, so :dev, :latest and
|
# Bake EVERY client in, on every image build, so a self-hoster gets a working
|
||||||
# :<version> all carry one and a `docker compose pull` delivers a new client
|
# app for their machine from the server holding their notes — without an
|
||||||
# along with the new server.
|
# account on this forge, which is private (issue 2091) and is why serving them
|
||||||
|
# from a release page was never an option for anybody but the operator.
|
||||||
#
|
#
|
||||||
# Always the rolling `dev` release — the newest build there is. A versioned
|
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
|
||||||
# image therefore carries the newest client rather than one pinned to that
|
# the price of the product being complete (rule 23), and the AppImage is not
|
||||||
# version; the two negotiate a sync protocol version before linking, so
|
# optional within it: it is the ONLY bundle that can replace itself in place,
|
||||||
# "newest" is safe in a way "matching" would not buy anything over.
|
# so a server without one cannot serve in-app updates to anyone.
|
||||||
#
|
#
|
||||||
# Fetched by the JOB, not by the Dockerfile: the release is private, and a
|
# Fetched by the JOB, not by the Dockerfile: the releases are private, and a
|
||||||
# token used inside a build lands in the context or a layer.
|
# token used inside a build lands in the context or a layer.
|
||||||
#
|
#
|
||||||
# NEVER fails the build. An image with no Android client advertises none and
|
# NEVER fails the build — see the script. A platform with nothing published
|
||||||
# hides the download — a supported state, and the only one available before
|
# means the server advertises nothing for it and the UI hides that download,
|
||||||
# the first Android build has ever published.
|
# which is a supported state and the only one available before that platform's
|
||||||
- name: Fetch the Android client to bake in
|
# first build has ever published.
|
||||||
|
- name: Fetch the clients to bake in
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||||
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p client
|
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
|
||||||
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves the dev
|
# :latest serves stable ones. This read `download/dev` unconditionally
|
||||||
# client; :latest serves the stable one. This read `download/dev`
|
# until M314 step 3, on every branch — so every stable server shipped a
|
||||||
# unconditionally until M314 step 3, on every branch — so every stable
|
# dev-channel APK to anyone who downloaded the client from it. Not a
|
||||||
# server shipped a dev-channel APK to anyone who downloaded the client
|
# versioning gap; a plain defect, and the reason the channel is chosen here
|
||||||
# from it. Not a versioning gap; a plain defect, fixed here because this
|
# rather than inside the script: the caller is what knows which image it is
|
||||||
# is the step that gave `stable` an APK to point at.
|
# building.
|
||||||
case "${{ github.ref_name }}" in
|
case "${{ github.ref_name }}" in
|
||||||
main) channel=stable ;;
|
main) channel=stable ;;
|
||||||
*) channel=dev ;;
|
*) channel=dev ;;
|
||||||
esac
|
esac
|
||||||
echo "Baking in the $channel client."
|
sh packaging/fetch-clients.sh "$channel" client
|
||||||
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
|
|
||||||
ok=1
|
|
||||||
for f in thoughtsync.apk thoughtsync-android.json; do
|
|
||||||
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
|
|
||||||
done
|
|
||||||
if [ "$ok" = 1 ]; then
|
|
||||||
echo "Baking in:"
|
|
||||||
cat client/thoughtsync-android.json
|
|
||||||
ls -l client/thoughtsync.apk
|
|
||||||
else
|
|
||||||
# Both or neither. Half a pair is worse than none: the server would
|
|
||||||
# read a sidecar describing an APK that isn't there, or an APK it
|
|
||||||
# cannot state a version for.
|
|
||||||
echo "::warning::No Android client on the dev release — this image ships without one."
|
|
||||||
rm -f client/thoughtsync.apk client/thoughtsync-android.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|||||||
@@ -463,6 +463,12 @@ jobs:
|
|||||||
# match the binary it points at is an updater that never settles. It must
|
# match the binary it points at is an updater that never settles. It must
|
||||||
# be `key`: this value is matched against bundle filenames.
|
# be `key`: this value is matched against bundle filenames.
|
||||||
version="$(sh packaging/version.sh key desktop)"
|
version="$(sh packaging/version.sh key desktop)"
|
||||||
|
# The version a PERSON reads, published beside the manifest as
|
||||||
|
# `thoughtsync-desktop.json`. The image build reads it to describe the
|
||||||
|
# bundles it bakes in (packaging/fetch-clients.sh) without re-deriving
|
||||||
|
# anything from its own checkout — which would be a different commit
|
||||||
|
# whenever the desktop did not rebuild.
|
||||||
|
display="$(sh packaging/version.sh display desktop)"
|
||||||
# Both channels are rolling: the manifest lands on the same release that
|
# Both channels are rolling: the manifest lands on the same release that
|
||||||
# holds the bundles, and the previous build's bundles are dropped once it
|
# holds the bundles, and the previous build's bundles are dropped once it
|
||||||
# points at this one. Nothing can reach them, and they are ~100 MB a push.
|
# points at this one. Nothing can reach them, and they are ~100 MB a push.
|
||||||
@@ -476,4 +482,5 @@ jobs:
|
|||||||
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
|
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
|
||||||
esac
|
esac
|
||||||
export PRUNE_OLD_ASSETS=true
|
export PRUNE_OLD_ASSETS=true
|
||||||
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
|
APP_VERSION="$version" DISPLAY_VERSION="$display" \
|
||||||
|
bash desktop/packaging/write-manifest.sh
|
||||||
|
|||||||
+13
-7
@@ -24,17 +24,23 @@ COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
|
|||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
COPY alembic/ alembic/
|
COPY alembic/ alembic/
|
||||||
|
|
||||||
# The Android client this server hands out. CI fetches the newest published build
|
# The clients this server hands out — the APK and all four desktop bundles. CI
|
||||||
# into ./client immediately before this runs (ci.yml), so every image tag — :dev,
|
# fetches the newest published build of each into ./client immediately before this
|
||||||
# :latest and :<version> alike — ships a client, and a `docker compose pull`
|
# runs (packaging/fetch-clients.sh), so both image tags ship a full set and a
|
||||||
# delivers a new one with no file copying by hand.
|
# `docker compose pull` delivers new ones with no file copying by hand.
|
||||||
#
|
#
|
||||||
# Fetched by the JOB rather than here on purpose: the release is private, and a
|
# ~104 MB of this image is that set, almost all of it the AppImage.
|
||||||
|
#
|
||||||
|
# Fetched by the JOB rather than here on purpose: the releases are private, and a
|
||||||
# token used inside a build ends up in the build context or a layer.
|
# token used inside a build ends up in the build context or a layer.
|
||||||
#
|
#
|
||||||
|
# LAST of the COPYs, deliberately: this directory changes on every build, so
|
||||||
|
# putting it above the `pip install` layer would invalidate that layer every time.
|
||||||
|
#
|
||||||
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
|
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
|
||||||
# that step never ran. An image with no APK is a supported state — the server
|
# that step never ran. An image with no clients — or with some and not others — is
|
||||||
# advertises nothing and the web UI hides the download (client_dist.py).
|
# a supported state: the server advertises what it has and the web UI hides the
|
||||||
|
# rest (client_dist.py).
|
||||||
COPY client/ src/thoughtsync/client/
|
COPY client/ src/thoughtsync/client/
|
||||||
|
|
||||||
ENV PYTHONPATH=/app/src
|
ENV PYTHONPATH=/app/src
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ set -euo pipefail
|
|||||||
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (owner/repo)}"
|
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (owner/repo)}"
|
||||||
: "${RELEASE_TAG:?RELEASE_TAG is required (the release holding the bundles)}"
|
: "${RELEASE_TAG:?RELEASE_TAG is required (the release holding the bundles)}"
|
||||||
: "${APP_VERSION:?APP_VERSION is required (the version the bundles carry)}"
|
: "${APP_VERSION:?APP_VERSION is required (the version the bundles carry)}"
|
||||||
|
# The version a PERSON reads, published beside the manifest so the image build can
|
||||||
|
# describe the bundles it bakes in without re-deriving anything. Required rather
|
||||||
|
# than defaulted: a missing value here would silently publish a sidecar naming the
|
||||||
|
# wrong build, and there is nothing downstream that could catch it.
|
||||||
|
: "${DISPLAY_VERSION:?DISPLAY_VERSION is required (the human-readable version)}"
|
||||||
|
|
||||||
# The manifest is published to the release that HOLDS the bundles. There is no
|
# The manifest is published to the release that HOLDS the bundles. There is no
|
||||||
# second place any more.
|
# second place any more.
|
||||||
@@ -116,27 +121,47 @@ pub_date="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
|||||||
echo "==> Manifest:"
|
echo "==> Manifest:"
|
||||||
cat "$work/latest.json"
|
cat "$work/latest.json"
|
||||||
|
|
||||||
# The manifest goes on the same release the bundles were just read from — which is
|
# Both files go on the same release the bundles were just read from — which is also
|
||||||
# also the one `publish-release.sh` created or refreshed moments earlier, so it is
|
# the one `publish-release.sh` created or refreshed moments earlier, so it is
|
||||||
# guaranteed to exist by the time this runs.
|
# guaranteed to exist by the time this runs.
|
||||||
target_id="$release_id"
|
|
||||||
target_assets="$assets"
|
|
||||||
|
|
||||||
# Replace rather than duplicate: Forgejo rejects a second asset with the same name,
|
# Replace rather than duplicate: Forgejo rejects a second asset with the same name,
|
||||||
# and this file is rewritten on every publish by design.
|
# and these files are rewritten on every publish by design.
|
||||||
old_id="$(printf '%s' "$target_assets" \
|
replace_asset() {
|
||||||
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"latest\.json\"" \
|
local path="$1" name="$2" escaped old_id
|
||||||
| head -1 | grep -oE '[0-9]+' | head -1 || true)"
|
escaped="${name//./\\.}"
|
||||||
if [ -n "${old_id:-}" ]; then
|
old_id="$(printf '%s' "$assets" \
|
||||||
echo "==> Removing the previous latest.json (id $old_id)"
|
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"$escaped\"" \
|
||||||
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$target_id/assets/$old_id" >/dev/null
|
| head -1 | grep -oE '[0-9]+' | head -1 || true)"
|
||||||
fi
|
if [ -n "${old_id:-}" ]; then
|
||||||
|
echo "==> Removing the previous $name (id $old_id)"
|
||||||
|
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$release_id/assets/$old_id" >/dev/null
|
||||||
|
fi
|
||||||
|
echo "==> Uploading $name to $RELEASE_TAG"
|
||||||
|
curl -fsS -X POST "${AUTH[@]}" "$API/releases/$release_id/assets?name=$name" \
|
||||||
|
-F "attachment=@$path" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
echo "==> Uploading latest.json to $RELEASE_TAG"
|
replace_asset "$work/latest.json" "latest.json"
|
||||||
curl -fsS -X POST "${AUTH[@]}" "$API/releases/$target_id/assets?name=latest.json" \
|
|
||||||
-F "attachment=@$work/latest.json" >/dev/null
|
|
||||||
|
|
||||||
echo "==> Done. $RELEASE_TAG now advertises $APP_VERSION for ${#entries[@]} platform(s)."
|
# The version pair, for whoever needs to describe these bundles without rebuilding
|
||||||
|
# them — today the image build, which bakes the desktop clients in and writes each
|
||||||
|
# one a sidecar (`packaging/fetch-clients.sh`).
|
||||||
|
#
|
||||||
|
# It is published HERE, beside the manifest, because this is the step that speaks
|
||||||
|
# for what the channel serves: both files are written in the same breath from the
|
||||||
|
# same two values, so they cannot disagree about which build is current. A consumer
|
||||||
|
# deriving the version from its own checkout instead would describe these bytes
|
||||||
|
# with whatever commit it happened to be on.
|
||||||
|
#
|
||||||
|
# No `size` or `sha256` — those are per-artifact and there are four. Whoever
|
||||||
|
# downloads a bundle measures the bytes it actually got, which is the only way to
|
||||||
|
# tell a truncated download from a whole one.
|
||||||
|
printf '{\n "version_name": "%s",\n "version_code": "%s"\n}\n' \
|
||||||
|
"$DISPLAY_VERSION" "$APP_VERSION" > "$work/thoughtsync-desktop.json"
|
||||||
|
replace_asset "$work/thoughtsync-desktop.json" "thoughtsync-desktop.json"
|
||||||
|
|
||||||
|
echo "==> Done. $RELEASE_TAG now advertises $DISPLAY_VERSION ($APP_VERSION) for ${#entries[@]} platform(s)."
|
||||||
|
|
||||||
# --- prune superseded builds from a rolling channel ---------------------------
|
# --- prune superseded builds from a rolling channel ---------------------------
|
||||||
#
|
#
|
||||||
@@ -169,7 +194,7 @@ if [ "${PRUNE_OLD_ASSETS:-false}" = "true" ]; then
|
|||||||
# every desktop push regardless. That is exactly what happened on run
|
# every desktop push regardless. That is exactly what happened on run
|
||||||
# 4098, which swept the APK run 4092 had just published.
|
# 4098, which swept the APK run 4092 had just published.
|
||||||
case "$asset_name" in
|
case "$asset_name" in
|
||||||
latest.json|thoughtsync.apk|thoughtsync-android.json) continue ;;
|
latest.json|thoughtsync-desktop.json|thoughtsync.apk|thoughtsync-android.json) continue ;;
|
||||||
*"$APP_VERSION"*) continue ;;
|
*"$APP_VERSION"*) continue ;;
|
||||||
esac
|
esac
|
||||||
echo " removing $asset_name"
|
echo " removing $asset_name"
|
||||||
|
|||||||
Executable
+161
@@ -0,0 +1,161 @@
|
|||||||
|
#!/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"
|
||||||
@@ -99,6 +99,18 @@ class Platform:
|
|||||||
# the only bundle that can replace itself in place — a package-manager install
|
# the only bundle that can replace itself in place — a package-manager install
|
||||||
# cannot, by design (see the desktop's update.rs).
|
# cannot, by design (see the desktop's update.rs).
|
||||||
signed: bool = False
|
signed: bool = False
|
||||||
|
# Whether this platform's ordering key is an INTEGER.
|
||||||
|
#
|
||||||
|
# `version_code` is "whatever this platform's comparator reads", and that is not
|
||||||
|
# one type. Android's is an int because Android's own install gate compares one,
|
||||||
|
# and it must stay a JSON number — `ClientRelease` in core/src/sync/client.rs
|
||||||
|
# declares it `i64` and a string would fail to deserialize on every phone in the
|
||||||
|
# field. The desktop's is Tauri's semver key, `1.0.<minutes>`, which is the value
|
||||||
|
# its updater compares and is not an integer at all.
|
||||||
|
#
|
||||||
|
# Coercing everything to int was inherited from the days when Android was the
|
||||||
|
# only platform, and would have rejected every desktop sidecar written.
|
||||||
|
code_is_int: bool = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def signature(self) -> str:
|
def signature(self) -> str:
|
||||||
@@ -130,6 +142,7 @@ PLATFORMS: tuple[Platform, ...] = (
|
|||||||
artifact="thoughtsync.deb",
|
artifact="thoughtsync.deb",
|
||||||
sidecar="thoughtsync-linux-deb.json",
|
sidecar="thoughtsync-linux-deb.json",
|
||||||
mimetype="application/vnd.debian.binary-package",
|
mimetype="application/vnd.debian.binary-package",
|
||||||
|
code_is_int=False,
|
||||||
),
|
),
|
||||||
Platform(
|
Platform(
|
||||||
id="linux-pacman",
|
id="linux-pacman",
|
||||||
@@ -137,6 +150,7 @@ PLATFORMS: tuple[Platform, ...] = (
|
|||||||
artifact="thoughtsync.pkg.tar.zst",
|
artifact="thoughtsync.pkg.tar.zst",
|
||||||
sidecar="thoughtsync-linux-pacman.json",
|
sidecar="thoughtsync-linux-pacman.json",
|
||||||
mimetype="application/zstd",
|
mimetype="application/zstd",
|
||||||
|
code_is_int=False,
|
||||||
),
|
),
|
||||||
Platform(
|
Platform(
|
||||||
id="linux-appimage",
|
id="linux-appimage",
|
||||||
@@ -148,6 +162,7 @@ PLATFORMS: tuple[Platform, ...] = (
|
|||||||
# cannot be wrong.
|
# cannot be wrong.
|
||||||
mimetype="application/octet-stream",
|
mimetype="application/octet-stream",
|
||||||
signed=True,
|
signed=True,
|
||||||
|
code_is_int=False,
|
||||||
),
|
),
|
||||||
Platform(
|
Platform(
|
||||||
id="windows",
|
id="windows",
|
||||||
@@ -155,6 +170,7 @@ PLATFORMS: tuple[Platform, ...] = (
|
|||||||
artifact="thoughtsync-setup.exe",
|
artifact="thoughtsync-setup.exe",
|
||||||
sidecar="thoughtsync-windows.json",
|
sidecar="thoughtsync-windows.json",
|
||||||
mimetype="application/vnd.microsoft.portable-executable",
|
mimetype="application/vnd.microsoft.portable-executable",
|
||||||
|
code_is_int=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -185,12 +201,19 @@ def _read(root: Path, platform: Platform) -> dict | None:
|
|||||||
size = (root / platform.artifact).stat().st_size
|
size = (root / platform.artifact).stat().st_size
|
||||||
meta = json.loads((root / platform.sidecar).read_text(encoding="utf-8"))
|
meta = json.loads((root / platform.sidecar).read_text(encoding="utf-8"))
|
||||||
version = str(meta["version_name"])
|
version = str(meta["version_name"])
|
||||||
code = int(meta["version_code"])
|
# See `code_is_int`. Android's must parse as an integer; the desktop's is
|
||||||
|
# Tauri's semver key and is carried through as written.
|
||||||
|
code = int(meta["version_code"]) if platform.code_is_int else str(meta["version_code"])
|
||||||
recorded = int(meta["size"])
|
recorded = int(meta["size"])
|
||||||
digest = str(meta["sha256"])
|
digest = str(meta["sha256"])
|
||||||
except (OSError, ValueError, TypeError, KeyError):
|
except (OSError, ValueError, TypeError, KeyError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if not str(code).strip() or not version.strip():
|
||||||
|
# A sidecar can be well-formed and still say nothing. An empty version is
|
||||||
|
# not a version, and it would render as a blank on the download card.
|
||||||
|
return None
|
||||||
|
|
||||||
# The pair has to describe one build. A sidecar left behind by a previous
|
# The pair has to describe one build. A sidecar left behind by a previous
|
||||||
# release would otherwise advertise a version this server cannot serve, and the
|
# release would otherwise advertise a version this server cannot serve, and the
|
||||||
# client would download something other than what it was promised.
|
# client would download something other than what it was promised.
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ PAYLOAD = b"not really a client, but the server only ever stats it"
|
|||||||
# four would ship untested.
|
# four would ship untested.
|
||||||
ALL_IDS = [p.id for p in PLATFORMS]
|
ALL_IDS = [p.id for p in PLATFORMS]
|
||||||
|
|
||||||
|
# `version_code` is "whatever this platform's comparator reads", and that is not one
|
||||||
|
# type. Android's install gate compares an integer; the desktop's updater compares
|
||||||
|
# Tauri's semver key. The tests carry both shapes for the same reason the module
|
||||||
|
# does — a suite that only ever wrote integers would pass while every desktop
|
||||||
|
# sidecar CI writes was being rejected.
|
||||||
|
ANDROID_CODE = 3503708
|
||||||
|
DESKTOP_CODE = "1.0.3503707"
|
||||||
|
|
||||||
|
|
||||||
|
def code_for(platform_id: str):
|
||||||
|
return ANDROID_CODE if BY_ID[platform_id].code_is_int else DESKTOP_CODE
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _empty_baked_client(tmp_path, monkeypatch):
|
def _empty_baked_client(tmp_path, monkeypatch):
|
||||||
@@ -71,7 +83,7 @@ def place(
|
|||||||
(root / platform.artifact).write_bytes(payload)
|
(root / platform.artifact).write_bytes(payload)
|
||||||
meta = {
|
meta = {
|
||||||
"version_name": "2026.08.30.0307",
|
"version_name": "2026.08.30.0307",
|
||||||
"version_code": 3503708,
|
"version_code": code_for(platform_id),
|
||||||
"size": len(payload),
|
"size": len(payload),
|
||||||
"sha256": "ab" * 32,
|
"sha256": "ab" * 32,
|
||||||
}
|
}
|
||||||
@@ -164,6 +176,30 @@ def test_a_sidecar_with_no_artifact_beside_it_counts_as_no_client(platform_id):
|
|||||||
assert release(platform_id) is None
|
assert release(platform_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_androids_code_must_be_an_integer():
|
||||||
|
"""`ClientRelease` in core/src/sync/client.rs declares it `i64`. A string here
|
||||||
|
would fail to deserialize on every phone in the field, so a sidecar carrying one
|
||||||
|
is not a client this server can honestly offer."""
|
||||||
|
place("android", version_code="1.0.3503707")
|
||||||
|
assert release("android") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_desktop_keeps_tauris_semver_key_verbatim():
|
||||||
|
"""It is not an integer and must not be coerced into one: this is the value the
|
||||||
|
desktop updater compares, and `1.0.3503707` truncated to `1` orders against
|
||||||
|
nothing."""
|
||||||
|
place("linux-deb")
|
||||||
|
assert release("linux-deb")["version_code"] == "1.0.3503707"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
||||||
|
def test_an_empty_version_name_counts_as_no_client(platform_id):
|
||||||
|
"""A sidecar can be well-formed and still say nothing. A blank would render as
|
||||||
|
an empty space on the download card, which reads as a layout bug."""
|
||||||
|
place(platform_id, version_name="")
|
||||||
|
assert release(platform_id) is None
|
||||||
|
|
||||||
|
|
||||||
def test_an_unknown_platform_is_not_a_client():
|
def test_an_unknown_platform_is_not_a_client():
|
||||||
assert release("blackberry") is None
|
assert release("blackberry") is None
|
||||||
|
|
||||||
@@ -178,7 +214,7 @@ def test_a_present_client_reports_what_a_comparator_reads(platform_id):
|
|||||||
assert found["version"] == "2026.08.30.0307"
|
assert found["version"] == "2026.08.30.0307"
|
||||||
# The integer is what decides "is this newer", not the name — a name is a string
|
# The integer is what decides "is this newer", not the name — a name is a string
|
||||||
# and sorts like one.
|
# and sorts like one.
|
||||||
assert found["version_code"] == 3503708
|
assert found["version_code"] == code_for(platform_id)
|
||||||
assert found["size"] == len(PAYLOAD)
|
assert found["size"] == len(PAYLOAD)
|
||||||
assert found["platform"] == platform_id
|
assert found["platform"] == platform_id
|
||||||
# A PATH, not an absolute URL: the client joins it to the base it is already
|
# A PATH, not an absolute URL: the client joins it to the base it is already
|
||||||
@@ -317,7 +353,7 @@ async def test_metadata_endpoint_is_public_so_an_updater_can_ask_cheaply(app, pl
|
|||||||
place(platform_id)
|
place(platform_id)
|
||||||
resp = await app.test_client().get(f"/api/client/{platform_id}")
|
resp = await app.test_client().get(f"/api/client/{platform_id}")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert (await resp.get_json())["version_code"] == 3503708
|
assert (await resp.get_json())["version_code"] == code_for(platform_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
@pytest.mark.parametrize("platform_id", ALL_IDS)
|
||||||
|
|||||||
Reference in New Issue
Block a user