Skip to main content

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.

→local

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.

▣docker

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.

⚙github-actions

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​

NeedRunner
Fast local iteration on a workstationlocal
Reproducible execution that matches CIdocker
Plans that embed GitHub Actionsgithub-actions
Mixed plans (shell + actions)github-actions (handles both)
CI environment, vendor-agnosticdocker
CI environment, on GitHub Actionsauto-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:

TierScopeWhat it stores
In-memoryCurrent processrepo@ref → SHA map, deduplicated via singleflight
On-diskPersistent across runsSHA 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
APIFallbackGitHub 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:

DirectoryExported asShared by
~/.orun/tool-cacheRUNNER_TOOL_CACHEevery 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/npmnpm_config_cachenpm
~/.orun/actions/shared/pnpm-storenpm_config_store_dir, pnpm_config_store_dir, PNPM_CONFIG_STORE_DIRpnpm
~/.orun/actions/shared/terraform-plugins[-N]TF_PLUGIN_CACHE_DIRTerraform, 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-cacheXDG_CACHE_HOMEevery tool that keeps its cache under $XDG_CACHE_HOME (pip, uv and Playwright on Linux among them)
~/.orun/actions/shared/go-build, go-modGOCACHE, GOMODCACHEGo's build and module caches
~/.orun/actions/shared/pip, uvPIP_CACHE_DIR, UV_CACHE_DIRpip, uv
~/.orun/actions/shared/gradleGRADLE_USER_HOMEGradle
~/.orun/actions/shared/ms-playwrightPLAYWRIGHT_BROWSERS_PATHPlaywright's browsers
~/.orun/actions/shared/yarn, deno, bunYARN_CACHE_FOLDER, DENO_DIR, BUN_INSTALL_CACHE_DIRYarn, Deno, Bun
~/.orun/actions/shared/caches/<name>—the caches steps declare with caches: (below)
<checkout>/.orun/cache/turboTURBO_CACHE_DIRturbo, 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-only on pull requests, apply on merge to main.
  • Dependency rules mark edges as enforced, advisory, or disabled — 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​