krz/octosentry

macOS menu bar app to monitor GitHub security alerts

clone: git clone https://gitbay.org/krz/octosentry.git

705c6029ab30adf094e6324006b9fb682d8189f2

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-07-17T22:34:22Z

Add repo picker, update checker, and distribution tooling

Closes #11-#15 (milestones 0.6.0, 0.7.0, 1.0.0).

- Repo picker: on-demand broader OAuth scope (security_events repo),
  requested only when the "Browse your repos" action is used, never by
  default. Lists /user/repos via the existing pagination helper. Granted
  scope persisted with backward-compatible decoding for existing state
  files. Fixed a bug where a failed re-auth force-signed-out a user who
  already had a valid narrower-scope token.
- Update checker: polls this repo's GitHub Releases API, surfaces a
  banner linking to new releases. Skipped on the Mac App Store build via
  a runtime receipt check rather than a separate build configuration.
- Fixed MARKETING_VERSION, stuck at Xcode's default "1.0" this whole
  time unrelated to our git tags — now 1.0.0, matching this release.
- Added PrivacyInfo.xcprivacy (no tracking, no collected data).
- Added scripts/build-dmg.sh (archive, Developer ID export, notarize,
  staple) and Casks/octosentry.rb (Homebrew Cask template), plus
  DISTRIBUTION.md documenting both channels end to end.

Entitlements were already identical across all builds — no divergence
needed there. What remains for actual App Store submission and notarized
DMG builds is account-specific (Apple Developer Program membership,
certificates, App Store Connect submission) and can't be done from here;
documented clearly in DISTRIBUTION.md.
 Casks/octosentry.rb                      |  27 +++++++
 DISTRIBUTION.md                          |  63 ++++++++++++++++
 octosentry.xcodeproj/project.pbxproj     |  12 +--
 octosentry/AuthStore.swift               |  50 ++++++++++---
 octosentry/GitHubAPIModels.swift         |   8 ++
 octosentry/GitHubDeviceAuthClient.swift  |  14 ++--
 octosentry/GitHubSecurityAPIClient.swift |  20 ++++-
 octosentry/PersistedState.swift          |  40 +++++++++-
 octosentry/PrivacyInfo.xcprivacy         |  14 ++++
 octosentry/SecurityEventListView.swift   | 123 +++++++++++++++++++++++++++++--
 octosentry/SecurityEventStore.swift      |  10 +++
 octosentry/UpdateChecker.swift           |  85 +++++++++++++++++++++
 octosentry/UpdateStore.swift             |  55 ++++++++++++++
 octosentry/octosentryApp.swift           |   5 +-
 scripts/build-dmg.sh                     |  76 +++++++++++++++++++
 15 files changed, 566 insertions(+), 36 deletions(-)

