No description
  • JavaScript 95%
  • Shell 3.2%
  • Makefile 1.8%
Find a file
Stephen Way 2610e0db4e
All checks were successful
test / e2e (push) Successful in 6s
test / e2e-post-save (push) Successful in 5s
test / unit (push) Successful in 25s
Close actions/cache parity gaps for large caches and concurrency
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.
2026-05-28 08:19:42 -07:00
.forgejo Add Forgejo-compatible cache action 2026-05-28 07:46:27 -07:00
.githooks Add Forgejo-compatible cache action 2026-05-28 07:46:27 -07:00
examples/workflows Add Forgejo-compatible cache action 2026-05-28 07:46:27 -07:00
restore Add Forgejo-compatible cache action 2026-05-28 07:46:27 -07:00
save Add Forgejo-compatible cache action 2026-05-28 07:46:27 -07:00
scripts Add Forgejo-compatible cache action 2026-05-28 07:46:27 -07:00
src Close actions/cache parity gaps for large caches and concurrency 2026-05-28 08:19:42 -07:00
tests Close actions/cache parity gaps for large caches and concurrency 2026-05-28 08:19:42 -07:00
.editorconfig Add Forgejo-compatible cache action 2026-05-28 07:46:27 -07:00
.gitignore Add Forgejo-compatible cache action 2026-05-28 07:46:27 -07:00
action.yml Add Forgejo-compatible cache action 2026-05-28 07:46:27 -07:00
CHANGELOG.md Close actions/cache parity gaps for large caches and concurrency 2026-05-28 08:19:42 -07:00
CONTRIBUTING.md Add Forgejo-compatible cache action 2026-05-28 07:46:27 -07:00
LICENSE Add Forgejo-compatible cache action 2026-05-28 07:46:27 -07:00
Makefile Add Forgejo-compatible cache action 2026-05-28 07:46:27 -07:00
MIGRATION.md Close actions/cache parity gaps for large caches and concurrency 2026-05-28 08:19:42 -07:00
package.json Close actions/cache parity gaps for large caches and concurrency 2026-05-28 08:19:42 -07:00
README.md Close actions/cache parity gaps for large caches and concurrency 2026-05-28 08:19:42 -07:00
SECURITY.md Add Forgejo-compatible cache action 2026-05-28 07:46:27 -07:00

cache-action

test

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>. scope isolates per repository; version is a hash of the paths, compression method, and (unless enableCrossOsArchive) 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 explicit RASTER_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 tar piped through zstd or gzip, using absolute paths (-P) so home-directory caches such as ~/.cargo restore where they came from.
  • S3: signed with SigV4 implemented from the spec. Object bodies use UNSIGNED-PAYLOAD so 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 upstream actions/cache ships) means reviewers audit source while the runner executes a generated blob. Source-run JS closes that gap.
  • Clone and run. No npm install to use it, contribute to it, or run the tests (node --test). The runner needs only Node plus the system tar, gzip, and zstd.
  • 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-hosted runner: it warns that it is built for self-hosted / Forgejo runners.

License

MIT. See LICENSE.