diff options
| author | Peter Geissler <peter@haxx.in> | 2026-08-27 00:08:52 +0200 |
|---|---|---|
| committer | Peter Geissler <peter@haxx.in> | 2026-08-27 00:08:52 +0200 |
| commit | aa2ec9b65dbee07886556b0d8ae353cb462964b9 (patch) | |
| tree | d06e2e2f99e41f550c47054a1ac96ae223a57df6 | |
| parent | brew-release: cut a release from the Linux box it is cut from (diff) | |
| download | slosh-aa2ec9b65dbee07886556b0d8ae353cb462964b9.tar.gz slosh-aa2ec9b65dbee07886556b0d8ae353cb462964b9.tar.xz slosh-aa2ec9b65dbee07886556b0d8ae353cb462964b9.zip | |
macos: the pipeline is the project's, the identity is yours
Makefile.macos was kept out of git because it named a team, a person and
a mac. But almost all of it is mechanism -- build, sign, zip, notarize,
staple, unlock a headless keychain -- and that is the project's, not the
releaser's. Keeping the whole file out to protect four values meant the
one machine that had it was the only place a release could be cut from,
and that nobody could read how signing worked without being handed it.
So the split is inverted. Makefile.macos, the builder doc and the scripts
are tracked; macos-release.mk holds TEAM_ID, SIGNER and BUILDER and is
the only thing gitignored. macos-release.mk.example is the template.
The identity strings are now assembled from SIGNER and TEAM_ID rather
than written out, which means an unconfigured checkout composes
"Developer ID Application: ()" -- plausible, wrong, and diagnosed only
by codesign a minute into a build. require-identity and require-builder
catch it before anything runs and name the file to create; `help` prints
"(unset)" rather than the empty shape.
macos-remote-build now pushes macos-release.mk instead of Makefile.macos.
That is the better arrangement anyway: the builder gets its pipeline from
git at the commit being released, so a release is signed by the flow
belonging to it and not by whatever the laptop was carrying.
MACOS-BUILDER.md was in the same position -- local, and referenced by a
file that is now tracked -- so it becomes docs/macos-release.md with the
certificate name and notary key id replaced by their shapes.
| -rw-r--r-- | .gitignore | 9 | ||||
| -rw-r--r-- | Makefile | 19 | ||||
| -rw-r--r-- | Makefile.macos | 262 | ||||
| -rwxr-xr-x | contrib/macos-remote-build | 18 | ||||
| -rw-r--r-- | docs/macos-release.md | 206 | ||||
| -rw-r--r-- | docs/nav | 1 | ||||
| -rw-r--r-- | macos-release.mk.example | 53 |
7 files changed, 549 insertions, 19 deletions
@@ -20,8 +20,9 @@ __pycache__/ web/slosh-preview.mp4 # the signed macOS artifacts, and the file that says who signs them. -# Makefile.macos names a signing identity and a builder machine: that is a -# property of whoever is releasing, not of the project. `make macos-dist` -# tells you how to make one; contrib/macos-builder-setup sets the mac up. +# The release *mechanism* is in the repo (Makefile.macos, contrib/macos-*). +# macos-release.mk is the one file that is not: it names a team, a certificate +# and a builder machine, which belong to whoever is releasing rather than to +# the project. macos-release.mk.example is the template, and is tracked. /dist/ -Makefile.macos +macos-release.mk @@ -327,16 +327,17 @@ hooks: ## install the pre-commit hook (formats staged C) # builder over ssh and brings back a signed, notarized zip. Works from Linux; # all it needs here is ssh, rsync and something that can hash a file. # -# Everything specific to *you* -- which mac, which identity, which Apple team -- -# lives in Makefile.macos, which is not in the repo. The mechanism is: -# contrib/macos-builder-setup sets a builder up, contrib/macos-remote-build -# drives it. REF=<tag> builds a ref origin already has, which is what a release -# is; without it you get this working tree, dirty and honestly labelled. +# The pipeline is Makefile.macos and contrib/macos-{builder-setup,remote-build}, +# all in the repo. Everything specific to *you* -- which mac, whose certificate, +# which Apple team -- is the handful of lines in macos-release.mk, which is not. +# REF=<tag> builds a ref origin already has, which is what a release is; +# without it you get this working tree, dirty and honestly labelled. macos-dist: ## signed, notarized macOS build on the builder mac (REF=<tag> for a release) - @test -f Makefile.macos || { \ - echo "no Makefile.macos here -- it names your signing identity and your"; \ - echo "builder mac, so it is kept out of the repo. To set one up, see:"; \ - echo " contrib/macos-builder-setup --help"; exit 1; } + @test -f macos-release.mk || { \ + echo "no macos-release.mk here -- it names your signing identity and your"; \ + echo "builder mac, so it is the one file kept out of the repo:"; \ + echo " cp macos-release.mk.example macos-release.mk # then fill it in"; \ + echo "To provision a builder mac: contrib/macos-builder-setup --help"; exit 1; } $(Q)$(MAKE) -f Makefile.macos remote-dist REF=$(REF) # ── installation ──────────────────────────────────────────────────────────── diff --git a/Makefile.macos b/Makefile.macos new file mode 100644 index 0000000..4bce7d6 --- /dev/null +++ b/Makefile.macos @@ -0,0 +1,262 @@ +# slosh — macOS code-signing & notarization +# +# The mechanism is the project's and lives here. Everything specific to *you* — +# which team, whose certificate, which mac — is in macos-release.mk, which is +# gitignored; copy macos-release.mk.example to it and fill it in. +# +# The signing happens on a dedicated mac (see docs/macos-release.md). From here you +# normally never run the pipeline directly — you drive that machine, and this +# end needs only ssh, rsync and make, so "here" may be a Linux box: +# +# make macos-dist # the main Makefile forwards to here +# make macos-dist REF=v0.2.0 # ...for a pushed ref +# +# which is this file's remote-dist target, and can also be run directly: +# +# make -f Makefile.macos remote-dist # sync this tree → build → sign → +# # notarize there → fetch the zip back +# make -f Makefile.macos builder-check # is the builder still set up? +# +# The targets below (build/sign/zip/notarize/dist) are what actually runs *on* +# the builder. They still work on any mac that holds the identity, which is how +# this started; the difference is that they now name a keychain explicitly, +# because a build machine nobody logs into has no unlocked login keychain. +# +# One-time setup of a new builder, from here: +# +# contrib/macos-builder-setup m1 # toolchain, clone, keychain, notary +# +# ── your identity ─────────────────────────────────────────────────────────── +# Not in this file: TEAM_ID, SIGNER and BUILDER come from macos-release.mk, so +# that the rest of this is the project's and can be shared. `-include` because +# a checkout without one is normal -- most people never sign anything -- and +# the targets that actually need it say so themselves (require-identity). +# A command-line override still beats both. +-include macos-release.mk + +IDENTITY ?= Developer ID Application: $(SIGNER) ($(TEAM_ID)) + +# The keychain profile name that `setup-notary` creates and `notarize` reads, +# so the actual Apple ID / password / API key never live in this file. +NOTARY_PROFILE ?= slosh-notary + +# ── the keychain ──────────────────────────────────────────────────────────── +# Both the signing key and the notary credential live in a keychain of their +# own rather than in login.keychain. On the builder that is not a preference: +# nobody ever logs in there, so the login keychain is never unlocked, and +# codesign over ssh fails with a bare "User interaction is not allowed". A +# separate keychain with a password we can read from a 0600 file is unlockable +# from a non-interactive shell, and holds nothing but slosh's signing material. +# +# If that keychain is absent we fall back to the default search list, so this +# file still behaves the old way on a machine where you sign by hand. +KEYCHAIN ?= $(HOME)/Library/Keychains/slosh-build.keychain-db +KEYCHAIN_PASS ?= $(HOME)/.config/slosh/build-keychain.pass +HAVE_KEYCHAIN := $(wildcard $(KEYCHAIN)) +KC_FLAG := $(if $(HAVE_KEYCHAIN),--keychain "$(KEYCHAIN)",) + +# ── the builder ───────────────────────────────────────────────────────────── +# An ssh host (see ~/.ssh/config) and the checkout on it. `remote` drives them. +# BUILDER comes from macos-release.mk; the checkout path has a sane default +# because macos-builder-setup is what creates it. +BUILDER_DIR ?= src/slosh +REF ?= + +# ── what we sign ──────────────────────────────────────────────────────────── +# Reuse the real build. `make all` (the main Makefile) produces build/slosh. +MAINMAKE ?= $(MAKE) -f Makefile +BIN ?= build/slosh +DIST ?= dist + +# Version string for the artifact names. Matches the Makefile's scheme. +VERSION ?= $(shell git describe --always --dirty 2>/dev/null || echo 0.0.0) +ARCH ?= $(shell uname -m) +STEM := slosh-$(VERSION)-macos-$(ARCH) +ZIP := $(DIST)/$(STEM).zip + +# Hardened runtime is required for notarization. slosh is a static binary that +# only forks shells/PTYs, so it needs no entitlements — leave this empty unless +# notarization or a runtime failure tells you otherwise, then point it at a +# .plist and it gets passed to codesign. +ENTITLEMENTS ?= +ENT_FLAG := $(if $(ENTITLEMENTS),--entitlements $(ENTITLEMENTS),) + +# ── optional: a signed, stapled installer package ─────────────────────────── +# `pkg` needs a *second* certificate: "Developer ID Installer" (distinct from +# the Application cert you already have). Create it the same way, then: +# make -f Makefile.macos pkg +INSTALLER_IDENTITY ?= Developer ID Installer: $(SIGNER) ($(TEAM_ID)) +PKG_ID ?= foo.sl0p.slosh +PKG := $(DIST)/$(STEM).pkg +# Where the binary lands on the user's machine. +INSTALL_LOC ?= /usr/local/bin + +.PHONY: help build sign verify zip notarize staple dist pkg setup-notary \ + notary-status clean-dist unlock remote-dist remote-pkg \ + builder-check builder-setup require-identity require-builder +.DEFAULT_GOAL := help + +# What help prints for a value macos-release.mk was supposed to supply. Better +# than the alternative: with SIGNER and TEAM_ID empty the identity composes as +# "Developer ID Application: ()", which looks like a real answer. +UNSET = (unset — see macos-release.mk.example) + +help: ## show this + @grep -hE '^[a-z-]+:.*##' Makefile.macos | sed 's/:.*##/\t/' | expand -t18 + @printf '\n identity : %s\n team : %s\n artifact : %s\n builder : %s\n keychain : %s\n' \ + '$(if $(strip $(SIGNER))$(strip $(TEAM_ID)),$(IDENTITY),$(UNSET))' \ + '$(if $(strip $(TEAM_ID)),$(TEAM_ID),$(UNSET))' \ + '$(ZIP)' \ + '$(if $(strip $(BUILDER)),$(BUILDER):$(BUILDER_DIR),$(UNSET))' \ + '$(if $(HAVE_KEYCHAIN),$(KEYCHAIN),(none here — default search list))' + +# ── drive the builder ─────────────────────────────────────────────────────── +# Without macos-release.mk the identity string would come out as the plausible +# and completely wrong "Developer ID Application: ()", and codesign would +# reject it a minute into a build with a message about no identity matching. +# Say the useful thing first instead, and name the file to create. +# One line each, echoed separately: a multi-line make variable would need the +# indentation escaped (make eats whitespace after a continuation) and must +# contain no '#', which make reads as a comment -- both of which this hint got +# wrong before anyone read its output. +CONFIG_HINT = macos-release.mk is missing or incomplete. +CONFIG_FIX = cp macos-release.mk.example macos-release.mk (then fill it in) + +require-identity: + @test -n "$(strip $(SIGNER))" && test -n "$(strip $(TEAM_ID))" || { \ + echo "no signing identity: $(CONFIG_HINT)"; \ + echo " $(CONFIG_FIX)"; \ + echo " need SIGNER and TEAM_ID (have SIGNER='$(SIGNER)' TEAM_ID='$(TEAM_ID)')"; \ + exit 1; } + +require-builder: + @test -n "$(strip $(BUILDER))" || { \ + echo "no builder: $(CONFIG_HINT)"; \ + echo " $(CONFIG_FIX)"; \ + echo " need BUILDER (an ssh host that holds the signing key)"; \ + exit 1; } + +# The everyday command from a laptop. The work happens on $(BUILDER); the zip +# comes back into ./dist so the rest of the release (contrib/brew-release, the +# website) is unchanged. +remote-dist: require-builder ## build+sign+notarize on $(BUILDER), fetch the artifacts into dist/ + @contrib/macos-remote-build --builder '$(BUILDER)' --dir '$(BUILDER_DIR)' \ + $(if $(REF),--ref '$(REF)',) --target dist + +remote-pkg: require-builder ## same, but produce the stapled installer .pkg + @contrib/macos-remote-build --builder '$(BUILDER)' --dir '$(BUILDER_DIR)' \ + $(if $(REF),--ref '$(REF)',) --target pkg + +builder-check: require-builder ## verify the builder still has toolchain, identity and credentials + @contrib/macos-builder-setup --check '$(BUILDER)' + +builder-setup: require-builder ## provision (or repair) the builder from this machine + @contrib/macos-builder-setup '$(BUILDER)' + +# ── the keychain ──────────────────────────────────────────────────────────── +# Every target that needs the private key depends on this. It is a no-op on a +# machine without the dedicated keychain, and it is cheap enough to repeat: +# unlocking an already-unlocked keychain succeeds and says nothing. +unlock: + @if [ -n "$(HAVE_KEYCHAIN)" ]; then \ + test -s "$(KEYCHAIN_PASS)" || { \ + echo " no keychain password at $(KEYCHAIN_PASS)"; exit 1; }; \ + security unlock-keychain -p "$$(cat '$(KEYCHAIN_PASS)')" "$(KEYCHAIN)"; \ + fi + +# ── build ─────────────────────────────────────────────────────────────────── +build: ## build the release binary via the main Makefile + $(MAINMAKE) all + +$(BIN): build + +# ── sign ──────────────────────────────────────────────────────────────────── +sign: require-identity $(BIN) unlock ## codesign the binary (hardened runtime + secure timestamp) + @echo " [SIGN] $(BIN) ($(IDENTITY))" + @codesign --force --timestamp --options runtime $(ENT_FLAG) $(KC_FLAG) \ + --sign "$(IDENTITY)" "$(BIN)" + @$(MAKE) -f Makefile.macos --no-print-directory verify + +verify: ## check the signature is valid and well-formed + @codesign --verify --strict --verbose=2 "$(BIN)" + @echo " [OK] signature valid" + @codesign --display --verbose=2 "$(BIN)" 2>&1 | \ + grep -E 'Authority|TeamIdentifier|Timestamp|Runtime' | sed 's/^/ /' || true + +# ── package for notarization ──────────────────────────────────────────────── +# A bare CLI binary can't be submitted to Apple; it must be inside a .zip, +# .pkg, or .dmg. --keepParent preserves the filename inside the archive. +# (The output dir is made in-recipe on purpose: a `$(DIST):` target would +# collide with the phony `dist` command below and create a circular rule.) +zip: sign ## zip the signed binary for submission / Homebrew + @mkdir -p $(DIST) + @rm -f "$(ZIP)" "$(DIST)/slosh" + @cp "$(BIN)" "$(DIST)/slosh" # stage as a clean top-level name + @ditto -c -k "$(DIST)/slosh" "$(ZIP)" # single file -> archive contains just 'slosh' + @rm -f "$(DIST)/slosh" + @echo " [ZIP] $(ZIP)" + +# ── notarize ──────────────────────────────────────────────────────────────── +# Submits and blocks until Apple's automated scan returns. On success the +# ticket lives on Apple's servers; Gatekeeper verifies online on first run. +# (You cannot staple a bare binary or a .zip — only .pkg/.dmg/.app. So this +# zip flow is the Homebrew-friendly path; use `pkg` if you want offline.) +notarize: zip ## submit the zip to Apple and wait for the verdict + @echo " [NOTARIZE] submitting $(ZIP) ..." + @xcrun notarytool submit "$(ZIP)" \ + --keychain-profile "$(NOTARY_PROFILE)" $(KC_FLAG) --wait 2>&1 \ + | tee $(DIST)/notary.log + @grep -q 'status: Accepted' $(DIST)/notary.log || { \ + echo " [FAIL] Apple did not accept this build:"; \ + sed -n 's/^ *status: */ status: /p' $(DIST)/notary.log; \ + id=$$(sed -n 's/^ *id: *//p' $(DIST)/notary.log | head -1); \ + [ -n "$$id" ] && xcrun notarytool log "$$id" \ + --keychain-profile "$(NOTARY_PROFILE)" $(KC_FLAG); \ + exit 1; } + @echo " [OK] notarized. Ship $(ZIP), or run 'make -f Makefile.macos pkg' for a stapled installer." + +# dist is the everyday command: build, sign, zip, notarize. +dist: notarize ## full pipeline: build → sign → zip → notarize + +# ── optional signed .pkg (offline / double-click install) ─────────────────── +pkg: sign ## build a signed, notarized, stapled installer .pkg + @mkdir -p $(DIST) + @echo " [PKG] staging $(BIN) -> $(INSTALL_LOC)" + @rm -rf $(DIST)/pkgroot && mkdir -p "$(DIST)/pkgroot$(INSTALL_LOC)" + @cp "$(BIN)" "$(DIST)/pkgroot$(INSTALL_LOC)/" + @pkgbuild --root $(DIST)/pkgroot --identifier $(PKG_ID) \ + --version $(VERSION) --install-location / \ + $(if $(HAVE_KEYCHAIN),--keychain "$(KEYCHAIN)",) \ + --sign "$(INSTALLER_IDENTITY)" "$(PKG)" + @echo " [NOTARIZE] $(PKG)" + @xcrun notarytool submit "$(PKG)" --keychain-profile "$(NOTARY_PROFILE)" $(KC_FLAG) --wait + @xcrun stapler staple "$(PKG)" + @spctl --assess --type install -vv "$(PKG)" || true + @echo " [OK] $(PKG) (stapled, verifies offline)" + +# ── one-time credential setup ─────────────────────────────────────────────── +# Stores a notarization credential in the login keychain under NOTARY_PROFILE +# so nothing secret lives in this file. Prefer an App Store Connect API key +# (--key/--key-id/--issuer); an app-specific password also works. Run once. +setup-notary: ## store notarization credentials in the keychain (interactive, once) + @echo "contrib/macos-builder-setup does this for the builder. By hand it is:" + @echo + @echo " # A) App Store Connect API key (recommended):" + @echo " xcrun notarytool store-credentials '$(NOTARY_PROFILE)' \\" + @echo " --key /path/to/AuthKey_XXXXXXXXXX.p8 --key-id XXXXXXXXXX --issuer <issuer-uuid> \\" + @echo " $(if $(HAVE_KEYCHAIN),--keychain '$(KEYCHAIN)',)" + @echo + @echo " # B) Apple ID + app-specific password (from appleid.apple.com):" + @echo " xcrun notarytool store-credentials '$(NOTARY_PROFILE)' \\" + @echo " --apple-id $(if $(strip $(APPLE_ID)),$(APPLE_ID),<your-apple-id>) --team-id $(TEAM_ID)" + @echo + @echo "It'll prompt for the secret and save it to the keychain under '$(NOTARY_PROFILE)'." + @echo "Omit --keychain and it lands in the login keychain, which a headless" + @echo "builder cannot open: on the builder, always name the keychain." + +notary-status: unlock ## show recent notarization submissions + @xcrun notarytool history --keychain-profile "$(NOTARY_PROFILE)" $(KC_FLAG) + +clean-dist: ## remove the dist/ artifacts (leaves build/ alone) + @rm -rf $(DIST) + @echo " removed $(DIST)/" diff --git a/contrib/macos-remote-build b/contrib/macos-remote-build index ae92760..52aafa0 100755 --- a/contrib/macos-remote-build +++ b/contrib/macos-remote-build @@ -69,12 +69,18 @@ ssh "$BUILDER" "test -d '$DIR/.git'" \ || fail "no checkout at $BUILDER:$DIR -- run contrib/macos-builder-setup $BUILDER" say "reachable, checkout present" -# Makefile.macos is not in the repo, so a clean checkout on the builder does not -# have one and it is pushed from here. Only that file: these scripts are tracked -# and run on *this* side, so copying them over would do nothing except make a -# release checkout dirty -- and a dirty release checkout is a failure below. +# macos-release.mk is not in the repo -- it is the one file that is not -- so a +# clean checkout on the builder does not have one and it is pushed from here. +# Only that file: Makefile.macos and these scripts are tracked, so the reset +# below brings the builder the versions belonging to the commit being released, +# which is what should sign it. Copying ours over would override that with +# whatever the laptop happens to have, and make a release checkout dirty -- +# which is a failure below. push_signing_flow() { - rsync -q "$root/Makefile.macos" "$BUILDER:$DIR/Makefile.macos" + [ -f "$root/macos-release.mk" ] \ + || fail "no macos-release.mk here -- it names the identity and the builder: + cp macos-release.mk.example macos-release.mk" + rsync -q "$root/macos-release.mk" "$BUILDER:$DIR/macos-release.mk" } if [ -n "$REF" ]; then @@ -106,7 +112,7 @@ if [ -n "$REF" ]; then # zig-out survive and a release build is a recompile, not a re-download. ssh "$BUILDER" "cd '$DIR' \ && git reset -q --hard '$full' \ - && git clean -qfd -e Makefile.macos -e dist \ + && git clean -qfd -e macos-release.mk -e dist \ && make -s clean" || fail "could not put the builder on $full" push_signing_flow diff --git a/docs/macos-release.md b/docs/macos-release.md new file mode 100644 index 0000000..24bcc04 --- /dev/null +++ b/docs/macos-release.md @@ -0,0 +1,206 @@ +# The macOS builder + +Signed, notarized macOS builds of slosh happen on a dedicated mac, not on a +laptop. This file says what is on that machine, how it got there, and what to +do when it stops working. + +Which mac, and whose certificate, is in `macos-release.mk` — the one file of +this flow that is not in the repo. Copy `macos-release.mk.example` to it and +fill in three lines. Examples below call the builder `m1`, which is just what +`BUILDER` happens to say; `$BUILDER` in a shell snippet means that host. + +## Everyday use + +From a checkout on any machine that can ssh to the builder — **including a +Linux one**; see "Driving it from Linux" below: + +```sh +make macos-dist # this working tree -> signed, notarized zip in dist/ +make macos-dist REF=v0.2.0 # a pushed tag instead (this is a release) + +make -f Makefile.macos builder-check # is the builder still healthy? +make -f Makefile.macos remote-dist # what macos-dist forwards to +``` + +About 40 seconds end to end, most of it Apple's notary service. + +## What is in the repo and what is not + +The **mechanism** is tracked; the **identity** is not. + +| in git | | +|---|---| +| `Makefile`'s `macos-dist` | the target you invoke; forwards and does nothing else | +| `Makefile.macos` | the pipeline: build, sign, zip, notarize, staple | +| `contrib/macos-builder-setup` | provisions a builder, checks one | +| `contrib/macos-remote-build` | drives a build, verifies and fetches the artifact | +| `macos-release.mk.example` | the template for the file below | +| this file | | + +| not in git | | +|---|---| +| `macos-release.mk` | team id, signer name, builder host | +| `~/.config/slosh/notary.env` | which App Store Connect key to notarize with | + +So nothing tracked names a host or an identity: `macos-release.mk` supplies +them, and the targets that need one say which is missing rather than composing +`Developer ID Application: ()` and letting `codesign` fail obscurely a minute +into a build. A clone without the file gets a `make macos-dist` that explains +itself instead of a missing-file error. + +That split is also why the builder is sent `macos-release.mk` and nothing else: +`Makefile.macos` and the scripts arrive there from git, at the commit being +released, so the release is signed by the pipeline belonging to it rather than +by whatever the laptop was carrying. + +The two source modes are not interchangeable: + +| mode | what gets built | version string | use it for | +|---|---|---|---| +| default | this working tree, rsynced | `…-dirty` when dirty | "does it sign, will Apple take it" | +| `REF=…` | a commit **origin publishes**, from a clean tree | the tag or short id | releases | + +`REF=` resolves through `git ls-remote origin`, so it builds what the server +has and refuses anything unpushed — including a local branch of the same name. +That matters because the default mode rsyncs `.git` too, so the builder's +`refs/heads/master` can be a commit only your laptop has ever seen. An artifact +named after a commit nobody can fetch is worse than no artifact. + +Then the release continues: `contrib/brew-release --tap … v0.2.0`. + +## Driving it from Linux + +Works, and is tested: `make -f Makefile.macos remote-dist` from a Debian box +produced a notarized `slosh-78ad926-macos-arm64.zip`, and that artifact — after +a round trip through Linux — still verifies as `accepted / source=Notarized +Developer ID` on a mac. + +This end needs only **ssh, rsync, git, GNU make** and something that can hash a +file (`sha256sum`, `shasum` or `openssl`). What it does *not* need is a mac: + +* **Verification happens on the builder.** `codesign` and `spctl` are macOS + tools, so the artifact is unpacked and assessed there, before it is fetched. + The verdict is the same command on the same machine no matter who asked. +* **This end hashes what arrives** and compares it to the hash the builder + vouched for. Same bytes, same verdict — that is the whole of what a + non-mac can honestly check, and it is enough. + +The scripts come with the checkout; add a `macos-release.mk` and point its +`BUILDER` at a `Host` entry in `~/.ssh/config`, done. + +The one step that genuinely requires a mac is **handing the signing key over**, +because it is read out of a login keychain. Run `contrib/macos-builder-setup` +once from the mac that holds the identity; afterwards everything, `--check` +included, works from Linux. Run it from Linux against a builder that has no +identity yet and it says so in those words rather than dying on +`security: not found`. + +## What is on the builder + +| | | +|---|---| +| toolchain | Command Line Tools; zig 0.16.0 at `~/zig-0.16.0/zig` (a symlink into `~/zig/`) | +| checkout | `~/src/slosh`, cloned from `https://git.sl0p.foo/slosh.git` | +| keychain | `~/Library/Keychains/slosh-build.keychain-db` | +| keychain password | `~/.config/slosh/build-keychain.pass`, mode 0600 | +| signing key | `Developer ID Application: <SIGNER> (<TEAM_ID>)`, Developer ID certs last five years | +| notary key | `~/.config/slosh/notary/AuthKey_XXXXXXXXXX.p8`, mode 0600 | +| notary profile | `slosh-notary`, stored **in the build keychain**, not the login one | + +### Why a separate keychain + +Nobody logs into the builder, so its **login keychain is never unlocked** and +anything that needs the private key over ssh dies with `User interaction is not +allowed`. A keychain of its own can be unlocked non-interactively from a +password file, and `Makefile.macos` does exactly that in its `unlock` target +before every sign and every notarization. It also holds nothing but slosh's +signing material, so handing this machine a second project's key is a decision, +not an accident. + +Two things make it usable headlessly, and both are easy to lose: + +* `security set-keychain-settings` with **no arguments** — no idle timeout and + no lock on sleep. The default six-hour timeout would relock the keychain + mid-notarization for no visible reason. +* `security set-key-partition-list -S apple-tool:,apple:,codesign:` — without + it macOS wants a click before letting `codesign` use the key, and over ssh + that is simply a failure. + +## Setting up a builder from scratch + +On a machine that holds the identity in its login keychain: + +```sh +contrib/macos-builder-setup m1 +``` + +Idempotent: every step checks first, so re-running it repairs whatever is +missing and touches nothing else. It installs zig, clones the repo, exports the +signing identity, creates the keychain, stores the notary credential, then +proves the machine can both **build and sign** before it claims success. + +It needs `~/.config/slosh/notary.env` on the machine you run it from: + +```sh +NOTARY_KEY=/Users/you/.config/slosh/notary/AuthKey_XXXXXXXXXX.p8 +NOTARY_KEY_ID=XXXXXXXXXX +NOTARY_ISSUER=<issuer-uuid from App Store Connect> +``` + +That file, and not the script, is where the credential lives — which is why +the scripts contain no secrets and are in the repo. + +### How the private key is moved + +`security export` hands over **every** identity in the login keychain, which on +a laptop also means localhost dev certs and whatever Compressor once installed. +The setup script exports the bundle, then picks out the one Developer ID — +pairing certificate to key by `localKeyID`, because the friendly name on a key +bag is the *person*, not the certificate, and name matching alone picks the +wrong key on a machine with more than one identity. It rebuilds a `.p12` +holding that key, its certificate and the Apple chain above it, ships that, and +deletes it on both ends. Nothing else leaves the laptop. + +## When it breaks + +**`errSecInternalComponent` from codesign.** Almost always the same identity in +**more than one keychain in the search list**. The error names nothing and +blames nothing; `make -f Makefile.macos builder-check` detects it and says so. + +```sh +ssh $BUILDER security find-identity -v -p codesigning # the same hash twice? +ssh $BUILDER security list-keychains -d user # who else is in the list +ssh $BUILDER security delete-keychain ~/Library/Keychains/<the extra one>-db +``` + +**`User interaction is not allowed`.** The keychain is locked and the `unlock` +target did not run, or the password file is gone. Check +`~/.config/slosh/build-keychain.pass` exists and is not empty. + +**Notarization says `Invalid`.** `Makefile.macos` fails the build and prints +Apple's log for the submission id rather than shipping it. `make -f +Makefile.macos notary-status` on the builder lists recent submissions. + +**`spctl --assess --type exec` says "does not seem to be an app".** That is not +a failure — it is what Gatekeeper says about every bare CLI binary ever signed, +because `--type exec` only understands app bundles. The check that means +something, and the one `contrib/macos-remote-build` runs on the builder for +every artifact, is the one a downloaded file gets: + +```sh +xattr -w com.apple.quarantine "0083;00000000;Safari;" slosh +spctl -a -t open --context context:primary-signature -vv slosh +# accepted / source=Notarized Developer ID +``` + +That path looks Apple's ticket up online, so "Notarized Developer ID" means a +stranger's mac will run it — not merely that we submitted something. + +## What is deliberately not here + +* **A stapled `.pkg`.** `make -f Makefile.macos pkg` needs a *Developer ID + Installer* certificate, which is a second cert separate from the Application + one. The zip is the Homebrew-friendly path anyway; a bare binary and a `.zip` + cannot be stapled, so Gatekeeper verifies them online on first run. +* **A CI trigger.** The builder holds a signing key; it runs when you ask it + to, from a machine you are sitting at, and not on a push hook. @@ -23,3 +23,4 @@ Driving it Inside design.md + macos-release.md diff --git a/macos-release.mk.example b/macos-release.mk.example new file mode 100644 index 0000000..930e0df --- /dev/null +++ b/macos-release.mk.example @@ -0,0 +1,53 @@ +# slosh — who signs the macOS builds, and on which mac. +# +# Copy this file to `macos-release.mk` and fill it in. That copy is gitignored: +# it is the *only* place the release flow keeps anything specific to you, which +# is what lets Makefile.macos and the contrib/ scripts live in the repo. +# +# cp macos-release.mk.example macos-release.mk +# $EDITOR macos-release.mk +# make macos-dist +# +# Nothing secret goes in here. The signing key lives in a keychain on the +# builder and the notarization credential is a keychain profile; neither is a +# value you could paste. This file only says *which* key and *which* mac. +# +# It is included by Makefile.macos, so it is make syntax: no quotes, and a +# value runs to the end of the line (trailing spaces included — do not add +# any after a name). + +# ── Apple ─────────────────────────────────────────────────────────────────── +# Your Developer Program Team ID: ten alphanumerics, shown at +# developer.apple.com/account under Membership. +TEAM_ID = XXXXXXXXXX + +# The name Apple issued the certificate to, exactly as it appears there. It is +# the middle of the identity string, and this prints the whole thing: +# +# security find-identity -v -p codesigning +# 1) A1B2... "Developer ID Application: Your Name (XXXXXXXXXX)" +# ^^^^^^^^^ this part +# +# A team certificate is issued to the organisation, so that name goes here +# instead of a person's. +SIGNER = Your Name + +# ── the builder ───────────────────────────────────────────────────────────── +# The mac that holds the private key, as an ssh host — a `Host` entry in +# ~/.ssh/config, or user@host. It does not have to be, and normally is not, +# the machine you develop on. contrib/macos-builder-setup provisions it. +BUILDER = m1 + +# Where the checkout lives on the builder. The default suits what +# macos-builder-setup creates; uncomment only to point somewhere else. +#BUILDER_DIR = src/slosh + +# ── optional ──────────────────────────────────────────────────────────────── +# Only used to print a copy-pasteable command in `make -f Makefile.macos +# setup-notary`. The credential itself is stored in the keychain, not here. +#APPLE_ID = you@example.com + +# The identity strings are assembled from SIGNER and TEAM_ID. Set them +# outright only if your certificate's common name is not the usual shape. +#IDENTITY = Developer ID Application: Your Name (XXXXXXXXXX) +#INSTALLER_IDENTITY = Developer ID Installer: Your Name (XXXXXXXXXX) |
