Add in-app auto-updates via Sparkle #5
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| # Push a version tag (e.g. `git tag v1.0.0 && git push origin v1.0.0`) and this | |
| # workflow builds, signs, notarizes and packages Spindle.dmg, then attaches it | |
| # to the matching GitHub Release. Without the signing secrets configured (see | |
| # docs/RELEASING.md) it still builds an UNSIGNED .dmg as a workflow artifact so | |
| # the pipeline is testable, but that build is not fit for public distribution. | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| workflow_dispatch: | |
| permissions: | |
| contents: write # create releases and upload assets | |
| pages: write # publish the Sparkle appcast to GitHub Pages | |
| id-token: write # required by actions/deploy-pages | |
| jobs: | |
| build: | |
| runs-on: macos-15 | |
| outputs: | |
| appcast: ${{ steps.appcast.outputs.generated }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Select latest stable Xcode | |
| uses: maxim-lobanov/setup-xcode@v1 | |
| with: | |
| xcode-version: latest-stable | |
| - name: Determine version | |
| id: version | |
| run: | | |
| set -euo pipefail | |
| BUILD="${GITHUB_RUN_NUMBER}" | |
| if [[ "$GITHUB_REF" == refs/tags/* ]]; then | |
| # v1.2.3 -> 1.2.3, used for both the app version and the DMG name. | |
| MARKETING="${GITHUB_REF_NAME#v}" | |
| DMG="$MARKETING" | |
| else | |
| # Manual runs aren't releases: keep a valid app version, label the | |
| # DMG with the commit so it's identifiable. | |
| MARKETING="0.0.0" | |
| DMG="$(git rev-parse --short HEAD)" | |
| fi | |
| echo "marketing=${MARKETING}" >> "$GITHUB_OUTPUT" | |
| echo "build=${BUILD}" >> "$GITHUB_OUTPUT" | |
| echo "dmg=${DMG}" >> "$GITHUB_OUTPUT" | |
| echo "Version ${MARKETING} (build ${BUILD}), DMG suffix ${DMG}" | |
| - name: Stamp version into Info.plist | |
| run: Scripts/set-version.sh "${{ steps.version.outputs.marketing }}" "${{ steps.version.outputs.build }}" | |
| - name: Detect signing secrets | |
| id: secrets | |
| env: | |
| MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} | |
| run: | | |
| if [[ -n "$MACOS_CERTIFICATE" ]]; then | |
| echo "signing=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "signing=false" >> "$GITHUB_OUTPUT" | |
| echo "::warning::No signing secrets configured — building an UNSIGNED, un-notarized .dmg. See docs/RELEASING.md." | |
| fi | |
| - name: Import Developer ID certificate | |
| if: steps.secrets.outputs.signing == 'true' | |
| env: | |
| MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} | |
| MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} | |
| run: | | |
| set -euo pipefail | |
| KEYCHAIN="$RUNNER_TEMP/build.keychain-db" | |
| KEYCHAIN_PWD="$(openssl rand -base64 24)" | |
| CERT="$RUNNER_TEMP/certificate.p12" | |
| echo "$MACOS_CERTIFICATE" | base64 --decode > "$CERT" | |
| security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN" | |
| security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" | |
| security import "$CERT" -P "$MACOS_CERTIFICATE_PWD" \ | |
| -t cert -f pkcs12 -k "$KEYCHAIN" \ | |
| -T /usr/bin/codesign -T /usr/bin/security | |
| security set-key-partition-list -S apple-tool:,apple:,codesign: \ | |
| -s -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null | |
| # Add the build keychain to the user search list so codesign finds it. | |
| security list-keychains -d user -s "$KEYCHAIN" \ | |
| $(security list-keychains -d user | sed s/\"//g) | |
| rm -f "$CERT" | |
| - name: Build and sign the app | |
| env: | |
| SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} | |
| run: Scripts/make-app.sh release | |
| - name: Notarize and staple the app | |
| if: steps.secrets.outputs.signing == 'true' | |
| env: | |
| NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }} | |
| NOTARY_TEAM_ID: ${{ secrets.NOTARY_TEAM_ID }} | |
| NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }} | |
| run: Scripts/notarize.sh dist/Spindle.app | |
| - name: Package the DMG | |
| env: | |
| SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} | |
| run: Scripts/make-dmg.sh | |
| - name: Notarize and staple the DMG | |
| if: steps.secrets.outputs.signing == 'true' | |
| env: | |
| NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }} | |
| NOTARY_TEAM_ID: ${{ secrets.NOTARY_TEAM_ID }} | |
| NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }} | |
| run: Scripts/notarize.sh dist/Spindle.dmg | |
| - name: Name the DMG after the version | |
| id: dmg | |
| run: | | |
| set -euo pipefail | |
| NAME="Spindle-${{ steps.version.outputs.dmg }}.dmg" | |
| mv dist/Spindle.dmg "dist/${NAME}" | |
| echo "name=${NAME}" >> "$GITHUB_OUTPUT" | |
| echo "path=dist/${NAME}" >> "$GITHUB_OUTPUT" | |
| - name: Upload build artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ steps.dmg.outputs.name }} | |
| path: ${{ steps.dmg.outputs.path }} | |
| if-no-files-found: error | |
| - name: Attach to GitHub Release | |
| if: startsWith(github.ref, 'refs/tags/') | |
| uses: softprops/action-gh-release@v3 | |
| with: | |
| files: ${{ steps.dmg.outputs.path }} | |
| generate_release_notes: true | |
| fail_on_unmatched_files: true | |
| # --- Sparkle appcast (auto-update feed) ------------------------------- | |
| # Only on real tag releases that were properly signed. The DMG stays on | |
| # the GitHub Release; we publish just the small appcast.xml to Pages, | |
| # merging the new item into the previously published feed. | |
| - name: Generate Sparkle appcast | |
| id: appcast | |
| if: startsWith(github.ref, 'refs/tags/') && steps.secrets.outputs.signing == 'true' | |
| env: | |
| SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} | |
| run: | | |
| set -euo pipefail | |
| # Sparkle's command-line tools (sign_update) come from its tarball. | |
| curl -sL -o "$RUNNER_TEMP/sparkle.tar.xz" \ | |
| https://github.com/sparkle-project/Sparkle/releases/download/2.9.3/Sparkle-2.9.3.tar.xz | |
| mkdir -p "$RUNNER_TEMP/sparkle" | |
| tar -xf "$RUNNER_TEMP/sparkle.tar.xz" -C "$RUNNER_TEMP/sparkle" | |
| mkdir -p _site | |
| # Carry forward the currently published feed so older versions persist. | |
| curl -sfL "https://thijsw.github.io/spindle/appcast.xml" \ | |
| -o "$RUNNER_TEMP/existing-appcast.xml" && \ | |
| EXISTING="$RUNNER_TEMP/existing-appcast.xml" || EXISTING="" | |
| DMG="${{ steps.dmg.outputs.path }}" \ | |
| DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.dmg.outputs.name }}" \ | |
| VERSION="${{ steps.version.outputs.marketing }}" \ | |
| BUILD="${{ steps.version.outputs.build }}" \ | |
| RELEASE_URL="https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}" \ | |
| EXISTING_APPCAST="$EXISTING" \ | |
| OUT="_site/appcast.xml" \ | |
| SPARKLE_BIN="$RUNNER_TEMP/sparkle/bin" \ | |
| Scripts/make-appcast.sh | |
| # A tiny landing page so the Pages site root isn't a 404. | |
| cat > _site/index.html <<'HTML' | |
| <!doctype html><meta charset="utf-8"><title>Spindle updates</title> | |
| <h1>Spindle</h1> | |
| <p>This site hosts the Sparkle <a href="appcast.xml">appcast.xml</a> | |
| auto-update feed. Downloads live on the | |
| <a href="https://github.com/thijsw/spindle/releases">Releases page</a>.</p> | |
| HTML | |
| echo "generated=true" >> "$GITHUB_OUTPUT" | |
| - name: Upload appcast as Pages artifact | |
| if: steps.appcast.outputs.generated == 'true' | |
| uses: actions/upload-pages-artifact@v3 | |
| with: | |
| path: _site | |
| # Deploy the appcast to GitHub Pages. Separate job because deploy-pages must | |
| # run in the github-pages environment. | |
| publish-appcast: | |
| needs: build | |
| if: needs.build.outputs.appcast == 'true' | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: github-pages | |
| url: ${{ steps.deploy.outputs.page_url }} | |
| steps: | |
| - name: Deploy to GitHub Pages | |
| id: deploy | |
| uses: actions/deploy-pages@v4 |