Files
cinny-desktop/.gitea/workflows/release.yml
Lotus CIandClaude Opus 5.5 1e71940789
Build Lotus Chat Desktop / prepare (push) Successful in 8s
Build Lotus Chat Desktop / build-linux (push) Successful in 30m37s
Build Lotus Chat Desktop / build-arch (push) Successful in 11s
Build Lotus Chat Desktop / build-windows (push) Successful in 32m6s
Build Lotus Chat Desktop / update-manifest (push) Successful in 8s
fix(build): bundle the web app's Giphy key so the desktop GIF picker works
The web deploy serves a config.json kept on the server (not in git), which
is where the Giphy key lives. The desktop app bundles the repo's
config.json, whose gifApiKey is empty, so on desktop the GIF button never
appeared even with Settings → GIF Picker switched on.

scripts/sync-web-config.mjs copies an allow-list of deployment-only keys
(just gifApiKey) from https://chat.lotusguild.org/config.json into
cinny/config.json before the Windows and Linux builds. The key is public
already (served to every web visitor; Giphy keys are client keys). A fetch
failure is a warning, not a build failure.

Tested locally: sets the key, is a no-op on a second run, and skips cleanly
on a 404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 15:50:35 -04:00

397 lines
18 KiB
YAML

name: Build Lotus Chat Desktop
on:
push:
branches: [main]
# Docs and the catch-up workflow itself don't need a ~30-min Tauri build.
paths-ignore:
- '**.md'
- '.gitea/workflows/catch-up.yml'
workflow_dispatch:
env:
GITEA_URL: https://code.lotusguild.org
REPO: LotusGuild/cinny-desktop
# Continuous "latest" builds track lotus HEAD, so only the newest submodule bump
# needs to build. Cancel a superseded in-flight release: rapid lotus pushes would
# otherwise queue several ~30-min Tauri builds back-to-back, and build-linux runs
# on ubuntu-latest — the same runner pool as cinny's web CI — so the queue also
# starves web CI/deploys. update-manifest only publishes release.json on full
# success, so a cancelled run leaves the last good manifest untouched.
concurrency:
group: desktop-release-${{ github.ref }}
cancel-in-progress: true
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.ver.outputs.version }}
release_id: ${{ steps.release.outputs.release_id }}
steps:
- name: Compute version
id: ver
run: echo "version=4.12.${{ github.run_number }}" >> $GITHUB_OUTPUT
- name: Create or update release
id: release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
# NOTE (partial-failure window): this renames the `latest` release's
# name/body to the new version up front, before the platform builds run.
# If a build later fails, the release keeps the OLD binary assets but the
# NEW name. That's cosmetic: the auto-updater reads release.json, which is
# only regenerated by the update-manifest job — and that job `needs:` BOTH
# build-windows and build-linux, so a failed/skipped build prevents any
# manifest (and therefore updater) change. Clients keep the last good
# release.json until a fully successful run replaces it.
run: |
VERSION="4.12.${{ github.run_number }}"
EXISTING=$(curl -sf "$GITEA_URL/api/v1/repos/$REPO/releases/tags/latest" \
-H "Authorization: token $TOKEN" 2>/dev/null || true)
RELEASE_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ] && [ "$RELEASE_ID" != "" ]; then
curl -sf -X PATCH "$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\":\"Lotus Chat $VERSION\",\"body\":\"Built from ${{ github.sha }}\"}" > /dev/null
else
RELEASE_ID=$(curl -sf -X POST "$GITEA_URL/api/v1/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"latest\",\"name\":\"Lotus Chat $VERSION\",\"prerelease\":true,\"body\":\"Built from ${{ github.sha }}\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
fi
echo "release_id=$RELEASE_ID" >> $GITHUB_OUTPUT
build-windows:
needs: prepare
runs-on: windows
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Checkout submodules (shallow)
shell: powershell
run: git submodule update --init --depth=1
- name: Patch version
shell: powershell
run: |
$ver = '${{ needs.prepare.outputs.version }}'
node -e "const fs=require('fs');const d=JSON.parse(fs.readFileSync('src-tauri/tauri.conf.json','utf8'));d.version='$ver';fs.writeFileSync('src-tauri/tauri.conf.json',JSON.stringify(d,null,2),'utf8');"
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Install frontend deps
shell: powershell
run: cd cinny; npm ci
# Deployment-only config (e.g. the Giphy key) lives on the web server,
# not in git; copy it in so the desktop GIF picker works.
- name: Sync web config
shell: powershell
run: node scripts/sync-web-config.mjs
- name: Install Tauri deps
shell: powershell
run: npm ci
- name: Build
shell: powershell
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
NODE_OPTIONS: '--max_old_space_size=4096'
# Sparse registry avoids a full git clone of the crates.io index —
# eliminates the curl SSL handshake failures seen on Windows runners.
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
# Disable HTTP/2 multiplexing — ALPN negotiation can reset on Windows Schannel.
CARGO_HTTP_MULTIPLEXING: 'false'
# Retry transient network errors before failing.
CARGO_NET_RETRY: '5'
run: |
# USERPROFILE is set by Windows directly; more reliable than C:\Users\$USERNAME
$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH"
# Also add the actual toolchain bin to bypass rustup shim execution issues
$toolchain = Get-ChildItem "$env:USERPROFILE\.rustup\toolchains" -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match 'stable' } | Select-Object -First 1
if ($toolchain) { $env:PATH = "$($toolchain.FullName)\bin;$env:PATH" }
Write-Host "cargo: $((Get-Command cargo -ErrorAction SilentlyContinue).Source)"
cargo --version
npm run tauri -- build --bundles nsis
- name: Upload to release
shell: powershell
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
$releaseId = $env:RELEASE_ID
$VERSION = $env:VERSION
Write-Host "Version: $VERSION Release: $releaseId"
$nsis = "src-tauri\target\release\bundle\nsis"
$files = @(
"$nsis\Lotus Chat_${VERSION}_x64-setup.exe",
"$nsis\Lotus Chat_${VERSION}_x64-setup.nsis.zip",
"$nsis\Lotus Chat_${VERSION}_x64-setup.nsis.zip.sig"
)
$names = @("LotusChat-x86_64-setup.exe", "LotusChat-x86_64-setup.nsis.zip", "LotusChat-x86_64-setup.nsis.zip.sig")
for ($i = 0; $i -lt $files.Length; $i++) {
$existing = (Invoke-RestMethod -Uri "$env:GITEA_URL/api/v1/repos/$env:REPO/releases/$releaseId/assets" `
-Headers @{ Authorization = "token $env:TOKEN" }) | Where-Object { $_.name -eq $names[$i] }
if ($existing) {
Invoke-RestMethod -Uri "$env:GITEA_URL/api/v1/repos/$env:REPO/releases/$releaseId/assets/$($existing.id)" `
-Method Delete -Headers @{ Authorization = "token $env:TOKEN" }
}
$bytes = [System.IO.File]::ReadAllBytes($files[$i])
Invoke-RestMethod -Uri "$env:GITEA_URL/api/v1/repos/$env:REPO/releases/$releaseId/assets?name=$($names[$i])" `
-Method Post `
-Headers @{ Authorization = "token $env:TOKEN"; "Content-Type" = "application/octet-stream" } `
-Body $bytes
}
build-linux:
needs: prepare
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Install system deps
run: |
apt-get update
apt-get install -y \
curl wget file gcc imagemagick \
libwebkit2gtk-4.1-dev \
libssl-dev \
libxdo-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
xdg-utils \
squashfs-tools
- name: Ensure icons are RGBA PNG
run: |
for f in src-tauri/icons/*.png; do
info=$(identify -verbose "$f" 2>/dev/null | grep "Type:" | head -1)
echo "$f: $info"
convert "$f" -type TrueColorAlpha PNG32:"$f"
done
- name: Set up Rust toolchain
run: |
source "$HOME/.cargo/env" 2>/dev/null || true
if command -v cargo >/dev/null 2>&1; then
echo "Using existing Rust: $(cargo --version)"
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --default-toolchain stable --profile minimal
source "$HOME/.cargo/env"
fi
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Patch version
run: python3 -c "import json; d=json.load(open('src-tauri/tauri.conf.json')); d['version']='${{ needs.prepare.outputs.version }}'; open('src-tauri/tauri.conf.json','w').write(json.dumps(d,indent=2))"
- name: Install frontend deps
run: cd cinny && npm ci
- name: Sync web config
run: node scripts/sync-web-config.mjs
- name: Install Tauri deps
run: npm ci
- name: Stage AppRun and linuxdeploy for AppImage bundler
run: |
set -e
mkdir -p ~/.cache/tauri
cp tools/AppRun-x86_64 ~/.cache/tauri/AppRun-x86_64
chmod +x ~/.cache/tauri/AppRun-x86_64
wget -q \
"https://github.com/tauri-apps/binary-releases/releases/download/linuxdeploy/linuxdeploy-x86_64.AppImage" \
-O /tmp/linuxdeploy.AppImage
chmod +x /tmp/linuxdeploy.AppImage
rm -rf /root/linuxdeploy-root
(cd /root && /tmp/linuxdeploy.AppImage --appimage-extract)
mv /root/squashfs-root /root/linuxdeploy-root
echo "Extracted linuxdeploy:"
ls /root/linuxdeploy-root/
ls /root/linuxdeploy-root/usr/bin/ 2>/dev/null || echo "no usr/bin"
# Pre-stage plugin scripts next to linuxdeploy so it finds them via /proc/self/exe lookup
wget -q "https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh" \
-O /root/linuxdeploy-root/usr/bin/linuxdeploy-plugin-gtk.sh
wget -q "https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gstreamer/master/linuxdeploy-plugin-gstreamer.sh" \
-O /root/linuxdeploy-root/usr/bin/linuxdeploy-plugin-gstreamer.sh
chmod +x /root/linuxdeploy-root/usr/bin/linuxdeploy-plugin-gtk.sh \
/root/linuxdeploy-root/usr/bin/linuxdeploy-plugin-gstreamer.sh
gcc -o ~/.cache/tauri/linuxdeploy-x86_64.AppImage tools/ld_wrapper.c
chmod +x ~/.cache/tauri/linuxdeploy-x86_64.AppImage
- name: Build
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
NODE_OPTIONS: '--max_old_space_size=4096'
RUST_LOG: tauri_bundler=debug
run: npm run tauri -- build --bundles appimage,deb
- name: Show linuxdeploy wrapper log
if: always()
run: cat /tmp/ld-wrapper.log 2>/dev/null || echo "no wrapper log found"
- name: Upload to release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
APPIMAGE_DIR="src-tauri/target/release/bundle/appimage"
DEB_DIR="src-tauri/target/release/bundle/deb"
upload() {
local name="$1" path="$2"
local existing_id
existing_id=$(curl -sf "$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets" \
-H "Authorization: token $TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$name'), ''))" 2>/dev/null || true)
if [ -n "$existing_id" ]; then
curl -sf -X DELETE "$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets/$existing_id" \
-H "Authorization: token $TOKEN" || true
fi
echo "Uploading $name"
curl -sf -X POST \
"$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$path"
}
upload "LotusChat-x86_64.AppImage" "$APPIMAGE_DIR/Lotus Chat_${VERSION}_amd64.AppImage"
upload "LotusChat-x86_64.AppImage.tar.gz" "$APPIMAGE_DIR/Lotus Chat_${VERSION}_amd64.AppImage.tar.gz"
upload "LotusChat-x86_64.AppImage.tar.gz.sig" "$APPIMAGE_DIR/Lotus Chat_${VERSION}_amd64.AppImage.tar.gz.sig"
upload "LotusChat-x86_64.deb" "$DEB_DIR/Lotus Chat_${VERSION}_amd64.deb"
build-arch:
# Needs build-linux's .deb to already be on the release (this job
# downloads it from the fixed "latest" asset URL, same as update-manifest
# does for signatures) rather than recompiling from source.
needs: [prepare, build-linux]
runs-on: ubuntu-latest
container:
image: archlinux:base-devel
steps:
# actions/checkout@v4 is a Node.js action and archlinux:base-devel has
# no Node runtime — it fails with "node: executable file not found".
# This job only needs one file, so skip checkout and fetch it directly
# from Gitea's raw endpoint instead.
- name: Install build tools
run: pacman -Sy --noconfirm --needed curl python
- name: Fetch PKGBUILD
run: |
mkdir -p packaging/pacman
curl -sf "$GITEA_URL/LotusGuild/cinny-desktop/raw/branch/main/packaging/pacman/PKGBUILD" \
-o packaging/pacman/PKGBUILD
- name: Set package version
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: sed -i "s/^pkgver=.*/pkgver=$VERSION/" packaging/pacman/PKGBUILD
- name: Build .pkg.tar.zst
run: |
# makepkg refuses to run as root. --nodeps: the package() step just
# extracts the .deb payload, so the runtime `depends` (webkit2gtk,
# gstreamer, ...) don't need to be installed on the build container.
useradd -m builder
chown -R builder:builder packaging/pacman
su builder -c 'cd packaging/pacman && makepkg -f --noconfirm --nodeps'
- name: Upload to release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
PKG=$(ls packaging/pacman/*.pkg.tar.zst)
NAME="LotusChat-x86_64.pkg.tar.zst"
existing_id=$(curl -sf "$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets" \
-H "Authorization: token $TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$NAME'), ''))" 2>/dev/null || true)
if [ -n "$existing_id" ]; then
curl -sf -X DELETE "$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets/$existing_id" \
-H "Authorization: token $TOKEN" || true
fi
curl -sf -X POST \
"$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$NAME" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$PKG"
update-manifest:
needs: [prepare, build-windows, build-linux]
runs-on: ubuntu-latest
steps:
- name: Generate and upload release.json
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
BASE="$GITEA_URL/LotusGuild/cinny-desktop/releases/download/latest"
WIN_SIG=$(curl -sf "$BASE/LotusChat-x86_64-setup.nsis.zip.sig")
LIN_SIG=$(curl -sf "$BASE/LotusChat-x86_64.AppImage.tar.gz.sig")
# Never publish a manifest with a missing/empty signature: the updater
# would reject (or worse, accept an unsigned) artifact. Fail the job so
# the previous good release.json stays in place.
[ -n "$WIN_SIG" ] || { echo "ERROR: empty Windows signature" >&2; exit 1; }
[ -n "$LIN_SIG" ] || { echo "ERROR: empty Linux signature" >&2; exit 1; }
DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
python3 -c "import json,sys; v,d,wu,ws,lu,ls=sys.argv[1:]; print(json.dumps({'version':v,'notes':'Latest Lotus Chat release','pub_date':d,'platforms':{'windows-x86_64':{'url':wu,'signature':ws},'linux-x86_64':{'url':lu,'signature':ls}}},indent=2))" \
"$VERSION" "$DATE" \
"$BASE/LotusChat-x86_64-setup.nsis.zip" "$WIN_SIG" \
"$BASE/LotusChat-x86_64.AppImage.tar.gz" "$LIN_SIG" \
> release.json
cat release.json
OLD=$(curl -sf "$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets" \
-H "Authorization: token $TOKEN" \
| python3 -c "import sys,json; print(next((str(a['id']) for a in json.load(sys.stdin) if a['name']=='release.json'), ''))" 2>/dev/null || true)
[ -n "$OLD" ] && curl -sf -X DELETE \
"$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets/$OLD" \
-H "Authorization: token $TOKEN" || true
curl -sf -X POST \
"$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=release.json" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @release.json