Linen uses Sparkle 2.10.1 for updates. The app
does not show the Sparkle windows. The update interface is only the banner in
Linen/App/Updates/UpdateBanner.swift.
Two files contain the hosting configuration. The values in these two files must agree:
Linen/App/Updates/UpdateFeed.swift: theownerandrepositoryvaluesLinen/Resources/Info.plist: theSUFeedURLvalue
The feed URL is
https://github.com/<owner>/<repo>/releases/latest/download/appcast.xml.
GitHub redirects that URL to the asset of the same name in the most recent
release, so you need no web server and no gh-pages branch. Attach
appcast.xml to each release to keep that URL working.
Sparkle ships generate_keys, sign_update and generate_appcast in an
artifact bundle rather than as package products, so Swift Package Manager
downloads them without building them. Set a variable to their directory:
export SPARKLE_BIN=$(dirname "$(find ~/Library/Developer/Xcode/DerivedData -path '*artifacts/sparkle/Sparkle/bin/sign_update' -print -quit)")The tools are stored in DerivedData. Cleaning the build folder removes them; the next build restores them. You can also download the release tarball from https://github.com/sparkle-project/Sparkle/releases.
The EdDSA key pair already exists. Linen/Resources/Info.plist holds the public
key as SUPublicEDKey, and this Mac’s login keychain holds the private key,
where sign_update finds it. Nothing else needs configuring.
Back up the private key and keep the backup for at least two years. If you lose it, installed copies cannot verify new updates. Users must download the app again.
To export a copy:
"$SPARKLE_BIN/generate_keys" -x sparkle-private-key.txtThe exported key authorizes updates. Store it in a password manager, then delete the exported file.
generate_keys -p shows the public key again at any time. If you run
generate_keys with no arguments, it keeps the existing key pair. It does not
make a new key pair.
The workflow signs the app with a Developer ID Application certificate. Only the Account Holder of the team can make this certificate. A Developer ID certificate is valid for five years.
- Open Xcode › Settings › Accounts.
- Select your Apple ID, then the team.
- Select Manage Certificates.
- Select the add button, then Developer ID Application.
- Run
security find-identity -v -p codesigning. The output must containDeveloper ID Application.
Then export the certificate for the workflow:
- Open Keychain Access › login › My Certificates.
- Select the Developer ID Application certificate. A private key must show below it. The Certificates category gives a file with no key, and the workflow then finds no signing identity.
- Select File › Export Items. Save a
.p12file. Set a password. - Put the file in the
DEVELOPER_ID_CERTIFICATE_P12secret below. - Put the password in the
DEVELOPER_ID_CERTIFICATE_PASSWORDsecret. - Put a copy of the file and the password in a password manager. Then delete the file from the disk.
The portal permits a small number of these certificates. If the portal shows a
certificate that this Mac does not have, the private key is on a different Mac.
Export the key from that Mac, or revoke the certificate and make a new one. A
revoked certificate does not stop a release that is already public. The workflow
signs with --timestamp.
Linen.entitlements declares two restricted entitlements. The app stores API
keys in the data-protection keychain. That keychain refuses an item from code
that has no keychain access group, so the file declares keychain-access-groups.
The app also lets a website use a passkey, so the file declares
com.apple.developer.web-browser.public-key-credential. A Developer ID
signature can only carry a restricted entitlement when an embedded profile
authorizes it. Gatekeeper refuses to launch an app that declares an entitlement
without the profile.
Once, in the Apple Developer portal:
-
Open Certificates, Identifiers & Profiles › Identifiers.
-
Select the
com.kavoye.LinenApp ID, or register it. -
Enable the Web Browser Public Key Credential Requests capability. Apple assigns this capability to the account. You must also enable it on the App ID.
-
Open Profiles. Add a profile.
-
Select the Developer ID type, under Distribution.
-
Select the
com.kavoye.LinenApp ID and your Developer ID Application certificate. -
Give the profile a name, for example
Linen Developer ID. Do not use the characters&,<or>. The export step puts the name in a plist. -
Download the profile as
Linen.provisionprofile. -
Run this command. The output must contain
keychain-access-groupsandcom.apple.developer.web-browser.public-key-credential:security cms -D -i Linen.provisionprofile | plutil -extract Entitlements xml1 -o - - -
Put the profile in the
DEVELOPER_ID_PROVISIONING_PROFILEsecret below.
The App ID prefix gives the access group. The portal has no Keychain Sharing capability. That switch is in Xcode, and it only writes the entitlements file. The passkey capability is the opposite. Only the portal can enable it. A build fails at the provisioning step until you do.
Make the profile again when it expires, and when you add a restricted entitlement. The release then fails at the Install provisioning profile step, and at the checks after the export.
Pushing a tag starts the release workflow, which uses the Developer ID certificate, notarization key and Sparkle private key. Only accounts with write access can push tags. The Sparkle key authorizes updates for installed copies. Set up all three controls below before you make the repository public.
1. The release environment. The workflow jobs declare
environment: release, and the secrets live in that environment. Only a job
that runs from an approved ref can read them.
- Open Settings › Environments. Add an environment. Name it
release. - Find Deployment branches and tags. Select Selected branches and tags.
- Select Add deployment branch or tag rule. Set the ref type to Tag. Enter
the pattern
v*. - Select Add deployment branch or tag rule again. Set the ref type to
Branch. Enter
main. - Make sure the list reads "1 branch and 1 tag allowed". A release comes from
the tag. A preview build comes from the branch, because CI runs from
mainand not from a tag. With no branch in the list, each preview build fails when it asks for the certificate. - Add the seven secrets from the next section as environment secrets.
Do not add a required reviewer. A reviewer stops the release until you come back and approve it, and stops each preview build the same way. The tag ruleset below controls who can start a release.
2. A tag ruleset. Restrict who can create, update or delete release tags.
- Open Settings › Rules › Rulesets. Add a new tag ruleset.
- Set Enforcement status to Active.
- Under Target tags, add the pattern
v*. - Select Restrict creations, Restrict updates and Restrict deletions.
- Leave the bypass list empty, then add only yourself.
3. A branch ruleset for main. The release only builds a commit that is on
main, so protect changes to main.
- Add a branch ruleset that targets the default branch.
- Select Require a pull request before merging. Require one approval.
- Select Require status checks to pass. Add the CI check.
- Select Block force pushes. Select Restrict deletions.
The workflow refuses a tag that points at a commit that is not on main, but a
ruleset is what stops the commit getting to main.
.github/workflows/release.yml needs seven secrets. Add them to the release
environment, in Settings › Environments › release › Environment secrets. Do not
add them in Settings › Secrets and variables › Actions: a repository secret is
available to every workflow run, with no approval:
| Secret | Description |
|---|---|
DEVELOPER_ID_CERTIFICATE_P12 |
The .p12 file from the certificate section above. Run base64 -i cert.p12 | pbcopy |
DEVELOPER_ID_CERTIFICATE_PASSWORD |
The password that you set on the .p12 file |
DEVELOPER_ID_PROVISIONING_PROFILE |
The Developer ID profile from the section above. Run base64 -i Linen.provisionprofile | pbcopy |
AC_API_KEY_P8 |
The full contents of the App Store Connect API key .p8 file. Include the BEGIN and END lines |
AC_API_KEY_ID |
The ID of the key. This is the ABCD1234EF part of AuthKey_ABCD1234EF.p8 |
AC_API_ISSUER_ID |
The issuer UUID. App Store Connect shows it above the list of keys |
SPARKLE_PRIVATE_KEY |
The EdDSA private key, from generate_keys -x |
Make the App Store Connect API key in Users and Access › Integrations › App
Store Connect API. Give the key the Developer role. App Store Connect downloads
the .p8 file one time only. notarytool uses this key to authenticate. An app-specific
password also works, but you can revoke the API key on its own,
and the API key does not give access to all of your Apple ID.
The team ID is not a secret. The workflow already contains it.
The release does not run the tests. CI tests each push to main, and the
workflow reads the result. Push the tag:
git tag v1.1 && git push origin v1.1Push the tag as soon as the commit is on main. You do not have to wait for
CI, because the gate job waits for you. From there the workflow:
- Confirms the tag is on
main. - Waits for the CI run on that commit, and stops if it fails.
- Builds an archive.
- Signs the app with the Developer ID certificate and the profile.
- Sends the app to Apple for notarization.
- Staples the notarization ticket to the app.
- Builds a zip file.
- Builds a disk image holding the app and a link to the Applications folder.
- Signs the disk image.
- Sends the disk image to Apple for notarization.
- Staples the notarization ticket to the disk image.
- Signs the app for Sparkle.
- Writes
appcast.xml. - Publishes the GitHub release with the three assets.
The workflow takes 20 to 40 minutes, plus the time that CI still needs. The Apple notary service uses most of this time, and the workflow waits for it two times.
If CI fails on that commit, the gate job stops the release. No signing key is
used. Correct the code, push it, then make the next tag. Do not move a tag that
you already pushed.
Release requirements:
- Use the correct asset format. Users download the disk image. Sparkle
downloads the zip file.
appcast.xmlpoints only at the zip file. Keep the disk image out of thedistfolder.generate_appcastreads a disk image also, and then the feed contains two items for one version. - Install in Applications. A copy that runs from another folder cannot
always update itself, so Linen offers to move itself to the Applications
folder at the first launch.
Linen/App/Install/InstallLocation.swiftmakes that decision. Linen asks one time only. Keep the Applications folder link in the disk image to show where to install the app. - The tag sets the version.
MARKETING_VERSIONcomes from the tag without thevcharacter.CURRENT_PROJECT_VERSIONcomes fromgit rev-list --count HEAD, the number of commits, so theCFBundleVersionthat Sparkle compares always increases, in both channels. Do not use the number of the workflow run: that counter restarts if the workflow file is replaced, and a build number that goes down stops every installed copy from updating. Do not change a version by hand in the Xcode project. The values in the Xcode project apply only to local builds. - The release body supplies the “What’s New” sheet. The workflow makes the
body from two parts.
CHANGELOG.mdis optional: make it only when a release needs notes of its own. If it contains a## 1.1section, that section comes first. GitHub then adds the list of the commits and the pull requests after the previous tag, and a New Contributors section, so every contributor gets credit with or without a changelog. The app reads the body from the API each time it shows the sheet, so you can edit a release later to correct the text without publishing a new one. - Give credit to a security reporter by hand. SECURITY.md
promises the reporter credit in the release notes. The workflow cannot know
the name. Put the name in the
CHANGELOG.mdsection before you make the tag. Do not use the name if the reporter asked you to keep it out. - Use a supported tag format. The notes sheet finds the release with
the name
v<version>. If it does not find that name, it uses<version>. Only the formatsvX.YandvX.Y.Zstart the workflow. - The workflow writes
appcast.xmlfrom the app bundle, so the version, the minimum system version and the architectures in the feed always match the app. The feed contains only the most recent release. This is sufficient for Sparkle to offer the update to all users.
The app shows the license of each open source package in Settings › About. The
list comes from Linen/Resources/Acknowledgements.json. A script makes this
file from Package.resolved and the resolved checkouts:
swift Tools/make-acknowledgements.swiftAfter you add, remove or update a package:
- Resolve the packages one time, or build the app.
- Run the command above.
- Commit the file with the change to
Package.resolved.
CI runs the same command and stops the build if the result is different. The MIT and Apache licenses require their terms to be included with distributed binaries, so this file is required.
The disk image window comes from two files in Tools/dmg: background.tiff
and DS_Store. The workflow copies both into the staging folder. Only Finder
writes a .DS_Store, and the runner has no Finder session, so the layout is
made once on a Mac.
After you change the artwork or the icon positions:
-
Run this command on a Mac:
sh Tools/make-dmg-layout.sh
-
Open the disk image from a release build. Look at the window.
-
Commit the two files in
Tools/dmg.
The volume name must stay Linen. The background is an alias that names the
volume.
macOS 26 Finder ignores the window size in the file. The window opens at the
size that Finder gives to each new window. The 1280x800 artwork
covers the window background, with the layout anchored at the top left.
A Finder icon position is the centre of the icon, so the positions in
Tools/make-dmg-layout.sh and the coordinates in Tools/make-dmg-background.swift
are the same numbers.
If the workflow fails, and you cannot wait, do these steps on your Mac:
-
Make an archive and export it with the Developer ID method.
-
Notarize the app with
notarytool. -
Staple the ticket to the app with
staple. -
Make a zip file:
ditto -c -k --sequesterRsrc --keepParent Linen.app Linen-1.1.zip -
Make the appcast:
"$SPARKLE_BIN/generate_appcast" \ --download-url-prefix "https://github.com/kavoye/linen-browser/releases/download/v1.1/" \ /path/to/folder-with-the-zip
-
Make a folder that contains the app, a link to the Applications folder and the window layout:
mkdir -p dmg/.background && ditto Linen.app dmg/Linen.app && ln -s /Applications dmg/Applications
cp Tools/dmg/background.tiff dmg/.background/ && cp Tools/dmg/DS_Store dmg/.DS_Store -
Make the disk image:
hdiutil create -volname Linen -srcfolder dmg -fs HFS+ -format UDZO -ov Linen.dmg
-
Sign the disk image:
codesign --force --sign "Developer ID Application" --timestamp Linen.dmg -
Notarize the disk image with
notarytool. -
Staple the ticket to the disk image with
staple. -
Attach the disk image, the zip file and
appcast.xmlto the release.
Linen has two update channels. A release is the default. A preview build comes
from the newest commit on main, before a release.
To follow preview builds, open Settings › About and set Update channel to Preview. Sparkle reads the other feed at the next check, with no restart.
.github/workflows/ci.yml builds a preview on each push to main. No manual
trigger or tag is needed. The workflow:
- Reads the version: the last
vtag, then the number of commits after it, such as0.1.2 (4). - Uses the tag alone when that count is zero, such as
0.1.2. The commit is the release itself. - Runs the tests and builds an archive at the same time. The archive job has no signing secrets.
- Cancels the run if the tests fail. This stops the archive job. No signing key is used.
- Signs the app and sends it to Apple for notarization.
- Builds a zip file, and no disk image.
- Signs the app for Sparkle and writes
appcast-tip.xml. - Moves the
tiptag to that commit. - Updates the
tippre-release with the two files. - Deletes every zip file outside the five most recent.
The workflow takes 20 to 30 minutes. Ten commits in ten minutes produce one build: a new run cancels the one before it, and the last commit is what builds.
| Channel | Tag | Pre-release | Feed asset |
|---|---|---|---|
| Release | v0.1.2, a new tag each time |
No | appcast.xml |
| Preview | tip, one tag that moves |
Yes | appcast-tip.xml |
Linen/App/Updates/UpdateFeed.swift contains both URLs. The release feed is
releases/latest/download/appcast.xml. The preview feed is
releases/download/tip/appcast-tip.xml. The second URL is a permalink because
the tag name never changes.
- The
tiprelease must stay a pre-release. GitHub makes the most recent release that is not a pre-release the “latest” release, andreleases/latest/download/reads that release. Atiprelease that is not a pre-release takes the release feed URL. The workflow sets the flag on each run. - The two feed files must keep different names. That is what makes the mistake above recoverable. With different names the release feed returns a 404, update checks fail, and you can fix it. With the same name everyone moves to the preview channel, and since Sparkle never installs an older version, you cannot fix it.
- The build number must always increase. Both workflows use
git rev-list --count HEAD. A release is always at or after the previews before it, so its number is never lower. - The
tiptag is not a version tag. The workflow reads the last version withgit describe --match 'v[0-9]*.[0-9]*'. Without the pattern,git describefindstip, and the count becomes zero at each build. - The commit a release tags still builds a preview. A release writes to the
release feed only. If the preview build stops at that commit, the preview
channel stays on the release before it, and everything since sits in no
preview feed until the next commit lands on
main. - A preview build uses the signing keys. The certificate, notarization
key and Sparkle key are used for every commit on
mainthat passes CI.
Set Update channel to Release, and Linen reads the release feed again. Sparkle never installs an older version, so the app stays on the preview build until a release carries a higher build number. To go back at once, download the disk image from the releases page and replace the app.
- Sparkle checks for an update at launch, and then every four hours. If a background check fails, the app shows nothing.
- The feed that Sparkle reads depends on the Update channel setting. A change to the setting starts a check immediately.
- When Sparkle finds an update, the banner appears in two places: above the settings page, and in the sidebar below the media player.
- Install downloads the update, installs it, and opens Linen again, without asking a second time. Nothing downloads before someone chooses Install.
- Dismissing the banner defers the update rather than skipping it, and a downloaded update carries on at the next launch.
- Linen › Check for Updates… checks straight away, and the same banner shows the result.