Skip to content

MinIO S3 Setup ​

MinIO is a high-performance distributed object store with native S3 API compatibility, widely used for on-premise infrastructure, private enterprise clouds, and local CI testing.

NOTE

Maintenance & Compatibility Notice: While upstream open-source MinIO changed licensing (AGPLv3) and standalone community releases are no longer actively maintained with free public security patches, many development teams and enterprise clusters continue to rely on existing MinIO infrastructure or use ephemeral containers in CI. cloud-cache-action maintains 100% interoperability with all MinIO versions.


Best Practices: Bucket & Cluster Setup ​

Follow these recommendations when setting up MinIO for CI/CD caching:

1. Bucket Privacy & Quotas ​

  • Bucket Access Policy: Ensure the bucket access policy is strictly private (none). Never set download or public access policies on cache buckets.
  • Hard Storage Quotas: CI builds can rapidly generate hundreds of gigabytes of dependency archives. Use the MinIO Client (mc) to set a hard storage quota on your cache bucket:
    bash
    # Set a 100 GB hard limit on the cache bucket
    mc quota set --hard 100GB myminio/ci-cache

2. Information Lifecycle Management (ILM) Auto-Expiration ​

MinIO includes native ILM lifecycle management. Configure automatic expiration so old caches are purged automatically:

bash
# Automatically expire and delete cache archives older than 30 days
mc ilm rule add --expire-days 30 myminio/ci-cache

# Automatically abort incomplete multipart uploads after 7 days
mc ilm rule add --expire-delete-marker --abort-incomplete-multipart-upload-days 7 myminio/ci-cache

3. Server-Side Encryption ​

Enable transparent server-side encryption with MinIO-managed keys:

bash
# Enable auto-encryption on the bucket
mc encrypt set sse-s3 myminio/ci-cache

Credentials & Least-Privilege Service Accounts ​

WARNING

Never use root credentials (MINIO_ROOT_USER / MINIO_ROOT_PASSWORD) in your GitHub Actions workflows. Always create a dedicated Service Account restricted strictly to the CI cache bucket.

1. Create a Restricted Policy (ci-cache-policy.json) ​

Copy the MinIO policy from the Security guide into ci-cache-policy.json. Change the bucket name and the prefix to match your set-up.

Add the policy to MinIO:

bash
mc admin policy create myminio ci-cache-policy ci-cache-policy.json

2. Generate Service Account Credentials ​

bash
# Create a service account restricted by the policy
mc admin user svcacct add --policy ci-cache-policy myminio ci-runner

MinIO will print the Access Key and Secret Key.

3. Configure GitHub Secrets ​

Store the credentials in your repository's Settings > Secrets and variables > Actions:

Secret NameDescription
MINIO_ACCESS_KEYMinIO Service Account Access Key
MINIO_SECRET_KEYMinIO Service Account Secret Key

Example 1: GitHub Actions CI with Ephemeral MinIO Container ​

You can spin up an ephemeral MinIO instance directly inside your GitHub Actions runner job using Docker or Docker Compose for zero-cost, isolated CI caching:

yaml
name: Build with Local MinIO Cache

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      # 1. Start ephemeral MinIO container
      - name: Start ephemeral MinIO
        run: |
          docker run -d --name ci-minio -p 9000:9000 \
            -e MINIO_ROOT_USER=minioadmin \
            -e MINIO_ROOT_PASSWORD=minioadmin \
            minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
          
          # Wait for MinIO readiness
          for i in {1..30}; do
            if curl -s http://127.0.0.1:9000/minio/health/live > /dev/null 2>&1; then
              echo "MinIO ready."
              break
            fi
            sleep 1
          done
          
          # Create cache bucket
          docker exec ci-minio mkdir -p /data/ci-cache

      # 2. Cache dependencies with Cloud Cache Action
      - name: Cache dependencies using local MinIO
        uses: xSAVIKx/cloud-cache-action@v1
        with:
          bucket: ci-cache
          endpoint: http://127.0.0.1:9000
          access-key: minioadmin
          secret-key: minioadmin
          force-path-style: true
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-
          path: ~/.npm

      - name: Install dependencies
        run: npm ci

Example 2: Self-Hosted / On-Prem MinIO Cluster ​

For organizations hosting a persistent MinIO server or cluster on private infrastructure (e.g., bare-metal or Kubernetes):

