This runbook covers the macOS app, its registry plugins and the static libraries; the iOS app ships by hand. The release skill (.claude/skills/release/SKILL.md) decides what ships and points at the sections here. Every command below is the maintainer's to run.
- Stable and beta share one appcast,
https://updates.tablepro.app/appcast.xml. A beta item carries<sparkle:channel>beta</sparkle:channel>. - The app takes betas only while Settings > General > Get beta updates is on (
allowedChannels(for:)). The key,com.TablePro.updates.receivesBetaUpdates, can be pinned by a configuration profile (Managed updates). - Sparkle never downgrades: with the toggle off, a beta install keeps its beta until a stable passes it. So a beta never writes storage the previous stable cannot read (
.claude/rules/data-sync-storage.md). - A stable rolls out in phases of 21600 s. A beta is not phased, and on GitHub it is a prerelease. Latest goes only to the highest stable, so a patch on an older line publishes with
--latest=false. - Critical means a security fix or data loss, nothing else. It is
[CRITICAL]on the version'sCHANGELOG.mdheading, never on a beta. Every later stable item carries the highest critical build forward (sparkle:criticalUpdate sparkle:version). - CI writes the orphan
feedsbranch, which holdsappcast.xmland the v3 plugin manifest (https://updates.tablepro.app/plugins/v3.json), andpages.ymldeploys it toupdates.tablepro.app. The appcast is EdDSA-signed, and the app requires that (SURequireSignedFeed,SUVerifyUpdateBeforeExtraction). - The legacy
appcast.xmlonmainis theSUFeedURLof apps up to 0.81 (https://raw.githubusercontent.com/TableProApp/TablePro/main/appcast.xml), andplugins.jsonin TableProApp/plugins is their registry. No tag writes either. The tablepro.app download button and the Homebrew casktableproread the same appcast. v0.82.0 is the one bridge. Once it publishes, TableProApp/web moves to the new feed. Then a PR copies the v0.82.0 feed item verbatim into the legacyappcast.xml, a PR in TableProApp/plugins pointsplugins.jsonat its plugin zips, and a Homebrew/homebrew-cask PR movestableprotoTablePro-#{version}.dmg, with livecheck on the new feed's items that have no channel. Both files then freeze.
- A tag is
vX.Y.ZorvX.Y.0-beta.N, N from 1 to 98. There is no-rc. MARKETING_VERSIONis the tag without thev. The build,CURRENT_PROJECT_VERSION, is((MAJOR*1000+MINOR)*100+PATCH)*100+R, where R is N for a beta and 99 for a stable:v0.82.0-beta.1is 820001,v0.82.0is 820099,v0.82.1is 820199. MINOR stays at most 999 and PATCH at most 99.- A local build is
0.0.0-dev, build 1 (project.yml,settings.base).scripts/release/version.py parse <tag>prints a tag's version, build, channel and line. Every "is newer" check, in the app and in the plugin manifest, compares the build (CFBundleVersion). - Every X.Y.0 ships at least one published
vX.Y.0-beta.Nfirst; patches go straight to stable. Promote the newest beta when it has no openregressionissue. - One universal build, arm64 and x86_64. It becomes arm64-only once the deployment target reaches macOS 27.
- An admin cuts
release/<X.Y>from a greenmaincommit at beta.1. Only admins createrelease/*branches. - Every fix lands on
mainfirst, then reaches the line through a cherry-pick PR frombackport/<PR>-<X.Y>: one fix per PR, titled with the original subject and its(#N), which is the provenance. A release PR comes fromprep/v<X.Y.Z>. No PR head branch lives underrelease/*. - A line takes patches until
X.(Y+1).0ships, then only security or data-loss fixes. At most two lines are live. - After a stable or patch publishes, a PR with the same title cherry-picks its release commit to
main, and to a newer open line when one exists. The next tag refuses to build until it has.
- Fragments in
changelog.d/(.claude/rules/changelog.md) become aCHANGELOG.mdsection. The tooling needs towncrier:python3 -m venv .venv && .venv/bin/pip install --require-hashes -r scripts/release/requirements.txt. .venv/bin/python scripts/release/notes.py assemble X.Y.Z [--date YYYY-MM-DD]runs onprep/v<X.Y.Z>. It refuses a beta, an emptychangelog.d/, and a versionCHANGELOG.mdalready has. It credits each fragmentby @loginwhen its PR's author is not an OWNER or MEMBER (read withgh), then runstowncrier build, which deletes the fragments and stagesCHANGELOG.md.- The lead: X.Y.0 only, at most 6 plain lines, typed under the heading in the release PR, naming what a reader would change their behavior over. With a lead, What's New, the Sparkle notes and the Telegram post show only the lead. Without one, the Sparkle notes carry the whole section and What's New points at the changelog.
- A beta gets no section. Its GitHub release body and feed notes are a towncrier draft of the fragments at the tag.
beta.1 cuts the line at the newest main commit whose push runs are all green; beta.N tags the line's head once the picks are on it.
git fetch origin
git push origin <sha>:refs/heads/release/X.Y # beta.1 only
gh label create "backport release/X.Y" # beta.1 only
git tag -s vX.Y.0-beta.N -m vX.Y.0-beta.N origin/release/X.Y
git push origin vX.Y.0-beta.Ngit fetch origin
git switch -c prep/vX.Y.Z origin/release/X.Y
.venv/bin/python scripts/release/notes.py assemble X.Y.Z
# X.Y.0: type the lead under the heading; critical: append ' [CRITICAL]' to it
git add CHANGELOG.md
git commit -m "chore(release): vX.Y.Z"
git push -u origin prep/vX.Y.Z
gh pr create --base release/X.Y --title "chore(release): vX.Y.Z" --body "Release notes for vX.Y.Z."The PR changes only CHANGELOG.md and changelog.d/; PR title refuses anything else. Squash-merge it, then tag its merge commit:
git fetch origin
git tag -s vX.Y.Z -m vX.Y.Z "$(gh pr view <PR> --json mergeCommit -q .mergeCommit.oid)"
git push origin vX.Y.ZAfter the run publishes, forward-port the release commit, then write the docs changelog:
git fetch origin
git switch -c forward/vX.Y.Z origin/main
git cherry-pick <release commit>
git push -u origin forward/vX.Y.Z
gh pr create --base main --title "chore(release): vX.Y.Z" --body "Forward-port of #<PR>."
python3 scripts/release/notes.py docs X.Y.Z --ref vX.Y.Z --mdx ../tablepro-docs/changelog.mdx- A cherry-pick conflict on a fragment reworded on
main: delete the fragment; onCHANGELOG.md: keep both sections, in version order. The docs PR isdocs(changelog): vX.Y.Zin TableProApp/docs, opened with the maintainer's owngh(a PR opened withGITHUB_TOKENstarts no workflows) and merged once the release is published.
A v* tag runs release.yml and nothing else. A run is never cancelled, and every job reads the version, build and channel from verify.
verify:version.py checkrefuses a tag that breaks the grammar or the bounds; thatorigin/release/<X.Y>does not contain; whose previous tag on the line is not its ancestor; a stable X.Y.0 with no published beta, or a beta after its X.Y.0; aCHANGELOG.mdmissing the stable's heading, or a published stable of this or the previous line below it. Then it needs macOS Tests, iOS and Package Tests and Release Build green on the tag commit or the nearest first-parent commit that ran them, waiting on a pending run. Last, it writes the notes.app(signing): a GA Xcode for a stable (setup-xcoderequire-release), the CloudKit Production schema check,build.sh app(archive, export withscripts/release/ExportOptions.plist, universal and plugin load checks),notarize.shon the app,package.sh app,notarize.shon the DMG.plugins(signing): the Dameng and HANA bridges,build.sh plugins(each targetregistry.py targetsprints, universal, signed with Developer ID, the hardened runtime and a timestamp), one notary submission for all,package.sh plugins(the zips, described as the manifest will read them).github-release:publish.py draftputs every asset on a draft and checks each digest,attest-build-provenanceattests them,publish.py releasepublishes.feed(release):feed.py adddownloads the archive and the zips from the release, checks their digests, runsgenerate_appcastandregistry.py add, runsfeed.py check, and pushesfeeds, retrying from a fresh fetch up to 3 times.pagesdeploysfeeds.notify, stable only and allowed to fail, posts the lead to Telegram.- Assets:
TablePro-<v>.dmg,TablePro-<v>.tar.xz(the Sparkle archive),TablePro-<v>-dSYMs.zip,TablePro-<v>-Plugins-dSYMs.zipand one<Target>-<v>.zipper registry plugin.
- Tags never move. Before
github-releasepublishes, an infrastructure failure is a re-run of the failed jobs; a code failure is fixed onmain, picked to the line, and ships as the next number. - After it publishes: Re-run failed jobs. Every later step skips what is done: a published release, or a feed item or manifest release with the same build and sha256.
- A stable that never published: the next release PR runs
git revert <its release commit>beforenotes.py assemble, and the leftover draft goes (gh release delete vX.Y.Z). - No GA Xcode on the runner image fails a stable in
app. The fix is a PR pointing.github/actions/setup-xcodeat a GA Xcode on an image that has one. Betas still ship.
gh workflow run withdraw.yml --ref v<newest tag> -f version=X.Y.Z- It removes the version's feed items and plugin releases, re-signs the feed, marks the highest stable left as Latest, and redeploys. It refuses to leave the feed with no stable item. The
--refis a tag because thereleaseenvironment allows onlyv*tags. - The GitHub release stays. The fix ships as the next number, critical when the bad build loses data.
| Environment | Refs | Secrets |
|---|---|---|
signing |
tags v* |
CERTIFICATES_P12, CERTIFICATES_PASSWORD, PROVISIONING_PROFILE, ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8, ANALYTICS_HMAC_SECRET, CLOUDKIT_MANAGEMENT_TOKEN |
release |
tags v* |
SPARKLE_PRIVATE_KEY, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_TOPIC_ID |
github-pages |
tags v* |
none |
- No repo-level secret or deploy key is left, and no environment has required reviewers: admin-only, immutable tags are the gate. Rulesets:
.claude/rules/ci.md, Rulesets. - Notarization uses the App Store Connect API key, which
.github/actions/apple-signingstores as theTablePronotarytool profile. The action warns when the certificate expires within 60 days. - An offline backup of the Sparkle EdDSA key exists and the maintainer holds it. The key never rotates without a signed transition, since every app from 0.82 verifies the feed with it.
feedstakes pushes fromgithub-actions[bot]withGITHUB_TOKENand has no required checks; the signed feed proves its content. It was seeded once with the legacyappcast.xmland an empty v3 manifest.
- One tag builds the app and every registry plugin, and the plugin zips are assets of the app's release. Each plugin's version and build are the app's. There are no plugin tags.
.github/plugin-registry.jsonholdsretainFromPluginKitand the publish set, keyed by target, with each plugin'snameandiconName. Bundled plugins are not in it.- The v3 manifest lists each plugin's releases: version, build, channel, PluginKit version,
minimumSystemVersion, URL and sha256. The bundle id anddatabaseTypeIdscome from the built bundle. - Selection: a release in the running build's channel (a
-beta.Nor local build takes beta and stable, any other stable only), with its PluginKit version in the app's range and itsminimumSystemVersionat or below the running macOS. The highest build wins. Installed registry plugins update after the app does. - Retention per channel, kit and
minimumSystemVersion: the two highest builds stay; a beta goes once a stable with the same key has a higher build; a kit belowretainFromPluginKitgoes. A release pruned on arrival is a logged no-op. retainFromPluginKitis the kit of the oldest app version still on at least 1% of the machines seen in the last 30 days (license dashboard, App Versions). Check it before each stable and raise it with a normal PR. The manifest keeps the higher of its own value and the tag's, so an older line never lowers it.- Accepted risks: the manifest is trusted through HTTPS, the plugin's Team ID check and the sha256, so a rollback or freeze of the manifest goes undetected.
- PluginKit ABI rules:
.claude/rules/plugin-system.md, ABI.
The .a files under Libs/ and the xcframeworks under Libs/ios/ live on the immutable libs-<n> GitHub release that Libs/release names, not in git; scripts/download-libs.sh fetches it and verifies every file against the checksums at HEAD. scripts/publish-libs.sh takes every library you rebuilt, added or removed, verifies the others against HEAD, creates the next libs-<n> and rewrites Libs/release and both checksum files (--dry-run only prints). A published libs-<n> never changes: a fix is the next <n>. Each library is built by a build-*.sh in scripts/ or scripts/ios/.
scripts/publish-libs.sh libmongoc_arm64.a libmongoc_x86_64.a libmongoc_universal.a libmongoc.a
git add Libs/release Libs/checksums.sha256 Libs/ios/checksums.sha256 && git commit -m "build(libs): publish libs-<n>"export VERSION=0.0.0-dev BUILD=9999999
scripts/download-libs.sh && scripts/generate-project.sh macos
scripts/release/build.sh app --unsigned && scripts/release/package.sh app --unsigned
scripts/build-dameng.sh both && scripts/build-hana.sh both
scripts/release/build.sh plugins --unsigned && scripts/release/package.sh plugins --unsignedbuild.shwritesbuild/release/(xcodebuild logs inbuild/release/logs/),package.shwritesdist/. CI builds the same in Release Build (.claude/rules/ci.md, Release Build). Signed: drop--unsigned, export the tag'sVERSIONandBUILD, and have the Developer ID identity and the provisioning profileExportOptions.plistnames.notarize.sh <path>...needs the profilexcrun notarytool store-credentials TablePro --key <p8> --key-id <id> --issuer <issuer>creates.