Skip to main content

v2.81.0

Binaries and the merged-pull-request list are on the GitHub releases page.

This release carries the cache-efficiency series (specs/orun-cache-efficiency in the repository): what a run had to download, build or compute again because a cache was missing, cold, private, stale or wrong, and the fixes for it. The first two changes below — the crash-safe download lock and the fetch by commit — were already in the v2.80.1 binaries, cut mid-series; everything after them is new here.

A crashed download no longer blocks the next run​

A remote action (actions/setup-node@v4, hashicorp/setup-terraform@v4) is downloaded once per version into ~/.orun/actions under a lock file, so two jobs or two orun processes never fetch the same action at once. A process killed in the middle of that download — a spot reclaim, an out-of-memory kill, Ctrl-C — left the lock behind, and every later run of that action version on the machine waited 30 seconds for it and failed, until someone found and deleted the file.

The lock now records its holder and is refreshed while the download runs; a waiter that finds a lock untouched for a minute knows its holder is gone and takes it over. A waiter also waits as long as a download can take rather than giving up after 30 seconds, and the download itself is bounded by progress instead of a wall-clock total: it fails only when no byte has arrived for 30 seconds, so a large action on a slow link completes instead of timing out.

Actions stay fresh, and are fetched by their commit​

A tag or branch reference (actions/setup-node@v4, org/action@main) was resolved to a commit once per machine and never again: the file that recorded the answer had no age, so @main never moved and @v4 never saw the next minor release until someone deleted ~/.orun/actions. The entry now records when it was resolved: a version tag is trusted for 24 hours, a branch for one hour, and a reference is resolved again past that (once per process, whatever its age). A resolve that fails because the machine is offline or rate-limited keeps the version on disk and prints a note. orun run --refresh-actions (or ORUN_REFRESH_ACTIONS=1) resolves every tag and branch this run.

The action's archive is now fetched by the resolved commit rather than by the tag or branch as written. The cache directory named by a commit holds that commit, even if the tag moved between the resolve and the download.

Two orun processes on one machine share its caches safely​

A setup action installs into the tool cache, and a job holds a Terraform plugin cache, that every orun process on the machine shares; both were guarded only within one process. A matrix run (one orun run --job per job) or several lanes on one self-hosted runner could extract the same Node version into the tool cache at once (a half-written tree, text file busy) or run terraform init against one plugin cache. Both are now held with a lock file every orun process respects: setup actions run one at a time per machine, and a job's Terraform plugin cache slot is held against the other processes for the job's life. The locks are the operating system's, released with the process, so a crash leaves none behind.

Jobs find the host's tool cache and share the toolchain caches​

On a GitHub runner, RUNNER_TOOL_CACHE already holds Node, Python and Go; orun pointed every job at an empty tool cache of its own, so every lane downloaded Node again until a workflow symlinked the host's cache in. The engine now uses the host's tool cache when the environment names an existing one, and ORUN_TOOL_CACHE names another explicitly.

Every job runs with a temporary HOME of its own, so global installs never race between jobs; the side effect was that every cache a tool keeps under HOME — Go's build and module caches, pip's and uv's, Gradle's, Playwright's browsers, Yarn's, Deno's, Bun's, anything under XDG_CACHE_HOME — was empty at every job's start and deleted at its end. Those caches, each one its tool documents as safe for concurrent use, now share one directory per machine under ~/.orun/actions/shared/; a variable the environment already carries is left alone. A Go job's second build on a machine takes seconds instead of a cold compile.

Declared caches​

A composition step may declare caches: — directories under the job's working directory that a tool fills and that a freshly staged tree would otherwise leave cold, a Next.js build cache being the common one:

- name: build
run: pnpm exec next build
caches:
- name: next-web
path: .next/cache

orun copies each named cache into the tree before the job's first step runs there (unless the tree brought its own) and keeps what the job left behind once it succeeded, swapped in whole under a lock. The copy-in-and-out a workflow had to script by hand becomes two lines in the composition.

The turbo cache lives with the checkout​

Every staged job ran with TURBO_CACHE_DIR under the run's own directory, .orun/runs/<exec-id>/cache/turbo: the second job of a lane hit what the first built, and the next run started cold, every time, and the directory stayed behind. Turbo's cache is content-addressed and safe under concurrent jobs, so it is now one per checkout, .orun/cache/turbo, shared by every job and every run on it (and ignored by git, beside .orun/runs/). A workflow that restored .turbo from actions/cache around every run can keep doing so; a self-hosted runner that keeps its checkout no longer needs to.

