21bfdce27a
Release desktop / bundle (push) Successful in 4m24s
The mirror step's release id came back empty on the last real run but nothing failed loudly — every curl response was swallowed (|| true, or piped straight to /dev/null), so we had no idea why. Capture HTTP status + response body on every call and exit 1 with the actual error instead of silently uploading to a malformed //assets URL with no release id.
252 lines
11 KiB
YAML
252 lines
11 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, 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 <<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 || true)
|
|
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 || true)
|
|
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 -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"
|