Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions Sources/Models/Runner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -261,6 +263,8 @@ struct AppSettings: Codable, Sendable {
autoCheckForUpdates: true,
autoRestartEnabled: true,
autoRestartMaxRetries: 5,
automaticDiskCleanupEnabled: false,
minimumFreeDiskSpaceGB: 100,
openFileLimit: ResourceLimits.defaultOpenFileLimit
)

Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions Sources/Services/CLIHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -124,6 +126,7 @@ enum CLIHandler {
stop <name> 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

Expand All @@ -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
Expand All @@ -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
""")
}

Expand Down Expand Up @@ -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)")
}
}
}
114 changes: 114 additions & 0 deletions Sources/Services/DiskCleanupService.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
30 changes: 30 additions & 0 deletions Sources/Services/RunnerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions Sources/Views/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Tests/MacRunnerTests/AppSettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -28,6 +30,8 @@ final class AppSettingsTests: XCTestCase {
autoCheckForUpdates: false,
autoRestartEnabled: false,
autoRestartMaxRetries: 8,
automaticDiskCleanupEnabled: true,
minimumFreeDiskSpaceGB: 80,
openFileLimit: 32768
)

Expand All @@ -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)
}

Expand Down
Loading
Loading