yaml
- name: Cache dependencies using self-hosted MinIO
  uses: xSAVIKx/cloud-cache-action@v1
  with:
    bucket: ci-cache
    endpoint: https://minio.internal.mycompany.com:9000
    access-key: ${{ secrets.MINIO_ACCESS_KEY }}
    secret-key: ${{ secrets.MINIO_SECRET_KEY }}
    force-path-style: true
    key: ${{ runner.os }}-build-${{ hashFiles('**/lock') }}
    path: build/

TIP

Path-Style Addressing: Always set force-path-style: true for MinIO unless you have configured wildcard DNS subdomains (virtual-host style) for your buckets.


Running MinIO Locally for Integration Testing ​

You can spin up MinIO locally using the repository's docker-compose.test.yml:

bash
docker compose -f docker-compose.test.yml up -d minio
docker-compose.test.yml (Click to view Compose definition)
yaml
name: cloud-cache-test

# Local S3 servers for the integration suite. Credentials match tests/support/s3Server.ts:
#   seaweedfs, minio, rustfs: cloudcacheci / cloudcachecisecret
#   garage: run `bash tests/ci/start-garage.sh` after `up`, then use the key in that script.
services:
  garage:
    image: dxflrs/garage:v2.4.1
    container_name: cloud-cache-garage
    ports:
      - '3900:3900' # S3 API
      - '3903:3903' # Admin API
    volumes:
      - ./tests/fixtures/garage.toml:/etc/garage.toml:ro
    environment:
      - RUST_LOG=info
    restart: unless-stopped

  seaweedfs:
    image: chrislusf/seaweedfs:4.46
    container_name: cloud-cache-seaweedfs
    ports:
      - '8333:8333' # S3 API
    volumes:
      - ./tests/fixtures/seaweedfs-s3.json:/etc/seaweedfs/s3.json:ro
    command: 'server -dir=/data -s3 -s3.port=8333 -s3.config=/etc/seaweedfs/s3.json -volume.max=0 -master.volumeSizeLimitMB=64'
    restart: unless-stopped

  minio:
    # Upstream MinIO no longer publishes community images; CleanStart rebuilds it from source.
    image: cleanstart/minio:0.20260330.001845@sha256:d5dc9098ea6239fc7215165c1496fa026abbb9560bdd7a67c4938e9226394839
    container_name: cloud-cache-minio
    ports:
      - '9000:9000'
    environment:
      - MINIO_ROOT_USER=cloudcacheci
      - MINIO_ROOT_PASSWORD=cloudcachecisecret
    tmpfs:
      - /data:uid=65532,gid=65532
    command: 'server /data'
    restart: unless-stopped

  rustfs:
    # RustFS serves S3 on 9000, the same port MinIO uses, so it is published on 9010 here to let
    # both run at once. The container runs as uid/gid 10001 and needs /data and /logs writable.
    image: rustfs/rustfs:1.0.0
    container_name: cloud-cache-rustfs
    ports:
      - '9010:9000' # S3 API
    environment:
      - RUSTFS_ACCESS_KEY=cloudcacheci
      - RUSTFS_SECRET_KEY=cloudcachecisecret
    tmpfs:
      - /data:uid=10001,gid=10001
      - /logs:uid=10001,gid=10001
    restart: unless-stopped

Once running:

  • S3 API: http://localhost:9000
  • Web Console: http://localhost:9001 (Default credentials: minioadmin / minioadmin)

Live CI Dogfooding Workflow ​

The repository dogfoods MinIO directly inside the continuous integration test suite: .github/workflows/test.yml.

.github/workflows/test.yml (Click to view test workflow)
yaml
name: CI Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

env:
  REQUIRE_S3: '1'

