From 36c05f50296bdc58d14455e977fe72f3e2c2b177 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Jul 2026 14:13:50 -0400 Subject: [PATCH] desktop: release-publish pipeline + one-command Linux installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A of the desktop Release + install path (M10 / tasks 2014, 1998): - .forgejo/workflows/desktop.yml: tag-gated "Publish release" step + contents:write. On a v* tag the build now publishes a Fabled-Git Release with the de-bundled AppImage + .deb attached — a stable, versioned fetch target (Actions artifacts are ephemeral/test-only). Dormant on dev/main. - desktop/packaging/publish-release.sh: creates/reuses the Release via the Forgejo API using the runner-injected token; idempotent asset replace. - desktop/packaging/install.sh: curl|sh one-command installer — native .deb on Debian/Ubuntu, de-bundled AppImage everywhere else (installed to ~/Applications/ThoughtSync.AppImage, matching src/integration.rs so the app sees itself integrated). AppImage path needs no sudo. Plumbing only — no release cut (rule 2); activates on the operator's first v* tag. In-app self-update (tauri-plugin-updater + signed latest.json) is Phase B, gated on the operator's signing key. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- .forgejo/workflows/desktop.yml | 16 +++- desktop/packaging/install.sh | 123 +++++++++++++++++++++++++++ desktop/packaging/publish-release.sh | 111 ++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 1 deletion(-) create mode 100755 desktop/packaging/install.sh create mode 100755 desktop/packaging/publish-release.sh diff --git a/.forgejo/workflows/desktop.yml b/.forgejo/workflows/desktop.yml index 0d3e5d5..02e6347 100644 --- a/.forgejo/workflows/desktop.yml +++ b/.forgejo/workflows/desktop.yml @@ -25,7 +25,10 @@ concurrency: cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} permissions: - contents: read + # write (not read) so the tag build can publish a Release with the bundles + # attached (the "Publish release" step). Read is enough for dev/main builds, + # but the token scope is per-workflow, so it's set once here. + contents: write jobs: build: @@ -89,3 +92,14 @@ jobs: desktop/src-tauri/target/release/bundle/appimage/*.AppImage desktop/src-tauri/target/release/bundle/deb/*.deb if-no-files-found: warn + + # Tag builds only: publish a real, versioned Fabled-Git Release with the + # AppImage + .deb attached — the stable fetch target the install script and + # the in-app updater consume (Actions artifacts above are ephemeral/test). + # Cutting the tag is the operator's action (rule 2); this only publishes a + # Release for a tag that already exists. Dormant on dev/main pushes. + - name: Publish release + if: startsWith(github.ref, 'refs/tags/v') + env: + GITHUB_TOKEN: ${{ github.token }} + run: bash desktop/packaging/publish-release.sh diff --git a/desktop/packaging/install.sh b/desktop/packaging/install.sh new file mode 100755 index 0000000..cf6eb63 --- /dev/null +++ b/desktop/packaging/install.sh @@ -0,0 +1,123 @@ +#!/bin/sh +# +# ThoughtSync desktop — one-command Linux installer. +# +# curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/raw/branch/main/desktop/packaging/install.sh | sh +# +# Fetches the LATEST published release for this machine's architecture and +# installs it, ending with a working app + menu entry. Native-first: +# * Debian/Ubuntu (dpkg+apt) -> the native .deb (system libs; needs sudo). +# * everything else (Arch/CachyOS/Fedora/…) -> the de-bundled AppImage, +# installed user-locally (no sudo). The AppImage's graphics libs are +# stripped in CI (see debundle-graphics.sh), so it uses the host GPU stack +# and renders where a stock Tauri AppImage would black-window (issue 2021). +# +# POSIX sh (dash-safe) so `curl … | sh` works everywhere. Dependency-light and +# auditable on purpose — read it before you pipe it. +set -eu + +INSTANCE="https://git.fabledsword.com" +REPO="bvandeusen/thoughtsync" +API="$INSTANCE/api/v1/repos/$REPO" + +say() { printf '==> %s\n' "$1"; } +die() { printf 'error: %s\n' "$1" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } + +have curl || die "curl is required." + +# --- architecture gate ------------------------------------------------------ +# Only x86_64 is built today; arm64 will be added when the CI matrix grows. The +# release-asset naming carries the arch, but since only one arch ships now we +# match by file extension below and just guard the arch here. +arch="$(uname -m)" +case "$arch" in + x86_64 | amd64) : ;; + *) die "ThoughtSync ships x86_64 Linux builds only right now (this machine: $arch)." ;; +esac + +# --- resolve the latest release --------------------------------------------- +say "Finding the latest ThoughtSync release…" +json="$(curl -fsSL "$API/releases/latest" 2>/dev/null)" || die \ + "no published release found at $INSTANCE/$REPO/releases — the maintainer publishes one by pushing a v* tag." + +# Pull asset URLs straight out of the release JSON (no jq): the only +# .AppImage/.deb URLs present are the asset download links. +appimage_url="$(printf '%s' "$json" | grep -oE 'https?://[^"]+\.AppImage' | head -1 || true)" +deb_url="$(printf '%s' "$json" | grep -oE 'https?://[^"]+\.deb' | head -1 || true)" +version="$(printf '%s' "$json" | grep -oE '"tag_name":"[^"]+"' | head -1 | sed -E 's/.*:"([^"]+)".*/\1/')" +[ -n "$appimage_url" ] || [ -n "$deb_url" ] || die "the latest release has no installable Linux asset." +say "Latest release: ${version:-unknown}" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT INT TERM + +# --- native .deb path (Debian/Ubuntu) --------------------------------------- +if have dpkg && have apt-get && [ -n "$deb_url" ]; then + say "Debian-family system detected — installing the native .deb" + curl -fSL -o "$tmp/thoughtsync.deb" "$deb_url" + if [ "$(id -u)" -eq 0 ]; then sudo=""; else + have sudo || die "installing the .deb needs root; re-run as root or install sudo." + sudo="sudo" + fi + say "Installing (you may be prompted for your password)…" + # apt-get resolves the .deb's dependencies (webkit2gtk etc.); dpkg is the + # fallback if this apt is too old for local-file installs. + $sudo apt-get install -y "$tmp/thoughtsync.deb" || $sudo dpkg -i "$tmp/thoughtsync.deb" + say "Done. Launch ThoughtSync from your application menu." + exit 0 +fi + +# --- universal AppImage path (user-local, no sudo) -------------------------- +# Install into ~/Applications/ThoughtSync.AppImage — the SAME location the app's +# own self-integration uses (src/integration.rs) — so the running app sees +# itself already installed and never makes a second copy or menu entry. +say "Installing the de-bundled AppImage (user-local, no sudo)" +[ -n "$appimage_url" ] || die "no AppImage asset on the latest release." + +apps_dir="$HOME/Applications" +dest="$apps_dir/ThoughtSync.AppImage" +mkdir -p "$apps_dir" +say "Downloading $(basename "$appimage_url")…" +curl -fSL -o "$tmp/ThoughtSync.AppImage" "$appimage_url" +chmod +x "$tmp/ThoughtSync.AppImage" +mv -f "$tmp/ThoughtSync.AppImage" "$dest" + +# Menu entry — written to match integration.rs verbatim (same paths + fields), +# so the app reports is_integrated=true and won't duplicate it. +apps_menu="$HOME/.local/share/applications" +icons_dir="$HOME/.local/share/icons" +mkdir -p "$apps_menu" "$icons_dir" + +# Best-effort: pull the real icon out of the AppImage (.DirIcon) so the menu +# entry looks right immediately. Extraction is a non-GUI unsquash (no FUSE, no +# black-window risk); if it fails we fall back to the themed name and the app +# writes its embedded icon on first launch anyway. +icon_ref="thoughtsync" +if ( cd "$tmp" && "$dest" --appimage-extract .DirIcon >/dev/null 2>&1 ) \ + && cp -L "$tmp/squashfs-root/.DirIcon" "$icons_dir/thoughtsync.png" 2>/dev/null; then + icon_ref="$icons_dir/thoughtsync.png" +fi +rm -rf "$tmp/squashfs-root" 2>/dev/null || true + +cat > "$apps_menu/thoughtsync.desktop" </dev/null 2>&1 || true + +# Convenience CLI launcher. +mkdir -p "$HOME/.local/bin" +ln -sf "$dest" "$HOME/.local/bin/thoughtsync" + +say "Installed to $dest" +printf ' Launch it from your application menu, or run \033[1mthoughtsync\033[0m' +printf ' (if ~/.local/bin is on your PATH).\n' diff --git a/desktop/packaging/publish-release.sh b/desktop/packaging/publish-release.sh new file mode 100755 index 0000000..9c7a79e --- /dev/null +++ b/desktop/packaging/publish-release.sh @@ -0,0 +1,111 @@ +#!/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 +# +# 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)}" + +API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY" +TAG="$GITHUB_REF_NAME" +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" + +# --- collect the assets to upload ------------------------------------------- +shopt -s nullglob +ASSETS=( + "$BUNDLE_ROOT"/appimage/*.AppImage + "$BUNDLE_ROOT"/deb/*.deb +) +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": 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 <&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' ' ')"