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
63 changes: 62 additions & 1 deletion Sources/Models/Runner.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
import Foundation

/// Whether a runner is registered against a single repository or an entire
/// organization. Org-level runners can be picked up by any repository in the
/// organization that the runner group grants access to.
enum RunnerScope: String, Codable, Sendable {
case repo
case org

var displayName: String {
switch self {
case .repo: return "Repository"
case .org: return "Organization"
}
}
}

struct Runner: Identifiable, Codable, Sendable {
let id: UUID
var name: String
var repo: String // Format: "owner/repo"
/// Target identifier. For `.repo` scope this is "owner/repo"; for `.org` scope this is the org login.
var repo: String
/// Whether this runner is registered to a single repository or an entire organization.
/// Defaults to `.repo` for backward compatibility with pre-v1.11 configs.
var scope: RunnerScope
var labels: [String]
var enabled: Bool
var status: RunnerStatus
Expand All @@ -18,6 +37,7 @@ struct Runner: Identifiable, Codable, Sendable {
id: UUID = UUID(),
name: String,
repo: String,
scope: RunnerScope = .repo,
labels: [String] = ["macos", "mac-runner"],
enabled: Bool = true,
status: RunnerStatus = .stopped,
Expand All @@ -31,6 +51,7 @@ struct Runner: Identifiable, Codable, Sendable {
self.id = id
self.name = name
self.repo = repo
self.scope = scope
self.labels = labels
self.enabled = enabled
self.status = status
Expand All @@ -47,6 +68,8 @@ struct Runner: Identifiable, Codable, Sendable {
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
repo = try container.decode(String.self, forKey: .repo)
// Default to .repo for configs written before scope was introduced.
scope = try container.decodeIfPresent(RunnerScope.self, forKey: .scope) ?? .repo
labels = try container.decode([String].self, forKey: .labels)
enabled = try container.decode(Bool.self, forKey: .enabled)
status = try container.decode(RunnerStatus.self, forKey: .status)
Expand All @@ -64,6 +87,11 @@ struct Runner: Identifiable, Codable, Sendable {
)
}

/// Convenience target descriptor pairing this runner's scope and identifier.
var target: RunnerTarget {
RunnerTarget(scope: scope, identifier: repo)
}

/// Returns the effective isolation mode for this runner.
///
/// If the runner has a specific isolation mode set, that is returned.
Expand All @@ -80,6 +108,39 @@ struct Runner: Identifiable, Codable, Sendable {
}
}

/// A scope-aware identifier for GitHub Actions runner registration targets.
///
/// Encapsulates the dual nature of GitHub's runner API: repository-level
/// runners live under `repos/{owner}/{repo}/...` while organization-level
/// runners live under `orgs/{org}/...`. Both share the same registration
/// download URL — `https://github.com/{identifier}` — which `config.sh`
/// uses to phone home.
struct RunnerTarget: Sendable, Equatable, Hashable {
let scope: RunnerScope
let identifier: String // "owner/repo" for .repo, "org" for .org

/// REST API path prefix used by `gh api` calls (no leading slash).
var apiPath: String {
switch scope {
case .repo: return "repos/\(identifier)"
case .org: return "orgs/\(identifier)"
}
}

/// URL passed to `config.sh --url` when registering the runner.
var registrationURL: String {
"https://github.com/\(identifier)"
}

/// Human-readable description suitable for log lines and CLI output.
var displayName: String {
switch scope {
case .repo: return identifier
case .org: return "\(identifier) (org)"
}
}
}

enum RunnerStatus: String, Codable, Sendable {
case running
case stopped
Expand Down
51 changes: 42 additions & 9 deletions Sources/Services/CLIHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ enum CLIHandler {
COMMANDS:
auth Show GitHub authentication status
list List configured runners
add <repo> Add a new runner
add <target> Add a new runner (repo by default; pass --org for org-level)
remove <name> Remove a runner
start <name> Start a runner
stop <name> Stop a runner
Expand All @@ -128,6 +128,8 @@ enum CLIHandler {
version Show version

ADD OPTIONS:
--org Register an organization-level runner (target is the org login)
--repo Register a repository-level runner (default)
--name <name> Runner name (default: auto-generated)
--labels <l1,l2> Comma-separated labels (default: macos)
--isolation <mode> Isolation mode: none|user|container (default: global)
Expand All @@ -139,6 +141,7 @@ enum CLIHandler {
EXAMPLES:
mac-runner auth
mac-runner add owner/repo --name my-runner --labels macos,arm64
mac-runner add my-org --org --labels macos,arm64
mac-runner add owner/repo --isolation container
mac-runner add owner/repo --isolation user
mac-runner add owner/repo --enable-gui
Expand Down Expand Up @@ -176,28 +179,35 @@ enum CLIHandler {
return "\(effective.icon) \(effective.displayName)\(isInherited ? " (global)" : "")"
}

// Display the scope alongside the identifier so org-level runners are
// visually distinguishable from repo-level runners with similar names.
let targets = runners.map { runner -> String in
runner.scope == .org ? "\(runner.repo) (org)" : runner.repo
}

// Table header
let nameW = max(runners.map(\.name.count).max() ?? 4, 4)
let repoW = max(runners.map(\.repo.count).max() ?? 4, 4)
let repoW = max(targets.map(\.count).max() ?? 4, 6)
let isoW = max(isolationTexts.map(\.count).max() ?? 9, 9)

let header = " \("NAME".padding(toLength: nameW, withPad: " ", startingAt: 0)) \("REPO".padding(toLength: repoW, withPad: " ", startingAt: 0)) STATUS \("ISOLATION".padding(toLength: isoW, withPad: " ", startingAt: 0)) GUI LABELS"
let header = " \("NAME".padding(toLength: nameW, withPad: " ", startingAt: 0)) \("TARGET".padding(toLength: repoW, withPad: " ", startingAt: 0)) STATUS \("ISOLATION".padding(toLength: isoW, withPad: " ", startingAt: 0)) GUI LABELS"
print(header)

for (runner, isolationText) in zip(runners, isolationTexts) {
for ((runner, isolationText), target) in zip(zip(runners, isolationTexts), targets) {
let status = "\(runner.status.icon) \(runner.status.rawValue)"
let labels = runner.labels.joined(separator: ",")
let guiStatus = runner.enableGUI ? "enabled " : "headless"
let line = " \(runner.name.padding(toLength: nameW, withPad: " ", startingAt: 0)) \(runner.repo.padding(toLength: repoW, withPad: " ", startingAt: 0)) \(status.padding(toLength: 10, withPad: " ", startingAt: 0)) \(isolationText.padding(toLength: isoW, withPad: " ", startingAt: 0)) \(guiStatus) \(labels)"
let line = " \(runner.name.padding(toLength: nameW, withPad: " ", startingAt: 0)) \(target.padding(toLength: repoW, withPad: " ", startingAt: 0)) \(status.padding(toLength: 10, withPad: " ", startingAt: 0)) \(isolationText.padding(toLength: isoW, withPad: " ", startingAt: 0)) \(guiStatus) \(labels)"
print(line)
}
}

@MainActor
private static func handleAdd(args: [String]) async {
guard let repo = args.first, repo.contains("/") else {
print("Error: repository required in owner/repo format")
guard let target = args.first else {
print("Error: repository or organization required")
print("Usage: mac-runner add <owner/repo> [--name <name>] [--labels <l1,l2>] [--isolation <mode>] [--enable-gui] [--open-files <limit>]")
print(" mac-runner add <org> --org [--name <name>] [--labels <l1,l2>] [--isolation <mode>] [--enable-gui] [--open-files <limit>]")
return
}

Expand All @@ -206,11 +216,18 @@ enum CLIHandler {
var isolationMode: IsolationMode? = nil
var enableGUI = false
var openFileLimit: Int? = nil
var scope: RunnerScope = .repo

// Parse optional flags
var i = 1
while i < args.count {
switch args[i] {
case "--org":
scope = .org
i += 1
case "--repo":
scope = .repo
i += 1
case "--name" where i + 1 < args.count:
name = args[i + 1]
i += 2
Expand Down Expand Up @@ -246,19 +263,35 @@ enum CLIHandler {
}
}

// Validate the identifier shape against the chosen scope.
switch scope {
case .repo:
guard target.contains("/") else {
print("Error: repository required in owner/repo format (or pass --org to register an organization runner)")
return
}
case .org:
guard !target.contains("/") else {
print("Error: --org expects an organization login only (no slashes)")
return
}
}

// Check auth first
let authState = await GHCLIService.shared.validateAuth()
guard authState.isAuthenticated else {
print("Error: \(authState.recoveryMessage)")
return
}

print("Adding runner '\(name)' for \(repo)...")
let scopeLabel = scope == .org ? "\(target) (org)" : target
print("Adding runner '\(name)' for \(scopeLabel)...")
let manager = RunnerManager()
do {
try await manager.addRunner(
name: name,
repo: repo,
repo: target,
scope: scope,
labels: labels,
isolationMode: isolationMode,
enableGUI: enableGUI,
Expand Down
38 changes: 35 additions & 3 deletions Sources/Services/GHCLIService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,23 @@ final class GHCLIService: Sendable {
return result.exitCode == 0
}

/// Verify that the authenticated `gh` user can access the given runner target.
///
/// For repository targets this falls through to `validateRepo`. For
/// organization targets we probe `orgs/{org}` — this requires the user
/// to be a member of the org, but does NOT verify admin access. The
/// registration-token POST below will fail loudly if the user lacks
/// the org admin permission needed to register runners.
func validateTarget(_ target: RunnerTarget) async throws -> Bool {
switch target.scope {
case .repo:
return try await validateRepo(target.identifier)
case .org:
let result = try await runGH(["api", "orgs/\(target.identifier)"])
return result.exitCode == 0
}
}

func repositoryRootEntries(for repo: String) async throws -> Set<String> {
let result = try await runGH([
"api", "repos/\(repo)/contents",
Expand Down Expand Up @@ -243,9 +260,13 @@ final class GHCLIService: Sendable {
// MARK: - Runners

func getRegistrationToken(for repo: String) async throws -> String {
try await getRegistrationToken(for: RunnerTarget(scope: .repo, identifier: repo))
}

func getRegistrationToken(for target: RunnerTarget) async throws -> String {
let result = try await runGH([
"api", "-X", "POST",
"repos/\(repo)/actions/runners/registration-token",
"\(target.apiPath)/actions/runners/registration-token",
"--jq", ".token"
])
guard result.exitCode == 0, !result.stdout.isEmpty else {
Expand All @@ -255,8 +276,12 @@ final class GHCLIService: Sendable {
}

func listRemoteRunners(for repo: String) async throws -> [RemoteRunner] {
try await listRemoteRunners(for: RunnerTarget(scope: .repo, identifier: repo))
}

func listRemoteRunners(for target: RunnerTarget) async throws -> [RemoteRunner] {
let result = try await runGH([
"api", "repos/\(repo)/actions/runners",
"api", "\(target.apiPath)/actions/runners",
"--jq", ".runners"
])
guard result.exitCode == 0 else {
Expand Down Expand Up @@ -292,9 +317,16 @@ final class GHCLIService: Sendable {
}

func deleteRunner(repo: String, githubRunnerId: Int) async throws {
try await deleteRunner(
target: RunnerTarget(scope: .repo, identifier: repo),
githubRunnerId: githubRunnerId
)
}

func deleteRunner(target: RunnerTarget, githubRunnerId: Int) async throws {
let result = try await runGH([
"api", "-X", "DELETE",
"repos/\(repo)/actions/runners/\(githubRunnerId)"
"\(target.apiPath)/actions/runners/\(githubRunnerId)"
])
guard result.exitCode == 0 else {
throw GHError.apiFailed("Failed to delete runner: \(result.stderr)")
Expand Down
50 changes: 46 additions & 4 deletions Sources/Services/RunnerInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,40 @@ class RunnerInstaller {
print("Runner installed successfully to: \(directory)")
}

/// Configure runner with a registration token (caller obtains it via GHCLIService)
/// Configure runner with a registration token (caller obtains it via GHCLIService).
///
/// Legacy `repo`-based overload preserved for callers that haven't been
/// updated to pass an explicit `RunnerTarget`.
func configureRunner(
at directory: String,
repo: String,
registrationToken: String,
name: String,
labels: [String],
isolation: IsolationMode = .none
) async throws {
try await configureRunner(
at: directory,
target: RunnerTarget(scope: .repo, identifier: repo),
registrationToken: registrationToken,
name: name,
labels: labels,
isolation: isolation
)
}

func configureRunner(
at directory: String,
target: RunnerTarget,
registrationToken: String,
name: String,
labels: [String],
isolation: IsolationMode = .none
) async throws {
// Build config command
var args = [
"./config.sh",
"--url", "https://github.com/\(repo)",
"--url", target.registrationURL,
"--token", registrationToken,
"--name", name,
"--unattended",
Expand Down Expand Up @@ -97,7 +118,9 @@ class RunnerInstaller {
print("Runner configured successfully")
}

/// One-click setup: Download, configure, and register runner
/// One-click setup: Download, configure, and register runner.
///
/// Legacy repo-only overload — defaults the runner target to repository scope.
@discardableResult
func setupRunner(
repo: String,
Expand All @@ -106,6 +129,25 @@ class RunnerInstaller {
labels: [String],
runnerId: UUID,
isolation: IsolationMode = .none
) async throws -> String {
try await setupRunner(
target: RunnerTarget(scope: .repo, identifier: repo),
registrationToken: registrationToken,
name: name,
labels: labels,
runnerId: runnerId,
isolation: isolation
)
}

@discardableResult
func setupRunner(
target: RunnerTarget,
registrationToken: String,
name: String,
labels: [String],
runnerId: UUID,
isolation: IsolationMode = .none
) async throws -> String {
let directory = try RunnerDirectory.path(for: runnerId, isolation: isolation)

Expand All @@ -120,7 +162,7 @@ class RunnerInstaller {
// Configure with GitHub
try await configureRunner(
at: directory,
repo: repo,
target: target,
registrationToken: registrationToken,
name: name,
labels: labels,
Expand Down
Loading
Loading