feat(updater): in-app update on desktop, releases link on Android
Anyone who installed an AppImage or ran the Windows installer was frozen
on that version forever. Nothing in the app ever mentioned a new release
existed, and the release notes were the only announcement.
Desktop now checks a signed manifest, shows the version and its notes in
Settings, and installs and relaunches on request. The signature check is
the whole point: it is what stops a substituted download from being
installed by the app itself. Windows binaries stay unsigned for
SmartScreen purposes -- that is a code-signing certificate, a separate
problem -- but the update payload is verified against our own key.
Android is deliberately not wired to the updater. An app may not replace
its own APK; that is the package installer's job, and the plugin has no
Android implementation. It gets a link to the releases page instead of a
button that would throw.
The plugins are gated with a target-triple cfg rather than
cfg(desktop). Cargo only evaluates target cfgs in a [target.'cfg(..)']
table, so cfg(desktop) matches nothing, silently drops the dependency,
and fails much later with "Permission updater:default not found" -- which
is exactly what the first attempt here did.
Where the manifest lives took some finding. This Gitea serves
/releases/download/<tag>/<asset> but 404s on
/releases/latest/download/<asset> (verified against a real asset), so
there is no stable latest-release URL. The gitea-pages branch is
force-pushed wholesale by publish-docs.yml, so it cannot host the file
either. latest.json therefore gets its own orphan branch, read over the
raw-file URL, and is published from a scratch repo in RUNNER_TEMP rather
than by switching branches in the checkout -- doing that would have left
the following steps standing on a one-commit history, and the next step
but one runs release:notes against the real commit range.
Also fixed, all of it release-integrity:
- "appimage" is in bundle.targets. The release notes have advertised an
AppImage for months; tauri.conf.json never built one, the artifact
step globbed for *.AppImage, found nothing, and said nothing. The
step now fails instead.
- The .AppImage.tar.gz/.sig pair and the NSIS .sig are collected. A
manifest referencing a signature that was never uploaded fails only
on the user's machine, so the manifest step also refuses to write an
entry with an empty signature.
- Release notes are generated by release:notes from the traceability
graph, which is what CLAUDE.md has asked for all along, instead of a
fixed heredoc that said "see CHANGELOG.md for detailed changes" and
linked "GitHub Issues" on a Gitea-hosted project.
- The notes tell users how to verify a download with SHA256SUMS.
Requirements UR-077 / DR-217, tests UT-208 (12 cases over the version
comparison and the platform decision, including that a pre-release does
not offer itself as an upgrade to the matching release).
Verified: 1070 frontend tests, cargo check for both the host and
aarch64-linux-android (confirming the plugins are absent there), clippy
-D warnings, svelte-check 0 errors.
This commit is contained in:
@@ -138,10 +138,19 @@ jobs:
|
||||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
# TAURI_SKIP_UPDATER is gone: it was suppressing the updater artifacts
|
||||
# (.AppImage.tar.gz + .sig) that the update manifest points at, back when
|
||||
# there was no updater to feed. With the signing key present, `tauri build`
|
||||
# emits and signs them.
|
||||
#
|
||||
# If TAURI_SIGNING_PRIVATE_KEY is ever absent the build fails loudly rather
|
||||
# than quietly shipping an unsigned release that no client will accept --
|
||||
# which is the behaviour we want.
|
||||
- name: Build for Linux
|
||||
run: bun run tauri build
|
||||
env:
|
||||
TAURI_SKIP_UPDATER: true
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Prepare Linux artifacts
|
||||
run: |
|
||||
@@ -160,14 +169,27 @@ jobs:
|
||||
# step (which is why v0.9.0 and v0.9.1 built but never published).
|
||||
# Without nullglob an unmatched pattern stays literal, so test each
|
||||
# candidate instead. Same POSIX-only rule as traceability-check.yml.
|
||||
#
|
||||
# The .AppImage.tar.gz + .sig pair is what the updater downloads and
|
||||
# verifies; the plain .AppImage is what a human downloads. Both ship.
|
||||
for bundle in \
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage \
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz \
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz.sig \
|
||||
src-tauri/target/release/bundle/deb/*.deb \
|
||||
src-tauri/target/release/bundle/rpm/*.rpm; do
|
||||
[ -e "$bundle" ] || continue
|
||||
cp -v "$bundle" dist/linux/
|
||||
done
|
||||
|
||||
# An AppImage that did not build means no updater artifact either, and
|
||||
# the release notes have advertised an AppImage for months. Fail rather
|
||||
# than publish a release whose manifest points at nothing.
|
||||
if ! ls dist/linux/*.AppImage >/dev/null 2>&1; then
|
||||
echo "::error::No AppImage produced -- check bundle.targets in tauri.conf.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A release with no Linux package is a failure, not a quiet success.
|
||||
if [ -z "$(ls -A dist/linux/)" ]; then
|
||||
echo "::error::No Linux bundles found under src-tauri/target/release/bundle/"
|
||||
@@ -244,6 +266,9 @@ jobs:
|
||||
|
||||
- name: Build Windows (NSIS installer + exe)
|
||||
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: List Windows artifacts
|
||||
run: ls -lah dist/windows/
|
||||
@@ -411,6 +436,106 @@ jobs:
|
||||
#
|
||||
# Written with paths relative to the asset directory so `sha256sum -c
|
||||
# SHA256SUMS` works in the directory a user downloaded into.
|
||||
# The update manifest. Built before the checksums so latest.json is not
|
||||
# itself hashed into SHA256SUMS (it is metadata about the release, not a
|
||||
# download), and after the artifacts exist so the signatures can be read.
|
||||
#
|
||||
# Why a dedicated `updater` branch and a raw-file URL: this Gitea serves
|
||||
# /releases/download/<tag>/<asset> but returns 404 for
|
||||
# /releases/latest/download/<asset>, so there is no stable "latest release"
|
||||
# URL to point a client at. The gitea-pages branch is force-pushed whole by
|
||||
# publish-docs.yml, so hosting the manifest there would delete it on the
|
||||
# next docs build. An orphan branch that only ever contains latest.json is
|
||||
# the one location both stable and ours.
|
||||
- name: Build update manifest (latest.json)
|
||||
id: manifest
|
||||
run: |
|
||||
set -e
|
||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
||||
# The manifest carries the bare version; the tag carries the v prefix.
|
||||
PLAIN="${VERSION#v}"
|
||||
BASE="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${VERSION}"
|
||||
|
||||
# Tauri matches on "<os>-<arch>". We ship one desktop arch today.
|
||||
APPIMAGE_SIG=""
|
||||
NSIS_SIG=""
|
||||
APPIMAGE_URL=""
|
||||
NSIS_URL=""
|
||||
|
||||
for f in artifacts/linux/*.AppImage.tar.gz; do
|
||||
[ -e "$f" ] || continue
|
||||
APPIMAGE_URL="${BASE}/$(basename "$f")"
|
||||
[ -e "$f.sig" ] && APPIMAGE_SIG="$(cat "$f.sig")"
|
||||
done
|
||||
|
||||
for f in artifacts/windows/*-setup.exe; do
|
||||
[ -e "$f" ] || continue
|
||||
NSIS_URL="${BASE}/$(basename "$f")"
|
||||
[ -e "$f.sig" ] && NSIS_SIG="$(cat "$f.sig")"
|
||||
done
|
||||
|
||||
# A manifest with an empty signature is worse than no manifest: the
|
||||
# client rejects it after downloading the whole payload.
|
||||
if [ -z "$APPIMAGE_SIG" ] || [ -z "$NSIS_SIG" ]; then
|
||||
echo "::error::Missing updater signature (appimage='$APPIMAGE_SIG' nsis='$NSIS_SIG')."
|
||||
echo "::error::Check that TAURI_SIGNING_PRIVATE_KEY reached both desktop build jobs."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Release notes for the update prompt come from the traceability graph,
|
||||
# same source as the release body.
|
||||
NOTES="$(bun run release:notes 2>/dev/null | head -c 4000 || echo "See the release page for details.")"
|
||||
|
||||
jq -n \
|
||||
--arg version "$PLAIN" \
|
||||
--arg notes "$NOTES" \
|
||||
--arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg lin_sig "$APPIMAGE_SIG" --arg lin_url "$APPIMAGE_URL" \
|
||||
--arg win_sig "$NSIS_SIG" --arg win_url "$NSIS_URL" \
|
||||
'{
|
||||
version: $version,
|
||||
notes: $notes,
|
||||
pub_date: $pub_date,
|
||||
platforms: {
|
||||
"linux-x86_64": { signature: $lin_sig, url: $lin_url },
|
||||
"windows-x86_64": { signature: $win_sig, url: $win_url }
|
||||
}
|
||||
}' > latest.json
|
||||
|
||||
echo "📄 latest.json:"
|
||||
cat latest.json
|
||||
|
||||
- name: Publish latest.json to the updater branch
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
||||
HOST="$(echo "$GITHUB_SERVER_URL" | sed -E 's#^https?://##')"
|
||||
REMOTE="https://oauth2:${TOKEN}@${HOST}/${GITHUB_REPOSITORY}.git"
|
||||
|
||||
# Built in a scratch repo, NOT by switching branches in the checkout.
|
||||
# `git checkout --orphan` here would leave every later step standing on
|
||||
# a one-commit branch -- and the next step but one runs
|
||||
# `bun run release:notes`, which resolves a commit range against the
|
||||
# real history and would silently produce nothing.
|
||||
WORK="$RUNNER_TEMP/updater-branch"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK"
|
||||
cp latest.json "$WORK/latest.json"
|
||||
cd "$WORK"
|
||||
git init -q
|
||||
git config user.email "ci@jellytau"
|
||||
git config user.name "JellyTau CI"
|
||||
git add latest.json
|
||||
git commit -qm "chore(updater): manifest for ${{ steps.tag_name.outputs.VERSION }}"
|
||||
echo "🚀 Force-pushing update manifest to the updater branch"
|
||||
# Force-push: the branch holds exactly one file and no history worth
|
||||
# keeping, same shape as publish-docs.yml's gitea-pages.
|
||||
git push -f "$REMOTE" HEAD:refs/heads/updater
|
||||
echo "✅ Served at ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/raw/branch/updater/latest.json"
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
run: |
|
||||
set -e
|
||||
@@ -424,64 +549,58 @@ jobs:
|
||||
# release rather than shipping and failing for users.
|
||||
sha256sum -c SHA256SUMS
|
||||
|
||||
# Release notes come from the traceability graph, not from a hardcoded
|
||||
# heredoc. scripts/release-notes.ts resolves the commit range's changed
|
||||
# files to their TRACES ids and then to requirement descriptions, grouping
|
||||
# UR into Features and DR/IR into Improvements -- which is what CLAUDE.md
|
||||
# has asked for all along, while this workflow pasted a fixed block of
|
||||
# install instructions and a line saying "see CHANGELOG.md for detailed
|
||||
# changes". It also linked "GitHub Issues" on a Gitea-hosted project.
|
||||
- name: Prepare release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
set -e
|
||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
||||
echo "## JellyTau $VERSION Release" > release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "### Downloads" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Linux" >> release_notes.md
|
||||
echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md
|
||||
echo "- **DEB** - Install via \`sudo dpkg -i JellyTau_*.deb\` (Ubuntu/Debian)" >> release_notes.md
|
||||
echo "- **RPM** - Install via \`sudo rpm -i JellyTau-*.rpm\` (Fedora/openSUSE)" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Windows" >> release_notes.md
|
||||
echo "- **Installer (.exe)** - Run \`JellyTau_*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run." >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Android" >> release_notes.md
|
||||
echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md
|
||||
echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "### What's New" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "### Installation" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Linux (AppImage)" >> release_notes.md
|
||||
echo "\`\`\`bash" >> release_notes.md
|
||||
echo "chmod +x JellyTau_*.AppImage" >> release_notes.md
|
||||
echo "./JellyTau_*.AppImage" >> release_notes.md
|
||||
echo "\`\`\`" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Linux (DEB)" >> release_notes.md
|
||||
echo "\`\`\`bash" >> release_notes.md
|
||||
echo "sudo dpkg -i JellyTau_*.deb" >> release_notes.md
|
||||
echo "jellytau" >> release_notes.md
|
||||
echo "\`\`\`" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Android" >> release_notes.md
|
||||
echo "- Sideload: Download APK and install via file manager or ADB" >> release_notes.md
|
||||
echo "- Play Store: Coming soon" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "### Known Issues" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "### Requirements" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "**Linux:**" >> release_notes.md
|
||||
echo "- 64-bit Linux system" >> release_notes.md
|
||||
echo "- GLIBC 2.29+" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "**Android:**" >> release_notes.md
|
||||
echo "- Android 8.0 or higher" >> release_notes.md
|
||||
echo "- 50MB free storage" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "---" >> release_notes.md
|
||||
echo "Built with Tauri, SvelteKit, and Rust" >> release_notes.md
|
||||
{
|
||||
echo "## JellyTau $VERSION"
|
||||
echo ""
|
||||
# A generated summary of what actually changed; falls back to a
|
||||
# pointer rather than failing the release if the range is odd.
|
||||
bun run release:notes 2>/dev/null || echo "See the commit log for changes in this release."
|
||||
echo ""
|
||||
echo "### Downloads"
|
||||
echo ""
|
||||
echo "| Platform | File |"
|
||||
echo "|---|---|"
|
||||
echo "| Linux (portable) | \`*.AppImage\` — \`chmod +x\` and run |"
|
||||
echo "| Linux (Debian/Ubuntu) | \`*.deb\` — \`sudo dpkg -i\` |"
|
||||
echo "| Linux (Fedora/openSUSE) | \`*.rpm\` — \`sudo rpm -i\` |"
|
||||
echo "| Windows | \`*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run. |"
|
||||
echo "| Android | \`*.apk\` sideload, or \`*.aab\` for Play Console |"
|
||||
echo ""
|
||||
echo "Desktop builds update themselves from here on: JellyTau checks this"
|
||||
echo "release feed and can install a new version in place."
|
||||
echo ""
|
||||
echo "### Verifying your download"
|
||||
echo ""
|
||||
echo "\`\`\`bash"
|
||||
echo "sha256sum -c SHA256SUMS"
|
||||
echo "\`\`\`"
|
||||
echo ""
|
||||
echo "\`SHA256SUMS\` covers every file in this release. An SBOM"
|
||||
echo "(\`*.cdx.json\`, \`frontend-dependencies.txt\`) lists what went into it."
|
||||
echo ""
|
||||
echo "### Requirements"
|
||||
echo ""
|
||||
echo "- **Linux:** 64-bit, GLIBC 2.29+"
|
||||
echo "- **Windows:** 64-bit Windows 10 or later"
|
||||
echo "- **Android:** 8.0 or later, ~50 MB free"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "Report a problem: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues"
|
||||
} > release_notes.md
|
||||
echo "📝 Release notes:"
|
||||
cat release_notes.md
|
||||
|
||||
- name: Publish Gitea release & upload assets
|
||||
env:
|
||||
|
||||
Reference in New Issue
Block a user