# Tauri desktop (Linux) build — SEPARATE from ci.yml on purpose: a Rust + AppImage # build that shouldn't run on server-only pushes. Produces the .deb and .AppImage. # # It DOES run on frontend changes. tauri's generate_context! embeds the built # frontend in the binary, so a frontend commit that never triggers this ships to # the web and silently never reaches the desktop app — and desktop, web and Android # are peer surfaces held to one quality bar, not a primary and its fallbacks. The # filter was once narrowed to the adapter/bridge directories against a "~20-40 min" # build; measured runs are 4-5 minutes, so the cost that justified the narrowing # isn't there. # # Toolchain comes from the ci-tauri image (Rust + Node + WebKitGTK 4.1 + tauri-cli); # runs-on is just a registered scheduling label (Label Model B), not a per-purpose # runner. The frontend is built here because tauri's generate_context! embeds it. name: Desktop (Tauri) on: push: # NO `paths:` FILTER. It was a second, independent statement of this artifact's # file set, hand-kept beside the one in `packaging/version.sh`, and it drifted # from it within a day (85ead4d). The `decide` job below reads the real set and # skips in seconds when nothing moved — one definition, one reader (§3). # # The cost is that this workflow starts on every push rather than on a matching # one. That is a ~15s container for a decision, against a lane that cannot # silently fail to run. branches: [dev, main] workflow_dispatch: concurrency: group: desktop-${{ github.ref }} cancel-in-progress: true permissions: # write (not read) so the tag build can publish a Release with the bundles # attached (the "Publish release" step). Read is enough for dev/main builds, # but the token scope is per-workflow, so it's set once here. contents: write jobs: # Does anything need building at all? # # ONE reader of ONE definition — the file sets in `packaging/version.sh` — replacing # the `paths:` filters that used to state the same fact a second time. They drifted # from it within a day: `packaging/` was added to the sets and not to the filters, # so the commit fixing a derivation bug never ran on the two lanes it fixed # (85ead4d). Note 3127 §3 warns about exactly that duplication. # # THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one (§6.3). # Skipping because "the channel already serves this version" is indistinguishable # from "we derived a stale value that happens to match" unless something checks. decide: name: Build, or is the channel already serving this? if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 outputs: build: ${{ steps.d.outputs.build }} steps: - uses: actions/checkout@v6 with: # Derives a version — depth-1 is silently wrong (§6.1). fetch-depth: 0 - name: Decide id: d env: GITHUB_TOKEN: ${{ github.token }} run: | case "$GITHUB_REF_NAME" in main) channel=stable ;; *) channel=dev ;; esac sh packaging/guard-forward.sh desktop "$channel" echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT build: name: Tauri desktop (Linux) needs: [decide] if: needs.decide.outputs.build == 'true' runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-tauri:1.97 env: # AppImage tooling (linuxdeploy) FUSE-mounts itself by default; CI containers # have no /dev/fuse, so tell it to extract-and-run instead. Without this the # AppImage bundle step fails with a FUSE error. APPIMAGE_EXTRACT_AND_RUN: "1" steps: - uses: actions/checkout@v6 with: # DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one # commit and `git log -- ` produces a too-LOW value, silently, with # the lane green — note 3127 §6.1, and the direction you cannot recover # from. `packaging/version.sh` fails loudly on an empty result rather than # emitting something plausible, which is what turns this into a red lane # if it is ever dropped. fetch-depth: 0 # tauri's generate_context! embeds the built frontend at compile time, so the # frontend must exist before any cargo compile (clippy/test/build), not just # at bundle time. - name: Build the shared frontend run: npm ci && npm run build working-directory: frontend # --locked on the FIRST cargo invocation of the job is the lockfile gate: it # fails the run if Cargo.toml and the committed Cargo.lock disagree, instead # of silently re-resolving. Everything after it in this job then compiles the # exact versions recorded in the lockfile, so the flag isn't repeated on the # bundle build (issue 2102). # # Run from the REPO ROOT with --workspace, not from desktop/src-tauri. # # These three steps used to run inside the desktop crate, which was right when # it was the only Rust in the repo. After the core was extracted (M12 step 1) # it silently stopped being right: cargo scoped to the desktop PACKAGE, so the # core's 89 tests stopped running and nothing lints the Android uniffi shim at # all. Both crates are dependencies of the desktop, so they still COMPILED — # which is exactly why the gap was invisible, and why a green run kept meaning # less than it looked like it meant. - name: Clippy run: cargo clippy --locked --workspace --all-targets -- -D warnings - name: Test run: cargo test --locked --workspace # Deliberately AFTER clippy + test, not before. # # It's the cheapest check, so fail-fast ordering would normally put it first — # but there is no Rust toolchain on the workstation (the desktop lane is # verified entirely here), so a formatting nit failing first SKIPS clippy and # the tests, and one CI cycle teaches nothing but whitespace. Running it here # means every push reports its real problems too. Still before the ~20-40 min # bundle build, so a fmt failure doesn't burn that. - name: Rust format check run: cargo fmt --all --check # Frontend already built above; skip the beforeBuildCommand rebuild. # # createUpdaterArtifacts is applied only when a signing key exists (M10.9): # tauri FAILS the build if it's asked to produce updater artifacts with no key, # so making it conditional is what lets the pipeline stay green before the # operator has added the secret. With the key present, each bundle gets a # `.sig` beside it — the file the updater actually verifies against. - name: Tauri build (deb + AppImage) env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | updater='{}' if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then echo "Signing key present — producing updater artifacts." updater='{"bundle":{"createUpdaterArtifacts":true}}' else echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts." fi # The ORDERING KEY, not the display version: this string is what Tauri's # updater parses as semver, and what it stamps into bundle FILENAMES that # `write-manifest.sh` then selects on. The human-readable version is a # separate value and arrives with the UI that shows it (#3181). version="$(sh ../../packaging/version.sh key desktop)" echo "Building desktop ordering key $version" cargo tauri build \ --config '{"build":{"beforeBuildCommand":""}}' \ --config "{\"version\":\"$version\"}" \ --config "$updater" working-directory: desktop/src-tauri # Tauri's AppImage bundles the build host's graphics/display libs # (libEGL/libGL/libdrm/libgbm/libwayland-*), which clash with end-user GPU # drivers and abort to a black window (EGL_BAD_PARAMETER, issue 2021). # Strip that host-coupled stack so the app uses the running system's # graphics libs; webkit/gtk stay bundled. Runs from the repo root (the # script resolves its own paths), overwriting the AppImage in place. - name: De-bundle AppImage graphics libraries run: bash desktop/packaging/appimage/debundle-graphics.sh # MUST run after de-bundling, not before. The step above DELETES the AppImage # and repackages it, so the signature tauri produced during the build now # describes a file that no longer exists. Publishing that stale .sig would make # every Linux update fail verification — and the error names a signature # mismatch, which points nowhere near "a later build step rewrote the file". # Windows needs no equivalent: nothing post-processes the NSIS installer. - name: Re-sign the de-bundled AppImage env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then echo "No signing key — the build produced no signature to replace." exit 0 fi appimage="$(find target/release/bundle/appimage -name '*.AppImage' -type f | head -1)" [ -n "$appimage" ] || { echo "ERROR: no AppImage found to re-sign" >&2; exit 1; } rm -f "$appimage.sig" cargo tauri signer sign "$appimage" [ -s "$appimage.sig" ] || { echo "ERROR: re-signing produced no .sig" >&2; exit 1; } echo "Re-signed $(basename "$appimage")" # install.sh hands the .deb to every Debian/Ubuntu user, so the package's # Depends must be right BEFORE a release exists. Prints the generated # control file and cross-checks it against what the ELF actually needs # (dpkg-shlibdeps). # # We deliberately do NOT set bundle.linux.deb.depends: run 2872 showed # tauri already infers exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so # declaring them again only produced a control file listing each twice. # This step is the guard instead — if tauri's inference ever stops # covering what the binary links, the build fails here. - name: Verify the .deb run: bash desktop/packaging/deb/verify.sh # Repackage the binary just built into a native pacman package, so Arch / # CachyOS gets a real native install from install.sh instead of the AppImage # fallback — without a second Rust build or an Arch CI image. Safe because # nothing is bundled: the binary resolves webkit/gtk by soname, which is # identical across the two distros. Can't be pacman-tested here (Debian # runner), so the step logs .PKGINFO + the full file listing for audit. - name: Package for Arch (pacman) run: bash desktop/packaging/arch/package-prebuilt.sh # Make the built .deb + .AppImage downloadable from the run (for hand-testing). # Mirrored action, never actions/upload-artifact: @v4+ throws # GHESNotSupportedError on the hostname before it connects, and @v3 uploads # something Gitea stores but will never serve back (it returns artifacts only # through the v4 API, which filters on content_encoding='application/zip'). # Pinned by SHA — the mirror auto-syncs, so a moved upstream tag would # silently change what runs. See Scribe issues 2255 / 2270. # No continue-on-error: a swallowed upload failure is exactly how 110 # unreachable artifacts accumulated here unnoticed. Fail loudly instead. - name: Upload bundles uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245 with: name: thoughtsync-linux path: | target/release/bundle/appimage/*.AppImage target/release/bundle/deb/*.deb target/release/bundle/arch/*.pkg.tar.* # error, not warn: a build that bundles nothing should report as a # failure, not as a green run with an empty artifact. if-no-files-found: error # The rolling channel for this branch: `dev` from dev, `stable` from main. Both # are releases whose tag never moves, so the updater has a permanent URL to # read — Forgejo has no /releases/latest/download/ route, so "newest" # cannot be named in a URL. # # MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until # M314 step 3 this job built on main and published nothing, so the stable # channel moved only when somebody cut a tag — that section's diagnostic # failing outright: main publishing was not sufficient for a user to receive # the build. # # Gated on the signing key INSIDE the script rather than with an `if:`, because # the secrets context isn't reliably available to step conditions. Publishing # bundles the app would then refuse to verify is worse than publishing nothing: # it looks like a working feed. - name: Publish to the channel for this branch if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' env: GITHUB_TOKEN: ${{ github.token }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} run: | if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish." exit 0 fi # POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81). # `prerelease` is true for dev so it does not read as a supported build, # and false for stable, which is the real thing. case "$GITHUB_REF_NAME" in main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;; *) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;; esac export RELEASE_TAG RELEASE_PRERELEASE echo "Publishing to the $RELEASE_TAG channel." bash desktop/packaging/publish-release.sh # Windows installer, CROSS-COMPILED from Linux — there is no Windows build host. # A Windows container can't run on a Linux host (containers share the host # kernel), so cross-compiling is the only route without Windows hardware: # cargo-xwin + LLVM's lld-link + makensis are Linux programs that emit Windows # PE output. That toolchain is why this needs its own image rather than ci-tauri. # # NSIS only. `.msi` needs WiX v3, which is a Windows program — Tauri: ".msi # installers can only be created on Windows". It returns if a Windows node does. # # A separate job, so a Windows-side failure never blocks the Linux artifacts that # are the primary product today. Tauri calls this path "not tested as much" and a # last resort, and nothing here can LAUNCH a Windows binary — green means it # built, not that it runs. A real-machine check stays mandatory before trusting it. windows: name: Windows installer (cross-compiled) needs: [decide] if: needs.decide.outputs.build == 'true' runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97 steps: - uses: actions/checkout@v6 with: # DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one # commit and `git log -- ` produces a too-LOW value, silently, with # the lane green — note 3127 §6.1, and the direction you cannot recover # from. `packaging/version.sh` fails loudly on an empty result rather than # emitting something plausible, which is what turns this into a red lane # if it is ever dropped. fetch-depth: 0 # Same reason as the Linux job: generate_context! embeds the built frontend # at compile time, so it must exist before cargo runs. - name: Build the shared frontend run: npm ci && npm run build working-directory: frontend # tauri-build generates a Windows Resource file and needs `icons/icon.ico`, # which the repo doesn't carry — only the PNG set the Linux bundles use. # Generating it from the committed 1024px source keeps one icon of record # instead of a hand-made .ico that could silently drift from the brand art. # Linux doesn't need this step, which is why it lives here and not in `build`. - name: Generate the Windows icon set run: cargo tauri icon app-icon.png working-directory: desktop/src-tauri # This lane's lockfile gate (the Linux job gets it from `cargo clippy # --locked`). It has to be its own step here because the build is this job's # only crate-graph command, and discovering the drift 30 minutes into a # cross-compile is the expensive way to learn it. Fetching for the Windows # target also pre-warms exactly the crates the build will want. - name: Verify the lockfile and fetch dependencies run: cargo fetch --locked --target x86_64-pc-windows-msvc working-directory: desktop/src-tauri # --runner cargo-xwin swaps cargo for the cross-compiling driver (it supplies # the MSVC CRT/SDK, pre-warmed into the image, and links with lld-link). # Frontend already built above; skip the beforeBuildCommand rebuild. - name: Tauri build (NSIS installer) env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | # The ORDERING KEY, not the display version: this string is what Tauri's # updater parses as semver, and what it stamps into bundle FILENAMES that # `write-manifest.sh` then selects on. The human-readable version is a # separate value and arrives with the UI that shows it (#3181). version="$(sh ../../packaging/version.sh key desktop)" echo "Building desktop ordering key $version" updater='{}' if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then updater='{"bundle":{"createUpdaterArtifacts":true}}' fi cargo tauri build \ --runner cargo-xwin \ --target x86_64-pc-windows-msvc \ --bundles nsis \ --config '{"build":{"beforeBuildCommand":""}}' \ --config "{\"version\":\"$version\"}" \ --config "$updater" working-directory: desktop/src-tauri # Mirrored action, never actions/upload-artifact — see the Linux job's # Upload bundles step for the full reasoning. Pinned by SHA because the # mirror auto-syncs. - name: Upload installer uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245 with: name: thoughtsync-windows path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe if-no-files-found: error # The rolling channel for this branch: `dev` from dev, `stable` from main. Both # are releases whose tag never moves, so the updater has a permanent URL to # read — Forgejo has no /releases/latest/download/ route, so "newest" # cannot be named in a URL. # # MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until # M314 step 3 this job built on main and published nothing, so the stable # channel moved only when somebody cut a tag — that section's diagnostic # failing outright: main publishing was not sufficient for a user to receive # the build. # # Gated on the signing key INSIDE the script rather than with an `if:`, because # the secrets context isn't reliably available to step conditions. Publishing # bundles the app would then refuse to verify is worse than publishing nothing: # it looks like a working feed. - name: Publish to the channel for this branch if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' env: GITHUB_TOKEN: ${{ github.token }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} run: | if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish." exit 0 fi # POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81). # `prerelease` is true for dev so it does not read as a supported build, # and false for stable, which is the real thing. case "$GITHUB_REF_NAME" in main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;; *) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;; esac export RELEASE_TAG RELEASE_PRERELEASE echo "Publishing to the $RELEASE_TAG channel." bash desktop/packaging/publish-release.sh # The updater manifest, written AFTER both bundle jobs — they run in separate # workspaces and neither can see the other's output, but one latest.json has to # describe both platforms. Building it inside either job would silently omit the # other, and a missing platform reads to a user as "no update available" rather # than as a broken feed. # # Reads what actually landed on the channel release, so it can never advertise a # bundle that failed to upload. manifest: name: Update manifest needs: [build, windows] if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-tauri:1.97 steps: - uses: actions/checkout@v6 with: # DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one # commit and `git log -- ` produces a too-LOW value, silently, with # the lane green — note 3127 §6.1, and the direction you cannot recover # from. `packaging/version.sh` fails loudly on an empty result rather than # emitting something plausible, which is what turns this into a red lane # if it is ever dropped. fetch-depth: 0 - name: Write and publish latest.json env: GITHUB_TOKEN: ${{ github.token }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} run: | if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then echo "No TAURI_SIGNING_PRIVATE_KEY — nothing was signed, so there is no" echo "manifest to write. Add the secret to enable in-app updates." exit 0 fi # The SAME helper AND the same request the bundles were built with — a # second derivation here could drift, and a manifest whose version doesn't # match the binary it points at is an updater that never settles. It must # be `key`: this value is matched against bundle filenames. version="$(sh packaging/version.sh key desktop)" # Both channels are rolling: the manifest lands on the same release that # holds the bundles, and the previous build's bundles are dropped once it # points at this one. Nothing can reach them, and they are ~100 MB a push. # # No tag arm any more. A `v*` tag does not reach this workflow at all — it # triggers release.yml, which writes a changelog and builds nothing. case "${GITHUB_REF_NAME}" in main) export RELEASE_TAG=stable export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;; *) export RELEASE_TAG=dev export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;; esac export PRUNE_OLD_ASSETS=true APP_VERSION="$version" bash desktop/packaging/write-manifest.sh