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
135 lines
6.7 KiB
Bash
Executable File
135 lines
6.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Publish the built desktop bundles as assets on a Fabled-Git (Forgejo) Release.
|
|
#
|
|
# WHY: CI Actions artifacts are ephemeral, per-run, and auth-gated — useless as a
|
|
# distribution/fetch target. The install script (install.sh) and the in-app
|
|
# updater both need a STABLE, versioned URL. A Release attached to the pushed
|
|
# `v*` tag is that target: `/releases/latest` always points at the newest one,
|
|
# and each asset has a permanent browser_download_url.
|
|
#
|
|
# WHEN: CI-only, and ONLY on a `v*` tag build (the workflow gates this step with
|
|
# `if: startsWith(github.ref, 'refs/tags/v')`). Cutting the tag is the operator's
|
|
# action (rule 2) — this script never creates a tag, it only publishes a Release
|
|
# for a tag that already exists.
|
|
#
|
|
# The build + de-bundle steps run first; this consumes their output:
|
|
# desktop/src-tauri/target/release/bundle/appimage/*.AppImage (de-bundled)
|
|
# desktop/src-tauri/target/release/bundle/deb/*.deb
|
|
# desktop/src-tauri/target/release/bundle/arch/*.pkg.tar.* (prebuilt pacman)
|
|
# desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
|
#
|
|
# Instance-agnostic: server + repo come from the runner's github.* context
|
|
# (Forgejo populates them for compatibility), so nothing is hardcoded to one host.
|
|
#
|
|
# Idempotent: re-running for the same tag reuses the existing Release and
|
|
# replaces same-named assets, so a re-run (or workflow_dispatch retry) is safe.
|
|
set -euo pipefail
|
|
|
|
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required (runner-injected; needs contents:write)}"
|
|
: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
|
|
: "${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="$RELEASE_TAG"
|
|
AUTH=(-H "Authorization: token $GITHUB_TOKEN")
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
BUNDLE_ROOT="$REPO_ROOT/desktop/src-tauri/target/release/bundle"
|
|
# Cross-compiled Windows output lands under the target triple, not the host root.
|
|
WIN_BUNDLE_ROOT="$REPO_ROOT/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
|
|
|
|
# --- collect the assets to upload -------------------------------------------
|
|
shopt -s nullglob
|
|
# nullglob (set above) drops the patterns that didn't match, which is what lets the
|
|
# 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
|
|
exit 1
|
|
fi
|
|
echo "==> Publishing release $TAG with ${#ASSETS[@]} asset(s):"
|
|
for a in "${ASSETS[@]}"; do echo " $(basename "$a") ($(du -h "$a" | cut -f1))"; done
|
|
|
|
# curl wrapper that returns the response body on stdout and fails the script on
|
|
# an HTTP >=400 that we didn't explicitly allow (via ALLOW_CODES).
|
|
api() {
|
|
local method="$1" url="$2"; shift 2
|
|
local out code
|
|
out="$(curl -sS -X "$method" "${AUTH[@]}" -w $'\n%{http_code}' "$url" "$@")"
|
|
code="${out##*$'\n'}"
|
|
out="${out%$'\n'*}"
|
|
if [ "$code" -ge 400 ] && [[ " ${ALLOW_CODES:-} " != *" $code "* ]]; then
|
|
echo "ERROR: $method $url -> HTTP $code" >&2
|
|
echo "$out" >&2
|
|
return 1
|
|
fi
|
|
printf '%s' "$out"
|
|
}
|
|
|
|
# First integer value of a "id": <n> pair — the release id is the first "id" in
|
|
# the release object. Avoids a jq dependency (not guaranteed in the CI image).
|
|
first_id() { grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+'; }
|
|
|
|
# --- 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":$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
|
|
)
|
|
# 409 = a release for this tag already exists (re-run) — fall through to lookup.
|
|
release="$(ALLOW_CODES=409 api POST "$API/releases" -H "Content-Type: application/json" -d "$BODY")"
|
|
RELEASE_ID="$(printf '%s' "$release" | first_id || true)"
|
|
|
|
if [ -z "${RELEASE_ID:-}" ]; then
|
|
echo " release exists; fetching it by tag"
|
|
release="$(api GET "$API/releases/tags/$TAG")"
|
|
RELEASE_ID="$(printf '%s' "$release" | first_id)"
|
|
fi
|
|
[ -n "${RELEASE_ID:-}" ] || { echo "ERROR: could not resolve release id" >&2; exit 1; }
|
|
echo " release id = $RELEASE_ID"
|
|
|
|
# Existing assets (name -> id), so a re-run replaces rather than duplicates.
|
|
existing="$(api GET "$API/releases/$RELEASE_ID/assets")"
|
|
|
|
# --- upload each asset ------------------------------------------------------
|
|
for asset in "${ASSETS[@]}"; do
|
|
name="$(basename "$asset")"
|
|
# If an asset with this name already exists, delete it first (Forgejo rejects
|
|
# a duplicate name). Match the "id" that precedes this asset's "name".
|
|
old_id="$(printf '%s' "$existing" \
|
|
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"$name\"" \
|
|
| first_id || true)"
|
|
if [ -n "${old_id:-}" ]; then
|
|
echo "==> Replacing existing asset $name (id $old_id)"
|
|
api DELETE "$API/releases/$RELEASE_ID/assets/$old_id" >/dev/null
|
|
fi
|
|
echo "==> Uploading $name"
|
|
api POST "$API/releases/$RELEASE_ID/assets?name=$name" -F "attachment=@$asset" >/dev/null
|
|
done
|
|
|
|
echo "==> Done. Release $TAG published with $(printf '%s\n' "${ASSETS[@]##*/}" | tr '\n' ' ')"
|