feat: initial
All checks were successful
Check build / check-dist (push) Successful in 1m24s
Run tests / Run vitest integration tests (push) Successful in 1m29s

This commit is contained in:
Maximilian Jugl 2026-04-23 09:49:39 +02:00
commit b04a255b4c
17 changed files with 6509 additions and 0 deletions

View file

@ -0,0 +1,32 @@
name: "Check build"
on:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize
- reopened
jobs:
check-dist:
runs-on: stackit-docker
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Rebuild Action
run: npm run build
- name: Check for uncommitted changes
run: |
if [[ -n "$(git status --porcelain dist/)" ]]; then
echo "::error::Distribution file is not up to date. Please run 'npm run build' and commit dist/index.js."
exit 1
fi

View file

@ -0,0 +1,23 @@
name: Check PR title
on:
pull_request:
types:
- opened
- synchronize
- reopened
- edited
jobs:
check:
runs-on: stackit-docker
steps:
- name: Checkout
uses: actions/checkout@v6
with:
sparse-checkout: |
.forgejo
- name: Check PR title
uses: https://stackit-solutions.git.onstackit.cloud/actions/check-conventional-commit@v1
with:
value: ${{ github.event.pull_request.title }}

View file

@ -0,0 +1,64 @@
name: "Run tests"
on:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize
- reopened
env:
S3_ENDPOINT: "http://localhost:9090"
jobs:
integration-tests:
services:
s3mock:
image: adobe/s3mock:latest
env:
COM_ADOBE_TESTING_S3MOCK_STORE_INITIAL_BUCKETS: "integration-test"
COM_ADOBE_TESTING_S3MOCK_STORE_REGION: "us-east-1"
ports:
- "9090:9090"
name: "Run vitest integration tests"
runs-on: stackit-docker
steps:
- name: Wait for S3Mock
run: |
echo "Waiting for S3Mock on $S3_ENDPOINT/favicon.ico"
SECONDS=0
while true; do
CURRENT_TIME="$(date +%s)"
if curl -sfo /dev/null "$S3_ENDPOINT/favicon.ico"; then
echo "S3Mock is ready (took $SECONDS seconds)"
exit 0
fi
if [ "$SECONDS" -ge 30 ]; then
echo "::error::S3Mock did not start up in time (30s timeout reached)"
exit 1
fi
sleep 0.2
done
echo "::error::S3Mock did not start up in time"
exit 1
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Build action
run: npm run build
- name: Run tests
run: npx vitest run

171
.gitignore vendored Normal file
View file

@ -0,0 +1,171 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
#dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# pnpm
.pnpm-store
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
# General
.DS_Store
.localized
__MACOSX/
.AppleDouble
.LSOverride
Icon[ ]
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk

2
.husky/pre-commit Normal file
View file

@ -0,0 +1,2 @@
npm run build
git add dist/index.js

3
.prettierrc Normal file
View file

@ -0,0 +1,3 @@
{
"printWidth": 120
}

5
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2
}

148
README.md Normal file
View file

