diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b3333aa..9bb48da 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -30,6 +30,8 @@ jobs:
SortingHat.xcodeproj \
Configuration/SortingHatApp-Info.plist \
Configuration/SendToSortingHatAction-Info.plist
+ - name: Verify tag matches the shared release identity
+ run: ./script/release_identity.sh --verify "${GITHUB_REF_NAME#v}"
- name: Import Developer ID certificate
env:
CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_APPLICATION_P12 }}
@@ -59,6 +61,8 @@ jobs:
extension="dist/Sorting Hat.app/Contents/PlugIns/Send to Sorting Hat.appex"
app_info="$app/Contents/Info.plist"
extension_info="$extension/Contents/Info.plist"
+ version="$(./script/release_identity.sh --version)"
+ build="$(./script/release_identity.sh --build)"
test -d "$extension"
codesign --verify --strict --deep --verbose=2 "$extension"
codesign --verify --strict --deep --verbose=2 "$app"
@@ -68,6 +72,10 @@ jobs:
codesign -dvvv "$app" 2>&1 | grep -Fx "TeamIdentifier=R8HXTBY3NM"
/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$app_info" | grep -Fx com.tcballard.sortinghat
/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$extension_info" | grep -Fx com.tcballard.sortinghat.finder-action
+ /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$app_info" | grep -Fx "$version"
+ /usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$app_info" | grep -Fx "$build"
+ /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$extension_info" | grep -Fx "$version"
+ /usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$extension_info" | grep -Fx "$build"
codesign -d --entitlements :- "$extension" > "$RUNNER_TEMP/extension-entitlements.plist" 2>/dev/null
codesign -d --entitlements :- "$app" > "$RUNNER_TEMP/app-entitlements.plist" 2>/dev/null
/usr/libexec/PlistBuddy -c "Print :com.apple.security.application-groups:0" "$RUNNER_TEMP/extension-entitlements.plist" | grep -Fx R8HXTBY3NM.com.tcballard.sortinghat
@@ -109,6 +117,8 @@ jobs:
extension_info="$extension/Contents/Info.plist"
app_entitlements="$RUNNER_TEMP/final-app-entitlements.plist"
extension_entitlements="$RUNNER_TEMP/final-extension-entitlements.plist"
+ version="$(./script/release_identity.sh --version)"
+ build="$(./script/release_identity.sh --build)"
test -f "$archive"
rm -rf "$verification_root"
@@ -128,6 +138,10 @@ jobs:
/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$app_info" | grep -Fx com.tcballard.sortinghat
/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$extension_info" | grep -Fx com.tcballard.sortinghat.finder-action
+ /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$app_info" | grep -Fx "$version"
+ /usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$app_info" | grep -Fx "$build"
+ /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$extension_info" | grep -Fx "$version"
+ /usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$extension_info" | grep -Fx "$build"
codesign -d --entitlements :- "$app" > "$app_entitlements" 2>/dev/null
codesign -d --entitlements :- "$extension" > "$extension_entitlements" 2>/dev/null
diff --git a/Configuration/Release.xcconfig b/Configuration/Release.xcconfig
new file mode 100644
index 0000000..c3dddb6
--- /dev/null
+++ b/Configuration/Release.xcconfig
@@ -0,0 +1,3 @@
+// One release identity for every signed Sorting Hat delivery channel.
+MARKETING_VERSION = 0.1.0
+CURRENT_PROJECT_VERSION = 2
diff --git a/Configuration/SendToSortingHatAction-Info.plist b/Configuration/SendToSortingHatAction-Info.plist
index dd7e453..8cfc431 100644
--- a/Configuration/SendToSortingHatAction-Info.plist
+++ b/Configuration/SendToSortingHatAction-Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
XPC!
CFBundleShortVersionString
- 0.1.0
+ $(MARKETING_VERSION)
CFBundleVersion
- 2
+ $(CURRENT_PROJECT_VERSION)
NSExtension
NSExtensionAttributes
diff --git a/Configuration/SortingHatApp-Info.plist b/Configuration/SortingHatApp-Info.plist
index bb5d851..b19e806 100644
--- a/Configuration/SortingHatApp-Info.plist
+++ b/Configuration/SortingHatApp-Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.1.0
+ $(MARKETING_VERSION)
CFBundleVersion
- 2
+ $(CURRENT_PROJECT_VERSION)
ITSAppUsesNonExemptEncryption
LSApplicationCategoryType
diff --git a/README.md b/README.md
index e4880d4..b9aaa2b 100644
--- a/README.md
+++ b/README.md
@@ -135,17 +135,20 @@ The 12-case private corpus is a regression gate, not a universal accuracy claim.
swift test
./script/generate_xcode_project.sh
./script/preflight_app_store.sh
-# After one-time notarytool Keychain setup:
-./script/release_local.sh 0.2.0
+# After one-time signing and notarization setup:
+./script/release_candidate.sh
```
Inference sits behind `FileAnalyzing`, so filesystem safety can be tested without a live model. The Foundation Models decision path can also be shared by a future iPhone or iPad client, but iOS cannot behave like a continuously watched Mac folder. The [iOS client boundary](docs/ios-client-architecture.md) documents what is reusable and what needs a Files or Share-extension workflow.
## Distribution status
-There are two release tracks. They are deliberately separate.
+There is one release train with two signed delivery channels. Both channels are
+built from the same source commit and share the version and build number in
+`Configuration/Release.xcconfig`; release tooling rejects a mismatched tag or
+artifact. Their security contracts remain intentionally different.
- **Mac App Store:** local-only build `0.1.0 (2)` passed Apple validation, processed as `VALID`, and is selected in App Store Connect. It has not been submitted for review or published. Pricing, App Privacy, export compliance, content rights, and installed-build verification still need owner sign-off.
-- **GitHub and Homebrew:** the downloadable `v0.1.0` artifact is an experimental, ad-hoc-signed pre-release. It is not Developer ID signed or notarised, so Gatekeeper may block it. Issue [#24](https://github.com/tcballard/SortingHat/issues/24) tracks the signed and notarised release path.
+- **GitHub and Homebrew:** the existing downloadable `v0.1.0` artifact predates the unified release tooling and remains an experimental, ad-hoc-signed pre-release. The next release candidate is Developer ID signed, notarised, stapled, and verified from its extracted ZIP before publication. Issue [#24](https://github.com/tcballard/SortingHat/issues/24) tracks that first signed publication.
Issue [#29](https://github.com/tcballard/SortingHat/issues/29) tracks the remaining App Store work. The [distribution guide](docs/distribution.md), [privacy policy](docs/privacy.md), and [support page](docs/support.md) hold the channel-specific details.
diff --git a/SortingHat.xcodeproj/project.pbxproj b/SortingHat.xcodeproj/project.pbxproj
index 02aad87..818e7df 100644
--- a/SortingHat.xcodeproj/project.pbxproj
+++ b/SortingHat.xcodeproj/project.pbxproj
@@ -66,6 +66,7 @@
88ADD613E69FA0224084FE99 /* ActionRequestHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionRequestHandler.swift; sourceTree = ""; };
A0EB807461CD2FBEAC90182D /* HatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HatStore.swift; sourceTree = ""; };
BC8CEA00F91AA384944B6DC1 /* APIKeyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeyStore.swift; sourceTree = ""; };
+ C0A331DE0605C30EA78F5E41 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; };
C24EBAF0EB6D03C27BC24A53 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; };
CF783C14FBCE3B3B969A8140 /* RulesEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesEditorView.swift; sourceTree = ""; };
D4A766D04A9129702226E922 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
@@ -161,6 +162,7 @@
isa = PBXGroup;
children = (
60D0A89FFA32378E1092BFB0 /* PrivacyInfo.xcprivacy */,
+ C0A331DE0605C30EA78F5E41 /* Release.xcconfig */,
);
path = Configuration;
sourceTree = "";
@@ -340,6 +342,7 @@
/* Begin XCBuildConfiguration section */
19EFF1725607A2884B659F81 /* AppStore */ = {
isa = XCBuildConfiguration;
+ baseConfigurationReference = C0A331DE0605C30EA78F5E41 /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ARCHS = arm64;
@@ -399,6 +402,7 @@
};
2505882B3D577106A5B4592E /* Release */ = {
isa = XCBuildConfiguration;
+ baseConfigurationReference = C0A331DE0605C30EA78F5E41 /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ARCHS = arm64;
@@ -458,6 +462,7 @@
};
6DC971CA1048E9F550FC314B /* Debug */ = {
isa = XCBuildConfiguration;
+ baseConfigurationReference = C0A331DE0605C30EA78F5E41 /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ARCHS = arm64;
diff --git a/docs/distribution.md b/docs/distribution.md
index 3f5e041..ff26d1b 100644
--- a/docs/distribution.md
+++ b/docs/distribution.md
@@ -1,7 +1,21 @@
# Distribution channels
-Sorting Hat is preparing two macOS distribution channels. They share the same
-product code but have different signing and sandbox contracts.
+Sorting Hat has one release train and two signed macOS delivery channels. Every
+candidate is built from one source commit with the same marketing version and
+build number from `Configuration/Release.xcconfig`. The channels retain the
+different signing, sandbox, and provider contracts required by their delivery
+mechanisms.
+
+Run the complete local candidate gate with:
+
+```sh
+./script/release_candidate.sh
+```
+
+It tests the code, rejects generated-project or version drift, builds and
+verifies the Developer ID ZIP, builds and verifies the Apple Distribution
+archive and package, and records their checksums and source commit in one
+manifest. It does not tag, publish, upload, or submit either artifact.
## Developer ID and Homebrew — Issue #24
@@ -15,9 +29,9 @@ The tag-triggered release workflow already fails closed while it:
5. extracts and re-verifies the final ZIP before publishing it;
6. updates the Homebrew cask with the final archive checksum.
-The repository currently lacks the credential secrets required to exercise that
-workflow. Add these through GitHub repository secrets, never through source,
-issues, workflow logs, or pull requests:
+The GitHub workflow expects these repository secrets. Store them only as
+GitHub repository secrets, never in source, issues, workflow logs, or pull
+requests:
- `DEVELOPER_ID_APPLICATION_P12`
- `DEVELOPER_ID_APPLICATION_PASSWORD`
@@ -38,16 +52,20 @@ xcrun notarytool store-credentials SortingHat-Notary \
--team-id R8HXTBY3NM
```
-Then produce and verify a local release candidate with:
+Then produce and verify only the Developer ID channel with:
```sh
-./script/release_local.sh 0.2.0
+./script/release_local.sh
```
The script requires the real Developer ID identity, submits through the named
Keychain profile, staples and assesses the app, creates the final ZIP, extracts
that ZIP, and repeats signature, stapler, and Gatekeeper validation against the
actual distributable artifact. It does not tag, publish, or update Homebrew.
+The script also accepts an App Store Connect API key through the
+`SORTING_HAT_NOTARY_KEY_PATH`, `SORTING_HAT_NOTARY_KEY_ID`, and
+`SORTING_HAT_NOTARY_ISSUER_ID` environment variables instead of a Keychain
+profile. Credentials are never read from the repository.
## Mac App Store — Issue #29
@@ -78,13 +96,17 @@ verifies the nested extension and privacy manifest, confirms that the legacy
entitlement inspection, and checks the minimum sandbox contract. It does not
produce an uploadable archive.
-To create a submission build, the owner must first register both bundle IDs and
-the App Group in the Apple Developer portal, attach the App Group to both IDs,
-install or allow Xcode to obtain matching Mac App Store profiles, and create an
-App Store Connect record for `com.tcballard.sortinghat`. Then archive the
-`SortingHatAppStore` scheme in Xcode and choose **Distribute App → App Store
-Connect**, or export a correctly signed archive with
-`Configuration/AppStoreExportOptions.plist`.
+Create and verify the Apple Distribution archive and upload package with:
+
+```sh
+./script/archive_app_store.sh
+```
+
+The script uses Xcode's installed account by default. For API-key authentication,
+set `SORTING_HAT_ASC_KEY_PATH`, `SORTING_HAT_ASC_KEY_ID`, and
+`SORTING_HAT_ASC_ISSUER_ID` together. It archives the `SortingHatAppStore`
+scheme, checks the nested signatures and shared release identity, and exports
+with `Configuration/AppStoreExportOptions.plist`. It does not upload or submit.
App Store Connect build **0.1.0 (2)** passed Apple validation, processed as
`VALID`, and is selected for version 0.1.0. The listing, screenshots,
diff --git a/project.yml b/project.yml
index 1c756ca..0e31adc 100644
--- a/project.yml
+++ b/project.yml
@@ -3,6 +3,10 @@ configs:
Debug: debug
Release: release
AppStore: release
+configFiles:
+ Debug: Configuration/Release.xcconfig
+ Release: Configuration/Release.xcconfig
+ AppStore: Configuration/Release.xcconfig
options:
bundleIdPrefix: com.tcballard
deploymentTarget:
@@ -34,8 +38,8 @@ targets:
path: Configuration/SortingHatApp-Info.plist
properties:
CFBundleDisplayName: Sorting Hat
- CFBundleShortVersionString: "0.1.0"
- CFBundleVersion: "2"
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
LSApplicationCategoryType: public.app-category.productivity
LSMinimumSystemVersion: "14.0"
LSUIElement: true
@@ -70,8 +74,8 @@ targets:
path: Configuration/SendToSortingHatAction-Info.plist
properties:
CFBundleDisplayName: Send to Sorting Hat
- CFBundleShortVersionString: "0.1.0"
- CFBundleVersion: "2"
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSExtension:
NSExtensionAttributes:
# Match the selected files' content types here. The handler loads
diff --git a/script/archive_app_store.sh b/script/archive_app_store.sh
new file mode 100755
index 0000000..3d3b39f
--- /dev/null
+++ b/script/archive_app_store.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+VERSION="$($ROOT_DIR/script/release_identity.sh --version)"
+BUILD="$($ROOT_DIR/script/release_identity.sh --build)"
+OUTPUT_DIR="${SORTING_HAT_APP_STORE_OUTPUT:-$ROOT_DIR/dist/releases/AppStore}"
+DERIVED_DATA="${SORTING_HAT_APP_STORE_DERIVED_DATA:-${TMPDIR%/}/SortingHatAppStoreReleaseDerivedData}"
+ARCHIVE_PATH="${SORTING_HAT_APP_STORE_ARCHIVE:-$OUTPUT_DIR/Sorting-Hat-$VERSION-$BUILD.xcarchive}"
+EXPORT_PATH="${SORTING_HAT_APP_STORE_EXPORT:-$OUTPUT_DIR/Export}"
+EXPORT_OPTIONS="$ROOT_DIR/Configuration/AppStoreExportOptions.plist"
+APP="$ARCHIVE_PATH/Products/Applications/Sorting Hat.app"
+EXTENSION="$APP/Contents/PlugIns/Send to Sorting Hat.appex"
+
+ASC_KEY_PATH="${SORTING_HAT_ASC_KEY_PATH:-}"
+ASC_KEY_ID="${SORTING_HAT_ASC_KEY_ID:-}"
+ASC_ISSUER_ID="${SORTING_HAT_ASC_ISSUER_ID:-}"
+AUTH_ARGS=(-allowProvisioningUpdates)
+
+if [[ -n "$ASC_KEY_PATH" || -n "$ASC_KEY_ID" || -n "$ASC_ISSUER_ID" ]]; then
+ if [[ -z "$ASC_KEY_PATH" || -z "$ASC_KEY_ID" || -z "$ASC_ISSUER_ID" ]]; then
+ echo "Set SORTING_HAT_ASC_KEY_PATH, SORTING_HAT_ASC_KEY_ID, and SORTING_HAT_ASC_ISSUER_ID together." >&2
+ exit 2
+ fi
+ AUTH_ARGS+=(
+ -authenticationKeyPath "$ASC_KEY_PATH"
+ -authenticationKeyID "$ASC_KEY_ID"
+ -authenticationKeyIssuerID "$ASC_ISSUER_ID"
+ )
+fi
+
+"$ROOT_DIR/script/release_identity.sh" --verify "$VERSION" "$BUILD"
+mkdir -p "$OUTPUT_DIR"
+rm -rf "$DERIVED_DATA" "$ARCHIVE_PATH" "$EXPORT_PATH"
+
+xcodebuild \
+ -quiet \
+ -project "$ROOT_DIR/SortingHat.xcodeproj" \
+ -scheme SortingHatAppStore \
+ -configuration AppStore \
+ -derivedDataPath "$DERIVED_DATA" \
+ -clonedSourcePackagesDirPath "$DERIVED_DATA/SourcePackages" \
+ -archivePath "$ARCHIVE_PATH" \
+ "${AUTH_ARGS[@]}" \
+ archive
+
+test -d "$APP"
+test -d "$EXTENSION"
+codesign --verify --strict --verbose=2 "$EXTENSION"
+codesign --verify --deep --strict --verbose=2 "$APP"
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" = "$VERSION"
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP/Contents/Info.plist")" = "$BUILD"
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$EXTENSION/Contents/Info.plist")" = "$VERSION"
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$EXTENSION/Contents/Info.plist")" = "$BUILD"
+
+xcodebuild \
+ -quiet \
+ -exportArchive \
+ -archivePath "$ARCHIVE_PATH" \
+ -exportPath "$EXPORT_PATH" \
+ -exportOptionsPlist "$EXPORT_OPTIONS" \
+ "${AUTH_ARGS[@]}"
+
+PACKAGE="$(find "$EXPORT_PATH" -maxdepth 1 -type f -name '*.pkg' -print -quit)"
+if [[ -z "$PACKAGE" ]]; then
+ echo "App Store export did not produce a .pkg in $EXPORT_PATH." >&2
+ exit 3
+fi
+SUMMARY="$EXPORT_PATH/DistributionSummary.plist"
+test -f "$SUMMARY"
+/usr/bin/python3 - "$SUMMARY" "$VERSION" "$BUILD" <<'PY'
+import plistlib
+import sys
+
+with open(sys.argv[1], "rb") as handle:
+ summary = plistlib.load(handle)
+
+entries = [entry for package in summary.values() for entry in package]
+if len(entries) != 1:
+ raise SystemExit("Expected one application in DistributionSummary.plist.")
+
+app = entries[0]
+binaries = [app, *app.get("embeddedBinaries", [])]
+for binary in binaries:
+ if binary.get("versionNumber") != sys.argv[2]:
+ raise SystemExit(f"Exported version mismatch for {binary.get('name')}.")
+ if binary.get("buildNumber") != sys.argv[3]:
+ raise SystemExit(f"Exported build mismatch for {binary.get('name')}.")
+ if binary.get("certificate", {}).get("type") != "Apple Distribution":
+ raise SystemExit(f"Unexpected certificate for {binary.get('name')}.")
+PY
+
+echo "Apple Distribution archive and upload package verified for $VERSION ($BUILD)."
+echo "Archive: $ARCHIVE_PATH"
+echo "Package: $PACKAGE"
+shasum -a 256 "$PACKAGE"
+echo "The package has not been uploaded or submitted."
diff --git a/script/preflight_app_store.sh b/script/preflight_app_store.sh
index b89c4e4..e63dfa6 100755
--- a/script/preflight_app_store.sh
+++ b/script/preflight_app_store.sh
@@ -13,6 +13,10 @@ PRIVACY_MANIFEST="$APP/Contents/Resources/PrivacyInfo.xcprivacy"
APP_ICON="$APP/Contents/Resources/AppIcon.icns"
ASSET_CATALOG="$APP/Contents/Resources/Assets.car"
APP_ICON_SOURCE="$ROOT_DIR/Sources/SortingHatApp/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png"
+VERSION="$($ROOT_DIR/script/release_identity.sh --version)"
+BUILD="$($ROOT_DIR/script/release_identity.sh --build)"
+
+"$ROOT_DIR/script/release_identity.sh" --verify "$VERSION" "$BUILD"
rm -rf "$DERIVED_DATA" "$ARCHIVE_PATH"
xcodebuild \
@@ -103,6 +107,10 @@ assert_plist_value "$EXTENSION_ACTUAL" "com.apple.security.application-groups:0"
assert_plist_value "$EXTENSION_ACTUAL" "com.apple.security.files.user-selected.read-only" true
assert_plist_value "$APP/Contents/Info.plist" ITSAppUsesNonExemptEncryption false
assert_plist_value "$APP/Contents/Info.plist" SortingHatLocalOnlyDistribution YES
+assert_plist_value "$APP/Contents/Info.plist" CFBundleShortVersionString "$VERSION"
+assert_plist_value "$APP/Contents/Info.plist" CFBundleVersion "$BUILD"
+assert_plist_value "$EXTENSION/Contents/Info.plist" CFBundleShortVersionString "$VERSION"
+assert_plist_value "$EXTENSION/Contents/Info.plist" CFBundleVersion "$BUILD"
echo "App Store structural preflight passed."
echo "Archive: $ARCHIVE_PATH"
diff --git a/script/release_candidate.sh b/script/release_candidate.sh
new file mode 100755
index 0000000..d87bfd9
--- /dev/null
+++ b/script/release_candidate.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+VERSION="${1:-$($ROOT_DIR/script/release_identity.sh --version)}"
+BUILD="${2:-$($ROOT_DIR/script/release_identity.sh --build)}"
+OUTPUT_DIR="${SORTING_HAT_RELEASE_OUTPUT:-$ROOT_DIR/dist/releases}"
+MANIFEST="$OUTPUT_DIR/Sorting-Hat-$VERSION-$BUILD.manifest.txt"
+
+"$ROOT_DIR/script/release_identity.sh" --verify "$VERSION" "$BUILD"
+if [[ -n "$(git -C "$ROOT_DIR" status --porcelain --untracked-files=all)" ]]; then
+ echo "Release candidates must be built from a clean committed worktree." >&2
+ exit 2
+fi
+"$ROOT_DIR/script/generate_xcode_project.sh"
+git -C "$ROOT_DIR" diff --exit-code -- \
+ SortingHat.xcodeproj \
+ Configuration/SortingHatApp-Info.plist \
+ Configuration/SendToSortingHatAction-Info.plist
+swift test
+"$ROOT_DIR/script/release_local.sh" "$VERSION"
+SORTING_HAT_APP_STORE_OUTPUT="$OUTPUT_DIR/AppStore" \
+ "$ROOT_DIR/script/archive_app_store.sh"
+
+DIRECT_ARCHIVE="$OUTPUT_DIR/Sorting-Hat-v$VERSION.zip"
+STORE_PACKAGE="$(find "$OUTPUT_DIR/AppStore/Export" -maxdepth 1 -type f -name '*.pkg' -print -quit)"
+test -f "$DIRECT_ARCHIVE"
+test -n "$STORE_PACKAGE"
+
+COMMIT="$(git -C "$ROOT_DIR" rev-parse HEAD)"
+DIRECT_SHA="$(shasum -a 256 "$DIRECT_ARCHIVE" | awk '{print $1}')"
+STORE_SHA="$(shasum -a 256 "$STORE_PACKAGE" | awk '{print $1}')"
+mkdir -p "$OUTPUT_DIR"
+printf '%s\n' \
+ "Sorting Hat release candidate" \
+ "version=$VERSION" \
+ "build=$BUILD" \
+ "commit=$COMMIT" \
+ "developer_id_archive=$DIRECT_ARCHIVE" \
+ "developer_id_sha256=$DIRECT_SHA" \
+ "app_store_package=$STORE_PACKAGE" \
+ "app_store_sha256=$STORE_SHA" \
+ "published=false" \
+ "app_store_uploaded=false" > "$MANIFEST"
+
+echo "Unified release candidate verified: $VERSION ($BUILD) at $COMMIT"
+echo "Manifest: $MANIFEST"
+echo "Neither channel has been published or uploaded."
diff --git a/script/release_identity.sh b/script/release_identity.sh
new file mode 100755
index 0000000..b3d055f
--- /dev/null
+++ b/script/release_identity.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+CONFIG="$ROOT_DIR/Configuration/Release.xcconfig"
+APP_INFO="$ROOT_DIR/Configuration/SortingHatApp-Info.plist"
+EXTENSION_INFO="$ROOT_DIR/Configuration/SendToSortingHatAction-Info.plist"
+
+read_setting() {
+ local key="$1"
+ awk -F= -v key="$key" '
+ $1 ~ "^[[:space:]]*" key "[[:space:]]*$" {
+ value = $2
+ sub(/\/\/.*/, "", value)
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
+ print value
+ exit
+ }
+ ' "$CONFIG"
+}
+
+VERSION="$(read_setting MARKETING_VERSION)"
+BUILD="$(read_setting CURRENT_PROJECT_VERSION)"
+
+if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
+ echo "Invalid MARKETING_VERSION in $CONFIG: $VERSION" >&2
+ exit 2
+fi
+if [[ ! "$BUILD" =~ ^[1-9][0-9]*$ ]]; then
+ echo "Invalid CURRENT_PROJECT_VERSION in $CONFIG: $BUILD" >&2
+ exit 2
+fi
+
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_INFO")" = '$(MARKETING_VERSION)'
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP_INFO")" = '$(CURRENT_PROJECT_VERSION)'
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$EXTENSION_INFO")" = '$(MARKETING_VERSION)'
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$EXTENSION_INFO")" = '$(CURRENT_PROJECT_VERSION)'
+
+case "${1:-}" in
+ --version)
+ printf '%s\n' "$VERSION"
+ ;;
+ --build)
+ printf '%s\n' "$BUILD"
+ ;;
+ --verify)
+ EXPECTED_VERSION="${2:-}"
+ EXPECTED_BUILD="${3:-$BUILD}"
+ if [[ "$VERSION" != "$EXPECTED_VERSION" || "$BUILD" != "$EXPECTED_BUILD" ]]; then
+ echo "Release identity mismatch: contract is $VERSION ($BUILD), expected $EXPECTED_VERSION ($EXPECTED_BUILD)." >&2
+ exit 2
+ fi
+ printf 'Release identity verified: %s (%s)\n' "$VERSION" "$BUILD"
+ ;;
+ "")
+ printf 'version=%s\nbuild=%s\n' "$VERSION" "$BUILD"
+ ;;
+ *)
+ echo "usage: $0 [--version | --build | --verify VERSION [BUILD]]" >&2
+ exit 2
+ ;;
+esac
diff --git a/script/release_local.sh b/script/release_local.sh
index 605f02e..5d0e215 100755
--- a/script/release_local.sh
+++ b/script/release_local.sh
@@ -1,19 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
-VERSION="${1:-}"
-if [[ -z "$VERSION" ]]; then
- echo "usage: $0 VERSION" >&2
- echo "example: $0 0.2.0" >&2
- exit 2
-fi
-if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
- echo "VERSION must be a semantic version without a leading v" >&2
- exit 2
-fi
-
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+CONTRACT_VERSION="$($ROOT_DIR/script/release_identity.sh --version)"
+CONTRACT_BUILD="$($ROOT_DIR/script/release_identity.sh --build)"
+VERSION="${1:-$CONTRACT_VERSION}"
+"$ROOT_DIR/script/release_identity.sh" --verify "$VERSION" "$CONTRACT_BUILD"
+
NOTARY_PROFILE="${SORTING_HAT_NOTARY_PROFILE:-SortingHat-Notary}"
+NOTARY_KEY_PATH="${SORTING_HAT_NOTARY_KEY_PATH:-}"
+NOTARY_KEY_ID="${SORTING_HAT_NOTARY_KEY_ID:-}"
+NOTARY_ISSUER_ID="${SORTING_HAT_NOTARY_ISSUER_ID:-}"
DERIVED_DATA="${SORTING_HAT_RELEASE_DERIVED_DATA:-${TMPDIR%/}/SortingHatReleaseDerivedData}"
OUTPUT_DIR="${SORTING_HAT_RELEASE_OUTPUT:-$ROOT_DIR/dist/releases}"
APP="$ROOT_DIR/dist/Sorting Hat.app"
@@ -23,6 +20,17 @@ SUBMISSION="${TMPDIR%/}/Sorting-Hat-v$VERSION-notarization.zip"
VERIFY_ROOT="${TMPDIR%/}/Sorting-Hat-v$VERSION-verification"
IDENTITY="Developer ID Application: Thomas Ballard (R8HXTBY3NM)"
+NOTARY_ARGS=()
+if [[ -n "$NOTARY_KEY_PATH" || -n "$NOTARY_KEY_ID" || -n "$NOTARY_ISSUER_ID" ]]; then
+ if [[ -z "$NOTARY_KEY_PATH" || -z "$NOTARY_KEY_ID" || -z "$NOTARY_ISSUER_ID" ]]; then
+ echo "Set SORTING_HAT_NOTARY_KEY_PATH, SORTING_HAT_NOTARY_KEY_ID, and SORTING_HAT_NOTARY_ISSUER_ID together." >&2
+ exit 2
+ fi
+ NOTARY_ARGS=(--key "$NOTARY_KEY_PATH" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER_ID")
+else
+ NOTARY_ARGS=(--keychain-profile "$NOTARY_PROFILE")
+fi
+
mkdir -p "$OUTPUT_DIR"
rm -rf "$DERIVED_DATA" "$VERIFY_ROOT"
rm -f "$ARCHIVE" "$SUBMISSION"
@@ -41,9 +49,13 @@ codesign --verify --strict --verbose=2 "$EXTENSION"
codesign --verify --deep --strict --verbose=2 "$APP"
codesign -dvvv "$APP" 2>&1 | grep -Fx "Authority=$IDENTITY" >/dev/null
codesign -dvvv "$APP" 2>&1 | grep -Fx "TeamIdentifier=R8HXTBY3NM" >/dev/null
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" = "$CONTRACT_VERSION"
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP/Contents/Info.plist")" = "$CONTRACT_BUILD"
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$EXTENSION/Contents/Info.plist")" = "$CONTRACT_VERSION"
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$EXTENSION/Contents/Info.plist")" = "$CONTRACT_BUILD"
ditto -c -k --sequesterRsrc --keepParent "$APP" "$SUBMISSION"
-xcrun notarytool submit "$SUBMISSION" --keychain-profile "$NOTARY_PROFILE" --wait
+xcrun notarytool submit "$SUBMISSION" "${NOTARY_ARGS[@]}" --wait
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
spctl --assess --type execute --verbose=2 "$APP"
@@ -57,7 +69,11 @@ codesign --verify --strict --verbose=2 "$FINAL_EXTENSION"
codesign --verify --deep --strict --verbose=2 "$FINAL_APP"
xcrun stapler validate "$FINAL_APP"
spctl --assess --type execute --verbose=2 "$FINAL_APP"
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$FINAL_APP/Contents/Info.plist")" = "$CONTRACT_VERSION"
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$FINAL_APP/Contents/Info.plist")" = "$CONTRACT_BUILD"
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$FINAL_EXTENSION/Contents/Info.plist")" = "$CONTRACT_VERSION"
+test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$FINAL_EXTENSION/Contents/Info.plist")" = "$CONTRACT_BUILD"
-echo "Signed, notarized, stapled, and extracted-archive verified:"
+echo "Signed, notarized, stapled, and extracted-archive verified for $CONTRACT_VERSION ($CONTRACT_BUILD):"
echo "$ARCHIVE"
shasum -a 256 "$ARCHIVE"