Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions macTEMTests/AppStoreReleaseTests.swift
Original file line number Diff line number Diff line change
@@ -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/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")
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")
}
}
21 changes: 21 additions & 0 deletions tools/lib/ExportOptions-AppStore.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?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">
<!-- App Store export (macTEM#103), used by tools/release-store.sh. Exports a signed .pkg to disk; the
upload is a separate step Paul runs (tools/release-store.sh --upload). Automatic signing creates the App Store
profile for the app under team 3B8SMSSAX4. -->
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>destination</key>
<string>export</string>
<key>teamID</key>
<string>3B8SMSSAX4</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
89 changes: 89 additions & 0 deletions tools/release-store.sh
Original file line number Diff line number Diff line change
@@ -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/<stamp>/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/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; }

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
Loading