From ae121c8b21fca527fc234087c50b3171fb08bd87 Mon Sep 17 00:00:00 2001 From: AlexanderSmyslowski Date: Wed, 29 Jul 2026 20:43:38 +0200 Subject: [PATCH] Make backup failures visible and retry transient reads --- CHANGELOG.md | 6 +++++ Makefile | 3 ++- README.md | 12 ++++----- docs/version-history.md | 3 ++- install.sh | 4 ++- .../GDriveBackupTiger.entitlements | 8 ++++++ macos/GDriveBackupTiger/Info.plist | 4 +-- macos/GDriveBackupTiger/NotificationSupport.m | 20 +++++++++++---- macos/GDriveBackupTiger/main.m | 5 ++++ packaging/build-pkg.sh | 1 + packaging/verify-pkg.sh | 10 ++++++++ tests/app-build-artifacts-test.sh | 20 ++++++++++++++- tests/automatic-retry-support-test.m | 25 +++++++++++++++++++ tests/notification-integration-test.m | 10 ++++++-- tests/notification-support-test.m | 11 ++++++++ tests/release-metadata-test.sh | 3 +++ tests/release-workflow-test.sh | 3 +++ 17 files changed, 129 insertions(+), 19 deletions(-) create mode 100644 macos/GDriveBackupTiger/GDriveBackupTiger.entitlements diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae9ffb..936fcd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +## v2.4.1 - 2026-07-29 + +- Mark automatic-backup failure alerts as time-sensitive and preserve the required signing entitlement across source installs, local builds, and release packages, so Focus may present the warning without the app taking focus or opening a window. +- Treat the fail-closed `destination_unreadable` NAS codec preflight as eligible for the same single delayed automatic retry as transient mount-readiness failures, while continuing to exclude permissions, damaged manifests, name collisions, unsupported tooling, and unclassified exit codes. +- Tell the user when this transient NAS read failure will be retried and verify the notification entitlement in both isolated app builds and packaged release artifacts. + ## v2.4.0 - 2026-07-29 - Offer a previously unknown directly attached physical disk once per attachment through a passive macOS notification, without opening a window, taking focus, writing, formatting, changing settings, or starting a backup; setup begins only after an explicit, revalidated action and preserves the active NAS target and schedule until Save. diff --git a/Makefile b/Makefile index bcee3b6..5e3ff5f 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ MACOS_DEPLOYMENT_TARGET ?= 13.0 APP_ARCH_FLAGS ?= -arch arm64 -arch x86_64 APP_OBJC_FLAGS := $(OBJC_FLAGS) -mmacosx-version-min=$(MACOS_DEPLOYMENT_TARGET) $(APP_ARCH_FLAGS) USER_NOTIFICATIONS_FRAMEWORK := -framework UserNotifications +APP_ENTITLEMENTS := macos/GDriveBackupTiger/GDriveBackupTiger.entitlements .PHONY: build install dry-run pkg test clean @@ -39,7 +40,7 @@ build: test -s "$(APP_DIR)/Contents/Resources/Assets.car"; \ ./scripts/trash-path.sh "$$ICON_WORK" xattr -cr "$(APP_DIR)" - codesign --force --deep --sign - "$(APP_DIR)" + codesign --force --deep --entitlements "$(APP_ENTITLEMENTS)" --sign - "$(APP_DIR)" install: ./install.sh diff --git a/README.md b/README.md index 2dabfc0..99a8309 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ macOS launchd backup setup for Google Drive, powered by `rclone`, with a tiny Mac OS X Tiger-inspired status window. “Tiger” describes the visual style; the app requires macOS 13 Ventura or later and does not run on Mac OS X 10.4 Tiger. -Current release: `v2.4.0` with persistent automatic-failure alerts and a safe retry, passive handling of unknown external disks, verified APFS and NAS identity, one coherent Dock presence, optional end-to-end `rclone crypt` backups, retained versions, verified recovery, named profiles, diagnostics, and a persistent menu bar overview. +Current release: `v2.4.1` with time-sensitive persistent automatic-failure alerts and a safe retry for transient NAS read failures, passive handling of unknown external disks, verified APFS and NAS identity, one coherent Dock presence, optional end-to-end `rclone crypt` backups, retained versions, verified recovery, named profiles, diagnostics, and a persistent menu bar overview. It backs up: @@ -26,7 +26,7 @@ the backup does not preserve Google Drive's native document revision history. - The controller observes macOS mount events. When an APFS volume UUID is saved, a changed `/Volumes/… 2` suffix is resolved automatically and a merely same-name disk cannot trigger a backup. Older path-only profiles remain available for manual and scheduled use, but a mount event is treated as unknown until a human explicitly binds the disk's UUID. - A previously unknown directly attached physical disk produces at most one passive notification per attachment. Mounting it never opens a window, takes focus, formats or writes to the disk, or starts a backup. **Set up as backup destination** revalidates the same disk and only stages it in setup; **Save** registers the disk while leaving the currently selected primary target and schedule as shown, so a NAS target is never replaced silently. **Ignore** makes no change. A dismissal remains remembered if the controller restarts during the same attachment, while fully unplugging the disk clears the notice and makes a later attachment eligible again. UUIDs retained by any named profile suppress the unknown-disk notice for that whole physical disk. - The overview shows the last verified run, configured schedule, exact local destination, and available destination capacity. -- With notifications enabled, macOS reports a failed automatic run immediately and a daily 20:00 run that is still missing at 21:00. A transient NAS mount/readiness failure gets exactly one controller-managed retry after 30 minutes; after sleep it remains eligible until the next wake within 24 hours, and a failed retry creates a separate alert. The controller restarts after a crash, alerts are deduplicated per profile and run, and only a newer successful automatic backup removes still-delivered failure alerts for that profile. +- With notifications enabled, macOS reports a failed automatic run immediately and a daily 20:00 run that is still missing at 21:00. A transient NAS mount/readiness or fail-closed destination-read failure gets exactly one controller-managed retry after 30 minutes; after sleep it remains eligible until the next wake within 24 hours, and a failed retry creates a separate alert. The controller restarts after a crash, alerts are deduplicated per profile and run, and only a newer successful automatic backup removes still-delivered failure alerts for that profile. - Scheduled, mount-triggered, and menu-bar-only runs stay headless. Their live and final state remains available through the menu bar, with a macOS notification for automatic failures. - Named profiles keep distinct destinations, schedules, encryption policies, and last-run histories while making the one active profile explicit in setup, the overview, and the menu bar. - On first use, if the backup volume does not exist yet, the helper can ask to create a dedicated APFS volume on the newly attached external APFS disk. @@ -87,7 +87,7 @@ rclone lsd gdrive: For most users, download the latest installer from the GitHub releases page: 1. Open -2. Download `GDrive-Backup-Tiger-2.4.0.pkg` from `Assets`. +2. Download `GDrive-Backup-Tiger-2.4.1.pkg` from `Assets`. 3. Double-click the package and follow the macOS Installer. 4. Open `/Applications/GDrive Backup Tiger.app` to choose language, external disk, NAS, and schedule settings. @@ -104,13 +104,13 @@ The package is currently unsigned because the project does not yet have an Apple 1. Click `Done`, not `Move to Trash`. 2. Open `System Settings > Privacy & Security`. -3. Scroll to `Security` and click `Open Anyway` for `GDrive-Backup-Tiger-2.4.0.pkg`. +3. Scroll to `Security` and click `Open Anyway` for `GDrive-Backup-Tiger-2.4.1.pkg`. 4. Confirm with `Open Anyway`, then install the package. Advanced users can also remove the download quarantine flag before opening: ```bash -xattr -d com.apple.quarantine "$HOME/Downloads/GDrive-Backup-Tiger-2.4.0.pkg" +xattr -d com.apple.quarantine "$HOME/Downloads/GDrive-Backup-Tiger-2.4.1.pkg" ``` ### Install from source @@ -240,7 +240,7 @@ Legacy profiles without the key retain exact-path behavior. Merely opening setup Saved schedules run unattended after the script verifies the configured destination. `GDRIVE_BACKUP_CONFIRM=1` still protects mount-triggered runs with a prompt. Set it to `0` only if you also deliberately want those mount-triggered backups to start unattended whenever the configured volume is mounted. Set `GDRIVE_BACKUP_PAUSED=1` to silence schedule and mount-triggered runs without changing the saved schedule. The menu bar toggles this setting; **Backup now** always remains manual and available. Set `GDRIVE_BACKUP_NOTIFY_FAILURES=0` to disable macOS alerts for automatic failures and missed daily runs. The menu bar and overview continue to show backup status even when alerts are disabled or macOS notification permission is denied. -To keep a failure visible until a person dismisses it or a later automatic backup succeeds, set the macOS notification style for **GDrive Backup Tiger** to **Persistent** (`System Settings` → `Notifications` → `GDrive Backup Tiger`). macOS controls this presentation setting; the app never opens a modal window or takes foreground focus for an automatic failure. A manual success deliberately leaves the alert in place. +To keep a failure visible until a person dismisses it or a later automatic backup succeeds, set the macOS notification style for **GDrive Backup Tiger** to **Persistent** (`System Settings` → `Notifications` → `GDrive Backup Tiger`). Leave **Time Sensitive Notifications** enabled there so macOS may present backup failures during Focus. macOS controls both presentation settings; the app never opens a modal window or takes foreground focus for an automatic failure. A manual success deliberately leaves the alert in place. For NAS targets, `GDRIVE_BACKUP_NAS_MOUNT_TIMEOUT_SECONDS` bounds the macOS mount request (default `90`, range `1`–`300`) and `GDRIVE_BACKUP_NAS_READY_TIMEOUT_SECONDS` bounds the subsequent verified-writable readiness wait (default `60`, range `0`–`300`). Set `GDRIVE_BACKUP_AUTO_CREATE_VOLUME=0` if you want to create the backup volume yourself. Set `GDRIVE_BACKUP_NAS_START_ON_MOUNT=1` only if mount events should also start the configured NAS backup; the default `0` reserves mount-triggered runs for the external APFS target. diff --git a/docs/version-history.md b/docs/version-history.md index 3665dc6..2939635 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -31,7 +31,8 @@ records are restored transparently as historical source milestones. | v2.3.0 | 21 | `0714564` | Historical source milestone published retrospectively | | v2.3.1 | 22 | `c3c94b2` | Historical source milestone published retrospectively | | v2.3.2 | 23 | `7ecda45` | Historical source milestone published retrospectively | -| v2.4.0 | 24 | tag `v2.4.0` | Current tested release | +| v2.4.0 | 24 | tag `v2.4.0` | Published tested release | +| v2.4.1 | 25 | tag `v2.4.1` | Current tested release | No historical binary installer is reconstructed and presented as an original artifact. Retrospective release pages expose GitHub's source archives and state diff --git a/install.sh b/install.sh index 096b032..7d3d363 100755 --- a/install.sh +++ b/install.sh @@ -396,7 +396,9 @@ test -s "$APP_CONTENTS/Resources/Assets.car" "$ROOT/scripts/trash-path.sh" "$ICON_WORK" /usr/bin/xattr -cr "$APP_DIR" -codesign --force --deep --sign - "$APP_DIR" >/dev/null +codesign --force --deep \ + --entitlements "$ROOT/macos/GDriveBackupTiger/GDriveBackupTiger.entitlements" \ + --sign - "$APP_DIR" >/dev/null sudo install -m 755 "$ROOT/bin/backup-google-drive.sh" /usr/local/bin/backup-google-drive.sh install -m 644 "$AGENT_SRC" "$AGENT_DST" diff --git a/macos/GDriveBackupTiger/GDriveBackupTiger.entitlements b/macos/GDriveBackupTiger/GDriveBackupTiger.entitlements new file mode 100644 index 0000000..8463a43 --- /dev/null +++ b/macos/GDriveBackupTiger/GDriveBackupTiger.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.usernotifications.time-sensitive + + + diff --git a/macos/GDriveBackupTiger/Info.plist b/macos/GDriveBackupTiger/Info.plist index ffad139..778e805 100644 --- a/macos/GDriveBackupTiger/Info.plist +++ b/macos/GDriveBackupTiger/Info.plist @@ -30,9 +30,9 @@ CFBundlePackageType APPL CFBundleVersion - 24 + 25 CFBundleShortVersionString - 2.4.0 + 2.4.1 LSMinimumSystemVersion 13.0 NSPrincipalClass diff --git a/macos/GDriveBackupTiger/NotificationSupport.m b/macos/GDriveBackupTiger/NotificationSupport.m index 0139e69..3e6a4ba 100644 --- a/macos/GDriveBackupTiger/NotificationSupport.m +++ b/macos/GDriveBackupTiger/NotificationSupport.m @@ -25,6 +25,17 @@ static NSTimeInterval GDTTimestamp(NSString *value) { return GDTSafeNotificationProfileID(value); } +static BOOL GDTIsRetryableNASReason(NSString *reason) { + return [@[ + @"nas_mount_unavailable", + @"nas_mount_not_ready", + // The shell emits this only when its read-only NAS codec preflight + // cannot safely inspect the destination. A later retry repeats every + // fail-closed check before any copy can start. + @"destination_unreadable" + ] containsObject:reason ?: @""]; +} + static NSDate *GDTWatchdogDateForNow(NSDate *now, NSCalendar *calendar) { NSDate *watchdog = [calendar dateBySettingHour:21 minute:0 second:0 ofDate:now options:0]; if ([watchdog compare:now] == NSOrderedDescending) { @@ -92,8 +103,7 @@ @implementation GDTBackupNotificationPolicy NSString *reason = summary[@"reason"] ?: @""; if (retryFailure) { bodyKey = @"backupNotificationRetryFailureBody"; - } else if ([@[@"nas_mount_unavailable", @"nas_mount_not_ready"] - containsObject:reason]) { + } else if (GDTIsRetryableNASReason(reason)) { bodyKey = @"backupNotificationNASRetryBody"; } else if ([reason isEqualToString:@"destination_permission_denied"]) { bodyKey = @"failedPermissionHint"; @@ -164,10 +174,10 @@ @implementation GDTAutomaticRetryPolicy return nil; } - // Exit 69 also covers permanent safety failures. Only mount-readiness - // outcomes are transient enough to repeat without user intervention. + // Exit 69 also covers permanent safety failures, so retry only the + // explicitly fail-closed NAS outcomes above. NSString *reason = summary[@"reason"] ?: @""; - if (![@[@"nas_mount_unavailable", @"nas_mount_not_ready"] containsObject:reason]) { + if (!GDTIsRetryableNASReason(reason)) { return nil; } diff --git a/macos/GDriveBackupTiger/main.m b/macos/GDriveBackupTiger/main.m index cfc08b9..e556f39 100644 --- a/macos/GDriveBackupTiger/main.m +++ b/macos/GDriveBackupTiger/main.m @@ -1799,6 +1799,11 @@ - (UNMutableNotificationContent *)backupNotificationContentForDecision: content.body = T(self.language ?: @"en", decision[@"bodyKey"]); content.sound = UNNotificationSound.defaultSound; content.categoryIdentifier = @"GDT_BACKUP_ALERT"; + if (@available(macOS 12.0, *)) { + // Backup failures must be noticeable during Focus without activating + // the app or stealing a full-screen workspace. + content.interruptionLevel = UNNotificationInterruptionLevelTimeSensitive; + } return content; } diff --git a/packaging/build-pkg.sh b/packaging/build-pkg.sh index 38f80ae..cde4200 100755 --- a/packaging/build-pkg.sh +++ b/packaging/build-pkg.sh @@ -52,6 +52,7 @@ if [[ -n "$APP_SIGN_IDENTITY" ]]; then --force \ --options runtime \ --timestamp \ + --entitlements "$ROOT/macos/GDriveBackupTiger/GDriveBackupTiger.entitlements" \ --sign "$APP_SIGN_IDENTITY" \ "$APP_PATH" >&2 fi diff --git a/packaging/verify-pkg.sh b/packaging/verify-pkg.sh index 4be830c..e7a102b 100755 --- a/packaging/verify-pkg.sh +++ b/packaging/verify-pkg.sh @@ -128,6 +128,16 @@ if ! /usr/bin/grep -Fq "/bin/chmod 600 \"\$config_file\"" "$POSTINSTALL"; then exit 1 fi /usr/bin/codesign --verify --deep --strict "$APP_PATH" +APP_ENTITLEMENTS="$VERIFY_ROOT/app-entitlements.plist" +if ! /usr/bin/codesign --display --entitlements :- "$APP_PATH" \ + >"$APP_ENTITLEMENTS" 2>/dev/null || + [[ "$(/usr/libexec/PlistBuddy \ + -c 'Print :com.apple.developer.usernotifications.time-sensitive' \ + "$APP_ENTITLEMENTS" 2>/dev/null || true)" != "true" ]]; then + printf '%s\n' \ + 'Packaged app is missing the time-sensitive notification entitlement.' >&2 + exit 1 +fi if [[ "$SIGNATURE_EXPECTATION" == "signed" ]]; then app_signature="$(/usr/bin/codesign --display --verbose=4 "$APP_PATH" 2>&1)" diff --git a/tests/app-build-artifacts-test.sh b/tests/app-build-artifacts-test.sh index 7c3130a..1fdc155 100644 --- a/tests/app-build-artifacts-test.sh +++ b/tests/app-build-artifacts-test.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" STAGE="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/gdrive-app-build-test.XXXXXX")" APP="$STAGE/GDrive Backup Tiger.app" +ENTITLEMENTS="$STAGE/app-entitlements.plist" cleanup() { if [[ -e "$STAGE" ]]; then @@ -31,4 +32,21 @@ done /usr/bin/file "$APP/Contents/MacOS/GDriveBackupTiger" \ | /usr/bin/grep -Fq 'Mach-O universal binary with 2 architectures' -printf '%s\n' 'ok - isolated app build produces signed universal binary and icon assets' +if ! /usr/bin/codesign --display --entitlements :- "$APP" >"$ENTITLEMENTS" 2>/dev/null || + [[ "$(/usr/libexec/PlistBuddy \ + -c 'Print :com.apple.developer.usernotifications.time-sensitive' \ + "$ENTITLEMENTS" 2>/dev/null || true)" != "true" ]]; then + printf '%s\n' 'not ok - isolated app build preserves the time-sensitive notification entitlement' + exit 1 +fi + +for signing_entrypoint in Makefile install.sh packaging/build-pkg.sh; do + if ! /usr/bin/grep -Fq 'GDriveBackupTiger.entitlements' "$ROOT/$signing_entrypoint"; then + printf 'not ok - %s preserves notification entitlements while signing\n' \ + "$signing_entrypoint" + exit 1 + fi +done + +printf '%s\n' \ + 'ok - isolated app build produces signed universal binary, icon assets, and notification entitlement' diff --git a/tests/automatic-retry-support-test.m b/tests/automatic-retry-support-test.m index 44c2f24..75a0728 100644 --- a/tests/automatic-retry-support-test.m +++ b/tests/automatic-retry-support-test.m @@ -70,6 +70,17 @@ int main(void) { [NSDate dateWithTimeIntervalSince1970:2840]) != nil, @"an unavailable configured NAS mount is retryable"); + NSMutableDictionary *destinationUnreadable = + [mountNotReady mutableCopy]; + destinationUnreadable[@"reason"] = @"destination_unreadable"; + NSDictionary *unreadableRetry = Decision( + policyClass, dailyNAS, destinationUnreadable, @"failure", + [NSDate dateWithTimeIntervalSince1970:2840]); + Assert([unreadableRetry[@"identifier"] + isEqualToString:@"office.automatic-retry.1000"] && + [unreadableRetry[@"trigger"] isEqualToString:@"schedule-retry"], + @"a fail-closed transient NAS read failure is retried once"); + NSMutableDictionary *permissionFailure = [mountNotReady mutableCopy]; permissionFailure[@"reason"] = @"destination_permission_denied"; @@ -77,6 +88,20 @@ int main(void) { [NSDate dateWithTimeIntervalSince1970:2840]) == nil, @"a permanent destination permission failure is not retried"); + for (NSString *reason in @[ + @"invalid_name_codec", + @"name_codec_collision", + @"unsupported_rclone" + ]) { + NSMutableDictionary *unsafeFailure = + [mountNotReady mutableCopy]; + unsafeFailure[@"reason"] = reason; + Assert(Decision(policyClass, dailyNAS, unsafeFailure, @"failure", + [NSDate dateWithTimeIntervalSince1970:2840]) == nil, + [NSString stringWithFormat: + @"the permanent safety failure %@ is never retried", reason]); + } + NSMutableDictionary *unclassified = [mountNotReady mutableCopy]; [unclassified removeObjectForKey:@"reason"]; diff --git a/tests/notification-integration-test.m b/tests/notification-integration-test.m index cb4aa24..cf2b7fb 100644 --- a/tests/notification-integration-test.m +++ b/tests/notification-integration-test.m @@ -433,9 +433,15 @@ int main(void) { ContentMethod contentMethod = (ContentMethod)[delegate methodForSelector:contentSelector]; content = contentMethod(delegate, contentSelector, first); } + BOOL timeSensitiveLevel = NO; + if (@available(macOS 12.0, *)) { + timeSensitiveLevel = + content.interruptionLevel == UNNotificationInterruptionLevelTimeSensitive; + } Assert(content.sound != nil && - [content.categoryIdentifier isEqualToString:@"GDT_BACKUP_ALERT"], - @"automatic backup alerts remain audible without opening a window"); + [content.categoryIdentifier isEqualToString:@"GDT_BACKUP_ALERT"] && + timeSensitiveLevel, + @"automatic backup alerts are time-sensitive and audible without opening a window"); NSSet *categories = nil; SEL categoriesSelector = NSSelectorFromString(@"appNotificationCategories"); diff --git a/tests/notification-support-test.m b/tests/notification-support-test.m index e5b2983..3ef8d32 100644 --- a/tests/notification-support-test.m +++ b/tests/notification-support-test.m @@ -93,6 +93,17 @@ int main(void) { [retryPlanned[@"bodyKey"] isEqualToString:@"backupNotificationNASRetryBody"], @"a transient NAS readiness failure announces the later automatic retry"); + NSMutableDictionary *destinationUnreadable = + [failedSummary mutableCopy]; + destinationUnreadable[@"reason"] = @"destination_unreadable"; + NSDictionary *readRetryPlanned = Decision( + policyClass, daily, destinationUnreadable, @"failure", + Date(calendar, 21, 20, 26), calendar); + Assert([readRetryPlanned[@"kind"] isEqualToString:@"failure"] && + [readRetryPlanned[@"bodyKey"] + isEqualToString:@"backupNotificationNASRetryBody"], + @"a transient NAS read failure announces the later automatic retry"); + NSMutableDictionary *retryFailed = [nasNotReady mutableCopy]; retryFailed[@"trigger"] = @"schedule-retry"; retryFailed[@"started_at"] = [NSString stringWithFormat:@"%.0f", diff --git a/tests/release-metadata-test.sh b/tests/release-metadata-test.sh index 4d6b0d4..73d3262 100644 --- a/tests/release-metadata-test.sh +++ b/tests/release-metadata-test.sh @@ -18,12 +18,15 @@ check_contains() { } version="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$INFO_PLIST")" +build="$(/usr/bin/plutil -extract CFBundleVersion raw -o - "$INFO_PLIST")" minimum_macos="$(/usr/bin/plutil -extract LSMinimumSystemVersion raw -o - "$INFO_PLIST")" check_contains "$ROOT/README.md" "Current release: \`v${version}\`" \ "README release matches the app version" check_contains "$ROOT/CHANGELOG.md" "## v${version} " \ "changelog contains the app version" +check_contains "$ROOT/docs/version-history.md" "| v${version} | ${build} |" \ + "publication history contains the app version and build" check_contains "$ROOT/README.md" "macOS ${minimum_macos%%.*}" \ "README states the minimum macOS generation" check_contains "$ROOT/install.sh" "GDRIVE_BACKUP_RETENTION=1" \ diff --git a/tests/release-workflow-test.sh b/tests/release-workflow-test.sh index 82b387d..9055b22 100755 --- a/tests/release-workflow-test.sh +++ b/tests/release-workflow-test.sh @@ -6,6 +6,7 @@ INFO_PLIST="$ROOT/macos/GDriveBackupTiger/Info.plist" VALIDATOR="$ROOT/scripts/validate-release.sh" NOTES_EXTRACTOR="$ROOT/scripts/changelog-release-notes.sh" WORKFLOW="$ROOT/.github/workflows/release.yml" +PKG_VERIFIER="$ROOT/packaging/verify-pkg.sh" failures=0 check_contains() { @@ -86,6 +87,8 @@ check_contains "$WORKFLOW" "SHA256SUMS.txt" \ check_contains "$WORKFLOW" "scripts/changelog-release-notes.sh" \ "release workflow derives notes from the versioned changelog" check_contains "$WORKFLOW" "gh release create" "release workflow creates the GitHub release" +check_contains "$PKG_VERIFIER" "com.apple.developer.usernotifications.time-sensitive" \ + "package verification rejects an app without the time-sensitive notification entitlement" if (( failures > 0 )); then printf '%s release workflow check(s) failed.\n' "$failures"