From c7d4db3dd083eeecb251e3b5ca9cc945d6bdeb26 Mon Sep 17 00:00:00 2001 From: superdooper86 Date: Wed, 15 Jul 2026 15:14:57 +0200 Subject: [PATCH 1/4] feat: prepare stable signed updates --- Meetingnotes.xcodeproj/project.pbxproj | 10 +-- .../xcshareddata/swiftpm/Package.resolved | 4 +- meetingnotes/Info.plist | 8 ++- .../Managers/LocalStorageManager.swift | 64 ++++++++++++++++++- meetingnotes/Views/SettingsView.swift | 38 +++++++++++ meetingnotes/meetingnotes.entitlements | 4 -- 6 files changed, 112 insertions(+), 16 deletions(-) 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/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..d19c6eb 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,11 @@ 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) + } else { + try FileManager.default.moveItem(at: tmpURL, to: fileURL) + } print("✅ Saved meeting: \(meeting.id)") return true @@ -128,6 +137,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 +200,11 @@ 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) + } else { + try FileManager.default.moveItem(at: tmpURL, to: fileURL) + } print("✅ Saved template: \(template.id)") return true @@ -241,4 +299,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 -- 2.47.3 From d1b0c28d7a53f3d3dd06d53da45dedb7b523cead Mon Sep 17 00:00:00 2001 From: superdooper86 Date: Wed, 15 Jul 2026 15:17:23 +0200 Subject: [PATCH 2/4] ci: automate signed macOS releases --- .github/workflows/release.yml | 94 ++++++++++++++++ README.md | 55 +++++----- appcast.xml | 82 +------------- scripts/configure_github_release_secrets.sh | 34 ++++++ scripts/package_release.sh | 115 ++++++++++++++++++++ 5 files changed, 276 insertions(+), 104 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100755 scripts/configure_github_release_secrets.sh create mode 100755 scripts/package_release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4edffd3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,94 @@ +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 + git tag "$tag" "$GITHUB_SHA" + git push origin "$tag" + gh release create "$tag" \ + "$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \ + "$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \ + --title "Meetingnotes $VERSION" \ + --generate-notes \ + --verify-tag + + - name: Remove signing keychain + if: always() + run: | + if [[ -n "${RELEASE_KEYCHAIN:-}" ]]; then + security delete-keychain "$RELEASE_KEYCHAIN" || true + fi 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/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" -- 2.47.3 From c397f11ebc536e24a6fe750af83c6b351cdda59d Mon Sep 17 00:00:00 2001 From: superdooper86 Date: Wed, 15 Jul 2026 15:18:39 +0200 Subject: [PATCH 3/4] ci: publish releases atomically --- .github/workflows/release.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4edffd3..9b653b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,14 +77,12 @@ jobs: echo "Tag already exists: $tag" >&2 exit 1 fi - git tag "$tag" "$GITHUB_SHA" - git push origin "$tag" 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 \ - --verify-tag + --generate-notes - name: Remove signing keychain if: always() -- 2.47.3 From 05e08eec4acb76befe85db5d4cb690b2b26798f1 Mon Sep 17 00:00:00 2001 From: superdooper86 Date: Wed, 15 Jul 2026 15:20:31 +0200 Subject: [PATCH 4/4] fix: use compatible atomic replacement API --- meetingnotes/Managers/LocalStorageManager.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/meetingnotes/Managers/LocalStorageManager.swift b/meetingnotes/Managers/LocalStorageManager.swift index d19c6eb..9fb0cde 100644 --- a/meetingnotes/Managers/LocalStorageManager.swift +++ b/meetingnotes/Managers/LocalStorageManager.swift @@ -53,7 +53,13 @@ class LocalStorageManager { let tmpURL = fileURL.appendingPathExtension("tmp") try data.write(to: tmpURL, options: .atomic) if FileManager.default.fileExists(atPath: fileURL.path) { - try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL) + try FileManager.default.replaceItem( + at: fileURL, + withItemAt: tmpURL, + backupItemName: nil, + options: [], + resultingItemURL: nil + ) } else { try FileManager.default.moveItem(at: tmpURL, to: fileURL) } @@ -201,7 +207,13 @@ class LocalStorageManager { let tmpURL = fileURL.appendingPathExtension("tmp") try data.write(to: tmpURL, options: .atomic) if FileManager.default.fileExists(atPath: fileURL.path) { - try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL) + try FileManager.default.replaceItem( + at: fileURL, + withItemAt: tmpURL, + backupItemName: nil, + options: [], + resultingItemURL: nil + ) } else { try FileManager.default.moveItem(at: tmpURL, to: fileURL) } -- 2.47.3