🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m32s
🏗️ Build and Test JellyTau / Supply Chain (push) Failing after 29s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m34s
Traceability Validation / Check Requirement Traces (push) Successful in 11s
Build & Release / Run Tests (push) Successful in 14m49s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m22s
Build & Release / Build Linux (push) Failing after 17m42s
Build & Release / Build Windows (push) Successful in 15m46s
Build & Release / Build Android (push) Successful in 30m54s
Build & Release / Create Release (push) Skipped
Two defects on the release path, both of which would have failed the v0.10.0 build after all three platforms had already compiled -- caught by running a real signed build locally instead of waiting for the tag. **createUpdaterArtifacts was never set.** Without it Tauri emits only the plain .AppImage and .exe: no signatures at all. The manifest step then finds none and aborts by design, so the release dies at Create Release having spent ~40 minutes building artifacts it cannot publish. **The manifest looked for the wrong filename.** Tauri v2 signs the .AppImage *itself* and writes <name>.AppImage.sig beside it. The .AppImage.tar.gz form this workflow globbed for only exists under createUpdaterArtifacts: "v1Compatible". A real signed build produced: 154M JellyTau_0.10.0_amd64.AppImage 420 JellyTau_0.10.0_amd64.AppImage.sig so the glob would have matched nothing and the step would have aborted for a second, entirely different reason. Both the artifact collection and the manifest now use the v2 names, and the AppImage and its .sig ship together -- a manifest referencing a signature that was never uploaded fails only on the user's machine. Verified before tagging rather than after: the manifest logic was run against the real artifacts (420-char minisign signature read correctly) and the resulting latest.json checked for validity and shape. The Windows side already used the correct pattern (<installer>.exe.sig), which is why only Linux needed the change.
716 lines
31 KiB
YAML
716 lines
31 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.
|
||
#
|
||
# Tauri v2 signs the .AppImage ITSELF and writes <name>.AppImage.sig
|
||
# beside it -- there is no .AppImage.tar.gz unless
|
||
# bundle.createUpdaterArtifacts is set to "v1Compatible". The updater
|
||
# downloads the same AppImage a human does and verifies that .sig, so
|
||
# both files must ship or the manifest points at a signature nobody
|
||
# can fetch.
|
||
for bundle in \
|
||
src-tauri/target/release/bundle/appimage/*.AppImage \
|
||
src-tauri/target/release/bundle/appimage/*.AppImage.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=""
|
||
|
||
# Tauri v2 signs the AppImage itself; <name>.AppImage.sig sits beside
|
||
# it. Verified against a real signed build before tagging -- the
|
||
# v1-style .AppImage.tar.gz is never produced with
|
||
# createUpdaterArtifacts: true.
|
||
for f in artifacts/linux/*.AppImage; do
|
||
[ -e "$f" ] || continue
|
||
case "$f" in *.sig) continue;; esac
|
||
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"
|