Files
thoughtsync/desktop/packaging/publish-release.sh
T
bvandeusen d77a79859c
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 5m32s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 8m8s
server: hand out the Android client this server syncs with (2726)
A self-hoster should not need an account on someone else's forge to get the app
for their own notes. The Fabled-Git instance is private — which is why
`install.sh` already cannot fetch for anyone but the operator — so a release page
is no use as a distribution point. The server holding the notes is something the
person already trusts and already reaches.

It also keeps the pair in step by construction. Client and server negotiate a
sync protocol version before linking, so a server that also serves the client
cannot hand out a phone it is unable to talk to.

**Two files, and both must be present**: `thoughtsync.apk` and a
`thoughtsync-android.json` sidecar carrying `{version_name, version_code, size,
sha256}`. The sidecar exists because an APK keeps its version in a binary AXML
manifest, which Python cannot read and which is not worth putting `aapt` on a
Quart server to reach. CI writes it beside the APK, where the values are already
known — including the digest, computed over the same bytes it uploads, so a
phone can tell a truncated download from a complete one before handing it to the
installer. Not a trust anchor; the signature is that.

**Under DATA_DIR, not baked into the image.** Baking charges ~55 MiB to every
self-hoster including everyone who never touches Android. `/var/thoughtsync` is
already the mounted volume that holds attachments, so a build dropped there
survives container recreation.

**Absence is an ordinary state, not an error.** No APK means the key is absent
from `/api/config` — absent rather than null, so a client testing for it cannot
confuse "this server has no client" with "this server predates the field" — the
web UI hides the card instead of offering a button that 404s, and the metadata
route answers 404. A server whose owner does not use Android is not misconfigured.

**A mismatched pair also counts as no client.** If the sidecar's recorded size
does not match the file on disk, the two did not arrive together; serving one
build while advertising another is worse than serving none, because the phone
would compare versions against a promise the bytes do not keep. That makes the
copy order in docs/android-distribution.md load-bearing, and it is written down
there: APK first, sidecar last.

**The version is public, the bytes are not.** An updater has to be able to ask
"is there something newer?" cheaply and before it has done anything; 55 MiB is
not for anyone who can reach the port. `login_required` already accepts either a
session cookie or a device bearer token, so the browser and a linked phone both
work with no second auth path.

The Android lane now publishes both files to the same rolling `dev` release the
desktop bundles use, reusing `publish-release.sh` — its nullglob asset list was
already built for several jobs in separate workspaces publishing to one release,
which is exactly this. Signed builds only: publishing an unsigned APK would offer
people something they cannot install over what they already have.

Nine tests, DB-free like the rest of the suite — this lane runs no Postgres, so
the advertisement is asserted through `advertisement()` rather than through
`/api/config`, whose other half needs a database. Both routes ARE exercised,
because neither opens a session.
2026-08-20 20:11:32 -04:00

164 lines
8.6 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:
# target/release/bundle/appimage/*.AppImage (de-bundled)
# target/release/bundle/deb/*.deb
# target/release/bundle/arch/*.pkg.tar.* (prebuilt pacman)
# 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/target/release/bundle"
# Cross-compiled Windows output lands under the target triple, not the host root.
WIN_BUNDLE_ROOT="$REPO_ROOT/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.
# The Android client publishes here too, from its own job and its own workspace
# — the same nullglob arrangement that already lets the Linux and Windows jobs
# share one release. Its two files are staged under android/dist by the workflow:
# a STABLY NAMED apk (a fixed name is the whole point of the fixed `dev` tag —
# Forgejo has no /releases/latest/download route) and the sidecar the server reads
# its version out of, because an APK keeps that in a binary manifest.
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
"$REPO_ROOT"/android/dist/thoughtsync.apk
"$REPO_ROOT"/android/dist/thoughtsync-android.json
)
if [ ${#ASSETS[@]} -eq 0 ]; then
echo "ERROR: nothing to publish — no desktop bundles under $BUNDLE_ROOT and no APK under $REPO_ROOT/android/dist." >&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 ------------------------------
# The install command printed on a release has to install THAT release's channel.
# install.sh defaults to stable, so the rolling dev release must opt in explicitly
# — otherwise someone following the instructions here lands on a tagged build and
# wonders why the version they were sent isn't what they got.
if [ "$TAG" = "dev" ]; then
INSTALL_TAIL='sh -s -- --channel dev'
# Backticks BARE, not `\``. The heredoc below is unquoted, so there the backslash
# is the shell's — it suppresses command substitution and never reaches the JSON.
# Here single quotes already do that job, so a backslash would survive into the
# body as `\``, which is not a legal JSON escape: Forgejo answers 422.
CHANNEL_NOTE='\n\nThis is the rolling **dev** channel: republished on every green push to `dev`, and pruned to the current build.'
else
INSTALL_TAIL='sh'
CHANNEL_NOTE=''
fi
echo "==> Creating release for $TAG"
BODY=$(cat <<JSON
{"tag_name":"$TAG","name":"ThoughtSync $TAG","draft":false,"prerelease":$RELEASE_PRERELEASE,
"body":"ThoughtSync $TAG.\n\n**Desktop**\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 | $INSTALL_TAIL\n\`\`\`\n\n**Android** — \`thoughtsync.apk\`. Copy it and \`thoughtsync-android.json\` into your server's \`/var/thoughtsync/client/\` and the server will offer it to your devices; see docs/android-distribution.md.$CHANNEL_NOTE"}
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)"
# And refresh its description. A fixed-tag release (dev, and any re-run) keeps
# whatever text the FIRST build wrote — including the install command. A stale
# one sends people to the wrong channel, silently.
echo " refreshing its description"
api PATCH "$API/releases/$RELEASE_ID" -H "Content-Type: application/json" -d "$BODY" >/dev/null
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' ' ')"