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.
655 lines
27 KiB
YAML
655 lines
27 KiB
YAML
name: Build & Release
|
||
|
||
on:
|
||
push:
|
||
tags:
|
||
- 'v*'
|
||
workflow_dispatch:
|
||
inputs:
|
||
version:
|
||
description: 'Version to build (e.g., v1.0.0)'
|
||
required: false
|
||
|
||
env:
|
||
RUST_BACKTRACE: 1
|
||
CARGO_TERM_COLOR: always
|
||
# Incremental state is never reused between CI runs -- pure disk cost.
|
||
CARGO_INCREMENTAL: 0
|
||
|
||
jobs:
|
||
test:
|
||
name: Run Tests
|
||
runs-on: linux/amd64
|
||
container:
|
||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||
steps:
|
||
- name: Checkout repository
|
||
uses: actions/checkout@v4
|
||
|
||
- name: Cache Rust dependencies
|
||
uses: actions/cache@v3
|
||
with:
|
||
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||
# was cached under five separate keys, which filled the runner's 74 GB
|
||
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||
# registry/src is omitted too: cargo re-extracts it for free from
|
||
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||
path: |
|
||
~/.cargo/registry/index
|
||
~/.cargo/registry/cache
|
||
~/.cargo/git/db
|
||
# One shared key across every job. The old per-job keys existed to stop
|
||
# debug/release target artifacts clobbering each other; with target no
|
||
# longer cached, registry contents are target-independent, so all jobs
|
||
# want the same crates. First job to finish saves; the rest restore.
|
||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-cargo-registry-
|
||
|
||
- name: Cache Node dependencies
|
||
uses: actions/cache@v3
|
||
with:
|
||
path: |
|
||
~/.bun/install/cache
|
||
node_modules
|
||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-bun-
|
||
|
||
- name: Install dependencies
|
||
run: bun install
|
||
|
||
- name: Run frontend tests
|
||
run: |
|
||
bunx svelte-kit sync
|
||
bun run test --run
|
||
continue-on-error: false
|
||
|
||
# Same gate as build-and-test.yml. A release must not ship from a tree
|
||
# that would fail the per-commit checks. rustfmt/clippy come from the
|
||
# builder image; nothing is installed here.
|
||
- name: Check Rust formatting
|
||
run: |
|
||
cd src-tauri
|
||
cargo fmt --all -- --check
|
||
continue-on-error: false
|
||
|
||
# Advisory until the ~51 pre-existing warnings are cleared; see the longer
|
||
# note in build-and-test.yml. Tighten both to `-- -D warnings` together.
|
||
- name: Run clippy (advisory)
|
||
run: |
|
||
cd src-tauri
|
||
cargo clippy --all-targets
|
||
|
||
- name: Run Rust tests
|
||
run: bun run test:rust
|
||
continue-on-error: false
|
||
|
||
- name: Check TypeScript
|
||
run: bun run check
|
||
continue-on-error: false
|
||
|
||
build-linux:
|
||
name: Build Linux
|
||
runs-on: linux/amd64
|
||
needs: test
|
||
container:
|
||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||
steps:
|
||
- name: Checkout repository
|
||
uses: actions/checkout@v4
|
||
|
||
- name: Cache Rust dependencies
|
||
uses: actions/cache@v3
|
||
with:
|
||
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||
# was cached under five separate keys, which filled the runner's 74 GB
|
||
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||
# registry/src is omitted too: cargo re-extracts it for free from
|
||
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||
path: |
|
||
~/.cargo/registry/index
|
||
~/.cargo/registry/cache
|
||
~/.cargo/git/db
|
||
# One shared key across every job. The old per-job keys existed to stop
|
||
# debug/release target artifacts clobbering each other; with target no
|
||
# longer cached, registry contents are target-independent, so all jobs
|
||
# want the same crates. First job to finish saves; the rest restore.
|
||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-cargo-registry-
|
||
|
||
- name: Cache Node dependencies
|
||
uses: actions/cache@v3
|
||
with:
|
||
path: |
|
||
~/.bun/install/cache
|
||
node_modules
|
||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-bun-
|
||
|
||
- name: Install dependencies
|
||
run: bun install
|
||
|
||
# The Linux job previously had no version step at all, so a tagged release
|
||
# built Linux packages from whatever version happened to be committed.
|
||
- name: Set app version from tag
|
||
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_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||
|
||
- name: Prepare Linux artifacts
|
||
run: |
|
||
mkdir -p dist/linux
|
||
# Match by extension, not by product name. Bundle filenames follow
|
||
# `productName`, so renaming the app (jellytau -> JellyTau) made the
|
||
# old `jellytau_*.deb` glob match nothing — and because the copy was
|
||
# wrapped in `if [ -f ... ]`, the artifact simply vanished from the
|
||
# release with no error. Each bundle directory holds one file.
|
||
#
|
||
# `if [ -f "dir/"*.ext ]` was also wrong on its own terms: with more
|
||
# than one match `test` gets extra arguments and fails.
|
||
#
|
||
# No `shopt -s nullglob` here: the runner executes `run:` blocks with
|
||
# POSIX sh, where shopt does not exist -- it exited 127 and killed the
|
||
# 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/"
|
||
exit 1
|
||
fi
|
||
ls -lah dist/linux/
|
||
|
||
- name: Upload Linux build artifact
|
||
uses: actions/upload-artifact@v3
|
||
with:
|
||
name: jellytau-linux
|
||
path: dist/linux/
|
||
retention-days: 7
|
||
|
||
build-windows:
|
||
name: Build Windows
|
||
runs-on: linux/amd64
|
||
needs: test
|
||
# Cross-compiled from Linux via the official Tauri path (MSVC + cargo-xwin),
|
||
# baked into the builder image. No toolchain installs here — the image has
|
||
# cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target.
|
||
container:
|
||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||
steps:
|
||
- name: Checkout repository
|
||
uses: actions/checkout@v4
|
||
|
||
- name: Cache Rust dependencies
|
||
uses: actions/cache@v3
|
||
with:
|
||
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||
# was cached under five separate keys, which filled the runner's 74 GB
|
||
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||
# registry/src is omitted too: cargo re-extracts it for free from
|
||
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||
path: |
|
||
~/.cargo/registry/index
|
||
~/.cargo/registry/cache
|
||
~/.cargo/git/db
|
||
# One shared key across every job. The old per-job keys existed to stop
|
||
# debug/release target artifacts clobbering each other; with target no
|
||
# longer cached, registry contents are target-independent, so all jobs
|
||
# want the same crates. First job to finish saves; the rest restore.
|
||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-cargo-registry-
|
||
|
||
- name: Cache Windows CRT/SDK (cargo-xwin)
|
||
uses: actions/cache@v3
|
||
with:
|
||
path: ~/.cache/cargo-xwin
|
||
# Contents track the xwin version baked into the builder image, not our
|
||
# lockfile -- keying this on Cargo.lock re-downloaded the whole SDK on
|
||
# every release bump. Bump the suffix by hand if the image's xwin moves.
|
||
key: ${{ runner.os }}-cargo-xwin-v1
|
||
|
||
- name: Cache Node dependencies
|
||
uses: actions/cache@v3
|
||
with:
|
||
path: |
|
||
~/.bun/install/cache
|
||
node_modules
|
||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-bun-
|
||
|
||
# The tag is the single source of truth for a release version; the script
|
||
# stamps every file that carries it (package.json, tauri.conf.json,
|
||
# Cargo.toml, Cargo.lock). This step used to sed only tauri.conf.json, so
|
||
# the other three shipped whatever was committed.
|
||
- name: Set app version from tag
|
||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||
if: startsWith(github.ref, 'refs/tags/v')
|
||
|
||
- 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/
|
||
|
||
- name: Upload Windows build artifact
|
||
uses: actions/upload-artifact@v3
|
||
with:
|
||
name: jellytau-windows
|
||
path: dist/windows/
|
||
retention-days: 7
|
||
|
||
build-android:
|
||
name: Build Android
|
||
runs-on: linux/amd64
|
||
needs: test
|
||
container:
|
||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||
env:
|
||
ANDROID_HOME: /opt/android-sdk
|
||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||
steps:
|
||
- name: Checkout repository
|
||
uses: actions/checkout@v4
|
||
|
||
- name: Cache Rust dependencies
|
||
uses: actions/cache@v3
|
||
with:
|
||
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
||
# was cached under five separate keys, which filled the runner's 74 GB
|
||
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
||
# registry/src is omitted too: cargo re-extracts it for free from
|
||
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
||
path: |
|
||
~/.cargo/registry/index
|
||
~/.cargo/registry/cache
|
||
~/.cargo/git/db
|
||
# One shared key across every job. The old per-job keys existed to stop
|
||
# debug/release target artifacts clobbering each other; with target no
|
||
# longer cached, registry contents are target-independent, so all jobs
|
||
# want the same crates. First job to finish saves; the rest restore.
|
||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-cargo-registry-
|
||
|
||
- name: Cache Node dependencies
|
||
uses: actions/cache@v3
|
||
with:
|
||
path: |
|
||
~/.bun/install/cache
|
||
node_modules
|
||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-bun-
|
||
|
||
- name: Install dependencies
|
||
run: bun install
|
||
|
||
# Stamp before `android init`: it derives its generated project (including
|
||
# the initial versionCode) from tauri.conf.json.
|
||
- name: Set app version from tag
|
||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||
if: startsWith(github.ref, 'refs/tags/v')
|
||
|
||
- name: Initialize Android project
|
||
run: bun run tauri android init
|
||
|
||
# Re-run after init: tauri.properties only exists now, and its
|
||
# autogenerated versionCode (0.0.15 -> 15) is both tiny and NOT monotonic
|
||
# against the 1000 floor already shipped in the field. The script rewrites
|
||
# it as 1000 + major*10000 + minor*100 + patch. Runs unconditionally so
|
||
# untagged builds get a sane code too, derived from git describe.
|
||
- name: Pin a monotonic Android versionCode
|
||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||
|
||
- name: Sync custom Android sources & gradle config
|
||
run: ./scripts/sync-android-sources.sh
|
||
|
||
- name: Write signing keystore
|
||
run: |
|
||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/jellytau-release.jks"
|
||
cat > src-tauri/gen/android/keystore.properties <<EOF
|
||
storeFile=$RUNNER_TEMP/jellytau-release.jks
|
||
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
|
||
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||
EOF
|
||
|
||
- name: Build signed Android APK
|
||
run: bun run tauri android build --apk true --target aarch64
|
||
|
||
- name: Collect & verify signed APK
|
||
run: |
|
||
mkdir -p dist/android
|
||
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*-release.apk' | head -1)
|
||
if [ -z "$APK" ]; then echo "❌ No release APK produced"; exit 1; fi
|
||
cp "$APK" dist/android/jellytau-release.apk
|
||
APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1)
|
||
echo "🔏 Verifying signature with $APKSIGNER"
|
||
"$APKSIGNER" verify --print-certs dist/android/jellytau-release.apk
|
||
ls -lah dist/android/
|
||
|
||
- name: Upload Android build artifact
|
||
uses: actions/upload-artifact@v3
|
||
with:
|
||
name: jellytau-android
|
||
path: dist/android/
|
||
retention-days: 7
|
||
|
||
create-release:
|
||
name: Create Release
|
||
runs-on: linux/amd64
|
||
needs: [build-linux, build-windows, build-android]
|
||
if: startsWith(github.ref, 'refs/tags/v')
|
||
container:
|
||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||
steps:
|
||
- name: Checkout repository
|
||
uses: actions/checkout@v4
|
||
|
||
- name: Get version from tag
|
||
id: tag_name
|
||
run: |
|
||
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||
echo "RELEASE_NAME=JellyTau ${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||
|
||
- name: Download Linux artifacts
|
||
uses: actions/download-artifact@v3
|
||
with:
|
||
name: jellytau-linux
|
||
path: artifacts/linux/
|
||
|
||
- name: Download Windows artifacts
|
||
uses: actions/download-artifact@v3
|
||
with:
|
||
name: jellytau-windows
|
||
path: artifacts/windows/
|
||
|
||
- name: Download Android artifacts
|
||
uses: actions/download-artifact@v3
|
||
with:
|
||
name: jellytau-android
|
||
path: artifacts/android/
|
||
|
||
# Software Bill of Materials, one per half of the app. Without it there is
|
||
# no answer to "does this release contain <vulnerable crate>?" other than
|
||
# rebuilding the tag and re-resolving it. cargo-cyclonedx is in the builder
|
||
# image; the JS side is read straight from the lockfile bun install used.
|
||
- name: Generate SBOM
|
||
run: |
|
||
set -e
|
||
mkdir -p artifacts/sbom
|
||
cd src-tauri
|
||
cargo cyclonedx --format json
|
||
find . -maxdepth 2 -name "*.cdx.json" -exec cp -v {} ../artifacts/sbom/ \;
|
||
cd ..
|
||
bun install --frozen-lockfile
|
||
bun pm ls --all > artifacts/sbom/frontend-dependencies.txt
|
||
ls -lah artifacts/sbom/
|
||
|
||
# Checksums over everything being published. A release of unsigned Linux
|
||
# and Windows binaries with no checksum gives a user no way at all to tell
|
||
# a corrupted or substituted download from a good one — and the AppImage
|
||
# and NSIS installer are both fetched over plain HTTP redirects.
|
||
#
|
||
# 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
|
||
mkdir -p artifacts/release
|
||
find artifacts/linux artifacts/windows artifacts/android -type f -exec cp -v {} artifacts/release/ \;
|
||
cd artifacts/release
|
||
sha256sum * > SHA256SUMS
|
||
echo "🔐 Published checksums:"
|
||
cat SHA256SUMS
|
||
# Verify what we just wrote, so a broken checksum file fails the
|
||
# 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"
|
||
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:
|
||
# GITEA_TOKEN (a PAT) is preferred; falls back to the auto-provided token.
|
||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
run: |
|
||
set -e
|
||
command -v jq >/dev/null || { echo "❌ jq is required on the runner"; exit 1; }
|
||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
||
API="${GITHUB_SERVER_URL}/api/v1"
|
||
REPO="${GITHUB_REPOSITORY}"
|
||
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
||
case "$VERSION" in *rc*|*beta*|*alpha*) PRE=true;; *) PRE=false;; esac
|
||
|
||
PAYLOAD=$(jq -n \
|
||
--arg tag "$VERSION" \
|
||
--arg name "JellyTau $VERSION" \
|
||
--rawfile body release_notes.md \
|
||
--argjson pre "$PRE" \
|
||
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')
|
||
|
||
echo "📦 Creating release $VERSION on $REPO"
|
||
# -f drops on HTTP error; capture status so an existing release (409) is handled gracefully.
|
||
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
|
||
-H "Authorization: token $TOKEN" \
|
||
-H "Content-Type: application/json" \
|
||
-d "$PAYLOAD")
|
||
if [ "$HTTP" = "201" ]; then
|
||
RELEASE_ID=$(jq -r '.id' resp.json)
|
||
elif [ "$HTTP" = "409" ]; then
|
||
echo "ℹ️ Release $VERSION already exists; fetching its id to upload assets"
|
||
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$VERSION" \
|
||
-H "Authorization: token $TOKEN" | jq -r '.id')
|
||
else
|
||
echo "❌ Failed to create release (HTTP $HTTP):"; cat resp.json; exit 1
|
||
fi
|
||
echo "Release id=$RELEASE_ID"
|
||
|
||
# artifacts/release/ holds a copy of every platform artifact plus the
|
||
# SHA256SUMS generated over exactly that set, so the checksums describe
|
||
# precisely what is uploaded. artifacts/sbom/ rides along.
|
||
for f in artifacts/release/* artifacts/sbom/*; do
|
||
[ -f "$f" ] || continue
|
||
echo "⬆️ Uploading $(basename "$f")"
|
||
curl -fsS -X POST \
|
||
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
|
||
-H "Authorization: token $TOKEN" \
|
||
-F "attachment=@$f" >/dev/null
|
||
done
|
||
echo "✅ Release $VERSION published with assets"
|