Files
parking_solution/.gitea/workflows/release.yml
T
julian a1f3103a76
Build desktop / desktop (push) Successful in 4m46s
CI / check (push) Successful in 43s
fix(desktop): mirror signed releases to public repo for the updater
The updater endpoint pointed at mca/parking_solution's own Gitea "latest
release" redirect, but that repo is private and field appliances have no
Gitea credentials — every update check was silently failing. release.yml
now mirrors signed installers to mca/public_releases (public, installers
only) under a fixed desktop-latest tag; tauri.conf.json points there.
Rejected embedding a read token in the app instead, given the booth-operator
threat model.

Also: make the appliance-provisioning root_directory gotcha impossible to
skim past (boxed callout + explicit next-step pointers), after it caused a
second missed step on the park-2 install.
2026-09-03 09:56:49 +02:00

233 lines
10 KiB
YAML

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: 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.
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' \) \
-exec cp {} dist/ \;
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 <<JSON
{
"version": "${VERSION}",
"notes": "Parking System ${TAG}",
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"platforms": {
"linux-x86_64": {
"signature": "${SIG}",
"url": "${ASSET_URL}"
}
}
}
JSON
echo "latest.json:"; cat dist/latest.json
- name: Create release + upload assets (Gitea API)
# Uses the built-in token; no marketplace release action required. Creates
# the release for this tag (idempotent-ish: ignores "already exists") and
# uploads every file in dist/ as an asset.
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.api_url }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -e
# Create the release (capture id; tolerate an existing one).
REL=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
"${API}/repos/${REPO}/releases" || true)
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "$REL_ID" ]; then
# Release may already exist for this tag — look it up by tag.
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/repos/${REPO}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
fi
echo "release id: ${REL_ID}"
for f in dist/*; do
name=$(basename "$f")
echo "uploading ${name}"
curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${f}" \
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/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-<TAG> 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 -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)
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "$REL_ID" ]; then
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
fi
}
upload_assets() {
local rel_id="$1"
for f in dist/*; do
name=$(basename "$f")
echo "mirroring ${name} -> release ${rel_id}"
curl -sS -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}" >/dev/null
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
upload_assets "${LATEST_REL_ID}"
echo "done"