diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..9b653b6
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,92 @@
+name: Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Version from MARKETING_VERSION, without the v prefix
+ required: true
+ type: string
+
+permissions:
+ contents: write
+
+concurrency:
+ group: meetingnotes-release
+ cancel-in-progress: false
+
+jobs:
+ release:
+ runs-on: macos-15
+ env:
+ VERSION: ${{ inputs.version }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
+ SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Validate release secrets
+ env:
+ APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ run: |
+ for variable in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD SPARKLE_PRIVATE_KEY; do
+ if [[ -z "${!variable:-}" ]]; then
+ echo "Missing GitHub Actions secret: $variable" >&2
+ exit 1
+ fi
+ done
+
+ - name: Import Developer ID certificate
+ env:
+ APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ KEYCHAIN_PASSWORD: ${{ github.run_id }}-${{ github.run_attempt }}
+ run: |
+ certificate_path="$RUNNER_TEMP/developer-id.p12"
+ keychain_path="$RUNNER_TEMP/release-signing.keychain-db"
+ echo -n "$APPLE_CERTIFICATE_P12" | 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 "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path"
+ security list-keychain -d user -s "$keychain_path"
+ security default-keychain -d user -s "$keychain_path"
+ security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$keychain_path"
+ signing_identity=$(security find-identity -v -p codesigning "$keychain_path" | awk -F '"' '/Developer ID Application/{print $2; exit}')
+ if [[ -z "$signing_identity" ]]; then
+ echo "The .p12 does not contain a Developer ID Application identity" >&2
+ exit 1
+ fi
+ echo "SIGNING_IDENTITY=$signing_identity" >> "$GITHUB_ENV"
+ echo "RELEASE_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV"
+
+ - name: Build, sign, notarize, and create appcast
+ run: scripts/package_release.sh
+
+ - name: Create GitHub release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ tag="v$VERSION"
+ if git rev-parse "$tag" >/dev/null 2>&1; then
+ echo "Tag already exists: $tag" >&2
+ exit 1
+ fi
+ gh release create "$tag" \
+ "$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \
+ "$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \
+ --target "$GITHUB_SHA" \
+ --title "Meetingnotes $VERSION" \
+ --generate-notes
+
+ - name: Remove signing keychain
+ if: always()
+ run: |
+ if [[ -n "${RELEASE_KEYCHAIN:-}" ]]; then
+ security delete-keychain "$RELEASE_KEYCHAIN" || true
+ fi
diff --git a/Meetingnotes.xcodeproj/project.pbxproj b/Meetingnotes.xcodeproj/project.pbxproj
index c8ac7eb..0286b81 100644
--- a/Meetingnotes.xcodeproj/project.pbxproj
+++ b/Meetingnotes.xcodeproj/project.pbxproj
@@ -276,7 +276,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 14;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = ML6HYR5LUR;
ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
- MARKETING_VERSION = 1.1.2;
+ MARKETING_VERSION = 1.1.3;
ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 14;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = ML6HYR5LUR;
ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
- MARKETING_VERSION = 1.1.2;
+ MARKETING_VERSION = 1.1.3;
ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
@@ -366,7 +366,7 @@
repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
requirement = {
kind = upToNextMajorVersion;
- minimumVersion = 2.7.1;
+ minimumVersion = 2.9.4;
};
};
CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */ = {
diff --git a/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 8606731..743f3ed 100644
--- a/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -15,8 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle.git",
"state" : {
- "revision" : "df074165274afaa39539c05d57b0832620775b11",
- "version" : "2.7.1"
+ "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
+ "version" : "2.9.4"
}
},
{
diff --git a/README.md b/README.md
index 0d3fa80..128aa1f 100644
--- a/README.md
+++ b/README.md
@@ -134,12 +134,25 @@ Open the project in Xcode. Command+R to build it and run it.
## Releasing a New Version
-Follow these steps to create a new release with auto-updates:
+Production releases are Developer ID signed, notarized by Apple, published to
+GitHub Releases, and signed for Sparkle auto-updates.
### Prerequisites
-- Homebrew packages: `brew install create-dmg sparkle`
-- Make scripts executable: `chmod +x scripts/update_version.sh scripts/build_release.sh`
+- Apple Developer Program membership
+- A Developer ID Application certificate exported from Keychain Access as a
+ password-protected `.p12`
+- An Apple ID app-specific password for notarization
+- GitHub CLI: `brew install gh`
+
+Configure the Apple release secrets once:
+
+```bash
+./scripts/configure_github_release_secrets.sh
+```
+
+The Sparkle private key is stored only as the `SPARKLE_PRIVATE_KEY` Actions
+secret. Its matching public key is committed in `meetingnotes/Info.plist`.
### Release Process
@@ -159,31 +172,19 @@ Follow these steps to create a new release with auto-updates:
./scripts/update_version.sh custom 1.2.0
```
-2. **Build the release:**
+2. Commit and push the version change to `main`.
- ```bash
- ./scripts/build_release.sh
- ```
+3. Run the `Release` workflow from GitHub Actions and enter the version without
+ the `v` prefix. The workflow signs and notarizes the app, generates the
+ signed appcast, creates the version tag, and publishes both release assets.
- This will:
+The app checks
+`https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml`
+and installs later releases automatically through Sparkle.
- - Clean build the app in Release mode
- - Create a signed DMG file
- - Generate the appcast.xml for auto-updates
+### Recovering Meetings
-3. **Create GitHub Release:**
-
- - Go to [GitHub Releases](https://github.com/owengretzinger/meetingnotes/releases)
- - Click "Create a new release"
- - Tag: `v1.0.1` (match the version number)
- - Title: `Meetingnotes v1.0.1`
- - Upload the DMG and zip files from `releases/` folder
- - Generate release notes
-
-4. **Update appcast:**
-
- ```bash
- git add appcast.xml
- git commit -m "Update appcast for v1.0.1"
- git push
- ```
+The first Developer ID signed build may not automatically inherit data from an
+older ad-hoc signed build. In Settings, use **Import Meetings...** and select the
+old `Meetings` folder. After this one-time transition, the stable signing
+identity keeps the same sandbox container across updates.
diff --git a/appcast.xml b/appcast.xml
index bc28488..e394bbe 100644
--- a/appcast.xml
+++ b/appcast.xml
@@ -1,78 +1,6 @@
-
+
-
- Meetingnotes
- -
- 1.1.1
- Tue, 05 Aug 2025 12:01:01 -0400
- 13
- 1.1.1
- 15.0
-
-
-
-
-
-
-
-
-
- -
- 1.1.0
- Fri, 25 Jul 2025 17:39:07 -0400
- 12
- 1.1.0
- 15.0
-
-
-
-
-
-
-
-
-
- -
- 1.0.6
- Mon, 21 Jul 2025 16:21:51 -0400
- 11
- 1.0.6
- 14.0
-
-
-
-
-
-
-
-
-
- -
- 1.0.5
- Thu, 17 Jul 2025 08:36:59 -0400
- 10
- 1.0.5
- 14.0
-
-
-
-
-
-
-
-
- -
- 1.0.4
- Wed, 16 Jul 2025 09:02:52 -0400
- 9
- 1.0.4
- 14.0
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+ Meetingnotes
+
+
diff --git a/meetingnotes/Info.plist b/meetingnotes/Info.plist
index c4bff09..4094c80 100644
--- a/meetingnotes/Info.plist
+++ b/meetingnotes/Info.plist
@@ -12,9 +12,13 @@
NSMicrophoneUsageDescription
Meetingnotes needs access to your microphone for transcription.
SUFeedURL
- https://raw.githubusercontent.com/superdooper86/meetingnotes/main/appcast.xml
+ https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml
SUPublicEDKey
- BVXHOV8ZxPxKZ1swhFndymzew9nyd3si7849JA9cqsg=
+ 9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0=
+ SUEnableAutomaticChecks
+
+ SUAutomaticallyUpdate
+
SUEnableInstallerLauncherService
diff --git a/meetingnotes/Managers/LocalStorageManager.swift b/meetingnotes/Managers/LocalStorageManager.swift
index 4e5990d..9fb0cde 100644
--- a/meetingnotes/Managers/LocalStorageManager.swift
+++ b/meetingnotes/Managers/LocalStorageManager.swift
@@ -6,6 +6,11 @@ import Foundation
/// Manages local file storage for meetings and app data
class LocalStorageManager {
static let shared = LocalStorageManager()
+
+ struct MeetingImportResult {
+ let importedCount: Int
+ let skippedCount: Int
+ }
private let documentsDirectory: URL
private let meetingsDirectory: URL
@@ -47,7 +52,17 @@ class LocalStorageManager {
// Write atomically using a temp file then replace
let tmpURL = fileURL.appendingPathExtension("tmp")
try data.write(to: tmpURL, options: .atomic)
- try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil)
+ if FileManager.default.fileExists(atPath: fileURL.path) {
+ try FileManager.default.replaceItem(
+ at: fileURL,
+ withItemAt: tmpURL,
+ backupItemName: nil,
+ options: [],
+ resultingItemURL: nil
+ )
+ } else {
+ try FileManager.default.moveItem(at: tmpURL, to: fileURL)
+ }
print("✅ Saved meeting: \(meeting.id)")
return true
@@ -128,6 +143,51 @@ class LocalStorageManager {
return false
}
}
+
+ /// Imports meeting JSON files from a folder selected by the user.
+ func importMeetings(from directory: URL) throws -> MeetingImportResult {
+ let didStartAccess = directory.startAccessingSecurityScopedResource()
+ defer {
+ if didStartAccess {
+ directory.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ let existingIDs = Set(loadMeetings().map(\.id))
+ var importedIDs = Set()
+ var skippedCount = 0
+
+ guard let enumerator = FileManager.default.enumerator(
+ at: directory,
+ includingPropertiesForKeys: [.isRegularFileKey],
+ options: [.skipsHiddenFiles, .skipsPackageDescendants]
+ ) else {
+ throw CocoaError(.fileReadUnknown)
+ }
+
+ for case let fileURL as URL in enumerator where fileURL.pathExtension.lowercased() == "json" {
+ do {
+ let values = try fileURL.resourceValues(forKeys: [.isRegularFileKey])
+ guard values.isRegularFile == true else { continue }
+ let data = try Data(contentsOf: fileURL)
+ let meeting = try decoder.decode(Meeting.self, from: data)
+ guard meeting.dataVersion <= Meeting.currentDataVersion,
+ !existingIDs.contains(meeting.id),
+ !importedIDs.contains(meeting.id),
+ saveMeeting(meeting) else {
+ skippedCount += 1
+ continue
+ }
+ importedIDs.insert(meeting.id)
+ } catch {
+ skippedCount += 1
+ }
+ }
+
+ return MeetingImportResult(importedCount: importedIDs.count, skippedCount: skippedCount)
+ }
// MARK: - Template Management
@@ -146,7 +206,17 @@ class LocalStorageManager {
// Write atomically using a temp file then replace
let tmpURL = fileURL.appendingPathExtension("tmp")
try data.write(to: tmpURL, options: .atomic)
- try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil)
+ if FileManager.default.fileExists(atPath: fileURL.path) {
+ try FileManager.default.replaceItem(
+ at: fileURL,
+ withItemAt: tmpURL,
+ backupItemName: nil,
+ options: [],
+ resultingItemURL: nil
+ )
+ } else {
+ try FileManager.default.moveItem(at: tmpURL, to: fileURL)
+ }
print("✅ Saved template: \(template.id)")
return true
@@ -241,4 +311,4 @@ class LocalStorageManager {
var meetingsDirectoryURL: URL {
meetingsDirectory
}
-}
\ No newline at end of file
+}
diff --git a/meetingnotes/Views/SettingsView.swift b/meetingnotes/Views/SettingsView.swift
index 25a9cc8..97a44b5 100644
--- a/meetingnotes/Views/SettingsView.swift
+++ b/meetingnotes/Views/SettingsView.swift
@@ -1,10 +1,14 @@
import SwiftUI
+import UniformTypeIdentifiers
struct SettingsView: View {
@ObservedObject var viewModel: SettingsViewModel
@StateObject private var localAPIServer = LocalAPIServer.shared
@State private var showingTemplateManager = false
@State private var confirmingTokenRegeneration = false
+ @State private var showingMeetingImporter = false
+ @State private var meetingImportMessage = ""
+ @State private var showingMeetingImportResult = false
@Binding var navigationPath: NavigationPath
init(viewModel: SettingsViewModel, navigationPath: Binding = .constant(NavigationPath())) {
@@ -119,6 +123,17 @@ struct SettingsView: View {
}
}
}
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Meeting Storage")
+ .font(.headline)
+
+ Button {
+ showingMeetingImporter = true
+ } label: {
+ Label("Import Meetings...", systemImage: "square.and.arrow.down")
+ }
+ }
// Note Templates Section: only the Manage Templates button
VStack(alignment: .leading, spacing: 8) {
@@ -276,6 +291,29 @@ struct SettingsView: View {
} message: {
Text(viewModel.saveMessage)
}
+ .fileImporter(
+ isPresented: $showingMeetingImporter,
+ allowedContentTypes: [.folder],
+ allowsMultipleSelection: false
+ ) { result in
+ do {
+ guard let directory = try result.get().first else { return }
+ let importResult = try LocalStorageManager.shared.importMeetings(from: directory)
+ meetingImportMessage = "Imported \(importResult.importedCount) meeting\(importResult.importedCount == 1 ? "" : "s")."
+ if importResult.skippedCount > 0 {
+ meetingImportMessage += " Skipped \(importResult.skippedCount) existing or invalid file\(importResult.skippedCount == 1 ? "" : "s")."
+ }
+ NotificationCenter.default.post(name: .meetingSaved, object: nil)
+ } catch {
+ meetingImportMessage = "Meeting import failed: \(error.localizedDescription)"
+ }
+ showingMeetingImportResult = true
+ }
+ .alert("Meeting Import", isPresented: $showingMeetingImportResult) {
+ Button("OK") { }
+ } message: {
+ Text(meetingImportMessage)
+ }
.confirmationDialog("Regenerate API token?", isPresented: $confirmingTokenRegeneration, titleVisibility: .visible) {
Button("Regenerate", role: .destructive) {
viewModel.regenerateMuteDeckAPIToken()
diff --git a/meetingnotes/meetingnotes.entitlements b/meetingnotes/meetingnotes.entitlements
index 42da2af..e1c5df7 100644
--- a/meetingnotes/meetingnotes.entitlements
+++ b/meetingnotes/meetingnotes.entitlements
@@ -4,10 +4,6 @@
com.apple.security.app-sandbox
- com.apple.security.application-groups
-
- group.owen.meetingnotes
-
com.apple.security.device.audio-input
com.apple.security.device.screen-capture
diff --git a/scripts/configure_github_release_secrets.sh b/scripts/configure_github_release_secrets.sh
new file mode 100755
index 0000000..ea9baee
--- /dev/null
+++ b/scripts/configure_github_release_secrets.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+REPOSITORY="${REPOSITORY:-superdooper86/meetingnotes}"
+
+if ! command -v gh >/dev/null 2>&1; then
+ echo "Install GitHub CLI first: brew install gh" >&2
+ exit 1
+fi
+
+gh auth status >/dev/null
+
+read -r -p "Developer ID certificate (.p12) path: " certificate_path
+if [[ ! -f "$certificate_path" ]]; then
+ echo "Certificate not found: $certificate_path" >&2
+ exit 1
+fi
+
+read -r -s -p "Certificate export password: " certificate_password
+printf '\n'
+read -r -p "Apple ID email: " apple_id
+read -r -p "Apple Developer Team ID: " team_id
+read -r -s -p "Apple app-specific password: " app_password
+printf '\n'
+
+base64 < "$certificate_path" | gh secret set APPLE_CERTIFICATE_P12 -R "$REPOSITORY"
+printf '%s' "$certificate_password" | gh secret set APPLE_CERTIFICATE_PASSWORD -R "$REPOSITORY"
+printf '%s' "$apple_id" | gh secret set APPLE_ID -R "$REPOSITORY"
+printf '%s' "$team_id" | gh secret set APPLE_TEAM_ID -R "$REPOSITORY"
+printf '%s' "$app_password" | gh secret set APPLE_APP_PASSWORD -R "$REPOSITORY"
+
+unset certificate_password app_password
+echo "Apple release secrets configured for $REPOSITORY."
diff --git a/scripts/package_release.sh b/scripts/package_release.sh
new file mode 100755
index 0000000..6b084ea
--- /dev/null
+++ b/scripts/package_release.sh
@@ -0,0 +1,115 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+APP_NAME="Meetingnotes"
+PROJECT="Meetingnotes.xcodeproj"
+SCHEME="meetingnotes"
+RUNNER_TEMP="${RUNNER_TEMP:-/tmp}"
+BUILD_ROOT="${BUILD_ROOT:-$RUNNER_TEMP/meetingnotes-release}"
+DERIVED_DATA="$BUILD_ROOT/DerivedData"
+RELEASE_DIR="$BUILD_ROOT/release"
+APP_PATH="$DERIVED_DATA/Build/Products/Release/$APP_NAME.app"
+
+required_variables=(
+ VERSION
+ SIGNING_IDENTITY
+ APPLE_ID
+ APPLE_TEAM_ID
+ APPLE_APP_PASSWORD
+ SPARKLE_PRIVATE_KEY
+ GITHUB_REPOSITORY
+)
+
+for variable in "${required_variables[@]}"; do
+ if [[ -z "${!variable:-}" ]]; then
+ echo "Missing required environment variable: $variable" >&2
+ exit 1
+ fi
+done
+
+project_version=$(grep -m1 'MARKETING_VERSION' "$PROJECT/project.pbxproj" | sed 's/.*= \(.*\);/\1/')
+if [[ "$project_version" != "$VERSION" ]]; then
+ echo "Release version $VERSION does not match project version $project_version" >&2
+ exit 1
+fi
+
+rm -rf "$BUILD_ROOT"
+mkdir -p "$RELEASE_DIR"
+
+xcodebuild \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Release \
+ -destination 'generic/platform=macOS' \
+ -derivedDataPath "$DERIVED_DATA" \
+ ARCHS="arm64 x86_64" \
+ ONLY_ACTIVE_ARCH=NO \
+ CODE_SIGNING_ALLOWED=NO \
+ clean build
+
+if [[ ! -d "$APP_PATH" ]]; then
+ echo "Built app not found at $APP_PATH" >&2
+ exit 1
+fi
+
+SPARKLE_FRAMEWORK="$APP_PATH/Contents/Frameworks/Sparkle.framework"
+SPARKLE_CONTENTS="$SPARKLE_FRAMEWORK/Versions/B"
+
+sign_component() {
+ codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" "$1"
+}
+
+sign_component "$SPARKLE_CONTENTS/XPCServices/Installer.xpc"
+if [[ -d "$SPARKLE_CONTENTS/XPCServices/Downloader.xpc" ]]; then
+ codesign --force --timestamp --options runtime \
+ --preserve-metadata=entitlements \
+ --sign "$SIGNING_IDENTITY" \
+ "$SPARKLE_CONTENTS/XPCServices/Downloader.xpc"
+fi
+sign_component "$SPARKLE_CONTENTS/Autoupdate"
+sign_component "$SPARKLE_CONTENTS/Updater.app"
+sign_component "$SPARKLE_FRAMEWORK"
+
+codesign --force --timestamp --options runtime \
+ --entitlements meetingnotes/meetingnotes.entitlements \
+ --sign "$SIGNING_IDENTITY" \
+ "$APP_PATH"
+
+codesign --verify --deep --strict --verbose=2 "$APP_PATH"
+codesign -d --entitlements :- "$APP_PATH" 2>&1 | grep -q 'com.apple.security.app-sandbox'
+
+PRE_NOTARY_ZIP="$BUILD_ROOT/$APP_NAME-pre-notary.zip"
+ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$PRE_NOTARY_ZIP"
+
+xcrun notarytool submit "$PRE_NOTARY_ZIP" \
+ --apple-id "$APPLE_ID" \
+ --team-id "$APPLE_TEAM_ID" \
+ --password "$APPLE_APP_PASSWORD" \
+ --wait
+
+xcrun stapler staple "$APP_PATH"
+xcrun stapler validate "$APP_PATH"
+
+ARCHIVE_NAME="$APP_NAME-$VERSION.zip"
+ARCHIVE_PATH="$RELEASE_DIR/$ARCHIVE_NAME"
+ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH"
+spctl --assess --type execute --verbose=2 "$APP_PATH"
+
+GENERATE_APPCAST=$(find "$DERIVED_DATA/SourcePackages/artifacts" -type f -name generate_appcast -print -quit)
+if [[ -z "$GENERATE_APPCAST" ]]; then
+ echo "Sparkle generate_appcast tool was not found" >&2
+ exit 1
+fi
+
+DOWNLOAD_URL="https://github.com/$GITHUB_REPOSITORY/releases/download/v$VERSION/"
+printf '%s' "$SPARKLE_PRIVATE_KEY" | "$GENERATE_APPCAST" "$RELEASE_DIR" \
+ --ed-key-file - \
+ --download-url-prefix "$DOWNLOAD_URL" \
+ --maximum-deltas 0 \
+ -o "$RELEASE_DIR/appcast.xml"
+
+grep -q "$DOWNLOAD_URL$ARCHIVE_NAME" "$RELEASE_DIR/appcast.xml"
+grep -q 'sparkle:edSignature=' "$RELEASE_DIR/appcast.xml"
+
+echo "Release artifacts are ready in $RELEASE_DIR"