SoundDash - App Release and Updates#
This is the single end-to-end procedure for preparing, notarizing, publishing, and testing a SoundDash release. Follow the execution checklist from top to bottom. The reference sections afterward explain setup and why the order matters.
The appcast at https://marcoue.github.io/SoundDash-Support/appcast.xml is the production update feed. Any Sparkle-enabled copy of SoundDash can see a release as soon as this feed advertises it. A GitHub release marked Pre-release is still offered by Sparkle if it is in this feed.
Execute this release in order#
1. Set the release version and build in Xcode#
In Xcode, select the SoundDash target, then General → Identity:
- Set Version to the new user-facing version.
- Increase Build above every build already in the public appcast.
- Save the project changes.
- Commit the release source and version changes before archiving.
Sparkle compares the build number (CFBundleVersion). The GitHub tag format used here is version_build, for example 1.2_6.
2. Archive and export the notarized app#
- Select the SoundDash scheme and Any Mac (arm64, x86_64) archive destination.
- Choose Product → Archive.
- In Organizer → Archives, select the new archive and choose Distribute App → Direct Distribution to submit it to Apple for notarization.
- When Organizer shows Ready to distribute, choose Export Notarized App and save it in Downloads as
SoundDash.app. If the archive already shows Ready to distribute, choose Export Notarized App directly; do not start the distribution flow again. - Open About SoundDash and confirm the exported app shows the version and build from step 1.
3. Create, sign, and notarize the final DMG#
Before running this block, complete One-time setup → Apple notarization credential profile and confirm the
SoundDash-NotaryKeychain profile was created successfully. Do not start the DMG block until that profile exists; the block creates and signs the DMG before it submits it to Apple.
Update the two marked values to match Xcode. Update the exported-app path if Xcode saved the app elsewhere. Run this entire block in Terminal. It creates one visible output folder named SoundDash 2.1 Release (version only, no build number); the temporary DMG source folder is created outside Downloads and automatically removed:
(
# Keep failures from closing the Warp shell.
set -e
# CHANGE for every release: use Xcode Target > General > Identity > Version.
APP_VERSION="2.1"
# CHANGE for every release: use the matching Xcode Build value.
APP_BUILD="8"
# CHANGE only if the exported app is in a different location.
EXPORTED_APP="/Users/marc/Downloads/SoundDash.app"
RELEASE_DIR="/Users/marc/Downloads/SoundDash $APP_VERSION Release"
DMG_PATH="$RELEASE_DIR/SoundDash.dmg"
# This is the only output folder. It must not already exist.
mkdir "$RELEASE_DIR"
# Temporary DMG source; automatically removed when this block finishes.
PACKAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/SoundDash-package.XXXXXX")"
trap 'rm -rf "$PACKAGE_DIR"' EXIT
ditto "$EXPORTED_APP" "$PACKAGE_DIR/SoundDash.app"
ln -s /Applications "$PACKAGE_DIR/Applications"
hdiutil create \
-volname "SoundDash" \
-srcfolder "$PACKAGE_DIR" \
-format UDZO \
"$DMG_PATH"
codesign --sign "Developer ID Application" \
--timestamp \
--identifier "com.marcouellet.SoundDash.dmg" \
"$DMG_PATH"
codesign --verify --verbose=2 "$DMG_PATH"
xcrun notarytool submit "$DMG_PATH" \
--keychain-profile "SoundDash-Notary" \
--wait
xcrun stapler staple "$DMG_PATH"
xcrun stapler validate "$DMG_PATH"
)Continue only after Apple’s submission is Accepted and stapler validate succeeds. The file in the release folder is now the final DMG. Keep its filename exactly SoundDash.dmg.
Resume if the notarization profile was missing#
If the block created and signed the DMG but notarytool submit reports No Keychain password item found for profile: SoundDash-Notary, do not rerun the full block: its mkdir command will stop because the release folder already exists. Complete One-time setup → Apple notarization credential profile first, then resume against the existing signed DMG. For the example version/build above, run:
RELEASE_DIR="/Users/marc/Downloads/SoundDash 2.1 Release"
DMG_PATH="$RELEASE_DIR/SoundDash.dmg"
xcrun notarytool submit "$DMG_PATH" \
--keychain-profile "SoundDash-Notary" \
--wait
# Continue only after Apple's result is Accepted.
xcrun stapler staple "$DMG_PATH"
xcrun stapler validate "$DMG_PATH"For a different release version, change RELEASE_DIR to that release’s existing output folder. After stapling succeeds, treat the DMG as final. Any change requires signing and notarizing the replacement DMG, then regenerating its Sparkle appcast signature.
Before publishing it, open this DMG in Finder. Confirm it contains SoundDash.app and the Applications shortcut, then test dragging the app into Applications and launching the copied app. Check the audio-capture permission flow. When possible, test on another Mac with Gatekeeper quarantine intact. This block creates the DMG directly in the release folder, so there is no separate cp command to stage a second copy.
4. Publish the GitHub release and its DMG#
Open New release:
- Create the tag using the exact pattern
version_build. For version1.2, build6, the tag is1.2_6. - Set the release title to
version (build), such as1.2 (6). - Enter the release notes.
- Attach the exact final
SoundDash.dmgfrom the release folder created in step 3. Its uploaded asset name must remainSoundDash.dmg. - For a production release, choose the normal None release label, not Pre-release. GitHub may not show a separate Latest release choice; it automatically labels the newest eligible published release as latest.
- Publish the release and confirm its tag page shows the notes and the
SoundDash.dmgasset.
If this version/build already has a published GitHub release, open it and verify its existing tag, notes, and asset instead of creating a duplicate. The appcast must use the tag that actually exists.
Publish a test release only when you intend every app that checks the production feed to be offered that build.
5. Generate and sign the appcast#
Update the version and build to match step 3. The tag is derived from them. Replace the sample Markdown between the marked lines with this release’s “What’s New” text, leaving both marker lines in place; the command removes those markers before embedding the notes. This command downloads the current public feed, keeps its earlier entries, signs the final DMG with Sparkle, and embeds the notes in the feed instead of linking to GitHub’s release page. It uses the single release folder created in step 3; the temporary DMG source folder has already been removed.
Run the entire block in Terminal. When macOS asks to unlock the login Keychain for the Sparkle signing key, enter the login Keychain password and choose Allow.
(
set -e
#### CHANGE for every release: use the same Xcode Version value as step 3.
APP_VERSION="2.1"
#### CHANGE for every release: use the same Xcode Build value as step 3.
APP_BUILD="8"
#### CHANGE the Markdown text between the marker lines for every release.
RELEASE_NOTES="$(cat <<'NOTES'
# MODIFY FROM HERE IN MARKDOWN--->
## What’s New in SoundDash 2.1
- Restoring settings now preserves the destination Mac’s master volume and applies only custom per-output volume levels.
# <--- TO HERE
NOTES
)"
RELEASE_NOTES="$(printf '%s\n' "$RELEASE_NOTES" | sed '1d;$d')"
RELEASE_TAG="${APP_VERSION}_${APP_BUILD}"
STAGING_DIR="/Users/marc/Downloads/SoundDash $APP_VERSION Release"
DMG_PATH="$STAGING_DIR/SoundDash.dmg"
APPCAST_URL="https://marcoue.github.io/SoundDash-Support/appcast.xml"
SUPPORT_REPO="https://github.com/marcoue/SoundDash-Support"
#### If Xcode uses a custom Derived Data location, change this to the path in Xcode > Settings > Locations.
DERIVED_DATA="$HOME/Library/Developer/Xcode/DerivedData"
if [ ! -f "$DMG_PATH" ]; then
echo "Final DMG not found: $DMG_PATH"
exit 1
fi
curl -fsSL "$APPCAST_URL" -o "$STAGING_DIR/appcast.xml"
APPCAST_TOOL="$(find "$DERIVED_DATA" \
-path '*/SoundDash-*/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_appcast' \
-type f -print -quit)"
if [ -z "$APPCAST_TOOL" ]; then
echo "Could not find Sparkle's generate_appcast tool under $DERIVED_DATA"
echo "Open the SoundDash project in Xcode and let Swift packages resolve."
exit 1
fi
"$APPCAST_TOOL" \
--maximum-versions 0 \
--download-url-prefix "$SUPPORT_REPO/releases/download/$RELEASE_TAG/" \
--link "$SUPPORT_REPO" \
"$STAGING_DIR"
python3 - "$STAGING_DIR/appcast.xml" "$APP_VERSION" "$APP_BUILD" "$RELEASE_TAG" "$SUPPORT_REPO" "$RELEASE_NOTES" <<'PY'
import sys
import xml.etree.ElementTree as ET
(
appcast_path,
app_version,
app_build,
release_tag,
support_repo,
release_notes_text,
) = sys.argv[1:]
sparkle_ns = "http://www.andymatuschak.org/xml-namespaces/sparkle"
ET.register_namespace("sparkle", sparkle_ns)
ET.register_namespace("dc", "http://purl.org/dc/elements/1.1/")
tree = ET.parse(appcast_path)
channel = tree.getroot().find("channel")
if channel is None:
raise SystemExit("Could not find the appcast channel")
matches = []
for item in channel.findall("item"):
build = item.find(f"{{{sparkle_ns}}}version")
if build is not None and (build.text or "").strip() == app_build:
matches.append(item)
if len(matches) != 1:
raise SystemExit(f"Expected one appcast item for build {app_build}; found {len(matches)}")
item = matches[0]
short_version = item.find(f"{{{sparkle_ns}}}shortVersionString")
if short_version is None or (short_version.text or "").strip() != app_version:
raise SystemExit(f"Appcast item for build {app_build} does not show version {app_version}")
enclosure = item.find("enclosure")
if enclosure is None:
raise SystemExit(f"Appcast item for build {app_build} has no enclosure")
expected_download = f"{support_repo}/releases/download/{release_tag}/SoundDash.dmg"
if enclosure.get("url") != expected_download:
raise SystemExit(
"Wrong DMG URL in appcast. Expected "
f"{expected_download}, found {enclosure.get('url')}"
)
if not enclosure.get(f"{{{sparkle_ns}}}edSignature") or not enclosure.get("length"):
raise SystemExit("Appcast enclosure is missing its Sparkle signature or file length")
# Remove any external release-notes link for this update.
for release_notes_link in item.findall(f"{{{sparkle_ns}}}releaseNotesLink"):
item.remove(release_notes_link)
# Embed the release notes as Markdown in this appcast item.
description = item.find("description")
if description is None:
description = ET.Element("description")
item.insert(list(item).index(enclosure), description)
description.set(f"{{{sparkle_ns}}}format", "markdown")
description.text = release_notes_text.strip()
if hasattr(ET, "indent"):
ET.indent(tree, space=" ")
tree.write(appcast_path, encoding="utf-8", xml_declaration=True)
print(f"Verified version {app_version}, build {app_build}")
print(f"DMG: {expected_download}")
print("Embedded the What's New text as Markdown.")
print(f"Updated {appcast_path}")
PY
)Do not publish if this block reports an error. Inspect the generated file at:
/Users/marc/Downloads/SoundDash 2.0 Release/appcast.xml
Confirm it contains the new version and build, the correct GitHub download URL, embedded Markdown release notes, the DMG signature, and the file length. Do not edit the signature or file length by hand.
6. Publish the appcast#
- Open the
SoundDash-Supportrepository on GitHub and selectappcast.xmlat the root of themainbranch. - Click Edit and replace the entire file with the generated
appcast.xmlfrom the release folder. - Commit the change to
main. - Wait for GitHub Pages to publish the commit, then reload https://marcoue.github.io/SoundDash-Support/appcast.xml.
- Confirm the new entry is visible, the DMG URL uses the exact release tag from step 4, and the What’s New text appears inside its
<description>element. Confirm the DMG link reaches the release asset.
The GitHub release and DMG must be public before the appcast points to them. Keep older appcast entries and release assets so previously installed versions can still update.
7. Test the update#
- Use an installed Sparkle-enabled SoundDash build older than the new build. The 1.0.1 build 2 app does not include Sparkle and cannot update itself.
- Choose Check for Updates… from the SoundDash menu.
- Confirm Sparkle shows the new version and the release notes load.
- Choose Install Update and allow Sparkle to finish its quit/relaunch flow.
- Confirm About SoundDash shows the new version and build. Check audio routing, saved device assignments, and preferences.
If the installed app already has the newest build advertised by the feed, You’re up to date is expected.
One-time setup#
These steps are done once per development Mac or when the relevant credential is renewed.
Developer ID and Hardened Runtime#
- In Xcode → Settings → Accounts → Manage Certificates, confirm a Developer ID Application certificate for the Apple Developer team.
- In the SoundDash target’s Build Settings → Signing – App Sandbox & Hardened Runtime, enable Hardened Runtime for Debug and Release.
- Keep App Sandbox enabled. SoundDash did not require Hardened Runtime exceptions in the tested build.
SoundDash’s bundle ID is com.marcouellet.SoundDash; its Apple Developer team ID is M99GK4VVC8.
Apple notarization credential profile#
If the SoundDash-Notary profile is not already saved in Keychain, create an Apple app-specific password at account.apple.com under Sign-In and Security → App-Specific Passwords, then run:
xcrun notarytool store-credentials "SoundDash-Notary" \
--apple-id "marc.ouellet@gmail.com" \
--team-id "M99GK4VVC8"Enter the app-specific password only when Terminal prompts. Do not put it in this note, a command, or source control. Do not use the normal Apple Account password. The profile is reused for each DMG submission.
Sparkle keys and app settings#
SUFeedURLandSUPublicEDKeyare already configured in the app. Keep them unchanged for routine releases.- The EdDSA private key must stay in the trusted release Mac’s login Keychain. Never put it in Git, the app bundle, a release asset, or a website.
- The public key belongs in the app; it is not the private signing key.
- Sparkle’s key and Apple’s notarization profile are separate credentials.
Why this order matters#
- Xcode exports the signed app; Apple notarizes that app during the Direct Distribution workflow.
- The final DMG is then built, Developer ID signed, submitted to Apple, and stapled. The file customers download is therefore the notarized file.
generate_appcastsigns the exact final DMG with Sparkle’s EdDSA key and records its byte length. Changing or rebuilding the DMG afterward makes the appcast signature or length wrong, so regenerate the appcast from the final DMG.- Publish the GitHub release first so the download and release-notes URLs work before the appcast advertises them.
- The appcast is the update source. Sparkle does not decide update availability from GitHub’s Latest or Pre-release label.
Troubleshooting#
Sparkle offers the update, but release notes fail to load#
Compare the tag in the appcast item’s two URLs with the tag shown on the GitHub release. They must match exactly. For example, a 1.2 build 6 release using tag 1.2_6 must have:
.../releases/tag/1.2_6.../releases/download/1.2_6/SoundDash.dmg
If they do not match, correct APP_VERSION and APP_BUILD in step 5, rerun the generator against the same final DMG, then publish the corrected appcast. The script derives the tag from these two values and checks both URLs before it finishes.
No update is offered#
- Confirm the public appcast contains a build greater than the installed app’s build.
- Confirm the app’s
SUFeedURLis the public feed above. - Confirm the installed copy includes Sparkle. SoundDash 1.0.1 build 2 does not.
- Allow GitHub Pages time to publish after committing the appcast.
Sparkle prints an hdiutil deprecation warning#
Sparkle may print a warning that its internal hdiutil attach invocation is deprecated. If the command continues and reports that it wrote or updated the appcast, the warning is informational; verify the generated appcast and DMG links as described above.
Apple rejects the DMG notarization#
Do not publish a rejected DMG. Use the submission ID reported by notarytool to retrieve Apple’s diagnostic log:
xcrun notarytool log "<submission-id>" \
--keychain-profile "SoundDash-Notary" \
"notary-log.json"Resolve the reported issue, rebuild or re-sign the DMG as needed, submit the final DMG again, staple and validate it, then regenerate the Sparkle appcast from that final file.