From 001b120076b91af4a077d302506caa0d834b9ef2 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Wed, 22 Jul 2026 07:30:16 +0100 Subject: [PATCH 1/2] chore(release): prepare v0.1.1 candidate [release:v0.1.1] Sets the unified macOS release identity to 0.1.1 (3), records channel status without overstating publication, and adds release notes for the first signed candidate. --- Configuration/Release.xcconfig | 4 +-- README.md | 7 ++--- docs/distribution.md | 11 +++++++- docs/releases/v0.1.1.md | 48 ++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 docs/releases/v0.1.1.md diff --git a/Configuration/Release.xcconfig b/Configuration/Release.xcconfig index c3dddb6..d05a72d 100644 --- a/Configuration/Release.xcconfig +++ b/Configuration/Release.xcconfig @@ -1,3 +1,3 @@ // One release identity for every signed Sorting Hat delivery channel. -MARKETING_VERSION = 0.1.0 -CURRENT_PROJECT_VERSION = 2 +MARKETING_VERSION = 0.1.1 +CURRENT_PROJECT_VERSION = 3 diff --git a/README.md b/README.md index b9aaa2b..a1dd653 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ swift test ./script/generate_xcode_project.sh ./script/preflight_app_store.sh # After one-time signing and notarization setup: -./script/release_candidate.sh +./script/release_candidate.sh 0.1.1 3 ``` 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. @@ -148,7 +148,8 @@ 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 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. +- **Release target:** `v0.1.1 (3)` is configured as the first unified candidate. Its [release notes](docs/releases/v0.1.1.md) are prepared, but no candidate has been notarised, uploaded, submitted, or published yet. +- **Mac App Store:** the earlier local-only build `0.1.0 (2)` passed Apple validation, processed as `VALID`, and remains selected in App Store Connect until the matching `0.1.1 (3)` candidate is uploaded and verified. Nothing has 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 existing downloadable `v0.1.0` artifact predates the unified release tooling and remains an experimental, ad-hoc-signed pre-release. Issue [#24](https://github.com/tcballard/SortingHat/issues/24) tracks replacing it with the signed and notarised `v0.1.1` release. 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/docs/distribution.md b/docs/distribution.md index ff26d1b..4bdedec 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -17,6 +17,11 @@ 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. +The current unified release target is **0.1.1 (3)**. The candidate must come +from the eventual release commit; the existing GitHub `v0.1.0` artifact and +App Store Connect build `0.1.0 (2)` remain historical channel artifacts until +their replacements are independently verified. + ## Developer ID and Homebrew — Issue #24 The tag-triggered release workflow already fails closed while it: @@ -41,6 +46,9 @@ requests: `HOMEBREW_TAP_TOKEN` is already configured. A successful signed release and a fresh downloaded-artifact verification remain required before closing Issue #24. +Create the `v0.1.1` tag only after all certificate and notarization secrets are +confirmed; otherwise the workflow intentionally fails closed rather than +publishing an unsigned artifact. The local Mac already has the Developer ID Application identity. Store reusable notarization credentials once in the login Keychain (the password is requested @@ -109,7 +117,8 @@ 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, +`VALID`, and remains selected for version 0.1.0. The matching **0.1.1 (3)** +candidate has not been uploaded. The listing, screenshots, categories, age rating, review contact, and review notes are saved. Before submission, choose pricing, attest **Data Not Collected**, complete export compliance and content rights, and run the installed-build smoke test. The diff --git a/docs/releases/v0.1.1.md b/docs/releases/v0.1.1.md new file mode 100644 index 0000000..338c454 --- /dev/null +++ b/docs/releases/v0.1.1.md @@ -0,0 +1,48 @@ +# Sorting Hat 0.1.1 + +- Release status: prepared, not published +- Build: 3 +- Channels: Developer ID download and Homebrew; sandboxed Mac App Store + +Sorting Hat 0.1.1 is the first release prepared through one signed release +train. Both delivery channels come from the same source commit and carry the +same version and build number, while retaining the security boundaries each +channel requires. + +## What is included + +- Describe a filing plan in plain language and review the generated rules + before applying them. +- Rename files meaningfully and route them into explicit rule-specific folders + outside the intake-only Inbox. +- Extract bounded context from searchable PDFs, scanned PDFs, receipts, images, + and common documents before routing. +- Review, retry, reveal, or remove files that cannot be sorted safely. +- Inspect Inbox and activity state inside Sorting Hat without using Finder as a + second application entry point. +- Send files from Finder through the bundled **Send to Sorting Hat** action. +- Use Apple's on-device Foundation Model or Ollama on the same Mac in the Mac + App Store build. + +## Safety and privacy + +- Sorting Hat validates every destination, filename, extension, and tag before + changing the filesystem. +- Files remain in the Inbox for manual review when analysis abstains or returns + an unsafe decision. +- The Mac App Store build is sandboxed and excludes OpenAI, remote Ollama, LAN + Ollama, and command-line inference adapters. +- API credentials and signing material are not included in the application or + repository. + +## Distribution + +- The direct-download build requires Developer ID signing, Apple notarization, + stapling, Gatekeeper assessment, and verification after extracting the final + ZIP. +- The Mac App Store build requires Apple Distribution signing, matching + profiles, App Store Connect processing, and an installed sandbox smoke test. +- Publication and App Review submission remain explicit maintainer actions. + +The previous `v0.1.0` GitHub artifact was experimental and ad-hoc signed. It is +not representative of the trust state required for this release. From 2ae7a3941e1b4fb8814f1af00c1b1b807a119f99 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Wed, 22 Jul 2026 07:44:24 +0100 Subject: [PATCH 2/2] feat(release): publish locally signed artifacts [release:v0.1.1] Keeps Developer ID and notarization credentials on the maintainer Mac, adds a fail-closed draft release command, and makes GitHub verify the exact published ZIP before updating Homebrew. --- .github/workflows/release.yml | 188 +++++-------------------------- README.md | 5 +- docs/distribution.md | 47 +++++--- docs/releases/v0.1.1.md | 3 + script/create_release_draft.sh | 57 ++++++++++ script/release_local.sh | 17 +-- script/verify_release_archive.sh | 65 +++++++++++ 7 files changed, 190 insertions(+), 192 deletions(-) create mode 100755 script/create_release_draft.sh create mode 100755 script/verify_release_archive.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9bb48da..1b23925 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,187 +1,59 @@ -name: Release +name: Release Artifact on: - push: - tags: ["v*"] + release: + types: [published] permissions: - contents: write + contents: read jobs: - release: + verify-and-update-homebrew: runs-on: macos-26 steps: - - uses: actions/checkout@v4 - - name: Install pinned XcodeGen - run: | - archive="$RUNNER_TEMP/xcodegen-2.44.1.zip" - install_root="$RUNNER_TEMP/xcodegen-2.44.1" - bin="$install_root/xcodegen/bin" - curl -fsSL "https://github.com/yonaskolb/XcodeGen/releases/download/2.44.1/xcodegen.zip" -o "$archive" - echo "a2e905fb68446e9bb4008cdfe2e13e3f176d0cbcca828b71770f8e53fca91b73 $archive" | shasum -a 256 -c - - ditto -x -k "$archive" "$install_root" - test -x "$bin/xcodegen" - "$bin/xcodegen" --version | grep -Fx "Version: 2.44.1" - echo "$bin" >> "$GITHUB_PATH" - - name: Verify generated Xcode project and Info plists are current - run: | - ./script/generate_xcode_project.sh - git diff --exit-code -- \ - SortingHat.xcodeproj \ - Configuration/SortingHatApp-Info.plist \ - Configuration/SendToSortingHatAction-Info.plist + - name: Check out the published release tag + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} - 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 }} - CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_APPLICATION_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.BUILD_KEYCHAIN_PASSWORD }} - run: | - test -n "$CERTIFICATE_BASE64" - test -n "$CERTIFICATE_PASSWORD" - test -n "$KEYCHAIN_PASSWORD" - certificate_path="$RUNNER_TEMP/sorting-hat-developer-id.p12" - keychain_path="$RUNNER_TEMP/sorting-hat-signing.keychain-db" - echo -n "$CERTIFICATE_BASE64" | base64 --decode > "$certificate_path" - security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" - security set-keychain-settings -lut 21600 "$keychain_path" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" - security import "$certificate_path" -P "$CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path" - security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$keychain_path" - security list-keychains -d user -s "$keychain_path" - - name: Build and sign app from the extension outward - env: - BUILD_CONFIGURATION: Release - SORTING_HAT_SIGN_IDENTITY: "Developer ID Application: Thomas Ballard (R8HXTBY3NM)" - run: ./script/build_and_run.sh package - - name: Verify nested signatures and entitlements - run: | - app="dist/Sorting Hat.app" - 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" - codesign -dvvv "$extension" 2>&1 | grep -Fx "Authority=Developer ID Application: Thomas Ballard (R8HXTBY3NM)" - codesign -dvvv "$extension" 2>&1 | grep -Fx "TeamIdentifier=R8HXTBY3NM" - codesign -dvvv "$app" 2>&1 | grep -Fx "Authority=Developer ID Application: Thomas Ballard (R8HXTBY3NM)" - 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 - /usr/libexec/PlistBuddy -c "Print :com.apple.security.application-groups:0" "$RUNNER_TEMP/app-entitlements.plist" | grep -Fx R8HXTBY3NM.com.tcballard.sortinghat - /usr/libexec/PlistBuddy -c "Print :com.apple.security.app-sandbox" "$RUNNER_TEMP/extension-entitlements.plist" | grep -Fx true - /usr/libexec/PlistBuddy -c "Print :com.apple.security.files.user-selected.read-only" "$RUNNER_TEMP/extension-entitlements.plist" | grep -Fx true - /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionPointIdentifier" "$extension_info" | grep -Fx com.apple.services - /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionPrincipalClass" "$extension_info" | grep -Fx SendToSortingHatAction.ActionRequestHandler - /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionServiceFinderPreviewLabel" "$extension_info" | grep -Fx "Send to Sorting Hat" - /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionServiceRoleType" "$extension_info" | grep -Fx NSExtensionServiceRoleTypeEditor - /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionServiceAllowsFinderPreviewItem" "$extension_info" | grep -Fx true - /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionActivationRule" "$extension_info" | grep -Fx 'SUBQUERY (extensionItems, $extensionItem, SUBQUERY ($extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.item").@count == $extensionItem.attachments.@count).@count == 1' - - name: Notarize and staple app env: - NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }} - NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }} - run: | - test -n "$NOTARY_APPLE_ID" - test -n "$NOTARY_PASSWORD" - submission="$RUNNER_TEMP/Sorting-Hat-notarization.zip" - ditto -c -k --sequesterRsrc --keepParent "dist/Sorting Hat.app" "$submission" - xcrun notarytool submit "$submission" \ - --apple-id "$NOTARY_APPLE_ID" \ - --password "$NOTARY_PASSWORD" \ - --team-id R8HXTBY3NM \ - --wait - xcrun stapler staple "dist/Sorting Hat.app" - xcrun stapler validate "dist/Sorting Hat.app" - spctl --assess --type execute --verbose=2 "dist/Sorting Hat.app" - - name: Archive verified app - run: ditto -c -k --sequesterRsrc --keepParent "dist/Sorting Hat.app" "Sorting-Hat-${GITHUB_REF_NAME}.zip" - - name: Verify final release archive - run: | - archive="Sorting-Hat-${GITHUB_REF_NAME}.zip" - verification_root="$RUNNER_TEMP/final-release-verification" - app="$verification_root/Sorting Hat.app" - extension="$app/Contents/PlugIns/Send to Sorting Hat.appex" - app_info="$app/Contents/Info.plist" - 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" - mkdir -p "$verification_root" - ditto -x -k "$archive" "$verification_root" - test -d "$app" - test -d "$extension" - - codesign --verify --strict --deep --verbose=2 "$extension" - codesign --verify --strict --deep --verbose=2 "$app" - codesign -dvvv "$extension" 2>&1 | grep -Fx "Authority=Developer ID Application: Thomas Ballard (R8HXTBY3NM)" - codesign -dvvv "$extension" 2>&1 | grep -Fx "TeamIdentifier=R8HXTBY3NM" - codesign -dvvv "$app" 2>&1 | grep -Fx "Authority=Developer ID Application: Thomas Ballard (R8HXTBY3NM)" - codesign -dvvv "$app" 2>&1 | grep -Fx "TeamIdentifier=R8HXTBY3NM" - xcrun stapler validate "$app" - spctl --assess --type execute --verbose=2 "$app" - - /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 - /usr/libexec/PlistBuddy -c "Print :com.apple.security.application-groups:0" "$app_entitlements" | grep -Fx R8HXTBY3NM.com.tcballard.sortinghat - /usr/libexec/PlistBuddy -c "Print :com.apple.security.application-groups:0" "$extension_entitlements" | grep -Fx R8HXTBY3NM.com.tcballard.sortinghat - /usr/libexec/PlistBuddy -c "Print :com.apple.security.app-sandbox" "$extension_entitlements" | grep -Fx true - /usr/libexec/PlistBuddy -c "Print :com.apple.security.files.user-selected.read-only" "$extension_entitlements" | grep -Fx true - - /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionPointIdentifier" "$extension_info" | grep -Fx com.apple.services - /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionPrincipalClass" "$extension_info" | grep -Fx SendToSortingHatAction.ActionRequestHandler - /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionServiceFinderPreviewLabel" "$extension_info" | grep -Fx "Send to Sorting Hat" - /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionServiceRoleType" "$extension_info" | grep -Fx NSExtensionServiceRoleTypeEditor - /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionServiceAllowsFinderPreviewItem" "$extension_info" | grep -Fx true - /usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionActivationRule" "$extension_info" | grep -Fx 'SUBQUERY (extensionItems, $extensionItem, SUBQUERY ($extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.item").@count == $extensionItem.attachments.@count).@count == 1' - - name: Publish signed pre-release + TAG: ${{ github.event.release.tag_name }} + run: ./script/release_identity.sh --verify "${TAG#v}" + - name: Download the locally produced release archive env: GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} run: | - gh release create "$GITHUB_REF_NAME" "Sorting-Hat-${GITHUB_REF_NAME}.zip" \ - --prerelease \ - --generate-notes \ - --title "Sorting Hat $GITHUB_REF_NAME" \ - --notes "Signed and notarized macOS pre-release. Enable the bundled Send to Sorting Hat action in System Settings → General → Login Items & Extensions → Finder." - - name: Calculate release checksum + version="${TAG#v}" + mkdir -p dist/releases + gh release download "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "Sorting-Hat-$TAG.zip" \ + --dir dist/releases + - name: Verify the exact published artifact id: release + env: + TAG: ${{ github.event.release.tag_name }} run: | - echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - echo "sha256=$(shasum -a 256 "Sorting-Hat-${GITHUB_REF_NAME}.zip" | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" + version="${TAG#v}" + archive="dist/releases/Sorting-Hat-$TAG.zip" + ./script/verify_release_archive.sh "$archive" "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "sha256=$(shasum -a 256 "$archive" | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" - name: Check out Homebrew tap uses: actions/checkout@v4 with: repository: tcballard/homebrew-tap token: ${{ secrets.HOMEBREW_TAP_TOKEN }} path: homebrew-tap - - name: Update Homebrew cask + - name: Update Homebrew cask from the verified archive working-directory: homebrew-tap env: VERSION: ${{ steps.release.outputs.version }} SHA256: ${{ steps.release.outputs.sha256 }} run: | + test -n "$VERSION" + test -n "$SHA256" sed -i '' -E "s/version \"[^\"]+\"/version \"$VERSION\"/" Casks/sorting-hat.rb sed -i '' -E "s/sha256 \"[^\"]+\"/sha256 \"$SHA256\"/" Casks/sorting-hat.rb git config user.name "github-actions[bot]" diff --git a/README.md b/README.md index a1dd653..10c9f75 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,9 @@ swift test ./script/preflight_app_store.sh # After one-time signing and notarization setup: ./script/release_candidate.sh 0.1.1 3 +# Verify the notarized ZIP and, only when explicitly intended, create a draft: +./script/create_release_draft.sh +./script/create_release_draft.sh --create ``` 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. @@ -148,7 +151,7 @@ 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. -- **Release target:** `v0.1.1 (3)` is configured as the first unified candidate. Its [release notes](docs/releases/v0.1.1.md) are prepared, but no candidate has been notarised, uploaded, submitted, or published yet. +- **Release target:** `v0.1.1 (3)` is configured as the first unified candidate. Its [release notes](docs/releases/v0.1.1.md) are prepared, but no candidate has been notarised, uploaded, submitted, or published yet. Signing and notarization happen only on the maintainer's Mac; GitHub verifies the published artifact and updates Homebrew without receiving private signing credentials. - **Mac App Store:** the earlier local-only build `0.1.0 (2)` passed Apple validation, processed as `VALID`, and remains selected in App Store Connect until the matching `0.1.1 (3)` candidate is uploaded and verified. Nothing has 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 existing downloadable `v0.1.0` artifact predates the unified release tooling and remains an experimental, ad-hoc-signed pre-release. Issue [#24](https://github.com/tcballard/SortingHat/issues/24) tracks replacing it with the signed and notarised `v0.1.1` release. diff --git a/docs/distribution.md b/docs/distribution.md index 4bdedec..26f35c2 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -17,6 +17,11 @@ 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. +The maintainer's Mac is the signing authority. Developer ID and Apple +Distribution private keys remain in the local Keychain, and notarization uses a +local Keychain profile or App Store Connect API key. GitHub never receives +those credentials. + The current unified release target is **0.1.1 (3)**. The candidate must come from the eventual release commit; the existing GitHub `v0.1.0` artifact and App Store Connect build `0.1.0 (2)` remain historical channel artifacts until @@ -24,31 +29,25 @@ their replacements are independently verified. ## Developer ID and Homebrew — Issue #24 -The tag-triggered release workflow already fails closed while it: +The local release path fails closed while it: -1. imports a Developer ID Application certificate into an ephemeral keychain; -2. builds with hardened runtime and signs the Finder Action before the app; +1. builds with hardened runtime and signs the Finder Action before the app; +2. verifies the local Developer ID Application identity; 3. verifies identities, App Group entitlements, bundle identifiers, and the extension contract; 4. notarizes, staples, and validates with Gatekeeper; -5. extracts and re-verifies the final ZIP before publishing it; -6. updates the Homebrew cask with the final archive checksum. +5. extracts and re-verifies the final ZIP; +6. creates an owner-reviewable draft GitHub release only when explicitly asked. -The GitHub workflow expects these repository secrets. Store them only as -GitHub repository secrets, never in source, issues, workflow logs, or pull -requests: +After the owner publishes that draft, GitHub downloads and verifies the exact +locally produced ZIP, then updates the Homebrew cask with its checksum. The +repository needs only: -- `DEVELOPER_ID_APPLICATION_P12` -- `DEVELOPER_ID_APPLICATION_PASSWORD` -- `BUILD_KEYCHAIN_PASSWORD` -- `NOTARY_APPLE_ID` -- `NOTARY_PASSWORD` +- `HOMEBREW_TAP_TOKEN` -`HOMEBREW_TAP_TOKEN` is already configured. A successful signed release and a -fresh downloaded-artifact verification remain required before closing Issue #24. -Create the `v0.1.1` tag only after all certificate and notarization secrets are -confirmed; otherwise the workflow intentionally fails closed rather than -publishing an unsigned artifact. +A successful signed release and a fresh downloaded-artifact verification remain +required before closing Issue #24. Publishing the GitHub draft is the explicit +owner gate; there is no tag-triggered cloud signing job. The local Mac already has the Developer ID Application identity. Store reusable notarization credentials once in the login Keychain (the password is requested @@ -75,6 +74,18 @@ The script also accepts an App Store Connect API key through the `SORTING_HAT_NOTARY_ISSUER_ID` environment variables instead of a Keychain profile. Credentials are never read from the repository. +After the unified candidate finishes, preflight the exact ZIP and create a +draft release with separate commands: + +```sh +./script/create_release_draft.sh +./script/create_release_draft.sh --create +``` + +The first command is read-only. The second creates a draft GitHub release and +uploads the verified ZIP; it still does not publish it. Publishing the draft +triggers GitHub's artifact-verification and Homebrew-update workflow. + ## Mac App Store — Issue #29 The `SortingHatAppStore` scheme uses the release-like `AppStore` configuration. diff --git a/docs/releases/v0.1.1.md b/docs/releases/v0.1.1.md index 338c454..21ca108 100644 --- a/docs/releases/v0.1.1.md +++ b/docs/releases/v0.1.1.md @@ -40,6 +40,9 @@ channel requires. - The direct-download build requires Developer ID signing, Apple notarization, stapling, Gatekeeper assessment, and verification after extracting the final ZIP. +- Signing and notarization happen on the maintainer's Mac. GitHub receives only + the final verified ZIP, then independently validates it before updating + Homebrew. - The Mac App Store build requires Apple Distribution signing, matching profiles, App Store Connect processing, and an installed sandbox smoke test. - Publication and App Review submission remain explicit maintainer actions. diff --git a/script/create_release_draft.sh b/script/create_release_draft.sh new file mode 100755 index 0000000..499c2ab --- /dev/null +++ b/script/create_release_draft.sh @@ -0,0 +1,57 @@ +#!/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)" +TAG="v$VERSION" +ARCHIVE="$ROOT_DIR/dist/releases/Sorting-Hat-$TAG.zip" +CREATE=false + +if [[ "${1:-}" == "--create" ]]; then + CREATE=true + shift +fi +if [[ $# -gt 1 ]]; then + echo "usage: $0 [--create] [ARCHIVE]" >&2 + exit 2 +fi +if [[ $# -eq 1 ]]; then + ARCHIVE="$1" +fi + +"$ROOT_DIR/script/release_identity.sh" --verify "$VERSION" "$BUILD" +if [[ -n "$(git -C "$ROOT_DIR" status --porcelain --untracked-files=all)" ]]; then + echo "Release drafts must be created from a clean committed worktree." >&2 + exit 2 +fi +if ! git -C "$ROOT_DIR" show-ref --verify --quiet refs/remotes/origin/main; then + echo "Missing origin/main. Fetch the current default branch before continuing." >&2 + exit 2 +fi +if [[ "$(git -C "$ROOT_DIR" rev-parse HEAD)" != "$(git -C "$ROOT_DIR" rev-parse refs/remotes/origin/main)" ]]; then + echo "Release drafts must be created from the exact origin/main commit." >&2 + exit 2 +fi + +NOTES="$ROOT_DIR/docs/releases/$TAG.md" +test -f "$NOTES" +"$ROOT_DIR/script/verify_release_archive.sh" "$ARCHIVE" "$VERSION" "$BUILD" +gh auth status >/dev/null + +if [[ "$CREATE" != true ]]; then + echo "Release draft preflight passed for $TAG." + echo "Run '$0 --create "$ARCHIVE"' to create the owner-reviewable draft." + exit 0 +fi + +gh release create "$TAG" "$ARCHIVE" \ + --repo tcballard/SortingHat \ + --target "$(git -C "$ROOT_DIR" rev-parse HEAD)" \ + --draft \ + --prerelease \ + --title "Sorting Hat $TAG" \ + --notes-file "$NOTES" + +echo "Draft GitHub release created for $TAG." +echo "Publishing it remains a separate maintainer action." diff --git a/script/release_local.sh b/script/release_local.sh index 5d0e215..cf4db1b 100755 --- a/script/release_local.sh +++ b/script/release_local.sh @@ -17,7 +17,6 @@ APP="$ROOT_DIR/dist/Sorting Hat.app" EXTENSION="$APP/Contents/PlugIns/Send to Sorting Hat.appex" ARCHIVE="$OUTPUT_DIR/Sorting-Hat-v$VERSION.zip" 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=() @@ -32,7 +31,7 @@ else fi mkdir -p "$OUTPUT_DIR" -rm -rf "$DERIVED_DATA" "$VERIFY_ROOT" +rm -rf "$DERIVED_DATA" rm -f "$ARCHIVE" "$SUBMISSION" if ! security find-identity -v -p codesigning | grep -Fq "\"$IDENTITY\""; then @@ -61,19 +60,7 @@ xcrun stapler validate "$APP" spctl --assess --type execute --verbose=2 "$APP" ditto -c -k --sequesterRsrc --keepParent "$APP" "$ARCHIVE" -mkdir -p "$VERIFY_ROOT" -ditto -x -k "$ARCHIVE" "$VERIFY_ROOT" -FINAL_APP="$VERIFY_ROOT/Sorting Hat.app" -FINAL_EXTENSION="$FINAL_APP/Contents/PlugIns/Send to Sorting Hat.appex" -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" +"$ROOT_DIR/script/verify_release_archive.sh" "$ARCHIVE" "$CONTRACT_VERSION" "$CONTRACT_BUILD" echo "Signed, notarized, stapled, and extracted-archive verified for $CONTRACT_VERSION ($CONTRACT_BUILD):" echo "$ARCHIVE" -shasum -a 256 "$ARCHIVE" diff --git a/script/verify_release_archive.sh b/script/verify_release_archive.sh new file mode 100755 index 0000000..eca1553 --- /dev/null +++ b/script/verify_release_archive.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +ARCHIVE="${1:-}" +VERSION="${2:-$($ROOT_DIR/script/release_identity.sh --version)}" +BUILD="${3:-$($ROOT_DIR/script/release_identity.sh --build)}" +IDENTITY="Developer ID Application: Thomas Ballard (R8HXTBY3NM)" + +if [[ -z "$ARCHIVE" ]]; then + echo "usage: $0 ARCHIVE [VERSION [BUILD]]" >&2 + exit 2 +fi +if [[ ! -f "$ARCHIVE" ]]; then + echo "Release archive not found: $ARCHIVE" >&2 + exit 2 +fi +"$ROOT_DIR/script/release_identity.sh" --verify "$VERSION" "$BUILD" + +VERIFY_ROOT="$(mktemp -d "${TMPDIR%/}/sorting-hat-release-verify.XXXXXX")" +trap 'rm -rf "$VERIFY_ROOT"' EXIT +ditto -x -k "$ARCHIVE" "$VERIFY_ROOT" + +APP="$VERIFY_ROOT/Sorting Hat.app" +EXTENSION="$APP/Contents/PlugIns/Send to Sorting Hat.appex" +APP_INFO="$APP/Contents/Info.plist" +EXTENSION_INFO="$EXTENSION/Contents/Info.plist" +APP_ENTITLEMENTS="$VERIFY_ROOT/app-entitlements.plist" +EXTENSION_ENTITLEMENTS="$VERIFY_ROOT/extension-entitlements.plist" + +test "$(find "$VERIFY_ROOT" -maxdepth 1 -type d -name '*.app' | wc -l | tr -d ' ')" = "1" +test -d "$APP" +test -d "$EXTENSION" +codesign --verify --strict --verbose=2 "$EXTENSION" +codesign --verify --deep --strict --verbose=2 "$APP" +codesign -dvvv "$EXTENSION" 2>&1 | grep -Fx "Authority=$IDENTITY" >/dev/null +codesign -dvvv "$EXTENSION" 2>&1 | grep -Fx "TeamIdentifier=R8HXTBY3NM" >/dev/null +codesign -dvvv "$APP" 2>&1 | grep -Fx "Authority=$IDENTITY" >/dev/null +codesign -dvvv "$APP" 2>&1 | grep -Fx "TeamIdentifier=R8HXTBY3NM" >/dev/null +xcrun stapler validate "$APP" +spctl --assess --type execute --verbose=2 "$APP" + +/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP_INFO" | grep -Fx com.tcballard.sortinghat >/dev/null +/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$EXTENSION_INFO" | grep -Fx com.tcballard.sortinghat.finder-action >/dev/null +/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_INFO" | grep -Fx "$VERSION" >/dev/null +/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$APP_INFO" | grep -Fx "$BUILD" >/dev/null +/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$EXTENSION_INFO" | grep -Fx "$VERSION" >/dev/null +/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$EXTENSION_INFO" | grep -Fx "$BUILD" >/dev/null + +codesign -d --entitlements :- "$APP" > "$APP_ENTITLEMENTS" 2>/dev/null +codesign -d --entitlements :- "$EXTENSION" > "$EXTENSION_ENTITLEMENTS" 2>/dev/null +/usr/libexec/PlistBuddy -c "Print :com.apple.security.application-groups:0" "$APP_ENTITLEMENTS" | grep -Fx R8HXTBY3NM.com.tcballard.sortinghat >/dev/null +/usr/libexec/PlistBuddy -c "Print :com.apple.security.application-groups:0" "$EXTENSION_ENTITLEMENTS" | grep -Fx R8HXTBY3NM.com.tcballard.sortinghat >/dev/null +/usr/libexec/PlistBuddy -c "Print :com.apple.security.app-sandbox" "$EXTENSION_ENTITLEMENTS" | grep -Fx true >/dev/null +/usr/libexec/PlistBuddy -c "Print :com.apple.security.files.user-selected.read-only" "$EXTENSION_ENTITLEMENTS" | grep -Fx true >/dev/null + +/usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionPointIdentifier" "$EXTENSION_INFO" | grep -Fx com.apple.services >/dev/null +/usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionPrincipalClass" "$EXTENSION_INFO" | grep -Fx SendToSortingHatAction.ActionRequestHandler >/dev/null +/usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionServiceFinderPreviewLabel" "$EXTENSION_INFO" | grep -Fx "Send to Sorting Hat" >/dev/null +/usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionServiceRoleType" "$EXTENSION_INFO" | grep -Fx NSExtensionServiceRoleTypeEditor >/dev/null +/usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionServiceAllowsFinderPreviewItem" "$EXTENSION_INFO" | grep -Fx true >/dev/null +/usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionAttributes:NSExtensionActivationRule" "$EXTENSION_INFO" | grep -Fx 'SUBQUERY (extensionItems, $extensionItem, SUBQUERY ($extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.item").@count == $extensionItem.attachments.@count).@count == 1' >/dev/null + +echo "Developer ID release archive verified: $VERSION ($BUILD)" +shasum -a 256 "$ARCHIVE"