name: Desktop Release # Builds the Quintodrome desktop app (Tauri) for macOS (.app/.dmg), bundling # the Navidrome server as a sidecar. On `v*` tags the same job, on the Mac, # publishes the Forgejo release and updates the Homebrew tap: the cask, and # the Sparkle appcasts the app checks for updates (one per architecture, # signed with the SPARKLE_PRIVATE_KEY secret; desktop/src-tauri/src/ # updater.rs). Windows isn't built here (no Windows runner); the Windows # steps below are kept for when one exists. # # Note: artifacts are not signed with Apple/Windows certs (macOS builds are only # ad-hoc signed), so users will hit Gatekeeper/SmartScreen prompts. The # Homebrew cask works around Gatekeeper by stripping the quarantine flag. on: push: tags: ["v*"] workflow_dispatch: concurrency: group: desktop-${{ github.ref }} cancel-in-progress: true jobs: build-desktop: name: Build desktop (${{ matrix.name }}) strategy: fail-fast: false matrix: include: # Both Mac targets build in one job, one after the other: there is # one Mac runner (no Intel one, so x86_64 is cross-compiled), and # concurrent jobs there collide on the shared action cache's git # locks. Forgejo ignores max-parallel. - os: macos-latest name: macos targets: aarch64-apple-darwin,x86_64-apple-darwin runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 24 cache: npm cache-dependency-path: ui/package-lock.json # actions/setup-go can't find the go it just installed on the host-mode # Mac runner ("/bin/sh: version: command not found"), so fetch the # latest patch of go.mod's Go release directly. - name: Setup Go if: runner.os != 'Windows' shell: bash run: | set -euo pipefail GO_MINOR=$(awk '/^go /{print $2}' go.mod) GO_VERSION=$(curl -fsSL 'https://go.dev/dl/?mode=json&include=all' \ | jq -r --arg m "go$GO_MINOR" '[.[] | select(.stable) | .version | select(. == $m or startswith($m + "."))][0]') case "$(uname -s)/$(uname -m)" in Darwin/arm64) PLATFORM=darwin-arm64 ;; Darwin/x86_64) PLATFORM=darwin-amd64 ;; Linux/x86_64) PLATFORM=linux-amd64 ;; Linux/aarch64) PLATFORM=linux-arm64 ;; esac GOROOT_PARENT="$RUNNER_TEMP/go-$GO_VERSION" if [[ ! -x "$GOROOT_PARENT/go/bin/go" ]]; then mkdir -p "$GOROOT_PARENT" curl -fsSL "https://go.dev/dl/$GO_VERSION.$PLATFORM.tar.gz" | tar -xz -C "$GOROOT_PARENT" fi echo "$GOROOT_PARENT/go/bin" >> "$GITHUB_PATH" "$GOROOT_PARENT/go/bin/go" version - name: Setup Go (Windows) if: runner.os == 'Windows' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Setup Rust uses: https://github.com/dtolnay/rust-toolchain@stable with: targets: ${{ matrix.targets }} - name: Cache Rust build uses: https://github.com/Swatinem/rust-cache@v2 with: workspaces: desktop/src-tauri # Sparkle, the macOS updater the app links against and embeds # (tauri.conf.json's bundle.macOS.frameworks), plus its signing tools. - name: Setup Sparkle if: runner.os == 'macOS' shell: bash run: | bash desktop/scripts/download-sparkle.sh desktop/src-tauri echo "SPARKLE_FRAMEWORK_PATH=$PWD/desktop/src-tauri" >> "$GITHUB_ENV" # Navidrome's sqlite_fts5 tag needs CGO, so on Windows we need a gcc. - name: Setup Windows CGO (mingw) if: runner.os == 'Windows' uses: https://github.com/msys2/setup-msys2@v2 with: msystem: MINGW64 install: mingw-w64-x86_64-gcc update: false - name: Add mingw to PATH if: runner.os == 'Windows' shell: bash run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH - name: Build Navidrome web UI shell: bash run: | cd ui # --ignore-scripts skips the workbox postinstall (bin/update-workbox.sh), # a bash script that fails under cmd.exe on Windows. It's only needed # for the PWA service worker, which the desktop app doesn't use. npm ci --ignore-scripts npm run build - name: Build Navidrome server binary shell: bash run: | GIT_SHA=$(git rev-parse --short HEAD) if [[ "$GITHUB_REF" == refs/tags/* ]]; then GIT_TAG="${GITHUB_REF_NAME#v}" else GIT_TAG="dev" fi export CGO_ENABLED=1 mkdir -p desktop/src-tauri/binaries for TARGET in $(echo "${{ matrix.targets }}" | tr , " "); do # Build for each target, not the runner's own arch. case "$TARGET" in aarch64-apple-*) export GOARCH=arm64 CC="clang -arch arm64" ;; x86_64-apple-*) export GOARCH=amd64 CC="clang -arch x86_64" ;; esac # Stage the sidecar under the target-triple name tauri expects. if [[ "$RUNNER_OS" == "Windows" ]]; then OUTPUT="desktop/src-tauri/binaries/navidrome-$TARGET.exe" else OUTPUT="desktop/src-tauri/binaries/navidrome-$TARGET" fi go build \ -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$GIT_SHA -X github.com/navidrome/navidrome/consts.gitTag=$GIT_TAG" \ -tags=netgo,sqlite_fts5 \ -o "$OUTPUT" done - name: Build Tauri app shell: bash run: | cd desktop # The app's version is the tag's (tauri.conf.json's own is a # placeholder for local builds). Sparkle doesn't compare that one: # it compares CFBundleVersion, and ignores everything after a "-", # so 1.0.0-alpha.10 and 1.0.0-alpha.11 are the same to it. So # CFBundleVersion is the run number, which only goes up; the appcast # says the same (sparkle:version), and shows the tag's version. CONFIG=() if [[ "$GITHUB_REF" == refs/tags/v* ]]; then CONFIG=(--config "{\"version\":\"${GITHUB_REF_NAME#v}\",\"bundle\":{\"macOS\":{\"bundleVersion\":\"$GITHUB_RUN_NUMBER\"}}}") fi for TARGET in $(echo "${{ matrix.targets }}" | tr , " "); do # rust-toolchain.toml pins a toolchain other than the one Setup # Rust gave the targets to, so add each to the pinned one here. rustup target add "$TARGET" npx --yes @tauri-apps/cli@2 build --target "$TARGET" "${CONFIG[@]}" done - name: Collect artifacts (macOS) if: runner.os == 'macOS' shell: bash run: | mkdir -p dist for TARGET in $(echo "${{ matrix.targets }}" | tr , " "); do BUNDLE=desktop/src-tauri/target/$TARGET/release/bundle ditto -c -k --keepParent "$BUNDLE/macos/Quintodrome.app" "dist/Quintodrome-$TARGET.app.zip" cp "$BUNDLE"/dmg/*.dmg dist/ 2>/dev/null || true done ls -la dist # Sparkle only installs updates signed with the key whose public half is # in Info.plist. The signatures go into the appcasts (below). - name: Sign updates (macOS) if: runner.os == 'macOS' shell: bash env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} run: | set -euo pipefail if [[ -z "$SPARKLE_PRIVATE_KEY" ]]; then if [[ "$GITHUB_REF" == refs/tags/* ]]; then echo "::error::SPARKLE_PRIVATE_KEY secret is not set (this repo's Settings > Actions > Secrets)" exit 1 fi echo "No SPARKLE_PRIVATE_KEY; not signing (not a release)" exit 0 fi KEY_FILE="$RUNNER_TEMP/sparkle_key" trap 'rm -f "$KEY_FILE"' EXIT (umask 077; printf '%s\n' "$SPARKLE_PRIVATE_KEY" > "$KEY_FILE") mkdir -p sparkle for TARGET in $(echo "${{ matrix.targets }}" | tr , " "); do ARCH="${TARGET%%-*}" # Prints: sparkle:edSignature="..." length="..." desktop/src-tauri/sparkle-bin/sign_update "dist/Quintodrome-$TARGET.app.zip" -f "$KEY_FILE" > "sparkle/$ARCH.txt" cat "sparkle/$ARCH.txt" done - name: Collect artifacts (Windows) if: runner.os == 'Windows' shell: bash run: | mkdir -p dist for TARGET in $(echo "${{ matrix.targets }}" | tr , " "); do BUNDLE=desktop/src-tauri/target/$TARGET/release/bundle cp "$BUNDLE"/nsis/*.exe dist/ 2>/dev/null || true cp "$BUNDLE"/msi/*.msi dist/ 2>/dev/null || true done ls -la dist - name: Upload artifacts uses: https://code.forgejo.org/forgejo/upload-artifact@v4 with: name: desktop-${{ matrix.name }} path: dist/* retention-days: 7 # The release itself happens here, on the Mac that built it, rather # than in jobs of their own on the Linux runners: those queued for # minutes behind CI runs, for a few curl calls and a git push. It runs # in the runner's own account (host mode), so everything it writes # stays in the job's directory or $RUNNER_TEMP, never in ~/.ssh. - name: Publish release (macOS) if: runner.os == 'macOS' && startsWith(github.ref, 'refs/tags/') shell: bash env: TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail # The release notes are the tag's message (git tag -a … -m "…"). NOTES=$(curl -fsS "$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/tags/$GITHUB_REF_NAME" \ -H "Authorization: token $TOKEN" | jq -r '.message // ""') printf '%s\n' "$NOTES" > notes.txt API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY" # A re-run finds the release an earlier attempt made, and replaces # its files. RELEASE=$(curl -sS "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN") RELEASE_ID=$(jq -r '.id // empty' <<<"$RELEASE") if [[ -z "$RELEASE_ID" ]]; then # tag_name must be the pushed tag itself (with its "v"), or # Forgejo creates a second tag without it. RELEASE=$(jq -n --arg tag "$GITHUB_REF_NAME" --arg name "${GITHUB_REF_NAME#v}" --arg body "$NOTES" \ '{tag_name: $tag, name: $name, body: $body, draft: false}' \ | curl -fsS -X POST "$API/releases" \ -H "Authorization: token $TOKEN" \ -H "Content-Type: application/json" \ --data-binary @-) RELEASE_ID=$(jq -r '.id' <<<"$RELEASE") fi for f in dist/*; do NAME=$(basename "$f") OLD=$(jq -r --arg n "$NAME" '.assets[]? | select(.name == $n) | .id' <<<"$RELEASE") for id in $OLD; do curl -fsS -X DELETE "$API/releases/$RELEASE_ID/assets/$id" -H "Authorization: token $TOKEN" done curl -fsS -o /dev/null -X POST "$API/releases/$RELEASE_ID/assets?name=$NAME" \ -H "Authorization: token $TOKEN" \ -H "Content-Type: application/octet-stream" \ --data-binary @"$f" echo "uploaded $NAME" done # The cask, and one Sparkle appcast per architecture (Sparkle has no # way to pick an architecture's download out of one feed). The app # reads the appcasts from the tap through rgit (desktop/src-tauri/ # Info.plist, src/updater.rs). The app is only ad-hoc signed, so the # cask strips the quarantine flag that Homebrew adds; otherwise # Gatekeeper refuses to open it. - name: Write cask and appcasts (macOS) if: runner.os == 'macOS' && startsWith(github.ref, 'refs/tags/') shell: bash env: # Where the cask and the appcasts download releases from: the # forge is tailnet-only, but git.quineglobal.com (the public view # of the repos, rgit) serves public repos' release files at the # forge's own paths, straight from it (petersweb-infra's # nixos/rgit.nix). DOWNLOAD_URL: https://git.quineglobal.com # The public view of the repos (rgit). GIT_URL: https://git.quineglobal.com run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" ARM_SHA=$(shasum -a 256 dist/Quintodrome-aarch64-apple-darwin.app.zip | cut -d' ' -f1) INTEL_SHA=$(shasum -a 256 dist/Quintodrome-x86_64-apple-darwin.app.zip | cut -d' ' -f1) mkdir -p tap/Casks tap/appcasts cat > tap/Casks/quintodrome.rb </\>/g' -e 's/$/
/' notes.txt) # RFC 822, in English whatever the Mac's locale (BSD date has no -R). PUB_DATE=$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000') for ARCH in aarch64 x86_64; do SIGNATURE=$(cat "sparkle/$ARCH.txt") cat > "tap/appcasts/quintodrome-$ARCH.xml" < Quintodrome Quintodrome $VERSION $GITHUB_RUN_NUMBER $VERSION $PUB_DATE APPCAST cat "tap/appcasts/quintodrome-$ARCH.xml" done - name: Push to tap (macOS) if: runner.os == 'macOS' && startsWith(github.ref, 'refs/tags/') shell: bash env: SSH_KEY: ${{ secrets.HOMEBREW_TAP_SSH_KEY }} TAP_REPO: ssh://git@forge.in.quineglobal.com:2200/quine-global/homebrew-tap.git # Forgejo SSH is on 2200; 22 is the host's sshd run: | set -euo pipefail if [[ -z "$SSH_KEY" ]]; then echo "::error::HOMEBREW_TAP_SSH_KEY secret is not set (add it under this repo's Settings > Actions > Secrets; its public key needs write access on the tap repo)" exit 1 fi # The key and known_hosts live in $RUNNER_TEMP, not the Mac # account's ~/.ssh. KEY_FILE="$RUNNER_TEMP/tap_key" KNOWN_HOSTS="$RUNNER_TEMP/tap_known_hosts" trap 'rm -f "$KEY_FILE" "$KNOWN_HOSTS"' EXIT (umask 077; printf '%s\n' "$SSH_KEY" > "$KEY_FILE") echo "[forge.in.quineglobal.com]:2200 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHECXyh0SwRlXikgu919nC9OTmAU7AEcrlbJ/J/H9DTU" > "$KNOWN_HOSTS" export GIT_SSH_COMMAND="ssh -i $KEY_FILE -o IdentitiesOnly=yes -o UserKnownHostsFile=$KNOWN_HOSTS -o StrictHostKeyChecking=yes" rm -rf repo git clone -q "$TAP_REPO" repo mkdir -p repo/Casks repo/appcasts cp tap/Casks/quintodrome.rb repo/Casks/ cp tap/appcasts/quintodrome-*.xml repo/appcasts/ cd repo git config user.name "Quintodrome release" git config user.email "noreply@forge.in.quineglobal.com" git add Casks/quintodrome.rb appcasts/quintodrome-*.xml git diff --cached --quiet && { echo "Cask and appcasts unchanged"; exit 0; } git commit -q -m "quintodrome ${GITHUB_REF_NAME#v}" git push -q