name: Build App on: push: branches: [main] paths: - "app/**" - "VERSION" - ".gitea/workflows/build-app.yml" pull_request: branches: [main] paths: - "app/**" - "VERSION" - ".gitea/workflows/build-app.yml" workflow_dispatch: env: GITEA_URL: ${{ gitea.server_url }} REPO: ${{ gitea.repository }} 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: Fetch all tags run: git fetch --tags - name: Compute version from VERSION file and tags id: version run: | MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]') echo "Major.Minor: ${MAJOR_MINOR}" # Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes) # `|| true` so an empty grep result doesn't fail the step under pipefail. 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 echo "Latest matching tag: ${LATEST_TAG}" PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD") else echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count" PATCH=$(git rev-list --count HEAD) fi VERSION="${MAJOR_MINOR}.${PATCH}" echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT echo "Computed version: ${VERSION}" build-linux: runs-on: ubuntu-latest needs: [compute-version] 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 echo "Node.js at: $(which node)" node --version npm --version - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set app version run: | VERSION="${{ needs.compute-version.outputs.version }}" sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml echo "Patched version to ${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 echo "Rust already installed: $(rustc --version)" 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/ - name: Upload to Gitea release if: gitea.event_name == 'push' env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | TAG="v${{ needs.compute-version.outputs.version }}" # Create release curl -s -X POST \ -H "Authorization: token ${TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') echo "Release ID: ${RELEASE_ID}" # Upload each artifact for file in artifacts/*; do [ -f "$file" ] || continue filename=$(basename "$file") echo "Uploading ${filename}..." curl -s -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] 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/') echo "Found Node.js $(node --version) (major: ${NODE_MAJOR})" if [ "$NODE_MAJOR" -lt 22 ]; then echo "Node.js ${NODE_MAJOR} is too old, upgrading to 22..." NEED_INSTALL=true fi else echo "Node.js not found, installing 22..." 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: | VERSION="${{ needs.compute-version.outputs.version }}" sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml echo "Patched version to ${VERSION}" - name: Install Rust stable run: | if command -v rustup >/dev/null 2>&1; then echo "Rust already installed: $(rustc --version)" 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/ - name: Upload to Gitea release if: gitea.event_name == 'push' env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail TAG="v${{ needs.compute-version.outputs.version }}-mac" # Idempotent get-or-create. macOS upload has historically failed # mid-stream (curl exit 92, exit 28), leaving the release record # with empty assets. A naive POST /releases on the next run hits # 409 from Gitea for the duplicate tag, the JSON parse below # then yields an empty RELEASE_ID, and pipefail aborts with an # opaque exit 1. Look the release up by tag first; create only # if it doesn't exist; reuse the existing id otherwise. 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 "Release ${TAG} not found, creating" curl -fsS -X POST \ -H "Authorization: token ${TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \ "${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}" # Upload each artifact. If an asset with the same name already # exists on the release (left over from a partial prior run), # delete it first so the upload is replace-not-conflict. # Network hardening: HTTP/1.1 to dodge HTTP/2 stream flakes # the macOS runner has hit, retries with backoff for transient # drops, and -f so HTTP errors stop being silently swallowed. for file in artifacts/*; do [ -f "$file" ] || continue 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 "Deleting existing asset ${filename} (id ${EXISTING_ID})" 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] defaults: run: shell: cmd steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set app version shell: powershell run: | $version = "${{ needs.compute-version.outputs.version }}" (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 MSVC C++ build tools shell: cmd run: | rem Tauri links with MSVC, so rustc needs link.exe and the Windows SDK. rem This job previously assumed a hand-provisioned runner; a runner rem without them registers fine, advertises windows-latest, accepts the rem job, downloads the whole crate graph and only then fails at link rem time with "linker `link.exe` not found". rem rem rustc finds MSVC via vswhere and the registry rather than PATH, so rem installing is enough - no dev-shell activation needed here. rem rem Delayed expansion is required: %VAR% inside a parenthesised block rem is substituted when the block is PARSED, not when it runs, so both rem %ERRORLEVEL% and %VSEXIT% would read as their pre-block values. setlocal enabledelayedexpansion set "VCPATH=" set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" if exist "%VSWHERE%" ( for /f "usebackq delims=" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VCPATH=%%i" ) if defined VCPATH ( echo MSVC build tools already present at !VCPATH! ) else ( echo MSVC build tools not found - installing Visual Studio Build Tools curl -fSL -o "%TEMP%\vs_BuildTools.exe" https://aka.ms/vs/17/release/vs_BuildTools.exe || exit /b 1 "%TEMP%\vs_BuildTools.exe" --quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended set "VSEXIT=!ERRORLEVEL!" del "%TEMP%\vs_BuildTools.exe" 2>nul rem 3010 means installed, reboot pending - a success for our purposes. if not "!VSEXIT!"=="0" if not "!VSEXIT!"=="3010" ( echo Visual Studio Build Tools installer failed with exit code !VSEXIT! exit /b 1 ) echo Visual Studio Build Tools installed ) endlocal - 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%" rem NSIS only: the MSI target needs WiX, whose candle.exe/light.exe are rem 32-bit. On a runner running as SYSTEM, Tauri caches WiX under rem %LOCALAPPDATA% = C:\Windows\system32\config\systemprofile\..., and rem WOW64 redirection points 32-bit processes at SysWOW64, where that rem directory does not exist - so candle cannot see its own folder and rem dies with 0x80131700. Tauri aborts the whole bundle when one target rem fails, so the MSI was also costing us the NSIS installer. rem rem NSIS is what the project ships for Windows upgrades anyway. To bring rem the MSI back, run the runner as a normal user (whose LOCALAPPDATA is rem outside System32) and restore --bundles msi,nsis. cargo tauri build --bundles nsis - name: Collect artifacts run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" mkdir artifacts copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ || exit /b 1 dir artifacts\ - name: Upload to Gitea release if: gitea.event_name == 'push' shell: powershell env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} COMMIT_SHA: ${{ gitea.sha }} VERSION: ${{ needs.compute-version.outputs.version }} run: | $ErrorActionPreference = "Stop" $tag = "v$env:VERSION-win" $headers = @{ Authorization = "token $env:TOKEN" } $api = "$env:GITEA_URL/api/v1/repos/$env:REPO" # Idempotent get-or-create. The old cmd-batch version swallowed # curl errors and parsed the release id with findstr, so a 409 on # a pre-existing tag yielded an empty RELEASE_ID and uploads went to # a malformed .../releases//assets URL while the step still reported # success. Look the release up by tag first; create only on 404. try { $release = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/tags/$tag" Write-Host "Release $tag already exists, reusing" } catch { if ($_.Exception.Response.StatusCode.value__ -eq 404) { Write-Host "Release $tag not found, creating" $body = @{ tag_name = $tag name = "Triple-C v$env:VERSION (Windows)" body = "Automated build from commit $env:COMMIT_SHA" } | ConvertTo-Json $release = Invoke-RestMethod -Method Post -Headers $headers ` -ContentType "application/json" -Body $body -Uri "$api/releases" } else { throw } } $releaseId = $release.id if (-not $releaseId) { throw "Failed to resolve release id for $tag" } Write-Host "Release ID: $releaseId" # Upload each artifact. Delete any same-named asset left over from a # partial prior run first, so the upload replaces rather than 409s. $existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$releaseId/assets" foreach ($file in Get-ChildItem -File -Path artifacts\*) { $name = $file.Name $dupe = $existing | Where-Object { $_.name -eq $name } if ($dupe) { Write-Host "Deleting existing asset $name (id $($dupe.id))" Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$releaseId/assets/$($dupe.id)" | Out-Null } Write-Host "Uploading $name..." $uploadUri = "$api/releases/$releaseId/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)" } } create-tag: runs-on: ubuntu-latest needs: [compute-version, build-linux, build-macos, build-windows] if: gitea.event_name == 'push' steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Create version tag env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | VERSION="${{ needs.compute-version.outputs.version }}" TAG="v${VERSION}" echo "Creating tag ${TAG}..." # Create annotated tag via Gitea API curl -s -X POST \ -H "Authorization: token ${TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \ "${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)" echo "Tag ${TAG} created successfully" sync-to-github: runs-on: ubuntu-latest needs: [compute-version, build-linux, build-macos, build-windows] if: gitea.event_name == 'push' env: GH_PAT: ${{ secrets.GH_PAT }} GITHUB_REPO: shadowdao/triple-c steps: - name: Download artifacts from Gitea releases env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} VERSION: ${{ needs.compute-version.outputs.version }} run: | set -e mkdir -p artifacts # Download assets from all 3 platform releases for TAG_SUFFIX in "" "-mac" "-win"; do TAG="v${VERSION}${TAG_SUFFIX}" echo "==> Fetching assets for release ${TAG}..." RELEASE_JSON=$(curl -sf \ -H "Authorization: token ${TOKEN}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}") echo "$RELEASE_JSON" | jq -r '.assets[]? | "\(.name) \(.browser_download_url)"' | while read -r NAME URL; do [ -z "$NAME" ] && continue echo " Downloading ${NAME}..." curl -sfL \ -H "Authorization: token ${TOKEN}" \ -o "artifacts/${NAME}" \ "$URL" done done echo "==> All downloaded artifacts:" ls -la artifacts/ - name: Create GitHub release and upload artifacts env: VERSION: ${{ needs.compute-version.outputs.version }} COMMIT_SHA: ${{ gitea.sha }} run: | set -e TAG="v${VERSION}" echo "==> Creating unified release ${TAG} on GitHub..." # Delete existing release if present (idempotent re-runs) EXISTING=$(curl -sf \ -H "Authorization: Bearer ${GH_PAT}" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}") EXISTING_ID=$(echo "$EXISTING" | jq -r '.id // empty') if [ -n "$EXISTING_ID" ]; then echo " Deleting existing GitHub release ${TAG} (id: ${EXISTING_ID})..." curl -sf -X DELETE \ -H "Authorization: Bearer ${GH_PAT}" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/${GITHUB_REPO}/releases/${EXISTING_ID}" fi RESPONSE=$(curl -sf -X POST \ -H "Authorization: Bearer ${GH_PAT}" \ -H "Accept: application/vnd.github+json" \ -H "Content-Type: application/json" \ "https://api.github.com/repos/${GITHUB_REPO}/releases" \ -d "{ \"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG}\", \"body\": \"Automated build from commit ${COMMIT_SHA}\n\nIncludes Linux, macOS, and Windows artifacts.\", \"draft\": false, \"prerelease\": false }") UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//') echo "==> Upload URL: ${UPLOAD_URL}" for file in artifacts/*; do [ -f "$file" ] || continue FILENAME=$(basename "$file") MIME="application/octet-stream" echo "==> Uploading ${FILENAME}..." curl -sf -X POST \ -H "Authorization: Bearer ${GH_PAT}" \ -H "Accept: application/vnd.github+json" \ -H "Content-Type: ${MIME}" \ --data-binary "@${file}" \ "${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${FILENAME}")" done echo "==> GitHub release sync complete."