A lane's setup actions run once​

A lane-scoped step (scope: lane) runs once per lane, but the setup actions before it — setup-node, setup-terraform — ran in every job, each one checking the tool cache and exporting the same PATH again. Under the GitHub Actions runner the first job of the lane now runs the action, and what it exported (its PATH entries, environment and outputs) is applied to every other job with the same action and inputs, recorded as shared with <job> like the install. An action whose exports point into the job's own temporary directory — pnpm/action-setup installs pnpm under RUNNER_TEMP, which is removed with the job — still runs in every job.

Composition sources are read in place, and the cache is bounded​

A dir composition source — the package beside the intent — was hashed on every plan and then copied whole into ~/.orun/cache/compositions under its digest, so every edit left another full copy behind, and nothing ever removed one. It is now read where it is: the digest still validates the source and feeds the lock file, and nothing is copied. An archive or OCI package is still cached by digest, and its fill is now held under a lock every orun process respects, built beside the entry and renamed into place: two processes resolving the same new digest no longer each delete the other's live entry.

orun cache is new: orun cache ls lists every cache orun keeps — remote actions, the tool cache, the shared stores, declared caches, composition packages, and in a checkout its runs and resolve memo — with its size and when it was last used; orun cache prune --older-than 30d removes the entries not used for a while; orun cache gc --max-size 10G keeps the whole of ~/.orun under a budget, least recently used first. An entry a running orun process holds is marked in use and never removed. orun stamps an entry's last use on every hit, so the age is the age of its last use, not of its download.

A memoized job's key covers its source tree​

A job labelled orun.dev/hermetic is served a prior result when its inputs match; the key was computed from the job's steps and environment-variable names alone, so a hermetic job that built its component was skipped on the run after the component changed, with the old result adopted. The key now also covers the composition sources the plan was compiled against and a content digest of the job's directory in the checkout — every tracked file's index entry plus every work-tree change, so an edit counts before it is staged and ignored files do not. A job whose directory cannot be digested runs unmemoized, with a note. The run summary counts hits as N memoized, apart from the jobs resumed from the execution's own state.

A hit still restores nothing: the result records that the job succeeded, not what it produced. The orun run reference now says which jobs the label fits — a verification, a test, a plan whose output is published — and which it does not: a build whose output the next job reads.

actions/cache works without GitHub's cache service​

A composition step using actions/cache reached for GitHub's cache service, which orun cannot offer, warned and restored nothing. 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 (path, key, restore-keys, fail-on-cache-miss, lookup-only) and outputs (cache-hit, cache-primary-key, cache-matched-key): a composition written for GitHub restores its caches under orun on any runner. orun cache ls lists the entries by key, and prune and gc remove them with the rest. The cache: input of setup-node still reaches nothing; under orun the package stores are shared across jobs already.

pnpm finds the shared store​

The GitHub Actions engine shares one pnpm store between every job on the machine, so a lane's second job installs from what its first downloaded. It named the store with pnpm_config_store_dir, which pnpm 10 does not read: each job fell back to a store under its own temporary HOME and downloaded every package again. The engine now also exports npm_config_store_dir, the name pnpm reads. A workflow that set it by hand can stop.

Cache hits are counted from the cache​

The run summary's N cache hits was derived from what setup actions print, and the messages do not mean the same thing across actions: setup-helm prints "has been cached at" after a download as well as after a hit, and setup-node prints "Found in cache" on a hit, which was not matched. The GitHub Actions engine now watches its own tool cache around every setup action: a step that added nothing to it found its tool there and is a hit; one that added a version installed it. The count no longer depends on the action's wording.

A run's staging directory is removed when it ends​

Jobs run in staged trees under .orun/runs/<exec-id>/; each job removed its own tree when it finished, but the caches tools wrote beside the trees (the per-run turbo cache) stayed, and a checkout kept one such directory per run. A run that covered the whole plan now removes its directory when it ends. --keep-workspaces keeps it, and a --job or --lane process, which shares the exec id with other processes on the same checkout, leaves it alone.