diff --git a/Casks/octosentry.rb b/Casks/octosentry.rb
new file mode 100644
index 0000000..becc4fd
--- /dev/null
+++ b/Casks/octosentry.rb
@@ -0,0 +1,27 @@
+# Homebrew Cask for octosentry (spec §9: DMG/Homebrew distribution channel).
+#
+# This file lives here as a template — Homebrew taps must be their own repo
+# named "homebrew-<tapname>" for `brew tap` to find them. To actually publish:
+#   1. Create github.com/zerolabsco/homebrew-tap (or similar)
+#   2. Copy this file there as Casks/octosentry.rb
+#   3. Fill in sha256 below with the real checksum of the released DMG:
+#        shasum -a 256 octosentry-<version>.dmg
+#   4. Users install via: brew tap zerolabsco/tap && brew install --cask octosentry
+
+cask "octosentry" do
+  version "1.0.0"
+  sha256 "REPLACE_WITH_REAL_SHA256_OF_RELEASED_DMG"
+
+  url "https://github.com/zerolabsco/octosentry/releases/download/#{version}/octosentry-#{version}.dmg"
+  name "octosentry"
+  desc "Menu bar app aggregating GitHub security alerts into one feed"
+  homepage "https://github.com/zerolabsco/octosentry"
+
+  depends_on macos: ">= :sonoma"
+
+  app "octosentry.app"
+
+  zap trash: [
+    "~/Library/Application Support/octosentry",
+  ]
+end
diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md
new file mode 100644
index 0000000..f81fb21
--- /dev/null
+++ b/DISTRIBUTION.md
@@ -0,0 +1,63 @@
+# Distribution
+
+octosentry ships on two channels with a single codebase and identical
+entitlements (spec §9) — the only divergence is signing method at export
+time and whether the update checker runs.
+
+## App Store
+
+1. Requires an active Apple Developer Program membership and an **Apple
+   Distribution** certificate (Xcode > Settings > Accounts > Manage
+   Certificates).
+2. Create the app record in [App Store Connect](https://appstoreconnect.apple.com)
+   with bundle ID `net.cleberg.octosentry`.
+3. Archive: Product > Archive in Xcode (Release configuration).
+4. In the Organizer, Distribute App > App Store Connect > Upload.
+5. Complete the app listing (screenshots, description, privacy nutrition
+   label — [PrivacyInfo.xcprivacy](octosentry/PrivacyInfo.xcprivacy) already
+   declares no tracking and no collected data) and submit for review.
+
+The update checker (`UpdateStore`) detects the App Store receipt at
+runtime and never runs on this build — no code changes needed per release.
+
+## DMG (direct distribution)
+
+Requires a **Developer ID Application** certificate and notarization
+credentials stored once locally:
+
+```bash
+xcrun notarytool store-credentials "octosentry-notary" \
+  --apple-id "you@example.com" \
+  --team-id "YOUR_TEAM_ID" \
+  --password "an-app-specific-password"
+```
+
+(App-specific password from [appleid.apple.com](https://appleid.apple.com),
+not your main Apple ID password.)
+
+Then, per release:
+
+```bash
+scripts/build-dmg.sh 1.0.0
+```
+
+This archives, exports with Developer ID signing, notarizes, staples the
+ticket, and produces `build/octosentry-1.0.0.dmg`. Attach that file to
+the corresponding GitHub Release (`gh release create 1.0.0 build/octosentry-1.0.0.dmg`)
+— the update checker links there.
+
+## Homebrew
+
+Not published yet. [Casks/octosentry.rb](Casks/octosentry.rb) is a
+template — to actually publish it:
+
+1. Create a `zerolabsco/homebrew-tap` repo.
+2. Copy the cask there, filling in the real `sha256` of the released DMG
+   (`shasum -a 256 octosentry-1.0.0.dmg`).
+3. Users install via `brew tap zerolabsco/tap && brew install --cask octosentry`.
+
+## Version bumps
+
+`MARKETING_VERSION` in the Xcode project must match the git tag for each
+release — the update checker compares `CFBundleShortVersionString`
+against the latest GitHub Release's tag name.
diff --git a/octosentry.xcodeproj/project.pbxproj b/octosentry.xcodeproj/project.pbxproj
index a6c2fcc..d8edb63 100644
--- a/octosentry.xcodeproj/project.pbxproj
+++ b/octosentry.xcodeproj/project.pbxproj
@@ -412,7 +412,7 @@
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 				);
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.0.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentry;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				REGISTER_APP_GROUPS = YES;
@@ -448,7 +448,7 @@
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 				);
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.0.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentry;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				REGISTER_APP_GROUPS = YES;
@@ -470,7 +470,7 @@
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = YES;
 				MACOSX_DEPLOYMENT_TARGET = 14.0;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.0.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryTests;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -491,7 +491,7 @@
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = YES;
 				MACOSX_DEPLOYMENT_TARGET = 14.0;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.0.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryTests;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -510,7 +510,7 @@
 				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = YES;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.0.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryUITests;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -529,7 +529,7 @@
 				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = YES;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.0.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.octosentryUITests;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = NO;
diff --git a/octosentry/AuthStore.swift b/octosentry/AuthStore.swift
index 4386e38..4194bba 100644
--- a/octosentry/AuthStore.swift
+++ b/octosentry/AuthStore.swift
@@ -6,6 +6,11 @@
 //  currently in the Keychain. Replaces the GITHUB_TOKEN env var dev
 //  shortcut (spec §13) with the real v1 auth flow (spec §6).
 //
+//  Sign-in requests the minimal security_events scope by default.
+//  Broader "repo" scope (needed to list repos for the picker, #15) is
+//  only ever requested on demand via requestRepoAccess(), never by
+//  default — a deliberate choice to keep the default blast radius small.
+//
 
 import Foundation
 import Observation
