Automating Xcode Sparkle Releases with GitHub Actions

Alexander Perathoner
6 min readDec 31, 2022

--

TLTR: in this tutorial you’ll learn to automate the release of a Sparkle update of a Xcode macOS app using GitHub Actions, with EdDSA signature.

Developing macOS apps outside of Mac App Store has one great challenge: getting the users to update your app.

You struggle for weeks developing new features, fixing bugs… and once you are finished, half of the users won’t update. No wonder: no one checks if there is a new release on GitHub. Except if they have a problem. But you should avoid putting bugs in your apps in order to get them on your site.

Fortunately, there’s a solution: Sparkle, an open source framework that does exactly that.

How does Sparkle work?

Following Sparkle’s docs:

  1. We add a Package Dependency to Sparkle.
  2. In the app’s storyboard we add a button, which is connected to SPUStandardUpdaterController, triggering his Check updates action
  3. In info.plist we add a SUFeedURL, which links to a rss feed, in this case it will be: https://alexperathoner.github.io/SparkleReleaseTest/Support/appcast.xml
  4. When the user clicks on the button, Sparkle will check if there are new entries in the given file. If so, it will download and install them.
the simplest window you’ll see this year

How can we automate this?

I wanted to be able to use a GitHub Action to do everything. This was the ideal outcome:

  1. The repository’s owner creates a pull request to master
  2. Once he wants to create a release he comments /release
  3. The action is triggered
  4. The app is compiled
  5. The appcast gets updated
  6. The PR is merged

The workflow file

We add our workflow file at .github/workflows/release.yml

As I want it to be triggered on a comment:

on:
issue_comment:
types: [created]

We’ll be compiling Xcode projects, so:

jobs:
release:
name: "Create Release"
runs-on: macos-12
# the owner should be the only one able to release a new version:
if: github.event.issue.pull_request && contains(github.event.comment.body, '/release') && github.event.comment.user.login == "${{ github.repository_owner }}"

The workflow should run only if the PR can be closed. Conflicts and unfinished checks should prevent a release. This can be accomplished with actions/github-script


- uses: actions/github-script@v6
id: get-run
with:
result-encoding: string
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
if (pr.data.draft || pr.data.mergeable_state !== "clean") {
core.setFailed("PR is not ready to be merged");
}

Note that we can’t use the context of the action, as the trigger is issue_comment . Such triggers run on the master branch. For this same reason we won’t directly checkout, like you’ve probably seen in other GitHub actions. What we need to do instead is:

- uses: xt0rted/pull-request-comment-branch@v1
id: comment-branch
- uses: actions/checkout@v3
if: success()
with:
ref: ${{ steps.comment-branch.outputs.head_ref }}

Let’s take a quick look at the Release_Notes.md file, in the root of our project:

# 1.4.10 - Some title
* The release notes which are interesting to us

# 1.4.9 - Some other title
* Old release notes

With a simple python script we can retrieve the latest version, its title and bullet points and the precedent version. For simplicity, each of these is then output to a file, which we’ll read from the workflow and then add to the output result of this step:

first_header = lines[0].strip()
title = first_header.split(' - ')[1]
new_version = first_header.split(' - ')[0].replace('# ', '')
lines = lines[1:]
latest_changes = ""
old_version = ""
for line in lines:
if line.strip().startswith('*'):
latest_changes += line.strip() + "\n"
elif line.strip().startswith('#'):
old_version = line.strip().split(' - ')[0].replace('# ', '')
break
- name: Extract latest changes
id: latest_changes
run: |
python3 ./Configuration/generate_latest_changes.py
echo "new_version=$(cat new_version)" >> $GITHUB_OUTPUT
echo "old_version=$(cat new_version)" >> $GITHUB_OUTPUT
echo "title=$(cat title)" >> $GITHUB_OUTPUT

We can now do some important checks, like checking if the extrapolated version has been released already, or if the release notes are empty. In these cases the action should stop and no release will happen:

  # prevent releasing the same version twice
- name: Check if version already released
run: |
if [[ $(xcrun agvtool what-version -terse) == $(cat new_version) ]]; then
echo "Version already released" >> $GITHUB_STEP_SUMMARY
exit 1
fi
# prevent releasing without release notes
- name: Check if release notes are empty
run: |
if [[ $(cat latest_changes) == "" ]]; then
echo "Release notes are empty" >> $GITHUB_STEP_SUMMARY
exit 1
fi

Having the old and the new version numbers, we can bump the version in the project by simply replacing the one with the other:

