scripts/build-release.sh is the whole release build: it signs every nested binary (plugins,
helpers, frameworks) and the app bundle last, so signing again by hand undoes its work.
Everything here needs the maintainer's credentials; a contributor wants CONTRIBUTING.md, Building.
The release skill in .claude/skills/release runs a release on top of this file and owns the
procedure and its decisions.
scripts/build-release.sh arm64 # or x86_64, or both (the default with no argument)Output lands in build/Release/TablePro-arm64.app and build/Release/TablePro-x86_64.app.
The script extracts the matching slice from the universal static libraries, copies every non-system
dynamic library the binaries reference into Contents/Frameworks and rewrites their install names,
strips the binaries, then signs with hardened runtime and TablePro/TablePro.entitlements.
| Variable | Default | Purpose |
|---|---|---|
SIGN_IDENTITY |
the maintainer's Developer ID Application | codesign identity for every binary |
NOTARIZE |
false |
true notarizes and staples after the build |
NOTARY_PROFILE |
TablePro |
notarytool keychain profile holding the credentials |
Create the notary profile once with xcrun notarytool store-credentials "TablePro" plus
--apple-id, --team-id and --password. scripts/lib/notarize.sh zips, submits, waits and
staples. It reads both the exit status and the reported status, because notarytool has exited 0
on a rejected submission.
scripts/create-dmg.sh <version> <arm64|x86_64> [source app] packages one architecture. The source
app defaults to the one build-release.sh wrote for that architecture. An empty version reads
MARKETING_VERSION from Configs/Version.xcconfig, the only place the app version is declared.
scripts/create-dmg.sh 0.80.0 arm64 # writes build/Release/TablePro-0.80.0-arm64.dmgIt prefers create-dmg (brew install create-dmg) and falls back to hdiutil. NOTARIZE=true
notarizes the DMG too.
The .a files under Libs/ and the xcframeworks under Libs/ios/ live on the libs-v1 GitHub
release, not in git. Each set has a checksum baseline committed beside it, and
scripts/download-libs.sh verifies the working copy against the baseline at HEAD on every run,
including runs that download nothing. An archive and its baseline move together, so publish through
the scripts, which do both:
scripts/publish-libs.sh libmongoc_arm64.a libmongoc_x86_64.a libmongoc_universal.a libmongoc.a
git add Libs/checksums.sha256 && git commit -m "build: update static library checksums"- Name every library you rebuilt.
publish-libs.shverifies each one you did not name against the checksums atHEAD, regeneratesLibs/checksums.sha256and uploads the archive with--clobber. - The iOS xcframeworks go through
scripts/publish-ios-libs.sh, then a commit ofLibs/ios/checksums.sha256. It takes--dry-runand refuses a publish that changes nothing. libs-v1predates release immutability, so GitHub keeps it mutable. Both scripts stop before touching a checksum file if it ever reports immutable.- Never write a checksum file by hand. Regenerating one from a stale
Libs/reverts every library you did not rebuild: that shipped a broken libmongoc and rolled DuckDB back a version. - Never upload with a bare
tarandgh release upload. That put 299 MB on the release with no baseline behind it, and the nextdownload-libs.shrun stopped on every machine and CI job.
.github/workflows/build.yml runs on a v* tag push or a manual dispatch, and a run is never
cancelled (concurrency group build-tablepro-<ref>). Every job except the two builds needs a v*
ref, so a dispatch from a branch only builds. The tag runs no test suite.
Tested on main(Ubuntu) fails when the tagged commit is not an ancestor oforigin/main.- For
macOS TestsandiOS and Package Testsseparately, it walks up to 50 first-parent commits back from the tag. On each it takes the newest push run of that workflow and waits for it if it is still going. A commit with no run, or whose job was skipped, is passed over; any conclusion other than success fails the release. - A passing run counts only when
scripts/ci/detect-changed-paths.sh --base <that commit> <suite>printsfalse, so nothing the suite reads changed between that commit and the tag. The step summary names the covering run for each suite. - The two suites may be covered by different commits. Requiring one commit for both would fail whenever only one suite ran later.
Build arm64andBuild x86_64run onxcode-27with no fail-fast; x86_64 cross-compiles on an arm64 runner. Each runsscripts/build-release.shwith notarization, then makes the DMG and the tar.xz update archive.Registry Readiness(Ubuntu) runsscripts/check-registry-readiness.pywith--floorand--currentset to the two kit versions inTablePro/Core/Plugins/PluginManager.swift. It fails when a registry driver has no binary in that range, so the app never ships ahead of its plugins.Create GitHub Releaseneeds all three and runs under thereleaseenvironment. It fails when the tag disagrees withMARKETING_VERSION(tag v... does not match MARKETING_VERSION ...).- It also fails when
CURRENT_PROJECT_VERSIONis not above the newest published release's. Sparkle compares that number as<sparkle:version>, so a flat one ships a release no install is offered. - It signs both update archives with the Sparkle key, builds deltas from the previous five
releases' archives, and takes the notes from
CHANGELOG.md. - It checks that the appcast deploy key works before publishing anything. Then it puts both DMGs,
both update archives and the deltas on a draft release, publishes the draft in one step, and only
then commits
appcast.xml, whose download URLs 404 until the release is public. - The appcast is merged with the tag's own copy of
scripts/ci/merge-appcast.pyand pushed withAPPCAST_DEPLOY_KEY, up to 3 attempts, each re-merging onto the currentorigin/main. generate_appcastsees only the new release. The merge script splices its items in and moves published items byte for byte, because Sparkle rewrites an existing item's download URL and drops its notes when a staged archive matches it.- A tag containing
-alpha,-betaor-rcpublishes as a prerelease.
maintakes no direct push. Its ruleset requires the checksAGENTS.mdlists under CI, and deploy keys bypass it, so the appcast push and the release commit push need no check run.- The appcast push uses
APPCAST_DEPLOY_KEY, a write deploy key held by thereleaseenvironment, which onlyv*tags can use. - The release commit and a withdrawal push with the release key,
~/.ssh/tablepro_release, a second write deploy key. The release skill's pre-flight step 7 creates it the first time a release needs it. - The release key has a passphrase and is never added to ssh-agent: agents run on the maintainer's Mac and the key skips every check, so only the maintainer runs a push with it.
- The push command is in
.claude/skills/release/SKILL.md, Commit, tag, push. - The
v*tag ruleset lets only admins create, move or delete a release tag.
-
A published release is immutable: GitHub refuses to add, replace or delete an asset, or to move the tag.
-
Before Publish the release, only a draft exists. Fix forward on
mainthrough a pull request, then move the tag to the commit it lands as. The next run reuses the draft.git fetch origin main git tag -f -a v<version> -m "v<version>" origin/main git push origin v<version> --force
-
After it (a failed
appcast.xmlpush, say), use Re-run failed jobs: the release job finds the same files already published and carries on. Re-run all jobs rebuilds, and the release job refuses the new files. -
A published build that is wrong ships as a new version.
- An ordinary release carries
<sparkle:phasedRolloutInterval>21600</sparkle:phasedRolloutInterval>. Sparkle splits installs into seven cohorts by a per-install random id, so the last cohort is offered the build 36 hours after the first. Check for Updates… is never phased. "criticalUpdate": truein.github/release-flags.jsonpasses--critical-update-version ""togenerate_appcastinstead. That writes<sparkle:criticalUpdate/>with no version, critical for every host: Sparkle skips phasing, hides Skip and Remind Me Later, retitles the alert, and shows it even under automatic downloads.- The release job fails when the flag is
trueand the file has not changed since the previous tag, which is what a leftover flag looks like. - When to set it:
.claude/skills/release/SKILL.md, Critical update flag.
Removing the version's items from appcast.xml is the only way to un-ship a Sparkle release.
python3 scripts/ci/pull-release.py <version> --dry-run # reports what it would remove
GIT_SSH_COMMAND='ssh -i ~/.ssh/tablepro_release -o IdentitiesOnly=yes' \
python3 scripts/ci/pull-release.py <version> # edits, commits and pushes to main- The second command pushes with the release key, so the maintainer runs it (Keys and rulesets).
- It refuses to push from any branch but
main, or when localmainis notorigin/main, since the key would carry any other commit past every check. It also refuses to empty the feed. - The GitHub release stays up, so a direct download link still resolves. Users already on the bad build are reached by shipping the fix.
.github/workflows/build-plugin.ymlruns on aplugin-*-v*tag, or on a dispatch withtags(tag[:pluginKitVersion],...) and an optionalbaseRef. Runs are grouped per ref and per matrix tag and never cancelled.Resolve Plugin Tags(Ubuntu) picks the runner:macos-26when the ref being built pins Xcode 26 in its own.github/actions/setup-xcode, elsexcode-27.- It runs
.github/scripts/test_update_registry.pyandscripts/ci/check-plugin-manifest.py, and validates each tag against^plugin-[a-z0-9-]+-v[0-9]+\.[0-9]+\.[0-9]+$and each kit version as an integer. A tag given without a kit version takescurrentPluginKitVersion. - The build job checks out
baseRefwhen one is given. It builds the Dameng bridge forplugin-dameng-*, a universal HANA helper plusgovulncheckforplugin-hana-*, then runsscripts/build-plugin.sh "$TARGET" both "$VERSION"with notarization. - Before publishing:
scripts/ci/verify-plugin-loads.shdlopens the arm64 bundle beside its PluginKit framework; both zips must be stapled and accepted by Gatekeeper; the builtTableProPluginKitVersionmust equal the release label. - It publishes the release from a draft, then records the binary in the TableProApp/plugins registry
with
.github/scripts/update-registry.py. The registry decides which binary a user gets: Plugin Registry covers the manifest, the tag names and the bulk re-release after a PluginKit ABI change.