Per ADR-0003 as amended by
ADR-0005, releases are ad-hoc-signed,
unnotarized disk images on GitHub Releases, and installed apps auto-update
via Sparkle 2 from an EdDSA-signed appcast (appcast.xml on main) — the
Sparkle signature is the sole trust root for updates.
Pushing a version tag does everything:
git tag v1.0.0
git push origin v1.0.0release.yml then builds and ad-hoc-signs
the app (scripts/release.sh --unsigned), attaches
the DMG to a GitHub Release, EdDSA-signs the DMG with Sparkle's sign_update,
commits the new appcast entry to main
(scripts/update_appcast.py), and updates the
version and DMG checksum in the
angusjune/homebrew-tap cask
(scripts/update_homebrew_cask.py).
The marketing version is the tag without the leading v; the build number
(CFBundleVersion, what Sparkle compares) is the commit count, so it
increases monotonically as long as tags are cut from main.
The keypair already exists and SUPublicEDKey in project.yml is committed;
this is here for reference and for regenerating the private key for CI if it
is ever lost. Download the
Sparkle distribution
(the pipeline pins 2.9.4) and generate the keypair — it is created in your
login keychain, which is its long-term home; guard it well, updates cannot be
signed without it:
./bin/generate_keys # prints the public key
./bin/generate_keys -x sparkle-private.key # exports the private key for CI
gh secret set SPARKLE_PRIVATE_KEY < sparkle-private.key
rm sparkle-private.keyPaste the printed public key into SUPublicEDKey in project.yml and commit.
scripts/release.sh refuses to cut a release while SUPublicEDKey is empty,
because shipped apps could never verify an update. Rotating the key strands
already-shipped apps (they verify updates against the old public key) —
only do this if the key is compromised, and read Sparkle's key-rotation
guidance first.
The release workflow checks out angusjune/homebrew-tap with a write-enabled
SSH deploy key and commits the new cask version and checksum directly to its
main branch. A deploy key is scoped to that repository, so the release job
does not need a personal access token with access to unrelated repositories.
Generate a dedicated keypair, add its public half to the tap with write access, and store its private half as a secret in this repository:
ssh-keygen -t ed25519 -N "" \
-C "3MFQuickLook release workflow" \
-f homebrew-tap-deploy-key
gh api --method POST repos/angusjune/homebrew-tap/keys \
-f title="3MFQuickLook release workflow" \
-f key="$(cat homebrew-tap-deploy-key.pub)" \
-F read_only=false
gh secret set HOMEBREW_TAP_DEPLOY_KEY < homebrew-tap-deploy-key
rm homebrew-tap-deploy-key homebrew-tap-deploy-key.pubRotating the deploy key does not affect installed apps. Add the replacement
public key and update HOMEBREW_TAP_DEPLOY_KEY before removing the old key, so
releases remain uninterrupted.
| Secret | Contents |
|---|---|
HOMEBREW_TAP_DEPLOY_KEY |
Private SSH deploy key with write access to angusjune/homebrew-tap |
SPARKLE_PRIVATE_KEY |
Sparkle EdDSA private key (generate_keys -x) |
The workflow fails fast, before building, if either required secret is missing.
- Add a LICENSE (ADR-0003 says permissive) — MIT, in
LICENSE. - Add the Homebrew tap deploy key and
HOMEBREW_TAP_DEPLOY_KEYsecret. - Set the
SPARKLE_PRIVATE_KEYsecret andSUPublicEDKey(above) — both done.
CI verifies the ad-hoc code signature (codesign --verify --deep --strict)
for the app. The following need a human and real artifacts:
- Gatekeeper on a clean account — on a machine (or fresh user account) that never built the project: download the DMG in a browser (so it gets the quarantine flag), open it, drag the app to Applications, launch it. The app is unnotarized, so macOS blocks the first launch with a "[app] Not Opened" dialog — this is expected. Verify the documented recovery works and matches the README: System Settings → Privacy & Security → scroll down → "Open Anyway" → confirm. Subsequent launches are unaffected.
- Extensions in the release build — with the release app in
/Applicationsand launched once: Finder shows thumbnails for.3mffiles, and Space opens the interactive preview. (The sandboxed extensions run identically under ad-hoc signing, but smoke-test the actual artifact.) - Sparkle end-to-end (once, with the first two real releases) — install release N, then tag release N+1. After the workflow finishes, open the installed app and use "3MF QuickLook → Check for Updates…": it should find, download, verify, and install N+1 through Sparkle's standard flow.
- Homebrew cask — after the workflow finishes, run
brew update && brew info --cask angusjune/tap/3mf-quicklook. The reported version should match the release tag. A freshbrew install --cask angusjune/tap/3mf-quicklookshould verify the checksum and install the same DMG attached to the release.
When the project has a paid Apple Developer Program membership (ADR-0005), flip back to Developer ID signing + notarization:
-
Set these four repository secrets:
You need a Developer ID Application certificate in your Apple Developer account (Certificates → create → "Developer ID Application"; generate it in Keychain Access via a certificate signing request so the private key lands in your keychain).
Export it: Keychain Access → My Certificates → right-click the certificate → Export as
.p12with a password. Then:base64 -i DeveloperID.p12 | gh secret set DEVELOPER_ID_CERT_P12 gh secret set DEVELOPER_ID_CERT_PASSWORD # paste the .p12 password
App Store Connect → Users and Access → Integrations → App Store Connect API → Team Keys → generate a key with the Developer role. Note the Key ID and the Issuer ID shown on that page, and download the
.p8file (downloadable only once).gh secret set NOTARY_KEY_ID # the key ID, e.g. ABC123DEF4 gh secret set NOTARY_ISSUER_ID # the issuer UUID gh secret set NOTARY_PRIVATE_KEY < AuthKey_ABC123DEF4.p8
Secret Contents DEVELOPER_ID_CERT_P12base64 of the Developer ID Application .p12(cert + private key)DEVELOPER_ID_CERT_PASSWORDpassword protecting the .p12NOTARY_KEY_IDApp Store Connect API key ID NOTARY_ISSUER_IDApp Store Connect issuer UUID NOTARY_PRIVATE_KEYcontents of the App Store Connect .p8API key -
Restore the three workflow steps that import the certificate, write the notarization API key, and clean up the signing keychain — they're removed from
release.ymlas of the unsigned-distribution commit but still exist in git history (git log -- .github/workflows/release.yml) to copy back. -
Drop
--unsignedfrom thescripts/release.shinvocation inrelease.yml— the no-flag default is the preserved Developer ID + notarization path, byte-for-byte the same as before ADR-0005.
The Sparkle key does not change: it was never tied to Apple signing, so already-installed apps keep updating seamlessly across the transition.
The DMG window is laid out by dmgbuild
from packaging/dmg_settings.py. It writes the window's .DS_Store directly
rather than driving Finder over AppleScript — the AppleScript approach needs a
UI session plus TCC automation consent, and is the usual reason styled DMGs are
flaky on hosted runners. CI installs it into a venv (the runner's Python is
externally managed, PEP 668); locally, python3 -m pip install dmgbuild.
release.sh fails fast if it is missing rather than quietly falling back to a
plain hdiutil DMG.
Both the app icon and the DMG backdrop are generated by
swift scripts/generate_art.swift, which draws them with CoreGraphics and
writes App/Assets.xcassets/AppIcon.appiconset/ and packaging/dmg-background*.png.
The outputs are committed so builds stay hermetic — CI never runs the
generator. Re-run it after editing the script and commit the result. The
DMG's volume icon is built at package time by iconutil straight from the
appiconset, whose filenames already follow the .iconset convention.
Icon coordinates in dmg_settings.py and the backdrop drawn by
generate_art.swift must stay in sync — they are two halves of one layout.
Sparkle and ZIPFoundation are MIT-family licenses requiring their copyright
notice to ship "in all copies or substantial portions" — an obligation that
attaches to the distributed DMG, not just to the source. THIRD-PARTY-LICENSES.md
reproduces both texts verbatim and is bundled into
Contents/Resources/THIRD-PARTY-LICENSES.md (wired up in project.yml), so
the notices travel with the binary.
After bumping or adding any package, re-run scripts/collect_licenses.sh
and commit the result; --check exits non-zero when the file is stale. The
script reads the resolved SPM checkouts under build/SourcePackages, so build
once first. It fails rather than skipping when a dependency has no
discoverable license file — adding a package cannot silently ship it
unattributed.
- Re-running a failed release is safe:
update_appcast.pyrefuses duplicate build numbers,gh release createfails if the release exists (delete the partial release first:gh release delete vX.Y.Z). - Local dry run of the packaging pipeline (no credentials, no signing):
scripts/release.sh --dry-run --version 0.0.1produces an unsigned DMG indist/, for pipeline verification only.scripts/release.sh --unsigned --version 0.0.1produces a real, ad-hoc-signed release artifact — the same thing CI ships. - Release notes are auto-generated from PRs/commits by
gh release create --generate-notes; edit the release afterwards if wanted. The appcast links to the release page rather than embedding notes. - Homebrew releases are pinned: the release job computes the SHA-256 from
the built DMG and updates the cask only after the GitHub Release and Sparkle
appcast have been published.
brew install --cask angusjune/tap/3mf-quicklookthen installs that exact release. - Rotating the Sparkle key effectively strands shipped apps (they verify updates against the old public key) — don't, unless compromised, and then read Sparkle's key-rotation guidance first.