@ -0,0 +1,148 @@
# S3 Cache Action
This action allows caching dependencies and build outputs to improve workflow execution time using S3-compatible storage (like MinIO, Ceph, AWS S3, etc.).
The interface, inputs, outputs, and internal behavior of this action are intentionally designed to be identical to the official `actions/cache`. This ensures that it can be used as a seamless drop-in replacement in self-hosted environments where the official GitHub Actions cache might not be available or suitable.
## Inputs
- `path` (Required): A list of files, directories, and wildcard patterns to cache and restore.
- `key` (Required): An explicit key for restoring and saving the cache.
- `restore-keys` (Optional): An ordered list of keys to use for restoring stale cache if no cache hit occurred for key. Note `cache-hit` returns false in this case.
- `lookup-only` (Optional): Check if a cache entry exists for the given input(s) (key, restore-keys) without downloading the cache. Default is `false`.
- `fail-on-cache-miss` (Optional): Fail the workflow if cache entry is not found. Default is `false`.
- `s3-endpoint` (Required): The endpoint URL of your S3-compatible storage.
- `s3-bucket` (Required): The name of the S3 bucket to store the cache in.
- `s3-access-key` (Required): The S3 access key.
- `s3-secret-key` (Required): The S3 secret key.
- `s3-region` (Optional): The S3 region. Default is `us-east-1`.
## Outputs
- `cache-hit`: A boolean value to indicate an exact match was found for the primary key.
## Examples
### npm
```yaml
steps:
- name: Get npm cache directory
id: npm-cache-dir
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- name: Cache npm
uses: https://stackit-solutions.git.onstackit.cloud/actions/s3-cache@v1
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
s3-endpoint: https://object.storage.eu01.onstackit.cloud
s3-region: eu01
s3-bucket: ${{ vars.S3_BUCKET }}
s3-access-key: ${{ vars.S3_ACCESS_KEY }}
s3-secret-key: ${{ secrets.S3_SECRET_KEY }}
```
### OpenTofu
```yaml
env:
TF_PLUGIN_CACHE_DIR: /opt/.terraform.d/plugin-cache
steps:
- name: Install OpenTofu
uses: opentofu/setup-opentofu@v2
with:
tofu_wrapper: false
- name: Setup OpenTofu provider cache
shell: bash
run: mkdir -p "$TF_PLUGIN_CACHE_DIR"
- name: Cache OpenTofu providers
uses: https://stackit-solutions.git.onstackit.cloud/actions/s3-cache@v1
with:
path: ${{ env.TF_PLUGIN_CACHE_DIR }}
key: ${{ runner.os }}-opentofu-${{ hashFiles('**/terraform.lock.hcl') }}
restore-keys: |
${{ runner.os }}-opentofu-
s3-endpoint: https://object.storage.eu01.onstackit.cloud
s3-region: eu01
s3-bucket: ${{ vars.S3_BUCKET }}
s3-access-key: ${{ vars.S3_ACCESS_KEY }}
s3-secret-key: ${{ secrets.S3_SECRET_KEY }}
- name: Initialize OpenTofu
shell: bash
run: tofu init
```
## Cache Strategies
This action supports the same caching strategies as the official cache action:
### Exact Match
An exact match occurs when the primary `key` exactly matches a previously saved cache in the S3 bucket. When this happens, the action downloads the cache, and the `cache-hit` output is set to `true`. Because an exact match was found, the `post` step will skip uploading the cache again at the end of the workflow.
### Prefix Match (Fallback)
If there is no exact match for the `key`, the action evaluates the `restore-keys`. It uses these keys as prefixes to search the S3 bucket. If multiple caches match the prefix, it will download the most recently created one.
In this case, the `cache-hit` output is set to `false` (because there was no exact match), but the cache is still restored. Since the primary `key` was a miss, the `post` step will pack and upload a fresh cache using the new primary `key` at the end of the job.
## S3 Store Configuration
This action relies on the underlying S3 storage to manage the lifecycle of the cache files. The action creates and reads objects but never deletes them. To prevent your bucket from growing indefinitely, it is highly recommended to configure a Lifecycle Policy on your S3 bucket to automatically remove objects after a certain period (e.g., 7 days).
### Example: Setting a 7-day expiration using s3cmd
Create a file named `lifecycle.xml`:
```xml
<LifecycleConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Rule>
<ID>ExpireOldCaches</ID>
<Prefix>runner-cache/</Prefix>
<Status>Enabled</Status>
<Expiration>
<Days>7</Days>
</Expiration>
</Rule>
</LifecycleConfiguration>
```
Apply the policy to your bucket:
```bash
s3cmd setlifecycle lifecycle.xml s3://your-s3-cache-bucket
```
## Developer Notes
### Setup
To set up the development environment, clone the repository and install the dependencies:
```bash
npm install
```
### Git Hooks (Husky)
The project uses `husky` to manage Git hooks. Upon running `npm install`, the `prepare` script will automatically configure Husky. This ensures that formatting, linting, and other quality checks are executed before you commit your code.
### Testing on macOS with Colima
The integration tests utilize `testcontainers` to spin up a local Adobe S3Mock instance. If you are developing on macOS and using Colima as your Docker runtime, `testcontainers` needs to know where to find the Docker socket inside the virtual machine.
Before running the tests, export the following environment variable:
```bash
export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock
npm run test
```
### Local Workflow Testing with act
You can test the entire GitHub Actions workflow locally using `act`.
```bash
act --workflows .github/workflows/test.yaml -P stackit-docker=registry.onstackit.cloud/devex-images/ubuntu:act-latest
```

