Cutting a release
How a maintainer builds, signs, notarizes and publishes a kinhin release — locally as a rehearsal, then from a tag for real.
Contributor conventions are in CONTRIBUTING.md; what CI runs is in Architecture.
What a release produces
Four files in dist/, all version-pinned to MAJOR.MINOR.PATCH (plus an optional -beta-N suffix):
| File | What it is |
|---|---|
kinhin-<v>-arm64.dmg | The app: kinhin.app (bundle executable + Resources/bin/kinhin CLI), Developer ID signed, notarized, stapled, Gatekeeper-verified |
kinhin-cli-<v>-arm64.zip | The CLI alone, Developer ID signed and notarized (zips cannot be stapled) |
install.sh | The install script, stamped with the Team ID of the app it installs, so a tampered app is refused |
checksums.txt | sha256 of those three files — what install.sh and the in-app updater verify against |
The version comes from exactly one place: Sources/KinhinKit/Core/Version.swift. Release CI stamps that file from the tag; nothing else defines the version.
Prerequisites
Full Xcode selected, not the Command Line Tools —
xcode-select -pmust print/Applications/Xcode.app/Contents/Developer(swift testneeds XCTest, which ships with Xcode).A Developer ID Application certificate in your keychain.
CODESIGN_IDENTITYoverrides; otherwise the first one found is used. A release is never ad-hoc signed — an ad-hoc build cannot be notarized and Gatekeeper refuses it.Notarization credentials, one of the two forms:
bash# Preferred: a stored notarytool profile. Nothing on disk, nothing in the environment. xcrun notarytool store-credentials kinhin \ --key AuthKey_XXXX.p8 --key-id <key-id> --issuer <issuer-id> # Or the App Store Connect API key, all three exported: export NOTARY_KEY_PATH=~/keys/AuthKey_XXXX.p8 export NOTARY_KEY_ID=<key-id> export NOTARY_ISSUER_ID=<issuer-id>
The two paths
Local rehearsal — prove the build, the signing and the installer stamping before a tag exists:
# 1. Bump the version and the notes, in one commit
# Sources/KinhinKit/Core/Version.swift current = "<version>"
# CHANGELOG.md a "## v<version>" heading
# 2. Everything CI runs must be green
scripts/check.sh
# 3. Build, sign, notarize, verify, package
scripts/release.sh <version>scripts/release.sh refuses to run on a dirty tree, on a version that disagrees with Version.swift, without the CHANGELOG heading, without a Developer ID identity, or without notarization credentials. It never commits, tags or pushes — its last screen prints the tag commands for you to run.
Flags: --skip-checks skips scripts/check.sh (iteration only, never ship this way), --allow-dirty runs on an uncommitted tree (warns), --no-notarize skips notarization and every check that depends on it — a smoke run whose output must never be published.
Tag publish — the real release:
git tag v<version>
git push origin v<version>The tag triggers .github/workflows/release.yml, which runs the tests, stamps Version.swift from the tag, requires the signing secrets, rebuilds, and runs the same scripts/release-artifacts.sh your local run executed — so a checkout release and a tag publish cannot drift apart. It then uploads the four files to a GitHub Release, with the version's CHANGELOG section as its notes. The workflow runs in the protected release environment, so secrets are only reachable after a reviewer approves. It needs a runner labelled kinhin-xcode (a Tart pool on a kinhin fleet).
Local publish — when no kinhin-xcode runner is available, publish the verified local dist/ instead. gh creates the tag on GitHub together with the release, so when the tag's release.yml run starts, its existing job finds the release and skips the rebuild. The commit you built must already be pushed.
awk -v h="## v<version>" '$0 == h {f=1; next} /^## /{f=0} f' CHANGELOG.md > dist/release-notes.md
gh release create v<version> --target "$(git rev-parse HEAD)" --latest --title "v<version>" \
--notes-file dist/release-notes.md \
dist/kinhin-<version>-arm64.dmg dist/kinhin-cli-<version>-arm64.zip dist/install.sh dist/checksums.txt
git fetch --tagsThe scripts
| Script | Role |
|---|---|
scripts/release.sh | The orchestrator: preflight → gates → build → artifacts → summary |
scripts/build-app.sh | Assembles dist/kinhin.app: builds both products, hash-verifies every copied binary, generates App Intents metadata, signs inside-out (nested CLI first) |
scripts/make-dmg.sh | Wraps build-app.sh, packs the DMG with an /Applications shortcut, signs it, calls notarize.sh |
scripts/notarize.sh | Submits to Apple, waits for the verdict, prints the notary log on rejection, staples DMGs — as submit / wait / staple / status subcommands so an interrupted run resumes (see below) |
scripts/release-artifacts.sh | CLI zip → Gatekeeper + in-bundle version verification → Team-ID-stamped install.sh → checksums.txt |
release-artifacts.sh pins checksums.txt to the version's own files: dist/ is a workbench, and a wildcard would silently checksum whatever older artifacts happen to be lying next to the new ones.
Apple's queue can take hours
Notarization is not a local step: the upload returns in seconds, then Apple's queue runs server-side. A first submission, or a busy day at Apple, can sit at In Progress for hours — a recurring, documented service problem, not a sign that anything is wrong with the build.
So scripts/notarize.sh never holds the verdict in memory. The submission id is written to a sidecar next to the artifact the moment the upload returns:
dist/kinhin-0.0.1-beta-1-arm64.dmg.notary # sha256=… id=… submittedAt=…Whatever kills the waiting process — a tool-call timeout, a CI job limit, Ctrl-C — the submission stays alive on Apple's side and the id survives. Pick it up whenever:
scripts/notarize.sh status dist/kinhin-<v>-arm64.dmg # id + live status
scripts/notarize.sh wait dist/kinhin-<v>-arm64.dmg # poll until it settles, then staple
scripts/notarize.sh staple dist/kinhin-<v>-arm64.dmg # staple now, if it is already Accepted
NOTARY_KEYCHAIN_PROFILE=kinhin ./scripts/release-artifacts.sh <v> # finish the rest of the runThe four subcommands are also how the default form stays honest:
notarize.sh <file>(= submit → wait → staple) resumes a recorded submission instead of uploading the same bytes again.- The sidecar stores the artifact's
sha256, so a rebuilt DMG or zip can never be stapled against another build's ticket — it fails with instructions instead. NOTARY_WAIT_SECONDSbounds the wait (unset = as long as Apple takes). On expiry the script exits 75 — distinct from a rejection — and prints the resume commands; nothing was lost.release.shprints the same resume hint if it dies with a submission in flight.
A fresh release.sh run clears stale sidecars with the rest of dist/, so a new release never resumes an old submission.
After the release
- Add the tag to
website/versions.jsonso the docs site snapshots it (see CONTRIBUTING.md). - The in-app updater checks GitHub Releases at most once a day; a Developer ID build only accepts a DMG signed by its own Team ID and accepted by Gatekeeper (Install).
- A beta (
-beta-N) is published as the latest release, not a prerelease: GitHub'sreleases/latest, which the install URL andinstall.shresolve, never returns a prerelease.
Verifying a shipped artifact
xcrun stapler validate dist/kinhin-<v>-arm64.dmg
spctl -a -vv -t open --context context:primary-signature dist/kinhin-<v>-arm64.dmg
shasum -a 256 -c <(grep 'kinhin-<v>-arm64.dmg' dist/checksums.txt)scripts/release.sh runs these for you; they are what install.sh and the updater check on the user's machine.
