diff --git a/.gitignore b/.gitignore index e26532e..ae8594a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Xcode build/ +.gh-pages-worktree/ DerivedData/ *.pbxuser !default.pbxuser diff --git a/README.md b/README.md index 4dbc130..ac1d5ec 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,11 @@ If a Hermes update changes the database schema or CLI output format, Scarf may n Download the latest build from [Releases](https://github.com/awizemann/scarf/releases): - `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended) -- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller) 1. Unzip and drag **Scarf.app** to Applications -2. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway) +2. Launch normally — builds are Developer ID signed and notarized, so Gatekeeper accepts them on first launch + +Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon. ### Build from Source @@ -178,6 +179,7 @@ The app opens `state.db` in read-only mode to avoid WAL contention with Hermes. | Package | Purpose | |---------|---------| | [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature | +| [Sparkle](https://github.com/sparkle-project/Sparkle) | Auto-updates from the GitHub-hosted appcast | Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching. @@ -327,6 +329,20 @@ Your agent can update the dashboard as part of cron jobs, after builds, or whene Each section defines a grid with 1–4 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type. +## Releases + +Scarf ships through GitHub releases — the App Store is not supported because Scarf spawns the user-installed `hermes` binary and reads `~/.hermes/` directly, both of which App Sandbox forbids. + +Each release goes through a single local script: [scripts/release.sh](scripts/release.sh). The script archives a universal binary, signs it with the Developer ID Application cert, submits to `notarytool`, staples the ticket, produces the distribution zip, signs an appcast entry with Sparkle's EdDSA key, pushes an updated `appcast.xml` to the `gh-pages` branch, creates the GitHub release, and tags `main`. + +The Sparkle appcast is served from [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml). + +Signing prerequisites (one-time): + +- `Developer ID Application` certificate in the login Keychain +- `scarf-notary` keychain profile registered via `xcrun notarytool store-credentials` +- Sparkle EdDSA private key in Keychain item `https://sparkle-project.org` (back this up — without it, shipped apps can never receive updates) + ## Contributing Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR. diff --git a/scarf/scarf.xcodeproj/project.pbxproj b/scarf/scarf.xcodeproj/project.pbxproj index 9799391..f7d3b1f 100644 --- a/scarf/scarf.xcodeproj/project.pbxproj +++ b/scarf/scarf.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; }; + 53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -33,9 +34,22 @@ 534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5349593F2F7B83B600BD31AD /* scarf */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 534959422F7B83B600BD31AD /* scarf */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */, + ); path = scarf; sourceTree = ""; }; @@ -57,6 +71,7 @@ buildActionMask = 2147483647; files = ( 53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */, + 53SPARKLE00010 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -118,6 +133,7 @@ name = scarf; packageProductDependencies = ( 53SWIFTTERM0001 /* SwiftTerm */, + 53SPARKLE00011 /* Sparkle */, ); productName = scarf; productReference = 534959402F7B83B600BD31AD /* scarf.app */; @@ -203,6 +219,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */, + 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */, ); preferredProjectObjectVersion = 77; productRefGroup = 534959412F7B83B600BD31AD /* Products */; @@ -407,23 +424,20 @@ CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = 3Q6X2L86C4; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Scarf; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat."; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = scarf/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.6.0; - PRODUCT_BUNDLE_IDENTIFIER = com.scarf; + PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -444,23 +458,20 @@ CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = 3Q6X2L86C4; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Scarf; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat."; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = scarf/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.6.0; - PRODUCT_BUNDLE_IDENTIFIER = com.scarf; + PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -594,6 +605,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.6.0; + }; + }; 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git"; @@ -605,6 +624,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 53SPARKLE00011 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; 53SWIFTTERM0001 /* SwiftTerm */ = { isa = XCSwiftPackageProductDependency; package = 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */; diff --git a/scarf/scarf/Core/Services/UpdaterService.swift b/scarf/scarf/Core/Services/UpdaterService.swift new file mode 100644 index 0000000..3ffd8e7 --- /dev/null +++ b/scarf/scarf/Core/Services/UpdaterService.swift @@ -0,0 +1,40 @@ +import Foundation +import Sparkle + +/// Thin wrapper around Sparkle's `SPUStandardUpdaterController`. +/// +/// Sparkle reads `SUFeedURL`, `SUPublicEDKey`, and check-interval defaults from Info.plist. +/// This service exposes the bits the UI needs: a "check now" trigger, a toggle for automatic +/// checks, and observable state for the Settings screen. +@MainActor +@Observable +final class UpdaterService: NSObject { + private let controller: SPUStandardUpdaterController + + /// User-facing toggle. Mirrors `updater.automaticallyChecksForUpdates`. + var automaticallyChecksForUpdates: Bool { + get { controller.updater.automaticallyChecksForUpdates } + set { controller.updater.automaticallyChecksForUpdates = newValue } + } + + /// Last time Sparkle checked the appcast (nil before the first check). + var lastUpdateCheckDate: Date? { + controller.updater.lastUpdateCheckDate + } + + override init() { + // startingUpdater: true → Sparkle scans for updates on launch per Info.plist schedule. + // Default delegates are sufficient for a non-sandboxed app. + self.controller = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) + super.init() + } + + /// Triggers a user-initiated update check. Sparkle handles the UI (alert, progress, install). + func checkForUpdates() { + controller.checkForUpdates(nil) + } +} diff --git a/scarf/scarf/Features/Settings/Views/Components/UpdatesSection.swift b/scarf/scarf/Features/Settings/Views/Components/UpdatesSection.swift new file mode 100644 index 0000000..01e7002 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Components/UpdatesSection.swift @@ -0,0 +1,51 @@ +import SwiftUI + +/// Updates section for the General tab. Wraps the Sparkle-backed `UpdaterService` +/// in the same row idioms used elsewhere in Settings (per CLAUDE.md guidance — +/// extract sections so individual tab bodies stay small). +struct UpdatesSection: View { + @Environment(UpdaterService.self) private var updater + + var body: some View { + SettingsSection(title: "Updates", icon: "arrow.down.circle") { + ReadOnlyRow(label: "Current Version", value: versionString) + ToggleRow( + label: "Check Automatically", + isOn: updater.automaticallyChecksForUpdates + ) { newValue in + updater.automaticallyChecksForUpdates = newValue + } + ReadOnlyRow(label: "Last Checked", value: lastCheckedString) + checkNowRow + } + } + + private var versionString: String { + let info = Bundle.main.infoDictionary + let short = info?["CFBundleShortVersionString"] as? String ?? "?" + let build = info?["CFBundleVersion"] as? String ?? "?" + return "\(short) (\(build))" + } + + private var lastCheckedString: String { + guard let date = updater.lastUpdateCheckDate else { return "Never" } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: date, relativeTo: Date()) + } + + private var checkNowRow: some View { + HStack { + Text("Check Now") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 160, alignment: .trailing) + Button("Check for Updates…") { updater.checkForUpdates() } + .controlSize(.small) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.3)) + } +} diff --git a/scarf/scarf/Features/Settings/Views/Tabs/GeneralTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/GeneralTab.swift index 3ad1555..960c953 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/GeneralTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/GeneralTab.swift @@ -39,6 +39,8 @@ struct GeneralTab: View { SettingsSection(title: "Locale", icon: "globe.americas") { EditableTextField(label: "Timezone (IANA)", value: viewModel.config.timezone) { viewModel.setTimezone($0) } } + + UpdatesSection() } /// Breadcrumb-style row that points users to the Credential Pools sidebar diff --git a/scarf/scarf/Info.plist b/scarf/scarf/Info.plist new file mode 100644 index 0000000..397ee1c --- /dev/null +++ b/scarf/scarf/Info.plist @@ -0,0 +1,42 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Scarf + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + + NSMicrophoneUsageDescription + Scarf uses the microphone for Hermes voice chat. + SUFeedURL + https://awizemann.github.io/scarf/appcast.xml + SUPublicEDKey + sxHR0OGLmx9I4Fyx1GdPANR9WUiVAz/rI38x3cLYnMU= + SUEnableAutomaticChecks + + SUScheduledCheckInterval + 86400 + SUEnableInstallerLauncherService + + + diff --git a/scarf/scarf/scarf.entitlements b/scarf/scarf/scarf.entitlements index b572d9c..454e8fb 100644 --- a/scarf/scarf/scarf.entitlements +++ b/scarf/scarf/scarf.entitlements @@ -4,5 +4,7 @@ com.apple.security.device.audio-input + com.apple.security.cs.disable-library-validation + diff --git a/scarf/scarf/scarfApp.swift b/scarf/scarf/scarfApp.swift index 816a43c..03b40e5 100644 --- a/scarf/scarf/scarfApp.swift +++ b/scarf/scarf/scarfApp.swift @@ -6,6 +6,7 @@ struct ScarfApp: App { @State private var fileWatcher = HermesFileWatcher() @State private var menuBarStatus = MenuBarStatus() @State private var chatViewModel = ChatViewModel() + @State private var updater = UpdaterService() var body: some Scene { WindowGroup { @@ -13,6 +14,7 @@ struct ScarfApp: App { .environment(coordinator) .environment(fileWatcher) .environment(chatViewModel) + .environment(updater) .onAppear { fileWatcher.startWatching() menuBarStatus.startPolling() @@ -23,9 +25,14 @@ struct ScarfApp: App { } } .defaultSize(width: 1100, height: 700) + .commands { + CommandGroup(after: .appInfo) { + Button("Check for Updates…") { updater.checkForUpdates() } + } + } MenuBarExtra("Scarf", systemImage: menuBarStatus.icon) { - MenuBarMenu(status: menuBarStatus, coordinator: coordinator) + MenuBarMenu(status: menuBarStatus, coordinator: coordinator, updater: updater) } } } @@ -90,6 +97,7 @@ final class MenuBarStatus { struct MenuBarMenu: View { let status: MenuBarStatus let coordinator: AppCoordinator + let updater: UpdaterService var body: some View { VStack { @@ -116,6 +124,8 @@ struct MenuBarMenu: View { NSApplication.shared.activate() } Divider() + Button("Check for Updates…") { updater.checkForUpdates() } + Divider() Button("Quit Scarf") { NSApplication.shared.terminate(nil) } diff --git a/scripts/ExportOptions.plist b/scripts/ExportOptions.plist new file mode 100644 index 0000000..ced639e --- /dev/null +++ b/scripts/ExportOptions.plist @@ -0,0 +1,18 @@ + + + + + method + developer-id + teamID + 3Q6X2L86C4 + signingStyle + manual + signingCertificate + Developer ID Application + destination + export + stripSwiftSymbols + + + diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..4b4eaab --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# +# Scarf release pipeline — local, manual, repeatable. +# +# Usage: ./scripts/release.sh 1.7.0 +# +# Prerequisites (one-time setup): +# 1. Developer ID Application cert installed in login Keychain. +# security find-identity -v -p codesigning | grep "Developer ID Application" +# 2. App Store Connect API key stored for notarytool as profile "scarf-notary": +# xcrun notarytool store-credentials "scarf-notary" \ +# --key ~/.private/AuthKey_XXXX.p8 --key-id --issuer +# 3. Sparkle EdDSA keypair generated (private key in Keychain item "https://sparkle-project.org"): +# ./scripts/sparkle/generate_keys # or similar, from Sparkle SPM artifacts +# 4. gh-pages branch exists with an appcast.xml and GitHub Pages enabled. +# 5. gh CLI authed: `gh auth status`. +# 6. GH_PAGES_WORKTREE env var pointing at a gh-pages checkout, OR let the +# script create one automatically at .gh-pages-worktree/ via `git worktree add`. +# +set -euo pipefail + +# ---------- config ---------- +VERSION="${1:?usage: ./scripts/release.sh e.g. 1.7.0}" +TEAM_ID="3Q6X2L86C4" +BUNDLE_ID="com.scarf.app" +SCHEME="scarf" +PROJECT="scarf/scarf.xcodeproj" +NOTARY_PROFILE="scarf-notary" +SIGNING_IDENTITY="Developer ID Application" +APPCAST_URL="https://awizemann.github.io/scarf/appcast.xml" +DOWNLOAD_URL_BASE="https://github.com/awizemann/scarf/releases/download" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="$REPO_ROOT/build" +ARCHIVE_PATH="$BUILD_DIR/scarf.xcarchive" +EXPORT_DIR="$BUILD_DIR/export" +EXPORT_OPTIONS="$REPO_ROOT/scripts/ExportOptions.plist" +RELEASE_DIR="$REPO_ROOT/releases/v${VERSION}" +GH_PAGES_WORKTREE="${GH_PAGES_WORKTREE:-$REPO_ROOT/.gh-pages-worktree}" + +# ---------- helpers ---------- +log() { printf '\033[1;34m==> %s\033[0m\n' "$*"; } +warn() { printf '\033[1;33m[WARN] %s\033[0m\n' "$*" >&2; } +die() { printf '\033[1;31m[ERR] %s\033[0m\n' "$*" >&2; exit 1; } + +require_cmd() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; } + +# ---------- preflight ---------- +log "Preflight checks" +require_cmd xcodebuild +require_cmd xcrun +require_cmd ditto +require_cmd gh + +cd "$REPO_ROOT" + +# git must be clean and on main +if [[ -n "$(git status --porcelain)" ]]; then + die "working tree not clean — commit or stash first" +fi +CUR_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +[[ "$CUR_BRANCH" == "main" ]] || die "not on main (on $CUR_BRANCH)" + +# identity present +security find-identity -v -p codesigning | grep -q "$SIGNING_IDENTITY" \ + || die "'$SIGNING_IDENTITY' certificate not in Keychain — create at developer.apple.com" + +# notary profile present (can't list, only test by dry-running submit help) +xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" --output-format json >/dev/null 2>&1 \ + || die "notarytool profile '$NOTARY_PROFILE' not set up — see script header" + +# locate sign_update (ships with Sparkle SPM artifacts) +SIGN_UPDATE="$(find ~/Library/Developer/Xcode/DerivedData -name sign_update -type f -perm +111 2>/dev/null | head -n1 || true)" +[[ -x "${SIGN_UPDATE:-}" ]] || die "sign_update not found — build the project once in Xcode so Sparkle artifacts resolve, then re-run" + +# ---------- bump version ---------- +log "Bumping version to $VERSION" +PBXPROJ="$PROJECT/project.pbxproj" +# CURRENT_PROJECT_VERSION (build number) bumps by 1 from existing +CUR_BUILD="$(awk -F'= ' '/CURRENT_PROJECT_VERSION/ {gsub(/[; ]/,"",$2); print $2; exit}' "$PBXPROJ")" +NEW_BUILD=$((CUR_BUILD + 1)) +sed -i '' -E "s/MARKETING_VERSION = [0-9]+\.[0-9]+\.[0-9]+;/MARKETING_VERSION = ${VERSION};/g" "$PBXPROJ" +sed -i '' -E "s/CURRENT_PROJECT_VERSION = [0-9]+;/CURRENT_PROJECT_VERSION = ${NEW_BUILD};/g" "$PBXPROJ" +git add "$PBXPROJ" +git commit -m "chore: Bump version to ${VERSION}" + +# ---------- build ---------- +log "Clean build directory" +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" + +log "Archive (universal arm64+x86_64)" +xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration Release \ + -archivePath "$ARCHIVE_PATH" \ + -destination "generic/platform=macOS" \ + ONLY_ACTIVE_ARCH=NO \ + ARCHS="arm64 x86_64" \ + archive + +log "Export signed .app" +xcodebuild \ + -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_DIR" \ + -exportOptionsPlist "$EXPORT_OPTIONS" + +APP_PATH="$EXPORT_DIR/Scarf.app" +[[ -d "$APP_PATH" ]] || die "exported app not found at $APP_PATH (expected Scarf.app — confirm PRODUCT_NAME)" + +# ---------- verify signature ---------- +log "Verify signature" +codesign --verify --deep --strict --verbose=2 "$APP_PATH" +# spctl will fail here (not yet notarized) — that's fine, we check after stapling +spctl --assess --type execute --verbose "$APP_PATH" || true + +# ---------- notarize ---------- +log "Zip for notarization" +NOTARIZE_ZIP="$BUILD_DIR/Scarf-notarize.zip" +ditto -c -k --keepParent "$APP_PATH" "$NOTARIZE_ZIP" + +log "Submit to notarytool (blocking)" +xcrun notarytool submit "$NOTARIZE_ZIP" \ + --keychain-profile "$NOTARY_PROFILE" \ + --wait \ + --timeout 30m + +log "Staple notarization ticket" +xcrun stapler staple "$APP_PATH" +xcrun stapler validate "$APP_PATH" + +log "Final gatekeeper assessment" +spctl --assess --type execute --verbose "$APP_PATH" + +# ---------- package distribution artifacts ---------- +log "Package distribution zips" +mkdir -p "$RELEASE_DIR" +UNIVERSAL_ZIP="$RELEASE_DIR/Scarf-v${VERSION}-Universal.zip" +ditto -c -k --keepParent "$APP_PATH" "$UNIVERSAL_ZIP" + +# ---------- sign appcast entry ---------- +log "Sign appcast entry with EdDSA" +# sign_update prints: sparkle:edSignature="..." length="..." +SIG_OUTPUT="$("$SIGN_UPDATE" "$UNIVERSAL_ZIP")" +ED_SIGNATURE="$(echo "$SIG_OUTPUT" | sed -nE 's/.*sparkle:edSignature="([^"]+)".*/\1/p')" +FILE_LENGTH="$(echo "$SIG_OUTPUT" | sed -nE 's/.*length="([^"]+)".*/\1/p')" +[[ -n "$ED_SIGNATURE" && -n "$FILE_LENGTH" ]] || die "sign_update did not produce signature: $SIG_OUTPUT" + +# ---------- update appcast on gh-pages ---------- +log "Update appcast.xml on gh-pages worktree" +if [[ ! -d "$GH_PAGES_WORKTREE" ]]; then + git worktree add "$GH_PAGES_WORKTREE" gh-pages +fi +( + cd "$GH_PAGES_WORKTREE" + git pull --ff-only origin gh-pages + PUB_DATE="$(LC_TIME=en_US.UTF-8 date -u +"%a, %d %b %Y %H:%M:%S +0000")" + DOWNLOAD_URL="$DOWNLOAD_URL_BASE/v${VERSION}/Scarf-v${VERSION}-Universal.zip" + NEW_ITEM=$(cat < + Version ${VERSION} + ${NEW_BUILD} + ${VERSION} + 14.6 + ${PUB_DATE} + + +EOF +) + # Insert new item after en line + python3 - "$NEW_ITEM" <<'PY' +import sys, pathlib +new_item = sys.argv[1] +p = pathlib.Path("appcast.xml") +xml = p.read_text() +marker = "en" +if marker not in xml: + sys.exit("appcast.xml missing en marker") +xml = xml.replace(marker, marker + "\n" + new_item, 1) +p.write_text(xml) +PY + git add appcast.xml + git commit -m "release: v${VERSION}" + git push origin gh-pages +) + +# ---------- github release ---------- +log "Create GitHub release and upload artifacts" +gh release create "v${VERSION}" \ + --title "Scarf v${VERSION}" \ + --notes "Release notes: https://github.com/awizemann/scarf/releases/tag/v${VERSION}" \ + "$UNIVERSAL_ZIP" + +log "Tag main and push" +git tag "v${VERSION}" +git push origin main --tags + +log "Release v${VERSION} complete" +log " Download: $DOWNLOAD_URL_BASE/v${VERSION}/Scarf-v${VERSION}-Universal.zip" +log " Appcast: $APPCAST_URL"