44
action.yaml Normal file
View file

@ -0,0 +1,44 @@
name: "S3 Cache"
description: "Cache artifacts like dependencies and build outputs to improve workflow execution time using S3-compatible storage"
inputs:
path:
description: "A list of files, directories, and wildcard patterns to cache and restore"
required: true
key:
description: "An explicit key for restoring and saving the cache"
required: true
restore-keys:
description: "An ordered list of keys to use for restoring stale cache if no cache hit occurred for key. Note `cache-hit` returns false in this case."
required: false
lookup-only:
description: "Check if a cache entry exists for the given input(s) (key, restore-keys) without downloading the cache"
required: false
default: "false"
fail-on-cache-miss:
description: "Fail the workflow if cache entry is not found"
required: false
default: "false"
s3-endpoint:
description: "S3 endpoint URL"
required: true
s3-bucket:
description: "S3 bucket name"
required: true
s3-access-key:
description: "S3 access key"
required: true
s3-secret-key:
description: "S3 secret key"
required: true
s3-region:
description: "S3 region"
required: false
default: "us-east-1"
outputs:
cache-hit:
description: "A boolean value to indicate an exact match was found for the primary key"
runs:
using: "node20"
main: "dist/index.js"
post: "dist/index.js"
post-if: success()

123
dist/index.js vendored Normal file

File diff suppressed because one or more lines are too long

5409
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "s3-cache-action",
"version": "1.0.0",
"private": true,
"main": "src/index.js",
"scripts": {
"build": "esbuild src/index.js --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js --minify",
"prepare": "husky",
"test": "vitest run"
},
"dependencies": {
"@actions/core": "^3.0.0",
"@actions/exec": "^3.0.0",
"@actions/glob": "^0.6.1",
"@actions/io": "^3.0.2",
"@aws-sdk/client-s3": "^3.1033.0",
"@aws-sdk/lib-storage": "^3.1033.0"
},
"devDependencies": {
"esbuild": "^0.28.0",
"husky": "^9.1.7",
"prettier": "^3.8.3",
"testcontainers": "^11.14.0",
"vitest": "^4.1.4"
},
"overrides": {
"glob": "^13.0.0"
}
}

23
src/index.js Normal file
View file

@ -0,0 +1,23 @@
import * as core from "@actions/core";
import { checkPrerequisites } from "./utils";
import { restore } from "./restore";
import { save } from "./save";
const run = async () => {
const isPost = core.getState("isPost") === "true";
try {
await checkPrerequisites();
if (!isPost) {
core.saveState("isPost", "true");
await restore();
} else {
await save();
}
} catch (error) {
core.setFailed(`Unexpected error: ${error.message}`);
}
};
run();

108
src/restore.js Normal file
View file

