Skip to content

Commit 1483b26

Browse files
authored
feat(release): embed AppImage update information and publish zsync (#1498)
* feat(release): embed AppImage update information and publish zsync Tauri's bundler leaves the AppImage runtime's 1 KiB `.upd_info` ELF section zeroed, so AppImageUpdate, AppImageLauncher, AppManager and AM all report "Could not find update information in the AppImage" and cannot update it. Add scripts/embed-appimage-update-info.sh, which writes a `gh-releases-zsync|<owner>|<repo>|latest|<bundle>_*_amd64.AppImage.zsync` string into that section and generates the matching `.zsync` from the patched image. The release workflow runs it on the built AppImage and uploads both the patched image and the `.zsync`, so updates transfer only the blocks that changed instead of the whole ~120 MB file. The version in the bundle name becomes a glob so the update information keeps matching future releases; the script fails loudly if the bundle is ever renamed so that cannot silently degrade into a pattern that matches nothing. test-build runs the same patch (without uploading) so a break surfaces on a test build rather than halfway through a real release. Fixes #1495 * Address CodeRabbit review feedback - Extract the duplicated "pick exactly one AppImage from artifactPaths" block out of release.yml and test-build.yml into scripts/select-single-appimage.sh, so the two workflows cannot drift. - Cover the extracted script in tests/appimage-update-info.test.ts (picks the AppImage past the other bundles, fails on zero and on more than one). The suite skips itself where jq is not installed. Writing the test caught a bash parse error: an apostrophe inside ${1:?...} swallowed the rest of the file.
1 parent 4c3a188 commit 1483b26

6 files changed

Lines changed: 341 additions & 5 deletions

File tree

.github/workflows/release.yml

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,14 @@ jobs:
7777
libxdo-dev \
7878
patchelf \
7979
rpm \
80-
wget
80+
wget \
81+
zsync
8182
8283
- name: Install frontend dependencies
8384
run: npm ci
8485

8586
- name: Build and upload release assets
87+
id: tauri
8688
uses: tauri-apps/tauri-action@v1.0
8789
env:
8890
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -107,6 +109,26 @@ jobs:
107109
args: ${{ matrix.args }}
108110
uploadPlainBinary: true
109111

112+
# Tauri's bundler leaves the AppImage runtime's 1 KiB `.upd_info` section
113+
# zeroed, so AppImageUpdate, AppImageLauncher, AppManager and AM all
114+
# report "no update information" and refuse to update the image. Fill it
115+
# in and publish the matching `.zsync` so those tools fetch only the
116+
# blocks that changed (~4 KiB in practice) instead of the whole ~120 MB
117+
# image. The patched AppImage replaces the one tauri-action just attached:
118+
# it differs only in that section, but the `.zsync` checksums have to
119+
# describe the file actually being served.
120+
- name: Embed AppImage update information
121+
if: runner.os == 'Linux'
122+
env:
123+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
124+
REPO: ${{ github.repository }}
125+
TAG: ${{ github.event.release.tag_name }}
126+
ARTIFACT_PATHS: ${{ steps.tauri.outputs.artifactPaths }}
127+
run: |
128+
image="$(scripts/select-single-appimage.sh "$ARTIFACT_PATHS")"
129+
scripts/embed-appimage-update-info.sh "$image"
130+
gh release upload "$TAG" "$image" "$image.zsync" --clobber
131+
110132
- name: Build MSIX package
111133
id: build_msix
112134
if: runner.os == 'Windows'

.github/workflows/test-build.yml

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ jobs:
110110
workspaces: apps/geolibre-desktop/src-tauri -> target
111111

112112
# Mirrors release.yml's Linux dependency list; libfuse2 and librsvg2-dev
113-
# are needed for the AppImage bundle, rpm for the .rpm.
113+
# are needed for the AppImage bundle, rpm for the .rpm, and zsync for the
114+
# AppImage delta-update file.
114115
- name: Install Linux dependencies
115116
if: steps.want.outputs.build == 'true' && runner.os == 'Linux'
116117
run: |
@@ -127,7 +128,8 @@ jobs:
127128
libxdo-dev \
128129
patchelf \
129130
rpm \
130-
wget
131+
wget \
132+
zsync
131133
132134
- name: Install frontend dependencies
133135
if: steps.want.outputs.build == 'true'
@@ -138,6 +140,7 @@ jobs:
138140
# why no GITHUB_TOKEN is passed here -- the action only needs one to create
139141
# a release or upload assets to it, neither of which this workflow does.
140142
- name: Build the desktop bundles
143+
id: tauri
141144
if: steps.want.outputs.build == 'true'
142145
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0
143146
env:
@@ -147,6 +150,22 @@ jobs:
147150
projectPath: apps/geolibre-desktop
148151
args: ${{ matrix.args }}
149152

153+
# Same patch release.yml applies, minus the upload: it runs here so a
154+
# break in the AppImage update information (a renamed bundle, a runtime
155+
# without a .upd_info section) surfaces on a test build rather than
156+
# halfway through a real release. The artifact uploaded below therefore
157+
# carries the update information too.
158+
- name: Embed AppImage update information
159+
if: steps.want.outputs.build == 'true' && runner.os == 'Linux'
160+
env:
161+
REPO: ${{ github.repository }}
162+
TAG: v${{ steps.tauri.outputs.appVersion }}
163+
ARTIFACT_PATHS: ${{ steps.tauri.outputs.artifactPaths }}
164+
run: |
165+
image="$(scripts/select-single-appimage.sh "$ARTIFACT_PATHS")"
166+
scripts/embed-appimage-update-info.sh "$image"
167+
"$image" --appimage-updateinfo
168+
150169
- name: Upload installers
151170
if: steps.want.outputs.build == 'true'
152171
uses: actions/upload-artifact@v7

docs/downloads.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,9 @@ by Apple, so Gatekeeper allows them to open without any extra steps:
109109

110110
GeoLibre offers several Linux install options. The AUR, COPR, and Flatpak
111111
packages auto-update (through your system package manager or `flatpak update`);
112-
the direct `.deb`, `.rpm`, and AppImage downloads are updated by re-downloading
113-
the new release.
112+
the AppImage updates itself in place with [AppImageUpdate](#appimage-any-distribution),
113+
and the direct `.deb` and `.rpm` downloads are updated by re-downloading the new
114+
release.
114115

115116
### Arch Linux / Manjaro (AUR)
116117

@@ -172,6 +173,23 @@ AppImages need FUSE. On distros that no longer ship it by default, install
172173
`libfuse2` (for example `sudo apt install libfuse2`) or run with
173174
`--appimage-extract-and-run`.
174175

176+
#### Delta updates
177+
178+
Releases after v2.3.0 embed update information in the AppImage, so
179+
[AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate),
180+
[AppImageLauncher](https://github.com/TheAssassin/AppImageLauncher),
181+
[AppManager](https://github.com/kem-a/AppManager) and
182+
[AM](https://github.com/ivan-hc/AM) can update it in place. Each release also
183+
ships a `.zsync` file next to the AppImage, so an update transfers only the
184+
blocks that changed rather than the whole image:
185+
186+
```bash
187+
appimageupdatetool GeoLibre.Desktop_<version>_amd64.AppImage
188+
```
189+
190+
Check what an AppImage points at with
191+
`./GeoLibre.Desktop_<version>_amd64.AppImage --appimage-updateinfo`.
192+
175193
## Build from source
176194

177195
```bash
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Embed AppImage update information into a built AppImage and write the matching
4+
# zsync file next to it.
5+
#
6+
# An AppImage type-2 runtime reserves a 1 KiB `.upd_info` ELF section for a
7+
# single "update information" string. Tauri's bundler leaves it zeroed, so
8+
# AppImageUpdate, AppImageLauncher, AppManager and AM all report that the image
9+
# carries no update information and refuse to update it. Filling it in, plus
10+
# publishing a `.zsync` alongside the AppImage, lets those tools fetch only the
11+
# blocks that changed instead of the whole ~120 MB image.
12+
#
13+
# See https://github.com/AppImage/AppImageSpec/blob/master/draft.md#update-information
14+
#
15+
# Usage:
16+
# REPO=opengeos/GeoLibre TAG=v1.5.0 \
17+
# scripts/embed-appimage-update-info.sh path/to/GeoLibre.Desktop_1.5.0_amd64.AppImage
18+
#
19+
# # Print the update information string for a file name and exit (no writes):
20+
# REPO=opengeos/GeoLibre TAG=v1.5.0 \
21+
# scripts/embed-appimage-update-info.sh --print GeoLibre.Desktop_1.5.0_amd64.AppImage
22+
#
23+
# REPO (owner/name) and TAG (the release tag, e.g. v1.5.0) are both required.
24+
# The AppImage is patched in place, so run this before uploading it.
25+
set -euo pipefail
26+
27+
: "${REPO:?Set REPO to the GitHub repository, e.g. opengeos/GeoLibre}"
28+
: "${TAG:?Set TAG to the release tag, e.g. v1.5.0}"
29+
30+
print_only=false
31+
if [[ "${1:-}" == "--print" ]]; then
32+
print_only=true
33+
shift
34+
fi
35+
36+
appimage="${1:?Pass the path to the .AppImage}"
37+
38+
[[ "$REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || {
39+
echo "REPO is not an owner/name pair: $REPO" >&2
40+
exit 1
41+
}
42+
owner="${REPO%%/*}"
43+
name="${REPO##*/}"
44+
45+
# The tag carries the version the bundler stamped into the file name.
46+
version="${TAG#v}"
47+
base="$(basename "$appimage")"
48+
49+
# The update information has to match *every future* release, not just this one,
50+
# so the version in the file name becomes a glob. Rewriting it here (rather than
51+
# hard-coding the pattern) keeps the two in step, and the guard below turns a
52+
# future rename of the bundle into a loud failure instead of update information
53+
# that silently matches nothing.
54+
pattern="${base/_${version}_/_*_}"
55+
[[ "$pattern" != "$base" ]] || {
56+
echo "AppImage name '$base' does not contain _${version}_; cannot derive a zsync pattern" >&2
57+
exit 1
58+
}
59+
60+
# `latest` makes the installed AppImage track the newest release rather than the
61+
# one it shipped in.
62+
update_information="gh-releases-zsync|${owner}|${name}|latest|${pattern}.zsync"
63+
64+
if [[ "$print_only" == true ]]; then
65+
printf '%s\n' "$update_information"
66+
exit 0
67+
fi
68+
69+
[[ -f "$appimage" ]] || {
70+
echo "No such AppImage: $appimage" >&2
71+
exit 1
72+
}
73+
74+
# Columns of `objdump -h`: Idx Name Size VMA LMA "File off" Algn. The two hex
75+
# fields are converted in bash, not awk: `strtonum` is a gawk extension and the
76+
# Ubuntu runners' default awk is mawk.
77+
read -r size_hex offset_hex < <(
78+
objdump -h "$appimage" | awk '$2 == ".upd_info" { print $3, $6; exit }'
79+
)
80+
[[ "${size_hex:-}" =~ ^[0-9a-fA-F]+$ && "${offset_hex:-}" =~ ^[0-9a-fA-F]+$ ]] || {
81+
echo "No .upd_info section in $appimage; is it an AppImage type-2 runtime?" >&2
82+
exit 1
83+
}
84+
size=$((16#$size_hex))
85+
offset=$((16#$offset_hex))
86+
(( size > 0 )) || {
87+
echo "The .upd_info section in $appimage is empty" >&2
88+
exit 1
89+
}
90+
# Leave room for the terminating NUL.
91+
(( ${#update_information} < size )) || {
92+
echo "Update information (${#update_information} bytes) does not fit in the ${size}-byte .upd_info section" >&2
93+
exit 1
94+
}
95+
96+
# Overwrite the whole section, NUL-padded, rather than only the prefix: the
97+
# reader stops at the first NUL, so any leftover bytes would corrupt the string.
98+
# `dd` (not objcopy) because objcopy would rewrite the ELF and drop the squashfs
99+
# image appended after it.
100+
{
101+
printf '%s' "$update_information"
102+
head -c "$((size - ${#update_information}))" /dev/zero
103+
} | dd of="$appimage" bs=1 seek="$offset" count="$size" conv=notrunc status=none
104+
105+
# Read it back so a silent short write cannot ship as a working AppImage.
106+
embedded="$(dd if="$appimage" bs=1 skip="$offset" count="$size" status=none | tr -d '\0')"
107+
[[ "$embedded" == "$update_information" ]] || {
108+
echo "Verification failed: .upd_info holds '$embedded', expected '$update_information'" >&2
109+
exit 1
110+
}
111+
echo "Embedded update information: $update_information"
112+
113+
# zsync must be generated from the *patched* image, otherwise its checksums
114+
# describe a file that no longer exists. The URL is absolute so the client never
115+
# has to resolve it against the redirect GitHub serves release assets through.
116+
command -v zsyncmake >/dev/null || {
117+
echo "zsyncmake not found; install the 'zsync' package" >&2
118+
exit 1
119+
}
120+
zsyncmake \
121+
-u "https://github.com/${REPO}/releases/download/${TAG}/${base}" \
122+
-o "${appimage}.zsync" \
123+
"$appimage"
124+
echo "Wrote ${appimage}.zsync"

scripts/select-single-appimage.sh

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Print the one .AppImage path out of tauri-action's `artifactPaths` output.
4+
#
5+
# Both release.yml and test-build.yml have to hand exactly that path to
6+
# scripts/embed-appimage-update-info.sh. Reading the action's own output beats
7+
# globbing the bundle directory, which also holds the AppImage-packaged build
8+
# tools, and keeping the selection in one place stops the two workflows from
9+
# drifting apart.
10+
#
11+
# Usage:
12+
# image="$(scripts/select-single-appimage.sh "$ARTIFACT_PATHS")"
13+
#
14+
# The argument is the JSON array tauri-action emits. Exits non-zero unless
15+
# exactly one entry ends in .AppImage, so a bundler change that stops producing
16+
# one (or starts producing several) fails the build rather than silently
17+
# patching the wrong file.
18+
set -euo pipefail
19+
20+
# No apostrophe in the message: bash parses quotes inside ${var:?word}, so one
21+
# there would swallow the rest of the file.
22+
artifacts="${1:?Pass the artifactPaths JSON array from tauri-action}"
23+
24+
mapfile -t images < <(jq -r '.[] | select(endswith(".AppImage"))' <<<"$artifacts")
25+
if [[ ${#images[@]} -ne 1 ]]; then
26+
echo "Expected exactly one .AppImage in artifactPaths, found ${#images[@]}" >&2
27+
exit 1
28+
fi
29+
printf '%s\n' "${images[0]}"

0 commit comments

Comments
 (0)