diff --git a/.gitignore b/.gitignore index ac411f7..4d0d7f6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,3 @@ DerivedData/ # Your signing identity — copy Config/Local.xcconfig.example to create this Config/Local.xcconfig - -# Your notarization credentials — copy Config/notary.env.example to create this -Config/notary.env diff --git a/Config/notary.env.example b/Config/notary.env.example deleted file mode 100644 index d0264fd..0000000 --- a/Config/notary.env.example +++ /dev/null @@ -1,13 +0,0 @@ -# Copy to Config/notary.env to notarize a release build. -# Config/notary.env is gitignored, so nothing about your account is committed. -# -# cp Config/notary.env.example Config/notary.env -# -# These are the three values an App Store Connect API key comes with. The key -# id is in the file name Apple gives you (AuthKey_.p8); the issuer id -# is on the same App Store Connect > Users and Access > Integrations page you -# downloaded the key from. - -NOTARY_KEY="$HOME/.appstoreconnect/private_keys/AuthKey_XXXXXXXXXX.p8" -NOTARY_KEY_ID="XXXXXXXXXX" -NOTARY_ISSUER="00000000-0000-0000-0000-000000000000" diff --git a/Makefile b/Makefile index d6cdeff..2c45d52 100644 --- a/Makefile +++ b/Makefile @@ -13,9 +13,11 @@ install: ./scripts/install.sh # A signed, notarized, stapled DMG. Runs on this machine, never in CI, so the -# Developer ID certificate stays here. +# Developer ID certificate stays here. The pipeline itself lives in the +# account-level tool (github.com/max1874/apple-developer, `asc`); this repo +# only builds the .app, `asc notarize` takes over from build/TypeSwitch.app. release: - ./scripts/release.sh + asc notarize type-switch clean: rm -rf build diff --git a/README.md b/README.md index 76e4dfd..01942ee 100644 --- a/README.md +++ b/README.md @@ -176,14 +176,10 @@ that order, so the situation cannot arise. A build refuses to run at all if it would overwrite a path something is running from. `make release` runs on a maintainer's own machine rather than in CI, so the -Developer ID certificate never leaves it. It reads the notarization -credentials from `Config/notary.env`, which is gitignored; create it once from -the example and fill in the three values that come with an App Store Connect -API key: - -```sh -cp Config/notary.env.example Config/notary.env -``` +Developer ID certificate never leaves it. This repo only builds the app; the +signing, notarization, stapling, and DMG steps are the `asc notarize` command +from the maintainer's account-level tooling, which also holds the App Store +Connect credentials. Nothing about the account lives in this repo. ## Known limitations diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 0661f52..7f147aa 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -154,12 +154,9 @@ make clean # 清掉 build/ 这种状态就不会出现;而构建时如果发现有进程正从将被覆盖的路径运行,会直接停下来。 `make release` 只在维护者自己的机器上跑,不进 CI,所以 Developer ID 证书不会离开 -本机。公证凭证从 `Config/notary.env` 读,这个文件已 gitignore;照样例复制一份,填上 -App Store Connect API key 自带的那三个值: - -```sh -cp Config/notary.env.example Config/notary.env -``` +本机。这个仓库只负责构建 app;签名、公证、装订、打 DMG 这几步是维护者账号级工具里的 +`asc notarize` 命令,App Store Connect 的凭据也在那边。这个仓库里没有任何账号相关的 +东西。 ## 已知限制 diff --git a/scripts/audit-release.sh b/scripts/audit-release.sh deleted file mode 100755 index 41f0267..0000000 --- a/scripts/audit-release.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/bin/sh -# Checks a built disk image the way a first-time download is checked, and -# fails on anything a downloader would hit. -# -# release.sh runs this at the end, but it takes a path, so it also works on a -# downloaded image: -# -# ./scripts/audit-release.sh ~/Downloads/TypeSwitch-1.0.0.dmg 1.0.0 -set -eu - -if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then - echo "Usage: $0 [expected-version]" >&2 - exit 2 -fi - -dmg=$1 -expected_version=${2-} -work_dir=$(mktemp -d -t typeswitch-audit) -work_dir=$(CDPATH='' cd -- "$work_dir" && pwd -P) -mount_dir="$work_dir/mount" -mkdir "$mount_dir" -mounted=0 - -cleanup() { - status=$? - trap - EXIT HUP INT TERM - - if [ "$mounted" -eq 1 ] && ! hdiutil detach "$mount_dir" >/dev/null; then - echo "Audit cleanup failed: could not detach $mount_dir." >&2 - status=1 - fi - - if mount | grep -Fq " on $mount_dir "; then - echo "Audit cleanup warning: $mount_dir is still mounted." >&2 - status=1 - else - case "$(basename "$work_dir")" in - typeswitch-audit.*) rm -rf "$work_dir" || status=1 ;; - *) echo "Refusing to remove unexpected audit directory: $work_dir" >&2; status=1 ;; - esac - fi - - exit "$status" -} -trap cleanup EXIT -trap 'exit 1' HUP INT TERM - -fail() { - echo "Release audit failed: $1" >&2 - exit 1 -} - -if [ ! -f "$dmg" ]; then - fail "no disk image at $dmg" -fi - -hdiutil verify "$dmg" >/dev/null - -xcrun stapler validate "$dmg" >/dev/null 2>&1 \ - || fail "the disk image carries no stapled notarization ticket." - -spctl --assess --type open --context context:primary-signature "$dmg" >/dev/null 2>&1 \ - || fail "Gatekeeper rejects the disk image." - -hdiutil attach -nobrowse -readonly -mountpoint "$mount_dir" "$dmg" >/dev/null -mounted=1 - -app="$mount_dir/TypeSwitch.app" -executable="$app/Contents/MacOS/TypeSwitch" - -[ -d "$app" ] || fail "the disk image holds no TypeSwitch.app." - -codesign --verify --deep --strict "$app" -signature=$(codesign -d --verbose=4 "$app" 2>&1) - -printf '%s\n' "$signature" | grep -q '^Authority=Developer ID Application: ' \ - || fail "the app is not signed with a Developer ID Application certificate." -printf '%s\n' "$signature" | grep -q '^TeamIdentifier=[A-Z0-9]' \ - || fail "no team identifier is embedded." -printf '%s\n' "$signature" | grep -q '^Timestamp=' \ - || fail "the signature carries no secure timestamp." -printf '%s\n' "$signature" | grep -q 'flags=.*runtime' \ - || fail "the hardened runtime is not enabled." - -# The reason this script exists. Stapling only the image leaves the copy -# someone drags out of it depending on a network round trip to Apple on first -# launch, and nothing else here would notice that coming back. -xcrun stapler validate "$app" >/dev/null 2>&1 \ - || fail "the app inside the image carries no stapled ticket of its own." - -assessment=$(spctl --assess --type exec --verbose=4 "$app" 2>&1 || true) -printf '%s\n' "$assessment" | grep -Fq "source=Notarized Developer ID" \ - || fail "Gatekeeper does not report the app as notarized: -$assessment" - -if [ -n "$expected_version" ]; then - actual=$(plutil -extract CFBundleShortVersionString raw "$app/Contents/Info.plist") - [ "$actual" = "$expected_version" ] \ - || fail "expected version $expected_version, found $actual." -fi - -architectures=$(lipo -archs "$executable") -case " $architectures " in - *" arm64 "*) ;; - *) fail "Apple silicon architecture is missing (found: $architectures)." ;; -esac - -echo "Release audit passed: Developer ID signature, hardened runtime, secure timestamp," -echo "tickets stapled to both the image and the app, Gatekeeper accepts, arm64 present." diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 81adff8..73af221 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -2,7 +2,7 @@ # Builds TypeSwitch.app into build/ and leaves it there. # # Signing follows Config/TypeSwitch.xcconfig, so this produces the same -# development build Xcode does. For a distributable build, use release.sh. +# development build Xcode does. For a distributable build, use `make release`. set -eu project_dir=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100755 index a9b8e43..0000000 --- a/scripts/release.sh +++ /dev/null @@ -1,192 +0,0 @@ -#!/bin/sh -# Builds a signed, notarized, stapled DMG that opens on someone else's Mac -# without a Gatekeeper detour. -# -# Runs locally, not in CI, so the Developer ID certificate never leaves this -# machine. Notarization credentials come from Config/notary.env, which is -# gitignored; see Config/notary.env.example. -set -eu - -project_dir=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -build_dir="$project_dir/build" -app="$build_dir/TypeSwitch.app" - -cd "$project_dir" - -if [ -f Config/notary.env ]; then - . ./Config/notary.env -fi - -# --- Preflight ------------------------------------------------------------- -# Everything that can be checked is checked before a build is spent: -# notarization takes minutes, and failing at the last step wastes all of it. - -if [ "${RELEASE_IDENTITY+x}" = "x" ]; then - identity=$RELEASE_IDENTITY -else - identity=$(security find-identity -v -p codesigning \ - | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' \ - | sed -n '1p') -fi -if [ -z "$identity" ]; then - echo "No Developer ID Application certificate found." >&2 - echo "A distributable build needs one; a paid Apple Developer Program" >&2 - echo "membership issues it. Set RELEASE_IDENTITY to choose explicitly." >&2 - exit 1 -fi - -for name in NOTARY_KEY NOTARY_KEY_ID NOTARY_ISSUER; do - eval "value=\${$name:-}" - if [ -z "$value" ]; then - echo "$name is not set." >&2 - echo "Copy Config/notary.env.example to Config/notary.env and fill it in." >&2 - exit 1 - fi -done -if [ ! -f "$NOTARY_KEY" ]; then - echo "App Store Connect key not found at: $NOTARY_KEY" >&2 - exit 1 -fi - -if ! command -v create-dmg >/dev/null 2>&1; then - echo "create-dmg is not installed. Install it with: brew install create-dmg" >&2 - exit 1 -fi - -version=$(xcodebuild -project TypeSwitch.xcodeproj -target TypeSwitch \ - -configuration Release -showBuildSettings 2>/dev/null \ - | sed -n 's/^ *MARKETING_VERSION = \(.*\)$/\1/p' | sed -n '1p') -case "$version" in - ''|*[!0-9A-Za-z.-]*) echo "Invalid MARKETING_VERSION: '$version'" >&2; exit 1 ;; -esac - -dmg_name="TypeSwitch-$version.dmg" -dmg="$build_dir/$dmg_name" - -# Submits one file and insists on an accepted verdict. -# -# `notarytool submit --wait` does exit non-zero on a rejected submission, but -# only when nothing swallows that code — a pipeline reports its last command's -# status, which is how a rejection gets read as success. The output goes to a -# file, and the verdict is confirmed in the text as well. -notarize() { - submission=$1 - submission_log="$build_dir/notarization-$(basename "$submission").log" - - if xcrun notarytool submit "$submission" \ - --key "$NOTARY_KEY" \ - --key-id "$NOTARY_KEY_ID" \ - --issuer "$NOTARY_ISSUER" \ - --wait >"$submission_log" 2>&1 - then - submitted=0 - else - submitted=1 - fi - cat "$submission_log" - - if [ "$submitted" -ne 0 ] || ! grep -q 'status: Accepted' "$submission_log"; then - echo "Notarization did not come back Accepted for $submission." >&2 - - # The verdict comes back from the submission, but never the reason — - # that only exists at the log endpoint, and it is the only thing that - # says which file Apple objected to. Fetching it here is the difference - # between a failure that can be acted on and one that cannot. - submission_id=$(sed -n 's/^ *id: \([0-9a-f-]*\)$/\1/p' "$submission_log" | sed -n '1p') - if [ -n "$submission_id" ]; then - echo "Log for submission $submission_id:" >&2 - xcrun notarytool log "$submission_id" \ - --key "$NOTARY_KEY" \ - --key-id "$NOTARY_KEY_ID" \ - --issuer "$NOTARY_ISSUER" >&2 || true - fi - exit 1 - fi -} - -# --- Build and sign -------------------------------------------------------- - -"$project_dir/scripts/build-app.sh" >/dev/null - -codesign --force --options runtime --timestamp --sign "$identity" "$app" -codesign --verify --deep --strict "$app" -echo "Signed with: $identity" - -# --- Notarize the app ------------------------------------------------------ -# The app is notarized and stapled before it goes into the image. Stapling only -# the image leaves the copy someone drags out of it with no ticket of its own, -# so that copy's first launch has to reach Apple over the network to confirm -# it was notarized. A stapled app carries the answer with it. - -app_zip="$build_dir/TypeSwitch.zip" -rm -f "$app_zip" -ditto -c -k --keepParent "$app" "$app_zip" -notarize "$app_zip" -xcrun stapler staple "$app" -xcrun stapler validate "$app" -rm -f "$app_zip" - -# --- Package and notarize the image ---------------------------------------- - -# create-dmg lays the window out by driving Finder over AppleScript, and -# Finder is not reliably ready when it asks — the failure is a -10006 on -# setting a window property, and it is intermittent. Give Finder room, clear -# the half-built read-write image each time, and retry. -dmg_work="$build_dir/dmg" -rm -rf "$dmg_work" -mkdir -p "$dmg_work/source" -ditto "$app" "$dmg_work/source/TypeSwitch.app" - -create_image() { - rm -f "$dmg" - find "$build_dir" -maxdepth 1 -type f -name "rw.*.$dmg_name" -delete - create-dmg \ - --volname "TypeSwitch $version" \ - --volicon "$app/Contents/Resources/AppIcon.icns" \ - --window-size 520 340 \ - --icon-size 96 \ - --icon "TypeSwitch.app" 130 160 \ - --hide-extension "TypeSwitch.app" \ - --app-drop-link 390 160 \ - --no-internet-enable \ - --applescript-sleep-duration 8 \ - --overwrite \ - "$dmg" \ - "$dmg_work/source" >/dev/null -} - -attempt=1 -while ! create_image -do - if [ "$attempt" -ge 3 ]; then - echo "create-dmg failed after $attempt attempts." >&2 - exit 1 - fi - attempt=$((attempt + 1)) - echo "Retrying create-dmg after a Finder layout failure (attempt $attempt of 3)..." >&2 - sleep 2 -done -rm -rf "$dmg_work" - -# create-dmg produces an unsigned image. Notarization and stapling work on one -# regardless, but an unsigned image has nothing of its own for Gatekeeper to -# assess, so signing it is what makes the download itself verifiable. -codesign --force --timestamp --sign "$identity" "$dmg" -notarize "$dmg" -xcrun stapler staple "$dmg" - -# --- Verify ---------------------------------------------------------------- -# What a first-time download goes through, checked here rather than discovered -# by whoever downloads it. The image and the app inside it are both checked, -# which is what stops the stapling above from quietly going away again. - -"$project_dir/scripts/audit-release.sh" "$dmg" "$version" - -# Written from inside the directory, so the file names the image rather than -# this machine's directory layout: `shasum -c` looks for the path it is given, -# and an absolute one exists on no other machine. -(cd "$build_dir" && shasum -a 256 "$dmg_name" >"$dmg_name.sha256") - -echo -echo "$dmg" -cat "$dmg.sha256"