Files
thoughtsync/desktop/packaging/write-manifest.sh
T
bvandeusenandClaude Opus 5 72968897ab
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m13s
channels: the dev channel publishes on dev-rolling, so its tag stops shadowing the branch
The rolling dev release lived on a tag named `dev`, beside the branch
named `dev`. Once a clone had fetched tags, `git push origin dev` failed
with "src refspec dev matches more than one" (Scribe #2184, note #3042),
and every session had to know to spell out refs/heads/dev.

The channel is still `dev` everywhere a person sees it: the app's
setting, `install.sh --channel dev`, the stored pref. Only the release
tag moves, to `dev-rolling`, matching roundtable-android. `stable` has no
branch to collide with and keeps its name.

- packaging/channel-tag.sh is the one channel -> tag mapping CI reads:
  the publish steps in android.yml and desktop.yml, the manifest job,
  fetch-clients.sh and guard-forward.sh. guard-forward exits 2 on an
  unmapped channel instead of fetching an empty URL and passing.
- update.rs and install.sh carry their own copy because neither can run
  it; update.rs gains a test that no channel feed is named like a branch.
- tests/test_channel_tag.py runs the script: no tag is a branch name,
  dev is exactly dev-rolling, an unknown channel fails with no output.
- publish-release.sh titles the release "ThoughtSync dev (rolling)", so
  the tag name does not leak into what people read.

TEMPORARY bridge: desktop apps installed before this have
.../download/dev/latest.json compiled in. The dev manifest job sets
BRIDGE_TAG=dev, and write-manifest.sh writes the same latest.json to
the old `dev` release. Its URLs name dev-rolling assets, so those apps
update once into a build that reads the new tag. The bridge, and the old
release and tag, are removed once installed apps have crossed over.
Until then the push still needs the explicit refspec, as
ci-requirements.md now says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
2026-09-10 18:34:11 -04:00

240 lines
12 KiB
Bash

#!/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": { "<target>-<arch>": { "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"
# --- TEMPORARY: the one-shot bridge off a retired channel tag ------------------
#
# The dev channel's tag was renamed `dev` -> `dev-rolling`, because a tag named `dev`
# shadowed the branch and broke `git push origin dev` (Scribe #2184). Desktop apps
# installed before the rename have `.../download/dev/latest.json` compiled in, so
# without this they would never be offered another build.
#
# BRIDGE_TAG names the old release; the SAME manifest is written there too. Its URLs
# are absolute and name `dev-rolling` assets, so an old app updates once into a build
# that reads the new tag, and never reads this one again. Only latest.json goes
# across: the sidecar is read by CI and the image build, which already follow the
# new tag.
#
# A missing bridge release is logged and skipped, not fatal — by then it has been
# deleted on purpose. A failed WRITE to one that exists fails the job: a bridge that
# silently stops is an installed base stranded without a word. Remove this block,
# the BRIDGE_TAG line in desktop.yml, and the old release and tag together.
if [ -n "${BRIDGE_TAG:-}" ]; then
bridge_release="$(curl -sS "${AUTH[@]}" "$API/releases/tags/$BRIDGE_TAG" || true)"
bridge_id="$(printf '%s' "$bridge_release" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+' || true)"
if [ -z "$bridge_id" ]; then
echo "==> No release tagged $BRIDGE_TAG — nothing to bridge (retired?)."
else
# A subshell, so replace_asset's globals point at the bridge release for this
# one call only and the prune below still works on $RELEASE_TAG.
(
release_id="$bridge_id"
RELEASE_TAG="$BRIDGE_TAG"
assets="$(curl -sS "${AUTH[@]}" "$API/releases/$release_id/assets")"
replace_asset "$work/latest.json" "latest.json"
)
echo "==> Bridged: $BRIDGE_TAG/latest.json now advertises the same build."
fi
fi
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