Files
thoughtsync/desktop/packaging/write-manifest.sh
T
Bryan Van Deusen ff6e99eb62
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
image: bake every client in, not just the phone
~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.
2026-08-30 13:11:04 -04:00

205 lines
10 KiB
Bash

#!/usr/bin/env bash
#
# Write the updater manifest (`latest.json`) for one channel and attach it to that
# channel's release.
#
# WHY A SEPARATE STEP: the Linux and Windows bundles are built by two jobs in two
# workspaces, and neither can see the other's output — but ONE manifest has to
# describe both platforms. So this runs after both, reads what actually landed on
# the release, and writes the manifest from that. Building it inside either job
# would produce a manifest that silently omits the other platform, and a missing
# platform reads to a user as "no update available" rather than as a broken feed.
#
# WHAT IT READS: the release's own asset list. The signature for each bundle is a
# `.sig` asset published beside it (see publish-release.sh); its CONTENT is what
# goes in the manifest, which is why each one is downloaded rather than linked.
#
# Tauri's expected shape:
# { "version": "0.1.0", "pub_date": "...", "notes": "...",
# "platforms": { "<target>-<arch>": { "signature": "...", "url": "..." } } }
set -euo pipefail
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (owner/repo)}"
: "${RELEASE_TAG:?RELEASE_TAG is required (the release holding the bundles)}"
: "${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
# second place any more.
#
# There used to be: `MANIFEST_TAG` let the manifest live on a `stable` pointer
# release while the bundles sat on a versioned `v*` one, because the app can only
# read a URL that never changes and a versioned tag is not that. M314 step 3 made
# `stable` a rolling release that holds its own bundles, exactly like `dev`, so the
# split had nothing left to bridge — and a parameter that can only ever be passed
# its own default is a branch nobody exercises and a comment that goes stale.
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
AUTH=(-H "Authorization: token $GITHUB_TOKEN")
NOTES="${RELEASE_NOTES:-}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT INT TERM
echo "==> Reading assets on release $RELEASE_TAG"
release="$(curl -sS "${AUTH[@]}" "$API/releases/tags/$RELEASE_TAG")"
release_id="$(printf '%s' "$release" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')"
[ -n "$release_id" ] || { echo "ERROR: no release tagged $RELEASE_TAG" >&2; exit 1; }
assets="$(curl -sS "${AUTH[@]}" "$API/releases/$release_id/assets")"
# Asset names, one per line. The API returns them in a single JSON blob; this is
# the only field needed, and grep beats adding a jq dependency to the CI image.
names="$(printf '%s' "$assets" | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' | sed -E 's/.*"([^"]+)"$/\1/')"
download_url() { printf '%s/%s/releases/download/%s/%s' "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$RELEASE_TAG" "$1"; }
# One platform entry, or nothing if that platform's bundle or signature is absent.
# Emitting a partial entry would be worse than emitting none: the app would try to
# install something it can't verify.
platform_entry() {
local target="$1" pattern="$2" bundle sig_name
# Matched on THIS build's version, not just the file extension.
#
# The rolling dev release accumulates every build's assets, and picking the first
# extension match returned the OLDEST one — so the manifest advertised the new
# version while pointing at an old binary. The client would install the older
# build, still be told the newer version was available, and update forever. The
# signature check couldn't catch it either: the old bundle's signature is
# perfectly valid FOR THE OLD BUNDLE.
bundle="$(printf '%s\n' "$names" | grep -F "_${APP_VERSION}_" | grep -E "$pattern" | head -1 || true)"
# A hard failure, not a skip: reaching here means this platform's build didn't
# upload, and the whole point is to never advertise a bundle that isn't there.
[ -n "$bundle" ] || { echo " no $APP_VERSION bundle matching $pattern — skipping $target" >&2; return; }
sig_name="$bundle.sig"
if ! printf '%s\n' "$names" | grep -qxF "$sig_name"; then
echo " $bundle has no $sig_name — skipping $target (was the build signed?)" >&2
return
fi
curl -fsSL "${AUTH[@]}" -o "$work/sig" "$(download_url "$sig_name")"
# The signature is base64 on one line already; strip any stray newline so it
# can't break the JSON string it's about to become.
local signature
signature="$(tr -d '\r\n' < "$work/sig")"
printf ' "%s": { "signature": "%s", "url": "%s" }' "$target" "$signature" "$(download_url "$bundle")"
}
echo "==> Building the manifest"
entries=()
# `.AppImage` only on Linux: the updater replaces the running bundle in place, which
# a package-manager install (deb/pacman) must never have done to it.
if entry="$(platform_entry "linux-x86_64" '\.AppImage$')" && [ -n "$entry" ]; then entries+=("$entry"); fi
if entry="$(platform_entry "windows-x86_64" '\.exe$')" && [ -n "$entry" ]; then entries+=("$entry"); fi
if [ ${#entries[@]} -eq 0 ]; then
echo "ERROR: no signed bundle on $RELEASE_TAG — refusing to publish an empty manifest." >&2
echo " (An empty manifest would tell every client it is up to date.)" >&2
exit 1
fi
# No `date -u -Is` — busybox date in the CI image doesn't take it.
pub_date="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
{
printf '{\n'
printf ' "version": "%s",\n' "$APP_VERSION"
printf ' "pub_date": "%s",\n' "$pub_date"
printf ' "notes": "%s",\n' "$NOTES"
printf ' "platforms": {\n'
for i in "${!entries[@]}"; do
[ "$i" -eq 0 ] || printf ',\n'
printf '%s' "${entries[$i]}"
done
printf '\n }\n'
printf '}\n'
} > "$work/latest.json"
echo "==> Manifest:"
cat "$work/latest.json"
# Both files go on the same release the bundles were just read from — which is also
# the one `publish-release.sh` created or refreshed moments earlier, so it is
# guaranteed to exist by the time this runs.
# Replace rather than duplicate: Forgejo rejects a second asset with the same name,
# and these files are rewritten on every publish by design.
replace_asset() {
local path="$1" name="$2" escaped old_id
escaped="${name//./\\.}"
old_id="$(printf '%s' "$assets" \
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"$escaped\"" \
| head -1 | grep -oE '[0-9]+' | head -1 || true)"
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
}
replace_asset "$work/latest.json" "latest.json"
# 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 ---------------------------
#
# The dev release is republished on every push and its assets otherwise accumulate
# forever — an AppImage alone is ~100 MB, so a week of pushes is gigabytes on the
# Git host for builds nobody can reach (the manifest only ever names the newest).
#
# Only for a rolling channel. A versioned release must keep its assets: that IS the
# archive, and the stable pointer's URLs aim at it.
if [ "${PRUNE_OLD_ASSETS:-false}" = "true" ]; then
echo "==> Pruning superseded assets from $RELEASE_TAG"
# Re-read: the manifest upload above changed the asset list.
current="$(curl -sS "${AUTH[@]}" "$API/releases/$release_id/assets")"
printf '%s' "$current" \
| grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+[^}]*"name"[[:space:]]*:[[:space:]]*"[^"]+"' \
| while IFS= read -r row; do
asset_id="$(printf '%s' "$row" | grep -oE '[0-9]+' | head -1)"
asset_name="$(printf '%s' "$row" | sed -E 's/.*"name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')"
# Keep FIXED-NAME assets and everything belonging to the current build.
#
# A fixed name is self-limiting: each publish replaces that same name, so
# it cannot accumulate and the reason this prune exists does not apply to
# it. It is also the only kind of URL that stays addressable on a rolling
# tag, which is the whole point of having one — deleting it breaks
# whatever was pointing at it.
#
# The Android client is on that list for a second reason too: it is built
# by a DIFFERENT workflow with its own run number, so its version never
# matches $APP_VERSION here and a version-stamped name would be pruned on
# every desktop push regardless. That is exactly what happened on run
# 4098, which swept the APK run 4092 had just published.
case "$asset_name" in
latest.json|thoughtsync-desktop.json|thoughtsync.apk|thoughtsync-android.json) continue ;;
*"$APP_VERSION"*) continue ;;
esac
echo " removing $asset_name"
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$release_id/assets/$asset_id" >/dev/null || \
echo " (couldn't remove $asset_name — leaving it)" >&2
done
fi