Sharing Data Between GitHub Actions Jobs
Three mechanisms move data between GitHub Actions jobs: job outputs for strings, artifacts for files, and cache or snapshot for runner state.
Last verified:
GitHub Actions offers three ways to move data between jobs, and they are not interchangeable: a job output carries a string through the needs graph, an artifact carries files that outlive the run, and a cache or a snapshot runner carries runner-local state a later job would otherwise rebuild. Pick by payload size and required lifetime, because the wrong pick either serializes a graph that could have run wide or pays runner minutes to move gigabytes that never had to move.
Diagnosis
Start with what actually crosses the job boundary. Every job in a workflow gets its own runner, and that runner is destroyed when the job ends, so nothing on disk survives unless something copies it. Three mechanisms do the copying, and each one fails differently.
Job outputs carry strings and add an edge to the graph
A step writes key=value to $GITHUB_OUTPUT, the job republishes it under outputs:, and a downstream job reads it as needs.<job>.outputs.<key>. Outputs are evaluated when the producing job ends, and values that match a secret are redacted, so an output is an identifier: a commit sha, an image tag, a computed matrix definition, a deploy target.
The cost here is structural rather than billed. needs is a dependency edge, so the consumer waits for the producer to finish before its runner is even requested. A workflow where five jobs each read one string from a 30-second setup job pays that 30 seconds plus a runner boot before any of the five starts. Outputs are free to move and expensive to overuse.
Artifacts carry files and bill runner minutes on both ends
actions/upload-artifact compresses the paths you name and uploads them; actions/download-artifact pulls them back on the consumer. The artifact outlives the run, appears on the run summary page for a human to download, and is held for your repository retention setting.
Both halves execute on a runner, so both are billed as runner minutes. A producer that uploads 8 GB and six consumers that each download it pay for seven transfers of the same bytes, and GitHub bills the artifact storage against your plan on top of that. Reach for an artifact when a person or a later workflow needs the file, and when a miss has to be impossible.
Runner-local state does not cross the boundary at all
The third mechanism is the one teams reach for by habit: leave the work on the runner. Runners do not keep state between jobs, so two products stand in for that, and they behave differently.
A cache action is still a transfer, keyed rather than addressed. The entry is scoped to the key, the cache version, and the branch, and it expires 7 days after its last use. A restore that misses is not an error by default, so a job that treats a cache as a handoff runs happily with an empty directory and fails later, in a test step, with a message that points nowhere near the cause.
A snapshot runner skips the transfer. It captures the runner VM mid-workflow and boots later jobs from that image, so the state is on disk at boot and nothing streams at job start. That trades the transfer for a per-job restore fee and a boot of 45 to 60 seconds.
Priced out, the three mechanisms look like this on WarpBuild hosted runners:
- A job output costs nothing to move and costs you the wall clock time of the
needsedge, billed at the consumer's per-minute runner rate. - An artifact costs upload minutes on the producer plus download minutes on every consumer, all at the runner rate, plus GitHub artifact storage.
- A cache costs $0.0001 per write or restore operation, $0.20 per GB-month of storage, and the transfer time at the runner rate. A snapshot costs $0.04 per restore and $0.025 per snapshot-hour, with no transfer time.
Fix
Match the mechanism to payload size and to how long the payload has to stay reachable.
| What you are moving | Typical size | Lifetime needed | Mechanism | What it costs |
|---|---|---|---|---|
| Commit sha, image tag, deploy target | Bytes, as a string | This run | Job output plus needs | Free to move, serializes the two jobs |
| Matrix definition computed at runtime | A JSON string | This run | Job output read through fromJSON | Free to move, serializes the fan-out behind one job |
| Build binary, test report, coverage file | MB to GB | Past the run, human readable | Artifact | Upload and download minutes, plus GitHub artifact storage |
| Compiled output consumed by later jobs | Hundreds of MB to GB | This run only | Cache keyed on github.run_id | $0.0001 per operation, $0.20 per GB-month, transfer minutes |
| Dependency tree keyed by a lockfile | Hundreds of MB | Across runs, best effort | Cache keyed on hashFiles | Same cache fees, entry expires 7 days after last use |
| Warm daemons, pulled images, incremental build trees | Tens of GB | Across runs | Snapshot runner on Cloud Ubuntu | $0.04 per restore, $0.025 per snapshot-hour |
| Docker layers | GB | Across runs | Remote Docker builders | Builder session rate, no cache entry to manage |
Three rules fall out of the table.
If the consumer cannot proceed without the payload, do not use a cache. Use a job output for a string and an artifact for files. A cache entry can be evicted, can expire 7 days after its last use, and can miss because the branch or the cache version differs across workflows. That is acceptable for an optimization and unacceptable for a contract.
If the consumer only runs faster with the payload, use a cache and handle the miss. Set fail-on-cache-miss deliberately: true when a miss should stop the job early with a clear message, false when the job has a slow path it can fall back to.
If what you want is a machine rather than a file, stop transferring. Warm daemons, a populated Docker image store, and incremental build trees do not survive a restore because they are machine state. A snapshot runner boots later jobs from the machine itself, and the break-even arithmetic for that trade is set by the $0.04 restore fee against your runner rate.
Fan-out shapes the decision as much as size does. Six shards downloading the same artifact pay six transfers, and there is no queue penalty for running them at once: run as many jobs as your workflows need, because generally available Linux and Windows runners do not have plan-level concurrency caps. The transfer bill scales with the width of the matrix, so widening the fan-out makes the payload size question sharper rather than softer.
Configuration
Job outputs and a computed matrix
name: build-and-test
on: [push, pull_request]
jobs:
plan:
runs-on: warp-ubuntu-latest-x64-2x
outputs:
image_tag: ${{ steps.meta.outputs.image_tag }}
shards: ${{ steps.meta.outputs.shards }}
steps:
- uses: actions/checkout@v5
- id: meta
run: |
echo "image_tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
echo 'shards=["1","2","3","4","5","6"]' >> "$GITHUB_OUTPUT"
test:
needs: plan
runs-on: warp-ubuntu-latest-x64-8x
strategy:
matrix:
shard: ${{ fromJSON(needs.plan.outputs.shards) }}
steps:
- run: ./scripts/test.sh --shard ${{ matrix.shard }} --tag ${{ needs.plan.outputs.image_tag }}Keep the plan job on a small runner. It moves strings, so warp-ubuntu-latest-x64-2x at $0.004 per minute is the right size, and every job behind the needs edge waits on it.
A run-scoped cache with a delete step
When the payload is a compiled tree that only this run needs, key it on the run and remove it when the run finishes. The delete-cache input on the WarpBuild cache action turns a cleanup job into three lines:
build:
needs: plan
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v5
- run: pnpm install --frozen-lockfile && pnpm build
- name: Save build output for this run
uses: WarpBuilds/cache/save@v1
with:
path: dist
key: ${{ runner.os }}-dist-${{ github.run_id }}-${{ github.run_attempt }}
shard:
needs: [plan, build]
runs-on: warp-ubuntu-latest-x64-8x
strategy:
matrix:
shard: ${{ fromJSON(needs.plan.outputs.shards) }}
steps:
- uses: actions/checkout@v5
- name: Restore build output
uses: WarpBuilds/cache/restore@v1
with:
path: dist
key: ${{ runner.os }}-dist-${{ github.run_id }}-${{ github.run_attempt }}
fail-on-cache-miss: true
- run: ./scripts/test.sh --shard ${{ matrix.shard }}
cleanup:
needs: shard
if: always()
runs-on: warp-ubuntu-latest-x64-2x
steps:
- name: Delete the run-scoped entry
uses: WarpBuilds/cache@v1
with:
path: dist
key: ${{ runner.os }}-dist-${{ github.run_id }}-${{ github.run_attempt }}
delete-cache: truefail-on-cache-miss: true is the line that turns a silent empty directory into a job that stops at the restore step. The cleanup job is what keeps storage flat, and the Cost or Time Model section prices exactly how much it saves.
Runner-local state with snapshot.key
The runner-local option carries its configuration in the runs-on label after a semicolon, so the whole thing stays one string. snapshot.key=<alias> boots from an existing snapshot for that alias when one exists and falls back to the base image when none does, which makes the first run of a new alias correct with no special handling. snapshot.enabled=true always boots from the base image and is what you pair with WarpBuilds/snapshot-save@v1 on a branch you control.
integration:
runs-on: warp-ubuntu-latest-x64-16x;snapshot.key=integration-main
steps:
- uses: actions/checkout@v5
- name: Prime toolchain on a cold machine
if: env.WARPBUILD_SNAPSHOT_KEY == ''
run: ./scripts/install-toolchain.sh
- run: ./scripts/integration.shA job booted from a snapshot carries WARPBUILD_SNAPSHOT_KEY set to the alias, which is how a step branches between the warm path and the cold path. The snapshot runner documentation covers the snapshot-save inputs: alias is required, fail-on-error defaults to true, and wait-timeout-minutes defaults to 30. Snapshots are deleted after 15 days, and /tmp does not persist because it is cleaned on reboot. Clean credentials with rm -rf $HOME/.ssh $HOME/.aws and git clean -ffdx before any save step, since a snapshot preserves whatever a step wrote into a home directory.
The same snapshot cannot be relied on across platforms
The runner-local mechanism does not span them.
- Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. BYOC runners, Windows runners, and macOS runners are not supported, and a snapshot label on those runner types is silently ignored. A matrix that appends
;snapshot.key=build-maintowarp-windows-latest-x64-8xlooks configured in the diff, produces no error at run time, and delivers no snapshot behavior. - Cache entries do not cross operating systems either. The cache version is a hash of the compression tool used on the runner OS and the paths being cached, so an entry written on
warp-macos-14-arm64-6xis a different cache from the consumer's point of view onwarp-ubuntu-latest-x64-4x. Caching is also not supported on Windows-based WarpBuild runners. - Absolute paths differ across images. Ubuntu 24.04 ARM64 runners set the work directory to
/runner/_workrather than GitHub's/home/runner/work/, so apath:list built from an absolute path on one image will not match on the other.
The mechanism that does cross every platform is the artifact, which is why a cross-platform handoff, such as a macOS build feeding a Linux packaging job, belongs in upload-artifact and download-artifact rather than in a cache key. The snapshot API supports listing and deleting aliases from a scheduled job as well as from the console.
Cost or Time Model
Fees for moving state on WarpBuild hosted runners, from the pricing page:
| Metric | Hosted rate | BYOC |
|---|---|---|
| Cache storage | $0.20 per GB-month | Free |
| Cache write, restore, or list | $0.0001 per operation | Free |
| Snapshot restore | $0.04 per job | Not supported |
| Snapshot storage | $0.025 per snapshot-hour | Not supported |
BYOC runs on AWS, GCP, and Azure, and cache storage and operations carry no WarpBuild fee there, so the model below applies to hosted runners and the transfer minutes are the only line that survives on BYOC.
Runner rates for the sizes used in the workflow above, with the nearest GitHub-hosted shapes from the GitHub Actions billing reference, checked on 2026-08-13:
| Shape | WarpBuild label | WarpBuild per minute | GitHub-hosted per minute |
|---|---|---|---|
| 2 vCPU, 8 GB | warp-ubuntu-latest-x64-2x | $0.004 | $0.006 |
| 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x | $0.008 | $0.012 |
| 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x | $0.016 | $0.022 |
| 16 vCPU, 64 GB | warp-ubuntu-latest-x64-16x | $0.032 | $0.042 |
Moving 8 GB between one producer and six consumers
Assumptions, stated so you can substitute your own step timings:
- One build job and six test shards, all on
warp-ubuntu-latest-x64-8xat $0.016 per minute. - The build job produces an 8 GB tree that all six shards need.
- 40 runs per weekday and 22 weekdays, so 880 runs per month.
- Artifact path: 4.0 minutes to compress and upload, 3.0 minutes to download and decompress on each shard.
- Cache path: 3.5 minutes to save, 2.5 minutes to restore on each shard.
- Snapshot path: the standing dependency tree and prior build outputs are already on disk at boot, so only a 1.2 GB delta moves, at 0.6 minutes to save and 0.45 minutes to restore on each shard.
| Line | Artifact handoff | Run-scoped cache | Snapshot plus delta cache |
|---|---|---|---|
| Transfer minutes per run | 22.0 | 18.5 | 3.3 |
| Runner minutes per month | $309.76 | $260.48 | $46.46 |
| Cache operations, 7 per run | GitHub artifact billing | $0.62 | $0.62 |
| Cache storage, entry deleted at run end | GitHub artifact billing | $0.98 | $0.15 |
| Snapshot restores, 7 jobs per run | none | none | $246.40 |
| Snapshot storage, one alias | none | none | $18.00 |
| Total priced here | $309.76 | $262.08 | $311.63 |
The line that surprises people
Cache storage looks negligible at $0.20 per GB-month until you multiply by retention. A run-scoped entry with no cleanup job lives until it expires 7 days after its last use, and a 7-day window holds five weekdays of runs, so steady state is 200 live entries of 8 GB, or 1,600 GB. At $0.20 per GB-month that is $320.00 per month, which turns the cache path from $262.08 into $581.10 and makes it the most expensive option on the page.
With the cleanup job from the Configuration section and a 30-minute workflow, each entry lives half an hour. That is 440 entry-hours across 880 runs, or 0.61 entries live on average in a 720-hour month, which is 4.9 GB and $0.98. The delete step is three lines of YAML and it is worth $319.02 per month on this workload.
Reading the snapshot column
The snapshot restore fee is charged per job, so a seven-job workflow pays it seven times. Divide $0.04 by $0.016 per minute and the break-even is 2.5 minutes: a snapshot has to remove more than 2.5 minutes of setup from each of those jobs before it wins on warp-ubuntu-latest-x64-8x. On this workload it removes the transfer and not much else, so the run-scoped cache with a delete step is the cheaper mechanism. On a workflow where each job installs dependencies, pulls container images, and warms a build daemon, the same fee clears the bar easily.
The same 22 minutes of artifact transfer per run on GitHub-hosted runners is priced at $0.022 per minute for the 8 vCPU, 32 GB shape against $0.016 per minute for warp-ubuntu-latest-x64-8x at the same 8 vCPU and 32 GB (GitHub billing reference, checked on 2026-08-13). Across 880 runs that is $425.92 against $309.76, a difference of $116.16 per month on transfer minutes alone, before any change to which mechanism you use.
Every number above is arithmetic over published rates, with the source and the checked-on date attached, so you can rerun it against your own step timings rather than trusting a headline.
FAQ
How do I pass a value from one GitHub Actions job to another?
Write the value to $GITHUB_OUTPUT in a step, republish it under the job's outputs: block, and read it downstream as needs.<job>.outputs.<key>. The consumer job needs a needs edge on the producer, so the value arrives at the cost of serializing those two jobs. Outputs are strings, they are evaluated when the producing job ends, and values matching a secret are redacted.
Can I use a cache to pass files between jobs in the same workflow run?
Yes, with a key scoped to github.run_id and github.run_attempt, and it is best effort rather than a contract. A cache entry is scoped to the key, the cache version, and the branch, and it expires 7 days after its last use, so set fail-on-cache-miss to true on the consumer and delete the entry when the run finishes. When the consumer cannot proceed without the payload, an artifact is the mechanism that guarantees delivery.
Why did a cache saved on a macOS job fail to restore on a Linux job?
The cache version is a hash of the compression tool used on the runner OS and the paths being cached, and entries with different versions are treated as different caches during matching. A cache written on warp-macos-14-arm64-6x therefore cannot restore on warp-ubuntu-latest-x64-4x. Caching is also not supported on Windows-based WarpBuild runners.
Do snapshot runners work for sharing state on Windows or macOS jobs?
No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, on x64 and ARM64. BYOC runners, Windows runners, and macOS runners are not supported, and a snapshot label on those runner types is silently ignored, so the job runs normally with no snapshot behavior and no error to alert you.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.