Files
Triple-C/scripts/finalize-appimage.sh
shadowdaoandClaude Opus 5 d38736007f
Secret Scan / scan (push) Successful in 3s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-linux (pull_request) Failing after 2m3s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / prune-previews (pull_request) Skipped
Take the re-review: distinguish "absent" from "unreachable"
Second review of this branch. Two blockers and one real defect I had papered
over with a true-but-misleading claim.

**`make_latest` was missing from the republish path.** The create path sends
`"make_latest": "false"` so the channel cannot displace the versioned release
on the releases page. The reuse path — taken on every run after the first —
omitted it, and the API's documented default for a publish transition is
`true`. So the second release would have quietly promoted `linux-latest` to
the repository's Latest release: a release whose own body says "for a specific
version, use the versioned releases instead". Now sent on both paths.
`tag_name` is re-sent deliberately and now says so in a comment — the API
removes the tag when a PATCH omits it, and this branch exists because a tag
disappeared.

**A transient Gitea error would have cost the whole release.** `curl -sf`
fails identically for "404, the tag is genuinely absent" and "503, Gitea is
briefly unreachable", and both landed in the create branch. Creating a tag that
already exists returns 409, which aborted the last step of `build-linux` — and
`create-tag` and `sync-to-github` both depend on it, so no version tag and no
GitHub sync at all. The failure message also read "the tag does not exist" when
Gitea had merely been unreachable. Now a `case` on the HTTP code — 200 leave
alone, 404 create, anything else fail loudly with the real code — the same
idiom `Upload to Gitea release` already uses two steps above. `422
already_exists` on the release POST is likewise a recoverable answer, not a
reason to lose a release.

**The empty `Categories=` was still shipping, and my claim hid it.** I wrote
that the guard "asserts the absence of an empty value rather than the presence
of any filled one" — true of the regex, false of the artifact. The AppDir root
`.desktop` is a *symlink* into usr/share/applications, so `sed -i` replaced the
link with a regular file and left the real entry empty; the guard globbed the
root only, so it saw the copy it had just written and passed. Verified on the
real artifact: two divergent entries, and the one that shipped was empty. Fixed
with `--follow-symlinks`, both locations globbed, and the guard turned into a
positive assertion over every entry — which also closes its missing-key and
unmatched-glob holes. Both entries now read `Categories=Development;Utility;`.

Also taken: the duplicate-AppImage check moves to a precondition, since as a
post-mortem it let the script repack and overwrite the versioned artifact
before failing, and it silently selected by glob order, i.e. the older version
— it now refuses in under a second; assets are deleted and re-uploaded one at
a time, because deleting both up front left a fresh AppImage with no .zsync if
the second upload failed, which silently stops every client; and the success
line no longer claims a fallback was kept when there was nothing to demote.

Left as informational, with the reasoning recorded rather than acted on:
`--retry-all-errors` retries permanent 4xx (fail-closed, matches the repo's
other upload steps); the release list is unpaginated (a GraphQL lookup by
pending tag name is the durable fix, but 7 releases is decades from the cliff,
and the 422 handling above covers the failure mode); process-substitution
failure is invisible to `mapfile` (fail-closed downstream).

Verified against the real 0.4.19 artifact — happy path, no AppImage, two
AppImages, and an AppDir rebuilt with the bundled library removed. shellcheck
clean at warning level on both scripts. appimagetool now reports the AppStream
metadata found.

