feat: initial
This commit is contained in:
commit
b04a255b4c
17 changed files with 6509 additions and 0 deletions
32
.forgejo/workflows/check-build.yaml
Normal file
32
.forgejo/workflows/check-build.yaml
Normal 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
|
||||||
23
.forgejo/workflows/check-pr-title.yaml
Normal file
23
.forgejo/workflows/check-pr-title.yaml
Normal 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 }}
|
||||||
64
.forgejo/workflows/test.yaml
Normal file
64
.forgejo/workflows/test.yaml
Normal 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
171
.gitignore
vendored
Normal 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
2
.husky/pre-commit
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
npm run build
|
||||||
|
git add dist/index.js
|
||||||
3
.prettierrc
Normal file
3
.prettierrc
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
||||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.tabSize": 2
|
||||||
|
}
|
||||||
148
README.md
Normal file
148
README.md
Normal 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
44
action.yaml
Normal 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
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
5409
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
package.json
Normal file
29
package.json
Normal 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
23
src/index.js
Normal 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
108
src/restore.js
Normal 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
65
src/save.js
Normal 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
28
src/utils.js
Normal 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
232
tests/action.test.js
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue