- JavaScript 95%
- Shell 3.2%
- Makefile 1.8%
Adds the large-cache and concurrency behavior that single-PUT S3 lacked, so the action handles caches beyond the single-object limit and matches more of GitHub's restore semantics. Ships as 1.1.0. - S3 multipart upload (parallel parts) for archives larger than the part size, removing the ~5 GB single-object ceiling. upload-chunk-size and RASTER_CACHE_S3_PART_SIZE set the part size. - Parallel segmented (ranged) download for large archives, with fallback to a single stream when the store ignores Range. - Conditional write (If-None-Match: *) so a save that loses a race skips instead of clobbering; CacheConflictError is treated as a benign skip. - Branch-aware scoping with a restore fallback chain (RASTER_CACHE_REF_SCOPING, RASTER_CACHE_DEFAULT_BRANCH, RASTER_CACHE_SCOPE_FALLBACKS). - Tests: in-process mock S3 covering multipart, conditional 412, ranged download and Range-ignored fallback, plus scope-chain config tests. 44 total. - Docs: README backend tables and how-it-works, MIGRATION behavior notes, CHANGELOG. Windows remains an explicit non-goal. |
||
|---|---|---|
| .forgejo | ||
| .githooks | ||
| examples/workflows | ||
| restore | ||
| save | ||
| scripts | ||
| src | ||
| tests | ||
| .editorconfig | ||
| .gitignore | ||
| action.yml | ||
| CHANGELOG.md | ||
| CONTRIBUTING.md | ||
| LICENSE | ||
| Makefile | ||
| MIGRATION.md | ||
| package.json | ||
| README.md | ||
| SECURITY.md | ||
cache-action
Cache dependencies and build outputs on Forgejo Actions runners. A drop-in port of actions/cache that talks to your storage (S3-compatible or a plain directory) instead of the GitHub Actions cache service. No github.com, no GitHub cache API, no PAT.
The with: surface matches actions/cache so most workflows port by changing one line. Where the cache lives is configured through environment variables.
Why this exists
actions/cache only knows how to talk to GitHub's hosted cache backend. On a Forgejo runner that backend does not exist, so the action either fails or silently never caches. This action keeps the same inputs and outputs but writes archives to an object store or a shared directory that you control.
It is a JavaScript (node20) action, like actions/cache, so it can register a post step that saves the cache automatically after your job finishes. It runs straight from source: no bundled node_modules, no build step, zero npm dependencies (only Node built-ins plus the system tar).
Quick start
S3-compatible backend (AWS S3, Cloudflare R2, MinIO, Hetzner Object Storage):
jobs:
build:
runs-on: [self-hosted, Linux]
env:
RASTER_CACHE_S3_BUCKET: my-ci-cache
RASTER_CACHE_S3_ENDPOINT: https://fsn1.your-objectstorage.com # omit for AWS
RASTER_CACHE_S3_REGION: fsn1
RASTER_CACHE_S3_ACCESS_KEY_ID: ${{ secrets.CACHE_S3_KEY_ID }}
RASTER_CACHE_S3_SECRET_ACCESS_KEY: ${{ secrets.CACHE_S3_SECRET }}
steps:
- uses: actions/checkout@v6
- uses: https://rasterhub.com/rasterstate/cache-action@v1
with:
path: |
~/.npm
node_modules
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- run: npm ci && npm run build
Filesystem backend (good when your runners share an NFS / SMB mount):
env:
RASTER_CACHE_LOCAL_DIR: /mnt/ci-cache
steps:
- uses: https://rasterhub.com/rasterstate/cache-action@v1
with:
path: ~/.cargo
key: cargo-${{ hashFiles('Cargo.lock') }}
The backend is auto-detected: set RASTER_CACHE_S3_BUCKET and you get S3; set RASTER_CACHE_LOCAL_DIR and you get the filesystem backend. Set RASTER_CACHE_BACKEND explicitly if both are present.
Inputs
These mirror actions/cache exactly.
| Input | Required | Default | Description |
|---|---|---|---|
path |
yes | Files, directories, and glob patterns to cache, one per line. Supports **, *, leading ~, and ! negation lines. |
|
key |
yes | Explicit key for saving and restoring. | |
restore-keys |
no | Ordered prefix keys to fall back to on a miss, one per line. | |
enableCrossOsArchive |
no | false |
Allow a cache saved on one OS to restore on another. When false the OS is folded into the cache version. |
fail-on-cache-miss |
no | false |
Fail the step if no entry matches key (a restore-key match still counts as a miss for this flag). |
lookup-only |
no | false |
Check existence without downloading, and skip the post-step save. |
upload-chunk-size |
no | S3 multipart part size in bytes. Overrides RASTER_CACHE_S3_PART_SIZE for this run. Clamped to a 5 MiB minimum. |
Outputs
| Output | Description |
|---|---|
cache-hit |
"true" only on an exact match of the primary key. A restore-key match reports "false". |
cache-primary-key |
The primary key you passed in. |
cache-matched-key |
The key actually restored: the primary key on an exact hit, or the matched restore-key. |
Backend configuration
All backend wiring comes from the environment. Put it in env: at the job or step level, with credentials in secrets.
Common
| Variable | Default | Description |
|---|---|---|
RASTER_CACHE_BACKEND |
auto | s3 or local. Auto-detected from which variables are set. |
RASTER_CACHE_SCOPE |
$GITHUB_REPOSITORY |
Isolation prefix so repos sharing a bucket never read each other's caches. |
RASTER_CACHE_COMPRESSION |
auto | zstd (if the zstd binary is present) or gzip. The method is folded into the cache version, so a gzip-only runner never tries to read a zstd archive. |
RASTER_CACHE_REF_SCOPING |
false |
When true, the primary scope gains the current ref and restore falls back to the PR base ref and default branch (GitHub-style per-branch caches). |
RASTER_CACHE_DEFAULT_BRANCH |
With ref scoping on, the branch to fall back to (e.g. main). |
|
RASTER_CACHE_SCOPE_FALLBACKS |
Extra scopes to try on restore, newline or comma separated. Save always writes the primary scope. |
S3-compatible
| Variable | Required | Default | Description |
|---|---|---|---|
RASTER_CACHE_S3_BUCKET |
yes | Bucket name. | |
RASTER_CACHE_S3_ENDPOINT |
for non-AWS | Endpoint URL. Omit for real AWS S3. | |
RASTER_CACHE_S3_REGION |
us-east-1 |
Region. Use auto for Cloudflare R2. |
|
RASTER_CACHE_S3_PREFIX |
Key prefix inside the bucket. | ||
RASTER_CACHE_S3_FORCE_PATH_STYLE |
true when an endpoint is set, else false |
Path-style (/bucket/key) vs virtual-hosted addressing. |
|
RASTER_CACHE_S3_USE_SSL |
derived from endpoint scheme | Force HTTPS on or off. | |
RASTER_CACHE_S3_ACCESS_KEY_ID |
yes | Falls back to AWS_ACCESS_KEY_ID. |
|
RASTER_CACHE_S3_SECRET_ACCESS_KEY |
yes | Falls back to AWS_SECRET_ACCESS_KEY. |
|
RASTER_CACHE_S3_SESSION_TOKEN |
Falls back to AWS_SESSION_TOKEN. For temporary credentials. |
||
RASTER_CACHE_S3_PART_SIZE |
33554432 (32 MiB) |
Multipart part size in bytes. Archives larger than this upload as multipart. Min 5 MiB. | |
RASTER_CACHE_S3_UPLOAD_CONCURRENCY |
4 |
Multipart parts uploaded in parallel. | |
RASTER_CACHE_S3_DOWNLOAD_SEGMENT_SIZE |
67108864 (64 MiB) |
Ranged-download segment size. Archives larger than this download in parallel segments. Set 0 to disable. |
|
RASTER_CACHE_S3_DOWNLOAD_CONCURRENCY |
4 |
Download segments fetched in parallel. | |
RASTER_CACHE_S3_CONDITIONAL_WRITE |
true |
Use If-None-Match: * so a concurrent save that loses the race skips instead of overwriting. Disable for stores that reject conditional writes. |
Filesystem
| Variable | Required | Default | Description |
|---|---|---|---|
RASTER_CACHE_LOCAL_DIR |
yes | Directory to store archives in. A shared mount gives you a cross-runner cache with no object store. | |
RASTER_CACHE_LOCAL_PREFIX |
Optional sub-path under the directory. |
Provider cheat sheet
| Provider | Endpoint | Region | Notes |
|---|---|---|---|
| AWS S3 | (omit) | your region | Virtual-hosted addressing by default. |
| Cloudflare R2 | https://<account-id>.r2.cloudflarestorage.com |
auto |
Path-style (default for custom endpoints). |
| MinIO | http(s)://host:9000 |
any | Path-style. Set RASTER_CACHE_S3_USE_SSL=false for plain HTTP on a private network. |
| Hetzner Object Storage | https://<region>.your-objectstorage.com |
e.g. fsn1 |
Path-style works out of the box. |
Restore and save separately
Like actions/cache, the restore and save halves are also available as standalone actions when you want to control exactly when the cache is written (for example save only on the default branch). The standalone save runs strict: a save failure fails the job.
- name: Restore
id: cache
uses: https://rasterhub.com/rasterstate/cache-action/restore@v1
with:
path: target
key: cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-
- run: cargo build --release
- name: Save (only on a clean default-branch build)
if: github.ref == 'refs/heads/main' && steps.cache.outputs.cache-hit != 'true'
uses: https://rasterhub.com/rasterstate/cache-action/save@v1
with:
path: target
key: cargo-${{ hashFiles('Cargo.lock') }}
How it works
- Storage layout:
<prefix>/<scope>/<version>/<key><.tar.zst|.tar.gz>.scopeisolates per repository;versionis a hash of the paths, compression method, and (unlessenableCrossOsArchive) the OS. - Restore: tries the exact key first, then each restore-key as a prefix, taking the most recently modified match.
- Restore scopes: by default just the repository. With
RASTER_CACHE_REF_SCOPING(or explicitRASTER_CACHE_SCOPE_FALLBACKS) restore walks an ordered chain of scopes and returns the first match, matching GitHub's current/base/default branch fallback. - Save: runs in the post step. It is skipped on an exact primary-key hit (already cached), when an entry for the key already exists, and on a lookup-only run. A conditional write (
If-None-Match: *) makes a concurrent save that loses the race skip rather than clobber. - Archives: created with the system
tarpiped throughzstdorgzip, using absolute paths (-P) so home-directory caches such as~/.cargorestore where they came from. - S3: signed with SigV4 implemented from the spec. Object bodies use
UNSIGNED-PAYLOADso archives stream from disk without being hashed first; HTTPS protects them in transit. Archives above the part size upload as multipart (parallel parts, no 5 GB single-object ceiling) and large archives download in parallel ranged segments.
Design: plain JavaScript, no build step, no dependencies
The action is plain JavaScript that runs straight from source on the runner's node20. There is no TypeScript, no bundler, no committed dist/, and zero runtime npm dependencies. The slices it would normally pull from a library (SigV4 signing, the @actions/core input/output/state helpers) are reimplemented against Node built-ins.
This is deliberate:
- What you read is what runs. A bundled
dist/index.js(as upstreamactions/cacheships) means reviewers audit source while the runner executes a generated blob. Source-run JS closes that gap. - Clone and run. No
npm installto use it, contribute to it, or run the tests (node --test). The runner needs only Node plus the systemtar,gzip, andzstd. - Type safety comes from tests, not the compiler. The suite drives the real entry points end to end (every backend, the SigV4 reference vector, restore/save/miss/glob flows), which catches the class of mistakes a type checker would. For a codebase this size that is the right amount of rigor.
If this grows enough that the type surface earns its keep (more backends, multipart upload, richer concurrency), the plan is to add // @ts-check with JSDoc types and a tsc --noEmit lint step. That keeps type checking without reintroducing a build or a runtime dependency.
Migrating from actions/cache
See MIGRATION.md. In short: change the uses: line and add backend env:. The with: block stays the same.
Unsupported GitHub-only assumptions
The action fails fast with an actionable message when:
- No backend is configured. It will not silently fall back to the GitHub cache service (there isn't one on Forgejo).
- S3 is selected but credentials are missing.
- It detects GitHub hosted-cache environment variables (
ACTIONS_CACHE_URL,ACTIONS_RESULTS_URL) with no backend configured: it warns that it never calls that service. - It is running on a
github-hostedrunner: it warns that it is built for self-hosted / Forgejo runners.
License
MIT. See LICENSE.