Skip to content

Add in-app auto-updates via Sparkle #5

Add in-app auto-updates via Sparkle

Add in-app auto-updates via Sparkle #5

Workflow file for this run

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