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
5 changes: 4 additions & 1 deletion .github/workflows/xcode-benchmark.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ on:

jobs:
benchmark:
runs-on: macos-latest
strategy:
matrix:
runner: [macos-15-xlarge]
runs-on: ${{ matrix.runner }}
steps:
- name: Show runner info
run: |
Expand Down
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,21 @@ Here's what some open source projects would save:

Local builds are also faster. Based on [XcodeBenchmark](https://github.com/devMEremenko/XcodeBenchmark):

| Runner | Time | vs GitHub |
|--------|------|-----------|
| GitHub macos-latest | [967s](https://github.com/bfulton/localmost/actions/runs/20388833445/job/58594848226) | — |
| MacBook Air M2 (2022) | 202s | **4.8x faster** |
| MacBook Pro M4 Max (2024) | 77s | **12.6x faster** |
| Runner | Time |
|--------|------|
| GitHub macos-latest M1 x3 ($0.06/m) | [838s](https://github.com/bfulton/localmost/actions/runs/20525324038/job/58967518701) |
| GitHub macos-15-large Intel x12 ($0.08/m) | [955s](https://github.com/bfulton/localmost/actions/runs/20525324038/job/58967518696) |
| GitHub macos-15-xlarge M2 Pro x5 ($0.10/m) | [339s](https://github.com/bfulton/localmost/actions/runs/20525919481/job/58969135831) |
| MacBook Air M2 x8 (2022) | 202s |
| MacBook Pro M4 Max x16 (2024) | 77s |

## Why else use localmost?

Features:
- **Automatic fallback** — workflows detect when your Mac is available; fall back to hosted runners when it's not
- **One-click setup** — no terminal commands, no manually generating registration tokens
- **Lid-close protection** — close your laptop without killing in-progress jobs
- **Multi-runner parallelism** — run 1-16 concurrent jobs
- **Multi-runner parallelism** — run 1-8 concurrent jobs
- **Network isolation** — runner traffic is proxied through an allowlist (GitHub, npm, PyPI, etc.)
- **Filesystem sandboxing** — runner processes can only write to their working directory
- **Resource-aware scheduling** — automatically pause runners when on battery or during video calls
Expand All @@ -48,12 +50,14 @@ Features:

localmost is a macOS app that manages GitHub's official [actions-runner](https://github.com/actions/runner) binary. It handles authentication, registration, runner process lifecycle, and automatic fallback — the tedious parts of self-hosted runners.

**Security note:** Running CI jobs on your local machine has inherent risks—especially for public repos that accept external contributions. localmost sandboxes runner processes and restricts network access, but these are not VM-level isolation. See [SECURITY.md](SECURITY.md) for details on the threat model and recommendations.

## Architecture

<img src="docs/localmost-arch.png" alt="localmost architecture diagram" width="600" style="background-color: white; padding: 10px; border-radius: 8px;">

- **Runner proxy** — maintains long-poll sessions with GitHub's broker to receive job assignments
- **Runner pool** — 1-16 worker instances that execute jobs in sandboxed environments
- **Runner pool** — 1-8 worker instances that execute jobs in sandboxed environments
- **HTTP proxy** — allowlist-based network isolation for runner traffic (GitHub, npm, PyPI, etc.)
- **Build cache** — persistent tool cache shared across job runs (Node.js, Python, etc.)

Expand Down Expand Up @@ -215,6 +219,8 @@ npm run make

Future feature ideas:

- **Trusted contributors for public repos** - Control which repos can run on your machine based on their contributor list. Options: never build public repos, only build repos where all contributors are trusted (default: you + known bots, customizable), or always build (with high-friction confirmation). Repos with untrusted contributors fail with a clear error.
- **Graceful heartbeat shutdown** - On clean exit, immediately mark heartbeat stale so workflows fall back to cloud without waiting for the 90s timeout.
- **Quick actions** - Re-run failed job, cancel all jobs.
- **Audit logging** - Detailed logs of what each job accessed.
- **Network policy customization** - User-defined network allowlists per repo.
Expand All @@ -224,6 +230,9 @@ Future feature ideas:
- **Disk space monitoring** - Warn or pause when disk is low, auto-clean old work dirs.
- **Runner handoff** - Transfer a running job to GitHub-hosted if you need to leave.
- **Reactive state management** - Unify disk state, React state, and state machine into a single reactive store to prevent synchronization bugs.
- **Linux and Windows host support** - Run self-hosted runners on non-Mac machines for projects that need them.
- **Higher parallelism cap** - Parallelize proxy registration to support 16+ concurrent runners (currently capped at 8 due to serial registration time).
- **Ephemeral VM isolation** - Run each job in a fresh lightweight VM for stronger isolation between jobs.

Bugs and quick improvements:

Expand Down
4 changes: 2 additions & 2 deletions src/main/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface AppConfig {
orgName?: string;
runnerName?: string;
labels?: string;
runnerCount?: number; // Number of parallel runners (1-16)
runnerCount?: number; // Number of parallel runners (1-8)
};
theme?: string;
launchAtLogin?: boolean;
Expand All @@ -62,7 +62,7 @@ export interface AppConfig {
userFilter?: UserFilterConfig;
/** Multi-target configuration - list of repos/orgs to register runners for */
targets?: Target[];
/** Maximum concurrent jobs across all targets (1-16, defaults to 4) */
/** Maximum concurrent jobs across all targets (1-8, defaults to 4) */
maxConcurrentJobs?: number;
/** Power settings (battery/video call pausing) */
power?: PowerConfig;
Expand Down
4 changes: 2 additions & 2 deletions src/main/target-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,10 @@ describe('TargetManager', () => {
expect(mockSaveConfig).toHaveBeenCalledWith({ maxConcurrentJobs: 1 });
});

it('should clamp value to maximum of 16', () => {
it('should clamp value to maximum of 8', () => {
mockLoadConfig.mockReturnValue({});
manager.setMaxConcurrentJobs(100);
expect(mockSaveConfig).toHaveBeenCalledWith({ maxConcurrentJobs: 16 });
expect(mockSaveConfig).toHaveBeenCalledWith({ maxConcurrentJobs: 8 });
});
});

Expand Down
2 changes: 1 addition & 1 deletion src/main/target-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ export class TargetManager {
const log = () => getLogger();
const config = loadConfig();
const oldValue = config.maxConcurrentJobs ?? 4;
const newValue = Math.max(1, Math.min(16, count));
const newValue = Math.max(1, Math.min(8, count));
if (oldValue !== newValue) {
log()?.info(`[Settings] maxConcurrentJobs: ${oldValue} -> ${newValue}`);
}
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({ onBack, scrollToSection, on
<input
type="range"
min="1"
max="16"
max="8"
value={runnerConfig.runnerCount}
onChange={(e) => updateRunnerConfig({ runnerCount: parseInt(e.target.value, 10) })}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export const DEFAULT_RUNNER_COUNT = 4;
export const MIN_RUNNER_COUNT = 1;

/** Maximum number of runner instances */
export const MAX_RUNNER_COUNT = 16;
export const MAX_RUNNER_COUNT = 8;

/** Default max job history entries to keep */
export const DEFAULT_MAX_JOB_HISTORY = 10;
Expand Down
2 changes: 1 addition & 1 deletion src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export interface ConfigureOptions {
orgName?: string; // Required for org level
runnerName: string;
labels: string[];
runnerCount?: number; // Number of parallel runners (1-16), defaults to 1
runnerCount?: number; // Number of parallel runners (1-8), defaults to 1
}

export type SleepProtection = 'never' | 'when-busy' | 'always';
Expand Down