diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 914f287c7..9f6f4b937 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -786,6 +786,37 @@ jobs: # org.opencontainers.image.source=https://github.com/tinyhumansai/openhuman # org.opencontainers.image.revision=${{ needs.prepare-release.outputs.sha }} + # ========================================================================= + # Phase 3c: Generate and upload latest.json for the Tauri auto-updater. + # Runs after every platform has uploaded its updater artifact (.sig files + # from createUpdaterArtifacts) so the manifest can reference all four + # platform entries. Production-only — staging builds don't feed the public + # updater endpoint. + # ========================================================================= + publish-updater-manifest: + name: Publish updater manifest (latest.json) + needs: [prepare-build, create-release, build-desktop] + if: >- + needs.prepare-build.outputs.release_enabled == 'true' + && needs.build-desktop.result == 'success' + runs-on: ubuntu-latest + environment: Production + steps: + - name: Checkout build ref + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare-build.outputs.build_ref }} + fetch-depth: 1 + + - name: Generate and upload latest.json + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.prepare-build.outputs.tag }} + VERSION: ${{ needs.prepare-build.outputs.version }} + REPO: tinyhumansai/openhuman + run: bash scripts/release/publish-updater-manifest.sh + # ========================================================================= # Phase 4: Publish the draft release (waits for ALL build phases) # ========================================================================= @@ -793,11 +824,12 @@ jobs: name: Publish draft release runs-on: ubuntu-latest environment: Production - needs: [prepare-build, create-release, build-desktop, build-docker] + needs: [prepare-build, create-release, build-desktop, build-docker, publish-updater-manifest] if: >- needs.prepare-build.outputs.release_enabled == 'true' && needs.build-desktop.result == 'success' && needs.build-docker.result == 'success' + && needs.publish-updater-manifest.result == 'success' steps: - name: Validate required installer assets exist uses: actions/github-script@v7 @@ -819,6 +851,9 @@ jobs: /OpenHuman_.*_amd64\.AppImage$/, /OpenHuman_.*_amd64\.deb$/, /(OpenHuman_.*_x64-setup\.exe$|OpenHuman_.*_x64.*\.msi$)/, + // Auto-updater manifest — without this, installed clients can't + // discover new releases via plugins.updater.endpoints. + /^latest\.json$/, ]; const missing = requiredPatterns.filter((pattern) => !names.some((name) => pattern.test(name))); @@ -875,13 +910,14 @@ jobs: name: Remove release and tag if build failed runs-on: ubuntu-latest environment: Production - needs: [prepare-build, create-release, build-desktop, build-docker] + needs: [prepare-build, create-release, build-desktop, build-docker, publish-updater-manifest] if: >- always() && needs.prepare-build.outputs.release_enabled == 'true' && needs.create-release.result == 'success' && (needs.build-desktop.result == 'failure' || needs.build-desktop.result == 'cancelled' - || needs.build-docker.result == 'failure' || needs.build-docker.result == 'cancelled') + || needs.build-docker.result == 'failure' || needs.build-docker.result == 'cancelled' + || needs.publish-updater-manifest.result == 'failure' || needs.publish-updater-manifest.result == 'cancelled') steps: - name: Delete GitHub release uses: actions/github-script@v7 diff --git a/scripts/release/publish-updater-manifest.sh b/scripts/release/publish-updater-manifest.sh new file mode 100755 index 000000000..2441737a8 --- /dev/null +++ b/scripts/release/publish-updater-manifest.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Generate and upload latest.json for the Tauri auto-updater. +# +# Tauri's updater fetches a JSON manifest at a fixed endpoint (configured in +# app/src-tauri/tauri.conf.json via `plugins.updater.endpoints`), reads the +# `version` field, compares to the running app, and — if newer — downloads the +# platform-specific `url` and verifies it against `signature`. +# +# We host the manifest on the GitHub release itself. The endpoint in +# `prepareTauriConfig.js` resolves to +# `https://github.com//releases/latest/download/latest.json`, which +# github permanently redirects to the asset on the newest non-draft release. +# +# Required env: +# TAG — the release tag (e.g. `v0.52.21`) +# VERSION — bare version (e.g. `0.52.21`) +# REPO — `owner/name` on github +# GITHUB_TOKEN — with release write scope (for `gh release`) +# +# Signature files (`.sig` — base64 minisign detached signatures produced by +# the Tauri bundler when `createUpdaterArtifacts = true`) are downloaded from +# the release; the matching bundle URLs use the stable +# `/releases/download//` form so the manifest is self-describing. +set -euo pipefail + +: "${TAG:?TAG required (e.g. v0.52.21)}" +: "${VERSION:?VERSION required (e.g. 0.52.21)}" +: "${REPO:?REPO required (e.g. tinyhumansai/openhuman)}" +: "${GITHUB_TOKEN:?GITHUB_TOKEN required}" + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +echo "[updater] Fetching asset list for $REPO $TAG" +gh release view "$TAG" --repo "$REPO" --json assets \ + --jq '.assets[].name' > "$WORKDIR/asset-names.txt" + +asset_url() { + printf 'https://github.com/%s/releases/download/%s/%s\n' "$REPO" "$TAG" "$1" +} + +# Find a single asset matching the given extended regex on the release; echo +# its name on stdout, or empty if none / multiple. We prefer unambiguous +# matches and surface the asset list on failure. +find_asset() { + local pattern="$1" + local matches + matches=$(grep -E "$pattern" "$WORKDIR/asset-names.txt" || true) + local count + count=$(printf '%s\n' "$matches" | grep -c . || true) + if [ "$count" = "0" ]; then + return 0 + fi + if [ "$count" -gt "1" ]; then + echo "[updater] WARN: pattern '$pattern' matched $count assets:" >&2 + printf ' %s\n' "$matches" >&2 + echo "[updater] WARN: using the first match" >&2 + fi + printf '%s\n' "$matches" | head -1 +} + +# Download a .sig for an asset and echo the signature payload. The .sig file +# is a single base64-encoded minisign signature — no trimming needed beyond +# the trailing newline. +read_sig() { + local name="$1" + local sig_name="${name}.sig" + if ! grep -Fxq "$sig_name" "$WORKDIR/asset-names.txt"; then + echo "[updater] ERROR: signature asset '$sig_name' not on release — did createUpdaterArtifacts produce it?" >&2 + return 1 + fi + gh release download "$TAG" --repo "$REPO" --pattern "$sig_name" \ + --dir "$WORKDIR" --clobber >&2 + # minisign sig format is two lines: an untrusted comment then the base64 + # payload. Tauri expects the whole file verbatim. + local path="$WORKDIR/$sig_name" + if [ ! -s "$path" ]; then + echo "[updater] ERROR: downloaded sig is empty: $path" >&2 + return 1 + fi + cat "$path" +} + +# Platform mapping. Tauri's updater consults these exact keys; see +# https://v2.tauri.app/plugin/updater/#static-json-file +# +# darwin-aarch64 — macOS Apple Silicon +# darwin-x86_64 — macOS Intel +# linux-x86_64 — Linux glibc x64 (AppImage) +# windows-x86_64 — Windows x64 (NSIS setup) +# +# Naming conventions emitted by tauri-bundler with createUpdaterArtifacts: +# darwin : __.app.tar.gz +# linux : __amd64.AppImage.tar.gz +# windows : __x64-setup.nsis.zip +MAC_AARCH64=$(find_asset "^OpenHuman(_| ).*aarch64\.app\.tar\.gz$") +MAC_X86_64=$(find_asset "^OpenHuman(_| ).*(x64|x86_64)\.app\.tar\.gz$") +LINUX_X86_64=$(find_asset "^OpenHuman(_| ).*(amd64|x86_64)\.AppImage\.tar\.gz$") +WIN_X86_64=$(find_asset "^OpenHuman(_| ).*x64-setup\.nsis\.zip$") + +echo "[updater] Resolved updater bundles:" +echo " darwin-aarch64 = ${MAC_AARCH64:-}" +echo " darwin-x86_64 = ${MAC_X86_64:-}" +echo " linux-x86_64 = ${LINUX_X86_64:-}" +echo " windows-x86_64 = ${WIN_X86_64:-}" + +PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") + +# Assemble manifest incrementally so a missing platform degrades gracefully +# rather than producing invalid JSON. jq reads env vars we set here. +MANIFEST="$WORKDIR/latest.json" +jq -n \ + --arg version "$VERSION" \ + --arg pub_date "$PUB_DATE" \ + --arg notes "See https://github.com/$REPO/releases/tag/$TAG" \ + '{version: $version, notes: $notes, pub_date: $pub_date, platforms: {}}' \ + > "$MANIFEST" + +add_platform() { + local key="$1" name="$2" + [ -z "$name" ] && return 0 + local sig url + sig=$(read_sig "$name") + url=$(asset_url "$name") + jq --arg key "$key" --arg sig "$sig" --arg url "$url" \ + '.platforms[$key] = {signature: $sig, url: $url}' \ + "$MANIFEST" > "$MANIFEST.tmp" + mv "$MANIFEST.tmp" "$MANIFEST" + echo "[updater] + $key → $name" +} + +add_platform "darwin-aarch64" "$MAC_AARCH64" +add_platform "darwin-x86_64" "$MAC_X86_64" +add_platform "linux-x86_64" "$LINUX_X86_64" +add_platform "windows-x86_64" "$WIN_X86_64" + +# Require at least one platform so we don't publish an empty manifest that +# would mislead installed clients into thinking no update is ever available. +platforms=$(jq -r '.platforms | keys | length' "$MANIFEST") +if [ "$platforms" = "0" ]; then + echo "[updater] ERROR: no platforms resolved — refusing to publish empty manifest" >&2 + exit 1 +fi + +echo "[updater] Final manifest:" +cat "$MANIFEST" + +gh release upload "$TAG" "$MANIFEST" --repo "$REPO" --clobber +echo "[updater] Uploaded latest.json to $TAG"