jobs:
  checks:
    name: Format, lint, types, dist and docs
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run format:check
      - run: npm run lint
      - name: Lint workflows with actionlint
        run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.12 -color
      - run: npm run typecheck
      - run: npm run build
      - name: Verify dist is clean and tracked
        run: |
          if [ -n "$(git status --porcelain dist)" ]; then
            echo "::error::Uncommitted dist changes detected. Run npm run build and commit."
            git status --porcelain dist
            exit 1
          fi
      - run: npm run docs:build

  unit:
    name: Jest (${{ matrix.os }})
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - name: Start SeaweedFS
        run: node tests/ci/startSeaweedfs.ts
      - name: Run Jest (unit, contract and integration)
        run: npm test

  provider-compat:
    name: S3 compatibility (${{ matrix.server }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        include:
          - server: seaweedfs
            endpoint: http://127.0.0.1:8333
            region: us-east-1
            access-key: cloudcacheci
            secret-key: cloudcachecisecret
          - server: garage
            endpoint: http://127.0.0.1:3900
            region: garage
            access-key: GK0123456789abcdef01234567
            secret-key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
          - server: minio
            endpoint: http://127.0.0.1:9000
            region: us-east-1
            access-key: cloudcacheci
            secret-key: cloudcachecisecret
          # RustFS serves S3 on 9000 like MinIO, so docker-compose.test.yml publishes it on 9010.
          - server: rustfs
            endpoint: http://127.0.0.1:9010
            region: us-east-1
            access-key: cloudcacheci
            secret-key: cloudcachecisecret
    env:
      TEST_S3_ENDPOINT: ${{ matrix.endpoint }}
      TEST_S3_REGION: ${{ matrix.region }}
      TEST_S3_ACCESS_KEY: ${{ matrix.access-key }}
      TEST_S3_SECRET_KEY: ${{ matrix.secret-key }}
      TEST_S3_PROVIDER: ${{ matrix.server }}
      KEY: compat-${{ matrix.server }}-${{ github.run_id }}-${{ github.run_attempt }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - name: Start ${{ matrix.server }}
        run: docker compose -f docker-compose.test.yml up -d ${{ matrix.server }}
      - name: Bootstrap Garage
        if: matrix.server == 'garage'
        run: bash tests/ci/start-garage.sh
      - name: Wait for S3 and create the bucket
        run: |
          for _ in $(seq 1 60); do
            if node tests/support/s3Server.ts; then exit 0; fi
            sleep 2
          done
          docker compose -f docker-compose.test.yml logs ${{ matrix.server }}
          exit 1
      - name: Integration tests
        run: npm run test:integration
      - name: Build the fixture tree
        id: fixture
        run: node tests/support/fixtureTree.ts build "$RUNNER_TEMP/fixture.json" --large-bytes 33554432
      - name: Save (save-only)
        uses: ./save
        with:
          key: ${{ env.KEY }}
          path: ${{ steps.fixture.outputs.paths }}
          bucket: cloud-cache-test
          endpoint: ${{ matrix.endpoint }}
          region: ${{ matrix.region }}
          provider: ${{ matrix.server }}
          access-key: ${{ matrix.access-key }}
          secret-key: ${{ matrix.secret-key }}
      - name: Remove the fixture tree
        run: node tests/support/fixtureTree.ts clear
      - name: Restore (restore-only)
        id: restore
        uses: ./restore
        with:
          key: ${{ env.KEY }}
          path: ${{ steps.fixture.outputs.paths }}
          fail-on-cache-miss: true
          bucket: cloud-cache-test
          endpoint: ${{ matrix.endpoint }}
          region: ${{ matrix.region }}
          provider: ${{ matrix.server }}
          access-key: ${{ matrix.access-key }}
          secret-key: ${{ matrix.secret-key }}
      - name: Verify the restored tree
        run: |
          test "${{ steps.restore.outputs.cache-hit }}" = "true"
          node tests/support/fixtureTree.ts verify "$RUNNER_TEMP/fixture.json"

  cli:
    name: CLI bundle against RustFS
    runs-on: ubuntu-latest
    env:
      TEST_S3_ENDPOINT: http://127.0.0.1:9010
      TEST_S3_REGION: us-east-1
      TEST_S3_ACCESS_KEY: cloudcacheci
      TEST_S3_SECRET_KEY: cloudcachecisecret
      TEST_S3_PROVIDER: rustfs
      TEST_CLI: '1'
      REQUIRE_S3: '1'
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run build:cli
      - name: Start RustFS
        run: docker compose -f docker-compose.test.yml up -d rustfs
      - name: Wait for S3 and create the bucket
        run: |
          for _ in $(seq 1 60); do
            if node tests/support/s3Server.ts; then exit 0; fi
            sleep 2
          done
          docker compose -f docker-compose.test.yml logs rustfs
          exit 1
      - name: Run the CLI integration test
        run: npm test -- tests/integration/cli.test.ts --testTimeout=60000

  roundtrip:
    name: Round trip (${{ matrix.os }}, streaming ${{ matrix.streaming }})
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        streaming: ['false', 'true']
    runs-on: ${{ matrix.os }}
    defaults:
      run:
        shell: bash
    env:
      AWS_ENDPOINT_URL: http://127.0.0.1:8333
      AWS_S3_BUCKET: cloud-cache-test
      AWS_ACCESS_KEY_ID: cloudcacheci
      AWS_SECRET_ACCESS_KEY: cloudcachecisecret
      KEY: rt-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci --omit=dev --no-audit --no-fund
      - run: node tests/ci/startSeaweedfs.ts
      - name: Build the fixture tree
        id: fixture
        run: node tests/support/fixtureTree.ts build "$RUNNER_TEMP/fixture.json" --large-bytes 33554432
      - name: Save (save-only)
        uses: ./save
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}-v1
          path: ${{ steps.fixture.outputs.paths }}
          streaming: ${{ matrix.streaming }}
          metrics-file: metrics.jsonl
      - name: Remove the fixture tree
        run: node tests/support/fixtureTree.ts clear
      - name: Restore the exact key
        id: exact
        uses: ./restore
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}-v1
          path: ${{ steps.fixture.outputs.paths }}
          fail-on-cache-miss: true
          streaming: ${{ matrix.streaming }}
          metrics-file: metrics.jsonl
      - name: Verify the restored tree
        run: |
          test "${{ steps.exact.outputs.cache-hit }}" = "true"
          node tests/support/fixtureTree.ts verify "$RUNNER_TEMP/fixture.json"
      - name: Verify the metrics file has one line per step
        run: |
          cat metrics.jsonl
          test "$(wc -l < metrics.jsonl)" -eq 2
      - name: Look up by primary-key prefix
        id: prefix
        uses: ./restore
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}-v
          path: ${{ steps.fixture.outputs.paths }}
          lookup-only: true
      - name: Look up by restore-keys
        id: restore-keys
        uses: ./restore
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}-other
          restore-keys: ${{ env.KEY }}-v
          path: ${{ steps.fixture.outputs.paths }}
          lookup-only: true
      - name: Look up with a different path list
        id: mismatch
        uses: ./restore
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}-v1
          path: |
            ${{ steps.fixture.outputs.paths }}
            not-in-the-original-list
          lookup-only: true
      - name: Verify the lookups
        run: |
          test "${{ steps.prefix.outputs.cache-hit }}" = "false"
          test "${{ steps.prefix.outputs.cache-matched-key }}" = "$KEY-v1"
          test "${{ steps.restore-keys.outputs.cache-hit }}" = "false"
          test "${{ steps.restore-keys.outputs.cache-matched-key }}" = "$KEY-v1"
          test "${{ steps.mismatch.outputs.cache-hit }}" = "false"
          test -z "${{ steps.mismatch.outputs.cache-matched-key }}"

  main-action:
    name: Main action post step (${{ matrix.os }})
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    defaults:
      run:
        shell: bash
    env:
      AWS_ENDPOINT_URL: http://127.0.0.1:8333
      AWS_S3_BUCKET: cloud-cache-test
      AWS_ACCESS_KEY_ID: cloudcacheci
      AWS_SECRET_ACCESS_KEY: cloudcachecisecret
      KEY: main-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci --omit=dev --no-audit --no-fund
      - run: node tests/ci/startSeaweedfs.ts
      - name: Create a payload
        run: mkdir -p payload && echo "${{ github.run_id }}" > payload/run.txt
      - name: After every post step, check that the cache was saved
        uses: ./.github/actions/assert-object
        with:
          key: ${{ env.KEY }}
      - name: Cache the payload (restore now, save in the post step)
        id: cache
        uses: ./
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}
          path: payload
      - name: Expect a miss on the first attempt
        run: test "${{ steps.cache.outputs.cache-hit }}" = "false"

  prune:
    name: Prune sub-action
    runs-on: ubuntu-latest
    env:
      AWS_ENDPOINT_URL: http://127.0.0.1:8333
      AWS_S3_BUCKET: cloud-cache-test
      AWS_ACCESS_KEY_ID: cloudcacheci
      AWS_SECRET_ACCESS_KEY: cloudcachecisecret
      KEY: prune-${{ github.run_id }}-${{ github.run_attempt }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci --omit=dev --no-audit --no-fund
      - run: node tests/ci/startSeaweedfs.ts
      - name: Create two payloads
        run: |
          mkdir -p payload-a payload-b
          echo "a-${{ github.run_id }}" > payload-a/data.txt
          echo "b-${{ github.run_id }}" > payload-b/data.txt
      - name: Save the first key
        uses: ./save
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}-a
          path: payload-a
      - name: Save the second key
        uses: ./save
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}-b
          path: payload-b
      - name: Dry-run prune of caches older than 1 day
        id: prune
        uses: ./prune
        with:
          bucket: cloud-cache-test
          older-than-days: 1
          dry-run: true
      - name: Expect nothing pruned, since the objects are brand new
        run: test "${{ steps.prune.outputs.pruned-count }}" = "0"
      - name: Confirm both objects still exist
        run: |
          count=$(node tests/ci/s3Objects.ts count "$GITHUB_REPOSITORY/" "/$KEY-")
          test "$count" -eq 2

  inspect:
    name: Inspect sub-action
    runs-on: ubuntu-latest
    env:
      AWS_ENDPOINT_URL: http://127.0.0.1:8333
      AWS_S3_BUCKET: cloud-cache-test
      AWS_ACCESS_KEY_ID: cloudcacheci
      AWS_SECRET_ACCESS_KEY: cloudcachecisecret
      KEY: inspect-${{ github.run_id }}-${{ github.run_attempt }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci --omit=dev --no-audit --no-fund
      - run: node tests/ci/startSeaweedfs.ts
      - name: Create two payloads
        run: |
          mkdir -p payload other-dir
          echo "payload-${{ github.run_id }}" > payload/data.txt
          echo "other-${{ github.run_id }}" > other-dir/data.txt
      - name: Save the cache
        uses: ./save
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}
          path: payload
      - name: Inspect the same lookup
        id: inspect
        uses: ./inspect
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}
          path: payload
      - name: Expect the lookup to hit the object that was just saved
        run: |
          test "${{ steps.inspect.outputs.would-hit }}" = "true"
          test "${{ steps.inspect.outputs.would-match-key }}" = "$KEY"
          test -n "${{ steps.inspect.outputs.would-match-object }}"
      - name: Smoke-test the inspect bundle itself
        env:
          INPUT_BUCKET: cloud-cache-test
          INPUT_KEY: ${{ env.KEY }}
          INPUT_PATH: payload
          GITHUB_OUTPUT: ${{ runner.temp }}/inspect-bundle-output.txt
        run: |
          : > "$GITHUB_OUTPUT"
          node dist/inspect/index.js
          cat "$GITHUB_OUTPUT"
          # core.setOutput writes "<name><<<delimiter>\n<value>\n<delimiter>".
          test "$(awk '/^would-hit<</ { getline; print; exit }' "$GITHUB_OUTPUT")" = "true"
      - name: Inspect the same key with different paths
        id: inspect-miss
        uses: ./inspect
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}
          path: other-dir
      - name: Expect a miss, since the paths hash to another version
        run: |
          test "${{ steps.inspect-miss.outputs.would-hit }}" = "false"
          test -z "${{ steps.inspect-miss.outputs.would-match-object }}"

  dual-cache:
    name: Dual-cache with the live GitHub Actions Cache
    runs-on: ubuntu-latest
    env:
      AWS_ENDPOINT_URL: http://127.0.0.1:8333
      AWS_S3_BUCKET: cloud-cache-test
      AWS_ACCESS_KEY_ID: cloudcacheci
      AWS_SECRET_ACCESS_KEY: cloudcachecisecret
      KEY: dual-${{ github.run_id }}-${{ github.run_attempt }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci --omit=dev --no-audit --no-fund
      - run: node tests/ci/startSeaweedfs.ts
      - name: Create a payload
        run: mkdir -p payload && echo "${{ github.run_id }}" > payload/run.txt
      - name: After the dual-cache post step, check that S3 received the object
        uses: ./.github/actions/assert-object
        with:
          key: ${{ env.KEY }}
      - name: Cache the payload in both tiers (restore now, post step saves to S3 and GitHub)
        id: cache
        uses: ./
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}
          path: payload
          dual-cache: true
      - name: Expect a miss in both tiers on the first attempt
        run: test "${{ steps.cache.outputs.cache-hit }}" = "false"

  strict-failure:
    name: Strict dual-cache save failure
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci --omit=dev --no-audit --no-fund
      - run: node tests/ci/startSeaweedfs.ts
      - name: Create a payload
        run: mkdir -p payload && echo "${{ github.run_id }}" > payload/run.txt
      - name: Save with a wrong secret in strict dual-cache mode (must fail)
        id: strict-save
        continue-on-error: true
        uses: ./save
        with:
          key: strict-${{ github.run_id }}-${{ github.run_attempt }}
          path: payload
          bucket: cloud-cache-test
          endpoint: http://127.0.0.1:8333
          access-key: cloudcacheci
          secret-key: wrong-secret
          dual-cache: true
          dual-cache-strict: true
      - name: Expect the strict save step to have failed
        run: test "${{ steps.strict-save.outcome }}" = "failure"

  cross-os-seed:
    name: Cross-OS seed (Linux)
    runs-on: ubuntu-latest
    env:
      AWS_ENDPOINT_URL: http://127.0.0.1:8333
      AWS_S3_BUCKET: cloud-cache-test
      AWS_ACCESS_KEY_ID: cloudcacheci
      AWS_SECRET_ACCESS_KEY: cloudcachecisecret
      KEY: xos-${{ github.run_id }}-${{ github.run_attempt }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci --omit=dev --no-audit --no-fund
      - run: node tests/ci/startSeaweedfs.ts
      - name: Build the portable fixture tree
        id: fixture
        run: node tests/support/fixtureTree.ts build "$RUNNER_TEMP/seed/fixture.json" --portable --large-bytes 8388608
      - name: Save with enableCrossOsArchive
        uses: ./save
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}
          path: ${{ steps.fixture.outputs.paths }}
          enableCrossOsArchive: true
      - name: Export the saved objects
        run: node tests/ci/s3Objects.ts export "$RUNNER_TEMP/seed/objects" "$GITHUB_REPOSITORY/" "/$KEY/"
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: cross-os-seed
          path: ${{ runner.temp }}/seed
          if-no-files-found: error

  cross-os-restore:
    name: Cross-OS restore (${{ matrix.os }})
    needs: cross-os-seed
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: macos-latest
            hit-without-cross-os: 'true'
          - os: windows-latest
            hit-without-cross-os: 'false'
    runs-on: ${{ matrix.os }}
    defaults:
      run:
        shell: bash
    env:
      AWS_ENDPOINT_URL: http://127.0.0.1:8333
      AWS_S3_BUCKET: cloud-cache-test
      AWS_ACCESS_KEY_ID: cloudcacheci
      AWS_SECRET_ACCESS_KEY: cloudcachecisecret
      KEY: xos-${{ github.run_id }}-${{ github.run_attempt }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci --omit=dev --no-audit --no-fund
      - name: Require zstd, which the Linux seed used
        run: zstd --version
      - run: node tests/ci/startSeaweedfs.ts
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: cross-os-seed
          path: ${{ runner.temp }}/seed
      - name: Import the seeded objects
        run: node tests/ci/s3Objects.ts import "$RUNNER_TEMP/seed/objects"
      - name: Read the seeded paths
        id: seed
        run: node tests/support/fixtureTree.ts paths "$RUNNER_TEMP/seed/fixture.json"
      - name: Look up without enableCrossOsArchive
        id: plain
        uses: ./restore
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}
          path: ${{ steps.seed.outputs.paths }}
          lookup-only: true
      - name: Restore with enableCrossOsArchive
        id: cross
        uses: ./restore
        with:
          bucket: cloud-cache-test
          key: ${{ env.KEY }}
          path: ${{ steps.seed.outputs.paths }}
          enableCrossOsArchive: true
          fail-on-cache-miss: true
      - name: Verify
        run: |
          test "${{ steps.plain.outputs.cache-hit }}" = "${{ matrix.hit-without-cross-os }}"
          test "${{ steps.cross.outputs.cache-hit }}" = "true"
          node tests/support/fixtureTree.ts verify "$RUNNER_TEMP/seed/fixture.json"

Notes ​

  • Checksums: for this provider, the action sends request checksums only when S3 requires them, because many S3-compatible services reject the CRC checksums recent AWS SDKs send by default. Nothing to configure.
  • Expiry: the action never deletes caches. Add a lifecycle rule that expires objects after 30–60 days so old caches do not accumulate.
  • Incomplete uploads: MinIO lists no incomplete multipart upload for a prefix that is not a full object key (measured on 2026-10-21 with the CI image). The prune input abort-uploads-older-than-hours therefore finds no upload and aborts none. Use the --abort-incomplete-multipart-upload-days lifecycle rule above instead.