@@ -14,12 +19,17 @@ import Observation
 final class AuthStore {
     private(set) var state: AuthState
     private(set) var errorMessage: String?
+    private(set) var hasRepoAccess = false
 
     private let client = GitHubDeviceAuthClient()
+    private let persistenceStore = PersistenceStore()
     private var authorizationTask: Task<Void, Never>?
 
     init() {
         state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut
+        Task {
+            hasRepoAccess = await persistenceStore.load().hasRepoScope
+        }
     }
 
     var isSignedIn: Bool {
@@ -28,13 +38,32 @@ final class AuthStore {
     }
 
     func signIn() {
+        beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope)
+    }
+
+    /// Re-runs device auth with broader scope so the repo picker can list
+    /// repos. Only called explicitly from the repo picker UI, never on
+    /// the default sign-in path.
+    func requestRepoAccess() {
+        beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope)
+    }
+
+    func signOut() {
+        authorizationTask?.cancel()
+        authorizationTask = nil
+        KeychainTokenStore.delete()
+        state = .signedOut
+        hasRepoAccess = false
+    }
+
+    private func beginAuthorization(scope: String) {
         guard authorizationTask == nil else { return }
         errorMessage = nil
 
         authorizationTask = Task {
             defer { authorizationTask = nil }
             do {
-                let deviceCode = try await client.requestDeviceCode()
+                let deviceCode = try await client.requestDeviceCode(scope: scope)
                 state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri)
 
                 let token = try await client.pollForToken(
@@ -43,18 +72,21 @@ final class AuthStore {
                     expiresIn: deviceCode.expiresIn
                 )
                 try KeychainTokenStore.save(token)
+
+                let grantedRepoScope = scope.contains("repo")
+                var persisted = await persistenceStore.load()
+                persisted.hasRepoScope = grantedRepoScope
+                await persistenceStore.save(persisted)
+                hasRepoAccess = grantedRepoScope
+
                 state = .signedIn
             } catch {
                 errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
-                state = .signedOut
+                // A failed re-auth (e.g. requestRepoAccess while already
+                // signed in) shouldn't sign the user out of their existing
+                // valid token — only reflect reality from the Keychain.
+                state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut
             }
         }
     }
-
-    func signOut() {
-        authorizationTask?.cancel()
-        authorizationTask = nil
-        KeychainTokenStore.delete()
-        state = .signedOut
-    }
 }
diff --git a/octosentry/GitHubAPIModels.swift b/octosentry/GitHubAPIModels.swift
index 3a84aa8..2b95b70 100644
--- a/octosentry/GitHubAPIModels.swift
+++ b/octosentry/GitHubAPIModels.swift
@@ -86,3 +86,11 @@ nonisolated struct SecretScanningAlertDTO: Decodable {
         case validity
     }
 }
