desktop: in-app updates, two channels, signed, fed by fixed-tag releases
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m23s
Desktop (Tauri) / Update manifest (push) Has been skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m23s
Desktop (Tauri) / Update manifest (push) Has been skipped
There was no in-place update anywhere. The app never checked, downloaded or applied anything, and the only published release predates the whole sync arc — so `install.sh` would hand out a build with no sync in it. Installing from per-run CI artifacts, which is what's been happening, is not something an updater can point at: ephemeral, auth-gated, no stable URL. Two channels, switchable in the app: `stable` follows tagged releases, `dev` follows every green push. The feed is a Fabled-Git release asset, not a ThoughtSync server route. This reverses the lean recorded in task 1998, and the reason matters — a server-hosted feed can only reach a desktop that has linked a server, and local-first-with-no-server is the whole premise. An unlinked install has to be able to update itself. Each channel reads a `latest.json` on a release whose TAG NEVER MOVES. That's forced, not stylistic: Forgejo has no /releases/latest/download/<asset> route (verified — it 404s with no redirect), so "newest" cannot be named in a URL. `dev` carries the rolling bundles; `stable` is a pointer release holding only the manifest, whose URLs aim at the versioned release's assets, so nothing is duplicated. The manifest is written by a third job that runs after both bundle jobs. They build in separate workspaces and neither can see the other's output, but one manifest has to describe both platforms — generating it inside either job would silently omit the other, and a missing platform reads to a user as "no update available" rather than as a broken feed. It reads what actually landed on the release, so it can never advertise a bundle that failed to upload. Signing is gated on the secret existing, in the script rather than an `if:` (the secrets context isn't reliably available to step conditions). No key means no updater artifacts and no publish: a feed the app would refuse to verify is worse than no feed, because it looks like it works. CI stays green until the key lands. On Linux the updater can only replace an AppImage — a deb or pacman install is owned by its package manager and must never be overwritten underneath it. The app detects that case up front and says so, instead of failing halfway through with a permissions error nobody can read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
This commit is contained in:
@@ -31,8 +31,15 @@ set -euo pipefail
|
||||
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (owner/repo)}"
|
||||
: "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required (the tag, e.g. v0.1.0)}"
|
||||
|
||||
# The release to publish to. Defaults to the pushed tag (the versioned, stable
|
||||
# case). M10.9 also calls this with RELEASE_TAG=dev to maintain the rolling
|
||||
# development channel — a release whose tag never moves, because Forgejo has no
|
||||
# `/releases/latest/download/<asset>` route for an updater to point at.
|
||||
RELEASE_TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
|
||||
RELEASE_PRERELEASE="${RELEASE_PRERELEASE:-false}"
|
||||
|
||||
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
TAG="$GITHUB_REF_NAME"
|
||||
TAG="$RELEASE_TAG"
|
||||
AUTH=(-H "Authorization: token $GITHUB_TOKEN")
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -47,11 +54,17 @@ shopt -s nullglob
|
||||
# Linux job and the Windows job each run this script against the SAME release and
|
||||
# upload only what they actually built — they run in separate workspaces, so neither
|
||||
# can see the other's bundles. The release is created once and reused (409 path).
|
||||
# The `.sig` files are the updater's whole trust story — a bundle published without
|
||||
# its signature is one the app will refuse, so they ship together or not at all.
|
||||
# They only exist when the build ran with a signing key (M10.9); nullglob drops
|
||||
# them silently otherwise, which is the correct behaviour for an unsigned build.
|
||||
ASSETS=(
|
||||
"$BUNDLE_ROOT"/appimage/*.AppImage
|
||||
"$BUNDLE_ROOT"/appimage/*.AppImage.sig
|
||||
"$BUNDLE_ROOT"/deb/*.deb
|
||||
"$BUNDLE_ROOT"/arch/*.pkg.tar.*
|
||||
"$WIN_BUNDLE_ROOT"/nsis/*.exe
|
||||
"$WIN_BUNDLE_ROOT"/nsis/*.exe.sig
|
||||
)
|
||||
if [ ${#ASSETS[@]} -eq 0 ]; then
|
||||
echo "ERROR: no bundles under $BUNDLE_ROOT — did the tauri build run?" >&2
|
||||
@@ -83,7 +96,7 @@ first_id() { grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE
|
||||
# --- create (or reuse) the release for the tag ------------------------------
|
||||
echo "==> Creating release for $TAG"
|
||||
BODY=$(cat <<JSON
|
||||
{"tag_name":"$TAG","name":"ThoughtSync $TAG","draft":false,"prerelease":false,
|
||||
{"tag_name":"$TAG","name":"ThoughtSync $TAG","draft":false,"prerelease":$RELEASE_PRERELEASE,
|
||||
"body":"ThoughtSync desktop $TAG.\n\n- **Debian / Ubuntu** — native \`.deb\`\n- **Arch / CachyOS** — native \`.pkg.tar.*\`\n- **everything else** — \`.AppImage\` (de-bundled graphics: renders on any GPU/Wayland setup)\n\nInstall / update — picks the right one for your system:\n\`\`\`\ncurl -fsSL $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/branch/dev/desktop/packaging/install.sh | sh\n\`\`\`"}
|
||||
JSON
|
||||
)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/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)}"
|
||||
|
||||
# Where the manifest is PUBLISHED, which need not be where the bundles live.
|
||||
#
|
||||
# That split is what makes the stable channel work at all. A versioned release
|
||||
# (`v0.2.0`) holds the real assets, but the app can only read a URL that never
|
||||
# changes — so the same manifest is also attached to a `stable` release whose tag is
|
||||
# permanent and whose only content is this file. It points back at the versioned
|
||||
# assets, so nothing is duplicated.
|
||||
MANIFEST_TAG="${MANIFEST_TAG:-$RELEASE_TAG}"
|
||||
|
||||
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
|
||||
bundle="$(printf '%s\n' "$names" | grep -E "$pattern" | head -1 || true)"
|
||||
[ -n "$bundle" ] || { echo " no 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"
|
||||
|
||||
# --- resolve the release the manifest is published TO ------------------------
|
||||
if [ "$MANIFEST_TAG" = "$RELEASE_TAG" ]; then
|
||||
target_id="$release_id"
|
||||
target_assets="$assets"
|
||||
else
|
||||
echo "==> Resolving the $MANIFEST_TAG channel release"
|
||||
target="$(curl -sS "${AUTH[@]}" "$API/releases/tags/$MANIFEST_TAG")"
|
||||
target_id="$(printf '%s' "$target" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+' || true)"
|
||||
if [ -z "${target_id:-}" ]; then
|
||||
# First publish to this channel. A pointer release: no bundles of its own, just
|
||||
# a permanent tag for the manifest to live under.
|
||||
echo " creating it (pointer release, manifest only)"
|
||||
body="{\"tag_name\":\"$MANIFEST_TAG\",\"name\":\"ThoughtSync ($MANIFEST_TAG channel)\",\"draft\":false,\"prerelease\":false,\"body\":\"Update channel pointer. The installable builds live on the versioned releases; this holds only the updater manifest.\"}"
|
||||
target="$(curl -sS -X POST "${AUTH[@]}" -H "Content-Type: application/json" -d "$body" "$API/releases")"
|
||||
target_id="$(printf '%s' "$target" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')"
|
||||
fi
|
||||
[ -n "${target_id:-}" ] || { echo "ERROR: could not resolve the $MANIFEST_TAG release" >&2; exit 1; }
|
||||
target_assets="$(curl -sS "${AUTH[@]}" "$API/releases/$target_id/assets")"
|
||||
fi
|
||||
|
||||
# Replace rather than duplicate: Forgejo rejects a second asset with the same name,
|
||||
# and this file is rewritten on every publish by design.
|
||||
old_id="$(printf '%s' "$target_assets" \
|
||||
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"latest\.json\"" \
|
||||
| head -1 | grep -oE '[0-9]+' | head -1 || true)"
|
||||
if [ -n "${old_id:-}" ]; then
|
||||
echo "==> Removing the previous latest.json (id $old_id)"
|
||||
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$target_id/assets/$old_id" >/dev/null
|
||||
fi
|
||||
|
||||
echo "==> Uploading latest.json to $MANIFEST_TAG"
|
||||
curl -fsS -X POST "${AUTH[@]}" "$API/releases/$target_id/assets?name=latest.json" \
|
||||
-F "attachment=@$work/latest.json" >/dev/null
|
||||
|
||||
echo "==> Done. $MANIFEST_TAG now advertises $APP_VERSION for ${#entries[@]} platform(s)."
|
||||
Reference in New Issue
Block a user