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
14 changes: 14 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Configuration/Release.xcconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// One release identity for every signed Sorting Hat delivery channel.
MARKETING_VERSION = 0.1.0
CURRENT_PROJECT_VERSION = 2
4 changes: 2 additions & 2 deletions Configuration/SendToSortingHatAction-Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
Expand Down
4 changes: 2 additions & 2 deletions Configuration/SortingHatApp-Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions SortingHat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
88ADD613E69FA0224084FE99 /* ActionRequestHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionRequestHandler.swift; sourceTree = "<group>"; };
A0EB807461CD2FBEAC90182D /* HatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HatStore.swift; sourceTree = "<group>"; };
BC8CEA00F91AA384944B6DC1 /* APIKeyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeyStore.swift; sourceTree = "<group>"; };
C0A331DE0605C30EA78F5E41 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
C24EBAF0EB6D03C27BC24A53 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
CF783C14FBCE3B3B969A8140 /* RulesEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesEditorView.swift; sourceTree = "<group>"; };
D4A766D04A9129702226E922 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
Expand Down Expand Up @@ -161,6 +162,7 @@
isa = PBXGroup;
children = (
60D0A89FFA32378E1092BFB0 /* PrivacyInfo.xcprivacy */,
C0A331DE0605C30EA78F5E41 /* Release.xcconfig */,
);
path = Configuration;
sourceTree = "<group>";
Expand Down Expand Up @@ -340,6 +342,7 @@
/* Begin XCBuildConfiguration section */
19EFF1725607A2884B659F81 /* AppStore */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = C0A331DE0605C30EA78F5E41 /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ARCHS = arm64;
Expand Down Expand Up @@ -399,6 +402,7 @@
};
2505882B3D577106A5B4592E /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = C0A331DE0605C30EA78F5E41 /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ARCHS = arm64;
Expand Down Expand Up @@ -458,6 +462,7 @@
};
6DC971CA1048E9F550FC314B /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = C0A331DE0605C30EA78F5E41 /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ARCHS = arm64;
Expand Down
50 changes: 36 additions & 14 deletions docs/distribution.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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`
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand Down
12 changes: 8 additions & 4 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions script/archive_app_store.sh
Original file line number Diff line number Diff line change
@@ -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."
8 changes: 8 additions & 0 deletions script/preflight_app_store.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading