Skip to content

Latest commit

 

History

History
234 lines (189 loc) · 11 KB

File metadata and controls

234 lines (189 loc) · 11 KB

Releasing 3MF QuickLook

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.0

release.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.

One-time setup

Sparkle EdDSA update-signing key

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.key

Paste 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.

Homebrew tap deploy key

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.pub

Rotating 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.

Secrets reference

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.

Before the repository goes public

  • Add a LICENSE (ADR-0003 says permissive) — MIT, in LICENSE.
  • Add the Homebrew tap deploy key and HOMEBREW_TAP_DEPLOY_KEY secret.
  • Set the SPARKLE_PRIVATE_KEY secret and SUPublicEDKey (above) — both done.

Release verification checklist

CI verifies the ad-hoc code signature (codesign --verify --deep --strict) for the app. The following need a human and real artifacts:

  1. 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.
  2. Extensions in the release build — with the release app in /Applications and launched once: Finder shows thumbnails for .3mf files, and Space opens the interactive preview. (The sandboxed extensions run identically under ad-hoc signing, but smoke-test the actual artifact.)
  3. 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.
  4. 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 fresh brew install --cask angusjune/tap/3mf-quicklook should verify the checksum and install the same DMG attached to the release.

Upgrading to notarized releases later

When the project has a paid Apple Developer Program membership (ADR-0005), flip back to Developer ID signing + notarization:

  1. Set these four repository secrets:

    1. Developer ID certificate

    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 .p12 with 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

    2. Notarization API key

    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 .p8 file (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_P12 base64 of the Developer ID Application .p12 (cert + private key)
    DEVELOPER_ID_CERT_PASSWORD password protecting the .p12
    NOTARY_KEY_ID App Store Connect API key ID
    NOTARY_ISSUER_ID App Store Connect issuer UUID
    NOTARY_PRIVATE_KEY contents of the App Store Connect .p8 API key
  2. 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.yml as of the unsigned-distribution commit but still exist in git history (git log -- .github/workflows/release.yml) to copy back.

  3. Drop --unsigned from the scripts/release.sh invocation in release.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.

Packaging and artwork

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.

Third-party attribution

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.

Odds and ends

  • Re-running a failed release is safe: update_appcast.py refuses duplicate build numbers, gh release create fails 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.1 produces an unsigned DMG in dist/, for pipeline verification only. scripts/release.sh --unsigned --version 0.0.1 produces 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-quicklook then 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.