desktop: release-publish pipeline + one-command Linux installer
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m45s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m45s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -25,7 +25,10 @@ concurrency:
|
|||||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
|
||||||
permissions:
|
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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -89,3 +92,14 @@ jobs:
|
|||||||
desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||||
desktop/src-tauri/target/release/bundle/deb/*.deb
|
desktop/src-tauri/target/release/bundle/deb/*.deb
|
||||||
if-no-files-found: warn
|
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
|
||||||
|
|||||||
Executable
+123
@@ -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" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=ThoughtSync
|
||||||
|
Comment=Capture a fleeting thought in a second
|
||||||
|
Exec=$dest %U
|
||||||
|
Icon=$icon_ref
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;Office;
|
||||||
|
StartupWMClass=ThoughtSync
|
||||||
|
EOF
|
||||||
|
|
||||||
|
have update-desktop-database && update-desktop-database "$apps_menu" >/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'
|
||||||
Executable
+111
@@ -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": <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":false,
|
||||||
|
"body":"ThoughtSync desktop $TAG. Linux AppImage (de-bundled graphics — renders on any GPU/Wayland setup) + .deb.\n\nInstall / update:\n\`\`\`\ncurl -fsSL $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/branch/main/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' ' ')"
|
||||||
Reference in New Issue
Block a user