🏡 index : quine-global/quintodrome.git

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, so for now so are installs and updates.
          FORGE_URL: https://forge.in.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 <<CASK
          cask "quintodrome" do
            arch arm: "aarch64", intel: "x86_64"

            version "$VERSION"
            sha256 arm:   "$ARM_SHA",
                   intel: "$INTEL_SHA"

            url "$FORGE_URL/$GITHUB_REPOSITORY/releases/download/v#{version}/Quintodrome-#{arch}-apple-darwin.app.zip"
            name "Quintodrome"
            desc "Desktop app wrapping the Navidrome music server"
            homepage "$GIT_URL/$GITHUB_REPOSITORY.git"

            # The app updates itself (Sparkle), so \`brew upgrade\` leaves it
            # be unless asked with --greedy.
            auto_updates true
            depends_on :macos

            app "Quintodrome.app"

            postflight_steps do
              run "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "{{appdir}}/Quintodrome.app"]
            end

            zap trash: [
              "~/Library/Application Support/com.quineglobal.drome",
              "~/Library/Application Support/com.quintodrome.desktop",
            ]
          end
          CASK
          cat tap/Casks/quintodrome.rb

          # Sparkle shows the notes in its update window: the tag's message,
          # as HTML (escaped, one line per line).
          NOTES_HTML=$(sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/$/<br>/' 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" <<APPCAST
          <?xml version="1.0" encoding="utf-8"?>
          <rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
            <channel>
              <title>Quintodrome</title>
              <item>
                <title>Quintodrome $VERSION</title>
                <sparkle:version>$GITHUB_RUN_NUMBER</sparkle:version>
                <sparkle:shortVersionString>$VERSION</sparkle:shortVersionString>
                <pubDate>$PUB_DATE</pubDate>
                <description><![CDATA[$NOTES_HTML]]></description>
                <enclosure url="$FORGE_URL/$GITHUB_REPOSITORY/releases/download/$GITHUB_REF_NAME/Quintodrome-$ARCH-apple-darwin.app.zip" type="application/octet-stream" $SIGNATURE/>
              </item>
            </channel>
          </rss>
          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