Runners
The runner is the last checkpoint between your standards and your infrastructure, and it adds no decisions of its own: it executes the plan that was verified and compiled, on the backend you choose.
orun separates compilation from execution. orun plan produces an immutable
plan.json — a DAG of jobs and steps with every reference resolved. orun run
consumes that plan through an execution backend (a "runner") of your choice.
Same plan, different runners. The plan is the boundary.
Run on the host shell
Each run: step executes through sh -c on your machine.
Fast feedback loop for development and any environment that already has the required
binaries on $PATH.
Run in a container
Pulls the job's image, mounts the workspace at /workspace, and
executes inside an isolated container. Use for CI parity and stronger isolation when
composition steps shouldn't touch the host.
Run Actions-compatible steps
Executes GitHub Actions-style use: steps and compatible workflow
commands. Auto-selected when the plan contains any use: step; can be
forced with --gha.
Selection order
orun run resolves the runner in a stable, predictable order:
1. --gha ← shorthand for --runner github-actions
2. --runner <name> ← explicit flag
3. $ORUN_RUNNER ← environment variable
4. auto: github-actions ← when GITHUB_ACTIONS=true
or any step in plan uses use:
5. local ← default
The chosen runner is recorded in the run metadata, so the cockpit always tells you which backend executed which step.
Choosing a runner
| Need | Runner |
|---|---|
| Fast local iteration on a workstation | local |
| Reproducible execution that matches CI | docker |
| Plans that embed GitHub Actions | github-actions |
| Mixed plans (shell + actions) | github-actions (handles both) |
| CI environment, vendor-agnostic | docker |
| CI environment, on GitHub Actions | auto-detected |
The runner does not affect the plan. Switching runners is a deploy-time choice, not a
compile-time one — you can validate against docker and ship with github-actions
from the same plan.json.
Preview before execute
--dry-run is the universal preview. It runs every stage up to shell execution:
resolves working directories, prepares the runner, picks images — and stops.
orun run --plan plan.json --dry-run
Use it when you want to inspect job ordering, step phases, runtime hints, and the selected runner without actually executing anything.
Resumption
Runs are durable. Every run is recorded as an execution in the object model under
.orun/objectmodel/ (see state model), so running again with
the same --exec-id skips the jobs that already succeeded and carries their logs
forward. orun run --job <id> --retry re-runs a single job. In CI, pin the ID with
--exec-id or ORUN_EXEC_ID so a rerun resumes the same execution; see
resume-aware CI reruns.
GitHub Actions steps
Action isolation
When multiple jobs run concurrently and both use the same remote action (e.g., actions/setup-node@v4), each job works from its own isolated copy of that action's directory. The shared on-disk cache is never modified during execution, so jobs cannot race on the same files.
Isolation is zero-cost by default: files are hardlinked from the cache into each job's temp directory. A full copy only happens when the job temp directory is on a different filesystem than the cache.
Local actions (paths starting with ./) are not isolated — they are used directly from the workspace.
Action reference caching
Resolving a non-SHA action reference (e.g., v4) to a commit SHA requires a GitHub API call. orun avoids redundant API calls through a three-tier cache:
| Tier | Scope | What it stores |
|---|---|---|
| In-memory | Current process | repo@ref → SHA map, deduplicated via singleflight |
| On-disk | Persistent across runs | SHA and the time it was resolved, written to ~/.orun/actions/<repo>/refs/<ref>; trusted for 24 hours for a version tag (v4), one hour for a branch |
| API | Fallback | GitHub REST API /repos/<owner>/<repo>/commits/<ref> |
This ensures that --concurrency 4 with many jobs using the same action versions never triggers API rate limits, even with large platform repositories containing dozens of concurrent jobs.
A tag or branch is resolved again once its entry is older than that, so uses: org/action@main follows the branch within the hour and @v4 picks up a new minor release within the day; within one process a reference is resolved once. orun run --refresh-actions (or ORUN_REFRESH_ACTIONS=1) resolves every tag and branch this run. A resolve that fails — offline, rate-limited — keeps the version on disk and says so. The tarball is always fetched by the resolved commit, never by the tag or branch as written, so the cache directory named by a SHA holds that commit.
The download of an action version is guarded by a lock file beside its cache directory, so two jobs or two orun processes never fetch the same version at once. The lock records its holder and is refreshed while the download runs; a lock left by a process that died is reclaimed by the next waiter after a minute. A waiter waits as long as a download can take, and the download itself is bounded by progress — it fails only when no data has arrived for 30 seconds — so a large action on a slow link completes.
actions/cache
GitHub's cache service is not reachable from orun, so a composition step using actions/cache restored nothing and warned. The GitHub Actions engine now runs actions/cache, actions/cache/restore and actions/cache/save itself, against a store under ~/.orun/actions/shared/actions-cache/ that every job and every orun process on the machine shares, with the action's own inputs and outputs:
- uses: actions/cache@v4
id: deps
with:
path: |
node_modules
~/.cache/ms-playwright
key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
deps-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
if: steps.deps.outputs.cache-hit != 'true'
path lines are relative to the workspace (a glob matches files and directories, ~ is the job's home, a ! line excludes); key must match exactly for cache-hit to be true, else the first restore-keys prefix with an entry restores the newest one and cache-matched-key names it. The main action saves under key in its post step when the key was not an exact hit; actions/cache/save saves at once; fail-on-cache-miss and lookup-only behave as documented for the action. A key already saved is never replaced, and a save is held under a lock every orun process respects. orun cache ls lists the entries by key, and prune and gc remove them with the rest.
The cache: input of setup-node and its siblings still reaches nothing: it calls GitHub's cache client inside the action. Under orun it is unnecessary, since the package stores (npm, pnpm-store, yarn) are shared across jobs already (below).
Shared caches
Every job gets a temporary HOME of its own (so global installs by npm install -g never race between jobs), and the caches that are safe to share are pointed at one directory for the whole machine:
| Directory | Exported as | Shared by |
|---|---|---|
~/.orun/tool-cache | RUNNER_TOOL_CACHE | every setup action (setup-node, setup-terraform, setup-helm); installers run one at a time per machine, under a lock file every orun process respects |
~/.orun/actions/shared/npm | npm_config_cache | npm |
~/.orun/actions/shared/pnpm-store | npm_config_store_dir, pnpm_config_store_dir, PNPM_CONFIG_STORE_DIR | pnpm |
~/.orun/actions/shared/terraform-plugins[-N] | TF_PLUGIN_CACHE_DIR | Terraform, one directory per job in flight on the machine, each held by a lock file (terraform init is not safe against a concurrent one) |
~/.orun/actions/shared/xdg-cache | XDG_CACHE_HOME | every tool that keeps its cache under $XDG_CACHE_HOME (pip, uv and Playwright on Linux among them) |
~/.orun/actions/shared/go-build, go-mod | GOCACHE, GOMODCACHE | Go's build and module caches |
~/.orun/actions/shared/pip, uv | PIP_CACHE_DIR, UV_CACHE_DIR | pip, uv |
~/.orun/actions/shared/gradle | GRADLE_USER_HOME | Gradle |
~/.orun/actions/shared/ms-playwright | PLAYWRIGHT_BROWSERS_PATH | Playwright's browsers |
~/.orun/actions/shared/yarn, deno, bun | YARN_CACHE_FOLDER, DENO_DIR, BUN_INSTALL_CACHE_DIR | Yarn, Deno, Bun |
~/.orun/actions/shared/caches/<name> | — | the caches steps declare with caches: (below) |
<checkout>/.orun/cache/turbo | TURBO_CACHE_DIR | turbo, for every job and every run on the checkout (a staged job's) |
Each variable is set only when the environment orun inherits does not already name a directory, so a workflow that points a tool elsewhere keeps its choice. Every shared cache is one its tool documents as safe for concurrent use (content-addressed or locked); a tool whose cache is not — Cargo's CARGO_HOME, Maven's repository, Cypress's binary cache — keeps the job's own HOME and can be kept across jobs as a declared cache instead.
orun cache ls lists every one of these with its size and last use, orun cache prune --older-than 30d removes the entries not used for a while and orun cache gc --max-size 10G keeps the whole of ~/.orun under a budget, least recently used first (orun cache).
The tool cache itself is the host runner's when there is one: on a GitHub runner RUNNER_TOOL_CACHE already holds Node, Python and Go, and orun uses that directory rather than an empty one of its own. ORUN_TOOL_CACHE names another directory explicitly.
The run summary's cache-hit count comes from the tool cache itself: a setup action that added nothing to it found its tool there.
Declared caches
A composition step may declare caches: — directories under the job's working directory that a tool fills (a Next.js build cache, a downloaded browser) and that a freshly staged tree and a per-job HOME would otherwise leave cold:
- name: build
run: pnpm exec next build
caches:
- name: next-web
path: .next/cache
Before the job's first step runs in its tree, each named cache is copied in from ~/.orun/actions/shared/caches/<name> unless the tree already holds that path; after the job succeeds, what it left at the path becomes the machine's copy, swapped in whole under a lock so a concurrent reader never sees a half-written cache. A failed job saves nothing. See the composition contract.
Trigger-aware execution
The plan already encodes how the trigger should shape execution:
- Profile rules select which job profile runs — e.g.
plan-onlyon pull requests,applyon merge to main. - Dependency rules mark edges as
enforced,advisory, ordisabled— PR validation runs in parallel; releases enforce order.
Both are baked into the plan at compile time. The runner just executes what it's given.
Next
orun run— full flag reference.- Execution model — phases, fail-fast, concurrency.
- Trigger bindings — how CI events shape the plan.
- Docker example — end-to-end Docker workflow.
- GitHub Actions example — end-to-end GHA workflow.