Nothing here is CI-proven, and that is worth stating plainly: `build-linux`
fails on this branch before `tauri build` even runs, at "Install frontend
dependencies" with `npm error Cannot read properties of null (reading
'edgesOut')` — confirmed in the logs of jobs 5644 and 5636. Unrelated to this
change and tracked separately, but it means the finalizer has never executed
in CI on either commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-03 09:17:34 -07:00

318 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Post-process a built AppImage: make it start on modern Mesa, and make it
# adoptable and updatable by an AppImage manager.
#
# Tauri hands off to linuxdeploy, which offers no hook between building the
# AppDir and packing it, so both jobs are done by unpacking the finished image
# and repacking it. That is also why the update information is embedded here
# rather than passed to the bundler.
#
# ---------------------------------------------------------------------------
# 1. The bundled Wayland client
# ---------------------------------------------------------------------------
#
# linuxdeploy-plugin-gtk bundles libwayland-client.so.0 as a dependency of
# GTK, and `AppRun.wrapped` puts the bundled lib directory ahead of the host's
# on the loader path. The host's Mesa then resolves its Wayland EGL platform
# against *our* copy instead of the system one it was built against, and when
# ours is older than Mesa needs, EGL initialisation fails outright:
#
# Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
#
# WebKitGTK prints that from its own C code and kills the webview, so the
# window comes up blank. Measured on CachyOS with wayland 1.26 / Mesa 26.2.1
# against an AppImage built on Ubuntu 22.04 (wayland 1.20): eleven symbols
# Mesa can ask for are missing from the bundled copy, `wl_proxy_get_display`,
# `wl_proxy_get_queue`, `wl_display_create_queue_with_name` and
# `wl_fixes_interface` among them. Removing this one file from the AppDir
# fixes it; removing libwayland-egl or libepoxy does not.
#
# **Building on a newer runner would not fix this.** libwayland-client is a
# host-coupled library in the same way libGL, libEGL and libdrm are: it has to
# match the compositor and Mesa actually running, not the ones the build
# machine had. Any pinned version is wrong on a system newer than the builder,
# so the only correct version is the host's. That is what AppImage excludelists
# are for; this library simply is not on linuxdeploy's.
#
# Bundling a *newer* wayland instead would not fix this either, only defer it.
# The version floor is set by the host's Mesa: `libEGL_mesa.so.0` — the driver
# libglvnd's `libEGL.so.1` dlopens — carries a hard DT_NEEDED on
# libwayland-client.so.0. If those symbols will not resolve, the driver never
# loads, glvnd is left with none, and `eglGetDisplay` reports no display. That
# is why forcing GDK_BACKEND=x11 does not dodge it, and why the symptom is a
# bad-parameter error rather than a link failure. Their Mesa updates independently of our releases, so any version
# we pick is one wayland release away from being too old again.
#
# So the copy is not deleted, it is demoted. It moves to a directory that is
# not on the loader path, and a hook puts that directory on the path only when
# the host has no libwayland-client of its own. Hosts with one — which is
# every host with a graphical desktop, since Mesa itself depends on it — get
# theirs, matching their Mesa. A host without one still gets a working app.
#
# The ordering works because `AppRun.wrapped` appends the inherited
# LD_LIBRARY_PATH after its own AppDir entries, so anything the hook exports
# lands last: a fallback, never an override.
#
# ---------------------------------------------------------------------------
# 2. Metadata an AppImage manager needs
# ---------------------------------------------------------------------------
#
# Two things, neither of which the bundler produces:
#
# * AppStream metadata, so a manager can show what the app is rather than a
# bare filename. appimagetool warns about its absence on every build.
# * Update information embedded in the image — the string that tells a
# manager where to look for a newer build. Without it the app can be
# adopted but never updated, which is the whole point.
#
# The update URL is a **fixed** tag on the GitHub mirror, which is where
# updates are pulled from, rather than `releases/latest`. `latest` follows
# whatever release is newest, and the Gitea-to-GitHub backfill creates one
# GitHub release per Gitea tag — including the `-win` and `-mac` tags, which
# carry no AppImage. A fixed tag cannot be pointed at a release that has none,
# and is equally immune to a release marked prerelease.
#
# The output is named for the fixed tag too. zsync records the filename it was
# generated for and a client resolves it relative to the .zsync URL, so a
# versioned name would send every client looking for the version it already
# has. The versioned copy is written afterwards for the normal release.
#
# It also fills in `Categories=`, which linuxdeploy leaves empty — that is what
# a desktop menu and most managers use to file the application.
#
# Usage: finalize-appimage.sh <directory holding the .AppImage>
set -euo pipefail
LIB="libwayland-client.so.0"
FALLBACK_DIR="usr/lib/wayland-fallback"
HOOK="apprun-hooks/triple-c-wayland-fallback.sh"
APPIMAGE_TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
APP_ID="com.triple-c.desktop"
# The channel pair lives in its own directory. Left beside the versioned image
# they are picked up by the release job's `*.AppImage` glob, and every release
# then carries an eighty-megabyte byte-identical duplicate under a second name
# — which is exactly as confusing on a downloads page as it sounds.
CHANNEL_DIR="update-channel"
STABLE_NAME="Triple-C_x86_64.AppImage"
UPDATE_TAG="linux-latest"
UPDATE_INFO="zsync|https://github.com/shadowdao/triple-c/releases/download/${UPDATE_TAG}/${STABLE_NAME}.zsync"
CATEGORIES="Development;Utility;"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
appdata_src="$repo_root/packaging/appimage/$APP_ID.appdata.xml"
# appimagetool looks for `<desktop basename>.appdata.xml` and warns the
# metadata is missing under any other name — while the script cheerfully
# reported it present. The AppStream id inside the file is unchanged and is
# what actually identifies the component; only the filename follows the tool.
appdata_installed_as="Triple-C.appdata.xml"
dir="${1:?usage: finalize-appimage.sh <bundle/appimage directory>}"
cd "$dir"
shopt -s nullglob
images=(*.AppImage)
shopt -u nullglob
if [ ${#images[@]} -eq 0 ]; then
echo "No .AppImage in $dir — nothing to do." >&2
exit 0
fi
# Refused here rather than after the repack: with two present the old position
# let the script download appimagetool, repack, overwrite the versioned
# artifact and write the channel pair, *then* fail — and it silently picked
# images[0], which is glob order, i.e. the older version.
if [ ${#images[@]} -ne 1 ]; then
echo "Expected 1 AppImage in $dir, found ${#images[@]}: ${images[*]}" >&2
exit 1
fi
appimage="${images[0]}"
here="$PWD"
work="$(mktemp -d)"
check="$(mktemp -d)"
trap 'rm -rf "$work" "$check"' EXIT
echo "Inspecting $appimage"
( cd "$work" && "$here/$appimage" --appimage-extract >/dev/null )
root="$work/squashfs-root"
# The demotion and the metadata are independent jobs, and an absent library
# must not skip the second. An early exit here also left `update-channel/`
# uncreated, which killed the publish step on a missing directory and took the
# tag and mirror jobs down with it — a half-published release.
demoted=false
if [ -e "$root/usr/lib/$LIB" ]; then
mkdir -p "$root/$FALLBACK_DIR"
mv "$root/usr/lib/$LIB" "$root/$FALLBACK_DIR/$LIB"
cat > "$root/$HOOK" <<'HOOK_EOF'
#! /usr/bin/env bash
# Fall back to the bundled libwayland-client only when the host has none.
#
# The host's copy is the correct one whenever it exists: its Mesa was built
# against it, and `libEGL.so.1` needs symbols from it before it will load.
# Ours is here so a host without any libwayland-client still starts.
#
# This runs before AppRun.wrapped, which appends the inherited
# LD_LIBRARY_PATH after its own entries — so this is always a fallback.
_tc_host_has_wayland_client() {
if command -v ldconfig >/dev/null 2>&1 &&
ldconfig -p 2>/dev/null | grep -q "libwayland-client\.so\.0"; then
return 0
fi
local d
for d in /usr/lib /usr/lib64 /usr/lib/x86_64-linux-gnu \
/lib /lib64 /lib/x86_64-linux-gnu; do
[ -e "$d/libwayland-client.so.0" ] && return 0
done
return 1
}
if ! _tc_host_has_wayland_client; then
_TC_APPDIR="${APPDIR:-"$(dirname "$(readlink -f "$0")")/.."}"
export LD_LIBRARY_PATH="${_TC_APPDIR}/usr/lib/wayland-fallback${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
fi
unset -f _tc_host_has_wayland_client
HOOK_EOF
chmod +x "$root/$HOOK"
# AppRun sources each hook by name rather than globbing the directory, so a
# new hook file is inert until AppRun is told about it.
if ! grep -q "triple-c-wayland-fallback" "$root/AppRun"; then
python3 - "$root/AppRun" <<'PATCH_EOF'
import sys
path = sys.argv[1]
src = open(path).read()
exec_line = 'exec "$this_dir"/AppRun.wrapped "$@"'
if exec_line not in src:
raise SystemExit("AppRun does not have the exec line this patch expects")
src = src.replace(
exec_line,
'source "$this_dir"/apprun-hooks/"triple-c-wayland-fallback.sh"\n' + exec_line,
)
open(path, "w").write(src)
PATCH_EOF
fi
demoted=true
echo "Demoted $LIB to $FALLBACK_DIR."
else
echo "$LIB is not bundled — nothing to demote."
fi
# --- metadata -------------------------------------------------------------
# Version comes from the artifact rather than a second source that could drift.
version="$(printf '%s' "$appimage" | sed -n 's/.*_\([0-9][0-9.]*\)_.*/\1/p')"
[ -n "$version" ] || { echo "Could not read a version out of $appimage" >&2; exit 1; }
if [ -f "$appdata_src" ]; then
mkdir -p "$root/usr/share/metainfo"
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" \
"$appdata_src" > "$root/usr/share/metainfo/$appdata_installed_as"
echo "Added AppStream metadata for $version."
else
echo "No AppStream source at $appdata_src — skipping." >&2
fi
# linuxdeploy emits `Categories=` empty, which files the app nowhere.
#
# The AppDir root entry is a **symlink** into usr/share/applications, so a
# plain `sed -i` replaces the link with a regular file and leaves the real entry
# untouched — two divergent copies, of which the empty one is the one that
# actually ships and the filled one is the only one a root-only guard can see.
# `--follow-symlinks` writes through. Both locations are globbed because the
# layout is linuxdeploy's, not ours, and it is free to stop symlinking.
for desktop in "$root"/*.desktop "$root"/usr/share/applications/*.desktop; do
[ -e "$desktop" ] || continue
if grep -q "^Categories=$" "$desktop"; then
sed -i --follow-symlinks "s/^Categories=$/Categories=$CATEGORIES/" "$desktop"
echo "Filled in Categories for ${desktop#"$root"/}."
fi
done
echo "Repacking."
tool="$work/appimagetool"
curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL"
chmod +x "$tool"
# --appimage-extract-and-run: CI runners generally have no FUSE.
# -u embeds the update string and writes "$STABLE_NAME.zsync" beside the image.
rm -rf "$CHANNEL_DIR"
mkdir -p "$CHANNEL_DIR"
ARCH=x86_64 "$tool" --appimage-extract-and-run \
-u "$UPDATE_INFO" "$root" "$CHANNEL_DIR/$STABLE_NAME" >/dev/null
chmod +x "$CHANNEL_DIR/$STABLE_NAME"
# The versioned name is what the per-version release publishes; the stable one
# and its .zsync go to the rolling tag. Same bytes, two names, two places.
# zsyncmake writes the .zsync into the working directory, not beside the image
# it describes, so it has to be collected rather than assumed in place.
[ -e "$STABLE_NAME.zsync" ] && mv "$STABLE_NAME.zsync" "$CHANNEL_DIR/"
cp "$CHANNEL_DIR/$STABLE_NAME" "$appimage"
chmod +x "$appimage"
# The guards are the test. Each one is a way the repack could look like it
# worked while shipping the original bug.
( cd "$check" && "$here/$appimage" --appimage-extract >/dev/null )
out="$check/squashfs-root"
fail() { echo "FAILED: $1" >&2; exit 1; }
if [ "$demoted" = true ]; then
[ -e "$out/usr/lib/$LIB" ] && fail "$LIB is still on the loader path."
[ -e "$out/$FALLBACK_DIR/$LIB" ] || fail "the fallback copy of $LIB is missing."
[ -e "$out/$HOOK" ] || fail "the fallback hook is missing."
grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook."
fi
[ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c."
# An empty Categories or missing metadata ships an image a manager cannot file
# or describe, and both fail silently at runtime rather than at build time.
# Asserted positively, over every entry: the earlier form checked only that no
# *root* file held an empty value, which passed while the real entry under
# usr/share/applications shipped empty, and also passed on a missing key.
desktops=0
for desktop in "$out"/*.desktop "$out"/usr/share/applications/*.desktop; do
[ -e "$desktop" ] || continue
desktops=$((desktops + 1))
grep -q "^Categories=$CATEGORIES$" "$desktop" \
|| fail "${desktop#"$out"/} does not carry Categories=$CATEGORIES."
done
[ "$desktops" -gt 0 ] || fail "the image contains no .desktop entry at all."
[ -f "$appdata_src" ] && { [ -e "$out/usr/share/metainfo/$appdata_installed_as" ] \
|| fail "AppStream metadata did not make it into the image."; }
# The update string is the difference between adoptable and updatable. It
# lives in the image's own `.upd_info` ELF section, not in the .zsync — the
# .zsync only records a *relative* filename, which a client resolves against
# the URL it fetched the .zsync from. That is exactly why the output is named
# for the fixed tag: a versioned name here resolves to the build the client
# already has.
[ -e "$CHANNEL_DIR/$STABLE_NAME" ] || fail "the stable-named image is missing."
[ -e "$CHANNEL_DIR/$STABLE_NAME.zsync" ] || fail "appimagetool wrote no .zsync."
readelf -p .upd_info "$CHANNEL_DIR/$STABLE_NAME" 2>/dev/null | grep -qF "$UPDATE_INFO" \
|| fail "the image does not carry exactly the expected update information."
grep -aq "^Filename: $STABLE_NAME$" "$CHANNEL_DIR/$STABLE_NAME.zsync" \
|| fail "the .zsync names something other than $STABLE_NAME."
# The versioned release must carry one AppImage, not two. This is the guard
# for the duplicate that shipped in 0.4.20 and 0.4.21.
shopt -s nullglob
beside=(*.AppImage)
shopt -u nullglob
[ "${#beside[@]}" -eq 1 ] \
|| fail "expected 1 AppImage beside the release, found ${#beside[@]}."
if [ "$demoted" = true ]; then
echo "OK: $appimage prefers the host $LIB (fallback kept) and carries"
else
echo "OK: $appimage had no bundled $LIB to demote, and carries"
fi
echo " AppStream metadata. Channel pair in $CHANNEL_DIR/, updating from $UPDATE_TAG."