A lightweight, modern Swift GitHub API client for macOS apps. Direct REST calls over URLSession, zero external dependencies, Swift 6.2 strict concurrency throughout.
Platform & Stack
CI Checks & Review
- 🔐 Dual authentication — OAuth Authorization Code flow for interactive users;
GH_TOKEN/GITHUB_TOKENenv var for CI and automation. Same call site, no branching - 🪜 Layered token resolution — memory cache → Keychain → env var → login-shell fallback, resolved at call time
- 🌐 Direct REST over
URLSession— no code generation, no auto-generated OpenAPI types, no third-party networking layer - 🛡️ Rate-limit aware — automatic backoff and retry on 429 / 403 rate-limit responses
- 📄 Link-header pagination — cursor-based pagination handled transparently
- 🔑
KeychainTokenStorebuilt in — ready-made Keychain integration viaSecurity.framework; swap in a mock for tests via theTokenStoreprotocol - ⚡ Swift 6.2 strict concurrency — no
@unchecked Sendable, compiler-enforced boundaries throughout - 🧪 Testable by design — every concrete type hidden behind a protocol; inject a fake transport or token store in tests with no Keychain involvement
- 🤖 Self-hosted runner queries — fetch all runners for an org or repo scope via
fetchRunners(scope:)/fetchRunners(scopeString:); returns[GitHubRunner]with name, status, busy flag, and labels. Pagination handled automatically - ⚙️ Workflow run & job inspection —
fetchActiveRuns(scope:)returns a typedGitHubRunsFetchResultdistinguishing.success,.rateLimited(partial), and.noToken;fetchJobs(runID:scope:)returns full[GitHubJob]trees with steps, runner name, and timestamps;fetchStepLog(jobID:stepNumber:scope:)fetches and parses raw CI logs per step, stripping ANSI codes automatically - 👤 User context helpers —
fetchUserOrgs()andfetchUserRepos()return the authenticated user's org login names andowner/repofull names; useful for building scope-picker UIs
- Swift 6.2+
- macOS 15+
The package is split into three independently-testable library targets and three matching test targets.
| Target | What it owns | Depends on |
|---|---|---|
EnvTokenKit |
EnvTokenProviding protocol, EnvTokenProvider (env var + login-shell resolution), EnvTokenProviderLoginShell |
— (no dependencies) |
OAuthTokenKit |
TokenStore protocol, KeychainTokenStore, OAuthServicing / OAuthServiceProtocol, OAuthService, GitHubScopes, URLSessionProtocol |
— (no dependencies) |
GitHubClient |
TokenCache, GitHubTransport, GitHubClient facade, all API domain functions and models |
EnvTokenKit, OAuthTokenKit |
| Target | What it tests |
|---|---|
EnvTokenKitTests |
EnvTokenProvider env-var resolution, shell latch behaviour, stub shell resolver |
OAuthTokenKitTests |
OAuthService OAuth flow and auth-state, KeychainTokenStore round-trip |
GitHubClientTests |
TokenCache resolution chain and store priority, GitHubClient facade wiring |
EnvTokenKitandOAuthTokenKitare peer targets — neither depends on the other.GitHubClientusespublic import OAuthTokenKitin bothGitHubClient.swift(required becauseGitHubClient.oauthServicenamesOAuthServiceProtocol) andTokenCache.swift(required becauseTokenCache's public initialisers nameTokenStore).TokenCache.swiftalso usespublic import EnvTokenKit(required becauseTokenCache's public initialisers nameEnvTokenProviding).GitHubClient.swiftusesinternal import EnvTokenKit— sufficient because noEnvTokenKittype appears in that file's public declarations;EnvTokenProvideris constructed locally and immediately erased toany EnvTokenProviding. Swift import access is per-file; each file independently satisfies its own compiler requirement. AddEnvTokenKitas an explicit dependency only when you need to nameEnvTokenProviderdirectly in your own code.GitHubLoggerstays inGitHubClient/Transport/. The kits receive a(@Sendable (String, String) -> Void)?log closure bridged at wiring time inGitHubClient.init— they never importGitHubLoggerdirectly.- Consuming apps depend only on
GitHubClient.EnvTokenKitandOAuthTokenKitare transitive; you do not add them as explicit dependencies unless you need to use their types directly.
Add to your Package.swift:
.package(url: "https://github.com/runbot-hq/GitHubClient", branch: "main").target(
name: "MyTarget",
dependencies: [
.product(name: "GitHubClient", package: "GitHubClient")
]
)OAuthTokenKit is re-exported through GitHubClient via public import — you get OAuthServiceProtocol, TokenStore, GitHubScopes, and related types without a separate dependency. EnvTokenKit is partially re-exported through GitHubClient via public import EnvTokenKit in TokenCache.swift only: the EnvTokenProviding protocol is accessible to consumers because it appears in TokenCache's public initialisers. The concrete EnvTokenProvider type is not re-exported and is not part of GitHubClient's public surface. GitHubClient.swift itself uses internal import EnvTokenKit — EnvTokenKit types do not leak from that file. If you need to name EnvTokenProvider directly in your own code, add EnvTokenKit as an explicit dependency.
The recommended entry point is the GitHubClient facade. It constructs and wires
KeychainTokenStore, EnvTokenProvider, TokenCache, OAuthService, and GitHubTransport in one call.
// AppDelegate or your app's composition root (@MainActor)
let github = GitHubClient(
clientID: "your-client-id",
clientSecret: "your-client-secret",
service: "com.yourapp.github",
account: "github-oauth-token",
logger: MyLogger.shared
)
let oauth: any OAuthServiceProtocol = github.oauthService
let transport: any GitHubTransportProtocol = github.transportFor tests, inject protocol mocks directly:
let github = GitHubClient(
oauthService: MockOAuthService(),
transport: MockTransport()
)import GitHubClient
// CI / automation — token picked up from GH_TOKEN / GITHUB_TOKEN automatically
let runners = await fetchRunners(scopeString: "orgs/acme")
for runner in runners ?? [] {
print("\(runner.name) — \(runner.status) (busy: \(runner.busy))")
}let scope = Scope.parse("repos/acme/my-repo")!
switch await fetchActiveRuns(scope: scope) {
case .success(let runs):
for run in runs {
print("[\(run.status)] \(run.name ?? "unnamed") — \(run.htmlUrl)")
}
case .rateLimited(let partial):
print("Rate-limited; \(partial.count) partial results returned")
case .noToken:
print("No GitHub token configured — trigger OAuth sign-in")
case .authFailure:
print("Token rejected by GitHub")
}let jobs = await fetchJobs(runID: 12345678, scope: scope)
for job in jobs {
let conclusion = job.conclusion ?? "in progress"
print(" \(job.name): \(conclusion) (\(job.steps.count) steps)")
}if let log = await fetchStepLog(jobID: 987654, stepNumber: 2, scope: "repos/acme/my-repo") {
print(log)
}let orgs = await fetchUserOrgs()
let repos = await fetchUserRepos()
print("Orgs: \(orgs)")
print("Repos: \(repos)")// Inject a MockTransport — no Keychain, no network
let client = GitHubClient(
oauthService: MockOAuthService(),
transport: MockTransport()
)
let jobs = await fetchJobs(runID: 1, scope: .org("acme"), transport: client.transport)
XCTAssertEqual(jobs.count, 2)Inject a StubEnvTokenProvider via TokenCache(tokenStore:envProvider:) to control the env/shell path without spawning a real subprocess:
let stub = StubEnvTokenProvider(result: .found("test-token"))
let cache = TokenCache(tokenStore: MockTokenStore(), envProvider: stub)
let token = await cache.token()
// stub.callCount reflects exactly how many times token() was calledAt every API call, the token is resolved in this order — first match wins:
- In-memory cache — zero I/O; warmed on first successful resolution
TokenStore(Keychain by default viaKeychainTokenStore) — synchronousSecItemCopyMatchingreadGH_TOKENenvironment variable — read viaProcessInfo.processInfo.environment(a snapshot captured at process launch); handled byEnvTokenProviderinEnvTokenKitGITHUB_TOKENenvironment variable — same; covers standard CI injection- Login-shell fallback — spawns
/bin/zsh -l -c 'echo $GH_TOKEN'; cold Finder/Dock launch only
Steps 3–5 are handled by EnvTokenProvider (in EnvTokenKit). TokenCache delegates to it via the EnvTokenProviding protocol — it never names the concrete type directly.
Note — two env-var read paths:
EnvTokenProvider(steps 3–4 above) reads env vars viaProcessInfo.processInfo.environment, a snapshot fixed at process launch.OAuthService.hasAnyTokenreads the same vars viagetenv(), which reflects the live process environment. In production these are equivalent. In test harnesses that inject env vars after process launch (e.g.setenv()in test setUp),hasAnyTokenwill see the injected value butTokenCache.token()will not. This divergence is intentional and documented inEnvTokenProvider.resolveFromEnvironment().
The cache is invalidated automatically after every sign-in and sign-out via the onTokenSaved / onTokenDeleted callbacks wired in GitHubClient.init.
if let url = github.oauthService.makeSignInURL() {
NSWorkspace.shared.open(url)
}
// Forward the OAuth redirect callback from AppDelegate.application(_:open:):
github.oauthService.handleCallback(url)Scopes control what permissions are requested from the user during sign-in. Pass a scopes: array to GitHubClient.init using the typed constants in GitHubScopes.
Default scopes (used when scopes: is omitted):
| Constant | GitHub scope | Access granted |
|---|---|---|
GitHubScopes.repo |
repo |
Full read/write access to code, commits, pull requests |
GitHubScopes.readOrg |
read:org |
Read-only access to org membership and teams |
GitHubScopes.adminOrg |
admin:org |
Full admin access to org membership and teams |
GitHubScopes.manageRunnersOrg |
manage_runners:org |
Manage self-hosted runners in an org |
GitHubScopes.workflow |
workflow |
Manage and trigger GitHub Actions workflows |
Request only what you need — for a read-only tool, narrow the scopes at init time:
let github = GitHubClient(
clientID: "your-client-id",
clientSecret: "your-client-secret",
service: "com.yourapp.github",
account: "github-oauth-token",
scopes: [GitHubScopes.readOrg, GitHubScopes.repo]
)Extend the defaults when you need an extra scope (e.g. reading user profile data):
let github = GitHubClient(
clientID: "your-client-id",
clientSecret: "your-client-secret",
service: "com.yourapp.github",
account: "github-oauth-token",
scopes: GitHubScopes.default + [GitHubScopes.readUser]
)See the GitHub OAuth scopes documentation for the full list of available scopes.
Note (PR #75 behaviour change):
isAuthenticatedpreviously returnedtruefor any non-nil Keychain entry, including stored empty strings. It now returnsfalsefor empty strings —tokenStore.load().map { !$0.isEmpty } ?? false. If you have a corrupted Keychain entry (empty string stored),isAuthenticatedwill now correctly returnfalse. Update anyMockOAuthServicestubs that returned""to represent a signed-in state.
// Keychain-only check (synchronous)
if github.oauthService.isAuthenticated {
print("OAuth token present")
}
// Any usable token — Keychain OR env var (uses getenv(), not ProcessInfo snapshot)
if github.oauthService.hasAnyToken {
print("A token is available from some source")
}Export GH_TOKEN or GITHUB_TOKEN — the library picks it up automatically with no additional configuration. EnvTokenProvider reads env vars from ProcessInfo.processInfo.environment (the snapshot captured at process launch), which is correct for standard CI runners where the environment is fixed before the process starts. OAuthService.hasAnyToken additionally checks the same vars via getenv() for a live read — see the note in Token resolution order for the full divergence rationale.
MIT