#!/usr/bin/env bash # # Write the updater manifest (`latest.json`) for one channel and attach it to that # channel's release. # # WHY A SEPARATE STEP: the Linux and Windows bundles are built by two jobs in two # workspaces, and neither can see the other's output — but ONE manifest has to # describe both platforms. So this runs after both, reads what actually landed on # the release, and writes the manifest from that. Building it inside either job # would produce a manifest that silently omits the other platform, and a missing # platform reads to a user as "no update available" rather than as a broken feed. # # WHAT IT READS: the release's own asset list. The signature for each bundle is a # `.sig` asset published beside it (see publish-release.sh); its CONTENT is what # goes in the manifest, which is why each one is downloaded rather than linked. # # Tauri's expected shape: # { "version": "0.1.0", "pub_date": "...", "notes": "...", # "platforms": { "-": { "signature": "...", "url": "..." } } } set -euo pipefail : "${GITHUB_TOKEN:?GITHUB_TOKEN is required}" : "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}" : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (owner/repo)}" : "${RELEASE_TAG:?RELEASE_TAG is required (the release holding the bundles)}" : "${APP_VERSION:?APP_VERSION is required (the version the bundles carry)}" # The version a PERSON reads, published beside the manifest so the image build can # describe the bundles it bakes in without re-deriving anything. Required rather # than defaulted: a missing value here would silently publish a sidecar naming the # wrong build, and there is nothing downstream that could catch it. : "${DISPLAY_VERSION:?DISPLAY_VERSION is required (the human-readable version)}" # The manifest is published to the release that HOLDS the bundles. There is no # second place any more. # # There used to be: `MANIFEST_TAG` let the manifest live on a `stable` pointer # release while the bundles sat on a versioned `v*` one, because the app can only # read a URL that never changes and a versioned tag is not that. M314 step 3 made # `stable` a rolling release that holds its own bundles, exactly like `dev`, so the # split had nothing left to bridge — and a parameter that can only ever be passed # its own default is a branch nobody exercises and a comment that goes stale. API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY" AUTH=(-H "Authorization: token $GITHUB_TOKEN") NOTES="${RELEASE_NOTES:-}" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT INT TERM echo "==> Reading assets on release $RELEASE_TAG" release="$(curl -sS "${AUTH[@]}" "$API/releases/tags/$RELEASE_TAG")" release_id="$(printf '%s' "$release" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')" [ -n "$release_id" ] || { echo "ERROR: no release tagged $RELEASE_TAG" >&2; exit 1; } assets="$(curl -sS "${AUTH[@]}" "$API/releases/$release_id/assets")" # Asset names, one per line. The API returns them in a single JSON blob; this is # the only field needed, and grep beats adding a jq dependency to the CI image. names="$(printf '%s' "$assets" | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' | sed -E 's/.*"([^"]+)"$/\1/')" download_url() { printf '%s/%s/releases/download/%s/%s' "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$RELEASE_TAG" "$1"; } # One platform entry, or nothing if that platform's bundle or signature is absent. # Emitting a partial entry would be worse than emitting none: the app would try to # install something it can't verify. platform_entry() { local target="$1" pattern="$2" bundle sig_name # Matched on THIS build's version, not just the file extension. # # The rolling dev release accumulates every build's assets, and picking the first # extension match returned the OLDEST one — so the manifest advertised the new # version while pointing at an old binary. The client would install the older # build, still be told the newer version was available, and update forever. The # signature check couldn't catch it either: the old bundle's signature is # perfectly valid FOR THE OLD BUNDLE. bundle="$(printf '%s\n' "$names" | grep -F "_${APP_VERSION}_" | grep -E "$pattern" | head -1 || true)" # A hard failure, not a skip: reaching here means this platform's build didn't # upload, and the whole point is to never advertise a bundle that isn't there. [ -n "$bundle" ] || { echo " no $APP_VERSION bundle matching $pattern — skipping $target" >&2; return; } sig_name="$bundle.sig" if ! printf '%s\n' "$names" | grep -qxF "$sig_name"; then echo " $bundle has no $sig_name — skipping $target (was the build signed?)" >&2 return fi curl -fsSL "${AUTH[@]}" -o "$work/sig" "$(download_url "$sig_name")" # The signature is base64 on one line already; strip any stray newline so it # can't break the JSON string it's about to become. local signature signature="$(tr -d '\r\n' < "$work/sig")" printf ' "%s": { "signature": "%s", "url": "%s" }' "$target" "$signature" "$(download_url "$bundle")" } echo "==> Building the manifest" entries=() # `.AppImage` only on Linux: the updater replaces the running bundle in place, which # a package-manager install (deb/pacman) must never have done to it. if entry="$(platform_entry "linux-x86_64" '\.AppImage$')" && [ -n "$entry" ]; then entries+=("$entry"); fi if entry="$(platform_entry "windows-x86_64" '\.exe$')" && [ -n "$entry" ]; then entries+=("$entry"); fi if [ ${#entries[@]} -eq 0 ]; then echo "ERROR: no signed bundle on $RELEASE_TAG — refusing to publish an empty manifest." >&2 echo " (An empty manifest would tell every client it is up to date.)" >&2 exit 1 fi # No `date -u -Is` — busybox date in the CI image doesn't take it. pub_date="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" { printf '{\n' printf ' "version": "%s",\n' "$APP_VERSION" printf ' "pub_date": "%s",\n' "$pub_date" printf ' "notes": "%s",\n' "$NOTES" printf ' "platforms": {\n' for i in "${!entries[@]}"; do [ "$i" -eq 0 ] || printf ',\n' printf '%s' "${entries[$i]}" done printf '\n }\n' printf '}\n' } > "$work/latest.json" echo "==> Manifest:" cat "$work/latest.json" # Both files go on the same release the bundles were just read from — which is also # the one `publish-release.sh` created or refreshed moments earlier, so it is # guaranteed to exist by the time this runs. # Replace rather than duplicate: Forgejo rejects a second asset with the same name, # and these files are rewritten on every publish by design. replace_asset() { local path="$1" name="$2" escaped old_id escaped="${name//./\\.}" old_id="$(printf '%s' "$assets" \ | grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"$escaped\"" \ | head -1 | grep -oE '[0-9]+' | head -1 || true)" if [ -n "${old_id:-}" ]; then echo "==> Removing the previous $name (id $old_id)" curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$release_id/assets/$old_id" >/dev/null fi echo "==> Uploading $name to $RELEASE_TAG" curl -fsS -X POST "${AUTH[@]}" "$API/releases/$release_id/assets?name=$name" \ -F "attachment=@$path" >/dev/null } replace_asset "$work/latest.json" "latest.json" # The version pair, for whoever needs to describe these bundles without rebuilding # them — today the image build, which bakes the desktop clients in and writes each # one a sidecar (`packaging/fetch-clients.sh`). # # It is published HERE, beside the manifest, because this is the step that speaks # for what the channel serves: both files are written in the same breath from the # same two values, so they cannot disagree about which build is current. A consumer # deriving the version from its own checkout instead would describe these bytes # with whatever commit it happened to be on. # # No `size` or `sha256` — those are per-artifact and there are four. Whoever # downloads a bundle measures the bytes it actually got, which is the only way to # tell a truncated download from a whole one. printf '{\n "version_name": "%s",\n "version_code": "%s"\n}\n' \ "$DISPLAY_VERSION" "$APP_VERSION" > "$work/thoughtsync-desktop.json" replace_asset "$work/thoughtsync-desktop.json" "thoughtsync-desktop.json" echo "==> Done. $RELEASE_TAG now advertises $DISPLAY_VERSION ($APP_VERSION) for ${#entries[@]} platform(s)." # --- prune superseded builds from a rolling channel --------------------------- # # The dev release is republished on every push and its assets otherwise accumulate # forever — an AppImage alone is ~100 MB, so a week of pushes is gigabytes on the # Git host for builds nobody can reach (the manifest only ever names the newest). # # Only for a rolling channel. A versioned release must keep its assets: that IS the # archive, and the stable pointer's URLs aim at it. if [ "${PRUNE_OLD_ASSETS:-false}" = "true" ]; then echo "==> Pruning superseded assets from $RELEASE_TAG" # Re-read: the manifest upload above changed the asset list. current="$(curl -sS "${AUTH[@]}" "$API/releases/$release_id/assets")" printf '%s' "$current" \ | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+[^}]*"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ | while IFS= read -r row; do asset_id="$(printf '%s' "$row" | grep -oE '[0-9]+' | head -1)" asset_name="$(printf '%s' "$row" | sed -E 's/.*"name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')" # Keep FIXED-NAME assets and everything belonging to the current build. # # A fixed name is self-limiting: each publish replaces that same name, so # it cannot accumulate and the reason this prune exists does not apply to # it. It is also the only kind of URL that stays addressable on a rolling # tag, which is the whole point of having one — deleting it breaks # whatever was pointing at it. # # The Android client is on that list for a second reason too: it is built # by a DIFFERENT workflow with its own run number, so its version never # matches $APP_VERSION here and a version-stamped name would be pruned on # every desktop push regardless. That is exactly what happened on run # 4098, which swept the APK run 4092 had just published. case "$asset_name" in latest.json|thoughtsync-desktop.json|thoughtsync.apk|thoughtsync-android.json) continue ;; *"$APP_VERSION"*) continue ;; esac echo " removing $asset_name" curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$release_id/assets/$asset_id" >/dev/null || \ echo " (couldn't remove $asset_name — leaving it)" >&2 done fi