+
+nonisolated struct GitHubRepoDTO: Decodable {
+    let fullName: String
+
+    enum CodingKeys: String, CodingKey {
+        case fullName = "full_name"
+    }
+}
diff --git a/octosentry/GitHubDeviceAuthClient.swift b/octosentry/GitHubDeviceAuthClient.swift
index 63799ff..3105733 100644
--- a/octosentry/GitHubDeviceAuthClient.swift
+++ b/octosentry/GitHubDeviceAuthClient.swift
@@ -15,10 +15,14 @@ actor GitHubDeviceAuthClient {
     // Not a secret — safe to embed in source.
     private let clientID = "Ov23li6tqaTghDc4IJYv"
 
-    // Grants Dependabot/code scanning/secret scanning alert access. Classic OAuth
-    // scopes have no read-only variant (unlike fine-grained PATs); this is the
-    // narrowest scope GitHub offers for these three endpoints via OAuth Apps.
-    private let scope = "security_events"
+    // Default sign-in scope: grants Dependabot/code scanning/secret scanning alert
+    // access. Classic OAuth scopes have no read-only variant (unlike fine-grained
+    // PATs); this is the narrowest scope GitHub offers for these three endpoints.
+    static let defaultScope = "security_events"
+
+    // Broader scope requested only on demand (repo picker, #15) — never the
+    // default, since it's a real increase in blast radius over defaultScope alone.
+    static let repoAccessScope = "security_events repo"
 
     private let session: URLSession
 
@@ -26,7 +30,7 @@ actor GitHubDeviceAuthClient {
         self.session = session
     }
 
-    func requestDeviceCode() async throws -> DeviceCodeResponse {
+    func requestDeviceCode(scope: String) async throws -> DeviceCodeResponse {
         let data = try await post(
             url: URL(string: "https://github.com/login/device/code")!,
             parameters: ["client_id": clientID, "scope": scope]
diff --git a/octosentry/GitHubSecurityAPIClient.swift b/octosentry/GitHubSecurityAPIClient.swift
index 629699a..2e480bd 100644
--- a/octosentry/GitHubSecurityAPIClient.swift
+++ b/octosentry/GitHubSecurityAPIClient.swift
@@ -3,9 +3,9 @@
 //  octosentry
 //
 //  Fetches Dependabot, code scanning, and secret scanning alerts for a
-//  single repo and normalizes them into SecurityEvent. Auth is a PAT read
-//  by the caller from the GITHUB_TOKEN environment variable — a dev-only
-//  shortcut ahead of the device authorization flow (spec §6, §13).
+//  repo and normalizes them into SecurityEvent, plus (with broader scope)
+//  listing repos the token can see for the repo picker. The token itself
+//  comes from Keychain via the device authorization flow (spec §6).
 //
 
 import Foundation
@@ -89,6 +89,20 @@ actor GitHubSecurityAPIClient {
         }
     }
 
+    /// Lists repos the token can see (requires the broader repo-access
+    /// scope granted via AuthStore.requestRepoAccess(), not the default
+    /// sign-in scope). Used by the repo picker (#15).
+    func fetchAccessibleRepos() async throws -> [String] {
+        var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
+        components.path = "/user/repos"
+        components.queryItems = [
+            URLQueryItem(name: "per_page", value: "100"),
+            URLQueryItem(name: "sort", value: "full_name"),
+        ]
+        let dtos: [GitHubRepoDTO] = try await fetchAllPages(url: components.url!)
+        return dtos.map(\.fullName)
+    }
+
     private func alertsURL(owner: String, repo: String, path: String) -> URL {
         var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
         components.path = "/repos/\(owner)/\(repo)/\(path)"
diff --git a/octosentry/PersistedState.swift b/octosentry/PersistedState.swift
index 3ef4234..c8c1bae 100644
--- a/octosentry/PersistedState.swift
+++ b/octosentry/PersistedState.swift
@@ -3,10 +3,12 @@
 //  octosentry
 //
 //  Everything the app remembers across launches: the repo watch list,
-//  local-only seen-state per event, last-fetch timestamp per repo, and the
-//  minimum severity filter. Flat JSON over SwiftData (see #1) — small,
-//  inspectable, and these are already plain Codable values passed across
-//  actor boundaries, not reference types tied to a persistence context.
+//  local-only seen-state per event, last-fetch timestamp per repo, the
+//  minimum severity filter, and whether the current token has the
+//  broader "repo" scope needed to list repos. Flat JSON over SwiftData
+//  (see #1) — small, inspectable, and these are already plain Codable
+//  values passed across actor boundaries, not reference types tied to a
+//  persistence context.
 //
 
 import Foundation
@@ -16,6 +18,36 @@ nonisolated struct PersistedState: Codable {
     var seenEventIDs: Set<String>
     var lastFetchByRepo: [String: Date]
     var minimumSeverity: SecurityEventSeverity
+    var hasRepoScope: Bool
+
+    enum CodingKeys: String, CodingKey {
+        case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope
+    }
+
+    init(
+        watchedRepos: [String],
+        seenEventIDs: Set<String>,
+        lastFetchByRepo: [String: Date],
+        minimumSeverity: SecurityEventSeverity,
+        hasRepoScope: Bool = false
+    ) {
+        self.watchedRepos = watchedRepos
+        self.seenEventIDs = seenEventIDs
+        self.lastFetchByRepo = lastFetchByRepo
+        self.minimumSeverity = minimumSeverity
+        self.hasRepoScope = hasRepoScope
+    }
+
+    // Custom decode so existing state.json files saved before hasRepoScope
+    // existed still load instead of falling back to .placeholder.
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        watchedRepos = try container.decode([String].self, forKey: .watchedRepos)
+        seenEventIDs = try container.decode(Set<String>.self, forKey: .seenEventIDs)
+        lastFetchByRepo = try container.decode([String: Date].self, forKey: .lastFetchByRepo)
+        minimumSeverity = try container.decode(SecurityEventSeverity.self, forKey: .minimumSeverity)
+        hasRepoScope = try container.decodeIfPresent(Bool.self, forKey: .hasRepoScope) ?? false
+    }
 
     static let placeholder = PersistedState(
         watchedRepos: ["ccleberg/cleberg.net"],
diff --git a/octosentry/PrivacyInfo.xcprivacy b/octosentry/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..e08a130
--- /dev/null
+++ b/octosentry/PrivacyInfo.xcprivacy
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>NSPrivacyTracking</key>
+	<false/>
+	<key>NSPrivacyTrackingDomains</key>
+	<array/>
+	<key>NSPrivacyCollectedDataTypes</key>
+	<array/>
+	<key>NSPrivacyAccessedAPITypes</key>
+	<array/>
+</dict>
+</plist>
diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift
index d817370..84e6638 100644
--- a/octosentry/SecurityEventListView.swift
+++ b/octosentry/SecurityEventListView.swift
@@ -9,6 +9,7 @@ import SwiftUI
 struct SecurityEventListView: View {
     var store: SecurityEventStore
     var authStore: AuthStore
+    var updateStore: UpdateStore
     var isStandaloneWindow: Bool = false
     @State private var showingRepoManager = false
     @Environment(\.openWindow) private var openWindow
@@ -16,6 +17,9 @@ struct SecurityEventListView: View {
     var body: some View {
         VStack(alignment: .leading, spacing: 0) {
             header
+            if let release = updateStore.availableRelease {
+                UpdateBanner(release: release)
+            }
             Divider()
             if !authStore.isSignedIn {
                 SignInView(authStore: authStore)
@@ -30,6 +34,9 @@ struct SecurityEventListView: View {
             await store.refresh()
             store.startPolling()
         }
+        .task {
+            await updateStore.checkForUpdate()
+        }
     }
 
     private var header: some View {
@@ -142,6 +149,10 @@ private struct RepoManagerView: View {
     var store: SecurityEventStore
     var authStore: AuthStore
     @State private var newRepoText = ""
+    @State private var isBrowsingRepos = false
+    @State private var availableRepos: [String] = []
+    @State private var isLoadingRepos = false
+    @State private var browseErrorMessage: String?
 
     var body: some View {
         VStack(alignment: .leading, spacing: 10) {
@@ -171,13 +182,24 @@ private struct RepoManagerView: View {
 
             Divider()
 
-            HStack {
-                TextField("owner/repo", text: $newRepoText)
-                    .textFieldStyle(.roundedBorder)
-                    .onSubmit(addRepo)
+            if isBrowsingRepos {
+                browsingContent
+            } else {
+                HStack {
+                    TextField("owner/repo", text: $newRepoText)
+                        .textFieldStyle(.roundedBorder)
+                        .onSubmit(addRepo)
+
+                    Button("Add", action: addRepo)
+                        .disabled(newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+                }
 
-                Button("Add", action: addRepo)
-                    .disabled(newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+                Button(action: startBrowsing) {
+                    Label("Browse your repos", systemImage: "list.bullet")
+                        .font(.caption)
+                }
+                .buttonStyle(.plain)
+                .foregroundStyle(Color.accentColor)
             }
 
             if let errorMessage = store.watchListErrorMessage {
@@ -200,6 +222,75 @@ private struct RepoManagerView: View {
         .frame(maxWidth: .infinity, alignment: .leading)
     }
 
+    @ViewBuilder
+    private var browsingContent: some View {
+        VStack(alignment: .leading, spacing: 6) {
+            HStack {
+                Text("Your Repositories")
+                    .font(.caption.weight(.semibold))
+                Spacer()
+                Button {
+                    isBrowsingRepos = false
+                } label: {
+                    Image(systemName: "xmark.circle")
+                }
+                .buttonStyle(.plain)
+            }
+
+            if isLoadingRepos {
+                ProgressView()
+                    .controlSize(.small)
+                    .frame(maxWidth: .infinity)
+            } else if let browseErrorMessage {
+                Text(browseErrorMessage)
+                    .font(.caption2)
+                    .foregroundStyle(.red)
+            } else {
+                let selectableRepos = availableRepos.filter { !store.watchedRepos.contains($0) }
+                if selectableRepos.isEmpty {
+                    Text("All visible repos are already watched.")
+                        .font(.caption2)
+                        .foregroundStyle(.secondary)
+                } else {
+                    ScrollView {
+                        LazyVStack(alignment: .leading, spacing: 4) {
+                            ForEach(selectableRepos, id: \.self) { repo in
+                                Button {
+                                    Task { await store.addRepo(repo) }
+                                    isBrowsingRepos = false
+                                } label: {
+                                    Text(repo)
+                                        .font(.callout)
+                                        .frame(maxWidth: .infinity, alignment: .leading)
+                                }
+                                .buttonStyle(.plain)
+                            }
+                        }
+                    }
+                    .frame(maxHeight: 160)
+                }
+            }
+        }
+    }
+
+    private func startBrowsing() {
+        guard authStore.hasRepoAccess else {
+            authStore.requestRepoAccess()
+            return
+        }
+        isBrowsingRepos = true
+        isLoadingRepos = true
+        browseErrorMessage = nil
+        Task {
+            do {
+                availableRepos = try await store.fetchAccessibleRepos()
+            } catch {
+                browseErrorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+            }
+            isLoadingRepos = false
+        }
+    }
+
     private func addRepo() {
         let text = newRepoText
         newRepoText = ""
@@ -207,6 +298,24 @@ private struct RepoManagerView: View {
     }
 }
 
+private struct UpdateBanner: View {
+    let release: UpdateChecker.LatestRelease
+
+    var body: some View {
+        Button {
+            NSWorkspace.shared.open(release.htmlURL)
+        } label: {
+            Label("Update available: \(release.version)", systemImage: "arrow.down.circle.fill")
+                .font(.caption)
+                .frame(maxWidth: .infinity, alignment: .leading)
+        }
+        .buttonStyle(.plain)
+        .foregroundStyle(.blue)
+        .padding(8)
+        .background(.blue.opacity(0.1))
+    }
+}
+
 private struct ErrorBanner: View {
     let messages: [String]
 
@@ -262,6 +371,6 @@ private struct StatusView: View {
 }
 
 #Preview {
-    SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore())
+    SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore(), updateStore: UpdateStore())
         .frame(width: 380, height: 420)
 }
diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift
index 26dcc16..8f3d0f5 100644
--- a/octosentry/SecurityEventStore.swift
+++ b/octosentry/SecurityEventStore.swift
@@ -134,6 +134,16 @@ final class SecurityEventStore {
         await refresh()
     }
 
+    /// Lists repos the current token can see, for the repo picker (#15).
+    /// Requires broader repo-access scope — throws if the token only has
+    /// the default security_events scope.
+    func fetchAccessibleRepos() async throws -> [String] {
+        guard let token = KeychainTokenStore.load() else {
+            throw GitHubAPIError.missingToken
+        }
+        return try await GitHubSecurityAPIClient(token: token).fetchAccessibleRepos()
+    }
+
     /// Local-only triage state (spec §11) — no API write, no scope beyond
     /// read needed. Removes the event from the active stream.
     func markSeen(_ eventID: String) async {
diff --git a/octosentry/UpdateChecker.swift b/octosentry/UpdateChecker.swift
new file mode 100644
index 0000000..e90cab1
--- /dev/null
+++ b/octosentry/UpdateChecker.swift
@@ -0,0 +1,85 @@
+//
+//  UpdateChecker.swift
+//  octosentry
+//
+//  Polls this repo's own GitHub Releases API (spec §9) — no auto-install,
+//  no Sparkle, just a link to the release page. Skipped entirely on the
+//  Mac App Store build, detected at runtime via the presence of an App
+//  Store receipt rather than a separate build configuration: same
+//  outcome (this code never runs there) with far less project surface
+//  than maintaining a second Xcode configuration/scheme just for this.
+//
+
+import Foundation
+
+actor UpdateChecker {
+    private let session: URLSession
+    private let repoOwner = "zerolabsco"
+    private let repoName = "octosentry"
+
+    init(session: URLSession = .shared) {
+        self.session = session
+    }
+
+    struct LatestRelease: Sendable {
+        let version: String
+        let htmlURL: URL
+    }
+
+    func fetchLatestRelease() async throws -> LatestRelease {
+        var request = URLRequest(url: URL(string: "https://api.github.com/repos/\(repoOwner)/\(repoName)/releases/latest")!)
+        request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
+        request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version")
+
+        let data: Data
+        let response: URLResponse
+        do {
+            (data, response) = try await session.data(for: request)
+        } catch {
+            throw UpdateCheckError.network(error.localizedDescription)
+        }
+
+        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+            throw UpdateCheckError.requestFailed
+        }
+
+        let dto: GitHubReleaseDTO
+        do {
+            dto = try JSONDecoder().decode(GitHubReleaseDTO.self, from: data)
+        } catch {
+            throw UpdateCheckError.decodingFailed(error.localizedDescription)
+        }
+
+        guard let url = URL(string: dto.htmlUrl) else {
+            throw UpdateCheckError.decodingFailed("Malformed release URL.")
+        }
+        return LatestRelease(version: dto.tagName, htmlURL: url)
+    }
+}
+
+nonisolated struct GitHubReleaseDTO: Decodable {
+    let tagName: String
+    let htmlUrl: String
+
+    enum CodingKeys: String, CodingKey {
+        case tagName = "tag_name"
+        case htmlUrl = "html_url"
+    }
+}
+
+nonisolated enum UpdateCheckError: Error, LocalizedError {
+    case network(String)
+    case requestFailed
+    case decodingFailed(String)
+
+    var errorDescription: String? {
+        switch self {
+        case .network(let message):
+            "Network error checking for updates: \(message)"
+        case .requestFailed:
+            "Failed to check for updates."
+        case .decodingFailed(let message):
+            "Unexpected response checking for updates: \(message)"
+        }
+    }
+}
diff --git a/octosentry/UpdateStore.swift b/octosentry/UpdateStore.swift
new file mode 100644
index 0000000..1ed463f
--- /dev/null
+++ b/octosentry/UpdateStore.swift
@@ -0,0 +1,55 @@
+//
+//  UpdateStore.swift
+//  octosentry
+//
+
+import Foundation
+import Observation
+
+@Observable
+final class UpdateStore {
+    private(set) var availableRelease: UpdateChecker.LatestRelease?
+
+    private let checker = UpdateChecker()
+
+    /// True for a Mac App Store build (has an App Store receipt), false for
+    /// a direct DMG/Homebrew build. Runtime check rather than a build flag —
+    /// see UpdateChecker.swift for why.
+    var isMacAppStoreBuild: Bool {
+        guard let receiptURL = Bundle.main.appStoreReceiptURL else { return false }
+        return FileManager.default.fileExists(atPath: receiptURL.path)
+    }
+
+    func checkForUpdate() async {
+        guard !isMacAppStoreBuild else { return }
+        guard let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return }
+        guard let latest = try? await checker.fetchLatestRelease() else { return }
+
+        if Self.isNewer(latest.version, than: currentVersion) {
+            availableRelease = latest
+        }
+    }
+
+    static func isNewer(_ candidate: String, than current: String) -> Bool {
+        let candidateParts = versionComponents(candidate)
+        let currentParts = versionComponents(current)
+        let count = max(candidateParts.count, currentParts.count)
+
+        for i in 0..<count {
+            let candidatePart = i < candidateParts.count ? candidateParts[i] : 0
+            let currentPart = i < currentParts.count ? currentParts[i] : 0
+            if candidatePart != currentPart {
+                return candidatePart > currentPart
+            }
+        }
+        return false
+    }
+
+    private static func versionComponents(_ version: String) -> [Int] {
+        var trimmed = version
+        if trimmed.hasPrefix("v") {
+            trimmed.removeFirst()
+        }
+        return trimmed.split(separator: ".").map { Int($0) ?? 0 }
+    }
+}
diff --git a/octosentry/octosentryApp.swift b/octosentry/octosentryApp.swift
index e80c4a0..d9f388e 100644
--- a/octosentry/octosentryApp.swift
+++ b/octosentry/octosentryApp.swift
@@ -15,10 +15,11 @@ enum SecurityEventWindow {
 struct octosentryApp: App {
     @State private var store = SecurityEventStore()
     @State private var authStore = AuthStore()
+    @State private var updateStore = UpdateStore()
 
     var body: some Scene {
         MenuBarExtra {
-            SecurityEventListView(store: store, authStore: authStore)
+            SecurityEventListView(store: store, authStore: authStore, updateStore: updateStore)
                 .frame(width: 380, height: 420)
         } label: {
             MenuBarIconView(criticalCount: store.unseenCriticalCount)
@@ -26,7 +27,7 @@ struct octosentryApp: App {
         .menuBarExtraStyle(.window)
 
         Window("Security Events", id: SecurityEventWindow.id) {
-            SecurityEventListView(store: store, authStore: authStore, isStandaloneWindow: true)
+            SecurityEventListView(store: store, authStore: authStore, updateStore: updateStore, isStandaloneWindow: true)
                 .frame(minWidth: 420, minHeight: 480)
         }
     }
diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh
new file mode 100755
index 0000000..14bd608
--- /dev/null
+++ b/scripts/build-dmg.sh
@@ -0,0 +1,76 @@
+#!/bin/bash
+#
+# build-dmg.sh
+#
+# Builds a notarized, Developer-ID-signed DMG for direct distribution
+# (spec §9: DMG/Homebrew channel). Requires local one-time setup this
+# script does NOT do for you:
+#
+#   1. A "Developer ID Application" certificate in your keychain, tied to
+#      an active Apple Developer Program membership. Xcode > Settings >
+#      Accounts > Manage Certificates > + > Developer ID Application.
+#   2. Notarization credentials stored once via:
+#        xcrun notarytool store-credentials "octosentry-notary" \
+#          --apple-id "you@example.com" \
+#          --team-id "YOUR_TEAM_ID" \
+#          --password "an-app-specific-password"
+#      (App-specific password from appleid.apple.com, not your main
+#      Apple ID password.)
+#
+# Usage: scripts/build-dmg.sh [version]
+# Output: build/octosentry-<version>.dmg
+
+set -euo pipefail
+
+VERSION="${1:-dev}"
+PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+BUILD_DIR="$PROJECT_DIR/build"
+ARCHIVE_PATH="$BUILD_DIR/octosentry.xcarchive"
+EXPORT_PATH="$BUILD_DIR/export"
+EXPORT_OPTIONS_PLIST="$BUILD_DIR/export-options.plist"
+DMG_PATH="$BUILD_DIR/octosentry-$VERSION.dmg"
+NOTARY_PROFILE="octosentry-notary"
+
+rm -rf "$BUILD_DIR"
+mkdir -p "$BUILD_DIR"
+
+echo "==> Archiving (Release configuration)"
+xcodebuild archive \
+  -project "$PROJECT_DIR/octosentry.xcodeproj" \
+  -scheme octosentry \
+  -configuration Release \
+  -archivePath "$ARCHIVE_PATH"
+
+cat > "$EXPORT_OPTIONS_PLIST" <<PLIST
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>method</key>
+	<string>developer-id</string>
+</dict>
+</plist>
+PLIST
+
+echo "==> Exporting (Developer ID)"
+xcodebuild -exportArchive \
+  -archivePath "$ARCHIVE_PATH" \
+  -exportPath "$EXPORT_PATH" \
+  -exportOptionsPlist "$EXPORT_OPTIONS_PLIST"
+
+APP_PATH="$EXPORT_PATH/octosentry.app"
+
+echo "==> Notarizing"
+DMG_STAGING="$BUILD_DIR/staging"
+mkdir -p "$DMG_STAGING"
+cp -R "$APP_PATH" "$DMG_STAGING/"
+ln -s /Applications "$DMG_STAGING/Applications"
+
+hdiutil create -volname "octosentry" -srcfolder "$DMG_STAGING" -ov -format UDZO "$DMG_PATH"
+
+xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
+
+echo "==> Stapling notarization ticket"
+xcrun stapler staple "$DMG_PATH"
+
+echo "==> Done: $DMG_PATH"