@ -0,0 +1,108 @@
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import { getS3Client } from "./utils";
import { GetObjectCommand, HeadObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import crypto from "crypto";
import path from "path";
import fs from "fs";
import { pipeline } from "stream/promises";
export const restore = async () => {
const s3 = getS3Client();
const key = core.getInput("key", { required: true });
const restoreKeys = core.getMultilineInput("restore-keys");
const lookupOnly = core.getInput("lookup-only") === "true";
const failOnMiss = core.getInput("fail-on-cache-miss") === "true";
const bucket = core.getInput("s3-bucket", { required: true });
// true if an exact cache hit has been found
let exactCacheHit = false;
let matchedKey = null;
try {
// check if an exact match exists
await s3.send(
new HeadObjectCommand({
Bucket: bucket,
Key: `runner-cache/${key}.tar.zst`,
}),
);
// if so, set the flag
exactCacheHit = true;
matchedKey = key;
} catch (err) {
if (err.name !== "NotFound") {
core.warning(`S3 error: ${err.message}`);
}
}
// if no exact match has been found, use prefix matching
if (!matchedKey && restoreKeys.length > 0) {
// stop at first restore key that yields a result
for (const restoreKey of restoreKeys) {
// check if any objects match the prefix
const response = await s3.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: `runner-cache/${restoreKey}`,
}),
);
if (response.Contents?.length > 0) {
const latestObj = response.Contents.filter((obj) => obj.LastModified) // check if LastModified is present
.sort((a, b) => b.LastModified.getTime() - a.LastModified.getTime())[0]; // sort in descending order
// remove "runner-cache/" prefix and ".tar.zst" suffix
matchedKey = latestObj.Key.replace(/^runner-cache\//, "").replace(/\.tar.zst$/, "");
break;
}
}
}
// if still no key was matched, exit
if (!matchedKey) {
core.setOutput("cache-hit", "false");
core.saveState("exactMatch", "false");
// check if we need to fail
if (failOnMiss) {
core.setFailed("No matching cache key found.");
} else {
core.info("No matching cache key found.");
}
return;
}
core.info(`Found cache key ${matchedKey}`);
// check if we need to actually restore the cache
if (!lookupOnly) {
core.info("Starting download");
const { Body } = await s3.send(
new GetObjectCommand({
Bucket: bucket,
Key: `runner-cache/${matchedKey}.tar.zst`,
}),
);
const tempArchivePath = path.join(process.env.RUNNER_TEMP, `${crypto.randomUUID()}`);
try {
await pipeline(Body, fs.createWriteStream(tempArchivePath));
core.info("Extracting archive");
await exec.exec("tar", ["-I", "zstd", "-P", "-xvf", tempArchivePath]);
} finally {
if (fs.existsSync(tempArchivePath)) {
fs.unlinkSync(tempArchivePath);
}
}
}
core.setOutput("cache-hit", exactCacheHit ? "true" : "false");
core.saveState("exactMatch", exactCacheHit ? "true" : "false");
};

65
src/save.js Normal file
View file

@ -0,0 +1,65 @@
import * as core from "@actions/core";
import * as glob from "@actions/glob";
import * as exec from "@actions/exec";
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { Upload } from "@aws-sdk/lib-storage";
import { getS3Client } from "./utils";
export const save = async () => {
// check if restore found an exact match using the provided cache key
if (core.getState("exactMatch") === "true") {
core.info("Exact match found, skipping cache upload");
return;
}
// inputs
const key = core.getInput("key", { required: true });
const bucket = core.getInput("s3-bucket", { required: true });
// temp files
const tempArchivePath = path.join(process.env.RUNNER_TEMP, `${crypto.randomUUID()}.tar.zst`);
const tempFileListPath = path.join(process.env.RUNNER_TEMP, `${crypto.randomUUID()}`);
// matched paths
const globber = await glob.create(core.getMultilineInput("path", { required: true }).join("\n"));
const resolvedPaths = await globber.glob();
if (resolvedPaths.length === 0) {
return core.info("No files found matching path.");
}
core.info(`Matched ${resolvedPaths.length} files for caching`);
try {
core.info("Creating compressed archive");
// write null-terminated list of files and compress
fs.writeFileSync(tempFileListPath, resolvedPaths.join("\0") + "\0", "utf8");
await exec.exec("tar", ["-I", "zstd -T0", "-cvf", tempArchivePath, "-P", "--null", "-T", tempFileListPath]);
core.info("Starting upload");
const upload = new Upload({
client: getS3Client(),
params: {
Bucket: bucket,
Key: `runner-cache/${key}.tar.zst`,
Body: fs.createReadStream(tempArchivePath),
},
});
await upload.done();
core.info("Upload complete");
} finally {
if (fs.existsSync(tempArchivePath)) {
fs.unlinkSync(tempArchivePath);
}
if (fs.existsSync(tempFileListPath)) {
fs.unlinkSync(tempFileListPath);
}
}
};

