Two halves of one mistake, both visible on the dev release right now: the manifest said 0.1.134 and pointed at ThoughtSync_0.1.132_amd64.AppImage. The rolling channel accumulates every build's assets, and the manifest picked its bundle by file extension with `head -1` — the OLDEST match. A client would have been told 0.1.134 was available, downloaded 0.1.132, installed it, and been offered 0.1.134 again. Forever. Signature verification could not have caught it. The old bundle's signature is perfectly valid for the old bundle; nothing about it says "this isn't the build the manifest claims". Selection is now matched on the build's own version string, so the manifest can only ever describe the binary it was written for. The accumulation is the other half. Nothing can reach a superseded build once the manifest moves on, and an AppImage is ~100 MB — three pushes had already left 300 MB of unreachable binaries on the Git host. A rolling channel now prunes everything but the current build once the manifest points at it. Versioned releases are untouched: that IS the archive, and the stable pointer's URLs aim into it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
352 lines
17 KiB
YAML
352 lines
17 KiB
YAML
# Tauri desktop (Linux) build — SEPARATE from ci.yml on purpose: this is a heavy
|
|
# Rust + AppImage build (~20-40 min) that should NOT run on backend/frontend-only
|
|
# pushes. Scoped to desktop/** (+ this file). Produces the .deb and .AppImage.
|
|
#
|
|
# 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:
|
|
branches: [dev, main]
|
|
tags: ["v*"]
|
|
paths:
|
|
- "desktop/**"
|
|
# The desktop app embeds the frontend, and the data seam / Tauri bridge are
|
|
# what the offline core rides on — rebuild the app when those change too.
|
|
- "frontend/src/adapters/**"
|
|
- "frontend/src/desktop/**"
|
|
- ".forgejo/workflows/desktop.yml"
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: desktop-${{ github.ref }}
|
|
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
|
|
|
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:
|
|
build:
|
|
name: Tauri desktop (Linux)
|
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
|
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
|
|
|
|
# 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
|
|
|
|
- name: Clippy
|
|
run: cargo clippy --all-targets -- -D warnings
|
|
working-directory: desktop/src-tauri
|
|
|
|
- name: Test
|
|
run: cargo test
|
|
working-directory: desktop/src-tauri
|
|
|
|
# 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 --check
|
|
working-directory: desktop/src-tauri
|
|
|
|
# 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
|
|
version="$(sh ../packaging/build-version.sh)"
|
|
echo "Building version $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")"
|
|
working-directory: desktop/src-tauri
|
|
|
|
# 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).
|
|
# continue-on-error: the Forgejo artifact backend may not be configured yet; a
|
|
# failed upload must not fail the build itself.
|
|
# Forgejo doesn't support the v4 artifact protocol (@actions/artifact v2+),
|
|
# so pin v3, which uses the older protocol the instance accepts.
|
|
- name: Upload bundles
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: thoughtsync-linux
|
|
path: |
|
|
desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
|
desktop/src-tauri/target/release/bundle/deb/*.deb
|
|
desktop/src-tauri/target/release/bundle/arch/*.pkg.tar.*
|
|
if-no-files-found: warn
|
|
|
|
# Tag builds only: publish a real, versioned Fabled-Git Release with the
|
|
# AppImage + .deb attached — the stable fetch target the install script and
|
|
# the in-app updater consume (Actions artifacts above are ephemeral/test).
|
|
# Cutting the tag is the operator's action (rule 2); this only publishes a
|
|
# Release for a tag that already exists. Dormant on dev/main pushes.
|
|
- name: Publish release
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: bash desktop/packaging/publish-release.sh
|
|
|
|
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
|
|
# the updater has a permanent URL to read — Forgejo has no
|
|
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
|
|
#
|
|
# 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 dev channel
|
|
if: github.ref == 'refs/heads/dev'
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
RELEASE_TAG: dev
|
|
RELEASE_PRERELEASE: "true"
|
|
run: |
|
|
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
|
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish."
|
|
exit 0
|
|
fi
|
|
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)
|
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
|
|
# 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
|
|
|
|
# --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: |
|
|
version="$(sh ../packaging/build-version.sh)"
|
|
echo "Building version $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
|
|
|
|
- name: Upload installer
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: thoughtsync-windows
|
|
path: desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
|
if-no-files-found: warn
|
|
|
|
# Publishes to the SAME release as the Linux job. Safe to run twice: the
|
|
# script reuses an existing release (409) and nullglob means each job uploads
|
|
# only the bundles present in its own workspace.
|
|
- name: Publish release
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: bash desktop/packaging/publish-release.sh
|
|
|
|
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
|
|
# the updater has a permanent URL to read — Forgejo has no
|
|
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
|
|
#
|
|
# 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 dev channel
|
|
if: github.ref == 'refs/heads/dev'
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
RELEASE_TAG: dev
|
|
RELEASE_PRERELEASE: "true"
|
|
run: |
|
|
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
|
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish."
|
|
exit 0
|
|
fi
|
|
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' || startsWith(github.ref, 'refs/tags/v')
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
|
|
- 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 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.
|
|
version="$(sh desktop/packaging/build-version.sh)"
|
|
if [ "${GITHUB_REF_NAME}" = "dev" ]; then
|
|
export RELEASE_TAG=dev
|
|
export RELEASE_NOTES="Development build from ${GITHUB_SHA}"
|
|
# Rolling channel: drop the previous build's bundles once the manifest
|
|
# points at this one. Nothing can reach them, and they're ~100 MB a push.
|
|
export PRUNE_OLD_ASSETS=true
|
|
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
|
|
else
|
|
export RELEASE_TAG="${GITHUB_REF_NAME}"
|
|
export RELEASE_NOTES="ThoughtSync ${GITHUB_REF_NAME}"
|
|
# Twice: once onto the versioned release itself, and once onto the
|
|
# permanent `stable` pointer the app actually reads. Same manifest both
|
|
# times — its URLs point at the versioned assets either way.
|
|
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
|
|
APP_VERSION="$version" MANIFEST_TAG=stable bash desktop/packaging/write-manifest.sh
|
|
fi
|