5. Caches and artifacts
Sections
Reading settings
Natural voice recorded for this course, the same in every browser.
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
Two ways to keep files
Section titled “Two ways to keep files”Every job starts on a fresh machine (lesson 1). GitHub offers two storages that outlive a job, and the documentation insists they “cannot be used interchangeably”:
| Cache | Artifact | |
|---|---|---|
| Holds | files you could regenerate: downloaded packages, intermediate builds | files a job produced: test reports, packages, binaries |
| Shared between | runs of the repository (by key and branch) | jobs of one run, and people who download it |
| Missing? | the job must still work, only slower | the job that needs it fails |
| Lifetime | removed after 7 days without access, 10 GB per repository by default | 90 days by default, retention-days to shorten |
| Actions | actions/cache, or cache: in setup-* actions |
actions/upload-artifact, actions/download-artifact |
| Azure Pipelines | Cache@2 |
PublishPipelineArtifact@1, DownloadPipelineArtifact@2 |
A NuGet cache needs lock files
Section titled “A NuGet cache needs lock files”Lesson 2 measured it: the Maven cache of setup-java saved 6 to 15 seconds per job, while the .NET jobs cached nothing. setup-dotnet has a cache: true input, but its README says the cache key is the hash of packages.lock.json files, and “if lock file does not exist, this action throws error”. The sample project had none.
A NuGet lock file records the exact version and content hash of every package, direct or transitive — the equivalent of package-lock.json for npm. One property in a Directory.Build.props next to the solution turns it on for every project (dotnet/Directory.Build.props):
<Project>
<PropertyGroup> <!-- Write packages.lock.json next to each project: setup-dotnet's cache key is its hash (lesson 5) --> <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> </PropertyGroup>
</Project>A local dotnet restore then wrote Slugs/packages.lock.json (5 lines, no packages) and Slugs.Tests/packages.lock.json (170 lines: the three test packages and their dependencies). Both are committed. In CI, dotnet restore --locked-mode fails instead of silently updating the file when a package no longer matches it.
The workflow
Section titled “The workflow”.github/workflows/gha-05-artifacts.yml has four jobs: test on three OSes with the NuGet cache and a test report, pack builds a NuGet package, cache-demo uses actions/cache directly, and report downloads the artifacts of the first two.
The four jobs, and what they keep: caches come back from earlier runs, artifacts go from one job to another within this run.
flowchart LR nuget[("NuGet cache: one per OS, shared between runs")] expensive[("cache gha-05-expensive-Linux-v1")] subgraph matrix["test: one job per OS"] tu["test (ubuntu-latest)"] tw["test (windows-latest)"] tm["test (macos-latest)"] end pack["pack"] demo["cache-demo"] report["report: needs test and pack"] nuget -.->|"restored"| matrix expensive -.->|"restored"| demo tu -->|"artifact test-results-ubuntu-latest"| report tw -->|"artifact test-results-windows-latest"| report tm -->|"artifact test-results-macos-latest"| report pack -->|"artifact package"| report# GitHub Actions course, lesson 5: dependency caches, actions/cache, build artifacts between jobsname: "GHA 05: caches and artifacts"
on: workflow_dispatch: push: branches: [main] paths: ['.github/workflows/gha-05-artifacts.yml', 'code/github-actions/dotnet/**']
jobs: test: name: test (${{ matrix.os }}) strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} env: NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages defaults: run: working-directory: code/github-actions/dotnet steps: - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: global-json-file: code/github-actions/dotnet/global.json cache: true cache-dependency-path: code/github-actions/dotnet/*/packages.lock.json - run: dotnet restore --locked-mode - run: dotnet test --no-restore --report-xunit-junit --report-xunit-junit-filename slugs.junit.xml --results-directory TestResults - name: Upload the test report if: always() uses: actions/upload-artifact@v7 with: name: test-results-${{ matrix.os }} path: code/github-actions/dotnet/TestResults/ retention-days: 7
pack: runs-on: ubuntu-latest defaults: run: working-directory: code/github-actions/dotnet steps: - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: global-json-file: code/github-actions/dotnet/global.json - run: dotnet pack Slugs/Slugs.csproj --configuration Release --output dist -p:Version=1.0.${{ github.run_number }} - uses: actions/upload-artifact@v7 with: name: package path: code/github-actions/dotnet/dist/*.nupkg if-no-files-found: error
cache-demo: runs-on: ubuntu-latest steps: - name: Restore the cache id: cache uses: actions/cache@v6 with: path: expensive key: gha-05-expensive-${{ runner.os }}-v1 - name: Show the cache-hit output run: echo "cache-hit='${{ steps.cache.outputs.cache-hit }}'" - name: Produce the files only on a miss if: steps.cache.outputs.cache-hit != 'true' run: | mkdir -p expensive date -u +%FT%TZ > expensive/created-at.txt echo "produced" - run: cat expensive/created-at.txt
report: needs: [test, pack] if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v8 with: pattern: test-results-* path: results - uses: actions/download-artifact@v8 with: name: package path: package - run: find results package -type f | sort - name: Tests per OS run: | for f in results/*/slugs.junit.xml; do echo "$(dirname "$f"): $(grep -o 'tests="[0-9]*" failures="[0-9]*"' "$f" | head -1)" done| Element | Why |
|---|---|
NUGET_PACKAGES |
moves the NuGet global packages folder into the workspace, as the setup-dotnet README recommends: the cache then holds only this project’s packages, not what the runner image already has |
cache-dependency-path |
the lock files aren’t at the repository root, where setup-dotnet looks by default |
--report-xunit-junit |
an xUnit v3 option (see dotnet test --help) that writes a JUnit XML report; --results-directory chooses the folder |
if: always() on the upload |
the report is most useful when tests fail |
name: test-results-${{ matrix.os }} |
one artifact per matrix job, with a distinct name |
if-no-files-found: error |
the default is warn: a wrong path would upload nothing and still pass |
Run 1 → run 2: what the cache did
Section titled “Run 1 → run 2: what the cache did”The first run, on the push, found no cache and saved one per OS at the end of the job (the Post step):
test (ubuntu-latest) | Dotnet cache is not foundtest (ubuntu-latest) | Cache saved with the key: dotnet-cache-Linux-557e0cce818be1e496015809f30b8f55625063a595165d73158bbd2bdf0b419cA second run, started with gh workflow run gha-05-artifacts.yml, restored it:
test (ubuntu-latest) | Cache hit for: dotnet-cache-Linux-557e0cce818be1e496015809f30b8f55625063a595165d73158bbd2bdf0b419ctest (ubuntu-latest) | Cache Size: ~54 MB (56306437 B)test (ubuntu-latest) | Cache restored from key: dotnet-cache-Linux-557e0cce818be1e496015809f30b8f55625063a595165d73158bbd2bdf0b419ctest (ubuntu-latest) | Restored /home/runner/work/learn/learn/code/github-actions/dotnet/Slugs.Tests/Slugs.Tests.csproj (in 294 ms).test (ubuntu-latest) | Cache hit occurred on the primary key dotnet-cache-Linux-557e0cce818be1e496015809f30b8f55625063a595165d73158bbd2bdf0b419c, not saving cache.The same hash on the three OSes (the lock files are identical), prefixed with Linux, Windows or macOS: three caches of about 54 MB each, visible with gh cache list.
Now the part that matters — the step durations, from gh api repos/spareilleux/learn/actions/runs/<run-id>/jobs:
| Step | Ubuntu miss → hit | Windows miss → hit | macOS miss → hit |
|---|---|---|---|
setup-dotnet (SDK install + cache restore) |
9 s → 11 s | 35 s → 29 s | 20 s → 12 s |
dotnet restore --locked-mode |
3 s → 1 s | 5 s → 4 s | 3 s → 1 s |
Post setup-dotnet (cache save) |
1 s → 0 s | 5 s → 1 s | 4 s → 0 s |
The restore step itself went from 3 s to 1 s. Downloading 54 MB of cache costs about as much: on this tiny project, the NuGet cache saves almost nothing, and the large variations of the setup-dotnet step come from the SDK download, not from the cache. It pays off when a restore downloads hundreds of packages, which is when you should add it — after measuring, as here. The lock files are worth keeping anyway: they make CI restore exactly what was tested locally.
actions/cache directly
Section titled “actions/cache directly”setup-java and setup-dotnet build the key for you. With actions/cache you choose path and key yourself. The cache-demo job, on the two runs:
Run 1Cache not found for input keys: gha-05-expensive-Linux-v1cache-hit=''produced2026-09-14T13:12:25ZCache saved with key: gha-05-expensive-Linux-v1
Run 2Cache restored from key: gha-05-expensive-Linux-v1cache-hit='true'2026-09-14T13:12:25ZCache hit occurred on the primary key gha-05-expensive-Linux-v1, not saving cache.- On a miss,
cache-hitis an empty string, not'false': test!= 'true', never== 'false'. - The second run printed the date written by the first one: the files came back.
- A cache is never updated: on a hit, nothing is saved, even if the job changed the files. To store new content, change the key — here the
-v1suffix, or ahashFiles()of the files that define the content. - The cache is saved by a Post step, at the end of the job, only if the job succeeded (
post-if: success()in the action’saction.yml).
Which caches a run can see
Section titled “Which caches a run can see”From the documentation: “Workflow runs can restore caches created in either the current branch or the default branch”, plus the base branch for a pull request; not caches of child or sibling branches. A cache created by a pull_request run belongs to the merge ref and “can only be restored by re-runs of the pull request”. In practice: let a push to main fill the caches, and every branch and pull request starts from them.
Artifacts
Section titled “Artifacts”Each test job uploaded its report, pack uploaded the package:
test (ubuntu-latest) | Artifact test-results-ubuntu-latest has been successfully uploaded! Final size is 510 bytes. Artifact ID is 10348099499pack | Successfully created package '/home/runner/work/learn/learn/code/github-actions/dotnet/dist/Slugs.1.0.1.nupkg'.pack | Artifact package has been successfully uploaded! Final size is 3463 bytes. Artifact ID is 10349121193report downloaded them. With pattern, each matching artifact goes into its own sub-folder (merge-multiple: true would put all files in one folder):
Found 4 artifact(s)Filtering artifacts by pattern 'test-results-*'Total of 3 artifact(s) downloadedpackage/Slugs.1.0.1.nupkgresults/test-results-macos-latest/slugs.junit.xmlresults/test-results-ubuntu-latest/slugs.junit.xmlresults/test-results-windows-latest/slugs.junit.xmlresults/test-results-macos-latest: tests="4" failures="0"results/test-results-ubuntu-latest: tests="4" failures="0"results/test-results-windows-latest: tests="4" failures="0"Expiry dates, from gh api repos/spareilleux/learn/actions/runs/<run-id>/artifacts: 7 days for the reports (retention-days: 7), 90 days for the package — the repository’s default, the maximum allowed for a public repository.
The same artifacts on your machine, with the GitHub CLI:
gh run download 34848185308 --repo spareilleux/learn --name package --dir packagegh run download 34848185308 --repo spareilleux/learn --pattern "test-results-*" --dir resultspackage/Slugs.1.0.2.nupkgresults/test-results-macos-latest/slugs.junit.xmlresults/test-results-ubuntu-latest/slugs.junit.xmlresults/test-results-windows-latest/slugs.junit.xmlKey takeaways
Section titled “Key takeaways”- Cache = regenerable files shared between runs; artifact = files produced by a run, shared between its jobs and with people.
setup-dotnetcache: truerequirespackages.lock.json(RestorePackagesWithLockFile); restore with--locked-mode.- Measure: here the NuGet cache turned a 3 s restore into 1 s, and cost as much to download.
cache-hitis'true'or empty; a cache is immutable, so change the key to refresh it.- Give every matrix job its own artifact name, set
if-no-files-found: error, and shortenretention-daysfor reports.
Exercises
Section titled “Exercises”- Two matrix jobs (
ubuntu-latest,macos-latest) upload an artifact with the same name,os, containing a file with the OS name. A later job downloadsname: os. What happens?
Solution
The upload-artifact README says “Artifacts created by upload-artifact@v4 are immutable”, and warns that in a matrix, uploading to the same artifact means “you will encounter conflict errors”. That’s not what happened in gha-05-exercises.yml, three times: both uploads succeeded and the run had two artifacts named os.
same-name (macos-latest) | Artifact os has been successfully uploaded! Final size is 141 bytes. Artifact ID is 10348738133same-name (ubuntu-latest) | Artifact os has been successfully uploaded! Final size is 142 bytes. Artifact ID is 10348957530same-name-download | Downloading single artifactsame-name-download | ubuntu-latestThe download picked one of them without any warning — the Ubuntu one, both in the job and with gh run download --name os. Which one is chosen isn’t documented: to verify whether it’s stable. Either way, a report can silently disappear. Put the matrix value in the name.
- Remove the
cache-dependency-pathinput and usedotnet-version: 10.0.xinstead ofglobal-json-file, keepingcache: true. What does the job do?
Solution
It fails in the setup-dotnet step, after installing the SDK (setup-dotnet/src/cache-restore.ts):
Without cache-dependency-path, the action looks for packages.lock.json at the repository root only. The cache isn’t optional once you ask for it: no lock file, no job.
- A step uses
key: counter-${{ github.run_id }}andrestore-keys: counter-, then adds one to a number stored in the cached folder. What docache-hitand the counter show on the first run, and on the second?
Solution
Run 1Cache not found for input keys: gha-05-counter-34847802892, gha-05-counter-cache-hit=''counter is now 1Cache saved with key: gha-05-counter-34847802892
Run 2Cache restored from key: gha-05-counter-34847802892cache-hit='false'counter is now 2Cache saved with key: gha-05-counter-34848189352On run 2 the exact key didn’t exist (new run_id), but the prefix gha-05-counter- matched the most recent cache: cache-hit is 'false' — a partial match — the files are restored anyway, and since the exact key was missed, a new cache is saved at the end. That’s how you build an incremental cache that keeps improving, at the cost of one new cache entry per run.