From 3d110de451ffb3e3453a8e1a592ad51f24f119f5 Mon Sep 17 00:00:00 2001 From: crazytan Date: Sat, 25 Jul 2026 20:46:47 -0700 Subject: [PATCH] Show the build's git commit next to the version in Settings Stamp BuildMetadata.xcconfig with the short HEAD hash via a scheme build pre-action (and explicitly in CI), feed it through BuildConfig.xcconfig into Info.plist as GITCommitHash, and render "Version 1.3.0 (a1b2c3d)" in Settings. Builds made outside a git checkout fall back to "dev". Update checks still compare the marketing version only. --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 3 + .gitignore | 3 + AGENTS.md | 2 +- BuildConfig.xcconfig | 9 ++ CHANGELOG.md | 3 + README.md | 1 + TaskMenu.xcodeproj/project.pbxproj | 34 ++--- .../xcshareddata/xcschemes/TaskMenu.xcscheme | 124 ++++++++++++++++++ TaskMenu/Models/AppState.swift | 15 ++- TaskMenu/Models/README.md | 3 +- TaskMenu/Resources/Info.plist | 2 + TaskMenu/Resources/README.md | 3 +- TaskMenu/Views/README.md | 2 +- TaskMenu/Views/SettingsView.swift | 4 +- TaskMenuTests/AppStateTests.swift | 43 ++++++ TaskMenuTests/README.md | 3 +- TaskMenuTests/SettingsVersionRowTests.swift | 56 ++++++++ project.yml | 27 +++- scripts/stamp_build_metadata.sh | 17 +++ 20 files changed, 330 insertions(+), 27 deletions(-) create mode 100644 BuildConfig.xcconfig create mode 100644 TaskMenu.xcodeproj/xcshareddata/xcschemes/TaskMenu.xcscheme create mode 100644 TaskMenuTests/SettingsVersionRowTests.swift create mode 100755 scripts/stamp_build_metadata.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 163e390..c484cb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: GOOGLE_REDIRECT_SCHEME = com.googleusercontent.apps.dummy-client-id EOF + - name: Stamp build metadata + run: ./scripts/stamp_build_metadata.sh . + - name: Install XcodeGen run: brew install xcodegen diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f504e1..226e04b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,6 +99,9 @@ jobs: printf 'DEVELOPMENT_TEAM = %s\n' "$APPLE_TEAM_ID" } > Config.xcconfig + - name: Stamp build metadata + run: ./scripts/stamp_build_metadata.sh . + - name: Install signing certificate shell: bash env: diff --git a/.gitignore b/.gitignore index d48eb43..019cbf4 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,7 @@ playground.xcworkspace *.key Secrets/ Config.xcconfig + +# Generated build metadata (git commit stamp) +BuildMetadata.xcconfig .wrangler/ diff --git a/AGENTS.md b/AGENTS.md index 113db13..2e1f41b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ Entry point for coding agents working on TaskMenu. Keep this file short and repo - If you add, remove, rename, or retarget source files, update `project.yml` and run `xcodegen generate`. - When files are added or deleted, update the corresponding folder-local `README.md` in the same change so its file map and ownership notes stay current. - Keep `TaskMenu.xcodeproj` generated; do not hand-edit it. -- Do not commit `Config.xcconfig`; it contains local OAuth and signing values. +- Do not commit `Config.xcconfig`; it contains local OAuth and signing values. `BuildConfig.xcconfig` is the committed entry point that includes it, plus the generated `BuildMetadata.xcconfig` git-commit stamp. - Preserve the menu-bar-only behavior for app launches. - Update `CHANGELOG.md` before committing feature or bug-fix work, under `## Unreleased` when present. diff --git a/BuildConfig.xcconfig b/BuildConfig.xcconfig new file mode 100644 index 0000000..ed13f4d --- /dev/null +++ b/BuildConfig.xcconfig @@ -0,0 +1,9 @@ +// Build configuration entry point for both Debug and Release. +// +// Config.xcconfig holds local OAuth and signing values and is never committed; +// copy Config.xcconfig.example to create it. +// BuildMetadata.xcconfig is generated by scripts/stamp_build_metadata.sh and +// supplies GIT_COMMIT_HASH for the version string shown in Settings. + +#include? "Config.xcconfig" +#include? "BuildMetadata.xcconfig" diff --git a/CHANGELOG.md b/CHANGELOG.md index d45f228..68fb7e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ ## Unreleased +### Added +- Settings now shows the git commit the build came from next to the version number, e.g. `Version 1.3.0 (a1b2c3d)`. Builds made outside a git checkout show `(dev)`. + ## v1.3.0 (2026-07-25) ### Added diff --git a/README.md b/README.md index 93c7a15..d7c2989 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ Or download the signed and notarized DMG from the [latest GitHub release](https: - XcodeGen-generated Xcode project - Apple frameworks only, with zero third-party dependencies - Strict concurrency enabled +- Builds are stamped with the current git commit by `scripts/stamp_build_metadata.sh` (a scheme build pre-action), and Settings shows it next to the version Build from the command line with: diff --git a/TaskMenu.xcodeproj/project.pbxproj b/TaskMenu.xcodeproj/project.pbxproj index 6bee3d5..8bb5112 100644 --- a/TaskMenu.xcodeproj/project.pbxproj +++ b/TaskMenu.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 0FD718B29CF8183561A6F550 /* TaskItemModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4CD6808B2587AD2F8F9563 /* TaskItemModelTests.swift */; }; 11E28771324DA02B74C9983F /* DateFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 500B536CAEBC7F3B0AF40519 /* DateFormattingTests.swift */; }; 16F0CFB570B7684CE136B82E /* MenuBarIcon.svg in Resources */ = {isa = PBXBuildFile; fileRef = D898FA58565B1CBB9ED2695E /* MenuBarIcon.svg */; }; + 199755B8E809EBE89ADC94B0 /* SettingsVersionRowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5CAE54DF55AAAB094159CE /* SettingsVersionRowTests.swift */; }; 27A7DDF8EE882EE13FB18EF6 /* MetricKitPayloadStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77DBAAB7CC102C6006686981 /* MetricKitPayloadStoreTests.swift */; }; 2869F2F8237B112F7767EFE6 /* TaskDetailAppKitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BE8A6C21B3086FFB9D3D86 /* TaskDetailAppKitViewController.swift */; }; 3059000A80728B12B6989398 /* KeychainServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 058C00FB539FD9B1D05BCBFD /* KeychainServiceTests.swift */; }; @@ -78,11 +79,13 @@ 220E8145372B626FB6D267C7 /* MetricKitService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricKitService.swift; sourceTree = ""; }; 246C75CF9B4CE92C49A9A967 /* TaskListContentAppKitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListContentAppKitView.swift; sourceTree = ""; }; 2667803C667BBD21DCCCD029 /* TaskListAppKitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListAppKitViewController.swift; sourceTree = ""; }; + 2C5CAE54DF55AAAB094159CE /* SettingsVersionRowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsVersionRowTests.swift; sourceTree = ""; }; 2C84C4C64E2BF982920144CA /* TaskListControlsAppKitViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListControlsAppKitViews.swift; sourceTree = ""; }; 33B8E5D270E820D7C6070D4D /* TaskMenuActionButtonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskMenuActionButtonTests.swift; sourceTree = ""; }; 3F2D65032E1F2A015F67B6D0 /* TaskPopoverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPopoverViewController.swift; sourceTree = ""; }; 3FC8DA7EF5FEF032B81469A3 /* GitHubUpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubUpdateChecker.swift; sourceTree = ""; }; 4041A68FD32907E3B89B8F8B /* MenuBarWindowGlassSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarWindowGlassSupport.swift; sourceTree = ""; }; + 4332DD537F3F61B0B7FAD4B1 /* BuildConfig.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildConfig.xcconfig; sourceTree = ""; }; 4AC00155161191D4DBBDA015 /* SearchFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFilterTests.swift; sourceTree = ""; }; 4DC959FBC4EF3DD0EF5D14D6 /* GoogleTasksAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleTasksAPI.swift; sourceTree = ""; }; 4E2E315C1C8E04FD083F0AEA /* TaskMenu.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TaskMenu.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -96,7 +99,6 @@ 68CD93FC69779BFB08340F08 /* TaskMenu.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TaskMenu.entitlements; sourceTree = ""; }; 6E4CD6808B2587AD2F8F9563 /* TaskItemModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItemModelTests.swift; sourceTree = ""; }; 77DBAAB7CC102C6006686981 /* MetricKitPayloadStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricKitPayloadStoreTests.swift; sourceTree = ""; }; - 7B9B96C944BC0E6F55E3D791 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; 7DF81FBE51E0B1CC80B1A5FD /* SettingsLaunchAtLoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLaunchAtLoginTests.swift; sourceTree = ""; }; 85062E7D51749F3ED4FD688D /* AppKitTaskUIHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKitTaskUIHelpers.swift; sourceTree = ""; }; 8C8C52C19BDA6F8F2D3C638F /* AppIcon.svg */ = {isa = PBXFileReference; path = AppIcon.svg; sourceTree = ""; }; @@ -124,15 +126,6 @@ /* End PBXFileReference section */ /* Begin PBXGroup section */ - 02C7954B6F0AE7D4B33301DC /* TaskMenu */ = { - isa = PBXGroup; - children = ( - 7B9B96C944BC0E6F55E3D791 /* Config.xcconfig */, - ); - name = TaskMenu; - path = .; - sourceTree = ""; - }; 141A484087CC4923B5F2371C /* Resources */ = { isa = PBXGroup; children = ( @@ -236,6 +229,7 @@ BB598B8D34C0A014D7826A63 /* MockURLProtocol.swift */, 4AC00155161191D4DBBDA015 /* SearchFilterTests.swift */, 7DF81FBE51E0B1CC80B1A5FD /* SettingsLaunchAtLoginTests.swift */, + 2C5CAE54DF55AAAB094159CE /* SettingsVersionRowTests.swift */, 62C9B2D2EE1B7668B9245DBE /* TaskDetailViewTests.swift */, 6E4CD6808B2587AD2F8F9563 /* TaskItemModelTests.swift */, A2191A69341B04C760925B55 /* TaskListViewTests.swift */, @@ -249,13 +243,22 @@ E7776CF5C61A2557700CB1FE = { isa = PBXGroup; children = ( + FBAF85847F4317CD71BEB8FB /* git-sha-in-settings */, A34BA795C75683448AFABACA /* TaskMenu */, - 02C7954B6F0AE7D4B33301DC /* TaskMenu */, C53DB1319E1111ADB350C841 /* TaskMenuTests */, 1E011DB940E268BD3223C95C /* Products */, ); sourceTree = ""; }; + FBAF85847F4317CD71BEB8FB /* git-sha-in-settings */ = { + isa = PBXGroup; + children = ( + 4332DD537F3F61B0B7FAD4B1 /* BuildConfig.xcconfig */, + ); + name = "git-sha-in-settings"; + path = .; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -304,11 +307,7 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 2640; TargetAttributes = { - 2499498786EF41DCFC6BE7F6 = { - DevelopmentTeam = V82M9YX8BR; - }; 54F3465CB5E85F801680719A = { - DevelopmentTeam = V82M9YX8BR; ProvisioningStyle = Automatic; }; }; @@ -397,6 +396,7 @@ D321CE95DA3DC0D6090CD619 /* MockURLProtocol.swift in Sources */, 8F3D1A4FC0600A0D85BA22E8 /* SearchFilterTests.swift in Sources */, C3C41DC18427B14CEA020FD6 /* SettingsLaunchAtLoginTests.swift in Sources */, + 199755B8E809EBE89ADC94B0 /* SettingsVersionRowTests.swift in Sources */, F583510B7875405F768BADD3 /* TaskDetailViewTests.swift in Sources */, 0FD718B29CF8183561A6F550 /* TaskItemModelTests.swift in Sources */, B1E9E90C4184BDD3DED97A2B /* TaskListViewTests.swift in Sources */, @@ -440,7 +440,7 @@ }; 6EF47391B5EF770AC931DC3F /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7B9B96C944BC0E6F55E3D791 /* Config.xcconfig */; + baseConfigurationReference = 4332DD537F3F61B0B7FAD4B1 /* BuildConfig.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -528,7 +528,7 @@ }; A734F39AA9B691088E02AEFF /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7B9B96C944BC0E6F55E3D791 /* Config.xcconfig */; + baseConfigurationReference = 4332DD537F3F61B0B7FAD4B1 /* BuildConfig.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; diff --git a/TaskMenu.xcodeproj/xcshareddata/xcschemes/TaskMenu.xcscheme b/TaskMenu.xcodeproj/xcshareddata/xcschemes/TaskMenu.xcscheme new file mode 100644 index 0000000..01042a5 --- /dev/null +++ b/TaskMenu.xcodeproj/xcshareddata/xcschemes/TaskMenu.xcscheme @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TaskMenu/Models/AppState.swift b/TaskMenu/Models/AppState.swift index 2b3b711..89d0f00 100644 --- a/TaskMenu/Models/AppState.swift +++ b/TaskMenu/Models/AppState.swift @@ -129,6 +129,16 @@ final class AppState { var isCheckingForUpdates = false var updateCheckErrorMessage: String? let currentAppVersion: String + /// Short git commit the running build was stamped with, or nil for builds + /// made outside a git checkout. + let currentBuildCommit: String? + + /// Version plus build commit, e.g. `1.3.0 (a1b2c3d)`. Unstamped builds + /// show `dev` in place of the commit. Update checks compare + /// `currentAppVersion`, not this string. + var currentAppVersionDisplay: String { + "\(currentAppVersion) (\(currentBuildCommit ?? "dev"))" + } var selectedList: TaskList? { taskLists.first { $0.id == selectedListId } @@ -234,7 +244,8 @@ final class AppState { userDefaults: UserDefaults = .standard, dueDateNotificationService: any DueDateNotificationServicing = DueDateNotificationService(), updateChecker: any UpdateChecking = GitHubUpdateChecker(), - currentAppVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + currentAppVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0", + currentBuildCommit: String? = Bundle.main.infoDictionary?["GITCommitHash"] as? String ) { self.authService = authService self.api = api ?? GoogleTasksAPI(authService: authService) @@ -242,6 +253,8 @@ final class AppState { self.dueDateNotificationService = dueDateNotificationService self.updateChecker = updateChecker self.currentAppVersion = currentAppVersion + let trimmedBuildCommit = currentBuildCommit?.trimmingCharacters(in: .whitespacesAndNewlines) + self.currentBuildCommit = (trimmedBuildCommit?.isEmpty ?? true) ? nil : trimmedBuildCommit self.dueDateNotificationsEnabled = userDefaults.object( forKey: Constants.UserDefaults.dueDateNotificationsEnabledKey ) as? Bool ?? true diff --git a/TaskMenu/Models/README.md b/TaskMenu/Models/README.md index 29256d4..c5ed1d0 100644 --- a/TaskMenu/Models/README.md +++ b/TaskMenu/Models/README.md @@ -4,7 +4,7 @@ Models hold the app's main state container and Google Tasks data shapes. Keep th ## Files -- `AppState.swift` - `@MainActor @Observable` source of truth for auth state, signed-in Google account profile, task lists, selected list, visible tasks, caches, row disclosure, search, due-date notification preference, update-check state, and task mutations. +- `AppState.swift` - `@MainActor @Observable` source of truth for auth state, signed-in Google account profile, task lists, selected list, visible tasks, caches, row disclosure, search, due-date notification preference, update-check state, build version identity, and task mutations. - `TaskItem.swift` - Google Task model, completion helpers, parent/subtask fields, Google due-date conversion, and paged task-list response model. - `TaskList.swift` - Google Task List model and collection response model. @@ -15,6 +15,7 @@ Models hold the app's main state container and Google Tasks data shapes. Keep th - Preserve `toggleTask(_:)` optimistic-update rollback behavior. If an optimistic API call fails, restore the prior local task state and set `errorMessage`. - Keep the per-list cache in sync when adding, adding subtasks, completing, updating, deleting, or selecting lists. - Use `taskLoadRequestID` guards when introducing async task-loading work so stale responses cannot overwrite the active list. Fetches also capture `taskStateGeneration`, which every committed mutation bumps, so a fetch snapshot taken before a local change is discarded rather than applied. +- Keep update checks comparing `currentAppVersion` (marketing version only). `currentBuildCommit` and `currentAppVersionDisplay` are display-only; the latter renders `1.3.0 (a1b2c3d)`, or `(dev)` when the build carries no commit stamp. - Route post-await mutation writes through `commitTaskChange(to:_:)`: it re-checks the captured list against the current selection and writes to the live array or `taskCacheByListID` accordingly. Re-check `isSignedIn` after every await before touching state. ## Task Ordering And Search diff --git a/TaskMenu/Resources/Info.plist b/TaskMenu/Resources/Info.plist index 7e9819f..73953e0 100644 --- a/TaskMenu/Resources/Info.plist +++ b/TaskMenu/Resources/Info.plist @@ -37,6 +37,8 @@ public.app-category.productivity LSUIElement + GITCommitHash + $(GIT_COMMIT_HASH) GOOGLE_CLIENT_ID $(GOOGLE_CLIENT_ID) GOOGLE_REDIRECT_SCHEME diff --git a/TaskMenu/Resources/README.md b/TaskMenu/Resources/README.md index 8357edc..1945709 100644 --- a/TaskMenu/Resources/README.md +++ b/TaskMenu/Resources/README.md @@ -4,7 +4,7 @@ Resources define bundle metadata, security settings, and visual assets. Keep gen ## Files -- `Info.plist` - bundle metadata, OAuth URL scheme registration, Google config placeholders, and menu-bar-only launch flag. +- `Info.plist` - bundle metadata, OAuth URL scheme registration, Google config placeholders, the build's git commit stamp, and menu-bar-only launch flag. - `TaskMenu.entitlements` - app sandbox and outbound network entitlement. - `Assets.xcassets` - compiled asset catalog, including app icon, menu bar icon, and Discord support-link image membership. - `AppIcon.svg` - source artwork for the app icon. @@ -16,6 +16,7 @@ Resources define bundle metadata, security settings, and visual assets. Keep gen - Testing-window launches switch activation policy at runtime; keep `LSUIElement = true` in the bundle plist. - Keep `CFBundleURLTypes` aligned with `GOOGLE_REDIRECT_SCHEME` for OAuth callbacks. - Keep `GOOGLE_CLIENT_ID` and `GOOGLE_REDIRECT_SCHEME` as build-setting placeholders; local values belong in `Config.xcconfig`. +- `GITCommitHash` comes from `$(GIT_COMMIT_HASH)`, which `scripts/stamp_build_metadata.sh` writes into the untracked `BuildMetadata.xcconfig` (included from `BuildConfig.xcconfig`). It is empty when the build is not made from a git checkout, and `AppState` then shows `dev`. ## Entitlements diff --git a/TaskMenu/Views/README.md b/TaskMenu/Views/README.md index 5977f2c..ea80673 100644 --- a/TaskMenu/Views/README.md +++ b/TaskMenu/Views/README.md @@ -12,7 +12,7 @@ Views render the AppKit menu-bar popover and settings UI. Keep business behavior - `TaskDetailAppKitViewController.swift` - task edit screen, title/notes fields, due-date state, disabled list picker, delete action, and subtask add/toggle UI. - `TaskPresentation.swift` - pure task-list, notes preview, and completed-subtask ordering helper logic. - `TestingWindowController.swift` - opt-in testing-mode window that hosts the popover UI outside the status item. -- `SettingsView.swift` - AppKit settings window/controller with native grouped section boxes (switch rows, update status row, account row), notification preference, launch-at-login, update checks, signed-in account email display, tips/support/about links, account disconnect confirmation, and quit controls. +- `SettingsView.swift` - AppKit settings window/controller with native grouped section boxes (switch rows, update status row showing the version and build commit, account row), notification preference, launch-at-login, update checks, signed-in account email display, tips/support/about links, account disconnect confirmation, and quit controls. - `MenuBarWindowGlassSupport.swift` - macOS 26 Liquid Glass window-background support. ## UI Ownership diff --git a/TaskMenu/Views/SettingsView.swift b/TaskMenu/Views/SettingsView.swift index 902e6fc..f0f1aae 100644 --- a/TaskMenu/Views/SettingsView.swift +++ b/TaskMenu/Views/SettingsView.swift @@ -101,7 +101,7 @@ private final class SettingsViewController: NSViewController { _ = appState.updateCheckErrorMessage _ = appState.latestAvailableUpdate _ = appState.lastUpdateCheckDate - _ = appState.currentAppVersion + _ = appState.currentAppVersionDisplay } onChange: { [weak self] in self?.render() } @@ -231,7 +231,7 @@ private final class SettingsViewController: NSViewController { var rows = [ settingRow( - title: "Version \(appState.currentAppVersion)", + title: "Version \(appState.currentAppVersionDisplay)", subtitle: updateStatusText, subtitleColor: appState.updateCheckErrorMessage == nil ? .secondaryLabelColor : .systemRed, control: checkButton diff --git a/TaskMenuTests/AppStateTests.swift b/TaskMenuTests/AppStateTests.swift index ca71bd5..07eae51 100644 --- a/TaskMenuTests/AppStateTests.swift +++ b/TaskMenuTests/AppStateTests.swift @@ -163,6 +163,49 @@ final class AppStateTests: XCTestCase { XCTAssertEqual(removeAllCallCount, 1) } + // MARK: - Version Display + + func testVersionDisplayIncludesBuildCommit() { + let state = AppState( + authService: GoogleAuthService(keychain: keychain), + userDefaults: userDefaults, + dueDateNotificationService: dueDateNotificationService, + currentAppVersion: "1.3.0", + currentBuildCommit: "a1b2c3d" + ) + + XCTAssertEqual(state.currentBuildCommit, "a1b2c3d") + XCTAssertEqual(state.currentAppVersionDisplay, "1.3.0 (a1b2c3d)") + XCTAssertEqual(state.currentAppVersion, "1.3.0") + } + + func testVersionDisplayTrimsWhitespaceFromBuildCommit() { + let state = AppState( + authService: GoogleAuthService(keychain: keychain), + userDefaults: userDefaults, + dueDateNotificationService: dueDateNotificationService, + currentAppVersion: "1.3.0", + currentBuildCommit: " a1b2c3d\n" + ) + + XCTAssertEqual(state.currentAppVersionDisplay, "1.3.0 (a1b2c3d)") + } + + func testVersionDisplayFallsBackToDevWhenBuildCommitMissing() { + for commit in [nil, "", " "] as [String?] { + let state = AppState( + authService: GoogleAuthService(keychain: keychain), + userDefaults: userDefaults, + dueDateNotificationService: dueDateNotificationService, + currentAppVersion: "1.3.0", + currentBuildCommit: commit + ) + + XCTAssertNil(state.currentBuildCommit) + XCTAssertEqual(state.currentAppVersionDisplay, "1.3.0 (dev)") + } + } + // MARK: - selectedList func testSelectedListReturnsNilWhenNoListSelected() { diff --git a/TaskMenuTests/README.md b/TaskMenuTests/README.md index 42f1c73..3c8aab8 100644 --- a/TaskMenuTests/README.md +++ b/TaskMenuTests/README.md @@ -4,7 +4,7 @@ Unit tests cover model behavior, app state, services, pure view helpers, and app ## Test Map -- `AppStateTests.swift` - initial state, preferences, selected list helpers, ordering, sign-out/disconnect reset, and basic guarded actions. +- `AppStateTests.swift` - initial state, preferences, version/build-commit display, selected list helpers, ordering, sign-out/disconnect reset, and basic guarded actions. - `AppStateBehaviorTests.swift` - task mutations, caching, stale response protection, selection changes, refresh behavior, errors, and notification sync. - `SearchFilterTests.swift` - title/notes search, parent context inclusion, and root/subtask filtered accessors. - `TaskItemModelTests.swift` and `GoogleTasksAPITests.swift` - model Codable round trips, completion helpers, parent/subtask fields, and due-date accessors. @@ -19,6 +19,7 @@ Unit tests cover model behavior, app state, services, pure view helpers, and app - `MetricKitPayloadStoreTests.swift` - local payload persistence. - `TaskMenuAppTests.swift` - app/app-delegate construction seams, launch UI mode parsing, the automatic update-check loop, and update-alert choice mapping. - `SettingsLaunchAtLoginTests.swift` - the pure launch-at-login status decision (`requiresApproval` notice) behind the Settings toggle. +- `SettingsVersionRowTests.swift` - the rendered Settings version row, covering the `Version ()` text and the `dev` fallback for unstamped builds. ## Test Doubles diff --git a/TaskMenuTests/SettingsVersionRowTests.swift b/TaskMenuTests/SettingsVersionRowTests.swift new file mode 100644 index 0000000..ca27db6 --- /dev/null +++ b/TaskMenuTests/SettingsVersionRowTests.swift @@ -0,0 +1,56 @@ +import AppKit +import XCTest +@testable import TaskMenu + +@MainActor +final class SettingsVersionRowTests: XCTestCase { + func testUpdatesSectionShowsVersionWithBuildCommit() { + let title = renderedVersionRowTitle(version: "1.3.0", buildCommit: "a1b2c3d") + + XCTAssertEqual(title, "Version 1.3.0 (a1b2c3d)") + } + + func testUpdatesSectionShowsDevWhenBuildIsNotStamped() { + let title = renderedVersionRowTitle(version: "1.3.0", buildCommit: nil) + + XCTAssertEqual(title, "Version 1.3.0 (dev)") + } + + private func renderedVersionRowTitle(version: String, buildCommit: String?) -> String? { + let suiteName = "dev.crazytan.TaskMenu.tests.settingsversion.\(UUID().uuidString)" + guard let userDefaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Could not create test defaults") + return nil + } + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let appState = AppState( + authService: GoogleAuthService(keychain: InMemoryKeychainService()), + userDefaults: userDefaults, + dueDateNotificationService: TestDueDateNotificationService(), + currentAppVersion: version, + currentBuildCommit: buildCommit + ) + + let controller = SettingsWindowController(appState: appState) + defer { controller.window?.close() } + guard let contentViewController = controller.window?.contentViewController else { + XCTFail("Settings window has no content view controller") + return nil + } + contentViewController.loadViewIfNeeded() + + return labelStrings(in: contentViewController.view).first { $0.hasPrefix("Version ") } + } + + private func labelStrings(in view: NSView) -> [String] { + var strings: [String] = [] + if let textField = view as? NSTextField { + strings.append(textField.stringValue) + } + for subview in view.subviews { + strings.append(contentsOf: labelStrings(in: subview)) + } + return strings + } +} diff --git a/project.yml b/project.yml index cea960c..9aa1c23 100644 --- a/project.yml +++ b/project.yml @@ -7,8 +7,8 @@ options: generateEmptyDirectories: true configFiles: - Debug: Config.xcconfig - Release: Config.xcconfig + Debug: BuildConfig.xcconfig + Release: BuildConfig.xcconfig settings: base: @@ -63,3 +63,26 @@ targets: GENERATE_INFOPLIST_FILE: YES BUNDLE_LOADER: "$(TEST_HOST)" TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TaskMenu.app/Contents/MacOS/TaskMenu" + +schemes: + TaskMenu: + build: + preActions: + - script: | + "${SRCROOT}/scripts/stamp_build_metadata.sh" "${SRCROOT}" + name: Stamp build metadata + settingsTarget: TaskMenu + targets: + TaskMenu: all + run: + config: Debug + test: + config: Debug + targets: + - name: TaskMenuTests + profile: + config: Release + analyze: + config: Debug + archive: + config: Release diff --git a/scripts/stamp_build_metadata.sh b/scripts/stamp_build_metadata.sh new file mode 100755 index 0000000..6ed8787 --- /dev/null +++ b/scripts/stamp_build_metadata.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Stamps BuildMetadata.xcconfig with the short git commit of the checkout being +# built so Settings can show it next to the version number. Runs as the +# TaskMenu scheme's build pre-action; CI also runs it explicitly before +# archiving. Never fails the build: a missing git checkout leaves the hash +# empty and the app falls back to "dev". +set -euo pipefail + +repo_root="${1:-$(pwd)}" +metadata_path="${repo_root}/BuildMetadata.xcconfig" + +commit_hash="$(/usr/bin/git -C "${repo_root}" rev-parse --short HEAD 2>/dev/null || true)" + +{ + printf '// Generated by scripts/stamp_build_metadata.sh. Not tracked in git.\n' + printf 'GIT_COMMIT_HASH = %s\n' "${commit_hash}" +} > "${metadata_path}"