feat(appstore): add a Mac App Store build track

Tauri has no App Store target, so mas-package.sh covers the distance between the .app and something
App Store Connect will take. The order is load-bearing: the provisioning profile goes in before
codesign runs because the signature covers it, which is also why Tauri's own signing is switched
off for this build.

The sandbox costs three things, all Apple's rules rather than choices. The updater is gone, and
falls out for free because the plugin was already conditional on the config declaring it; only the
release overlay does. The Check for Updates menu item is gated on the same condition, since a menu
item that errors when clicked is its own rejection risk. The library moves into the container, and
the system spelling dictionary becomes unreadable.

Two things the first upload taught us. The profile is kept owner-only where it lives next to the
signing keys, and cp carried that mode into the bundle; Apple rejects a package containing anything
a non-root user cannot read. And altool exits 0 after printing UPLOAD FAILED, so its exit status
cannot be trusted and the transcript is the only reliable signal.

Claude-Session: https://claude.ai/code/session_018HwEWvoE1NkkUjJCMLSnup
This commit is contained in:
pj committed 2026-08-31 17:26:03 +05:30
1 parent 39d4097773
commit 92b446973f
7 files changed
+339 -6

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
name: App Store
on:
workflow_dispatch:
inputs:
tag:
description: "Tag to build, e.g. v0.1.18. Defaults to the latest release."
required: false
type: string
upload:
description: "Upload to App Store Connect. Off means build and sign only."
required: false
default: true
type: boolean
permissions:
contents: read
jobs:
build:
runs-on: macos-26
steps:
- name: Resolve the tag
id: tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
TAG="${{ inputs.tag }}"
if [ -z "$TAG" ]; then
TAG=$(gh release view --repo "$REPO" --json tagName --jq .tagName)
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Building $TAG for the App Store"
- uses: actions/checkout@v7
with:
ref: ${{ steps.tag.outputs.tag }}
- uses: actions/setup-node@v6
with:
node-version: 26
- uses: pnpm/action-setup@v6
with:
version: 10
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri -> target
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Provision Google credentials
env:
GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }}
run: |
if [ -n "$GOOGLE_CREDENTIALS" ]; then
printf '%s' "$GOOGLE_CREDENTIALS" > google-credentials.json
else
echo "::error::GOOGLE_CREDENTIALS is not set; an App Store build with placeholder credentials would ship a broken backup feature."
exit 1
fi
- name: Import the App Store certificates
env:
APP_CERT: ${{ secrets.MAS_APP_CERTIFICATE }}
APP_CERT_PASSWORD: ${{ secrets.MAS_APP_CERTIFICATE_PASSWORD }}
INSTALLER_CERT: ${{ secrets.MAS_INSTALLER_CERTIFICATE }}
INSTALLER_CERT_PASSWORD: ${{ secrets.MAS_INSTALLER_CERTIFICATE_PASSWORD }}
PROFILE: ${{ secrets.MAS_PROVISION_PROFILE }}
run: |
keychain="$RUNNER_TEMP/appstore.keychain-db"
password=$(uuidgen)
security create-keychain -p "$password" "$keychain"
security set-keychain-settings -lut 3600 "$keychain"
security unlock-keychain -p "$password" "$keychain"
import_p12() {
printf '%s' "$1" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$2" \
-T /usr/bin/codesign -T /usr/bin/productbuild
rm -f "$RUNNER_TEMP/cert.p12"
}
import_p12 "$APP_CERT" "$APP_CERT_PASSWORD"
import_p12 "$INSTALLER_CERT" "$INSTALLER_CERT_PASSWORD"
# Without this, codesign on a headless runner blocks on a keychain prompt nobody can
# answer and the job hangs until it times out.
security set-key-partition-list -S apple-tool:,apple: -k "$password" "$keychain" > /dev/null
security list-keychains -d user -s "$keychain" login.keychain-db
printf '%s' "$PROFILE" | base64 --decode > "$RUNNER_TEMP/margin.provisionprofile"
security find-identity -v "$keychain"
- name: Build the sandboxed bundle
run: |
# No APPLE_SIGNING_IDENTITY here on purpose: mas-package.sh signs, because the
# provisioning profile has to be inside the bundle before codesign runs.
pnpm tauri build --target universal-apple-darwin --config src-tauri/tauri.appstore.conf.json --bundles app
- name: Sign, package and upload
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
MAS_APP_IDENTITY: ${{ secrets.MAS_APP_IDENTITY }}
MAS_INSTALLER_IDENTITY: ${{ secrets.MAS_INSTALLER_IDENTITY }}
MAS_PROVISION_PROFILE: ${{ runner.temp }}/margin.provisionprofile
MAS_BUILD_NUMBER: ${{ github.run_number }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
MAS_UPLOAD: ${{ inputs.upload && '1' || '' }}
run: |
mkdir -p ~/private_keys
printf '%s' "$APPLE_API_KEY_P8" | base64 --decode > ~/private_keys/AuthKey_$APPLE_API_KEY_ID.p8
chmod 600 ~/private_keys/AuthKey_$APPLE_API_KEY_ID.p8
./scripts/mas-package.sh
- uses: actions/upload-artifact@v4
if: always()
with:
name: margin-appstore-pkg
path: target-mas/*.pkg
if-no-files-found: warn
+7
View File
@@ -33,3 +33,10 @@ src-tauri/target/
# Screenshots & Playwright MCP artifacts # Screenshots & Playwright MCP artifacts
.playwright-mcp/ .playwright-mcp/
/*.png /*.png
# App Store packaging output
target-mas/
# Beta app review contact details. Apple requires a real phone number and this repo is public.
appstore/metadata/review_phone.txt
appstore/metadata/review_email.txt
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Turn the .app that `tauri build` produced into a signed .pkg the App Store will accept, and
# optionally hand it to App Store Connect.
#
# Tauri has no App Store target, so everything after the bundle is done here: the provisioning
# profile goes in before signing (codesign hashes it), the entitlements carry the team identifier,
# and productbuild wraps the result. Tauri's own signing is deliberately not used, because it
# cannot embed a profile and would sign the app before the profile was in place.
set -euo pipefail
# CI builds universal; a local check against a single-arch build only needs to override this.
app="${APP_PATH:-src-tauri/target/universal-apple-darwin/release/bundle/macos/Margin.app}"
pkg="${PKG_PATH:-target-mas/Margin.pkg}"
: "${APPLE_TEAM_ID:?set APPLE_TEAM_ID to the 10-character team identifier}"
: "${MAS_PROVISION_PROFILE:?set MAS_PROVISION_PROFILE to the .provisionprofile path}"
: "${MAS_APP_IDENTITY:?set MAS_APP_IDENTITY, e.g. '3rd Party Mac Developer Application: Priyanshu Jain (TEAMID)'}"
: "${MAS_INSTALLER_IDENTITY:?set MAS_INSTALLER_IDENTITY, e.g. '3rd Party Mac Developer Installer: Priyanshu Jain (TEAMID)'}"
[ -d "$app" ] || { echo "mas-package: $app does not exist; run the build first." >&2; exit 1; }
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
mkdir -p "$(dirname "$pkg")"
# App Store Connect rejects an upload whose CFBundleVersion it has already seen, so a rejected
# build has to come back with a higher one. The marketing version stays put.
if [ -n "${MAS_BUILD_NUMBER:-}" ]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $MAS_BUILD_NUMBER" "$app/Contents/Info.plist"
fi
cp "$MAS_PROVISION_PROFILE" "$app/Contents/embedded.provisionprofile"
# The profile is kept owner-only where it lives, because it sits next to signing keys, and cp
# carries that mode across. Apple rejects a package containing anything a non-root user cannot
# read, since the code signature could not then be verified at launch. Widen everything rather
# than just the profile, and only ever add permission bits, never remove one.
find "$app" -type d -exec chmod go+rx {} +
find "$app" -type f -exec chmod go+r {} +
sed "s/__TEAM_ID__/$APPLE_TEAM_ID/g" src-tauri/entitlements.mas.plist > "$work/entitlements.plist"
plutil -lint "$work/entitlements.plist" > /dev/null
# Nested code has to be signed before the bundle that contains it, and --deep is the wrong tool
# for signing (it applies the outer entitlements to everything inside). Tauri bundles carry no
# frameworks today, so this loop is usually empty, and it stays here so that stops being silent
# the day one appears.
while IFS= read -r -d '' nested; do
codesign --force --timestamp --options runtime --sign "$MAS_APP_IDENTITY" "$nested"
done < <(find "$app/Contents/Frameworks" "$app/Contents/XPCServices" -maxdepth 1 -mindepth 1 -print0 2>/dev/null)
codesign --force --timestamp --options runtime \
--sign "$MAS_APP_IDENTITY" \
--entitlements "$work/entitlements.plist" \
"$app"
codesign --verify --deep --strict --verbose=2 "$app"
echo "Entitlements on the signed bundle:"
codesign --display --entitlements - --xml "$app" | plutil -convert xml1 -o - -
productbuild --component "$app" /Applications --sign "$MAS_INSTALLER_IDENTITY" "$pkg"
pkgutil --check-signature "$pkg"
echo "mas-package: wrote $pkg"
# The upload needs the .p8 where altool looks for it; the caller places it and sets these.
# altool exits 0 even when it has just printed UPLOAD FAILED, so its exit status cannot be
# trusted and the transcript is the only reliable signal.
run_altool() {
local action="$1" output
output=$(xcrun altool "$action" -f "$pkg" -t macos \
--apiKey "$APPLE_API_KEY_ID" --apiIssuer "$APPLE_API_ISSUER" 2>&1) || true
printf '%s\n' "$output"
if printf '%s' "$output" | grep -qE "VERIFY FAILED|UPLOAD FAILED|ERROR:"; then
echo "mas-package: $action failed, see the errors above." >&2
return 1
fi
}
if [ -n "${MAS_UPLOAD:-}" ]; then
: "${APPLE_API_KEY_ID:?}" "${APPLE_API_ISSUER:?}"
run_altool --validate-app
run_altool --upload-app
echo "mas-package: uploaded to App Store Connect; the build appears once processing finishes."
fi
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Sign, package and upload an App Store build from this machine, using the certificates in
# ~/.margin-signing rather than the ones in CI.
#
# The certificates go into a keychain that exists only for the length of this run, and the login
# keychain is never written to. That keeps a machine that has signed once from quietly being able
# to sign forever, and it means this leaves nothing behind to go stale.
#
# ./scripts/mas-upload-local.sh # build, sign, package, upload
# MAS_UPLOAD= ./scripts/mas-upload-local.sh # stop after the .pkg
set -euo pipefail
cd "$(dirname "$0")/.."
DIR="${MARGIN_SIGNING_DIR:-$HOME/.margin-signing}"
BUNDLE_ID="${BUNDLE_ID:-studio.margin.app}"
KEYCHAIN="$(mktemp -d)/margin-mas.keychain-db"
for f in "$BUNDLE_ID.env" "$BUNDLE_ID.provisionprofile" apple-distribution.p12 mac-installer.p12; do
[ -f "$DIR/$f" ] || { echo "mas-upload-local: $DIR/$f is missing; run apple-provision.rb first." >&2; exit 1; }
done
# shellcheck source=/dev/null
set -a; . "$DIR/$BUNDLE_ID.env"; set +a
export MAS_PROVISION_PROFILE="$DIR/$BUNDLE_ID.provisionprofile"
# App Store Connect refuses an upload whose build number it has already seen, and seconds since the
# epoch is both unique and monotonic without needing anything to be remembered between runs.
export MAS_BUILD_NUMBER="${MAS_BUILD_NUMBER:-$(date +%s)}"
export MAS_UPLOAD="${MAS_UPLOAD-1}"
if [ -n "${MAS_UPLOAD:-}" ]; then
# shellcheck source=/dev/null
set -a; . "$DIR/AuthKey.env"; set +a
mkdir -p ~/private_keys
cp "$DIR/AuthKey.p8" ~/private_keys/"AuthKey_$APPLE_API_KEY_ID.p8"
chmod 600 ~/private_keys/"AuthKey_$APPLE_API_KEY_ID.p8"
fi
original_keychains=$(security list-keychains -d user | sed 's/^ *"//;s/"$//')
restore() {
# shellcheck disable=SC2086
security list-keychains -d user -s $original_keychains
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
}
trap restore EXIT
security create-keychain -p margin "$KEYCHAIN"
security unlock-keychain -p margin "$KEYCHAIN"
for cert in apple-distribution mac-installer; do
security import "$DIR/$cert.p12" -k "$KEYCHAIN" -P "$(cat "$DIR/$cert.p12.pass")" \
-T /usr/bin/codesign -T /usr/bin/productbuild > /dev/null
done
# Without this, codesign stops on a keychain prompt rather than signing.
security set-key-partition-list -S apple-tool:,apple: -k margin "$KEYCHAIN" > /dev/null
# shellcheck disable=SC2086
security list-keychains -d user -s "$KEYCHAIN" $original_keychains
if [ -z "${SKIP_BUILD:-}" ]; then
pnpm tauri build --target universal-apple-darwin \
--config src-tauri/tauri.appstore.conf.json --bundles app
fi
./scripts/mas-package.sh
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Google Drive backup talks to googleapis.com. Nothing else leaves the machine. -->
<key>com.apple.security.network.client</key>
<true/>
<!-- The Drive OAuth flow redirects to a loopback listener on 127.0.0.1 (gdrive.rs), which is
the only installed-app flow Google still supports. Without this the sandbox refuses the
bind and sign-in hangs on the browser tab that has nowhere to come back to. -->
<key>com.apple.security.network.server</key>
<true/>
<!-- Importing an EPUB and exporting a PDF or EPUB both go through NSOpenPanel/NSSavePanel, so
the app only ever reaches the one file the user pointed at. The library itself lives in
the container and needs no entitlement. -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.application-identifier</key>
<string>__TEAM_ID__.studio.margin.app</string>
<key>com.apple.developer.team-identifier</key>
<string>__TEAM_ID__</string>
</dict>
</plist>
+17 -6
View File
@@ -30,8 +30,13 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…") let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…")
.accelerator("CmdOrCtrl+Shift+E") .accelerator("CmdOrCtrl+Shift+E")
.build(handle)?; .build(handle)?;
let check_updates = let check_updates = handle
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?; .config()
.plugins
.0
.contains_key("updater")
.then(|| MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle))
.transpose()?;
let settings = MenuItemBuilder::with_id("settings", "Settings…") let settings = MenuItemBuilder::with_id("settings", "Settings…")
.accelerator("CmdOrCtrl+,") .accelerator("CmdOrCtrl+,")
.build(handle)?; .build(handle)?;
@@ -93,9 +98,13 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
if let Some(app_submenu) = submenus.first() { if let Some(app_submenu) = submenus.first() {
app_submenu.insert(&check_updates, 1)?; let mut settings_at = 2;
app_submenu.insert(&settings, 3)?; if let Some(check_updates) = &check_updates {
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?; app_submenu.insert(check_updates, 1)?;
settings_at += 1;
}
app_submenu.insert(&settings, settings_at)?;
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, settings_at + 1)?;
} }
if let Some(window) = find_submenu("Window") { if let Some(window) = find_submenu("Window") {
let show_window = MenuItemBuilder::with_id("show-window", "Open Window") let show_window = MenuItemBuilder::with_id("show-window", "Open Window")
@@ -126,7 +135,9 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
{ {
if let Some(file) = find_submenu("File") { if let Some(file) = find_submenu("File") {
file.append_items(&[&PredefinedMenuItem::separator(handle)?, &check_updates])?; if let Some(check_updates) = &check_updates {
file.append_items(&[&PredefinedMenuItem::separator(handle)?, check_updates])?;
}
} }
if let Some(edit) = find_submenu("Edit") { if let Some(edit) = find_submenu("Edit") {
edit.append_items(&[&settings])?; edit.append_items(&[&settings])?;
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"targets": ["app"],
"macOS": {
"hardenedRuntime": true
}
}
}