From a79a569a7d88cd11f3461a90d0454c1973e73197 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Sat, 5 Sep 2026 10:00:10 +0400 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20updater=20hardening=20=E2=80=94=20t?= =?UTF-8?q?imeouts,=20signature=20verification,=20atomic=20install+rollbac?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 30s/600s URLSession timeouts on the update session (was 15s/15s, too tight for a real zip download). Every staged and installed payload is now verified with the Security framework (SecStaticCodeCheckValidityWithErrors, strict + all architectures + nested code) against bundle id ai.flowmaster.shotdeck and an allowed-team set (PWMCBMX5M8, L3N9S54CN3; overridable via REDLINE_ALLOWED_TEAMS for the self-test only) — an unsigned or wrongly-signed update is discarded before checkNow ever offers it, and installStaged re-verifies what actually landed on disk as defense in depth. installStaged is now atomic: ditto into an itemReplacementDirectory, then FileManager.replaceItemAt swaps it into place, keeping exactly one Redline.app.previous rollback copy (older ones are dropped first). Added revertToPrevious(target:) to swap that copy back in (itself reversible — the replaced version becomes the new .previous), and previousVersion(target:) to read its CFBundleShortVersionString. Relaunch is now a detached "wait for this PID to exit, then open -n" shell handoff instead of a synchronous open+terminate, so there is never a moment with two instances running. checkNow(manual:) now says "Redline X is up to date." when the user asked directly, and exposes isCheckingNow/lastCheckedAt for the UI. Co-Authored-By: Claude Fable 5.1 --- Sources/Shotdeck/UpdateChecker.swift | 245 ++++++++++++++++++++++++--- 1 file changed, 224 insertions(+), 21 deletions(-) diff --git a/Sources/Shotdeck/UpdateChecker.swift b/Sources/Shotdeck/UpdateChecker.swift index 207f51a..18ff2d0 100644 --- a/Sources/Shotdeck/UpdateChecker.swift +++ b/Sources/Shotdeck/UpdateChecker.swift @@ -1,6 +1,7 @@ import AppKit import CryptoKit import Foundation +import Security /// Built-in updater. Checks an appcast, stages a verified payload, and installs /// only when the user clicks the menu row — never automatically. @@ -10,22 +11,31 @@ final class UpdateChecker { static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")! static let defaultInstallTarget = URL(fileURLWithPath: "/Applications/Redline.app") + /// Required bundle identifier for any staged or installed payload. + static let expectedBundleIdentifier = "ai.flowmaster.shotdeck" + /// Developer team identifiers MMD ships Redline under. Overridable only for the self-test. + static let allowedTeamIdentifiers: Set = ["PWMCBMX5M8", "L3N9S54CN3"] + private(set) var availableUpdate: (version: String, notes: String)? private(set) var stagedAppURL: URL? private(set) var statusMessage: String? + private(set) var lastCheckedAt: Date? + private(set) var isCheckingNow: Bool = false var onChecked: (() -> Void)? + /// Fired whenever `isCheckingNow` flips, so a UI can show "Checking…" for the + /// whole duration of a check rather than only after it lands. + var onCheckingChanged: ((Bool) -> Void)? private let urlSession: URLSession private var repeatingTimer: Timer? private var firstCheckTask: Task? - private var isChecking = false private var stagingDirectory: URL? init() { let config = URLSessionConfiguration.ephemeral - config.timeoutIntervalForRequest = 15 - config.timeoutIntervalForResource = 15 + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 600 config.httpCookieAcceptPolicy = .never config.httpShouldSetCookies = false config.httpCookieStorage = nil @@ -51,10 +61,18 @@ final class UpdateChecker { repeatingTimer = timer } - func checkNow() async { - guard !isChecking else { return } - isChecking = true - defer { isChecking = false } + /// Checks the appcast and stages a newer, signature-verified payload. + /// `manual` only affects the status message shown when already up to date — + /// a user-initiated check says so; the silent background check stays quiet. + func checkNow(manual: Bool = false) async { + guard !isCheckingNow else { return } + isCheckingNow = true + onCheckingChanged?(true) + defer { + isCheckingNow = false + onCheckingChanged?(false) + } + lastCheckedAt = Date() let appcast: Appcast do { @@ -67,7 +85,7 @@ final class UpdateChecker { guard Self.isNewer(appcast.version, than: Self.currentVersion()) else { clearOffer() - statusMessage = nil + statusMessage = manual ? "Redline \(Self.currentVersion()) is up to date." : nil onChecked?() return } @@ -80,6 +98,10 @@ final class UpdateChecker { discardStaging() availableUpdate = nil statusMessage = "Update file failed the checksum — not installed." + } catch UpdateCheckError.signatureInvalid { + discardStaging() + availableUpdate = nil + statusMessage = "Update is not signed by MMD — not installed." } catch { discardStaging() availableUpdate = nil @@ -88,9 +110,9 @@ final class UpdateChecker { onChecked?() } - /// Copies the staged app onto `target` with ditto (in place; never deletes the old app). - /// Relaunches unless `SHOTDECK_UPDATE_SELFTEST` is set, so the in-process self-test - /// can assert the installed Info.plist without killing the process. + /// Installs the staged app onto `target` atomically, keeping exactly one rollback + /// copy (`Redline.app.previous`), then hands off to a relaunch and quits. + /// Never deletes the old app before the new one is verified in place. func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) { guard let staged = stagedAppURL else { statusMessage = "No update is staged." @@ -98,29 +120,118 @@ final class UpdateChecker { return } + let targetDir = target.deletingLastPathComponent() + let previousURL = targetDir.appendingPathComponent("Redline.app.previous") + do { - try FileManager.default.createDirectory( - at: target.deletingLastPathComponent(), - withIntermediateDirectories: true + try FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) + + let replacementDir = try FileManager.default.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: target, + create: true ) - try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, target.path]) + defer { try? FileManager.default.removeItem(at: replacementDir) } + + let newCopy = replacementDir.appendingPathComponent(target.lastPathComponent) + try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, newCopy.path]) + + // Exactly one rollback copy is kept — drop any older one before this install. + if FileManager.default.fileExists(atPath: previousURL.path) { + try FileManager.default.removeItem(at: previousURL) + } + + if FileManager.default.fileExists(atPath: target.path) { + _ = try FileManager.default.replaceItemAt( + target, + withItemAt: newCopy, + backupItemName: previousURL.lastPathComponent, + options: [.withoutDeletingBackupItem] + ) + } else { + try FileManager.default.moveItem(at: newCopy, to: target) + } } catch { statusMessage = "The update could not be installed." onChecked?() return } - let isSelfTest = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil - if isSelfTest { return } - + // Defense in depth: re-verify what actually landed on disk, not just the staged copy. do { - try Self.runProcess(executable: "/usr/bin/open", arguments: ["-n", target.path]) + try Self.verifySignature(of: target) } catch { - statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications." + statusMessage = "The update was installed but failed verification." onChecked?() return } - NSApp.terminate(nil) + + discardStaging() + availableUpdate = nil + relaunch(target: target) + } + + /// Swaps `Redline.app.previous` back into place, verifying its signature first. + /// The just-replaced (newer) app becomes the new `.previous` — a revert is + /// itself reversible. + func revertToPrevious(target: URL = UpdateChecker.defaultInstallTarget) { + let targetDir = target.deletingLastPathComponent() + let previousURL = targetDir.appendingPathComponent("Redline.app.previous") + + guard FileManager.default.fileExists(atPath: previousURL.path) else { + statusMessage = "No previous version to revert to." + onChecked?() + return + } + + do { + try Self.verifySignature(of: previousURL) + } catch { + statusMessage = "The previous version failed verification and was not restored." + onChecked?() + return + } + + do { + // `previousURL` cannot be handed to replaceItemAt directly: its own path + // IS the requested backup name, so the backup step would clobber it + // before the swap ever reads it. Stage a throwaway copy first, exactly + // like installStaged does for the forward direction. + let replacementDir = try FileManager.default.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: target, + create: true + ) + defer { try? FileManager.default.removeItem(at: replacementDir) } + + let newCopy = replacementDir.appendingPathComponent(target.lastPathComponent) + try Self.runProcess(executable: "/usr/bin/ditto", arguments: [previousURL.path, newCopy.path]) + try FileManager.default.removeItem(at: previousURL) + + _ = try FileManager.default.replaceItemAt( + target, + withItemAt: newCopy, + backupItemName: previousURL.lastPathComponent, + options: [.withoutDeletingBackupItem] + ) + } catch { + statusMessage = "Could not revert to the previous version." + onChecked?() + return + } + + relaunch(target: target) + } + + /// The version recorded in `Redline.app.previous`'s Info.plist, or nil when no + /// rollback copy exists. + func previousVersion(target: URL = UpdateChecker.defaultInstallTarget) -> String? { + let previousURL = target.deletingLastPathComponent().appendingPathComponent("Redline.app.previous") + let plistURL = previousURL.appendingPathComponent("Contents/Info.plist") + guard let plist = NSDictionary(contentsOf: plistURL) as? [String: Any] else { return nil } + return plist["CFBundleShortVersionString"] as? String } static func resolvedAppcastURL() -> URL { @@ -156,6 +267,67 @@ final class UpdateChecker { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } + /// Validates the code signature of the app at `appURL`: strictly, across all + /// architectures and nested code, then checks its bundle identifier and team + /// identifier against `expectedBundleIdentifier` / the allowed-teams set. + /// `REDLINE_ALLOWED_TEAMS` (comma separated) overrides the allowed set — for + /// the self-test only, so it can accept a locally re-signed fake bundle. + static func verifySignature(of appURL: URL) throws { + var staticCode: SecStaticCode? + let createStatus = SecStaticCodeCreateWithPath(appURL as CFURL, [], &staticCode) + guard createStatus == errSecSuccess, let code = staticCode else { + throw UpdateCheckError.signatureInvalid( + "could not read a code signature (status \(createStatus))" + ) + } + + let validityFlags = SecCSFlags( + rawValue: kSecCSStrictValidate | kSecCSCheckAllArchitectures | kSecCSCheckNestedCode + ) + var validityError: Unmanaged? + let validityStatus = SecStaticCodeCheckValidityWithErrors(code, validityFlags, nil, &validityError) + guard validityStatus == errSecSuccess else { + let detail = (validityError?.takeRetainedValue()).map { String(describing: $0) } ?? "status \(validityStatus)" + throw UpdateCheckError.signatureInvalid("signature is not valid: \(detail)") + } + + var signingInfo: CFDictionary? + let infoStatus = SecCodeCopySigningInformation( + code, + SecCSFlags(rawValue: kSecCSSigningInformation), + &signingInfo + ) + guard infoStatus == errSecSuccess, let info = signingInfo as? [String: Any] else { + throw UpdateCheckError.signatureInvalid("could not read signing information (status \(infoStatus))") + } + + let identifier = info[kSecCodeInfoIdentifier as String] as? String + guard identifier == expectedBundleIdentifier else { + throw UpdateCheckError.signatureInvalid( + "unexpected bundle identifier: \(identifier ?? "nil")" + ) + } + + let teamIdentifier = info[kSecCodeInfoTeamIdentifier as String] as? String + guard let teamIdentifier, resolvedAllowedTeamIdentifiers().contains(teamIdentifier) else { + throw UpdateCheckError.signatureInvalid( + "unexpected team identifier: \(teamIdentifier ?? "nil")" + ) + } + } + + private static func resolvedAllowedTeamIdentifiers() -> Set { + if let env = ProcessInfo.processInfo.environment["REDLINE_ALLOWED_TEAMS"], !env.isEmpty { + let parts = env.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + if !parts.isEmpty { + return Set(parts) + } + } + return allowedTeamIdentifiers + } + // MARK: - Private private struct Appcast: Decodable { @@ -170,6 +342,7 @@ final class UpdateChecker { case invalidPayload case httpStatus(Int) case processFailed(String) + case signatureInvalid(String) } private func fetchAppcast() async throws -> Appcast { @@ -219,6 +392,7 @@ final class UpdateChecker { guard FileManager.default.fileExists(atPath: executable.path) else { throw UpdateCheckError.invalidPayload } + try Self.verifySignature(of: appURL) stagedAppURL = appURL } @@ -235,6 +409,35 @@ final class UpdateChecker { stagedAppURL = nil } + /// Spawns a detached watcher that waits for this process to exit, then reopens + /// `target`, and quits. Never called during the self-test, so the in-process + /// assertions after `installStaged`/`revertToPrevious` can still run. + private func relaunch(target: URL) { + guard ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] == nil else { return } + + let ownPID = ProcessInfo.processInfo.processIdentifier + let script = "while kill -0 \(ownPID) 2>/dev/null; do sleep 0.2; done; " + + "/usr/bin/open -n \(Self.shellQuoted(target.path))" + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", script] + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications." + onChecked?() + return + } + NSApp.terminate(nil) + } + + private static func shellQuoted(_ path: String) -> String { + "'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + private static func findRedlineApp(in directory: URL) -> URL? { let fm = FileManager.default let direct = directory.appendingPathComponent("Redline.app") From 064e410e30c7c81e434ff0390202a8a52cbd5f99 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Sat, 5 Sep 2026 10:00:17 +0400 Subject: [PATCH 2/7] feat: surface check/revert/version in the menu and badge the icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppModel exposes appVersion, previousVersion, isCheckingForUpdates and updateStatusMessage (an alias for the existing statusLine plumbing — one status channel, not a new one), plus checkForUpdates() and revertToPreviousVersion() wired to the hardened UpdateChecker. Menu gains, in order: "Update to X" (unchanged, staged-only), "Check for updates" (labelled "Checking…" and disabled mid-check), "Revert to " (only when a rollback copy exists), then the existing rows unchanged, then a non-interactive footer "Redline " with the status line under it — same caption/secondary styles already used elsewhere in the file, no new tokens. Menu-bar icon gets a small badge while an update is staged: uses the SF Symbol's own ".badge" variant when one exists for the current icon, otherwise overlays a small dot on the plain symbol. Reads live model state, so the badge disappears on its own once the offer clears. Co-Authored-By: Claude Fable 5.1 --- Sources/Shotdeck/AppModel.swift | 26 +++++++++++++++++++++++++ Sources/Shotdeck/MenuBarView.swift | 31 ++++++++++++++++++++++++++++++ Sources/Shotdeck/main.swift | 26 ++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index 47897ad..8937316 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -43,6 +43,8 @@ public final class AppModel { var hotkeyDisplayString: String { captureHotkey.displayString } /// Staged update offered in the menu. Set only after checksum + payload validation. public private(set) var updateAvailable: (version: String, notes: String)? + /// True for the duration of any appcast check (manual or scheduled). + public private(set) var isCheckingForUpdates: Bool = false let paths: AppSupportPaths let spool: SpoolStore @@ -96,8 +98,19 @@ public final class AppModel { self.setStatus(message) } } + self.updateChecker.onCheckingChanged = { [weak self] checking in + self?.isCheckingForUpdates = checking + } } + /// CFBundleShortVersionString of the running app. + public var appVersion: String { UpdateChecker.currentVersion() } + /// Version recorded in the app-managed rollback copy, when one exists. + public var previousVersion: String? { updateChecker.previousVersion() } + /// Most recent status text — shared with the general status line by design + /// (Redline has one status channel, not a separate update-only one). + public var updateStatusMessage: String? { statusLine } + // MARK: Seam mutators — the only way a WP-4b/4c extension changes state. func setStatus(_ text: String?) { statusLine = text } @@ -219,6 +232,19 @@ public final class AppModel { updateChecker.installStaged() } + /// User-initiated appcast check ("Check for updates" menu row). + public func checkForUpdates() { + Task { @MainActor in + await updateChecker.checkNow(manual: true) + } + } + + /// Reverts `/Applications/Redline.app` to the app-managed rollback copy and relaunches. + /// Does nothing unless a `Redline.app.previous` exists and the user clicked the row. + public func revertToPreviousVersion() { + updateChecker.revertToPrevious() + } + /// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new /// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working. func reRegisterHotkey() { diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift index 5b239cb..778e3e5 100644 --- a/Sources/Shotdeck/MenuBarView.swift +++ b/Sources/Shotdeck/MenuBarView.swift @@ -15,6 +15,8 @@ struct MenuBarView: View { Divider() returnsBlock } + Divider() + updateFooter } .padding(10) .frame(width: 320, alignment: .leading) @@ -83,6 +85,21 @@ struct MenuBarView: View { } } + Button { + model.checkForUpdates() + } label: { + actionLabel(model.isCheckingForUpdates ? "Checking…" : "Check for updates") + } + .disabled(model.isCheckingForUpdates) + + if let previous = model.previousVersion { + Button { + model.revertToPreviousVersion() + } label: { + actionLabel("Revert to \(previous)") + } + } + Button { let anchor = NSApp.keyWindow?.contentView if let sender = model as? SendCapable { @@ -193,4 +210,18 @@ struct MenuBarView: View { private var newestReturns: [ReturnedDocument] { model.allReturns.sorted { $0.detectedAt > $1.detectedAt } } + + private var updateFooter: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Redline \(model.appVersion)") + .font(.caption) + .foregroundStyle(.secondary) + if let message = model.updateStatusMessage { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } } diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift index 3b61e42..beef734 100644 --- a/Sources/Shotdeck/main.swift +++ b/Sources/Shotdeck/main.swift @@ -21,8 +21,9 @@ struct ShotdeckApp: App { .environment(appDelegate.model) } label: { let state = appDelegate.model.iconState + let hasUpdate = appDelegate.model.updateAvailable != nil HStack(spacing: 4) { - Image(systemName: state.symbolName) + menuBarIcon(for: state, hasUpdate: hasUpdate) if let count = state.countText { Text(count).font(.system(size: 11, weight: .semibold)) } @@ -33,6 +34,29 @@ struct ShotdeckApp: App { } } +/// The menu-bar symbol for `state`, badged while an update is staged. Uses the +/// SF Symbol's own `.badge` variant when one exists; falls back to a small +/// overlaid dot on the plain symbol otherwise. The badge disappears on its own +/// once `updateAvailable` clears, since this reads live model state. +@ViewBuilder +private func menuBarIcon(for state: MenuIconState, hasUpdate: Bool) -> some View { + if hasUpdate { + let badgeName = "\(state.symbolName).badge" + if NSImage(systemSymbolName: badgeName, accessibilityDescription: nil) != nil { + Image(systemName: badgeName) + } else { + ZStack(alignment: .topTrailing) { + Image(systemName: state.symbolName) + Circle() + .frame(width: 6, height: 6) + .offset(x: 3, y: -3) + } + } + } else { + Image(systemName: state.symbolName) + } +} + @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { let model: AppModel From 365220ade82fa600904acd47cbdb39c6c36971f1 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Sat, 5 Sep 2026 10:00:28 +0400 Subject: [PATCH 3/7] =?UTF-8?q?test:=20extend=20UPDATE-SELFTEST=20?= =?UTF-8?q?=E2=80=94=20reject-unsigned,=20staged-signed,=20atomic-install,?= =?UTF-8?q?=20revert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fake-99.0.0-bundle setup as before, but now drives it through the hardened UpdateChecker end to end, in-process: (a) reject-unsigned — the fake bundle, copied then plist-edited without re-signing (editing Info.plist after copy invalidates the inherited signature on its own — nothing stripped by hand), must be rejected by checkNow(): updateAvailable stays nil and statusMessage is the exact "Update is not signed by MMD" text. (b) staged-signed — codesign --force --deep --sign the same bundle (SHOTDECK_SELFTEST_SIGN_IDENTITY or the default Apple Development identity), re-zip, re-serve the same appcast path; must now stage. (c) atomic-install — installStaged into a throwaway /Applications (never real /Applications) pre-populated with a copy of the actually running app; asserts the target lands on 99.0.0, Redline.app.previous holds the original version, and no replacement-directory cruft is left beside them. (d) revert — revertToPrevious swaps the rollback copy back in; asserts the target is back to the original version and .previous now holds 99.0.0. Caught a real bug while wiring (d): replaceItemAt(target, withItemAt: previousURL, backupItemName: "Redline.app.previous") self-clobbers, because the backup name and the withItemAt source resolve to the same path — the backup write lands before the swap ever reads it, so target ends up unchanged. Fixed in UpdateChecker by staging previousURL through a throwaway ditto copy first (same pattern installStaged already used). Every existing phase (PICKER-SELFTEST, REGION-PERSIST, SEND-TRUTH, and the final "UPDATE-SELFTEST PASS version=99.0.0") is unchanged and still prints. Co-Authored-By: Claude Fable 5.1 --- Sources/Shotdeck/PickerSelfTest.swift | 168 ++++++++++++++++++++------ 1 file changed, 134 insertions(+), 34 deletions(-) diff --git a/Sources/Shotdeck/PickerSelfTest.swift b/Sources/Shotdeck/PickerSelfTest.swift index b30c703..aa5de7c 100644 --- a/Sources/Shotdeck/PickerSelfTest.swift +++ b/Sources/Shotdeck/PickerSelfTest.swift @@ -325,6 +325,9 @@ enum PickerSelfTest { return true } + /// (a) rejects an invalidly-signed payload, (b) stages the same payload once + /// properly signed, (c) installs it atomically into a throwaway target with + /// exactly one rollback copy, (d) reverts back. Never touches `/Applications`. private static func runUpdateSelfTest(outputDirectory: URL) async throws { let fm = FileManager.default try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true) @@ -332,6 +335,9 @@ enum PickerSelfTest { guard let sourceApp = ownAppBundleURL() else { throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))") } + guard let originalVersion = readShortVersion(atAppURL: sourceApp) else { + throw UpdateSelfTestError.detail("own Info.plist has no CFBundleShortVersionString") + } let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true) if fm.fileExists(atPath: payload.path) { @@ -349,32 +355,37 @@ enum PickerSelfTest { plist["CFBundleShortVersionString"] = "99.0.0" let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) try rewritten.write(to: plistURL) + // Editing Info.plist after copying it invalidates the inherited signature — + // Info.plist is a sealed special slot in the CodeDirectory — so this fake + // bundle is genuinely unsigned-in-effect without us stripping anything. let zipURL = outputDirectory.appendingPathComponent("Redline-99.0.0.zip") - if fm.fileExists(atPath: zipURL.path) { - try fm.removeItem(at: zipURL) - } - try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path]) - - let zipData = try Data(contentsOf: zipURL) - let hex = UpdateChecker.sha256Hex(zipData) - let appcastURL = outputDirectory.appendingPathComponent("appcast.json") - let appcast: [String: String] = [ - "version": "99.0.0", - "zipURL": zipURL.absoluteString, - "sha256": hex, - "notes": "UPDATE-SELFTEST", - ] - let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys]) - try appcastData.write(to: appcastURL) + + func writeZipAndAppcast() throws { + if fm.fileExists(atPath: zipURL.path) { + try fm.removeItem(at: zipURL) + } + try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path]) + let zipData = try Data(contentsOf: zipURL) + let hex = UpdateChecker.sha256Hex(zipData) + let appcast: [String: String] = [ + "version": "99.0.0", + "zipURL": zipURL.absoluteString, + "sha256": hex, + "notes": "UPDATE-SELFTEST", + ] + let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys]) + try appcastData.write(to: appcastURL) + } + try writeZipAndAppcast() let defaults = UserDefaults.standard - let previous = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey) + let previousAppcastPref = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey) defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey) defer { - if let previous { - defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey) + if let previousAppcastPref { + defaults.set(previousAppcastPref, forKey: UpdateChecker.appcastURLDefaultsKey) } else { defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey) } @@ -383,39 +394,128 @@ enum PickerSelfTest { let (model, isolatedRoot) = try makeIsolatedUpdateModel() defer { try? fm.removeItem(at: isolatedRoot) } + // (a) NEGATIVE — invalidly-signed payload must never be offered or staged. await model.updateChecker.checkNow() + guard model.updateAvailable == nil else { + throw UpdateSelfTestError.detail( + "reject-unsigned: updateAvailable=\(model.updateAvailable?.version ?? "nil") (expected nil)" + ) + } + guard model.updateChecker.statusMessage == "Update is not signed by MMD — not installed." else { + throw UpdateSelfTestError.detail( + "reject-unsigned: statusMessage=\(model.updateChecker.statusMessage ?? "nil")" + ) + } + print("UPDATE-SELFTEST reject-unsigned PASS") + fflush(stdout) + // (b) POSITIVE — re-sign the same bundle, re-zip, re-serve; must now stage. + let signIdentity = ProcessInfo.processInfo.environment["SHOTDECK_SELFTEST_SIGN_IDENTITY"] + ?? "Apple Development: ben@flow-master.ai (QH2H9G2LK5)" + try runCodesign(identity: signIdentity, path: fakeApp.path) + try writeZipAndAppcast() + + await model.updateChecker.checkNow() guard model.updateAvailable?.version == "99.0.0" else { throw UpdateSelfTestError.detail( - "updateAvailable=\(model.updateAvailable?.version ?? "nil")" + "staged-signed: updateAvailable=\(model.updateAvailable?.version ?? "nil")" ) } guard let staged = model.updateChecker.stagedAppURL else { - throw UpdateSelfTestError.detail("staged payload missing") + throw UpdateSelfTestError.detail("staged-signed: staged payload missing") } guard staged.lastPathComponent == "Redline.app" else { - throw UpdateSelfTestError.detail("staged name \(staged.lastPathComponent)") + throw UpdateSelfTestError.detail("staged-signed: staged name \(staged.lastPathComponent)") } let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck") guard fm.fileExists(atPath: stagedExe.path) else { - throw UpdateSelfTestError.detail("staged Contents/MacOS/Shotdeck missing") + throw UpdateSelfTestError.detail("staged-signed: staged Contents/MacOS/Shotdeck missing") } + print("UPDATE-SELFTEST staged-signed PASS") + fflush(stdout) - let targetRoot = outputDirectory.appendingPathComponent("target", isDirectory: true) - if fm.fileExists(atPath: targetRoot.path) { - try fm.removeItem(at: targetRoot) + // (c) ATOMIC INSTALL — a throwaway target pre-populated with the real + // running version; never `/Applications`. + let tempAppsRoot = outputDirectory.appendingPathComponent("Applications", isDirectory: true) + if fm.fileExists(atPath: tempAppsRoot.path) { + try fm.removeItem(at: tempAppsRoot) } - let target = targetRoot.appendingPathComponent("Redline.app") - model.updateChecker.installStaged(to: target) + try fm.createDirectory(at: tempAppsRoot, withIntermediateDirectories: true) + let tempTarget = tempAppsRoot.appendingPathComponent("Redline.app") + try fm.copyItem(at: sourceApp, to: tempTarget) - let installedPlist = target.appendingPathComponent("Contents/Info.plist") - guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any], - let installedVersion = installed["CFBundleShortVersionString"] as? String - else { - throw UpdateSelfTestError.detail("installed Info.plist unreadable") + model.updateChecker.installStaged(to: tempTarget) + + guard let installedVersion = readShortVersion(atAppURL: tempTarget) else { + throw UpdateSelfTestError.detail("atomic-install: installed Info.plist unreadable") } guard installedVersion == "99.0.0" else { - throw UpdateSelfTestError.detail("installed version \(installedVersion)") + throw UpdateSelfTestError.detail("atomic-install: installed version \(installedVersion)") + } + let previousCopy = tempAppsRoot.appendingPathComponent("Redline.app.previous") + guard let previousVersionAfterInstall = readShortVersion(atAppURL: previousCopy) else { + throw UpdateSelfTestError.detail("atomic-install: Redline.app.previous missing or unreadable") + } + guard previousVersionAfterInstall == originalVersion else { + throw UpdateSelfTestError.detail( + "atomic-install: previous version=\(previousVersionAfterInstall) expected=\(originalVersion)" + ) + } + try assertNoLeftoverEntries(in: tempAppsRoot, expecting: ["Redline.app", "Redline.app.previous"]) + print("UPDATE-SELFTEST atomic-install PASS") + fflush(stdout) + + // (d) REVERT — the rollback copy swaps back in; the just-replaced version + // becomes the new rollback copy, so a revert is itself reversible. + model.updateChecker.revertToPrevious(target: tempTarget) + + guard let revertedVersion = readShortVersion(atAppURL: tempTarget) else { + throw UpdateSelfTestError.detail("revert: reverted Info.plist unreadable") + } + guard revertedVersion == originalVersion else { + throw UpdateSelfTestError.detail("revert: target version=\(revertedVersion) expected=\(originalVersion)") + } + guard let previousVersionAfterRevert = readShortVersion(atAppURL: previousCopy) else { + throw UpdateSelfTestError.detail("revert: Redline.app.previous missing or unreadable") + } + guard previousVersionAfterRevert == "99.0.0" else { + throw UpdateSelfTestError.detail( + "revert: previous version=\(previousVersionAfterRevert) expected=99.0.0" + ) + } + try assertNoLeftoverEntries(in: tempAppsRoot, expecting: ["Redline.app", "Redline.app.previous"]) + print("UPDATE-SELFTEST revert PASS") + fflush(stdout) + } + + private static func readShortVersion(atAppURL url: URL) -> String? { + let plistURL = url.appendingPathComponent("Contents/Info.plist") + guard let dict = NSDictionary(contentsOf: plistURL) as? [String: Any] else { return nil } + return dict["CFBundleShortVersionString"] as? String + } + + private static func runCodesign(identity: String, path: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + process.arguments = ["--force", "--deep", "--sign", identity, path] + let err = Pipe() + process.standardError = err + process.standardOutput = Pipe() + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw UpdateSelfTestError.detail("codesign failed: \(message)") + } + } + + private static func assertNoLeftoverEntries(in directory: URL, expecting expected: Set) throws { + let entries = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] + let unexpected = entries.filter { !expected.contains($0) } + guard unexpected.isEmpty else { + throw UpdateSelfTestError.detail( + "unexpected entries in \(directory.path): \(unexpected.joined(separator: ", "))" + ) } } From 9884c844d4619a73b3204cbd1f38108a8ec5db60 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Sat, 5 Sep 2026 10:00:41 +0400 Subject: [PATCH 4/7] =?UTF-8?q?release:=20publish-update.sh=20=E2=80=94=20?= =?UTF-8?q?resolve=20a=20real=20signing=20identity,=20notarize,=20record?= =?UTF-8?q?=20it=20in=20the=20appcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SIGN_IDENTITY now comes from REDLINE_SIGN_IDENTITY, else the first "Developer ID Application" identity in the keychain (REDLINE_KEYCHAIN adds --keychain everywhere it's searched/used), else the existing Apple Development identity with a loud WARNING that Gatekeeper will block first install elsewhere. build-app.sh still has to sign first with its own hardcoded Apple Development identity (that pair is what keeps the Screen Recording grant alive) — this script now re-signs the resulting bundle with the resolved identity, --options runtime --timestamp, before zipping. Notarization is optional: set REDLINE_NOTARY_PROFILE (a notarytool keychain profile) or all three of REDLINE_NOTARY_KEY_ID/_ISSUER/_KEY_PATH, and after the zip is built the script submits it, waits, and on Accepted staples Redline.app, rebuilds the zip and DMG from the stapled app (a new build_dmg() that ditto-copies whatever is already at .build/Redline.app rather than re-invoking make-dmg.sh, which would rebuild from source and strip both the resolved signature and the staple), staples the DMG, and requires `spctl -a -vv -t exec` to say "accepted" or the script aborts. Absent notary config: prints NOT NOTARIZED and continues exactly as before. appcast.json gains "notarized" and "teamIdentifier" (from codesign -dv); confirmed UpdateChecker's Appcast Decodable already ignores unknown JSON keys (verified with a standalone decode), so no app-side change was needed for old appcasts to keep working. Ends with a summary block: identity used, notarized yes/no, spctl verdict, team, sha256. --test dry-run behaviour (upload to .../test/, skip the git commit) is unchanged. Known gap, out of scope here: build-app.sh's own pre-sign step still hard- requires its hardcoded Apple Development identity in the keychain even when a Developer ID identity is what will actually ship — untouched per the task boundary (this file only). Co-Authored-By: Claude Fable 5.1 --- scripts/publish-update.sh | 233 +++++++++++++++++++++++++++++++++----- 1 file changed, 204 insertions(+), 29 deletions(-) diff --git a/scripts/publish-update.sh b/scripts/publish-update.sh index ca6550d..6a636d5 100755 --- a/scripts/publish-update.sh +++ b/scripts/publish-update.sh @@ -13,7 +13,10 @@ PLISTBUDDY="/usr/libexec/PlistBuddy" REMOTE_HOST="mmd01" REMOTE_BASE="/opt/mmd-installer-content/cowork/redline" PUBLIC_BASE="https://get.baobab-ts.com/cowork/redline" -SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)" +BUNDLE_ID="ai.flowmaster.shotdeck" +# Used both as the fallback signing identity and as what build-app.sh itself +# still hardcodes for its own (pre-final) signing pass. +FALLBACK_SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)" usage() { echo "Usage: $0 [\"notes\"]" >&2 @@ -72,6 +75,7 @@ else PUBLIC_DIR="${PUBLIC_BASE}" fi +APP_BUNDLE="${ROOT}/.build/Redline.app" ZIP_NAME="Redline-${VERSION}.zip" DMG_NAME="Redline-${VERSION}.dmg" ZIP_PATH="${ROOT}/.build/${ZIP_NAME}" @@ -139,6 +143,38 @@ else echo "==> --test: skipping git commit of version bump" fi +# --- Resolve the signing identity for the shipped artifacts ----------------- +# REDLINE_KEYCHAIN (optional): a specific keychain to search/sign against, +# for hosts where the Developer ID identity does not live in the login +# keychain that codesign searches by default. +FIND_IDENTITY_ARGS=(-v -p codesigning) +CODESIGN_KEYCHAIN_ARGS=() +if [[ -n "${REDLINE_KEYCHAIN:-}" ]]; then + FIND_IDENTITY_ARGS+=("${REDLINE_KEYCHAIN}") + CODESIGN_KEYCHAIN_ARGS=(--keychain "${REDLINE_KEYCHAIN}") +fi + +if [[ -n "${REDLINE_SIGN_IDENTITY:-}" ]]; then + SIGN_IDENTITY="${REDLINE_SIGN_IDENTITY}" + echo "==> Signing identity: ${SIGN_IDENTITY} (REDLINE_SIGN_IDENTITY)" +else + DEVELOPER_ID_LINE="$(security find-identity "${FIND_IDENTITY_ARGS[@]}" 2>/dev/null \ + | grep -o '"Developer ID Application:[^"]*"' | head -n1 || true)" + DEVELOPER_ID="${DEVELOPER_ID_LINE//\"/}" + if [[ -n "${DEVELOPER_ID}" ]]; then + SIGN_IDENTITY="${DEVELOPER_ID}" + echo "==> Signing identity: ${SIGN_IDENTITY} (auto-detected Developer ID Application)" + else + SIGN_IDENTITY="${FALLBACK_SIGN_IDENTITY}" + echo + echo "************************************************************************" + echo "WARNING: signing with Apple Development identity — not Developer ID;" + echo "Gatekeeper will block first install on other Macs." + echo "************************************************************************" + echo + fi +fi + # Restricted HOMEs (agent sandboxes) hide the login keychain from codesign. # Re-run signed steps with the account's real home when the identity is missing. signing_home() { @@ -167,31 +203,85 @@ run_signed() { echo "==> Building signed Redline.app" run_signed ./scripts/build-app.sh -if [[ ! -d "${ROOT}/.build/Redline.app" ]]; then - echo "Signed app missing at ${ROOT}/.build/Redline.app" >&2 +if [[ ! -d "${APP_BUNDLE}" ]]; then + echo "Signed app missing at ${APP_BUNDLE}" >&2 exit 1 fi +# build-app.sh always signs with its own hardcoded Apple Development identity +# first (it has to — that identifier+identity pair is what keeps the Screen +# Recording grant alive). Re-sign here with the identity actually resolved +# above, which is what ships. A no-op when the two happen to be the same. +echo "==> Signing ${APP_BUNDLE} with resolved identity" +run_signed codesign --force --options runtime --timestamp \ + "${CODESIGN_KEYCHAIN_ARGS[@]}" \ + --sign "${SIGN_IDENTITY}" \ + --identifier "${BUNDLE_ID}" \ + "${APP_BUNDLE}" + +build_zip() { + mkdir -p "${ROOT}/.build" + ( + cd "${ROOT}/.build" + rm -f "${ZIP_NAME}" + ditto -c -k --keepParent Redline.app "${ZIP_NAME}" + ) + if [[ ! -s "${ZIP_PATH}" ]]; then + echo "Zip was not created at ${ZIP_PATH}" >&2 + exit 1 + fi +} + +# Rebuilds the manual-installer DMG from whatever is currently at +# ${APP_BUNDLE} — never re-invokes build-app.sh, so a prior custom signature +# or notarization staple on ${APP_BUNDLE} survives into the DMG untouched. +build_dmg() { + local staging="${ROOT}/.build/dmg-staging" + local mount_point="${ROOT}/.build/dmg-mnt" + + rm -rf "${staging}" + mkdir -p "${staging}" + ditto "${APP_BUNDLE}" "${staging}/Redline.app" + ln -s /Applications "${staging}/Applications" + + mkdir -p "$(dirname "${DMG_PATH}")" + rm -f "${DMG_PATH}" + hdiutil create -volname "Redline" -srcfolder "${staging}" -ov -format UDZO "${DMG_PATH}" + + if [[ -d "${mount_point}" ]] && /sbin/mount | grep -F -q "${mount_point}"; then + hdiutil detach "${mount_point}" || hdiutil detach "${mount_point}" -force + fi + rm -rf "${mount_point}" + mkdir -p "${mount_point}" + + hdiutil attach "${DMG_PATH}" -nobrowse -readonly -mountpoint "${mount_point}" + + local ok=1 + if [[ ! -d "${mount_point}/Redline.app" ]]; then + echo "Verification failed: Redline.app missing from mounted DMG" >&2 + ok=0 + fi + if [[ "${ok}" -eq 1 && "$(readlink "${mount_point}/Applications" 2>/dev/null || true)" != "/Applications" ]]; then + echo "Verification failed: Applications does not point at /Applications" >&2 + ok=0 + fi + if [[ "${ok}" -eq 1 ]] && ! codesign --verify --deep --verbose=2 "${mount_point}/Redline.app"; then + ok=0 + fi + + hdiutil detach "${mount_point}" || hdiutil detach "${mount_point}" -force || true + + if [[ "${ok}" -ne 1 ]]; then + exit 1 + fi + if [[ ! -s "${DMG_PATH}" ]]; then + echo "DMG was not created at ${DMG_PATH}" >&2 + exit 1 + fi +} + echo "==> Zipping Redline.app -> ${ZIP_PATH}" -mkdir -p "${ROOT}/.build" -( - cd "${ROOT}/.build" - rm -f "${ZIP_NAME}" - ditto -c -k --keepParent Redline.app "${ZIP_NAME}" -) - -if [[ ! -s "${ZIP_PATH}" ]]; then - echo "Zip was not created at ${ZIP_PATH}" >&2 - exit 1 -fi - -echo "==> Building manual installer DMG" -run_signed ./scripts/make-dmg.sh - -if [[ ! -s "${DMG_PATH}" ]]; then - echo "DMG was not created at ${DMG_PATH}" >&2 - exit 1 -fi +build_zip SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')" ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")" @@ -200,18 +290,95 @@ PUBDATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" echo "==> Zip SHA256: ${SHA256}" echo " Zip bytes: ${ZIP_BYTES}" +# --- Notarization (optional) ------------------------------------------------- +# Either REDLINE_NOTARY_PROFILE (a `notarytool store-credentials` keychain +# profile) or all three of REDLINE_NOTARY_KEY_ID / REDLINE_NOTARY_ISSUER / +# REDLINE_NOTARY_KEY_PATH (App Store Connect API key). Absent both: skip. +NOTARIZED=0 +NOTARY_CONFIGURED=0 +if [[ -n "${REDLINE_NOTARY_PROFILE:-}" ]]; then + NOTARY_CONFIGURED=1 +elif [[ -n "${REDLINE_NOTARY_KEY_ID:-}" && -n "${REDLINE_NOTARY_ISSUER:-}" && -n "${REDLINE_NOTARY_KEY_PATH:-}" ]]; then + NOTARY_CONFIGURED=1 +fi + +if [[ "${NOTARY_CONFIGURED}" -eq 1 ]]; then + echo "==> Submitting ${ZIP_PATH} to notarytool" + NOTARY_ARGS=(xcrun notarytool submit "${ZIP_PATH}" --wait --timeout 30m) + if [[ -n "${REDLINE_NOTARY_PROFILE:-}" ]]; then + NOTARY_ARGS+=(--keychain-profile "${REDLINE_NOTARY_PROFILE}") + else + NOTARY_ARGS+=( + --key "${REDLINE_NOTARY_KEY_PATH}" + --key-id "${REDLINE_NOTARY_KEY_ID}" + --issuer "${REDLINE_NOTARY_ISSUER}" + ) + fi + + set +e + NOTARY_OUTPUT="$("${NOTARY_ARGS[@]}" 2>&1)" + NOTARY_STATUS=$? + set -e + echo "${NOTARY_OUTPUT}" + if [[ "${NOTARY_STATUS}" -ne 0 ]]; then + echo "notarytool submit failed (exit ${NOTARY_STATUS})." >&2 + exit 1 + fi + if ! grep -qi 'status: *Accepted' <<<"${NOTARY_OUTPUT}"; then + echo "notarytool did not report Accepted." >&2 + exit 1 + fi + NOTARIZED=1 + + echo "==> Stapling ${APP_BUNDLE}" + xcrun stapler staple "${APP_BUNDLE}" + + echo "==> Rebuilding zip from the stapled app" + build_zip + SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')" + ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")" + echo " Zip SHA256: ${SHA256}" + echo " Zip bytes: ${ZIP_BYTES}" + + echo "==> Rebuilding DMG from the stapled app" + build_dmg + + echo "==> Stapling ${DMG_PATH}" + xcrun stapler staple "${DMG_PATH}" +else + echo "==> REDLINE_NOTARY_KEY_ID/ISSUER/KEY_PATH (or REDLINE_NOTARY_PROFILE) not set" + echo "NOT NOTARIZED" + echo "==> Building manual installer DMG" + build_dmg +fi + +echo "==> Gatekeeper check: spctl -a -vv -t exec ${APP_BUNDLE}" +set +e +SPCTL_OUTPUT="$(spctl -a -vv -t exec "${APP_BUNDLE}" 2>&1)" +SPCTL_STATUS=$? +set -e +echo "${SPCTL_OUTPUT}" +if [[ "${SPCTL_STATUS}" -ne 0 ]] || ! grep -qi 'accepted' <<<"${SPCTL_OUTPUT}"; then + echo "spctl did not report accepted for ${APP_BUNDLE}." >&2 + exit 1 +fi + +TEAM_IDENTIFIER="$(codesign -dv "${APP_BUNDLE}" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2}')" + echo "==> Writing ${APPCAST_PATH}" -python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" <<'PY' +python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" "${NOTARIZED}" "${TEAM_IDENTIFIER}" <<'PY' import json import sys -version, zip_url, sha256, notes, pub_date, out_path = sys.argv[1:] +version, zip_url, sha256, notes, pub_date, out_path, notarized, team_identifier = sys.argv[1:] payload = { "version": version, "zipURL": zip_url, "sha256": sha256, "notes": notes, "pubDate": pub_date, + "notarized": notarized == "1", + "teamIdentifier": team_identifier, } with open(out_path, "w", encoding="utf-8") as fh: json.dump(payload, fh, indent=2) @@ -280,8 +447,16 @@ fi echo echo "Published v${VERSION}" -echo " appcast: ${APPCAST_URL}" -echo " zip: ${ZIP_URL}" -echo " sha256: ${SHA256}" -echo " dmg: ${PUBLIC_DIR}/${DMG_NAME}" -echo " dmg: ${PUBLIC_DIR}/Redline.dmg" +echo " identity: ${SIGN_IDENTITY}" +if [[ "${NOTARIZED}" -eq 1 ]]; then + echo " notarized: yes" +else + echo " notarized: no" +fi +echo " spctl: ${SPCTL_OUTPUT}" +echo " team: ${TEAM_IDENTIFIER}" +echo " appcast: ${APPCAST_URL}" +echo " zip: ${ZIP_URL}" +echo " sha256: ${SHA256}" +echo " dmg: ${PUBLIC_DIR}/${DMG_NAME}" +echo " dmg: ${PUBLIC_DIR}/Redline.dmg" From bfdc6fde9d0e0d6ea68d7aa1e740cf1988294f9d Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 5 Sep 2026 10:03:40 +0400 Subject: [PATCH 5/7] release: spctl gate only hard-fails when the build was notarized Un-notarized fallback builds (Apple Development identity) are rejected by spctl by design; the script must still publish them with a warning, otherwise the fallback path can never release. Co-Authored-By: Claude Fable 5.1 --- scripts/publish-update.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/publish-update.sh b/scripts/publish-update.sh index 6a636d5..fb8c231 100755 --- a/scripts/publish-update.sh +++ b/scripts/publish-update.sh @@ -359,8 +359,11 @@ SPCTL_STATUS=$? set -e echo "${SPCTL_OUTPUT}" if [[ "${SPCTL_STATUS}" -ne 0 ]] || ! grep -qi 'accepted' <<<"${SPCTL_OUTPUT}"; then - echo "spctl did not report accepted for ${APP_BUNDLE}." >&2 - exit 1 + if [[ "${NOTARIZED}" == "true" ]]; then + echo "spctl did not report accepted for ${APP_BUNDLE} although it was notarized." >&2 + exit 1 + fi + echo "WARNING: Gatekeeper does not accept this build (not notarized). First install on other Macs needs right-click > Open." >&2 fi TEAM_IDENTIFIER="$(codesign -dv "${APP_BUNDLE}" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2}')" From 8a12ed0a54faf04b98c281d3c7935cf91575ff1b Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 5 Sep 2026 10:03:48 +0400 Subject: [PATCH 6/7] release: NOTARIZED is 0/1, compare numerically Co-Authored-By: Claude Fable 5.1 --- scripts/publish-update.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/publish-update.sh b/scripts/publish-update.sh index fb8c231..37c70d9 100755 --- a/scripts/publish-update.sh +++ b/scripts/publish-update.sh @@ -359,7 +359,7 @@ SPCTL_STATUS=$? set -e echo "${SPCTL_OUTPUT}" if [[ "${SPCTL_STATUS}" -ne 0 ]] || ! grep -qi 'accepted' <<<"${SPCTL_OUTPUT}"; then - if [[ "${NOTARIZED}" == "true" ]]; then + if [[ "${NOTARIZED}" -eq 1 ]]; then echo "spctl did not report accepted for ${APP_BUNDLE} although it was notarized." >&2 exit 1 fi From a32cd03581ed05fa668f92e4350585e81350e54b Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 5 Sep 2026 10:06:57 +0400 Subject: [PATCH 7/7] review: refuse to publish a bundle without a team identifier; document the allowed-teams override Co-Authored-By: Claude Fable 5.1 --- scripts/publish-update.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/publish-update.sh b/scripts/publish-update.sh index 37c70d9..4849151 100755 --- a/scripts/publish-update.sh +++ b/scripts/publish-update.sh @@ -367,6 +367,10 @@ if [[ "${SPCTL_STATUS}" -ne 0 ]] || ! grep -qi 'accepted' <<<"${SPCTL_OUTPUT}"; fi TEAM_IDENTIFIER="$(codesign -dv "${APP_BUNDLE}" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2}')" +if [[ -z "${TEAM_IDENTIFIER}" ]]; then + echo "No TeamIdentifier on ${APP_BUNDLE} — the build is not signed with a team identity; refusing to publish." >&2 + exit 1 +fi echo "==> Writing ${APPCAST_PATH}" python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" "${NOTARIZED}" "${TEAM_IDENTIFIER}" <<'PY'