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. # # This is also the **PR build check**: it compiles Linux, macOS and Windows, so # a push that breaks any of them fails here. build-app.yml used to do that job # in parallel and publish nothing, which meant six OS builds per push and one # unreachable set of bundles; it is now releases-only. # # The cost of the swap, stated plainly: one prerelease per PR commit that # touches `app/**` — so the workflow prunes its own, keeping the newest # KEEP_PREVIEWS (see Lifecycle). # # ## 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 never crowd the real # release list, and a manual cleanup sweeps any this workflow missed. # # But that cleanup is a manual, dry-run-by-default action, and one prerelease per # pushed commit accumulates faster than anyone runs it. So the last job here # prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a # release; the point of a preview is the build you are testing now. # # `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub. env: GITEA_URL: ${{ gitea.server_url }} REPO: ${{ gitea.repository }} # How many preview releases survive a run, newest first — including the one # just published. KEEP_PREVIEWS: "2" on: # Every push to an open PR: this *is* the branch's build check — it compiles # Linux, macOS and Windows — and publishing the result costs nothing extra # once they are built. build-app.yml deliberately no longer runs on PRs. pull_request: branches: [main] paths: - "app/**" - "VERSION" - ".gitea/workflows/build-app-preview.yml" workflow_dispatch: jobs: compute-version: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.VERSION }} sha: ${{ steps.version.outputs.SHA }} 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) # From the checkout, not from `gitea.sha`: on a pull_request event # that variable can be the merge ref, which is not the commit anyone # is testing and not something to hang a tag on. echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT # 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 }} SHA: ${{ needs.compute-version.outputs.sha }} BRANCH: ${{ gitea.head_ref || gitea.ref_name }} 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\": \"${SHA}\", \"name\": \"Preview ${VERSION}\", \"prerelease\": true, \"body\": \"Unreleased build of \`${BRANCH}\` at ${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)" } } # Keep the preview list short. Runs after the builds and only if all three # succeeded: a half-published run must not be what evicts a good older build. prune-previews: runs-on: ubuntu-latest needs: [create-release, build-linux, build-macos, build-windows] steps: - name: Delete all but the newest preview releases env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} KEEP_TAG: ${{ needs.create-release.outputs.tag }} run: | set -euo pipefail curl -fsS -H "Authorization: token ${TOKEN}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50" > releases.json # Newest first by creation time, `preview-` only, and never the one # this run just published — a clock skew must not delete it. DOOMED=$(python3 - "${KEEP_PREVIEWS}" "${KEEP_TAG}" <<'PY' import json, sys keep, keep_tag = int(sys.argv[1]), sys.argv[2] previews = [r for r in json.load(open("releases.json")) if r["tag_name"].startswith("preview-")] previews.sort(key=lambda r: r["created_at"], reverse=True) for r in previews[keep:]: if r["tag_name"] != keep_tag: print(r["id"], r["tag_name"]) PY ) if [ -z "${DOOMED}" ]; then echo "Nothing to prune (keeping ${KEEP_PREVIEWS})" exit 0 fi echo "${DOOMED}" | while read -r ID TAG; do [ -z "${ID}" ] && continue echo "Deleting ${TAG} (id ${ID})" # Best effort: a preview someone deleted by hand mid-run is not a # reason to fail a build that otherwise succeeded. curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || true curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ "${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" || true done