28
src/utils.js Normal file
View file

@ -0,0 +1,28 @@
import * as core from "@actions/core";
import * as io from "@actions/io";
import { S3Client } from "@aws-sdk/client-s3";
export const getS3Client = () => {
return new S3Client({
region: core.getInput("s3-region") || "us-east-1",
endpoint: core.getInput("s3-endpoint", { required: true }),
credentials: {
accessKeyId: core.getInput("s3-access-key", { required: true }),
secretAccessKey: core.getInput("s3-secret-key", { required: true }),
},
forcePathStyle: true,
// deactivates flexible checksums
requestChecksumCalculation: "WHEN_REQUIRED",
});
};
export const checkPrerequisites = async () => {
for (const tool of ["tar", "zstd"]) {
try {
await io.which(tool, true);
} catch (error) {
throw new Error(`Missing required system binary: '${tool}' is not installed on this runner.`);
}
}
};

232
tests/action.test.js Normal file
View file

@ -0,0 +1,232 @@
import { afterAll, beforeAll, beforeEach, afterEach, describe, vi, it, expect } from "vitest";
import { GenericContainer, Wait } from "testcontainers";
import * as core from "@actions/core";
import fs from "fs/promises";
import os from "os";
import path from "path";
import crypto from "crypto";
import { restore } from "../src/restore.js";
import { save } from "../src/save.js";
vi.mock("@actions/core");
const fileSha256 = async (filePath) => {
const buf = await fs.readFile(filePath);
return crypto.createHash("sha256").update(buf).digest("hex");
};
describe("S3 Cache Action Integration Tests", () => {
// defaults
const s3bucket = "integration-test";
const s3region = "us-east-1";
// env leak prevention
const originalEnv = process.env;
// endpoint generated by testcontainers
let s3mockContainer;
let s3endpoint;
// tmpdirs used by runner
let runnerWorkspace;
let runnerTemp;
// tmpdirs and files used by action
let testDataFolder;
let testDataFile;
// mocked inputs and states
let inputs;
let states;
beforeAll(async () => {
process.env = { ...originalEnv };
// gnutar fix for macos
if (os.platform() == "darwin") {
const gnuTarPath =
os.arch() === "arm64" ? "/opt/homebrew/opt/gnu-tar/libexec/gnubin" : "/usr/local/opt/gnu-tar/libexec/gnubin";
process.env.PATH = `${gnuTarPath}:${process.env.PATH}`;
}
if (process.env.S3_ENDPOINT) {
s3endpoint = process.env.S3_ENDPOINT;
} else {
// start testcontainers
s3mockContainer = await new GenericContainer("adobe/s3mock:5.0.0")
.withExposedPorts(9090)
.withEnvironment({
COM_ADOBE_TESTING_S3MOCK_STORE_INITIAL_BUCKETS: s3bucket,
COM_ADOBE_TESTING_S3MOCK_STORE_REGION: s3region,
})
.withWaitStrategy(Wait.forLogMessage(/.*Started S3MockApplication\.Companion in.*/))
.start();
s3endpoint = `http://${s3mockContainer.getHost()}:${s3mockContainer.getMappedPort(9090)}`;
}
// setup runner environment
const workspacePrefix = path.join(os.tmpdir(), "runner-workspace-");
const tempPrefix = path.join(os.tmpdir(), "runner-temp-");
runnerWorkspace = await fs.mkdtemp(workspacePrefix);
runnerTemp = await fs.mkdtemp(tempPrefix);
process.env.GITHUB_WORKSPACE = runnerWorkspace;
process.env.RUNNER_TEMP = runnerTemp;
}, 60000);
beforeEach(async () => {
vi.clearAllMocks();
// create new data folder for each test run
const testDataPrefix = path.join(os.tmpdir(), "runner-data-");
testDataFolder = await fs.mkdtemp(testDataPrefix);
// default mock inputs
inputs = {
key: `test-${crypto.randomUUID()}`,
path: testDataFolder,
"s3-bucket": s3bucket,
"s3-endpoint": s3endpoint,
"s3-access-key": "mock-access",
"s3-secret-key": "mock-secret",
"s3-region": s3region,
"lookup-only": "false",
"fail-on-cache-miss": "false",
"restore-keys": "",
};
// mock state
states = {};
// mock function calls
vi.mocked(core.getInput).mockImplementation((name) => inputs[name] || "");
vi.mocked(core.getMultilineInput).mockImplementation((name) => {
if (name === "path") {
return [inputs["path"]];
}
if (name === "restore-keys") {
return inputs["restore-keys"] ? inputs["restore-keys"].split("\n") : [];
}
return [];
});
vi.mocked(core.getState).mockImplementation((name) => states[name] || "");
vi.mocked(core.saveState).mockImplementation((name, value) => {
states[name] = value;
});
// generate test data file with random data
testDataFile = path.join(testDataFolder, `${crypto.randomUUID()}`);
await fs.writeFile(testDataFile, crypto.randomBytes(4 * 1024 * 1024));
});
afterEach(async () => {
await fs.rm(testDataFolder, { force: true, recursive: true });
});
it("should result in cache-miss when key is not found", async () => {
await restore();
expect(core.setOutput).toHaveBeenCalledWith("cache-hit", "false");
expect(core.saveState).toHaveBeenCalledWith("exactMatch", "false");
});
it("should save the cache successfully", async () => {
await save();
expect(core.info).toHaveBeenCalledWith("Upload complete");
});
it("should restore matched cache exactly", async () => {
const originalChecksum = await fileSha256(testDataFile);
await save();
await fs.rm(testDataFile);
await restore();
expect(core.setOutput).toHaveBeenCalledWith("cache-hit", "true");
expect(core.saveState).toHaveBeenCalledWith("exactMatch", "true");
const restoredChecksum = await fileSha256(testDataFile);
expect(restoredChecksum).toBe(originalChecksum);
});
it("should skip save if exact match exists", async () => {
states["exactMatch"] = "true";
await save();
expect(core.info).toHaveBeenCalledWith("Exact match found, skipping cache upload");
});
it("should match prefix using restore-keys", async () => {
inputs["key"] = "test-key-12345";
const originalChecksum = await fileSha256(testDataFile);
await save();
await fs.rm(testDataFile);
inputs["key"] = "completely-different-key";
inputs["restore-keys"] = "test-key-";
await restore();
expect(core.setOutput).toHaveBeenCalledWith("cache-hit", "false");
expect(core.saveState).toHaveBeenCalledWith("exactMatch", "false");
const restoredChecksum = await fileSha256(testDataFile);
expect(restoredChecksum).toBe(originalChecksum);
});
it("should respect lookup-only and not extract files", async () => {
await save();
await fs.rm(testDataFile);
inputs["lookup-only"] = "true";
await restore();
expect(core.setOutput).toHaveBeenCalledWith("cache-hit", "true");
expect(core.saveState).toHaveBeenCalledWith("exactMatch", "true");
await expect(fs.readFile(testDataFile)).rejects.toThrow(/ENOENT/);
});
it("should respect fail-on-cache-miss", async () => {
inputs["fail-on-cache-miss"] = "true";
await restore();
expect(core.setOutput).toHaveBeenCalledWith("cache-hit", "false");
expect(core.setFailed).toHaveBeenCalledWith("No matching cache key found.");
});
afterAll(async () => {
// reset env
process.env = originalEnv;
if (s3mockContainer) {
await s3mockContainer.stop();
}
if (runnerWorkspace) {
await fs.rm(runnerWorkspace, { recursive: true, force: true });
}
if (runnerTemp) {
await fs.rm(runnerTemp, { recursive: true, force: true });
}
if (testDataFolder) {
await fs.rm(testDataFolder, { recursive: true, force: true });
}
});
});