diff --git a/Info.plist b/Info.plist
index 8717b5c..e64972c 100644
--- a/Info.plist
+++ b/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.1.0
+ 0.2.0
CFBundleVersion
1
CFBundleIconFile
diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift
index 3f40ba1..47897ad 100644
--- a/Sources/Shotdeck/AppModel.swift
+++ b/Sources/Shotdeck/AppModel.swift
@@ -41,6 +41,8 @@ public final class AppModel {
/// Currently bound capture combo (the last one Carbon accepted, or the preferred load).
private(set) var captureHotkey: HotkeyPreference
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)?
let paths: AppSupportPaths
let spool: SpoolStore
@@ -50,6 +52,7 @@ public final class AppModel {
let picker: RegionPickerController
let ledger: ReturnLedger
let watcher: ReturnWatcher
+ let updateChecker: UpdateChecker
public init(
paths: AppSupportPaths,
@@ -85,6 +88,14 @@ public final class AppModel {
self.outboxDisplayName = folders.outbox.lastPathComponent
self.watchFolderDisplayName = folders.watch.lastPathComponent
self.captureHotkey = HotkeyPreference.load()
+ self.updateChecker = UpdateChecker()
+ self.updateChecker.onChecked = { [weak self] in
+ guard let self else { return }
+ self.updateAvailable = self.updateChecker.availableUpdate
+ if let message = self.updateChecker.statusMessage {
+ self.setStatus(message)
+ }
+ }
}
// MARK: Seam mutators — the only way a WP-4b/4c extension changes state.
@@ -192,6 +203,20 @@ public final class AppModel {
if !bindCaptureHotkey(pref) {
setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.")
}
+
+ let skipSchedule =
+ ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil
+ || ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil
+ || ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
+ if !skipSchedule {
+ updateChecker.startSchedule()
+ }
+ }
+
+ /// Installs the staged update over `/Applications/Redline.app` and relaunches.
+ /// Does nothing unless the user clicked the menu row.
+ public func installUpdate() {
+ updateChecker.installStaged()
}
/// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new
diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift
index 83c054e..5b239cb 100644
--- a/Sources/Shotdeck/MenuBarView.swift
+++ b/Sources/Shotdeck/MenuBarView.swift
@@ -74,6 +74,15 @@ struct MenuBarView: View {
private var actionsList: some View {
VStack(alignment: .leading, spacing: 2) {
+ if let update = model.updateAvailable {
+ Button {
+ model.installUpdate()
+ } label: {
+ actionLabel("Update to \(update.version)")
+ .foregroundStyle(Color.accentColor)
+ }
+ }
+
Button {
let anchor = NSApp.keyWindow?.contentView
if let sender = model as? SendCapable {
diff --git a/Sources/Shotdeck/PickerSelfTest.swift b/Sources/Shotdeck/PickerSelfTest.swift
index 27e49c6..b30c703 100644
--- a/Sources/Shotdeck/PickerSelfTest.swift
+++ b/Sources/Shotdeck/PickerSelfTest.swift
@@ -116,7 +116,8 @@ enum PickerSelfTest {
runRegionPersistPhase()
// Hop off this MainActor job so the SEND-TRUTH Task can run; do not
- // exit(0) here — runSendTruthPhase exits when it finishes.
+ // exit(0) here — runSendTruthPhase prints its own PASS/FAIL, then
+ // chains to UPDATE-SELFTEST (or exits if that phase is not requested).
runSendTruthPhase()
}
@@ -165,14 +166,16 @@ enum PickerSelfTest {
/// Fail path must leave the session open in the temp spool; success path archives
/// and mints a fresh empty session. Scheduled as a new MainActor job because this
/// function is called from inside `execute()` — a nested run-loop wait would never
- /// let the Task start.
+ /// let the Task start. On success, chains to UPDATE-SELFTEST instead of exiting.
private static func runSendTruthPhase() {
Task { @MainActor in
do {
try await executeSendTruth()
print("SEND-TRUTH PASS")
fflush(stdout)
- exit(0)
+ if !startUpdateSelfTestIfRequested() {
+ exit(0)
+ }
} catch {
print("SEND-TRUTH FAIL \(error)")
fflush(stdout)
@@ -297,6 +300,179 @@ enum PickerSelfTest {
exit(1)
}
+ /// Phase 4: builds a fake 99.0.0 bundle, serves a local appcast, stages via
+ /// `checkNow`, then `installStaged` into the env dir — never `/Applications`.
+ /// Returns true when the async phase was scheduled (it calls `exit` itself).
+ @discardableResult
+ private static func startUpdateSelfTestIfRequested() -> Bool {
+ guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"],
+ !raw.isEmpty
+ else { return false }
+
+ let output = URL(fileURLWithPath: raw, isDirectory: true)
+ Task { @MainActor in
+ do {
+ try await runUpdateSelfTest(outputDirectory: output)
+ print("UPDATE-SELFTEST PASS version=99.0.0")
+ fflush(stdout)
+ exit(0)
+ } catch let error as UpdateSelfTestError {
+ updateFail(error.description)
+ } catch {
+ updateFail(String(describing: error))
+ }
+ }
+ return true
+ }
+
+ private static func runUpdateSelfTest(outputDirectory: URL) async throws {
+ let fm = FileManager.default
+ try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
+
+ guard let sourceApp = ownAppBundleURL() else {
+ throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))")
+ }
+
+ let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true)
+ if fm.fileExists(atPath: payload.path) {
+ try fm.removeItem(at: payload)
+ }
+ try fm.createDirectory(at: payload, withIntermediateDirectories: true)
+ let fakeApp = payload.appendingPathComponent("Redline.app")
+ try fm.copyItem(at: sourceApp, to: fakeApp)
+
+ let plistURL = fakeApp.appendingPathComponent("Contents/Info.plist")
+ let plistData = try Data(contentsOf: plistURL)
+ guard var plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] else {
+ throw UpdateSelfTestError.detail("could not parse copied Info.plist")
+ }
+ plist["CFBundleShortVersionString"] = "99.0.0"
+ let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
+ try rewritten.write(to: plistURL)
+
+ 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)
+
+ let defaults = UserDefaults.standard
+ let previous = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey)
+ defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey)
+ defer {
+ if let previous {
+ defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey)
+ } else {
+ defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey)
+ }
+ }
+
+ let (model, isolatedRoot) = try makeIsolatedUpdateModel()
+ defer { try? fm.removeItem(at: isolatedRoot) }
+
+ await model.updateChecker.checkNow()
+
+ guard model.updateAvailable?.version == "99.0.0" else {
+ throw UpdateSelfTestError.detail(
+ "updateAvailable=\(model.updateAvailable?.version ?? "nil")"
+ )
+ }
+ guard let staged = model.updateChecker.stagedAppURL else {
+ throw UpdateSelfTestError.detail("staged payload missing")
+ }
+ guard staged.lastPathComponent == "Redline.app" else {
+ throw UpdateSelfTestError.detail("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")
+ }
+
+ let targetRoot = outputDirectory.appendingPathComponent("target", isDirectory: true)
+ if fm.fileExists(atPath: targetRoot.path) {
+ try fm.removeItem(at: targetRoot)
+ }
+ let target = targetRoot.appendingPathComponent("Redline.app")
+ model.updateChecker.installStaged(to: target)
+
+ 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")
+ }
+ guard installedVersion == "99.0.0" else {
+ throw UpdateSelfTestError.detail("installed version \(installedVersion)")
+ }
+ }
+
+ private static func ownAppBundleURL() -> URL? {
+ let bundle = Bundle.main.bundleURL
+ if bundle.pathExtension == "app" { return bundle }
+ let up3 = bundle
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ if up3.pathExtension == "app" { return up3 }
+ return nil
+ }
+
+ private static func makeIsolatedUpdateModel() throws -> (AppModel, URL) {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("shotdeck-update-selftest-\(UUID().uuidString)", isDirectory: true)
+ let paths = try AppSupportPaths(
+ root: root,
+ outbox: root.appendingPathComponent("outbox", isDirectory: true),
+ watchFolder: root.appendingPathComponent("watch", isDirectory: true)
+ )
+ let ledger = try ReturnLedger(paths: paths)
+ let model = AppModel(
+ paths: paths,
+ spool: try SpoolStore(paths: paths),
+ composer: PDFComposer(),
+ capturer: ScreenCapturer(),
+ hotkeys: HotkeyCenter(),
+ picker: RegionPickerController(),
+ ledger: ledger,
+ watcher: ReturnWatcher(paths: paths, ledger: ledger)
+ )
+ return (model, root)
+ }
+
+ private static func runDitto(arguments: [String]) throws {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
+ process.arguments = arguments
+ 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("ditto failed: \(message)")
+ }
+ }
+
+ private static func updateFail(_ detail: String) -> Never {
+ print("UPDATE-SELFTEST FAIL \(detail)")
+ fflush(stdout)
+ exit(1)
+ }
+
private static func interpolate(_ step: Int) -> NSPoint {
let t = CGFloat(step) / CGFloat(dragSteps)
return NSPoint(
@@ -353,3 +529,12 @@ enum PickerSelfTest {
exit(1)
}
}
+
+private enum UpdateSelfTestError: Error, CustomStringConvertible {
+ case detail(String)
+ var description: String {
+ switch self {
+ case .detail(let s): return s
+ }
+ }
+}
diff --git a/Sources/Shotdeck/UpdateChecker.swift b/Sources/Shotdeck/UpdateChecker.swift
new file mode 100644
index 0000000..207f51a
--- /dev/null
+++ b/Sources/Shotdeck/UpdateChecker.swift
@@ -0,0 +1,281 @@
+import AppKit
+import CryptoKit
+import Foundation
+
+/// Built-in updater. Checks an appcast, stages a verified payload, and installs
+/// only when the user clicks the menu row — never automatically.
+@MainActor
+final class UpdateChecker {
+ static let appcastURLDefaultsKey = "ai.flowmaster.shotdeck.appcastURL"
+ static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")!
+ static let defaultInstallTarget = URL(fileURLWithPath: "/Applications/Redline.app")
+
+ private(set) var availableUpdate: (version: String, notes: String)?
+ private(set) var stagedAppURL: URL?
+ private(set) var statusMessage: String?
+
+ var onChecked: (() -> 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.httpCookieAcceptPolicy = .never
+ config.httpShouldSetCookies = false
+ config.httpCookieStorage = nil
+ config.urlCache = nil
+ urlSession = URLSession(configuration: config)
+ }
+
+ /// First check 10 seconds after start, then every 6 hours. Stages only — never installs.
+ func startSchedule() {
+ firstCheckTask?.cancel()
+ firstCheckTask = Task { [weak self] in
+ try? await Task.sleep(for: .seconds(10))
+ guard !Task.isCancelled else { return }
+ await self?.checkNow()
+ }
+ repeatingTimer?.invalidate()
+ let timer = Timer(timeInterval: 6 * 60 * 60, repeats: true) { [weak self] _ in
+ Task { @MainActor in
+ await self?.checkNow()
+ }
+ }
+ RunLoop.main.add(timer, forMode: .common)
+ repeatingTimer = timer
+ }
+
+ func checkNow() async {
+ guard !isChecking else { return }
+ isChecking = true
+ defer { isChecking = false }
+
+ let appcast: Appcast
+ do {
+ appcast = try await fetchAppcast()
+ } catch {
+ statusMessage = "Could not check for updates."
+ onChecked?()
+ return
+ }
+
+ guard Self.isNewer(appcast.version, than: Self.currentVersion()) else {
+ clearOffer()
+ statusMessage = nil
+ onChecked?()
+ return
+ }
+
+ do {
+ try await downloadAndStage(appcast)
+ availableUpdate = (version: appcast.version, notes: appcast.notes ?? "")
+ statusMessage = nil
+ } catch UpdateCheckError.checksumMismatch {
+ discardStaging()
+ availableUpdate = nil
+ statusMessage = "Update file failed the checksum — not installed."
+ } catch {
+ discardStaging()
+ availableUpdate = nil
+ statusMessage = "The update could not be prepared."
+ }
+ 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.
+ func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) {
+ guard let staged = stagedAppURL else {
+ statusMessage = "No update is staged."
+ onChecked?()
+ return
+ }
+
+ do {
+ try FileManager.default.createDirectory(
+ at: target.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, target.path])
+ } catch {
+ statusMessage = "The update could not be installed."
+ onChecked?()
+ return
+ }
+
+ let isSelfTest = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
+ if isSelfTest { return }
+
+ do {
+ try Self.runProcess(executable: "/usr/bin/open", arguments: ["-n", target.path])
+ } catch {
+ statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications."
+ onChecked?()
+ return
+ }
+ NSApp.terminate(nil)
+ }
+
+ static func resolvedAppcastURL() -> URL {
+ if let env = ProcessInfo.processInfo.environment["REDLINE_APPCAST_URL"],
+ !env.isEmpty,
+ let url = URL(string: env)
+ {
+ return url
+ }
+ if let stored = UserDefaults.standard.string(forKey: appcastURLDefaultsKey),
+ !stored.isEmpty,
+ let url = URL(string: stored)
+ {
+ return url
+ }
+ return defaultAppcastURL
+ }
+
+ static func currentVersion() -> String {
+ Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
+ }
+
+ static func isNewer(_ candidate: String, than current: String) -> Bool {
+ let a = semverParts(candidate)
+ let b = semverParts(current)
+ for i in 0..<3 {
+ if a[i] != b[i] { return a[i] > b[i] }
+ }
+ return false
+ }
+
+ static func sha256Hex(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ // MARK: - Private
+
+ private struct Appcast: Decodable {
+ var version: String
+ var zipURL: URL
+ var sha256: String
+ var notes: String?
+ }
+
+ private enum UpdateCheckError: Error {
+ case checksumMismatch
+ case invalidPayload
+ case httpStatus(Int)
+ case processFailed(String)
+ }
+
+ private func fetchAppcast() async throws -> Appcast {
+ let data = try await fetchData(from: Self.resolvedAppcastURL())
+ return try JSONDecoder().decode(Appcast.self, from: data)
+ }
+
+ private func fetchData(from url: URL) async throws -> Data {
+ if url.isFileURL {
+ return try Data(contentsOf: url)
+ }
+ let (data, response) = try await urlSession.data(from: url)
+ if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
+ throw UpdateCheckError.httpStatus(http.statusCode)
+ }
+ return data
+ }
+
+ private func downloadAndStage(_ appcast: Appcast) async throws {
+ let zipData = try await fetchData(from: appcast.zipURL)
+ let expected = appcast.sha256.trimmingCharacters(in: .whitespacesAndNewlines)
+ let actual = Self.sha256Hex(zipData)
+ guard actual.caseInsensitiveCompare(expected) == .orderedSame else {
+ throw UpdateCheckError.checksumMismatch
+ }
+
+ discardStaging()
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("shotdeck-update-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ stagingDirectory = root
+
+ let zipURL = root.appendingPathComponent("update.zip")
+ try zipData.write(to: zipURL)
+
+ let extracted = root.appendingPathComponent("extracted", isDirectory: true)
+ try FileManager.default.createDirectory(at: extracted, withIntermediateDirectories: true)
+ try Self.runProcess(
+ executable: "/usr/bin/ditto",
+ arguments: ["-x", "-k", zipURL.path, extracted.path]
+ )
+
+ guard let appURL = Self.findRedlineApp(in: extracted) else {
+ throw UpdateCheckError.invalidPayload
+ }
+ let executable = appURL.appendingPathComponent("Contents/MacOS/Shotdeck")
+ guard FileManager.default.fileExists(atPath: executable.path) else {
+ throw UpdateCheckError.invalidPayload
+ }
+ stagedAppURL = appURL
+ }
+
+ private func clearOffer() {
+ availableUpdate = nil
+ discardStaging()
+ }
+
+ private func discardStaging() {
+ if let stagingDirectory {
+ try? FileManager.default.removeItem(at: stagingDirectory)
+ }
+ stagingDirectory = nil
+ stagedAppURL = nil
+ }
+
+ private static func findRedlineApp(in directory: URL) -> URL? {
+ let fm = FileManager.default
+ let direct = directory.appendingPathComponent("Redline.app")
+ if fm.fileExists(atPath: direct.path) { return direct }
+
+ guard let enumerator = fm.enumerator(
+ at: directory,
+ includingPropertiesForKeys: [.isDirectoryKey],
+ options: [.skipsHiddenFiles]
+ ) else { return nil }
+
+ while let item = enumerator.nextObject() as? URL {
+ if item.lastPathComponent == "Redline.app" {
+ return item
+ }
+ if item.pathExtension == "app" {
+ enumerator.skipDescendants()
+ }
+ }
+ return nil
+ }
+
+ private static func semverParts(_ string: String) -> [Int] {
+ let core = string.split(separator: "-").first.map(String.init) ?? string
+ var parts = core.split(separator: ".").prefix(3).map { Int($0) ?? 0 }
+ while parts.count < 3 { parts.append(0) }
+ return parts
+ }
+
+ private static func runProcess(executable: String, arguments: [String]) throws {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: executable)
+ process.arguments = arguments
+ 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 UpdateCheckError.processFailed("\(executable) failed: \(message)")
+ }
+ }
+}
diff --git a/scripts/publish-update.sh b/scripts/publish-update.sh
new file mode 100755
index 0000000..ca6550d
--- /dev/null
+++ b/scripts/publish-update.sh
@@ -0,0 +1,287 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# One-command Redline release: bump Info.plist, commit, signed build, zip,
+# DMG, appcast, upload to mmd01, verify the public URLs.
+# Hidden flag: --test — upload under .../redline/test/ and skip the git commit.
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${ROOT}"
+
+PLIST="${ROOT}/Info.plist"
+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)"
+
+usage() {
+ echo "Usage: $0 [\"notes\"]" >&2
+ exit 1
+}
+
+TEST_MODE=0
+VERSION=""
+NOTES=""
+NOTES_SET=0
+for arg in "$@"; do
+ case "${arg}" in
+ --test)
+ TEST_MODE=1
+ ;;
+ --help|-h)
+ usage
+ ;;
+ --*)
+ echo "Unknown argument: ${arg}" >&2
+ usage
+ ;;
+ *)
+ if [[ -z "${VERSION}" ]]; then
+ VERSION="${arg}"
+ elif [[ "${NOTES_SET}" -eq 0 ]]; then
+ NOTES="${arg}"
+ NOTES_SET=1
+ else
+ echo "Unexpected extra argument: ${arg}" >&2
+ usage
+ fi
+ ;;
+ esac
+done
+
+if [[ -z "${VERSION}" ]]; then
+ usage
+fi
+
+if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][A-Za-z0-9.-]+)*$ ]]; then
+ echo "Version '${VERSION}' is not a semver (e.g. 1.2.3 or 0.0.0-test)." >&2
+ exit 1
+fi
+
+if [[ "${VERSION}" == *'/'* || "${VERSION}" == *'..'* ]]; then
+ echo "Version contains illegal path characters: ${VERSION}" >&2
+ exit 1
+fi
+
+if [[ "${TEST_MODE}" -eq 1 ]]; then
+ REMOTE_DIR="${REMOTE_BASE}/test"
+ PUBLIC_DIR="${PUBLIC_BASE}/test"
+else
+ REMOTE_DIR="${REMOTE_BASE}"
+ PUBLIC_DIR="${PUBLIC_BASE}"
+fi
+
+ZIP_NAME="Redline-${VERSION}.zip"
+DMG_NAME="Redline-${VERSION}.dmg"
+ZIP_PATH="${ROOT}/.build/${ZIP_NAME}"
+DMG_PATH="${ROOT}/.build/Redline.dmg"
+APPCAST_PATH="${ROOT}/.build/appcast.json"
+ZIP_URL="${PUBLIC_DIR}/${ZIP_NAME}"
+APPCAST_URL="${PUBLIC_DIR}/appcast.json"
+
+if [[ "${TEST_MODE}" -eq 1 ]]; then
+ echo "==> Publish Redline ${VERSION} (test)"
+else
+ echo "==> Publish Redline ${VERSION}"
+fi
+echo " remote: ${REMOTE_HOST}:${REMOTE_DIR}/"
+echo " public: ${PUBLIC_DIR}/"
+
+if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ echo "Not inside a git work tree." >&2
+ exit 1
+fi
+
+# Tracked files must match HEAD. Untracked files are ignored so this script
+# can be dry-run (--test) before it is itself committed.
+if [[ -n "$(git status --porcelain -uno)" ]]; then
+ echo "git tree is not clean; commit or stash before publishing." >&2
+ git status --porcelain -uno >&2
+ exit 1
+fi
+
+if [[ ! -x "${PLISTBUDDY}" ]]; then
+ echo "PlistBuddy not found at ${PLISTBUDDY}" >&2
+ exit 1
+fi
+if [[ ! -f "${PLIST}" ]]; then
+ echo "Info.plist not found at ${PLIST}" >&2
+ exit 1
+fi
+
+restore_plist() {
+ git checkout -- "${PLIST}" >/dev/null 2>&1 || true
+}
+
+if [[ "${TEST_MODE}" -eq 1 ]]; then
+ trap restore_plist EXIT
+fi
+
+CURRENT_BUILD="$("${PLISTBUDDY}" -c 'Print :CFBundleVersion' "${PLIST}")"
+if [[ ! "${CURRENT_BUILD}" =~ ^[0-9]+$ ]]; then
+ echo "CFBundleVersion is not an integer: ${CURRENT_BUILD}" >&2
+ exit 1
+fi
+NEW_BUILD=$((CURRENT_BUILD + 1))
+
+echo "==> Bumping Info.plist"
+echo " CFBundleShortVersionString -> ${VERSION}"
+echo " CFBundleVersion ${CURRENT_BUILD} -> ${NEW_BUILD}"
+"${PLISTBUDDY}" -c "Set :CFBundleShortVersionString ${VERSION}" "${PLIST}"
+"${PLISTBUDDY}" -c "Set :CFBundleVersion ${NEW_BUILD}" "${PLIST}"
+
+if [[ "${TEST_MODE}" -eq 0 ]]; then
+ echo "==> Committing version bump on $(git rev-parse --abbrev-ref HEAD)"
+ git add "${PLIST}"
+ git commit -m "release: v${VERSION}"
+else
+ echo "==> --test: skipping git commit of version bump"
+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() {
+ if security find-identity -v -p codesigning 2>/dev/null | grep -Fq "${SIGN_IDENTITY}"; then
+ echo "${HOME}"
+ return
+ fi
+ local rh
+ rh="$(dscl . -read "/Users/$(id -un)" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
+ if [[ -n "${rh}" && -d "${rh}" ]]; then
+ echo "${rh}"
+ else
+ echo "${HOME}"
+ fi
+}
+
+run_signed() {
+ local sign_home
+ sign_home="$(signing_home)"
+ if [[ "${sign_home}" != "${HOME}" ]]; then
+ echo "==> Using HOME=${sign_home} so codesign can see the login keychain"
+ fi
+ HOME="${sign_home}" "$@"
+}
+
+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
+ 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
+
+SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')"
+ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")"
+PUBDATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
+
+echo "==> Zip SHA256: ${SHA256}"
+echo " Zip bytes: ${ZIP_BYTES}"
+
+echo "==> Writing ${APPCAST_PATH}"
+python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" <<'PY'
+import json
+import sys
+
+version, zip_url, sha256, notes, pub_date, out_path = sys.argv[1:]
+payload = {
+ "version": version,
+ "zipURL": zip_url,
+ "sha256": sha256,
+ "notes": notes,
+ "pubDate": pub_date,
+}
+with open(out_path, "w", encoding="utf-8") as fh:
+ json.dump(payload, fh, indent=2)
+ fh.write("\n")
+PY
+
+echo "==> Uploading to ${REMOTE_HOST}:${REMOTE_DIR}/"
+ssh -o BatchMode=yes "${REMOTE_HOST}" "mkdir -p '${REMOTE_DIR}'"
+rsync -e "ssh -o BatchMode=yes" -av "${ZIP_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${ZIP_NAME}"
+rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/Redline.dmg"
+rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${DMG_NAME}"
+rsync -e "ssh -o BatchMode=yes" -av "${APPCAST_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/appcast.json"
+ssh -o BatchMode=yes "${REMOTE_HOST}" \
+ "chmod 644 \
+ '${REMOTE_DIR}/${ZIP_NAME}' \
+ '${REMOTE_DIR}/Redline.dmg' \
+ '${REMOTE_DIR}/${DMG_NAME}' \
+ '${REMOTE_DIR}/appcast.json'"
+
+echo "==> Verifying public appcast ${APPCAST_URL}"
+APPCAST_BODY=""
+ok=0
+attempt=1
+while [[ "${attempt}" -le 15 ]]; do
+ if APPCAST_BODY="$(curl -fsS "${APPCAST_URL}")"; then
+ echo "${APPCAST_BODY}"
+ if grep -F -q "${VERSION}" <<<"${APPCAST_BODY}"; then
+ echo "OK: appcast contains ${VERSION}"
+ ok=1
+ break
+ fi
+ echo "appcast fetched but does not contain '${VERSION}' (attempt ${attempt})" >&2
+ else
+ echo "appcast fetch failed (attempt ${attempt})" >&2
+ fi
+ attempt=$((attempt + 1))
+ sleep 2
+done
+if [[ "${ok}" -ne 1 ]]; then
+ echo "Public appcast verification failed for ${APPCAST_URL}" >&2
+ exit 1
+fi
+
+echo "==> Verifying public zip HEAD ${ZIP_URL}"
+ok=0
+attempt=1
+HEAD_OUT=""
+while [[ "${attempt}" -le 15 ]]; do
+ HEAD_OUT="$(curl -sS -D - -o /dev/null -I "${ZIP_URL}" || true)"
+ echo "${HEAD_OUT}"
+ HTTP_CODE="$(awk 'BEGIN{c=""} toupper($1) ~ /^HTTP\//{c=$2} END{print c}' <<<"${HEAD_OUT}" | tr -d '\r')"
+ CONTENT_LENGTH="$(awk 'tolower($1)=="content-length:" {gsub("\r","",$2); print $2}' <<<"${HEAD_OUT}" | tail -n 1)"
+ if [[ "${HTTP_CODE}" == "200" && "${CONTENT_LENGTH}" == "${ZIP_BYTES}" ]]; then
+ echo "OK: zip HTTP ${HTTP_CODE}, Content-Length ${CONTENT_LENGTH} matches local ${ZIP_BYTES}"
+ ok=1
+ break
+ fi
+ echo "zip HEAD mismatch (attempt ${attempt}): HTTP '${HTTP_CODE}', Content-Length '${CONTENT_LENGTH}', local '${ZIP_BYTES}'" >&2
+ attempt=$((attempt + 1))
+ sleep 2
+done
+if [[ "${ok}" -ne 1 ]]; then
+ echo "Public zip verification failed for ${ZIP_URL}" >&2
+ exit 1
+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"