Syncs across the Macs signed in to one iCloud account through CKSyncEngine on the private database, with a switch per category in Settings → iCloud Sync. Every user field is stored in encryptedValues. - Features/Sync: a pure ledger decides sends, deletes and holds; a merge policy picks the newer edit; CloudSyncSchema binds each record kind to its store, reusing the settings.json bindings so a setting has one spelling. - A record this Mac can't take (an app it lacks, a chord it has bound) is held and retried, never deleted, so a missing app costs no other Mac. - Consent flags have no SettingsFileKey, so they never sync; the sync switches are excluded from backups. - Releases sign with Developer ID and embed the channel's provisioning profile, which grants the restricted iCloud entitlements. Debug and self-signed builds show the pane as unavailable.
7.1 KiB
Release
How a build reaches a user. The local development loop is in development.md; the signing identity itself is in signing.md.
Packaging a DMG locally
./Scripts/build-dmg.sh # -> build/Tinycast-<version>.dmg (version from project.yml)
./Scripts/build-dmg.sh 0.5.7 # -> build/Tinycast-0.5.7.dmg
It builds a Release Tinycast.app signed with Tinycast Self-Signed and packs it with an
/Applications symlink. Without the team's profile it can't use iCloud, so its sync pane stays off.
Official per-channel releases are built by CI, below.
Signing & Gatekeeper
Local builds sign with the stable Tinycast Self-Signed identity; CI releases sign with the team's
Developer ID and embed the channel's provisioning profile, which is what grants iCloud sync. Releases
are not notarized yet, so macOS quarantines a directly-downloaded DMG. The Homebrew cask strips that
automatically; direct downloaders run xattr -dr com.apple.quarantine "…/Tinycast.app" once. Full
details in signing.md.
How the in-app updater consumes a release
Every release publishes two assets from one build: Tinycast-<version>.dmg, which people download by
hand and which the cask installs, and Tinycast-<version>.zip, which the in-app updater installs. The
zip is produced with ditto -c -k --keepParent --sequesterRsrc — the only zip that leaves the code
signature verifiable, which matters because the updater refuses any bundle whose signature does not
prove it is ours.
A stable release publishes two more from the universal job, Tinycast-Universal-<version>.dmg and
.zip, built from the same commit at the same version and bundle id but with both slices. They are
uploaded after the thin pair, which keeps the thin zip first in the asset list so builds predating
architecture-aware selection keep choosing it.
Three things a release must keep true, or the updater skips it:
- It carries a
.zipasset this Mac can run. A DMG-only release is not installable and is not offered, and an Intel build is offered nothing rather than a thin arm64 zip. - The tag parses as
vMAJOR.MINOR.PATCHorvMAJOR.MINOR.PATCH-beta.N, and agrees with theprereleaseflag. A tag of any other shape is treated as mis-published and skipped. - It is not a draft.
Both casks declare auto_updates true. That is Homebrew's flag for an app that manages its own
version, and it is what keeps brew update && brew upgrade from fighting an app that updated itself:
brew never reports Tinycast outdated, never re-downloads it, and never rolls a self-updated copy back.
Removing that line would reintroduce exactly those three problems. See
features/updates.md.
Pull request review
There is no CI workflow. CodeRabbit reviews every PR against .coderabbit.yaml: it runs SwiftLint
with .swiftlint.yml, annotates the diff and applies the pre-merge checks. It is a reviewer, not a
gate — it neither runs the harnesses nor builds the app, so the whole bar in
testing.md is run locally before a PR is opened.
Releasing
.github/workflows/release.yml builds and publishes a DMG from GitHub Actions, no local machine
needed. Run it from the Actions tab (Release → Run workflow) and pick:
- channel —
betaorstable. Each builds a distinct app (Tinycast Beta.app/Tinycast.app) with its own bundle id, alongside the localTinycast Dev.app. Beta gets an auto-incrementing-beta.Nsuffix (N= the Actions run number) so re-running never collides; stable ships the version as-is. - version — base semver, e.g.
0.2.0.
It builds on a macos-26 runner with Xcode 26 and publishes a GitHub Release tagged
v<full-version> with a versioned DMG and zip asset, marked prerelease for beta. On success it also
bumps the matching cask in the tap and announces the release on Discord.
A release that changes Scripts/cloudkit/schema.ckdb needs the schema in each container's
Production environment first (features/icloud-sync.md): an older
schema refuses the new records, and sync fails on every Mac that updates.
A stable run then fans out to a second job, universal, which rebuilds the same commit with
ARCHS="arm64 x86_64" and attaches Tinycast-Universal-<version>.dmg / .zip to the release the
first job created, then bumps tinycast-universal. macOS 26 is the last release that boots on Intel,
and those Macs need both slices. Both jobs pin ARCHS explicitly and assert the slices on every
shipping binary — the app and the bundled ClipboardTextHelper: trusting ARCHS_STANDARD is what
shipped a thin arm64 build to Intel users once already, and it also keeps the Apple silicon download
from silently gaining a slice it never needs. A thin helper inside a universal app is the quiet form
of the same bug: the app boots on Intel and only clipboard OCR stops working.
Release notes
Scripts/release-notes.sh composes the release body, and CI runs it just before gh release create.
It is safe to run by hand against any tag — it only reads:
CHANNEL=beta TAG=v0.9.13-beta.61 ./Scripts/release-notes.sh /tmp/body.md /tmp/discord.md
The changelog itself comes from GitHub's own release-notes API, which lists every merged PR with its
author and number — so contributors are credited without anyone maintaining a CHANGELOG.md, and
without Conventional Commits. Nothing is ever committed to this repo: the tag is created
server-side by gh release create, and no release, bot or version-bump commit exists.
Two details the script exists for:
- The previous tag is picked per channel. Beta and stable tags interleave on
main— the same commit can carry both — so "the previous release" is only ever right within one channel. A stable release therefore spans every beta since the last stable. - The body is split by
<!-- tinycast:install -->. Everything above it is the changelog; everything below is the Homebrew and quarantine text, which only a download page needs. The update window cuts at that marker — see features/updates.md. Full PR URLs are shortened to#304, which still autolinks on the web and fits a 460pt window.
The Discord announcement carries the same changelog, truncated to fit Discord's component limit, and
pings @everyone.
Homebrew tap automation
Each job's final step rewrites the version + sha256 of its cask (tinycast, tinycast@beta or
tinycast-universal) in the homebrew-tinycast tap
and pushes. It needs a HOMEBREW_TAP_TOKEN repo secret — a fine-grained PAT with Contents:
read/write on the tap repo. Without the secret the step logs a warning and skips; the release still
publishes. The sed is anchored to ^ version / ^ sha256, so a cask's two-space indent on those
lines is load-bearing.
Both stable casks install Tinycast.app under com.tinycast.app, so they conflicts_with one
another and Homebrew routes each Mac by depends_on: tinycast requires arch: :arm64, and
tinycast-universal takes the Intel Macs.