| 1 | # Oficina Mac updates |
| 2 | |
| 3 | Meet, Calendar, Memoria, Docs and Sheets share `RastrilloMacUpdates`, a macOS-only |
| 4 | Sparkle 2.9.6 integration. Each app owns one updater and puts Check for Updates |
| 5 | and the automatic-check preference in its application menu. By default Sparkle asks |
| 6 | before enabling automatic checks, and installation uses the standard |
| 7 | confirmation/relaunch flow and the app's normal quit handling. iOS uses Apple's |
| 8 | distribution mechanisms. |
| 9 | |
| 10 | ## Quiet updates (opt-in) |
| 11 | |
| 12 | An app that must never be interrupted (Calls, during a call) opts into quiet |
| 13 | updates in two places, and needs both: |
| 14 | |
| 15 | - `NativeUpdater(policy: .quiet)` in code. A scheduled check never opens a window. |
| 16 | A staged update waits for quit, and `pendingUpdate` becomes `.ready`. The app |
| 17 | menu gains "Update Ready — Restart <App>", and `NativeUpdateNotice` is a one-line |
| 18 | notice for the app's home screen. If the person has turned automatic downloads |
| 19 | off, a found update becomes `.available` and the notice offers it. Set |
| 20 | `isBusy` while the app must not be disturbed. Scheduled checks are then declined |
| 21 | and rerun once it clears, and the restart action is disabled. A declined check |
| 22 | also means no download during a call. Check for Updates always shows Sparkle's |
| 23 | standard UI, because the person asked for it. It is disabled while a staged |
| 24 | update waits: the Restart item replaces it. |
| 25 | - `"install": "automatic"` in `mac-updates.json`. Packaging then writes |
| 26 | `SUAllowsAutomaticUpdates`, `SUEnableAutomaticChecks`, `SUAutomaticallyUpdate` |
| 27 | and a daily `SUScheduledCheckInterval` into Info.plist. There is no first-launch |
| 28 | permission question. These are defaults, so a person's own choice (the menu |
| 29 | toggle, or `automaticallyInstallsUpdates` in the app's settings) is stored in |
| 30 | their preferences and wins. The default, `"prompt"`, is the behaviour above, |
| 31 | and release and publication reject an archive whose Info.plist disagrees with |
| 32 | its configuration's mode. |
| 33 | |
| 34 | The installed build decides how an update arrives. A person running a build |
| 35 | from before quiet updates sees Sparkle's usual prompts once more, for the update |
| 36 | that brings quiet updates. |
| 37 | |
| 38 | The delegate methods are optional Objective-C selectors. A drifted Swift |
| 39 | signature still compiles but is never called, and the app is back to Sparkle's |
| 40 | alerts. `QuietUpdaterTests.testDelegateAnswersSparklesSelectors` pins them. |
| 41 | |
| 42 | Unconfigured development builds disable update controls. A release must pass its |
| 43 | app-specific `native/mac-updates.json` to packaging. These files contain only the |
| 44 | public Ed25519 key, Keychain account name, bundle ID and HTTPS appcast URL. The |
| 45 | private Sparkle key stays in the login Keychain under the `oficina` account; it is |
| 46 | separate from Apple signing and notarization credentials. Keep that key for future |
| 47 | updates; rotating it requires a deliberate Sparkle key migration. |
| 48 | |
| 49 | ## Build and notarize |
| 50 | |
| 51 | Use Team Tito Limited's Developer ID Application certificate, team `9U4A84C4WK`. |
| 52 | The existing `slopbox-notary` Keychain profile authenticated successfully during |
| 53 | setup; successful notarization of the actual release is the final validation. |
| 54 | One team profile is reusable across the five apps. No credentials belong in Git. |
| 55 | |
| 56 | For the four standard apps, from their `native` directory: |
| 57 | |
| 58 | ```sh |
| 59 | scripts/build-mac.sh --configuration release --updates-config mac-updates.json \ |
| 60 | --version 0.1.0 --build 2 \ |
| 61 | --sign-identity 'Developer ID Application: Team Tito Limited (9U4A84C4WK)' |
| 62 | ``` |
| 63 | |
| 64 | Meet uses `OFICINA_BUILD_CONFIGURATION=release` instead of `--configuration`. |
| 65 | `OFICINA_NATIVE_PACKAGE_PATH` can select an unpublished local shared-library |
| 66 | checkout for development; omit it to use the reviewed pinned dependency. |
| 67 | |
| 68 | `tools/release_mac.py` takes the built app, `--config`, `--sparkle-tools` (the |
| 69 | Sparkle SwiftPM artifact's `bin` directory), `--identity`, `--team`, |
| 70 | `--notary-profile` and a new `--output` directory. It copies the app, signs helpers |
| 71 | and frameworks before the host, verifies the selected Developer ID team, |
| 72 | notarizes and staples the app, checks Gatekeeper, then creates and EdDSA-signs the |
| 73 | final ZIP. Meet receives camera and audio-input hardened-runtime entitlements. |
| 74 | The result contains the ZIP and a `release.json` audit manifest. Nothing is |
| 75 | installed or published by preparation. |
| 76 | |
| 77 | The initial binaries built here target Apple silicon. `generate_appcast` reads |
| 78 | minimum macOS and architecture requirements from the archive, so incompatible |
| 79 | machines are not offered these updates. Docs and Sheets require macOS 26; |
| 80 | Calendar, Memoria and Meet require macOS 14. |
| 81 | |
| 82 | ## Amadan hosting |
| 83 | |
| 84 | Use a dedicated **public, artifacts-only** repository `oficina/native-releases`. |
| 85 | Do not change the visibility of any application source repository. Initialize its |
| 86 | `main` branch and clone it before publishing. The proposed feeds are: |
| 87 | |
| 88 | ``` |
| 89 | https://raw.amadan.net/oficina/native-releases/main/<app>/appcast.xml |
| 90 | ``` |
| 91 | |
| 92 | Git holds the feeds and nothing else. Each archive is an Amadan release asset, |
| 93 | stored in object storage, on a release tagged `<app>-<build>`: |
| 94 | |
| 95 | ``` |
| 96 | https://amadan.net/oficina/native-releases/-/releases/<app>-<build>/<archive>.zip |
| 97 | ``` |
| 98 | |
| 99 | That URL redirects to a short-lived signed download, which Sparkle follows. |
| 100 | Never commit an archive: every zip in Git stays in history for good and is |
| 101 | copied by every clone. On 2026-10-26 a repack of about 1.1 GB of them took |
| 102 | amadan.net down for ten minutes (`oficina/native-releases#1`). Assets can be up |
| 103 | to 500 MB each, and `release_mac.py` still caps archives at 100 MiB. |
| 104 | |
| 105 | Run `tools/publish_mac_update.py RELEASE --config CONFIG --repo ARTIFACT_CLONE |
| 106 | --sparkle-tools TOOLS` to validate and print a plan. Add `--publish` only when the |
| 107 | concrete release is ready to publish. It verifies the archive signature, actual |
| 108 | archived app metadata, manifest, Keychain public key, monotonic build number, |
| 109 | clean main checkout and correct origin. It pushes the release tag and uploads the |
| 110 | archive with `amadan release create`. Then it downloads the archive anonymously |
| 111 | and checks its length and SHA-256. Only after that does it generate and push the |
| 112 | appcast, using Sparkle's official tool. The generated version must match the |
| 113 | signed app. Existing feed items are preserved; deltas are disabled to keep |
| 114 | upload sizes bounded. |
| 115 | |
| 116 | `amadan release create` needs writer access to the artifact repository. Set |
| 117 | `AMADAN_HOME` to the publishing account's credentials (an agent publishes as its |
| 118 | agent account), and use a Git credential for that same account to push the tag |
| 119 | and feed. |
| 120 | |
| 121 | If publication stops before the feed commit, the old feed stays usable. Rerun the |
| 122 | same command. The tag is reused, and an asset cannot be replaced, so the second |
| 123 | upload is refused. The publisher accepts that refusal only when the public |
| 124 | download already matches the signed archive, and then publishes the feed. A |
| 125 | different archive for a build already uploaded needs a new build number. If it |
| 126 | stopped while writing the feed, before its commit, discard the half-written feed |
| 127 | and rerun. The publisher only starts on a clean checkout, so this removes nothing |
| 128 | else: `git reset --hard HEAD`, then `git clean -f -- <app>/appcast.xml` for an |
| 129 | app's first feed. If only the final feed push failed, the feed commit is already |
| 130 | verified. Push it with `git push origin HEAD:refs/heads/main`, and do not rerun, |
| 131 | because a rerun refuses a build the local feed already lists. |
| 132 | |
| 133 | The hub does not yet make an asset's bytes atomic against two simultaneous |
| 134 | uploads of the same name. Only one publisher may run for a build at a time. |
| 135 | Sparkle still checks each download's EdDSA signature, so replaced bytes fail |
| 136 | to install rather than installing. |
| 137 | |
| 138 | Older feed items point at zips committed before this change, by full commit SHA |
| 139 | on `raw.amadan.net`. They keep working. Removing those zips from history is a |
| 140 | separate decision, for the repository's owner. |
| 141 | |
| 142 | Users of pre-Sparkle builds need one initial installation of a Sparkle-enabled |
| 143 | release. Subsequent releases can update in place. The first publication should |
| 144 | include a real update/relaunch check before broad distribution. |
| 145 | |
| 146 | ## Validation |
| 147 | |
| 148 | `make ci` includes Swift tests, Mac/iOS companion builds, Go race tests, relocated |
| 149 | resource packaging and release-boundary tests. Publication tests check that |
| 150 | metadata tampering, archive tampering, failed public download, a refused upload |
| 151 | without a matching asset and generated version mismatch cannot publish a feed. |
| 152 | They also check that no archive is ever added to Git, and that the archive is |
| 153 | uploaded and verified between the tag push and the feed push. Independent mutation probes confirmed these |
| 154 | checks detect removal of the corresponding guards. |
| 155 | |