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.