From 3c13f517d1d2697fa25be229bbfb30c4c898a342 Mon Sep 17 00:00:00 2001 From: Paullo9 Date: Thu, 5 Nov 2026 15:26:07 +0100 Subject: [PATCH 1/2] build: tools/release-store.sh builds the App Store package From the same release tag as the DMG: archive with automatic signing (team 3B8SMSSAX4, MACEM_STORE_BUILD=YES, arm64), export with tools/release/ExportOptions-AppStore.plist, check the product (installer signature, sandbox, no get-task-allow, privacy manifest, export-compliance key, no Sparkle, version equals tag). --upload validates and uploads with an App Store Connect API key; nothing uploads by default. A script of its own so tools/release.sh (the DMG) is untouched. Tests: AppStoreReleaseTests pins the export options and the guards. Done: macTEM store build script (#103), not run end to end. Next: Paul runs it from a release tag (first run creates the distribution certs). Co-Authored-By: Claude Opus 5.5 --- macTEMTests/AppStoreReleaseTests.swift | 37 +++++++++ tools/release-store.sh | 89 ++++++++++++++++++++++ tools/release/ExportOptions-AppStore.plist | 21 +++++ 3 files changed, 147 insertions(+) create mode 100644 macTEMTests/AppStoreReleaseTests.swift create mode 100755 tools/release-store.sh create mode 100644 tools/release/ExportOptions-AppStore.plist diff --git a/macTEMTests/AppStoreReleaseTests.swift b/macTEMTests/AppStoreReleaseTests.swift new file mode 100644 index 0000000..1778724 --- /dev/null +++ b/macTEMTests/AppStoreReleaseTests.swift @@ -0,0 +1,37 @@ +// +// AppStoreReleaseTests.swift +// One tag, two builds (macTEM#103): tools/release.sh builds the Developer ID DMG, tools/release-store.sh the App Store +// package. The export options and the script's guards are files a later edit can quietly break; these read them. +// + +import XCTest + +nonisolated final class AppStoreReleaseTests: XCTestCase { + private let root = URL(fileURLWithPath: #filePath).resolvingSymlinksInPath().deletingLastPathComponent() + .deletingLastPathComponent() + + /// Mutation: method back to "developer-id", destination "upload", or another team - red. + func testTheStoreExportOptionsExportAnAppStorePackageForTheTeam() throws { + let url = root.appendingPathComponent("tools/release/ExportOptions-AppStore.plist") + let options = try XCTUnwrap(PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) + as? [String: Any]) + XCTAssertEqual(options["method"] as? String, "app-store-connect") + XCTAssertEqual(options["destination"] as? String, "export", "the export never uploads; --upload is its own step") + XCTAssertEqual(options["teamID"] as? String, "3B8SMSSAX4") + XCTAssertEqual(options["signingStyle"] as? String, "automatic") + } + + /// Mutation: drop a product check, or let the script upload without --upload - red. + func testTheStoreScriptChecksItsProductAndUploadsOnlyWhenAsked() throws { + let script = try String(contentsOf: root.appendingPathComponent("tools/release-store.sh"), encoding: .utf8) + for check in ["MACEM_STORE_BUILD=YES", "ARCHS=arm64", "pkgutil --check-signature", "com.apple.security.app-sandbox", + "get-task-allow", "PrivacyInfo.xcprivacy", "ITSAppUsesNonExemptEncryption", "Sparkle.framework", + "ExportOptions-AppStore.plist", "describe --exact-match --tags HEAD"] { + XCTAssertTrue(script.contains(check), "release-store.sh no longer checks \(check)") + } + XCTAssertEqual(script.components(separatedBy: "--upload-app").count - 1, 1) + let guardLine = try XCTUnwrap(script.range(of: "if (( UPLOAD == 1 )); then")) + let uploadLine = try XCTUnwrap(script.range(of: "--upload-app")) + XCTAssertLessThan(guardLine.lowerBound, uploadLine.lowerBound, "the upload sits behind --upload") + } +} diff --git a/tools/release-store.sh b/tools/release-store.sh new file mode 100755 index 0000000..909e9fe --- /dev/null +++ b/tools/release-store.sh @@ -0,0 +1,89 @@ +#!/bin/zsh +# Builds the macTEM App Store package from the same release tag as the DMG (macTEM#103; one tag, two builds, decision of +# 2026-11-04). tools/release.sh stays the Developer ID DMG; this is the store flavour beside it, kept as its own script. +# +# tools/release-store.sh archive, export a signed .pkg, check it +# tools/release-store.sh --upload the same, then validate and upload it to App Store Connect +# +# Needs Xcode signed in to the team's Apple ID (team 3B8SMSSAX4): -allowProvisioningUpdates lets Xcode create the Apple +# Distribution and Mac Installer Distribution certificates and the App Store profile on first use. Validation and upload +# need an App Store Connect API key: ASC_KEY_ID, ASC_ISSUER_ID, the .p8 in ~/.appstoreconnect/private_keys/. Without +# the key it builds and checks the package and stops. STORE_BUILD_NUMBER overrides CURRENT_PROJECT_VERSION for a second +# upload of the same version. Output: build/release//store/macTEM.pkg. +set -euo pipefail + +UPLOAD=0 +for arg in "$@"; do + case "$arg" in + --upload) UPLOAD=1 ;; + *) echo "usage: tools/release-store.sh [--upload]" >&2; exit 2 ;; + esac +done + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +. "$ROOT/tools/lib/developer-dir.sh" +resolve_mactem_developer_dir + +TEAM="${DEVELOPMENT_TEAM:-3B8SMSSAX4}" +OUT="$ROOT/build/release/$(date +%Y%m%d-%H%M%S)" # a new folder per run, so a rerun never deletes an earlier archive +ARCHIVE="$OUT/macTEM-store.xcarchive" +APP="$ARCHIVE/Products/Applications/macTEM.app" +EXPORT="$OUT/store" + +[[ -z "$(git -C "$ROOT" status --porcelain)" ]] || { echo "The working tree is not clean; commit first so the build has a commit." >&2; exit 1; } +# Built from a release tag, carrying that tag's version (Config/Version.xcconfig, which release-please bumps). +TAG="$(git -C "$ROOT" describe --exact-match --tags HEAD 2>/dev/null)" || { echo "HEAD is not a release tag; check out the tag to build (git worktree add … vX.Y.Z)." >&2; exit 1; } +EXPECTED="${TAG#v}" +SET="$(sed -n 's/^MARKETING_VERSION = \([0-9.]*\).*/\1/p' "$ROOT/Config/Version.xcconfig")" +[[ "$SET" == "$EXPECTED" ]] || { echo "Config/Version.xcconfig says $SET but the tag is $TAG." >&2; exit 1; } +mkdir -p "$OUT" + +BUILD_ARGS=() +[[ -n "${STORE_BUILD_NUMBER:-}" ]] && BUILD_ARGS+=("CURRENT_PROJECT_VERSION=$STORE_BUILD_NUMBER") + +echo "== App Store archive ($(git -C "$ROOT" rev-parse --short HEAD))" +# ARCHS=arm64 on the command line, as tools/release.sh: the project's setting does not reach the package targets. +# MACEM_STORE_BUILD=YES is the one switch the store build flips: the Sparkle updater (#104) compiles only without it. +xcodebuild archive -project "$ROOT/macTEM.xcodeproj" -scheme macTEM -configuration Release \ + -destination 'generic/platform=macOS' -archivePath "$ARCHIVE" ARCHS=arm64 \ + -allowProvisioningUpdates CODE_SIGN_STYLE=Automatic DEVELOPMENT_TEAM="$TEAM" MACEM_STORE_BUILD=YES \ + "${BUILD_ARGS[@]}" > "$OUT/archive.log" 2>&1 || { tail -30 "$OUT/archive.log"; exit 1; } + +echo "== Export" +xcodebuild -exportArchive -archivePath "$ARCHIVE" -exportPath "$EXPORT" \ + -exportOptionsPlist "$ROOT/tools/release/ExportOptions-AppStore.plist" -allowProvisioningUpdates \ + > "$OUT/export.log" 2>&1 || { tail -30 "$OUT/export.log"; exit 1; } +PKG="$(print -l "$EXPORT"/*.pkg(N) | head -1)" +[[ -f "$PKG" ]] || { echo "The export wrote no .pkg to $EXPORT." >&2; exit 1; } + +echo "== Check the package" +pkgutil --check-signature "$PKG" | grep -q "Installer Distribution\|3rd Party Mac Developer Installer" \ + || { echo "$PKG is not signed with an installer distribution certificate." >&2; exit 1; } +for binary in "$APP/Contents/MacOS/macTEM" "$APP"/Contents/Frameworks/{FormatoLibHDF5,FormatoLibSZ,FormatoLibAEC}.framework/{FormatoLibHDF5,FormatoLibSZ,FormatoLibAEC}(N); do + [[ "$(lipo -archs "$binary")" == "arm64" ]] || { echo "Not arm64 alone: $binary" >&2; exit 1; } +done +codesign -d --entitlements :- "$APP" > "$OUT/store-entitlements.plist" 2>/dev/null +[[ "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.app-sandbox' "$OUT/store-entitlements.plist" 2>/dev/null)" == true ]] \ + || { echo "The store app is not sandboxed." >&2; exit 1; } +if /usr/libexec/PlistBuddy -c 'Print :com.apple.security.get-task-allow' "$OUT/store-entitlements.plist" >/dev/null 2>&1; then + echo "The store app carries get-task-allow." >&2; exit 1 +fi +[[ -f "$APP/Contents/Resources/PrivacyInfo.xcprivacy" ]] || { echo "The app has no privacy manifest." >&2; exit 1; } +[[ "$(/usr/libexec/PlistBuddy -c 'Print :ITSAppUsesNonExemptEncryption' "$APP/Contents/Info.plist" 2>/dev/null)" == false ]] \ + || { echo "Info.plist does not answer the export-compliance question." >&2; exit 1; } +[[ ! -d "$APP/Contents/Frameworks/Sparkle.framework" ]] || { echo "Sparkle is in the store build." >&2; exit 1; } +VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" +[[ "$VERSION" == "$EXPECTED" ]] || { echo "The built app says $VERSION but the tag is $TAG." >&2; exit 1; } +echo "macTEM $VERSION: $PKG" + +if [[ -z "${ASC_KEY_ID:-}" || -z "${ASC_ISSUER_ID:-}" ]]; then + echo "No App Store Connect API key (ASC_KEY_ID, ASC_ISSUER_ID): built and checked, not validated or uploaded." + exit 0 +fi +echo "== Validate with App Store Connect" +xcrun altool --validate-app -f "$PKG" -t macos --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" +if (( UPLOAD == 1 )); then + echo "== Upload to App Store Connect" + xcrun altool --upload-app -f "$PKG" -t macos --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" + echo "Uploaded. The build shows in App Store Connect > TestFlight after processing (minutes to an hour)." +fi diff --git a/tools/release/ExportOptions-AppStore.plist b/tools/release/ExportOptions-AppStore.plist new file mode 100644 index 0000000..dd91ad1 --- /dev/null +++ b/tools/release/ExportOptions-AppStore.plist @@ -0,0 +1,21 @@ + + + + + + method + app-store-connect + destination + export + teamID + 3B8SMSSAX4 + signingStyle + automatic + uploadSymbols + + manageAppVersionAndBuildNumber + + + From 0a27d2daf2d2a383d5d82da2b2be1377bf0f3f85 Mon Sep 17 00:00:00 2001 From: Paullo9 Date: Thu, 5 Nov 2026 18:07:45 +0100 Subject: [PATCH 2/2] build: export options under tools/lib, where the inventory expects tool files --- macTEMTests/AppStoreReleaseTests.swift | 2 +- tools/{release => lib}/ExportOptions-AppStore.plist | 0 tools/release-store.sh | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename tools/{release => lib}/ExportOptions-AppStore.plist (100%) diff --git a/macTEMTests/AppStoreReleaseTests.swift b/macTEMTests/AppStoreReleaseTests.swift index 1778724..2e66c99 100644 --- a/macTEMTests/AppStoreReleaseTests.swift +++ b/macTEMTests/AppStoreReleaseTests.swift @@ -12,7 +12,7 @@ nonisolated final class AppStoreReleaseTests: XCTestCase { /// Mutation: method back to "developer-id", destination "upload", or another team - red. func testTheStoreExportOptionsExportAnAppStorePackageForTheTeam() throws { - let url = root.appendingPathComponent("tools/release/ExportOptions-AppStore.plist") + let url = root.appendingPathComponent("tools/lib/ExportOptions-AppStore.plist") let options = try XCTUnwrap(PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) as? [String: Any]) XCTAssertEqual(options["method"] as? String, "app-store-connect") diff --git a/tools/release/ExportOptions-AppStore.plist b/tools/lib/ExportOptions-AppStore.plist similarity index 100% rename from tools/release/ExportOptions-AppStore.plist rename to tools/lib/ExportOptions-AppStore.plist diff --git a/tools/release-store.sh b/tools/release-store.sh index 909e9fe..59cecac 100755 --- a/tools/release-store.sh +++ b/tools/release-store.sh @@ -51,7 +51,7 @@ xcodebuild archive -project "$ROOT/macTEM.xcodeproj" -scheme macTEM -configurati echo "== Export" xcodebuild -exportArchive -archivePath "$ARCHIVE" -exportPath "$EXPORT" \ - -exportOptionsPlist "$ROOT/tools/release/ExportOptions-AppStore.plist" -allowProvisioningUpdates \ + -exportOptionsPlist "$ROOT/tools/lib/ExportOptions-AppStore.plist" -allowProvisioningUpdates \ > "$OUT/export.log" 2>&1 || { tail -30 "$OUT/export.log"; exit 1; } PKG="$(print -l "$EXPORT"/*.pkg(N) | head -1)" [[ -f "$PKG" ]] || { echo "The export wrote no .pkg to $EXPORT." >&2; exit 1; }