Files
inkwell/desktop/packaging/write-manifest.sh
T
bvandeusenandClaude Opus 5.5 4b4659157e
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 14s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 18s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / integration (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m51s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Retire the bridge to the old dev channel release
Scribe #3884. The dev channel's release tag moved from `dev` to `dev-rolling`
on 2026-09-10. Since then, the manifest job has also written latest.json to the
old `dev` release, so that desktop apps installed before the move could update
across. The operator has had two desktop installs and is fine reinstalling,
so the bridge goes. The old release and tag are deleted next, through the forge.

- desktop.yml: the BRIDGE_TAG=dev export is removed.
- write-manifest.sh: the TEMPORARY bridge block is removed.
- ci-requirements.md: the "Transitional" paragraph becomes a note that the tag
  is gone, and that an app installed before 2026-09-10 reinstalls with
  install.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:46:37 -04:00

205 lines
10 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/inkwell-desktop.json"
replace_asset "$work/inkwell-desktop.json" "inkwell-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|inkwell-desktop.json|inkwell.apk|inkwell-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