🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m51s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Failing after 25s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 14s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m19s
The plugin versions could not be matched upward without this: both
tauri-plugin-log 2.9.0 and tauri-plugin-updater 2.10.1 require tauri
^2.10, and the tree was on 2.9.5. So the framework moves with them --
tauri 2.9.5 -> 2.11.5, tauri-build 2.5.3 -> 2.6.3, wry 0.53.5 -> 0.55.1
-- and every plugin's Rust crate and npm package is now pinned to the
same version on both sides.
That upgrade broke Android outright, and the breakage is the interesting
part.
Seven call sites in this crate reach JNI through
ndk_context::android_context(), which reads a process-global pair of
pointers. Nothing here ever set that global. `tao` did -- the windowing
layer under wry, three levels below anything this project names in
Cargo.toml. tao 0.34.5 called initialize_android_context() while starting
the activity and our code read what it left behind. tao 0.35.3 keeps the
same two pointers in a private struct and no longer publishes them.
The result, on every launch, was:
PANIC at ndk-context/src/lib.rs:72: android context was not initialized
8: ndk_context::android_context
9: jellytau_lib::run::{{closure}}
Not a crash in our code, and not a change to our code: an undocumented
side effect of a transitive dependency disappeared. Relying on someone
else to populate a global is a dependency that does not appear in
Cargo.toml and gives no warning when it goes.
src-tauri/src/android_context.rs now owns that invariant instead of
assuming it. JNI_OnLoad captures the JavaVM as the shared library loads
-- the earliest moment available, and nothing in tao, wry or tauri
defines one to collide with. The Context is resolved lazily via
ActivityThread.currentApplication() and pinned as a global reference for
the process lifetime, since ndk_context stores a bare pointer and does
not own it. It publishes the Application rather than the Activity:
SecureStorage.initialize() immediately reduces its argument to
applicationContext anyway, and an Application cannot outlive itself the
way a retained Activity would.
Restoring the global keeps all seven callers untouched. Threading a VM
and Context handle through five credential call sites would have been a
larger change with more risk, on the credential path.
Failure now degrades instead of aborting: it is logged and credentials
fall back to the encrypted-file path, which the app already supports.
Verified on a device, R8-minified, not merely compiled:
[INIT] Android JavaVM and Application published to ndk_context
Android SecureStorage initialized successfully
Android Keystore available via SecureStorage
[INIT] Using system keyring for credential storage
[CodecDetection] Detected 7 video codecs: av1,h263,h264,hevc,...
-- the real keystore path, not the fallback, and the app stays up. None
of this is reachable by CI: nothing there runs the app.
Also fixed here, both found the same way:
- `tauri android build --apk true` is now `--apk`. The CLI took a value
until 2.10; from 2.11 the stray `true` is a positional and the build
fails before starting. Three call sites in build-android.sh and one
in build-release.yml -- the latter builds the signed APK, by far the
most-downloaded artifact.
- scripts/build-android.sh ran `npm install` on its clean-build path in
a bun project, ignoring bun.lock and re-resolving the tree. That is
exactly how the plugin crate/package versions drift apart again.
scripts/check-tooling.sh now fails on any npm/yarn/pnpm invocation or
foreign lockfile, and runs in CI.
DR-222, DR-223.
708 lines
30 KiB
YAML
708 lines
30 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.
|
||
# Same hazard as the Windows job: the bundle directory is never cleaned by
|
||
# cargo and the runner reuses src-tauri/target, while the copy step below
|
||
# globs bundle/deb/*.deb and friends. Windows is where this actually bit
|
||
# (v0.8.2 shipped thirteen stale installers), but only because Linux
|
||
# packaging is newer -- the glob is identical. Remove the directory so a
|
||
# stale artifact cannot exist to be copied.
|
||
- name: Clear previous bundle output
|
||
run: rm -rf src-tauri/target/release/bundle
|
||
|
||
- name: Build for Linux
|
||
run: bun run tauri build
|
||
env:
|
||
# linuxdeploy's bundled `strip` cannot parse the `.relr.dyn` section
|
||
# modern toolchains emit, and fails on every bundled library:
|
||
# strip: libzstd.so.1: unknown type [0x13] section `.relr.dyn'
|
||
# failed to bundle project `failed to run linuxdeploy`
|
||
# Ubuntu 23.10+ links with -z pack-relative-relocs by default, so this
|
||
# image hits it. Skipping strip is linuxdeploy's documented escape
|
||
# hatch; the cost is a larger AppImage. Found by building the target
|
||
# locally before tagging -- nothing in CI builds the app, so a release
|
||
# would have been the first time anyone discovered the AppImage target
|
||
# does not work.
|
||
NO_STRIP: "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: |
|
||
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
|
||
|
||
# `--apk` is a boolean flag, not `--apk true`. tauri-cli took a value here
|
||
# until 2.10; from 2.11 the stray `true` is parsed as a positional and the
|
||
# command fails with "unexpected argument 'true' found" before building.
|
||
# This line and scripts/build-android.sh must agree.
|
||
- name: Build signed Android APK
|
||
run: bun run tauri android build --apk --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/
|
||
|
||
# Runs before the SBOM, the checksums and the upload -- everything
|
||
# downstream describes this set of files, so a stale artifact must be
|
||
# caught before it gets hashed into SHA256SUMS and published as though it
|
||
# belonged to this release.
|
||
#
|
||
# See the script for the eight months of releases that shipped their
|
||
# predecessors' Windows installers.
|
||
- name: Verify artifacts belong to this release
|
||
run: |
|
||
./scripts/check-release-artifacts.sh \
|
||
"${{ steps.tag_name.outputs.VERSION }}" \
|
||
artifacts/linux artifacts/windows 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
|
||
|
||
# What the in-app update prompt shows. Same reviewed source as the
|
||
# release body -- the CHANGELOG section for this version, not the
|
||
# traceability draft.
|
||
NOTES="$(awk -v ver="## $VERSION" '$0==ver{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md | head -c 4000)"
|
||
[ -n "$NOTES" ] || NOTES="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
|
||
|
||
# The published body is the hand-written CHANGELOG.md section for this
|
||
# version. `bun run release:notes` is printed into the job log as a
|
||
# drafting aid, but is NOT published: CLAUDE.md is explicit that its
|
||
# output is "a reviewed draft, not a final changelog", and publishing it
|
||
# unreviewed proved the point -- a range containing a repo-wide prettier
|
||
# sweep resolved to nearly the whole requirement matrix and produced notes
|
||
# claiming one release had added the entire application.
|
||
#
|
||
# A missing CHANGELOG section fails the release. A release whose notes say
|
||
# nothing is worse than one that waits for a maintainer to write two
|
||
# sentences, and the checklist already requires that entry.
|
||
- name: Prepare release notes
|
||
id: release_notes
|
||
run: |
|
||
set -e
|
||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
||
|
||
echo "📋 Traceability draft (for reference; not published):"
|
||
bun run release:notes 2>/dev/null || echo "(could not derive a draft)"
|
||
echo ""
|
||
|
||
# The section between this version's heading and the next one.
|
||
CHANGES=$(awk -v ver="## $VERSION" '$0==ver{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md)
|
||
if [ -z "$(echo "$CHANGES" | tr -d '[:space:]')" ]; then
|
||
echo "::error::CHANGELOG.md has no '## $VERSION' section."
|
||
echo "::error::Add the entry for this version and re-tag; see docs/release-checklist.md."
|
||
exit 1
|
||
fi
|
||
|
||
{
|
||
echo "$CHANGES"
|
||
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 check for updates from here and can install a new"
|
||
echo "version in place, verifying its signature first."
|
||
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"
|