sed -i '' "s/_VERSION = $(xcrun agvtool what-version -terse)/_VERSION = ${{ steps.latest_changes.outputs.new_version }}/g" ${{ env.projname }}.xcodeproj/project.pbxproj;

Following this guide we can add a Build certificate and a build provisioning profile to the GitHub runner. This allows to not only build and archive the Xcode project, but also to export it as a .app :

- name: Build and archive # create archive
run: xcodebuild clean archive -project ${{ env.projname }}.xcodeproj -scheme ${{ env.projname }} -archivePath ${{ env.projname }}
- name: Export app # create .app
run: xcodebuild -exportArchive -archivePath "${{ env.projname }}.xcarchive" -exportPath Release -exportOptionsPlist "Configuration/export_options.plist"
# Sparkle need the app to be zipped so do that:
- name: Zip app
run: |
cd Release
ditto -c -k --sequesterRsrc --keepParent ${{ env.projname }}.app ${{ env.projname }}.zip
cd ..

Sparkle offers a tool to generate or update the appcast.xml file.
It takes as input:

  • a directory with the .zip and a .html with the same name, containing its release notes
  • the path to the appcast file
  • a url prefix telling where the zip will be uploaded to
  • a backup link to show to the user, if he needs to update manually

But the release notes files currently contains the bullet points in markdown format. We’ll need another script to convert them to html:

with open('title', 'r') as f:
title = f.read()
with open('latest_changes', 'r') as f:
changes = f.read()

text = '# ' + title + '\n\n' + changes
html = markdown.markdown(text)
- name: Generate Sparkle notes
run: |
pip3 install -r Configuration/requirements.txt
python3 ./Configuration/generate_html_for_sparkle_release.py
mv Release/latest_changes.html Release/${{ env.projname }}.html
- name: Update appcast
run: |
./Configuration/generate_appcast \
--link https://github.com/AlexPerathoner/${{ github.event.repository.name }}/releases \
--download-url-prefix https://github.com/AlexPerathoner/${{ github.event.repository.name }}/releases/download/v${{ steps.latest_changes.outputs.new_version }}/ \
-o docs/Support/appcast.xml \
Release/

We are finally ready to create a GitHub release:

- name: Create GitHub release # Upload .zip to GitHub release
uses: softprops/action-gh-release@v1
with:
name: ${{ steps.latest_changes.outputs.title }}
tag_name: v${{ steps.latest_changes.outputs.new_version }}
fail_on_unmatched_files: true
body_path: latest_changes
files: |
Release/${{ env.projname }}.zip

Now we need to remove the unneeded files (containing the version, the release notes, the app etc), commit all changes (to save the version bump) and merge the PR.
It’s important to note that after the commit, we’ll need to checkout again, in order to merge the latest changes, too:

- name: Cleanup # remove all build files, keys, etc.
run: |
rm -rf Release
...
- name: Saving changes # commits changes to branch (version bump, appcast.xml)
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: "Update version to v${{ steps.latest_changes.outputs.new_version }}"
- uses: xt0rted/pull-request-comment-branch@v1 # checkout again, because the previous checkout is detached
id: comment-branch-2
- uses: actions/checkout@v3
if: success()
with:
ref: ${{ steps.comment-branch-2.outputs.head_ref }}
- name: Merge PR # merge PR
uses: "pascalgn/automerge-action@v0.15.5"
env:
MERGE_LABELS: "" # no labels necessary for the PR to be merged
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
MERGE_COMMIT_MESSAGE: "Release version v${{ steps.latest_changes.outputs.new_version }}"
MERGE_FILTER_AUTHOR: "${{ github.repository_owner }}"
MERGE_ERROR_FAIL: true

Cool, are we done?
Meh, kinda.

At this point everything works, except we get a warning that we should use Sparkle’s EdDSA signature. In fact, in a couple of months, this method will stop working. Let’s see how we can fix it.

Adding EdDSA signing

If you don’t want to read Sparkle’s paragraph about EdDSA signing here’s what you need to know: we have a public key, added in the Xcode project, a private key and a signature, added to the appcast file.

  1. First part is easy: add a SUPublicEDKey item in info.plist with the value generated by Sparkle’s tool generate_keys .
  2. The generate_appcast tool we used before has a --ed-key-file option which, you guessed it, allows us to specify a file containing the private key.
    We obviously don’t want to save the private key in our repository, though. What we can do is similar to the procedure used for importing the build certificate used before: we add the key to GitHub secrets, read it in the action, save it to a file and (important) delete the file before committing all new changes.

Now we’re really done!

Here’s a repository that you can use as example, and the complete workflow file.

--

--