From 25ccd30d2063e8a6e5f485bc40ac3521f5d63960 Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Thu, 17 Sep 2026 00:02:54 +0530 Subject: [PATCH 1/2] feat: check GitHub Releases for updates and surface them quietly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UpdateChecker: one GET of the latest-release API 10s after launch, then daily; compared numerically against CFBundleShortVersionString (off under swift run, which has no bundle) - A newer tag shows an "Update Available: v…" menu-bar item that opens the release page, a dot on the menu-bar icon until the menu is opened or the item clicked (remembered per version), and one notification banner per version (authorization is requested in context, the first time an update is found; declined just means menu item only) - Check-only on purpose: the app is ad-hoc signed, so a self-replacing updater can't be trusted - package.sh defaults VERSION to the latest git tag so local builds aren't "outdated"; the plist template reads 0.0.0 (releases stamp the tag) - Tests cover the version compare, the payload parsing, and the item / dot / banner bookkeeping with GitHub and the notification center stubbed --- CLAUDE.md | 19 +++ Packaging/Info.plist | 2 +- Sources/Tic/AppDelegate.swift | 21 +++ Sources/Tic/AppModel.swift | 2 + Sources/Tic/TicApp.swift | 42 ++++- Sources/Tic/UpdateChecker.swift | 197 ++++++++++++++++++++++++ Tests/TicTests/UpdateCheckerTests.swift | 161 +++++++++++++++++++ scripts/package.sh | 3 + 8 files changed, 438 insertions(+), 9 deletions(-) create mode 100644 Sources/Tic/UpdateChecker.swift create mode 100644 Tests/TicTests/UpdateCheckerTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 2e13666..b4f40e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,6 +166,25 @@ panels**, and a few responsibilities are deliberately split across the AppKit/Sw (title/task/secondary/completed/checkbox) for a `Surface` (`.solid` uses per-theme tuned inks; `.glass` uses adaptive `.primary`/`.secondary`). Views read a resolved `NoteTheme`, never branch on material themselves. +- **Updates are check-only (`UpdateChecker`), never auto-install.** Tic is ad-hoc signed, so a + self-replacing updater (Sparkle) can't be trusted. Daily GET of the GitHub `releases/latest` API, + compared numerically against `CFBundleShortVersionString` (nil under `swift run` → checker off). + A newer tag shows a menu-bar item, a dot on the menu-bar icon until the menu is opened or the + item clicked (`isUnseen`, remembered in the `updateSeenVersion` default; "opened" is detected via + `NSMenu.didEndTrackingNotification` on a menu containing our item; the dot is **drawn into a + second template `NSImage`** because the status item renders only the label's image, never a + SwiftUI overlay), and **one** notification + banner per version (`updateNotifiedVersion`; authorization is requested only then, so the system + prompt appears in context). `UNUserNotificationCenter` needs a bundle id, so both the delegate + and the banner are guarded. `package.sh` defaults `VERSION` to the latest git tag so a local build + isn't "outdated". + - **Seeing it locally:** `VERSION=0.1.0 ./scripts/package.sh --open` shows the menu item and the + dot. The banner is stricter: macOS refuses notification authorization (`UNErrorDomain` code 1, + "Notifications are not allowed for this application") for an ad-hoc bundle **outside + /Applications**, and the refusal seemed to stick to the bundle id afterwards. So to test the + banner, copy the build to `/Applications/Tic Dev.app`, give it a unique `CFBundleIdentifier` + (PlistBuddy), re-sign ad-hoc, then `open` it. Re-arm with + `defaults delete updateNotifiedVersion updateSeenVersion`, and delete the copy after. - **Models are sync-friendly.** `Note`/`TaskItem` use `UUID` PKs + `updatedAt`; relationships and defaults are chosen so CloudKit/iCloud sync stays feasible later (currently local-only). diff --git a/Packaging/Info.plist b/Packaging/Info.plist index 0133956..9f97bb2 100644 --- a/Packaging/Info.plist +++ b/Packaging/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.2.0 + 0.0.0 CFBundleVersion 1 LSMinimumSystemVersion diff --git a/Sources/Tic/AppDelegate.swift b/Sources/Tic/AppDelegate.swift index 49413a7..efe0318 100644 --- a/Sources/Tic/AppDelegate.swift +++ b/Sources/Tic/AppDelegate.swift @@ -1,4 +1,5 @@ import AppKit +import UserNotifications /// App lifecycle owner. As a plain SwiftPM executable (no .app bundle yet) we must explicitly /// adopt a regular activation policy so Tic gets a Dock icon and can take foreground focus. @@ -11,6 +12,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApp.setActivationPolicy(.regular) Task { await AppModel.shared.bootstrap() } installNewNoteShortcut() + // Update banners (UpdateChecker). `current()` throws without a bundle id (swift run). + if Bundle.main.bundleIdentifier != nil { + UNUserNotificationCenter.current().delegate = self + } } /// ⌘N → New Note while Tic is the active app. A menu-style `MenuBarExtra` button's @@ -49,3 +54,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { AppModel.shared.newNote() } } + +/// Update-banner delegate: show it even while Tic is frontmost (macOS hides a frontmost app's +/// notifications by default), and a click opens the release page. +extension AppDelegate: UNUserNotificationCenterDelegate { + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + [.banner, .list] + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse + ) async { + await MainActor.run { AppModel.shared.updates.openReleasePage() } + } +} diff --git a/Sources/Tic/AppModel.swift b/Sources/Tic/AppModel.swift index 8233e76..e224ba6 100644 --- a/Sources/Tic/AppModel.swift +++ b/Sources/Tic/AppModel.swift @@ -14,6 +14,7 @@ final class AppModel { let database: AppDatabase let windows: NoteWindowManager + let updates = UpdateChecker() /// All saved notes (ascending `sortIndex`, i.e. creation order) — drives the menu bar list. /// Stays in sync as notes are created, renamed, or deleted. @@ -38,6 +39,7 @@ final class AppModel { await windows.restoreAll() startObservingNotes() applyLaunchAtLogin(launchAtLogin) // honor the saved preference (effective when packaged) + updates.start() } private func startObservingNotes() { diff --git a/Sources/Tic/TicApp.swift b/Sources/Tic/TicApp.swift index 35dc7f2..71bd8cf 100644 --- a/Sources/Tic/TicApp.swift +++ b/Sources/Tic/TicApp.swift @@ -17,23 +17,44 @@ struct TicApp: App { } /// The status-bar icon: a template-rendered menu-bar glyph (bundled via SPM resources), falling -/// back to an SF Symbol if the resource can't be found. +/// back to an SF Symbol if the resource can't be found. A small dot marks an update the user +/// hasn't looked at yet (`UpdateChecker.isUnseen`). The dot is drawn *into* the image: the status +/// item renders only the label's image, so a SwiftUI overlay never shows. private struct MenuBarLabel: View { + private var updates: UpdateChecker { AppModel.shared.updates } + var body: some View { - if let icon = Self.icon { + if let icon = updates.isUnseen ? Self.badgedIcon : Self.icon { Image(nsImage: icon).renderingMode(.template) } else { - Image(systemName: "checklist") + Image(systemName: updates.isUnseen ? "checklist.checked" : "checklist") } } - private static let icon: NSImage? = { - guard let url = menuBarIconURL(), - let image = NSImage(contentsOf: url) else { return nil } + private static let glyph: NSImage? = { + guard let url = menuBarIconURL() else { return nil } + return NSImage(contentsOf: url) + }() + private static let icon = glyph.map { compose($0, dot: false) } + private static let badgedIcon = glyph.map { compose($0, dot: true) } + + /// 20×18 template image: the 18pt glyph plus (optionally) a 6pt dot at the top-right, with a + /// knocked-out ring so it reads as a badge. Both variants share a size so nothing shifts. + private static func compose(_ glyph: NSImage, dot: Bool) -> NSImage { + let image = NSImage(size: NSSize(width: 20, height: 18), flipped: false) { _ in + glyph.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18)) + if dot { + let dot = NSRect(x: 14, y: 11, width: 6, height: 6) + NSGraphicsContext.current?.compositingOperation = .destinationOut + NSBezierPath(ovalIn: dot.insetBy(dx: -1.5, dy: -1.5)).fill() + NSGraphicsContext.current?.compositingOperation = .sourceOver + NSBezierPath(ovalIn: dot).fill() + } + return true + } image.isTemplate = true - image.size = NSSize(width: 18, height: 18) return image - }() + } private static func menuBarIconURL() -> URL? { let fileManager = FileManager.default @@ -100,6 +121,11 @@ private struct MenuBarContent: View { set: { model.setLaunchAtLogin($0) } )) + if let title = model.updates.menuTitle { + Divider() + Button(title) { model.updates.openReleasePage() } + } + Divider() Button("Quit Tic") { NSApplication.shared.terminate(nil) } diff --git a/Sources/Tic/UpdateChecker.swift b/Sources/Tic/UpdateChecker.swift new file mode 100644 index 0000000..949ea43 --- /dev/null +++ b/Sources/Tic/UpdateChecker.swift @@ -0,0 +1,197 @@ +import AppKit +import Foundation +import Observation +import UserNotifications + +/// Checks GitHub Releases for a newer Tic and surfaces it quietly: a menu-bar item while an update +/// exists, a dot on the menu-bar icon until the user has looked, and one notification banner per +/// new version. Check-only on purpose — Tic is ad-hoc signed, so a self-replacing updater +/// (Sparkle) can't be trusted; the release page is the download. +@MainActor +@Observable +final class UpdateChecker { + struct Release: Sendable, Equatable { + let version: String // "0.6.0" (tag without the leading "v") + let url: URL // the GitHub release page + } + + /// A release newer than the running build, if the last check found one. + private(set) var available: Release? + + /// True while `available` hasn't been looked at yet — drives the dot on the menu-bar icon. + /// Cleared (and remembered per version) once the menu is opened with the item in it, or the + /// item is clicked. + private(set) var isUnseen = false + + /// The menu item's title; also how the menu-tracking observer recognises our menu. + var menuTitle: String? { available.map { "Update Available: v\($0.version)…" } } + + /// The running build's `CFBundleShortVersionString`. Nil under `swift run` (no bundle), which + /// disables the checker entirely. + let currentVersion: String? + + private nonisolated static let latestReleaseURL = + URL(string: "https://api.github.com/repos/kasvith/tic/releases/latest")! + private static let interval: TimeInterval = 24 * 60 * 60 + private static let notifiedVersionKey = "updateNotifiedVersion" + private static let seenVersionKey = "updateSeenVersion" + + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private let fetch: @Sendable () async throws -> Release + @ObservationIgnored private let notify: @MainActor (Release, String) async -> Void + @ObservationIgnored private var loop: Task? + + /// The parameters exist for tests; the app uses the defaults (GitHub + a real banner). + init( + currentVersion: String? = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, + defaults: UserDefaults = .standard, + fetch: @escaping @Sendable () async throws -> Release = UpdateChecker.fetchLatest, + notify: @escaping @MainActor (Release, String) async -> Void = UpdateChecker.postBanner + ) { + self.currentVersion = currentVersion + self.defaults = defaults + self.fetch = fetch + self.notify = notify + + // Opening the menu (and so seeing the item) counts as "seen": the dot goes once it closes. + NotificationCenter.default.addObserver( + forName: NSMenu.didEndTrackingNotification, object: nil, queue: .main + ) { [weak self] note in + nonisolated(unsafe) let menu = note.object as? NSMenu // queue: .main, so this is main-actor safe + MainActor.assumeIsolated { + guard let self, let menu, let title = self.menuTitle, + menu.items.contains(where: { $0.title == title }) else { return } + self.markSeen() + } + } + } + + /// Check shortly after launch (so it never competes with restoring notes), then daily. + func start() { + guard currentVersion != nil else { + NSLog("[Tic] update check disabled: no bundle version (swift run?)") + return + } + loop?.cancel() + loop = Task { [weak self] in + try? await Task.sleep(for: .seconds(10)) + while !Task.isCancelled { + await self?.check() + try? await Task.sleep(for: .seconds(Self.interval)) + } + } + } + + func check() async { + guard let currentVersion else { return } + do { + let release = try await fetch() + guard Self.isNewer(release.version, than: currentVersion) else { + available = nil + isUnseen = false + return + } + available = release + isUnseen = defaults.string(forKey: Self.seenVersionKey) != release.version + NSLog("[Tic] update available: v\(release.version)") + await notifyOnce(release) + } catch { + NSLog("[Tic] update check failed: \(error)") // silent for the user, by design + } + } + + func openReleasePage() { + guard let available else { return } + markSeen() + NSWorkspace.shared.open(available.url) + } + + func markSeen() { + guard let available else { return } + isUnseen = false + defaults.set(available.version, forKey: Self.seenVersionKey) + } + + /// One banner per version, remembered in `defaults` so daily checks and relaunches never repeat it. + private func notifyOnce(_ release: Release) async { + guard let currentVersion, defaults.string(forKey: Self.notifiedVersionKey) != release.version else { return } + defaults.set(release.version, forKey: Self.notifiedVersionKey) + await notify(release, currentVersion) + } + + // MARK: - Version compare + + /// Numeric, component-wise compare ("0.10.0" > "0.9.0"; "1.0" == "1.0.0"); a leading "v" is ignored. + nonisolated static func isNewer(_ candidate: String, than current: String) -> Bool { + let a = components(candidate), b = components(current) + let n = max(a.count, b.count) + for i in 0.. y } + } + return false + } + + private nonisolated static func components(_ version: String) -> [Int] { + var s = Substring(version) + if s.first == "v" || s.first == "V" { s = s.dropFirst() } + return s.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 } + } + + // MARK: - GitHub + + private struct LatestRelease: Decodable { // snake_case keys: tag_name, html_url + let tagName: String + let htmlUrl: URL + } + + /// `releases/latest` already excludes drafts and pre-releases. + nonisolated static func fetchLatest() async throws -> Release { + var request = URLRequest(url: latestReleaseURL) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + let (data, _) = try await URLSession.shared.data(for: request) + return try parse(data) + } + + /// Decodes the `releases/latest` payload, stripping the tag's leading "v". + nonisolated static func parse(_ data: Data) throws -> Release { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let latest = try decoder.decode(LatestRelease.self, from: data) + var version = latest.tagName + if version.hasPrefix("v") { version.removeFirst() } + return Release(version: version, url: latest.htmlUrl) + } + + // MARK: - Banner + + /// Authorization is requested here, the first time an update is actually found, so the system + /// prompt appears with the banner that explains it. Declined → the menu item still works and + /// we never ask again for this version. `UNUserNotificationCenter` needs a bundle id (swift run). + static func postBanner(_ release: Release, currentVersion: String) async { + guard Bundle.main.bundleIdentifier != nil else { return } + let center = UNUserNotificationCenter.current() + let granted: Bool + do { + granted = try await center.requestAuthorization(options: [.alert]) + } catch { + NSLog("[Tic] notification authorization failed: \(error)") + return + } + guard granted else { + NSLog("[Tic] notifications declined; the menu item still shows the update") + return + } + + let content = UNMutableNotificationContent() + content.title = "Tic v\(release.version) is available" + content.body = "You're on v\(currentVersion). Click to see what's new and download." + let request = UNNotificationRequest(identifier: "update-\(release.version)", content: content, trigger: nil) + do { + try await center.add(request) + } catch { + NSLog("[Tic] update notification failed: \(error)") + } + } +} diff --git a/Tests/TicTests/UpdateCheckerTests.swift b/Tests/TicTests/UpdateCheckerTests.swift new file mode 100644 index 0000000..80750ed --- /dev/null +++ b/Tests/TicTests/UpdateCheckerTests.swift @@ -0,0 +1,161 @@ +import AppKit +import Foundation +import Testing +@testable import Tic + +@Suite("UpdateChecker") +struct UpdateCheckerTests { + @Test("detects newer versions numerically") + func newer() { + #expect(UpdateChecker.isNewer("0.6.0", than: "0.5.0")) + #expect(UpdateChecker.isNewer("v0.6.0", than: "0.5.0")) + #expect(UpdateChecker.isNewer("0.10.0", than: "0.9.0")) // not lexical + #expect(UpdateChecker.isNewer("1.0.1", than: "1.0")) + } + + @Test("same, older, or unparseable is not an update") + func notNewer() { + #expect(!UpdateChecker.isNewer("0.5.0", than: "0.5.0")) + #expect(!UpdateChecker.isNewer("1.0", than: "1.0.0")) + #expect(!UpdateChecker.isNewer("0.4.9", than: "0.5.0")) + #expect(!UpdateChecker.isNewer("garbage", than: "0.5.0")) + } + + @Test("parses the GitHub latest-release payload and strips the tag's v") + func parse() throws { + let json = #""" + {"tag_name":"v0.6.0","name":"Tic v0.6.0","assets":[], + "html_url":"https://github.com/kasvith/tic/releases/tag/v0.6.0"} + """# + let release = try UpdateChecker.parse(Data(json.utf8)) + #expect(release.version == "0.6.0") + #expect(release.url.absoluteString == "https://github.com/kasvith/tic/releases/tag/v0.6.0") + #expect(throws: (any Error).self) { try UpdateChecker.parse(Data("{}".utf8)) } + } +} + +/// The stateful side: menu item, dot, and banner bookkeeping, with GitHub and the notification +/// center stubbed out. +@Suite("UpdateChecker state") +@MainActor +struct UpdateCheckerStateTests { + /// Banners the stubbed notifier "posted" (by version). + final class Banners { var posted: [String] = [] } + + private nonisolated static let releasePage = URL(string: "https://github.com/kasvith/tic/releases/tag/v0.6.0")! + + private static func freshDefaults() -> UserDefaults { + let name = "UpdateCheckerTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + private static func makeChecker( + current: String? = "0.5.0", latest: String? = "0.6.0", + defaults: UserDefaults = freshDefaults(), banners: Banners = Banners() + ) -> UpdateChecker { + UpdateChecker( + currentVersion: current, defaults: defaults, + fetch: { + guard let latest else { throw URLError(.notConnectedToInternet) } + return .init(version: latest, url: releasePage) + }, + notify: { release, _ in banners.posted.append(release.version) } + ) + } + + @Test("a newer release sets the item and the dot, and posts one banner even across re-checks") + func newerRelease() async { + let banners = Banners() + let checker = Self.makeChecker(banners: banners) + await checker.check() + #expect(checker.menuTitle == "Update Available: v0.6.0…") + #expect(checker.isUnseen) + #expect(banners.posted == ["0.6.0"]) + + await checker.check() // the daily re-check + #expect(checker.menuTitle == "Update Available: v0.6.0…") + #expect(banners.posted == ["0.6.0"]) + } + + @Test("up to date (or ahead) shows nothing") + func upToDate() async { + let banners = Banners() + for latest in ["0.5.0", "0.4.0"] { + let checker = Self.makeChecker(latest: latest, banners: banners) + await checker.check() + #expect(checker.menuTitle == nil) + #expect(!checker.isUnseen) + } + #expect(banners.posted.isEmpty) + } + + @Test("the banner is remembered per version across launches") + func bannerOncePerVersion() async { + let defaults = Self.freshDefaults() + let banners = Banners() + await Self.makeChecker(defaults: defaults, banners: banners).check() + await Self.makeChecker(defaults: defaults, banners: banners).check() // relaunch + #expect(banners.posted == ["0.6.0"]) + await Self.makeChecker(latest: "0.7.0", defaults: defaults, banners: banners).check() + #expect(banners.posted == ["0.6.0", "0.7.0"]) + } + + @Test("markSeen clears the dot, keeps the item, and is remembered across launches") + func seen() async { + let defaults = Self.freshDefaults() + let checker = Self.makeChecker(defaults: defaults) + await checker.check() + checker.markSeen() + #expect(!checker.isUnseen) + #expect(checker.menuTitle != nil) + + let relaunched = Self.makeChecker(defaults: defaults) + await relaunched.check() + #expect(!relaunched.isUnseen) + #expect(relaunched.menuTitle != nil) + + let newer = Self.makeChecker(latest: "0.7.0", defaults: defaults) // a newer one re-dots + await newer.check() + #expect(newer.isUnseen) + } + + @Test("closing a menu that showed the item counts as seen; other menus don't") + func menuTracking() async throws { + let checker = Self.makeChecker() + await checker.check() + #expect(checker.isUnseen) + + let other = NSMenu() + other.addItem(withTitle: "Quit Tic", action: nil, keyEquivalent: "") + NotificationCenter.default.post(name: NSMenu.didEndTrackingNotification, object: other) + try await Task.sleep(for: .milliseconds(50)) + #expect(checker.isUnseen) + + let ours = NSMenu() + ours.addItem(withTitle: checker.menuTitle!, action: nil, keyEquivalent: "") + NotificationCenter.default.post(name: NSMenu.didEndTrackingNotification, object: ours) + try await Task.sleep(for: .milliseconds(50)) + #expect(!checker.isUnseen) + } + + @Test("a failed fetch is silent and leaves the last result alone") + func fetchFailure() async { + let banners = Banners() + let checker = Self.makeChecker(latest: nil, banners: banners) + await checker.check() + #expect(checker.menuTitle == nil) + #expect(!checker.isUnseen) + #expect(banners.posted.isEmpty) + } + + @Test("no bundle version (swift run) disables the check") + func noBundleVersion() async { + let banners = Banners() + let checker = Self.makeChecker(current: nil, banners: banners) + await checker.check() + #expect(checker.menuTitle == nil) + #expect(banners.posted.isEmpty) + } +} diff --git a/scripts/package.sh b/scripts/package.sh index 73dd0d9..8d4b773 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -43,6 +43,9 @@ if [ ! -f "$APP/Contents/Resources/Tic_Tic.bundle/MenuBarIcon.png" ]; then exit 1 fi +# The release workflow passes the tag; local builds default to the latest tag so a dev build +# doesn't look outdated to the update checker. +VERSION="${VERSION:-$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')}" if [ -n "${VERSION:-}" ]; then echo "▸ Stamping version ${VERSION}…" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${VERSION}" "$APP/Contents/Info.plist" From a3c52cb69167670c21c463a7b1ac045a19f16d7b Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Thu, 17 Sep 2026 00:07:49 +0530 Subject: [PATCH 2/2] fix(packaging): don't abort when no git tag is reachable (shallow CI checkout) --- scripts/package.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/package.sh b/scripts/package.sh index 8d4b773..1ecdc80 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -44,8 +44,11 @@ if [ ! -f "$APP/Contents/Resources/Tic_Tic.bundle/MenuBarIcon.png" ]; then fi # The release workflow passes the tag; local builds default to the latest tag so a dev build -# doesn't look outdated to the update checker. -VERSION="${VERSION:-$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')}" +# doesn't look outdated to the update checker. No reachable tag (a shallow CI checkout) → unstamped. +if [ -z "${VERSION:-}" ]; then + VERSION="$(git describe --tags --abbrev=0 2>/dev/null || true)" + VERSION="${VERSION#v}" +fi if [ -n "${VERSION:-}" ]; then echo "▸ Stamping version ${VERSION}…" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${VERSION}" "$APP/Contents/Info.plist"