Note: This is still a work in progress and should not be used on a production system. Use at your own risk.
A GitHub Actions workflow orchestrator that dispatches jobs based on runner availability, not blind schedules.
Dispatchoor solves a common problem with self-hosted GitHub Actions runners: you have expensive infrastructure sitting idle while jobs queue up, or you're paying for runners that aren't being utilized efficiently.
Instead of triggering workflows on a schedule and hoping runners are available, dispatchoor:
- Monitors your self-hosted runner pools for availability via the GitHub API
- Maintains its own job queue with visibility and priority ordering
- Dispatches
workflow_dispatchevents only when matching runners are idle - Provides a real-time dashboard for queue management and monitoring
- Smart Dispatching: Triggers jobs only when runners with matching labels are available
- Queue Management: Drag-and-drop reordering, priority support, job history
- Real-time Updates: WebSocket-based live updates for runner status and job state
- Multi-group Support: Organize runners into groups with different label requirements
- Authentication: Basic auth and GitHub OAuth with role-based access control
- Metrics: Prometheus endpoint for monitoring and alerting
- Database Support: SQLite (default) or PostgreSQL
- Go 1.24+
- Node.js 22+
- A GitHub PAT with at least the following scopes:
- Repo : Actions - Read/Write
- Organization: Self-hosted runners - Read/Write
-
Clone the repository
git clone https://github.com/ethpandaops/dispatchoor.git cd dispatchoor -
Create a configuration file
cp config.example.yaml config.yaml
-
Edit the configuration
Set your GitHub token and configure at least one group:
github: token: ${GITHUB_TOKEN} # Or paste token directly auth: basic: enabled: true users: - username: admin password: changeme role: admin groups: github: - id: my-runners name: My Runners runner_labels: - self-hosted - linux workflow_dispatch_templates: - id: my-job name: My Workflow owner: my-org repo: my-repo workflow_id: my-workflow.yml ref: main inputs: param1: "value1"
-
Set environment variables
export GITHUB_TOKEN="ghp_your_token_here" export ADMIN_PASSWORD="your_secure_password"
-
Build and run
# Build everything make build # Run database migrations make migrate # Start the server ./bin/dispatchoor server --config config.yaml
-
Access the dashboard
Open http://localhost:3001 in your browser and log in with your configured credentials.
# Build the Docker image
make docker-build
# Run with your config
docker run -d \
-p 9090:9090 \
-v $(pwd)/config.yaml:/app/config.yaml:ro \
-e GITHUB_TOKEN \
dispatchoor:latestSQLite (default):
database:
driver: sqlite
sqlite:
path: ./dispatchoor.dbPostgreSQL:
database:
driver: postgres
postgres:
host: localhost
port: 5432
user: dispatchoor
password: ${DB_PASSWORD}
database: dispatchoor
sslmode: disableBasic auth:
auth:
session_ttl: 24h
basic:
enabled: true
users:
- username: admin
password: ${ADMIN_PASSWORD}
role: admin
- username: viewer
password: ${VIEWER_PASSWORD}
role: readonlyGitHub OAuth:
auth:
github:
enabled: true
client_id: ${GITHUB_CLIENT_ID}
client_secret: ${GITHUB_CLIENT_SECRET}
redirect_url: http://localhost:3000 # Where to redirect after login
org_role_mapping:
my-org: admin
user_role_mapping:
octocat: adminTo set up GitHub OAuth:
- Go to your GitHub organization settings → Developer settings → OAuth Apps → New OAuth App
- Set the Authorization callback URL to
https://your-domain.com/api/v1/auth/github/callback - After creating the app, copy the Client ID and generate a Client Secret
- Set the environment variables:
export GITHUB_CLIENT_ID="your_client_id" export GITHUB_CLIENT_SECRET="your_client_secret"
- Use
org_role_mappingto grant access and assign roles based on organization membership (e.g.,my-org: admingives admin role to all members ofmy-org) - Use
user_role_mappingto grant access and assign roles to individual GitHub users by username (case-insensitive, takes priority overorg_role_mapping)
Users must be in at least one role mapping (org_role_mapping or user_role_mapping) to log in.
Groups define pools of runners identified by labels. Each group can have multiple workflow dispatch templates defined inline, loaded from local files, or fetched from remote URLs:
groups:
github:
- id: sync-tests
name: Sync Tests
description: Ethereum sync testing jobs
runner_labels:
- self-hosted
- sync-test
# Option 1: Inline templates
workflow_dispatch_templates:
- id: sync-geth-prysm
name: Sync Test geth/prysm
owner: ethpandaops
repo: syncoor-tests
workflow_id: syncoor.yaml
ref: master
inputs:
el-client: "geth"
cl-client: "prysm"
config: '{"network": "mainnet"}'
# Option 2: Load templates from local files (paths relative to config file)
# workflow_dispatch_templates_files:
# - templates/hoodi.yaml
# - templates/mainnet.yaml
# Option 3: Load templates from remote URLs
# workflow_dispatch_templates_urls:
# - https://raw.githubusercontent.com/myorg/templates/main/sync-tests.yamlTemplate file format (templates/sync-tests.yaml):
- id: sync-geth-prysm
name: Sync Test geth/prysm
owner: ethpandaops
repo: syncoor-tests
workflow_id: syncoor.yaml
ref: master
inputs:
el-client: "geth"
cl-client: "prysm"
- id: sync-geth-lighthouse
name: Sync Test geth/lighthouse
owner: ethpandaops
repo: syncoor-tests
workflow_id: syncoor.yaml
ref: master
inputs:
el-client: "geth"
cl-client: "lighthouse"All template sources can be used together - file and URL templates are appended to inline templates. The UI displays badges indicating the source of each template (inline, local file, or URL).
When creating GitHub Actions workflows to be dispatched by dispatchoor, it's recommended to make runs-on and timeout-minutes configurable via inputs. This allows you to control runner selection and timeouts from dispatchoor without modifying the workflow file.
See examples/workflows/example.yaml for a reference implementation:
on:
workflow_dispatch:
inputs:
runs-on:
description: On which runner we want to run the workflow
required: false
default: '{"group": "your-runner-group", "labels": "XXL"}'
type: string
timeout-minutes:
description: 'Timeout in minutes'
required: false
default: '1800'
type: string
message:
description: A message that we want to print
default: Hello world
type: string
jobs:
example:
timeout-minutes: ${{ fromJSON(inputs.timeout-minutes) }}
runs-on: ${{ fromJSON(inputs.runs-on) }}
steps:
- name: Print message
run: echo "${{ inputs.message }}"This pattern allows you to:
- Override which runner pool executes the job via the
runs-oninput - Set custom timeouts per job dispatch
- Pass any additional parameters your workflow needs
Important: Ideally you want you group configuration to match the
runs-onlabels of your workflow. Dispatchoor can't decide by itself on which runners a workflow should be executed. So you group configuration and dispatched workflow should have the same runner labels.
Full API documentation is available in OpenAPI/Swagger format (YAML).
| Method | Path | Description |
|---|---|---|
| GET | /health |
Health check |
| GET | /metrics |
Prometheus metrics |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/v1/auth/login |
- | Login with username/password |
| GET | /api/v1/auth/github |
- | Initiate GitHub OAuth |
| GET | /api/v1/auth/github/callback |
- | GitHub OAuth callback |
| POST | /api/v1/auth/logout |
User | Logout and invalidate session |
| GET | /api/v1/auth/me |
User | Get current user info |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/groups |
User | List all groups with stats |
| GET | /api/v1/groups/{id} |
User | Get group details |
| POST | /api/v1/groups/{id}/pause |
Admin | Pause dispatching for group |
| POST | /api/v1/groups/{id}/unpause |
Admin | Resume dispatching for group |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/groups/{id}/templates |
User | List templates for a group |
| GET | /api/v1/templates/{id} |
User | Get template details |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/groups/{id}/queue |
User | Get queued/running jobs |
| POST | /api/v1/groups/{id}/queue |
Admin | Add job to queue |
| PUT | /api/v1/groups/{id}/queue/reorder |
Admin | Reorder queue priorities |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/jobs/{id} |
User | Get job details |
| PUT | /api/v1/jobs/{id} |
Admin | Update job fields |
| DELETE | /api/v1/jobs/{id} |
Admin | Delete pending job |
| POST | /api/v1/jobs/{id}/pause |
Admin | Pause job dispatching |
| POST | /api/v1/jobs/{id}/unpause |
Admin | Resume job dispatching |
| POST | /api/v1/jobs/{id}/cancel |
Admin | Cancel triggered/running job |
| PUT | /api/v1/jobs/{id}/auto-requeue |
Admin | Update auto-requeue settings |
| POST | /api/v1/jobs/{id}/disable-requeue |
Admin | Disable auto-requeue |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/groups/{id}/history |
User | Get completed job history |
| GET | /api/v1/groups/{id}/history/stats |
User | Get aggregated history stats |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/runners |
User | List all runners |
| GET | /api/v1/groups/{id}/runners |
User | List runners for a group |
| POST | /api/v1/runners/refresh |
Admin | Force refresh runner status |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/status |
User | System status and health |
| GET | /api/v1/ws |
User | WebSocket for real-time updates |
# Run API in development mode
make dev-api
# Run UI in development mode (separate terminal)
make dev-ui
# Run API tests
make test-api
# Run API linter
make lint-api
# Run UI linter
make lint-uiThe UI loads its configuration from config.json at runtime, making it easy to deploy the same build to different environments.
Configuration file (ui/dist/config.json or ui/public/config.json):
{
"apiUrl": "/api/v1"
}Development (default):
- Vite dev server runs on
http://localhost:3000 - Proxies
/api,/health,/metricstohttp://localhost:9090 - Configure proxy target in
ui/vite.config.ts
Production (same origin):
- Default
config.jsonuses relative path/api/v1 - Serve UI static files and API from the same domain
- Use a reverse proxy (nginx, Caddy) or embed UI in the Go server
Production (separate origins):
- Update
config.jsonin the deployed UI:{ "apiUrl": "https://api.example.com/api/v1" } - Ensure CORS is configured on the API:
server: cors_origins: - https://ui.example.com
Docker/Kubernetes:
The dispatchoor-web Docker image automatically generates the UI config.json at startup with apiUrl: "/api/v1". The nginx server proxies API requests to the backend using the API_URL environment variable.
# docker-compose.yaml example
services:
api:
image: dispatchoor:latest
volumes:
- ./config.yaml:/app/config.yaml:ro
environment:
- GITHUB_TOKEN
ports:
- "9090:9090"
web:
image: dispatchoor-web:latest
environment:
- API_URL=http://api:9090 # Where nginx proxies /api/ requests
ports:
- "3000:80"To override the UI config, mount a custom config.json:
volumes:
- ./custom-config.json:/usr/share/nginx/html/config.json:roHelm Charts:
Helm charts for deploying dispatchoor on Kubernetes are available at ethpandaops/general-helm-charts.
dispatchoor/
├── cmd/dispatchoor/ # CLI entry point
├── pkg/
│ ├── api/ # HTTP server, WebSocket hub
│ ├── auth/ # Authentication (basic, GitHub OAuth)
│ ├── config/ # YAML config loader
│ ├── dispatcher/ # Core dispatch loop
│ ├── github/ # GitHub API client
│ ├── metrics/ # Prometheus metrics
│ ├── queue/ # Job queue management
│ └── store/ # Database (SQLite, PostgreSQL)
└── ui/ # React + Tailwind frontend
Prometheus metrics are exposed at /metrics:
dispatchoor_jobs_created_total- Jobs created by groupdispatchoor_jobs_completed_total- Jobs completed by groupdispatchoor_jobs_failed_total- Jobs failed by groupdispatchoor_queue_size- Current queue size by group and statusdispatchoor_runners_online- Online runners by groupdispatchoor_runners_busy- Busy runners by groupdispatchoor_dispatcher_cycles_total- Dispatcher loop cyclesdispatchoor_github_rate_limit_remaining- GitHub API rate limit
This project is licensed under the GNU General Public License v3.0 - see the LICENSE file for details.
Contributions are welcome! Please feel free to submit a Pull Request.

