Skip to content

Blog

How the Actions cache works on our runners

actions/cache works unmodified on our runners, and so do the wrappers built on it: the cache: input of setup-node, setup-go and setup-python, and the separate restore and save actions. You do not change a workflow to use ours and you do not point it anywhere. This post is about how that works, because it involves a certificate you did not install, and you are entitled to know exactly what it does.

How a job finds the cache without being told

The cache action does not know our address. It asks GitHub’s results service, by name, where to store and fetch entries. Inside a job on our runners that name points at a proxy that serves the job, and the job trusts a certificate authority made for that one name. That is the whole mechanism: the action reaches our cache because the name it was always going to ask for leads to us.

Where the proxy runs depends on the machine the job got. On one of our own servers it is the server the job runs on, with an authority created per server, limited by its own constraints to that one name, and replaced at least every ninety days. On a Windows or ARM job’s machine, and on any machine that is not one of our own servers, the proxy runs inside the job’s own machine, and its authority is generated there for that one job and is gone when the machine is destroyed or rebuilt at the end of it. No authority outlives the job it was made for, and none can be used for any other site.

What the proxy answers, and what it passes on

The cache calls are answered by us. For those calls the runner’s GitHub token is removed before the request reaches the cache, which does not need it: it knows which organisation a job belongs to from the credential, or the machine, the request came from. The cache implements the same Twirp API the cache action uses to talk to GitHub, which is why the action needs no change.

Everything else on that name goes to GitHub, with the runner’s token: artifact uploads and downloads, step and job logs, the runner’s other results traffic. We do not store it. No other site is touched: github.com, the API, package registries and your own services keep their own certificates.

Because this is a redirect of a name, it can be switched off. An owner or admin can turn our cache off for the whole organisation under Settings, Runners, and the action then goes to GitHub, with GitHub’s limits and GitHub’s speed. It is on by default and a change reaches new jobs within about two minutes.

What a job can read and what it can write

Entries are partitioned by organisation, repository and git ref. A restore looks in two places, in order: the ref the job is running on, then the default branch. A save writes to one place, the job’s own ref, and never anywhere else, which is what stops a feature branch replacing the entry the default branch depends on. Sibling branches cannot see each other.

The scope comes from the credential the job’s machine was issued, not from anything the workflow sends, so a step cannot widen it. We follow the rules GitHub documents with one difference: a pull request’s job does not read its base branch’s entries, only its own and the default branch’s. Eviction mirrors GitHub’s too. An entry not read for seven days is removed, and a repository holding more than 25 GiB is trimmed least recently read first. Age since creation is deliberately not a policy: an entry a nightly build restores every day for a year is doing its job.

The Docker layer cache is a different thing

The Actions cache is a keyed archive your workflow restores and saves explicitly. A Docker build wants something else: somewhere to put layers that survives between jobs, because the builder starts empty on every job. The Docker layer cache is an OCI registry we run beside the fleet, reachable from a job and from nowhere else, with layers addressed by digest so a layer that has not changed is not rebuilt and not uploaded again. It is turned on by one step in your workflow.

The two are billed differently, on purpose. The Actions cache is included and appears as no line on your bill. The layer cache is metered by what it stores, at $0.50/GB-month. A layer cache that cannot be reached never fails a build; the build runs without it and is only slower.

Why a hit has to be asserted

Two things went wrong while we built this, and both were quiet. The first is that a cache can fail by not being used at all. Our acceptance run saved an entry and restored it on a second run, and it passed for weeks while every byte of every job went to GitHub’s cache, because the assertion that the second run hit was guarded on a hit having happened, and so skipped itself when nothing hit. A total outage of our cache was a perfectly green run. The acceptance now takes an explicit expectation that the second run hits, and fails when it does not. The rule we took from it is general: a check that cannot fail on the thing it is about is not evidence.

The second is that a redirect has to be trusted by everything that follows it. The first version of the certificate trust was set up for Node programs only, through NODE_EXTRA_CA_CERTS. The Actions runner is a .NET program and ignores that variable. It resolved the redirected name, was shown a certificate it did not trust, refused it, and could not reach the results service. The symptom was the worst available: every step green, every job successful, and the job’s log completely empty, because the runner uploads logs through that same service. The authority is now added to the machine’s own trust store as well. On a Windows job that is the machine’s certificate store, and if the redirect cannot be set up the job falls back to GitHub’s cache rather than failing.

The full reference, with the eviction rules, the delete API and the Turborepo remote cache, is in the Actions cache documentation, and prices are on the pricing page.

Run your CI and your infrastructure on one bill

Connect GitHub

Card required. You pay only for the minutes your jobs run.