diff --git a/README.md b/README.md index 8fe475e..b90a5e1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Simple Mac menu bar app and CLI for managing GitHub Actions self-hosted runners. - ๐Ÿ“Š Monitor runner status from menu bar - โšก Native Mac app, lightweight and fast - ๐Ÿค– **Fully automated setup** โ€” downloads and configures runners automatically +- ๐Ÿงน **Disk pressure cleanup** โ€” safely reclaim idle runner workspaces and CI caches ## Why? @@ -76,10 +77,22 @@ mac-runner remove my-runner # Status summary mac-runner status + +# Preview or run cleanup (active runner data is always skipped) +mac-runner cleanup --dry-run +mac-runner cleanup ``` Runners started via CLI persist in the background โ€” they survive the terminal session. Stop and start them from any terminal or from the GUI. +### Disk Cleanup + +GitHub Actions jobs can leave large workspaces and dependency caches behind. `mac-runner cleanup` removes the contents of stopped runners' `_work` directories plus known npm, SwiftPM, Homebrew, Go, Cargo, Gradle, and Xcode caches. If any runner is active, its workspace is skipped and shared caches are preserved. + +In Settings, enable **Clean CI Data When Disk Space Is Low** and choose a minimum free-space target. Mac Runner checks at most once per hour and only cleans when available space falls below that target. Automatic cleanup is off by default. + +Use `mac-runner cleanup --workspaces-only` to preserve all shared caches. + ## CI/CD: Self-Hosted Runner with Automatic Cloud Fallback Mac Runner uses a pattern that automatically routes CI jobs to your self-hosted Mac when it's online, and falls back to GitHub-hosted cloud runners when it's not. This means pushes to main always build, regardless of whether your Mac is on. diff --git a/Sources/Models/Runner.swift b/Sources/Models/Runner.swift index cad0984..b1589d5 100644 --- a/Sources/Models/Runner.swift +++ b/Sources/Models/Runner.swift @@ -249,6 +249,8 @@ struct AppSettings: Codable, Sendable { var autoCheckForUpdates: Bool var autoRestartEnabled: Bool var autoRestartMaxRetries: Int + var automaticDiskCleanupEnabled: Bool + var minimumFreeDiskSpaceGB: Int var openFileLimit: Int static let `default` = AppSettings( @@ -261,6 +263,8 @@ struct AppSettings: Codable, Sendable { autoCheckForUpdates: true, autoRestartEnabled: true, autoRestartMaxRetries: 5, + automaticDiskCleanupEnabled: false, + minimumFreeDiskSpaceGB: 100, openFileLimit: ResourceLimits.defaultOpenFileLimit ) @@ -274,6 +278,8 @@ struct AppSettings: Codable, Sendable { autoCheckForUpdates: Bool = true, autoRestartEnabled: Bool = true, autoRestartMaxRetries: Int = 5, + automaticDiskCleanupEnabled: Bool = false, + minimumFreeDiskSpaceGB: Int = 100, openFileLimit: Int = ResourceLimits.defaultOpenFileLimit ) { self.startOnLogin = startOnLogin @@ -285,6 +291,8 @@ struct AppSettings: Codable, Sendable { self.autoCheckForUpdates = autoCheckForUpdates self.autoRestartEnabled = autoRestartEnabled self.autoRestartMaxRetries = max(1, autoRestartMaxRetries) + self.automaticDiskCleanupEnabled = automaticDiskCleanupEnabled + self.minimumFreeDiskSpaceGB = max(1, minimumFreeDiskSpaceGB) self.openFileLimit = ResourceLimits.normalizedOpenFileLimit(openFileLimit) ?? ResourceLimits.defaultOpenFileLimit } @@ -301,6 +309,8 @@ struct AppSettings: Codable, Sendable { autoCheckForUpdates = try container.decodeIfPresent(Bool.self, forKey: .autoCheckForUpdates) ?? true autoRestartEnabled = try container.decodeIfPresent(Bool.self, forKey: .autoRestartEnabled) ?? true autoRestartMaxRetries = max(1, try container.decodeIfPresent(Int.self, forKey: .autoRestartMaxRetries) ?? 5) + automaticDiskCleanupEnabled = try container.decodeIfPresent(Bool.self, forKey: .automaticDiskCleanupEnabled) ?? false + minimumFreeDiskSpaceGB = max(1, try container.decodeIfPresent(Int.self, forKey: .minimumFreeDiskSpaceGB) ?? 100) openFileLimit = ResourceLimits.normalizedOpenFileLimit( try container.decodeIfPresent(Int.self, forKey: .openFileLimit) ) ?? ResourceLimits.defaultOpenFileLimit diff --git a/Sources/Services/CLIHandler.swift b/Sources/Services/CLIHandler.swift index 86b1925..d4c2e87 100644 --- a/Sources/Services/CLIHandler.swift +++ b/Sources/Services/CLIHandler.swift @@ -97,6 +97,8 @@ enum CLIHandler { await handleStatus() case "setup": await handleSetup(args: Array(args.dropFirst())) + case "cleanup": + await handleCleanup(args: Array(args.dropFirst())) default: print("Unknown command: \(command)") printUsage() @@ -124,6 +126,7 @@ enum CLIHandler { stop Stop a runner status Show runner status summary setup Set up dedicated user isolation + cleanup Remove idle runner workspaces and CI caches help Show this help message version Show version @@ -138,6 +141,10 @@ enum CLIHandler { SETUP OPTIONS: --teardown Remove isolation (delete user, sudoers, reset config) + CLEANUP OPTIONS: + --dry-run Show what would be removed + --workspaces-only Keep shared language, Homebrew, and Xcode caches + EXAMPLES: mac-runner auth mac-runner add owner/repo --name my-runner --labels macos,arm64 @@ -151,6 +158,7 @@ enum CLIHandler { mac-runner remove my-runner sudo mac-runner setup sudo mac-runner setup --teardown + mac-runner cleanup --dry-run """) } @@ -421,4 +429,28 @@ enum CLIHandler { await SetupWizard.runSetup() } } + + private static func handleCleanup(args: [String]) async { + let dryRun = args.contains("--dry-run") + let includeSharedCaches = !args.contains("--workspaces-only") + do { + let config = try ConfigService().loadConfig() + let report = try DiskCleanupService().cleanup( + runners: config.runners, + globalIsolationMode: config.settings.isolationMode, + includeSharedCaches: includeSharedCaches, + dryRun: dryRun + ) + let size = ByteCountFormatter.string(fromByteCount: report.reclaimedBytes, countStyle: .file) + print("\(dryRun ? "Would reclaim" : "Reclaimed") \(size) from \(report.removedPaths.count) item(s).") + if !report.skippedRunnerNames.isEmpty { + print("Skipped active runners: \(report.skippedRunnerNames.joined(separator: ", "))") + if includeSharedCaches { + print("Shared CI caches were preserved while runners are active.") + } + } + } catch { + print("Error: \(error.localizedDescription)") + } + } } diff --git a/Sources/Services/DiskCleanupService.swift b/Sources/Services/DiskCleanupService.swift new file mode 100644 index 0000000..d71d4ed --- /dev/null +++ b/Sources/Services/DiskCleanupService.swift @@ -0,0 +1,114 @@ +import Foundation + +struct DiskCleanupReport: Equatable { + let reclaimedBytes: Int64 + let removedPaths: [String] + let skippedRunnerNames: [String] + let dryRun: Bool +} + +struct DiskCleanupService { + private let fileManager: FileManager + private let homeDirectory: URL + + init( + fileManager: FileManager = .default, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) { + self.fileManager = fileManager + self.homeDirectory = homeDirectory + } + + func availableDiskBytes() -> Int64? { + let values = try? homeDirectory.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + return values?.volumeAvailableCapacityForImportantUsage + } + + func cleanup( + runners: [Runner], + globalIsolationMode: IsolationMode, + includeSharedCaches: Bool, + dryRun: Bool + ) throws -> DiskCleanupReport { + var candidates: [URL] = [] + var skipped: [String] = [] + + for runner in runners { + guard runner.status != .running && !runner.busy else { + skipped.append(runner.name) + continue + } + + let isolation = runner.effectiveIsolationMode(global: globalIsolationMode) + guard isolation != .container, + let runnerDirectory = try? RunnerDirectory.path(for: runner.id, isolation: isolation) else { + continue + } + candidates.append(URL(fileURLWithPath: runnerDirectory).appendingPathComponent("_work", isDirectory: true)) + } + + // Shared caches can be in use by any job, so only touch them when every + // configured runner is stopped and idle. + if includeSharedCaches && skipped.isEmpty { + candidates.append(contentsOf: sharedCICacheDirectories()) + } + + var reclaimedBytes: Int64 = 0 + var removedPaths: [String] = [] + for directory in candidates where fileManager.fileExists(atPath: directory.path) { + let children = (try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [] + )) ?? [] + + for child in children { + reclaimedBytes += allocatedSize(of: child) + removedPaths.append(child.path) + if !dryRun { + try fileManager.removeItem(at: child) + } + } + } + + return DiskCleanupReport( + reclaimedBytes: reclaimedBytes, + removedPaths: removedPaths.sorted(), + skippedRunnerNames: skipped.sorted(), + dryRun: dryRun + ) + } + + private func sharedCICacheDirectories() -> [URL] { + [ + homeDirectory.appendingPathComponent(".cache", isDirectory: true), + homeDirectory.appendingPathComponent(".npm/_cacache", isDirectory: true), + homeDirectory.appendingPathComponent(".npm/_npx", isDirectory: true), + homeDirectory.appendingPathComponent(".cargo/registry/cache", isDirectory: true), + homeDirectory.appendingPathComponent(".cargo/git", isDirectory: true), + homeDirectory.appendingPathComponent(".gradle/caches", isDirectory: true), + homeDirectory.appendingPathComponent("Library/Caches/Homebrew", isDirectory: true), + homeDirectory.appendingPathComponent("Library/Caches/go-build", isDirectory: true), + homeDirectory.appendingPathComponent("Library/Caches/org.swift.swiftpm", isDirectory: true), + homeDirectory.appendingPathComponent("Library/Developer/Xcode/DerivedData", isDirectory: true) + ] + } + + private func allocatedSize(of url: URL) -> Int64 { + guard let enumerator = fileManager.enumerator( + at: url, + includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey], + options: [.skipsPackageDescendants] + ) else { + let values = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey]) + return Int64(values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0) + } + + var total: Int64 = 0 + while let item = enumerator.nextObject() as? URL { + let values = try? item.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey]) + total += Int64(values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0) + } + return total + } +} diff --git a/Sources/Services/RunnerManager.swift b/Sources/Services/RunnerManager.swift index 51ebefe..d4c79be 100644 --- a/Sources/Services/RunnerManager.swift +++ b/Sources/Services/RunnerManager.swift @@ -40,6 +40,7 @@ class RunnerManager: ObservableObject { private let pidManager = PIDFileManager() private let updateChecker = UpdateChecker() private let updateInstaller = UpdateInstaller() + private let diskCleanupService = DiskCleanupService() #if canImport(Containerization) private var _containerService: Any? // ContainerIsolationService, but untyped for availability #endif @@ -69,6 +70,7 @@ class RunnerManager: ObservableObject { private let restartBaseDelaySeconds = 5 private let restartMaxDelaySeconds = 60 private var installedUpdateVersion: String? + private var lastAutomaticDiskCleanupCheck: Date? /// Initialize the RunnerManager and restore runtime state. /// @@ -896,6 +898,34 @@ class RunnerManager: ObservableObject { if !becameIdleRunnerIDs.isEmpty { await restartRunnersWithStalePathSnapshots(candidateIDs: becameIdleRunnerIDs) } + + runAutomaticDiskCleanupIfNeeded() + } + + private func runAutomaticDiskCleanupIfNeeded(now: Date = Date()) { + guard currentSettings.automaticDiskCleanupEnabled else { return } + if let lastCheck = lastAutomaticDiskCleanupCheck, + now.timeIntervalSince(lastCheck) < 3600 { + return + } + lastAutomaticDiskCleanupCheck = now + + let threshold = Int64(currentSettings.minimumFreeDiskSpaceGB) * 1_000_000_000 + guard let available = diskCleanupService.availableDiskBytes(), available < threshold else { return } + + do { + let report = try diskCleanupService.cleanup( + runners: runners, + globalIsolationMode: currentSettings.isolationMode, + includeSharedCaches: true, + dryRun: false + ) + if report.reclaimedBytes > 0 { + print("Automatic disk cleanup reclaimed \(ByteCountFormatter.string(fromByteCount: report.reclaimedBytes, countStyle: .file)).") + } + } catch { + print("Automatic disk cleanup failed: \(error.localizedDescription)") + } } // MARK: - Duplicate Runner diff --git a/Sources/Views/MenuBarView.swift b/Sources/Views/MenuBarView.swift index ff6f740..40e37b0 100644 --- a/Sources/Views/MenuBarView.swift +++ b/Sources/Views/MenuBarView.swift @@ -481,6 +481,40 @@ struct SettingsView: View { .font(.caption) .foregroundColor(.secondary) + Toggle("Clean CI Data When Disk Space Is Low", isOn: Binding( + get: { runnerManager.currentSettings.automaticDiskCleanupEnabled }, + set: { newValue in + var settings = runnerManager.currentSettings + settings.automaticDiskCleanupEnabled = newValue + runnerManager.updateSettings(settings) + } + )) + + HStack { + Text("Minimum free disk space") + Spacer() + Stepper( + value: Binding( + get: { runnerManager.currentSettings.minimumFreeDiskSpaceGB }, + set: { newValue in + var settings = runnerManager.currentSettings + settings.minimumFreeDiskSpaceGB = max(1, newValue) + runnerManager.updateSettings(settings) + } + ), + in: 10...500, + step: 10 + ) { + Text("\(runnerManager.currentSettings.minimumFreeDiskSpaceGB) GB") + .monospacedDigit() + } + .labelsHidden() + } + + Text("At most once per hour, removes stopped-runner workspaces and known CI caches. Active runner data is always preserved.") + .font(.caption) + .foregroundColor(.secondary) + Toggle("Job Notifications", isOn: Binding( get: { runnerManager.currentSettings.notificationsEnabled }, set: { newValue in diff --git a/Tests/MacRunnerTests/AppSettingsTests.swift b/Tests/MacRunnerTests/AppSettingsTests.swift index 497335c..23eb046 100644 --- a/Tests/MacRunnerTests/AppSettingsTests.swift +++ b/Tests/MacRunnerTests/AppSettingsTests.swift @@ -16,6 +16,8 @@ final class AppSettingsTests: XCTestCase { XCTAssertEqual(settings.autoCheckForUpdates, true) XCTAssertEqual(settings.autoRestartEnabled, true) XCTAssertEqual(settings.autoRestartMaxRetries, 5) + XCTAssertEqual(settings.automaticDiskCleanupEnabled, false) + XCTAssertEqual(settings.minimumFreeDiskSpaceGB, 100) XCTAssertEqual(settings.openFileLimit, ResourceLimits.defaultOpenFileLimit) } @@ -28,6 +30,8 @@ final class AppSettingsTests: XCTestCase { autoCheckForUpdates: false, autoRestartEnabled: false, autoRestartMaxRetries: 8, + automaticDiskCleanupEnabled: true, + minimumFreeDiskSpaceGB: 80, openFileLimit: 32768 ) @@ -39,6 +43,8 @@ final class AppSettingsTests: XCTestCase { XCTAssertEqual(settings.autoCheckForUpdates, false) XCTAssertEqual(settings.autoRestartEnabled, false) XCTAssertEqual(settings.autoRestartMaxRetries, 8) + XCTAssertEqual(settings.automaticDiskCleanupEnabled, true) + XCTAssertEqual(settings.minimumFreeDiskSpaceGB, 80) XCTAssertEqual(settings.openFileLimit, 32768) } diff --git a/Tests/MacRunnerTests/DiskCleanupServiceTests.swift b/Tests/MacRunnerTests/DiskCleanupServiceTests.swift new file mode 100644 index 0000000..15b7c18 --- /dev/null +++ b/Tests/MacRunnerTests/DiskCleanupServiceTests.swift @@ -0,0 +1,80 @@ +import XCTest +@testable import MacRunner + +final class DiskCleanupServiceTests: XCTestCase { + private var temporaryDirectory: URL! + + override func setUpWithError() throws { + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + + func testDryRunReportsSharedCacheWithoutRemovingIt() throws { + let cacheFile = temporaryDirectory.appendingPathComponent(".cache/tool/artifact.bin") + try FileManager.default.createDirectory( + at: cacheFile.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data(repeating: 1, count: 4096).write(to: cacheFile) + + let report = try DiskCleanupService(homeDirectory: temporaryDirectory).cleanup( + runners: [], + globalIsolationMode: .none, + includeSharedCaches: true, + dryRun: true + ) + + XCTAssertGreaterThan(report.reclaimedBytes, 0) + XCTAssertEqual(report.removedPaths.count, 1) + XCTAssertTrue(report.removedPaths[0].hasSuffix("/.cache/tool")) + XCTAssertTrue(FileManager.default.fileExists(atPath: cacheFile.path)) + } + + func testCleanupRemovesSharedCacheContentsButKeepsCacheRoot() throws { + let cacheRoot = temporaryDirectory.appendingPathComponent(".npm/_npx", isDirectory: true) + let cacheFile = cacheRoot.appendingPathComponent("package/index.js") + try FileManager.default.createDirectory( + at: cacheFile.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("cached".utf8).write(to: cacheFile) + + let report = try DiskCleanupService(homeDirectory: temporaryDirectory).cleanup( + runners: [], + globalIsolationMode: .none, + includeSharedCaches: true, + dryRun: false + ) + + XCTAssertEqual(report.removedPaths.count, 1) + XCTAssertTrue(report.removedPaths[0].hasSuffix("/.npm/_npx/package")) + XCTAssertTrue(FileManager.default.fileExists(atPath: cacheRoot.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: cacheFile.path)) + } + + func testActiveRunnerPreservesSharedCaches() throws { + let cacheFile = temporaryDirectory.appendingPathComponent(".cache/tool/artifact.bin") + try FileManager.default.createDirectory( + at: cacheFile.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("cached".utf8).write(to: cacheFile) + let runner = Runner(name: "busy-runner", repo: "owner/repo", status: .running, busy: true) + + let report = try DiskCleanupService(homeDirectory: temporaryDirectory).cleanup( + runners: [runner], + globalIsolationMode: .none, + includeSharedCaches: true, + dryRun: false + ) + + XCTAssertEqual(report.skippedRunnerNames, ["busy-runner"]) + XCTAssertTrue(report.removedPaths.isEmpty) + XCTAssertTrue(FileManager.default.fileExists(atPath: cacheFile.path)) + } +}