Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

555 lines · 34.1 KBRaw History
1---
2title: Caching and artifacts
3description: Speed up workflows with actions/cache, pass files between jobs and runs with upload-artifact and download-artifact, and manage both through the API.
4---
5
6A cache keeps files such as dependency downloads between runs. An artifact keeps files produced by a run, so later jobs (in the same run or another one) can use them and you can download them afterwards. Diffinery serves the same cache and artifact services as GitHub, so the official `actions/cache`, `actions/upload-artifact` and `actions/download-artifact` work unmodified, and the runner also has faster built-in versions that share their storage and formats.
7
8## Built-in actions and the official actions
9
10For speed, the runner runs its own implementation of these actions instead of downloading them:
11
12| Action | Built-in for | What it does |
13| --- | --- | --- |
14| `actions/cache` | `v3` to `v6` | Restores a cache entry, then saves one in a post step when the job ends |
15| `actions/cache/restore` | `v3` to `v6` | Restores a cache entry only |
16| `actions/cache/save` | `v3` to `v6` | Saves a cache entry only |
17| `actions/upload-artifact` | `v4` to `v7` | Uploads files as an artifact of the run |
18| `actions/download-artifact` | `v4` to `v8` | Downloads artifacts of this run, or of another run with `run-id` and `github-token` |
19
20The built-ins use the same services, archive formats, cache versions and artifact ids as the official actions. An entry saved by the built-in `actions/cache` restores with the official action and the other way round, and an artifact uploaded by either downloads with either.
21
22A built-in steps aside and the real action is downloaded from GitHub and run when:
23
24- the reference isn't a version tag in the range above (`vN`, `vN.N` or `vN.N.N`), for example a commit SHA, a branch, or a newer major version;
25- the step passes an input the built-in doesn't know;
26- the step's environment has `DIFFINERY_BUILTINS` set to `false`, `0`, `no` or `off` (in the workflow's, the job's or the step's `env:`);
27- a self-hosted runner was started with `DIFFINERY_DISABLE_BUILTINS=1`.
28
29```yaml
30env:
31 DIFFINERY_BUILTINS: false # every step in this workflow runs the real actions
32```
33
34The `cache` input of `actions/setup-node`, `actions/setup-python` and `actions/setup-go` uses the same service; see [Dependency caching with setup actions](#dependency-caching-with-setup-actions).
35
36Third-party actions that use the `@actions/cache` or `@actions/artifact` toolkit packages, and `docker/build-push-action` with `cache-from: type=gha` and `cache-to: type=gha`, work as well (see [The runtime services](#the-runtime-services)).
37
38## Caching dependencies
39
40A cache entry is a compressed archive of one or more paths, stored under a key you choose. A typical workflow restores the entry at the start of a job and saves it at the end:
41
42```yaml
43name: CI
44on: [push, pull_request]
45
46jobs:
47 test:
48 runs-on: ubuntu-latest
49 steps:
50 - uses: actions/checkout@v4
51 - uses: actions/cache@v4
52 id: npm-cache
53 with:
54 path: ~/.npm
55 key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
56 restore-keys: |
57 npm-${{ runner.os }}-
58 - run: npm ci
59 - run: npm test
60```
61
62On the first run there is no entry, so the step logs `Cache not found for input keys: ...` and the job continues. When the job succeeds, the post step **Post actions/cache@v4** archives `~/.npm` and saves it under the key (`Cache saved with key: <key>`). The next run with the same `package-lock.json` restores it (`Cache restored from key: <key>`).
63
64### Inputs for actions/cache
65
66| Input | Required | Default | Description |
67| --- | --- | --- | --- |
68| `path` | Yes | | Files and directories to cache, one per line. Supports globs and `!` exclusions (`! pattern` is read as `!pattern`). Relative paths are relative to the workspace; `~/` expands to the home directory. |
69| `key` | Yes | | Key to save the entry under and to look up first. At most 512 characters, no commas. |
70| `restore-keys` | No | | Prefixes to try, one per line, in order, when `key` has no exact match. At most 20. |
71| `lookup-only` | No | `false` | Check whether an entry exists without downloading it. Logs `Cache found and can be restored from key: <key>`. |
72| `fail-on-cache-miss` | No | `false` | Fail the step when neither `key` nor any restore key matches. |
73| `save-always` | No | `false` | Save in the post step even when the job failed or was cancelled. |
74| `upload-chunk-size`, `enableCrossOsArchive` | No | | Accepted. The built-in uploads in 16 MiB blocks, 8 at a time (8 MiB blocks, 4 at a time, on the 1 GiB `tiny` runner), whatever `upload-chunk-size` says. |
75
76`actions/cache/restore` and `actions/cache/save` take the same inputs. If `key` or `path` is missing, the step fails with `Input required and not supplied: key` or `Input required and not supplied: path`.
77
78### Outputs for actions/cache
79
80| Output | Set by | Value |
81| --- | --- | --- |
82| `cache-hit` | `actions/cache`, `actions/cache/restore` | `true` when the restored entry's key equals `key` (ignoring case), `false` when a restore key matched. Not set on a miss. |
83| `cache-primary-key` | `actions/cache/restore` | The `key` input, always. |
84| `cache-matched-key` | `actions/cache/restore` | The key of the entry that was restored. Not set on a miss. |
85
86Use `cache-hit` to skip work when the exact entry was restored. Compare with `'true'`, because the output is empty on a miss:
87
88```yaml
89 - uses: actions/cache@v4
90 id: deps
91 with:
92 path: node_modules
93 key: modules-${{ hashFiles('package-lock.json') }}
94 - if: steps.deps.outputs.cache-hit != 'true'
95 run: npm ci
96```
97
98### How keys and restore keys match
99
100An entry has a key, a version and a scope. The version is a hash of the `path` lines and the compression, computed the way `@actions/cache` computes it, so an entry only restores for the same paths saved with the same compression. The scope is the ref of the job that saved it.
101
102When a restore step runs, Diffinery searches these scopes in order, and stops at the first match:
103
1041. The job's own ref, for example `refs/heads/feature-x`, `refs/tags/v1.2.0` or, for a `pull_request` run, `refs/pull/7/merge`.
1052. For pull request runs, the pull request's base branch. For merge queue runs, the queue's target branch (`merge_group.base_ref`).
1063. The default branch.
107
108In each scope it tries:
109
1101. An entry whose key equals `key` exactly.
1112. The newest entry whose key starts with `key`.
1123. For each line of `restore-keys`, in order, the newest entry whose key starts with that line.
113
114```yaml
115key: build-${{ github.ref_name }}-${{ hashFiles('**/go.sum') }}
116restore-keys: |
117 build-${{ github.ref_name }}-
118 build-
119```
120
121With this configuration, a run on a feature branch first looks for the exact key and the newest `build-` entry saved on that branch, then on the default branch.
122
123### Scope
124
125A job saves only to its own ref. Branches, tags and pull requests read their own entries and those of the default branch (and pull requests those of their base branch), but never each other's. This is the same isolation GitHub has, so a pull request can't plant a cache entry that a release job on the default branch later restores.
126
127Different repositories never share entries, even within one organization.
128
129### When the cache is saved
130
131`actions/cache` saves in a post step that runs after all other steps of the job:
132
133- It runs only if the job succeeded so far. With `save-always: true` it runs even after a failure or cancellation.
134- It's skipped when the restore found an exact match for `key`: `Cache hit occurred on the primary key <key>, not saving cache.`
135- It archives whatever exists at `path` at that moment. If none of the paths exist, it logs the warning `Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.`
136
137Entries are immutable. If an entry with the same key and version already exists in the job's scope, or another running job is saving it, the save logs `Failed to save: Unable to reserve cache with key <key>, another job may be creating this cache.` and the job continues. To refresh an entry, change its key, for example by including a hash of the lock file. An upload left unfinished by a job that has ended is discarded and the key can be saved again.
138
139`actions/cache/save` saves immediately when its step runs, so you can save a cache mid-job or from a step with its own `if:`:
140
141```yaml
142 - uses: actions/cache/restore@v4
143 id: restore
144 with:
145 path: .build-cache
146 key: build-${{ github.sha }}
147 restore-keys: build-
148 - run: make build
149 - uses: actions/cache/save@v4
150 if: always()
151 with:
152 path: .build-cache
153 key: ${{ steps.restore.outputs.cache-primary-key }}
154```
155
156### Archive format
157
158The built-in creates the same archive as `@actions/cache`: a POSIX tar file of the matched paths, stored relative to the workspace, compressed with zstd when `zstd` is installed (it is on hosted runners) and with gzip otherwise. Restoring extracts it to the same paths relative to the workspace.
159
160Restoring is built for speed. The runner downloads the archive in 8 MiB ranges, 16 at a time (4 on the 1 GiB `tiny` runner), and pipes them straight into `tar`, so the archive extracts while it is still arriving. On hosted runners a 216 MB pnpm store restores in about 3 to 5 seconds. Every range must come from the same stored entry; if one doesn't, or a range keeps failing after its retries, the log says `Streaming the cache entry failed (<reason>); downloading it in full and extracting it again.` and the runner downloads the whole archive to a file and extracts it again, which replaces anything the stream left half done. If that fails too, the restore removes what it had extracted and behaves like a miss, so a job never runs with half a cache. Artifact downloads use the same ranged download.
161
162Saving is built for speed too. Files of up to 32 MiB go up in one request; larger ones go up in 16 MiB blocks, 8 at a time (8 MiB blocks, 4 at a time, on the `tiny` runner). Diffinery then joins the blocks into one stored object. Objects over 16 MiB are joined as 8 MiB parts, copied by several workers at once, so a 200 MB artifact or cache entry is stored within seconds of its last block. The same applies to uploads from the official actions.
163
164- The compression is part of the version, so an entry saved on a runner with zstd doesn't restore on one without it.
165- A path outside the workspace, such as `~/.npm`, is stored relative to the workspace too. A runner whose workspace sits at a different depth restores it into a different directory. Include `runner.environment` or a label in the key if you mix runner types.
166- The step log shows the archive size as `Cache Size: ~<n> MB (<bytes> B)` on both save and restore.
167
168### Size and eviction
169
170| Limit | Value |
171| --- | --- |
172| Key | 512 characters, no commas. A longer key or a comma fails the restore step with `Key Validation Error: <key> cannot be larger than 512 characters.` or `Key Validation Error: <key> cannot contain commas.` |
173| Restore keys | 20 per lookup |
174| One entry | 10 GB. A larger archive isn't saved: `Cache size of ~<n> MB (<bytes> B) is over the 10GB limit, not saving cache.` |
175| Repository total | 10 GB of entries, counting uploads in progress |
176| One upload request | 256 MiB. The built-ins and the official actions upload large archives in smaller blocks, so this only matters for your own clients. |
177
178Diffinery deletes entries in two ways:
179
180- Entries that no job has restored for 7 days are deleted the next time a job saves a cache in the repository.
181- When a save needs room above the 10 GB total, the least recently used entries are deleted until it fits. Restoring an entry counts as using it.
182
183If uploads still in progress use the whole budget, the save is refused (`uploads in progress use the repository's whole cache budget`) and the job continues with a warning.
184
185### Manage caches with the API
186
187List, inspect and delete cache entries with the REST API. Reading needs read access to the repository; deleting needs write access. For a job's `GITHUB_TOKEN`, these endpoints need the `actions` permission.
188
189List entries:
190
191```http
192GET /api/v3/repos/acme/website/actions/caches?key=npm-&ref=refs/heads/main
193Authorization: Bearer dfy_...
194```
195
196```json
197{
198 "total_count": 1,
199 "actions_caches": [
200 {
201 "id": 42,
202 "ref": "refs/heads/main",
203 "key": "npm-Linux-3f1c...",
204 "version": "a1b2c3...",
205 "last_accessed_at": "2026-10-12T09:41:07Z",
206 "created_at": "2026-10-10T08:02:51Z",
207 "size_in_bytes": 48211301
208 }
209 ]
210}
211```
212
213| Query parameter | Meaning |
214| --- | --- |
215| `key` | Only entries whose key starts with this |
216| `ref` | Only entries saved for this ref, for example `refs/heads/main` or `refs/pull/7/merge` |
217| `sort` | `last_accessed_at` (default), `created_at` or `size_in_bytes` |
218| `direction` | `desc` (default) or `asc` |
219| `per_page`, `page` | Paging; `per_page` defaults to 30, at most 100 |
220
221Other endpoints:
222
223| Request | Result |
224| --- | --- |
225| `GET /repos/{owner}/{repo}/actions/cache/usage` | `{"full_name": "acme/website", "active_caches_size_in_bytes": ..., "active_caches_count": ...}` |
226| `DELETE /repos/{owner}/{repo}/actions/caches?key=<key>&ref=<ref>` | Deletes the entries with exactly this key (in every scope, or only `ref`'s). Returns `{"total_count": n, "actions_caches": [...]}` with the deleted entries. `422 key is required` without `key`; `404 Not Found` when nothing matches. |
227| `DELETE /repos/{owner}/{repo}/actions/caches/{cache_id}` | Deletes one entry. `204 No Content`, or `404 Not Found`. |
228
229```sh
230diffinery api GET "/repos/acme/website/actions/caches?key=npm-"
231diffinery api DELETE "/repos/acme/website/actions/caches?key=npm-Linux-3f1c..."
232```
233
234Deletions are recorded in the [audit log](/docs/security/audit-log) as `actions.cache.delete`.
235
236### Errors never fail the job
237
238Cache service problems are reported as warnings and the job carries on, as on GitHub:
239
240- `Failed to restore: <reason>` on restore. The step behaves like a miss, and nothing the restore extracted before it failed is left behind.
241- `Failed to save: <reason>` on save.
242- `The cache service is not available to this job; skipping.` when the job has no runtime services.
243
244The exceptions are a key validation error (see the table above) and `fail-on-cache-miss: true`, which fails the step with `Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: <key>`.
245
246## Dependency caching with setup actions
247
248`actions/setup-node`, `actions/setup-python` and `actions/setup-go` can restore and save a dependency cache for you. They use the same service, scopes and limits as `actions/cache`, and the same keys and paths as the official setup actions, so a cache saved by the built-in restores with the official action and the other way round. The save happens in a post step only when the job succeeds, the exact key wasn't restored, and the cache directory exists.
249
250| Action | Input | Cached directory | Files hashed by default | Key on a hosted runner |
251| --- | --- | --- | --- | --- |
252| `actions/setup-node` | `cache: npm`, `pnpm` or `yarn` | `npm config get cache`, `pnpm store path`, or the Yarn cache folder | The first of `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` in the workspace root (npm); `pnpm-lock.yaml` (pnpm); `yarn.lock` (yarn) | `node-cache-Linux-x64-<manager>-<hash>` |
253| `actions/setup-python` | `cache: pip` | `pip cache dir` | `**/requirements.txt`, or `**/pyproject.toml` when none match | `setup-python-Linux-x64-<os version>-<os name>-python-<version>-pip-<hash>`, with the part before the hash as a restore key |
254| `actions/setup-python` | `cache: pipenv` | `~/.local/share/virtualenvs` | `**/Pipfile.lock` | `setup-python-Linux-x64-python-<version>-pipenv-<hash>` |
255| `actions/setup-go` | `cache` (on by default) | `GOMODCACHE` and `GOCACHE` | `go.sum` in the workspace root | `setup-go-Linux-x64-ubuntu24-go-<version>-<hash>` |
256
257All three accept `cache-dependency-path` to hash different files, one pattern per line, and set the `cache-hit` output (`true` or `false`). `actions/setup-node` also sets `cache-primary-key`.
258
259```yaml
260 - uses: actions/setup-node@v4
261 with:
262 node-version: 22
263 cache: pnpm
264 cache-dependency-path: apps/web/pnpm-lock.yaml
265```
266
267Details to be aware of:
268
269- `actions/setup-node@v5` and later turn on npm caching by themselves when `package.json` names npm in `packageManager` or `devEngines.packageManager`, and log `Detected npm as the package manager from package.json's packageManager field. ...`. Set `package-manager-cache: false` to turn it off.
270- If setup-node's lock file is missing, the step fails with `Dependencies lock file is not found in <workspace>. Supported file patterns: <files>`. If `cache-dependency-path` matches nothing, it fails with `Some specified paths were not resolved, unable to cache dependencies.`
271- If setup-python's patterns match nothing, the step fails with `No file in <workspace> matched to [<patterns>], make sure you have checked out the target repository`.
272- `actions/setup-go` doesn't fail without a `go.sum`: it logs the warning `Restore cache failed: Dependencies lock file is not found in <workspace>. Supported file patterns: go.sum` and continues. Set `cache: false` to turn caching off.
273- On a miss the log says `<manager> cache is not found`, for example `npm cache is not found`.
274- `cache` values the built-ins don't handle (for example `cache: poetry` in setup-python) run the official action instead.
275
276## Artifacts
277
278An artifact is a named file, normally a zip archive, that belongs to a workflow run. Jobs in the same run download it with `actions/download-artifact`, other runs can download it with `run-id` and a token, and anyone with read access to the repository can download it from the run page or the API until it expires.
279
280### Upload an artifact
281
282```yaml
283 - run: npm run build
284 - uses: actions/upload-artifact@v4
285 id: upload
286 with:
287 name: site
288 path: |
289 dist/
290 !dist/**/*.map
291 if-no-files-found: error
292 retention-days: 14
293```
294
295| Input | Required | Default | Description |
296| --- | --- | --- | --- |
297| `name` | No | `artifact` | Artifact name. It can't contain `"`, `:`, `<`, `>`, `\|`, `*`, `?`, `\`, `/`, carriage return or line feed. |
298| `path` | Yes | | Files, directories or globs to upload, one per line. Lines starting with `!` exclude matches, and lines starting with `#` are ignored. Relative paths are relative to the workspace. |
299| `if-no-files-found` | No | `warn` | `warn` logs a warning, `error` fails the step, `ignore` logs a plain message. |
300| `retention-days` | No | 90 | Days to keep the artifact, from 1 to 90. |
301| `compression-level` | No | `6` | Zip compression level, `0` (none) to `9` (best). Use `0` for files that are already compressed. |
302| `overwrite` | No | `false` | Replace an artifact with the same name in the run instead of failing. |
303| `include-hidden-files` | No | `false` | Include files and directories whose names start with `.`. |
304| `archive` | No | `true` | `false` uploads a single file as it is, without a zip archive. The artifact is then named after the file. |
305
306The step logs `With the provided path, there will be <n> file(s) uploaded`, then `Artifact <name> has been successfully uploaded! Final size is <n> bytes. Artifact ID is <id>`.
307
308### Outputs of upload-artifact
309
310| Output | Value |
311| --- | --- |
312| `artifact-id` | The artifact's numeric id |
313| `artifact-digest` | The SHA-256 digest of the uploaded file, in hex |
314| `artifact-url` | `https://diffinery.com/acme/website/actions/runs/<run number>/artifacts/<id>`, a link that downloads the artifact |
315
316### What goes into the archive
317
318The runner collects the matching files, then archives them relative to the deepest directory that contains all of them, as `actions/upload-artifact` does:
319
320| `path` | Files matched | Paths inside the artifact |
321| --- | --- | --- |
322| `dist/` | `dist/index.html`, `dist/assets/app.js` | `index.html`, `assets/app.js` |
323| `dist/index.html` | `dist/index.html` | `index.html` |
324| `dist/` and `reports/junit.xml` | `dist/index.html`, `reports/junit.xml` | `dist/index.html`, `reports/junit.xml` |
325
326Only files are uploaded; empty directories are dropped. Hidden files are left out unless `include-hidden-files: true`. The archive is a zip file.
327
328With `archive: false`, exactly one file may match; otherwise the step fails with `When 'archive' is set to false, only a single file can be uploaded. Found <n> files to upload.` The file is stored with a content type from its extension, for example `text/html` for `.html`.
329
330### Download artifacts in a later job
331
332The job must run after the job that uploaded the artifact, so list that job in `needs`:
333
334```yaml
335jobs:
336 build:
337 runs-on: ubuntu-latest
338 steps:
339 - uses: actions/checkout@v4
340 - run: npm ci && npm run build
341 - uses: actions/upload-artifact@v4
342 with:
343 name: site
344 path: dist/
345
346 deploy:
347 needs: build
348 runs-on: ubuntu-latest
349 steps:
350 - uses: actions/download-artifact@v4
351 id: get-site
352 with:
353 name: site
354 path: public
355 - run: ls -R ${{ steps.get-site.outputs.download-path }}
356```
357
358| Input | Default | Description |
359| --- | --- | --- |
360| `name` | | Download only the artifact with this name. |
361| `artifact-ids` | | Download only these artifacts, by id, separated by commas. Can't be combined with `name`. |
362| `pattern` | | Download the artifacts whose names match this glob, for example `junit-*`. |
363| `path` | The workspace | Directory to extract into, relative to the workspace. It's created if missing. |
364| `merge-multiple` | `false` | Extract every selected artifact into `path` itself instead of one directory per artifact. |
365| `skip-decompress` | `false` | Save the zip file instead of extracting it. |
366| `digest-mismatch` | `error` for `v8` and later, `warn` before | What to do when a download doesn't match the artifact's digest: `ignore`, `info`, `warn` or `error`. |
367| `github-token`, `run-id`, `repository` | | Download from another run or repository; see below. |
368
369With no `name`, `artifact-ids` or `pattern`, every artifact of the run is downloaded. Where they go:
370
371- With `name`, or `merge-multiple: true`, files go straight into `path`.
372- Otherwise each artifact goes into `<path>/<artifact name>`. From `v5` on, a download that selects exactly one artifact goes straight into `path`.
373
374The step sets the output `download-path` to the absolute directory, and logs `Artifact '<name>' downloaded to <path>` for each artifact.
375
376### Download from another run or repository
377
378Pass `run-id` and a token in `github-token` to read another run's artifacts through the REST API. Add `repository` for a run in another repository:
379
380```yaml
381jobs:
382 report:
383 runs-on: ubuntu-latest
384 permissions:
385 actions: read
386 steps:
387 - uses: actions/download-artifact@v4
388 with:
389 name: coverage
390 run-id: ${{ github.event.workflow_run.id }}
391 github-token: ${{ secrets.GITHUB_TOKEN }}
392```
393
394- `run-id` is a run number, such as `github.run_id` or `github.event.workflow_run.id`. It covers every attempt of that run.
395- The job's `GITHUB_TOKEN` can read another run of its own repository when the job has `actions: read` (or the repository default gives it). It can read artifacts of other **public** repositories on Diffinery, but not of other private ones; use a personal or agent token with read access for those. See [GITHUB_TOKEN permissions](/docs/actions/workflow-permissions).
396- `run-id` without `github-token` is ignored, and the current run is read.
397
398Without these inputs, `actions/download-artifact` reads artifacts from the job's own run only. Jobs of a [reusable workflow](/docs/actions/reusable-workflows) are part of the caller's run, so the caller and the called workflow can exchange artifacts. A re-run attempt is a separate run for this purpose: its jobs don't see the artifacts of earlier attempts without `run-id`, except those of the jobs a failed-job or single-job re-run kept from an earlier attempt (see [Re-run a run](/docs/actions/overview#re-run-a-run)), which belong to the new attempt too.
399
400### Uploading the same name twice
401
402Artifact names are unique within a run. Uploading a name that already exists fails:
403
404```text
405Failed to CreateArtifact: an artifact with the name 'site' already exists on the workflow run. Set 'overwrite: true' to replace it.
406```
407
408Set `overwrite: true` to delete the old artifact and upload the new one. In a matrix, give each job its own name:
409
410```yaml
411 strategy:
412 matrix:
413 node: [20, 22]
414 steps:
415 - run: npm test -- --reporter junit > junit.xml
416 - uses: actions/upload-artifact@v4
417 with:
418 name: junit-node-${{ matrix.node }}
419 path: junit.xml
420```
421
422A later job can collect them all with `pattern: junit-*` and `merge-multiple: true`.
423
424### Retention and expiry
425
426Each artifact keeps its `retention-days` from 1 to 90 days, 90 when you don't set one. A value above 90 is lowered with the warning `Retention days cannot be greater than the maximum allowed retention set within the repository. Using 90 instead.`
427
428When an artifact expires:
429
430- Its content is deleted, and it no longer counts toward the storage quota.
431- The run page no longer lists it, and download-artifact no longer finds it.
432- The REST API still lists it, with `"expired": true`, for another 90 days. Downloading it answers `410 Artifact has expired`.
433
434### Storage quota
435
436By default a repository can store up to 10 GB of artifacts. Live artifacts and uploads in progress count; expired and deleted artifacts don't. An upload beyond the quota fails the step with a message that ends in:
437
438```text
439the repository's artifact storage quota (10737418240 bytes) is used up; delete artifacts or wait for them to expire
440```
441
442Delete artifacts you no longer need with the API (see below). A job's unfinished uploads are removed when the job ends. An instance's administrators change the quota with `ARTIFACT_QUOTA_BYTES` (see [Configuration](/docs/self-hosting/configuration)).
443
444## Download artifacts from the web
445
446Open the run at `https://diffinery.com/<owner>/<repo>/actions/runs/<run number>`. When the run has artifacts that haven't expired, an **Artifacts** box lists each one with its size and upload time. Select a name to download it as `<name>.zip`, or as the file itself for an artifact uploaded with `archive: false`.
447
448The download link is `https://diffinery.com/<owner>/<repo>/actions/runs/<run>/artifacts/<name or id>`, the same form as the `artifact-url` output. Extract a zip file with:
449
450```sh
451unzip site.zip -d site
452```
453
454You need read access to the repository. For a public repository, anyone can download. Artifacts uploaded by older Diffinery runner versions download as `<name>.tar.gz`.
455
456## Download artifacts with the API
457
458The REST API has GitHub's artifact endpoints. Reading needs read access to the repository; deleting needs write access. For a job's `GITHUB_TOKEN`, they need the `actions` permission.
459
460| Request | Result |
461| --- | --- |
462| `GET /repos/{owner}/{repo}/actions/artifacts` | Every artifact of the repository, newest first, expired ones included. Takes `name`, `per_page` (default 30, at most 100) and `page`. |
463| `GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | The artifacts of one run, expired ones included. `run_id` is the run number (every attempt) or a run's id. Takes `name`, `per_page` and `page`. |
464| `GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | One artifact. |
465| `GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip` | `302 Found` to a signed download URL valid for 10 minutes. `410 Artifact has expired` for an expired artifact. |
466| `DELETE /repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | Deletes the artifact. `204 No Content`. Recorded in the [audit log](/docs/security/audit-log) as `actions.artifact.delete`. |
467
468Both lists send a `Link` header, so `octokit.paginate` works. Artifacts uploaded by older Diffinery runners are listed, fetched and deleted the same way and kept for 90 days; their download is a gzip-compressed tar file (`<name>.tar.gz`) rather than a zip. Unknown ids answer `404 Not Found`. An artifact looks like this:
469
470```json
471{
472 "id": 1187,
473 "node_id": "MDg6QXJ0aWZhY3Q1187",
474 "name": "site",
475 "size_in_bytes": 482113,
476 "url": "https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187",
477 "archive_download_url": "https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187/zip",
478 "expired": false,
479 "digest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
480 "created_at": "2026-10-12T09:41:07Z",
481 "updated_at": "2026-10-12T09:41:07Z",
482 "expires_at": "2026-10-26T09:41:07Z",
483 "workflow_run": {
484 "id": 128,
485 "repository_id": "...",
486 "head_repository_id": "...",
487 "head_branch": "main",
488 "head_sha": "9fceb02d0ae598e95dc970b74767f19372d61af8"
489 }
490}
491```
492
493`workflow_run.id` is the run number. Download with the CLI's API command or curl; curl follows the redirect with `-L`:
494
495```sh
496curl -fsSL -H "Authorization: Bearer dfy_..." \
497 https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187/zip -o site.zip
498```
499
500Install the CLI with `curl -fsSL https://diffinery.com/install | bash` and sign in with `diffinery login`, then list a run's artifacts:
501
502```sh
503diffinery api GET /repos/acme/website/actions/runs/128/artifacts
504```
505
506Artifacts uploaded by older Diffinery runner versions are in these endpoints too, as `.tar.gz` downloads.
507
508## The runtime services
509
510Diffinery gives each job the same runtime services GitHub does, through these environment variables:
511
512| Variable | Value |
513| --- | --- |
514| `ACTIONS_RUNTIME_TOKEN` | A token for this job's cache and artifact requests. It works only while the job runs and expires 30 minutes after the job's timeout. |
515| `ACTIONS_RESULTS_URL` | `https://diffinery.com/` |
516| `ACTIONS_CACHE_URL`, `ACTIONS_RUNTIME_URL` | `https://diffinery.com/_runtime/` |
517| `ACTIONS_CACHE_SERVICE_V2` | `true` |
518
519As on GitHub, they're given to JavaScript actions, Docker container actions and the built-ins, not to `run:` steps. So `docker buildx build --cache-to type=gha` in a `run:` step finds no cache service, while `docker/build-push-action`, a JavaScript action, runs the build with them.
520
521The services speak the protocols the `@actions/cache` and `@actions/artifact` toolkit packages use: cache service v2 and artifact service v4, the older cache REST API, and signed upload and download URLs. A signed download URL is valid for one hour. Requests from a job that has finished fail with `401 the job is not running`.
522
523For Docker layer caching you can use `type=gha` through `docker/build-push-action`, which stores the layers as cache entries under the same 10 GB budget, or keep the cache in the [container registry](/docs/packages/container-registry) with `type=registry`, for example `diffinery.com/acme/website:buildcache`. See [Docker in workflows](/docs/actions/docker).
524
525## Differences from GitHub
526
527| Area | GitHub Actions | Diffinery |
528| --- | --- | --- |
529| How the actions run | Downloaded from GitHub | Built into the runner for the listed versions; the real action otherwise |
530| Cache scope, size and eviction | Per ref with fallbacks, 10 GB, 7-day idle eviction | The same |
531| Artifact storage | Counted against the account's storage | 10 GB per repository by default, then uploads are refused |
532| Artifact retention | Set per repository or organization | 1 to 90 days, 90 by default |
533| Web page for caches | Yes | No; use the API |
534| `artifact-url` | Points at github.com | Points at Diffinery |
535| `run-id` in download-artifact | The run's id | The run number (`github.run_id`), which is the same thing on Diffinery |
536
537## Troubleshooting
538
539| Symptom | Cause and fix |
540| --- | --- |
541| `Cache not found for input keys: ...` on every run | The save never happened, or it's in a scope this run can't read. Check the post step's log: the job may have failed (use `save-always: true`), the paths may not exist. An entry saved on a feature branch isn't visible to other branches; save on the default branch for shared entries. |
542| `Failed to save: Unable to reserve cache with key <key>, another job may be creating this cache.` | An entry with this key already exists for this ref, or another job is saving it. Include something that changes in the key, such as `hashFiles(...)`, and use `restore-keys` to pick up the latest. |
543| `Key Validation Error: ... cannot contain commas.` | Remove commas from `key` and `restore-keys`. |
544| `cache-hit` is `false` although files were restored | A restore key matched, not `key`. That's expected. |
545| A cache saved by one runner doesn't restore on another | The version includes the paths and the compression. Use the same `path` lines, and make sure `zstd` is installed on both or neither. |
546| Restored files appear in an unexpected directory | Paths are stored relative to the workspace. A runner with a different workspace depth restores into other directories. Add the runner type to the key. |
547| `The artifact name is not valid: <name>. Contains the following character: ...` | Remove `/`, `\`, `:` and the other characters listed in [Upload an artifact](#upload-an-artifact). |
548| `No files were found with the provided path: ... No artifacts will be uploaded.` | The paths don't match any files, or only hidden files. Check the paths relative to the workspace, or set `include-hidden-files: true`. |
549| `Failed to CreateArtifact: an artifact with the name '<name>' already exists on the workflow run. ...` | Set `overwrite: true`, or include matrix values in the name. |
550| `... artifact storage quota ... is used up; delete artifacts or wait for them to expire` | Delete old artifacts with `DELETE /repos/{owner}/{repo}/actions/artifacts/{artifact_id}`, or use shorter `retention-days`. |
551| `Unable to download artifact(s): Artifact not found for name: <name>` | No job in this run has uploaded that name yet, or it expired. Add the uploading job to `needs`, and check the name's spelling and case. |
552| `Inputs 'name' and 'artifact-ids' cannot be used together. Please specify only one.` | Remove one of the two inputs. |
553| `Digest validation failed for artifact(s): ...` | The downloaded file doesn't match the digest recorded at upload. Download again; set `digest-mismatch: warn` to continue anyway. |
554| `Listing artifacts of run <n> in <repo> failed: HTTP 403` | The token in `github-token` lacks access. Give the job `permissions: actions: read`, or use a token that can read the other repository. |
555| `410 Artifact has expired` | The artifact passed its retention period. Its content is gone. |