Files
thoughtsync/desktop/packaging/publish-release.sh
T
bvandeusen e6da720e6b
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m34s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m42s
Desktop (Tauri) / Update manifest (push) Successful in 5s
packaging: drop assets that aren't there, instead of trusting nullglob
`d77a798` added the Android client to publish-release.sh's asset list and broke
the desktop lane's publish, which had been working (run 4094, curl exit 26 —
"couldn't read local file"). The Android lane published fine, which is what made
the shape of the mistake clear.

`shopt -s nullglob` drops PATTERNS that match nothing. The two entries I added —
`android/dist/thoughtsync.apk` and its sidecar — contain no wildcard, so they are
not patterns at all: globbing leaves them in the array verbatim and curl is handed
a path to a file that does not exist. In the Android job those files are there, so
it worked; in the desktop job they never are, so it did not.

Every entry is now filtered on existence, which is what the array has always
meant. That covers the literal paths and the globs alike, rather than relying on
each future entry containing a `*` to be safe — the trap that just cost a run.

Verified both ways before pushing: a literal missing path survives nullglob and is
removed by the filter, and an all-empty result still exits cleanly under `set -u`.
2026-08-20 20:20:58 -04:00

176 lines
9.2 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
)
# nullglob drops PATTERNS that match nothing — it does nothing for a path with no
# wildcard in it, which stays in the array as a literal and reaches curl as a file
# that isn't there (exit 26). The Android entries above are exactly that shape, and
# adding them broke the desktop publish that had been working. Filter on existence
# instead, which is what the array actually means and covers every entry rather
# than only the ones that happen to contain a `*`.
present=()
for a in "${ASSETS[@]}"; do
[ -f "$a" ] && present+=("$a")
done
ASSETS=("${present[@]}")
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' ' ')"