build(macos): build the App Store Connect package with make appstore
- The App Store variant was only signed ad hoc to run locally, so nothing it produced could be submitted; with a Mac App Store Connect provisioning profile in .signing/app-store.provisionprofile it now embeds the profile, adds the profile's App ID and team to the app's entitlements, signs both executables with the keychain's Apple Distribution identity, and wraps the app in an installer package signed with Mac Installer Distribution. - A profile whose App ID is not the bundle's is refused, and a missing distribution or installer certificate stops the build rather than producing an unsubmittable package. - CFBundleVersion becomes the build's UTC time to the minute, since App Store Connect rejects a build number it has seen, while CFBundleShortVersionString stays VERSION; ITSAppUsesNonExemptEncryption is false, as the build's only cryptography is hashing and random numbers. - The bundled daemon keeps only the sandbox and inherit entitlements, which a helper inheriting its parent's sandbox requires. - Without a profile the variant builds as before, ad hoc or with MIDI_HARBOR_SIGNING_IDENTITY, to run on the building Mac.
This commit is contained in:
parent
488e42b9c8
commit
b70ac24cc4
8 changed files with 229 additions and 16 deletions
7
Makefile
7
Makefile
|
|
@ -81,6 +81,13 @@ release: | $(SYSROOT_DONE)
|
|||
|| { echo "HEAD is not tagged v$(VERSION); tag it and push the tag: git tag v$(VERSION) && git push origin v$(VERSION)" >&2; exit 1; }
|
||||
$(call GORELEASER,--env-file .release-env) release --clean --parallelism 1
|
||||
|
||||
# Builds the Mac App Store variant on a Mac, into target/package/macos-app-store: an installer
|
||||
# package for App Store Connect when .signing/app-store.provisionprofile exists, and an app signed
|
||||
# to run on this Mac otherwise. See packaging/README.md.
|
||||
.PHONY: appstore
|
||||
appstore:
|
||||
packaging/macos/build.sh --app-store
|
||||
|
||||
# Removes every build output, the Linux sysroots under target/ included, which the next release
|
||||
# builds again.
|
||||
.PHONY: clean
|
||||
|
|
|
|||
|
|
@ -73,14 +73,44 @@ To build them on a Mac without Docker, run `packaging/macos/build.sh`, which wri
|
|||
and builds a universal binary when both Rust targets are installed (`rustup target add
|
||||
x86_64-apple-darwin aarch64-apple-darwin`), and a binary for the building Mac otherwise.
|
||||
|
||||
`packaging/macos/build.sh --app-store` builds the Mac App Store variant instead, into
|
||||
`target/package/macos-app-store/`: sandboxed, requiring macOS 13, starting without a Dock icon,
|
||||
and carrying the headless build as `Contents/MacOS/midi-harbor-daemon`, the daemon the app starts.
|
||||
The app is signed with `packaging/macos/app-store.entitlements` and the helper with
|
||||
`packaging/macos/helper.entitlements`, which lets it take the app's sandbox. It makes no disk
|
||||
image: the App Store takes an installer package, built with the owner's certificates and
|
||||
provisioning profile. It signs ad hoc unless `MIDI_HARBOR_SIGNING_IDENTITY` names a certificate,
|
||||
since a Developer ID one is the wrong kind for the App Store.
|
||||
`make appstore`, which runs `packaging/macos/build.sh --app-store`, builds the Mac App Store
|
||||
variant instead, into `target/package/macos-app-store/`: sandboxed, requiring macOS 13, starting
|
||||
without a Dock icon, and carrying the headless build as `Contents/MacOS/midi-harbor-daemon`, the
|
||||
daemon the app starts. The app is signed with `packaging/macos/app-store.entitlements` and the
|
||||
helper with `packaging/macos/helper.entitlements`, which lets it take the app's sandbox. It makes
|
||||
no disk image.
|
||||
|
||||
With a provisioning profile in `.signing/app-store.provisionprofile` it is built for submission,
|
||||
as `Midi-Harbor-<version>.pkg` beside the app:
|
||||
|
||||
- the profile is embedded as `Contents/embedded.provisionprofile`, and the App ID and team it
|
||||
names are added to the app's entitlements; a profile for another App ID is refused;
|
||||
- both executables are signed with the keychain's Apple Distribution identity, or the one
|
||||
`MIDI_HARBOR_SIGNING_IDENTITY` names;
|
||||
- the build number, `CFBundleVersion`, is the build's time in UTC (`202609281937`), since every
|
||||
upload needs a higher one, while the version shown stays `VERSION`;
|
||||
- `ITSAppUsesNonExemptEncryption` is false: hashing and random numbers are all the encryption
|
||||
Midi Harbor has;
|
||||
- the installer package is signed with the keychain's Mac Installer Distribution identity, which
|
||||
the keychain names "3rd Party Mac Developer Installer".
|
||||
|
||||
Upload the package with Apple's Transporter app, then choose the build in App Store Connect for
|
||||
TestFlight or review. An App Store build does not run until the App Store installs it. Without
|
||||
the profile, the app is signed ad hoc, or with `MIDI_HARBOR_SIGNING_IDENTITY`, to run on this Mac.
|
||||
|
||||
Setting up for submission, once:
|
||||
|
||||
1. In Xcode, **Settings → Accounts → Manage Certificates**, add an **Apple Distribution** and a
|
||||
**Mac Installer Distribution** certificate. If the keychain calls them untrusted
|
||||
(`security find-identity -v` leaves them out), install Apple's
|
||||
[WWDR G3 intermediate](https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer).
|
||||
2. In the developer portal, **Identifiers**, register an explicit macOS App ID for
|
||||
`com.mrgeckosmedia.MidiHarbor`, with no capabilities: every entitlement the build uses is a
|
||||
sandbox one that signing grants.
|
||||
3. In **Profiles**, generate a **Mac App Store Connect** distribution profile for that App ID and
|
||||
the Apple Distribution certificate, and save it as `.signing/app-store.provisionprofile`. It
|
||||
lasts a year, and is made again when it or the certificate is renewed.
|
||||
4. In App Store Connect, add the app with that bundle ID.
|
||||
|
||||
The app is signed with the hardened runtime either way, as notarization requires. Bluetooth
|
||||
permission is granted to the signed app, which is why the daemon should run from inside it:
|
||||
|
|
@ -106,8 +136,9 @@ every build that finds them, `make snapshot` included, and adds a few minutes.
|
|||
To make them, once:
|
||||
|
||||
1. In Xcode, **Settings → Accounts → Manage Certificates**, add a **Developer ID Application**
|
||||
certificate; only the team's Account Holder can. In Keychain Access, export it with its private key from **My Certificates** as
|
||||
`.signing/developer-id.p12`, and write the password to `.signing/developer-id.p12.password`.
|
||||
certificate; only the team's Account Holder can. In Keychain Access, export it with its
|
||||
private key from **My Certificates** as `.signing/developer-id.p12`, and write the password to
|
||||
`.signing/developer-id.p12.password`.
|
||||
Keychain Access exports the older `.p12` encryption rcodesign reads; one made by OpenSSL 3
|
||||
needs `-legacy`.
|
||||
2. In App Store Connect, **Users and Access → Integrations → App Store Connect API**, generate a
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
#
|
||||
# --app-store builds the sandboxed App Store variant instead, under target/package/macos-app-store:
|
||||
# the full program as the app and the headless build as the daemon it starts
|
||||
# (specs/014-mac-app-store-mode/contracts/bundle.md).
|
||||
# (specs/014-mac-app-store-mode/contracts/bundle.md), and the installer package App Store Connect
|
||||
# takes when .signing/app-store.provisionprofile exists. `make appstore` runs it.
|
||||
#
|
||||
# Signs and notarizes as packaging/macos/bundle.sh describes. Builds universal binaries
|
||||
# when the x86_64 target is installed (rustup target add x86_64-apple-darwin), and binaries for
|
||||
|
|
|
|||
|
|
@ -20,8 +20,12 @@
|
|||
#
|
||||
# --helper makes the App Store variant instead, on a Mac only: the sandboxed app, with the helper
|
||||
# as the daemon it starts (Contents/MacOS/midi-harbor-daemon), requiring macOS 13 and starting
|
||||
# without a Dock icon. It makes no disk image, since the App Store takes an installer package
|
||||
# built with the owner's certificates (specs/014-mac-app-store-mode/contracts/bundle.md).
|
||||
# without a Dock icon (specs/014-mac-app-store-mode/contracts/bundle.md). With a Mac App Store
|
||||
# Connect provisioning profile in .signing/app-store.provisionprofile it is built for submission:
|
||||
# the profile embedded, signed with the keychain's Apple Distribution identity, and wrapped in an
|
||||
# installer package signed with its Mac Installer Distribution identity, the only form App Store
|
||||
# Connect takes (specs/016-app-store-submission). Without one it is signed ad hoc, or with
|
||||
# MIDI_HARBOR_SIGNING_IDENTITY, to run on this Mac.
|
||||
set -eu
|
||||
|
||||
helper=
|
||||
|
|
@ -53,19 +57,66 @@ sed "s/@VERSION@/${version%%-*}/g" packaging/macos/Info.plist > "$app/Contents/I
|
|||
# The App Store variant: the helper signed first, since signing the bundle seals it, then the
|
||||
# app with the sandbox.
|
||||
if [ -n "$helper" ]; then
|
||||
identity=${MIDI_HARBOR_SIGNING_IDENTITY:--}
|
||||
profile=.signing/app-store.provisionprofile
|
||||
entitlements=packaging/macos/app-store.entitlements
|
||||
cp "$helper" "$app/Contents/MacOS/midi-harbor-daemon"
|
||||
chmod 0755 "$app/Contents/MacOS/midi-harbor-daemon"
|
||||
plutil -replace LSMinimumSystemVersion -string 13.0 "$app/Contents/Info.plist"
|
||||
plutil -replace LSUIElement -bool true "$app/Contents/Info.plist"
|
||||
# Hashing and random numbers are all the encryption it has, which is exempt, so App Store
|
||||
# Connect need not ask at every upload.
|
||||
plutil -replace ITSAppUsesNonExemptEncryption -bool false "$app/Contents/Info.plist"
|
||||
|
||||
# For submission: the identities, the profile, and the identifiers it grants.
|
||||
if [ -f "$profile" ]; then
|
||||
identity=${MIDI_HARBOR_SIGNING_IDENTITY:-$(security find-identity -v -p codesigning \
|
||||
| sed -n 's/^.*"\(Apple Distribution: .*\)"$/\1/p' | head -n 1)}
|
||||
installer=$(security find-identity -v \
|
||||
| sed -n 's/^.*"\(3rd Party Mac Developer Installer: .*\)"$/\1/p' | head -n 1)
|
||||
if [ -z "$identity" ] || [ -z "$installer" ]; then
|
||||
echo "$profile needs an Apple Distribution and a Mac Installer Distribution" \
|
||||
"certificate in the keychain; see packaging/README.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
security cms -D -i "$profile" -o "$out/profile.plist"
|
||||
field() { /usr/libexec/PlistBuddy -c "Print :$1" "$out/profile.plist"; }
|
||||
team=$(field Entitlements:com.apple.developer.team-identifier)
|
||||
app_id=$(field Entitlements:com.apple.application-identifier)
|
||||
bundle_id=$(plutil -extract CFBundleIdentifier raw "$app/Contents/Info.plist")
|
||||
if [ "$app_id" != "$team.$bundle_id" ]; then
|
||||
echo "$profile is for $app_id, not $team.$bundle_id" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$profile" "$app/Contents/embedded.provisionprofile"
|
||||
entitlements="$out/app-store.entitlements"
|
||||
cp packaging/macos/app-store.entitlements "$entitlements"
|
||||
/usr/libexec/PlistBuddy \
|
||||
-c "Add :com.apple.application-identifier string $app_id" \
|
||||
-c "Add :com.apple.developer.team-identifier string $team" "$entitlements"
|
||||
rm "$out/profile.plist"
|
||||
# Every upload needs a build number above the last, whatever the version, so it is the
|
||||
# time of the build.
|
||||
plutil -replace CFBundleVersion -string "$(date -u +%Y%m%d%H%M)" \
|
||||
"$app/Contents/Info.plist"
|
||||
else
|
||||
identity=${MIDI_HARBOR_SIGNING_IDENTITY:--}
|
||||
fi
|
||||
|
||||
# Sign.
|
||||
plutil -lint "$app/Contents/Info.plist" >/dev/null
|
||||
codesign --force --options runtime --sign "$identity" \
|
||||
--entitlements packaging/macos/helper.entitlements \
|
||||
"$app/Contents/MacOS/midi-harbor-daemon"
|
||||
codesign --force --options runtime --sign "$identity" \
|
||||
--entitlements packaging/macos/app-store.entitlements "$app"
|
||||
codesign --force --options runtime --sign "$identity" --entitlements "$entitlements" "$app"
|
||||
codesign --verify --strict "$app"
|
||||
echo "$app"
|
||||
|
||||
# Package for App Store Connect.
|
||||
if [ -f "$profile" ]; then
|
||||
pkg="$out/Midi-Harbor-$version.pkg"
|
||||
productbuild --quiet --component "$app" /Applications --sign "$installer" "$pkg"
|
||||
echo "$pkg"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
|
|||
51
specs/016-app-store-submission/research.md
Normal file
51
specs/016-app-store-submission/research.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Research: App Store Submission
|
||||
|
||||
The investigation behind this spec, under its number in the project-wide research log.
|
||||
|
||||
---
|
||||
|
||||
## R-103: What App Store Connect needs beyond the sandboxed app
|
||||
|
||||
**Status**: **VERIFIED** (2026-09-28) locally; not yet uploaded. Built as T245.
|
||||
|
||||
**The App ID needs no capabilities.** Every entitlement the build uses (the sandbox, network
|
||||
client and server, Bluetooth, USB, and the helper's inherit) is a sandbox entitlement that
|
||||
signing grants. The login item uses `SMAppService`, and the helper shares the app's container by
|
||||
inheriting its sandbox, so App Groups are not needed either. The profile Apple generated for the
|
||||
bare App ID carries `com.apple.application-identifier`, `com.apple.developer.team-identifier` and
|
||||
`keychain-access-groups` (`<team>.*`). The first two must be in the app's signed entitlements to
|
||||
match the profile; keychain access groups are not used, so they are left out.
|
||||
|
||||
**The certificates came untrusted.** Xcode created the Apple Distribution and Mac Installer
|
||||
Distribution certificates, but `security find-identity` called both `CSSMERR_TP_NOT_TRUSTED`:
|
||||
they are issued by Apple's WWDR G3 intermediate, which this Mac lacked. The Developer ID
|
||||
certificate, issued by another intermediate, was unaffected. Installing
|
||||
`AppleWWDRCAG3.cer` made both valid. The keychain names the installer certificate
|
||||
"3rd Party Mac Developer Installer", its older name, which is what the build looks for.
|
||||
|
||||
**The helper keeps only the sandbox and inherit.** A helper signed to inherit its parent's
|
||||
sandbox must carry exactly those two entitlements, so the profile's identifiers go on the app
|
||||
alone.
|
||||
|
||||
**The build number is the build's time.** App Store Connect refuses a build number it has seen
|
||||
for the app, so `CFBundleVersion` is the time in UTC, `%Y%m%d%H%M`, twelve digits. The version
|
||||
shown, `CFBundleShortVersionString`, stays `VERSION`. A count of commits was rejected, since the
|
||||
owner rewrites history before publishing and the count would fall.
|
||||
|
||||
**Encryption.** The macOS build links no TLS; `cargo tree` finds only `sha2` and `chacha20`,
|
||||
used for hashing and random numbers, which are exempt. `ITSAppUsesNonExemptEncryption` is false,
|
||||
so App Store Connect does not ask at every upload.
|
||||
|
||||
**Evidence**, `make appstore` on the development Mac:
|
||||
|
||||
- `lipo -archs`: `x86_64 arm64` for both executables, once `x86_64-apple-darwin` was installed.
|
||||
- `codesign -dvv`: both signed by "Apple Distribution: James Coleman (8XMLMKNPUT)" with the
|
||||
hardened runtime. The app's entitlements are the sandbox list plus
|
||||
`8XMLMKNPUT.com.mrgeckosmedia.MidiHarbor` and `8XMLMKNPUT`. The helper's are the sandbox and
|
||||
inherit.
|
||||
- `Contents/embedded.provisionprofile` present; `codesign --verify --deep --strict` passes.
|
||||
- `pkgutil --check-signature`: signed by "3rd Party Mac Developer Installer: James Coleman
|
||||
(8XMLMKNPUT)", which it reports as a development certificate, as it does every App Store
|
||||
installer certificate.
|
||||
|
||||
**Not checked**: App Store Connect's own validation, which runs on upload.
|
||||
59
specs/016-app-store-submission/spec.md
Normal file
59
specs/016-app-store-submission/spec.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Feature Specification: App Store Submission
|
||||
|
||||
**Created**: 2026-09-28
|
||||
|
||||
**Status**: Implemented; built and checked on 2026-09-28 with the owner's certificates and
|
||||
profile, not yet uploaded
|
||||
|
||||
**Input**: User request: "How do I build for the app store?", then the certificates and the
|
||||
provisioning profile, and "Okay, added."
|
||||
|
||||
014 built the sandboxed App Store variant, signed ad hoc to run on the building Mac, and left the
|
||||
package App Store Connect takes to the owner. `make appstore` now builds that package when the
|
||||
owner's provisioning profile is in `.signing/`.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Building a submission (Priority: P1)
|
||||
|
||||
The owner runs `make appstore` and gets `Midi-Harbor-<version>.pkg`, which Transporter uploads
|
||||
to App Store Connect as a new build of Midi Harbor.
|
||||
|
||||
**Why this priority**: it is the whole request.
|
||||
|
||||
**Independent Test**: With the certificates and profile in place, run `make appstore` and check
|
||||
the signatures, entitlements, embedded profile, architectures and Info.plist of what it makes.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the profile and both certificates, **When** `make appstore` runs, **Then** it makes
|
||||
a universal app signed with Apple Distribution, the profile embedded and its identifiers in
|
||||
the app's entitlements, and a package signed with Mac Installer Distribution.
|
||||
2. **Given** two builds of one version, **When** both are uploaded, **Then** the second is
|
||||
accepted, its build number being higher.
|
||||
3. **Given** a profile for another App ID, **When** `make appstore` runs, **Then** it stops,
|
||||
naming both.
|
||||
4. **Given** no profile, **When** `make appstore` runs, **Then** it builds the app to run on this
|
||||
Mac, as before.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-S01**: `make appstore` MUST build an installer package App Store Connect accepts, from the
|
||||
profile and certificates alone, with nothing else to set.
|
||||
- **FR-S02**: Each build MUST carry a build number higher than any before it.
|
||||
- **FR-S03**: The build MUST refuse a profile whose App ID is not the bundle's.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-S01**: A new build reaches App Store Connect with one command and one upload.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Uploading stays with Transporter: an API key upload needs the App Store Connect key that
|
||||
notarization is also waiting for, and can be added with it.
|
||||
- The build number is the build's time in UTC to the minute; two submissions within one minute
|
||||
are not expected.
|
||||
5
specs/016-app-store-submission/tasks.md
Normal file
5
specs/016-app-store-submission/tasks.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Tasks: App Store Submission
|
||||
|
||||
Tasks by their numbers in the project-wide sequence, which continues across every spec.
|
||||
|
||||
- [x] T245 Build the App Store package with `make appstore`, per FR-S01 to FR-S03 — done: with `.signing/app-store.provisionprofile`, `bundle.sh --helper` embeds the profile, adds its App ID and team to the app's entitlements after checking the App ID is the bundle's, signs both executables with the keychain's Apple Distribution identity, sets the build number to the time in UTC and `ITSAppUsesNonExemptEncryption` to false, and wraps the app in a package signed with Mac Installer Distribution; without a profile it builds as before. Checked on the owner's Mac (R-103); not unit tested, since it is a build script.
|
||||
|
|
@ -26,6 +26,7 @@ exception is 014, whose task list started again at T001: its T001 to T034 are ci
|
|||
| [013-windows-support](013-windows-support/spec.md) | Windows as a first-class platform |
|
||||
| [014-mac-app-store-mode](014-mac-app-store-mode/spec.md) | The sandboxed App Store build, run from the menu bar |
|
||||
| [015-mac-menus](015-mac-menus/spec.md) | The menus on macOS: File, Edit, View, Window and Help |
|
||||
| [016-app-store-submission](016-app-store-submission/spec.md) | Building the package App Store Connect takes |
|
||||
|
||||
## Where each number is
|
||||
|
||||
|
|
@ -129,6 +130,13 @@ exception is 014, whose task list started again at T001: its T001 to T034 are ci
|
|||
- **Research**: R-102
|
||||
- **Tasks**: T244
|
||||
|
||||
### 016-app-store-submission
|
||||
|
||||
- **Requirements**: FR-S01, FR-S02, FR-S03
|
||||
- **Success criteria**: SC-S01
|
||||
- **Research**: R-103
|
||||
- **Tasks**: T245
|
||||
|
||||
## Earlier names
|
||||
|
||||
Until 2026-09-27 there were three specs. Branches, commit messages and older notes use these names.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue