name: Build App (Preview) # Builds the Tauri app for branches other than main and publishes the bundles as # a **prerelease**, so they are downloadable from the Releases page. No GitHub # sync — intended for smoke-testing a feature branch before it merges. # # ## Why not workflow artifacts # # Two attempts failed before this one, and both failure modes are worth knowing: # # * `actions/upload-artifact@v4` cannot run here at all. It bundles # `@actions/artifact` v2, whose `isGhes()` treats any GITHUB_SERVER_URL that # is not github.com / *.ghe.com / *.localhost as GitHub Enterprise Server and # throws before making a single request. act_runner sets that variable to this # Gitea instance, so every platform died with "GHESNotSupportedError" — after # the whole Tauri build had been paid for (run #265). # * `@v3` uploads *succeed*, and the files are downloadable by direct URL — but # Gitea does not **list** them: `/api/v1/…/runs//artifacts` reports # `total_count: 0` and the run page shows nothing (verified on run #267). # A build nobody can find is not a build. # # So previews publish the same way every other workflow here does: curl to the # Gitea releases API. One release per preview, tagged `preview-`. # # ## Lifecycle # # The `preview-` tag prefix is deliberate. `cleanup-releases.yml` keeps the most # recent `v..` releases and separately deletes every release # whose tag does *not* start with `v[0-9]` — so previews are pruned by the # cleanup that is already run, and never crowd the real release list. # # `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub. env: GITEA_URL: ${{ gitea.server_url }} REPO: ${{ gitea.repository }} on: workflow_dispatch: jobs: compute-version: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.VERSION }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Compute preview version id: version run: | MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]') SHORT_SHA=$(git rev-parse --short HEAD) # The patch number is computed exactly as build-app.yml does it, so a # preview is labelled with the version the release it previews would # carry. This used to be hard-coded `.0`, which made every preview # installer claim to be x.y.0 no matter what it contained. LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true) if [ -n "$LATEST_TAG" ]; then PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD") echo "Latest matching tag: ${LATEST_TAG} (+${PATCH} commits)" else echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0" PATCH=0 fi VERSION="${MAJOR_MINOR}.${PATCH}-preview.${SHORT_SHA}" echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT echo "Computed preview version: ${VERSION}" # One release, created once. The three build jobs run concurrently, so # get-or-create in each of them would race on the same tag: whoever loses gets # a 409 and (the way the old build-app.yml parsed it) an empty release id that # still reported success. Creating it in a job they all depend on removes the # race rather than handling it. create-release: runs-on: ubuntu-latest needs: [compute-version] outputs: release_id: ${{ steps.release.outputs.RELEASE_ID }} tag: ${{ steps.release.outputs.TAG }} steps: - name: Create the preview release id: release env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} VERSION: ${{ needs.compute-version.outputs.version }} run: | set -euo pipefail TAG="preview-${VERSION##*.}" echo "TAG=${TAG}" >> $GITHUB_OUTPUT # Idempotent: re-dispatching the same commit must update the existing # release rather than fail on the duplicate tag. HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \ -H "Authorization: token ${TOKEN}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}") case "${HTTP_CODE}" in 200) echo "Release ${TAG} already exists, reusing" ;; 404) echo "Creating release ${TAG}" # prerelease: true keeps it off "latest" — this is a branch build, # not something anyone should install by accident. curl -fsS -X POST \ -H "Authorization: token ${TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${{ gitea.sha }}\", \"name\": \"Preview ${VERSION}\", \"prerelease\": true, \"body\": \"Unreleased build of \`${{ gitea.ref_name }}\` at ${{ gitea.sha }}. Not a release — pruned by Cleanup Old Releases.\"}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json ;; *) echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2 cat release.json >&2 || true exit 1 ;; esac RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true) if [ -z "${RELEASE_ID}" ]; then echo "Failed to parse release id; response was:" >&2 cat release.json >&2 exit 1 fi echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT echo "Release ${TAG} is id ${RELEASE_ID}" build-linux: runs-on: ubuntu-latest needs: [compute-version, create-release] steps: - name: Install Node.js 22 run: | NEED_INSTALL=false if command -v node >/dev/null 2>&1; then NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/') OLD_NODE_DIR=$(dirname "$(which node)") echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})" if [ "$NODE_MAJOR" -lt 22 ]; then echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..." sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack" hash -r NEED_INSTALL=true fi else echo "Node.js not found, installing 22..." NEED_INSTALL=true fi if [ "$NEED_INSTALL" = true ]; then curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt-get install -y nodejs hash -r fi node --version npm --version - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set app version run: | # Tauri / Cargo require a strict semver; strip the preview suffix for # the bundle version but keep it in the artifact filename. BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)" sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json sed -i "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml echo "Patched version to ${BASE_VERSION}" - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y \ libgtk-3-dev \ libwebkit2gtk-4.1-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ libsoup-3.0-dev \ libssl-dev \ libxdo-dev \ patchelf \ pkg-config \ build-essential \ curl \ wget \ file \ xdg-utils - name: Install Rust stable run: | if command -v rustup >/dev/null 2>&1; then rustup update stable rustup default stable else curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable fi export PATH="$HOME/.cargo/bin:$PATH" rustc --version cargo --version - name: Install frontend dependencies working-directory: ./app run: | rm -rf node_modules package-lock.json npm install - name: Install Tauri CLI working-directory: ./app run: | export PATH="$HOME/.cargo/bin:$PATH" npx tauri --version || npm install @tauri-apps/cli - name: Build Tauri app working-directory: ./app run: | export PATH="$HOME/.cargo/bin:$PATH" npx tauri build - name: Collect artifacts run: | mkdir -p artifacts cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true ls -la artifacts/ # Assets, not workflow artifacts — see the note at the top of this file. # Delete-then-upload so a re-dispatch replaces rather than 409s, and the # retry/http1.1 hardening that build-app.yml learned from real macOS # upload failures (curl exit 92 and exit 28 mid-stream). - name: Upload Linux bundles to the preview release shell: bash env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} RELEASE_ID: ${{ needs.create-release.outputs.release_id }} run: | set -euo pipefail shopt -s nullglob files=(artifacts/*) if [ ${#files[@]} -eq 0 ]; then echo "No Linux bundles were produced" >&2 exit 1 fi for file in "${files[@]}"; do filename=$(basename "$file") EXISTING_ID=$(curl -sS \ -H "Authorization: token ${TOKEN}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \ | python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true) if [ -n "${EXISTING_ID}" ]; then echo "Replacing existing asset ${filename}" curl -fsS -X DELETE \ -H "Authorization: token ${TOKEN}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}" fi echo "Uploading ${filename}..." curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \ -X POST \ -H "Authorization: token ${TOKEN}" \ -H "Content-Type: application/octet-stream" \ --data-binary "@${file}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" done build-macos: runs-on: macos-latest needs: [compute-version, create-release] steps: - name: Install Node.js 22 run: | NEED_INSTALL=false if command -v node >/dev/null 2>&1; then NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/') if [ "$NODE_MAJOR" -lt 22 ]; then NEED_INSTALL=true fi else NEED_INSTALL=true fi if [ "$NEED_INSTALL" = true ]; then brew install node@22 brew link --overwrite node@22 fi node --version npm --version - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set app version run: | BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)" sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json sed -i '' "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml echo "Patched version to ${BASE_VERSION}" - name: Install Rust stable run: | if command -v rustup >/dev/null 2>&1; then rustup update stable rustup default stable else curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable fi export PATH="$HOME/.cargo/bin:$PATH" rustup target add aarch64-apple-darwin x86_64-apple-darwin rustc --version cargo --version - name: Install frontend dependencies working-directory: ./app run: | rm -rf node_modules npm install - name: Install Tauri CLI working-directory: ./app run: | export PATH="$HOME/.cargo/bin:$PATH" npx tauri --version || npm install @tauri-apps/cli - name: Build Tauri app (universal) working-directory: ./app run: | export PATH="$HOME/.cargo/bin:$PATH" npx tauri build --target universal-apple-darwin - name: Collect artifacts run: | mkdir -p artifacts cp app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg artifacts/ 2>/dev/null || true cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true ls -la artifacts/ # Assets, not workflow artifacts — see the note at the top of this file. # Delete-then-upload so a re-dispatch replaces rather than 409s, and the # retry/http1.1 hardening that build-app.yml learned from real macOS # upload failures (curl exit 92 and exit 28 mid-stream). - name: Upload macOS bundles to the preview release shell: bash env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} RELEASE_ID: ${{ needs.create-release.outputs.release_id }} run: | set -euo pipefail shopt -s nullglob files=(artifacts/*) if [ ${#files[@]} -eq 0 ]; then echo "No macOS bundles were produced" >&2 exit 1 fi for file in "${files[@]}"; do filename=$(basename "$file") EXISTING_ID=$(curl -sS \ -H "Authorization: token ${TOKEN}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \ | python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true) if [ -n "${EXISTING_ID}" ]; then echo "Replacing existing asset ${filename}" curl -fsS -X DELETE \ -H "Authorization: token ${TOKEN}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}" fi echo "Uploading ${filename}..." curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \ -X POST \ -H "Authorization: token ${TOKEN}" \ -H "Content-Type: application/octet-stream" \ --data-binary "@${file}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" done build-windows: runs-on: windows-latest needs: [compute-version, create-release] defaults: run: shell: cmd steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set app version shell: powershell run: | $raw = "${{ needs.compute-version.outputs.version }}" $version = $raw.Split('-')[0] (Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json (Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json (Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml Write-Host "Patched version to $version" - name: Install Rust stable run: | where rustup >nul 2>&1 && ( rustup update stable rustup default stable ) || ( curl -fSL -o rustup-init.exe https://win.rustup.rs/x86_64 rustup-init.exe -y --default-toolchain stable del rustup-init.exe ) - name: Install Node.js run: | where node >nul 2>&1 && ( node --version ) || ( curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi" msiexec /i node-install.msi /quiet /norestart del node-install.msi ) - name: Verify tools run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" rustc --version cargo --version node --version npm --version - name: Install Tauri CLI via cargo run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" cargo install tauri-cli --version "^2" - name: Fix npm platform detection run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" npm config set os win32 npm config list - name: Install frontend dependencies working-directory: ./app run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" if exist node_modules rmdir /s /q node_modules npm ci - name: Build frontend working-directory: ./app run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" npm run build - name: Build Tauri app working-directory: ./app env: TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}" run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" cargo tauri build - name: Collect artifacts run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" mkdir artifacts copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul dir artifacts\ # PowerShell, because this job's default shell is cmd. Same # delete-then-upload shape as the other two. - name: Upload Windows bundles to the preview release shell: powershell env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} RELEASE_ID: ${{ needs.create-release.outputs.release_id }} run: | $ErrorActionPreference = "Stop" $headers = @{ Authorization = "token $env:TOKEN" } $api = "$env:GITEA_URL/api/v1/repos/$env:REPO" $files = @(Get-ChildItem -File -Path artifacts\*) if ($files.Count -eq 0) { throw "No Windows bundles were produced" } $existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets" foreach ($file in $files) { $name = $file.Name $dupe = $existing | Where-Object { $_.name -eq $name } if ($dupe) { Write-Host "Replacing existing asset $name" Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets/$($dupe.id)" | Out-Null } Write-Host "Uploading $name..." $uploadUri = "$api/releases/$env:RELEASE_ID/assets?name=$([uri]::EscapeDataString($name))" curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 ` -X POST -H "Authorization: token $env:TOKEN" ` -H "Content-Type: application/octet-stream" ` --data-binary "@$($file.FullName)" $uploadUri if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" } }