diff --git a/.github/workflows/xcode-benchmark.yaml b/.github/workflows/xcode-benchmark.yaml
index 20568ec..7fb3c90 100644
--- a/.github/workflows/xcode-benchmark.yaml
+++ b/.github/workflows/xcode-benchmark.yaml
@@ -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: |
diff --git a/README.md b/README.md
index dfe161e..9f9362b 100644
--- a/README.md
+++ b/README.md
@@ -27,11 +27,13 @@ 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?
@@ -39,7 +41,7 @@ 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
@@ -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
- **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.)
@@ -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.
@@ -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:
diff --git a/src/main/config.ts b/src/main/config.ts
index f24009b..0abb117 100644
--- a/src/main/config.ts
+++ b/src/main/config.ts
@@ -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;
@@ -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;
diff --git a/src/main/target-manager.test.ts b/src/main/target-manager.test.ts
index fc6fc85..eaf093a 100644
--- a/src/main/target-manager.test.ts
+++ b/src/main/target-manager.test.ts
@@ -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 });
});
});
diff --git a/src/main/target-manager.ts b/src/main/target-manager.ts
index 8bbaced..93f3a19 100644
--- a/src/main/target-manager.ts
+++ b/src/main/target-manager.ts
@@ -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}`);
}
diff --git a/src/renderer/components/SettingsPage.tsx b/src/renderer/components/SettingsPage.tsx
index 41cbd2d..b411fb2 100644
--- a/src/renderer/components/SettingsPage.tsx
+++ b/src/renderer/components/SettingsPage.tsx
@@ -364,7 +364,7 @@ const SettingsPage: React.FC = ({ onBack, scrollToSection, on
updateRunnerConfig({ runnerCount: parseInt(e.target.value, 10) })}
/>
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 03515d6..f1cb1b9 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -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;
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 8dac09b..19e11b6 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -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';