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 <> $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 ?" 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// but returns 404 for # /releases/latest/download/, 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 "-". 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"