name: Release desktop # Build the signed Tauri desktop installers on a version tag and publish them as # a Gitea Release — TWICE: once on this (private, source) repo for our own # records/history, and once mirrored to mca/public_releases, which is what the # Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) actually points at. # # WHY a separate public repo: the updater runs on offline-first field appliances # with no Gitea credentials, so its endpoint + installer downloads must be # reachable unauthenticated. Mirroring compiled installers to a public # releases-only repo avoids embedding any read token in the shipped app (which # would leak the moment a booth PC is compromised — this box's threat model # names the operator/booth as the primary adversary, see CLAUDE.md). Source # stays private; only signed installers become public, same as most desktop # software. mca/public_releases is shared across apps in the org, not # parking-specific — namespace release tags/asset names accordingly if another # app starts publishing there too. # # Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them # with the updater key (Gitea secrets), assembles latest.json pointing at the # MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets. on: push: tags: - 'v*' workflow_dispatch: jobs: bundle: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Node 22 uses: actions/setup-node@v4 with: node-version: 22 - name: Enable pnpm run: corepack enable && corepack prepare pnpm@10.24.0 --activate - name: Install Tauri system deps # ubuntu-latest runner has no GUI/webkit libs by default. These are the # exact deps a Tauri v2 Linux build needs (verified locally): WebKitGTK # 4.1 + libsoup-3 + the GTK/appindicator/rsvg stack + AppImage tooling. run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ libwebkit2gtk-4.1-dev \ libsoup-3.0-dev \ libgtk-3-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ patchelf \ file \ build-essential \ curl \ wget - name: Set up Rust uses: dtolnay/rust-toolchain@stable - name: Cache cargo + target uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git apps/desktop/src-tauri/target key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo- - name: Install dependencies run: pnpm install --frozen-lockfile - name: Sync tauri.conf.json version to the git tag # tauri.conf.json's own "version" field is what Tauri bakes into the # bundle filename, the app's internal version, AND the updater's # "current vs. new" comparison — it is NOT derived from the git tag # automatically. Hit in v0.1.1: the tag was bumped but this file # wasn't, so the signed binary + its .sig were still built (and # named) as 0.1.0 while latest.json (built from TAG below) claimed # 0.1.1 — the updater found the "update", downloaded a file whose # signature didn't match what the manifest claimed to sign, and # silently failed (a separate bug in desktop-updater.ts's error # handling made this invisible — also fixed). Patch it here so the # checked-in value is only ever a placeholder for local dev builds; # a real release's version is always driven by the tag. run: | set -e VERSION="${TAG#v}" sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" apps/desktop/src-tauri/tauri.conf.json grep '"version"' apps/desktop/src-tauri/tauri.conf.json env: TAG: ${{ github.ref_name }} - name: Build + sign desktop bundle env: # Updater signing key (Gitea repo/org secrets). Without these the # bundle is unsigned and the updater would reject it. TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: pnpm --filter @parking/desktop bundle - name: Collect artifacts id: collect # Gather the installers + their .sig into a flat dist/ for upload, spaces # stripped from filenames. productName is "Parking System" (a space), so # Tauri's bundle output is e.g. "Parking System_0.1.0_amd64.deb" — an # unescaped space in a filename breaks the later curl asset-upload URL # ("URL rejected: Malformed input to a URL function", hit on the very # first v0.1.0 release) AND would land in latest.json's asset url, which # the updater's plain HTTP GET can't handle either. Rename on copy. run: | set -e BUNDLE=apps/desktop/src-tauri/target/release/bundle mkdir -p dist find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \ -o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \ -print0 | while IFS= read -r -d '' f; do name=$(basename "$f" | tr ' ' '-') cp "$f" "dist/${name}" done echo "Artifacts:"; ls -la dist/ - name: Assemble latest.json # The Tauri updater fetches a manifest describing the newest version, its # notes, and per-target {signature, url}. The URL points at the MIRROR # repo (mca/public_releases) — that's the unauthenticated endpoint field # appliances actually reach; see the workflow header for why. Adjust the # platform keys you actually ship. env: SERVER_URL: ${{ github.server_url }} MIRROR_REPO: mca/public_releases TAG: ${{ github.ref_name }} run: | set -e VERSION="${TAG#v}" APPIMAGE=$(cd dist && ls *.AppImage | head -1) SIG=$(cat "dist/${APPIMAGE}.sig") ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}" cat > dist/latest.json </dev/null done echo "done" - name: Mirror release to mca/public_releases (Gitea API) # This is the release the updater and any human downloader actually use — # public_releases has no source, only installers, so it can be public # without exposing this repo. RELEASES_MIRROR_TOKEN is a write:repository # token scoped for pushing releases into that repo (Gitea's org secrets, # not exposed to any deployed client). # # Publishes to TWO tags there, since public_releases is shared across # apps in the org and Gitea's "latest release" redirect resolves by # newest tag on the WHOLE repo (would break the moment another app # publishes something newer): # - desktop- versioned, permanent — audit trail / rollback. # - desktop-latest moving — assets deleted + re-uploaded each release. # This is the fixed URL tauri.conf.json's updater endpoint points at # (a stable name every appliance can always resolve, regardless of # what else gets released in this repo meanwhile). env: TOKEN: ${{ secrets.RELEASES_MIRROR_TOKEN }} API: ${{ github.api_url }} MIRROR_REPO: mca/public_releases TAG: ${{ github.ref_name }} run: | set -e create_or_get_release() { local mirror_tag="$1" prerelease="$2" REL=$(curl -sS -w '\n%{http_code}' -X POST \ -H "Authorization: token ${TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"tag_name\":\"${mirror_tag}\",\"name\":\"Parking System ${TAG}\",\"draft\":false,\"prerelease\":${prerelease}}" \ "${API}/repos/${MIRROR_REPO}/releases" || true) echo "create response (${mirror_tag}): ${REL}" REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true) if [ -z "$REL_ID" ]; then LOOKUP=$(curl -sS -w '\n%{http_code}' -H "Authorization: token ${TOKEN}" \ "${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}") echo "tag lookup response (${mirror_tag}): ${LOOKUP}" REL_ID=$(printf '%s' "$LOOKUP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true) fi if [ -z "$REL_ID" ]; then echo "::error::could not create or find release for tag ${mirror_tag} on ${MIRROR_REPO} — see responses above" exit 1 fi } upload_assets() { local rel_id="$1" for f in dist/*; do name=$(basename "$f") echo "mirroring ${name} -> release ${rel_id}" HTTP_CODE=$(curl -sS -o /tmp/upload_resp.json -w '%{http_code}' -X POST \ -H "Authorization: token ${TOKEN}" \ -H "Content-Type: application/octet-stream" \ --data-binary @"${f}" \ "${API}/repos/${MIRROR_REPO}/releases/${rel_id}/assets?name=${name}") if [ "$HTTP_CODE" -ge 300 ]; then echo "::error::upload of ${name} failed (HTTP ${HTTP_CODE}): $(cat /tmp/upload_resp.json)" exit 1 fi done } # 1. Versioned, permanent. create_or_get_release "desktop-${TAG}" false echo "versioned mirror release id: ${REL_ID}" upload_assets "${REL_ID}" # 2. Moving desktop-latest — delete existing assets first (re-upload # with the same name 409s otherwise), then re-upload. create_or_get_release "desktop-latest" false LATEST_REL_ID="${REL_ID}" echo "latest mirror release id: ${LATEST_REL_ID}" EXISTING=$(curl -sS -H "Authorization: token ${TOKEN}" \ "${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets") printf '%s' "$EXISTING" | grep -o '"id":[0-9]*' | cut -d: -f2 | while read -r asset_id; do curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ "${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets/${asset_id}" >/dev/null done || true upload_assets "${LATEST_REL_ID}" echo "done"